* fix(space): keep dedicated-environment Space login working
A dedicated environment runs the OSS runtime with system.edition=cloud. The
process-level 'LangBot Models' compatibility provider is part of the OSS
single-Workspace contract, but write_space_model_providers() skipped creating it
for every non-community edition. The Space OAuth callback then unconditionally
rewarded the provider row by api key, so provider_service raised
WorkspaceNotFoundError('Provider not found') and the whole callback returned 404
-- after the Account and its Workspace had already been created. Every retry
failed the same way, making dedicated-environment login impossible on 4.11.x.
Gate the compatibility provider on the deployment shape instead of the edition
(genuine multi-tenant Cloud runs in CLOUD_RUNTIME persistence and never reaches
this path), and treat a missing provider row as a model-routing warning rather
than an authentication failure so a data anomaly can never block login.
* fix(agent): bind the first model to the 4.11 runner config
create_llm_model() still wrote the auto-selected model into the pre-4.11
ai['local-agent'] container. On a 4.11 Workspace that key does not exist, so the
assignment raised KeyError and POST /provider/models/llm returned 500 the first
time a user added a model -- and the default pipeline kept primary='', so every
Agent run failed with 'No authorized model for local-agent'.
Delegate to RunnerDefaultConfigService, which resolves the selected runner and
writes the schema-defined model selector into ai.runner_config[<runner id>].
Also default the NOT NULL description column on pipeline and bot creation:
omitting an optional description returned a 500 from the constraint instead of
creating the resource.
* fix(agent): keep seeding the model for pipelines that never migrated
set_pipeline_llm_model_if_empty() returns False when a pipeline has no 4.11
runner selected -- that is exactly the un-migrated shape, which still carries
ai.local-agent. Seed that container, but only when it exists, so the legacy path
keeps its old behaviour without the KeyError.
The host inspects a package to read manifest.yaml and requirements.txt before the
SDK extracts it. Inspection normalized entry names with only
replace('\\','/').strip('/'), so it accepted names its own extractor rejects
(absolute paths, '..' components, drive letters) and it silently kept the first
member when two normalized to the same path.
There was no traversal write, because the SDK's _extract_verified_zip validates
every member before writing and the host only reads members by ZipInfo. The bug
is the divergence: a package could pass inspection and then fail to install, and
the two rule sets could drift further as either side changes.
Inspection now applies the same rule set as the SDK, via
_normalize_archive_entry_name: reject empty/NUL names, absolute paths, any '..'
component and drive letters; reject a duplicate after normalization. '.' and
empty segments are still collapsed, so './manifest.yaml' and 'components//x.py'
keep working.
Tests: 13 cases covering each rejection, the duplicate, traversal taking
precedence over the duplicate check, and two positive cases (nested/unicode
layout, cosmetic separators).
Co-authored-by: multica-agent <[email protected]>
* fix(platform): enforce credential-session TTL on a timer, not on traffic
The four adapter registration flows (Lark one-click app, DingTalk device flow,
WeCom bot, QQ Official bind) each park a short-lived secret in an in-memory
session dict until the caller persists it: app_secret, client_secret, or the bot
secret.
Each store had a TTL and a cleanup function, but the cleanup only ran at the top
of a route handler. An abandoned flow therefore kept its secret in memory until
some *other* request happened to arrive, which for a quiet instance can be far
past the validity window (15 min for Lark, 10 for DingTalk, 5 for WeCom/QQ).
Changes:
- Move the sweep to module level as _sweep_credential_sessions(stores) so it is
directly testable, and have it scrub app_secret/client_secret/secret as it
evicts, so an evicted session dict no longer retains a readable credential.
- Add a background sweeper task started from the router group's initialize(),
running every 60s, so the TTL holds regardless of traffic.
- Keep the per-route cleanups: they make eviction immediate under load.
Tests: 9 new cases (expiry, fresh sessions survive, secret scrubbing, per-store
TTL, cancelling a pending handshake task, not cancelling a finished one,
malformed session without created_at, multiple evictions, empty stores).
Co-authored-by: multica-agent <[email protected]>
* fix(platform): tolerate a task manager without create_task when starting the sweeper
The sweeper is started from the adapter router group's initialize(). Some
embeddings of the app provide a task manager that only exposes create_user_task,
so an unconditional create_task broke those harnesses. Fall back to the per-route
cleanups when create_task is unavailable.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
* fix(adapters): stop SSRF and arbitrary local file reads in media handling
The platform adapters fetch URLs and open paths that come from inbound
messages, so both are attacker-influenced.
New guard module pkg/utils/outbound_url.py:
- assert_safe_outbound_url: checks the scheme, rejects literal private /
loopback / link-local / reserved / multicast addresses, and resolves the
hostname so a public-looking name pointing at an internal address is refused
too. Optional allowed_domains confines a fetch to a known prefix.
- assert_safe_local_media_path: resolves a caller-supplied path and requires it
to stay inside the media roots (data dir + temp dir), so symlinks and .. cannot
escape. Directories are refused.
Wired in:
- DingTalk send_message: session_webhook is taken from the inbound event and was
POSTed verbatim, so a forged event could redirect reply content (including LLM
output) to any host. Now restricted to dingtalk.com.
- DingTalk send_attachment: component.url was fetched with no destination check;
now goes through the SSRF guard, which also covers cloud metadata.
- DingTalk upload_image_media: caller-supplied file_path was opened directly; now
confined to the media roots.
- Slack attachment_bytes: component.path was opened directly and uploaded to the
channel (arbitrary file exfiltration); now confined to the media roots.
- Slack download_media: redirects were followed with no destination check. Every
hop is now validated, the hop count is bounded, and a redirect off the Slack
domain drops the bearer token.
Tests: 25 new cases for the guard (internal address forms, scheme rejection,
suffix impostor, traversal, symlink escape, directory). The Slack credential test
now stubs DNS because the guard resolves before fetching, and asserts
allow_redirects is False for every case since redirects are handled manually.
Co-authored-by: multica-agent <[email protected]>
* fix(web): hide passkey UI unless the core reports it is supported
The core refuses every passkey route on a cloud deployment (403
edition_unsupported) and reports passkey_login_enabled/passkey_supported as
false. The WebUI still rendered the controls:
- Login page read 'passkey_login_enabled !== false || passkey_supported', so an
absent flag counted as enabled. It now requires an explicit true.
- The account settings panel always rendered the passkey section and called
GET /passkeys on open, which 403s on cloud. It now reads the capabilities
first, skips the list call when unsupported, and does not render the section.
Also drop the client-supplied origin from both passkey options requests. The
server resolves the WebAuthn RP ID and expected origin from api.webui_url /
api.webhook_prefix, so sending window.location.origin was dead weight that would
invite a future handler to trust it again.
Verified: tsc --noEmit clean, eslint clean (only two pre-existing hook-dep
warnings), pnpm build succeeds.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
The platform adapters fetch URLs and open paths that come from inbound
messages, so both are attacker-influenced.
New guard module pkg/utils/outbound_url.py:
- assert_safe_outbound_url: checks the scheme, rejects literal private /
loopback / link-local / reserved / multicast addresses, and resolves the
hostname so a public-looking name pointing at an internal address is refused
too. Optional allowed_domains confines a fetch to a known prefix.
- assert_safe_local_media_path: resolves a caller-supplied path and requires it
to stay inside the media roots (data dir + temp dir), so symlinks and .. cannot
escape. Directories are refused.
Wired in:
- DingTalk send_message: session_webhook is taken from the inbound event and was
POSTed verbatim, so a forged event could redirect reply content (including LLM
output) to any host. Now restricted to dingtalk.com.
- DingTalk send_attachment: component.url was fetched with no destination check;
now goes through the SSRF guard, which also covers cloud metadata.
- DingTalk upload_image_media: caller-supplied file_path was opened directly; now
confined to the media roots.
- Slack attachment_bytes: component.path was opened directly and uploaded to the
channel (arbitrary file exfiltration); now confined to the media roots.
- Slack download_media: redirects were followed with no destination check. Every
hop is now validated, the hop count is bounded, and a redirect off the Slack
domain drops the bearer token.
Tests: 25 new cases for the guard (internal address forms, scheme rejection,
suffix impostor, traversal, symlink escape, directory). The Slack credential test
now stubs DNS because the guard resolves before fetching, and asserts
allow_redirects is False for every case since redirects are handled manually.
Co-authored-by: multica-agent <[email protected]>
* LANG-145: bind WebAuthn to server config and make passkeys OSS-only
Two problems in the passkey implementation:
1. The RP ID and expected origin came from the request: a JSON 'origin' field,
then the Origin/Referer headers, then the request URL. WebAuthn's
anti-phishing guarantee depends on values the server chose; trusting the
request lets a sibling host under the same registrable domain obtain a valid
assertion for a victim's credential. Resolution now follows the same rule the
OAuth and invitation links already use: api.webui_url, then
api.webhook_prefix, then the configured port on loopback.
2. Passkeys are now an OSS-only feature. Cloud deployments authenticate through
their own account service, so every passkey route (register options/verify,
auth options/verify, list, rename, delete) refuses with
edition_unsupported, and /account-info advertises neither passkey_login_enabled
nor passkey_supported. Both cloud flavours are covered: system.edition ==
'cloud' and deployment.mode == 'cloud'.
Tests:
- New unit suite for the edition matrix and for origin resolution (including
that client-supplied origin/Origin/Referer are ignored).
- The three 'reverse proxy scenario' tests asserted the old, unsafe behaviour
(server trusting the request). They now assert the server-config contract.
- test_smoke cloud capabilities expectation updated to passkey_* = false.
Co-authored-by: multica-agent <[email protected]>
* style(api): drop the typing import left unused by the WebAuthn origin change
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
Boot continues for minutes after the first directory snapshot is committed:
platform ports reconnect, knowledge bases and pipelines reload, and a cold
shared Runtime restore may still be running. The refresh loop only started at
the end of that stage, so any boot slower than the projection staleness window
failed closed while restoring knowledge bases, and Core then exited during
startup - every retry hit the same window.
Start the single refresh task as soon as the first snapshot is committed and
keep the later start as a guarded fallback so the loop is never created twice.
Observed on the production Cloud instance: a boot that took ~120s to reach
knowledge base restore raised 'Cloud directory projection is stale' and the
process exited, while the same image had booted earlier with a faster start.
Co-authored-by: multica-agent <[email protected]>
Cloud deployments need a way to tell readers what is happening to the
instance they are using (maintenance windows, service notices) without
shipping a release per notice.
Add a cloud.banner config block (switch, tone, dismissal, link targets and
per-language copy) and expose the resolved banner on the unauthenticated
/api/v1/system/info payload so the WebUI can render it before login. The
banner resolves to null on OSS deployments, so a self-hosted copy of
config.yaml never shows managed-service copy. Copy is plain text; only
absolute http(s) link targets are rendered.
The WebUI renders it once from the root layout, offsets the fixed app shell
and the wizard through --cloud-banner-height, and keeps a dismissal bound to
the notice it dismissed so new copy reappears.
Co-authored-by: multica-agent <[email protected]>
- creditsLabel: 每月赠送积分 → 模型积分 (zh), Monthly credits → Model credits (en), 毎月のクレジット → モデルクレジット (ja)
- creditsHelp: append note that own model providers remain usable after credits run out, all locales
- WorkspaceSwitcher trigger: cap workspace name to 6rem, remove justify-start
- HomeSidebar: add ArrowUp upgrade icon button next to workspace switcher (cloud_projection only), opens CloudPlansDialog lazy-loaded
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Set no-referrer on message thumbnails and previews to prevent
QQ image requests from returning 400 and triggering ORB.
Add browser coverage for image loading and request headers.
Apply group response rules before busy-session steering. Initialize usable private-message and group-mention routes for new bots, including Wizard-created pipelines, and preserve member-configured rules across saves and retries.
Fix empty pipeline configuration, refresh the processor sidebar after automatic creation, and show creation feedback. Align routing regressions and use Node 24 for the browser CI job.
Correct the task's erroneous agent attribution while preserving every code tree and the original human contribution.
Co-authored-by: multica-agent <[email protected]>
- Add covering indexes through migration 0040
- Preserve PostgreSQL FORCE RLS state when marking historical summaries
- Add query-plan and migration regression tests to CI
Count and paginate lightweight records, then load payloads only for the
selected page to avoid materializing large historical messages.
Add regression coverage for oversized history, ingress deduplication,
stable pagination, and PostgreSQL tenant isolation.
- Maintain counts and display snapshots in source transactions with replay-safe deduplication.
- Read session lists exclusively from summaries and remove execution-only filters.
- Automatically initialize historical summaries in bounded, resumable batches during OSS startup.
- Add behavioral contracts covering accounting, retention, query cost and PostgreSQL concurrency.
Verified with the required session_summary suite and real container upgrade startup.
* fix(tools): require python3 for Box native file scripts
Unversioned `python` is still Python 2 on some host-rootfs nsjail
mounts. Falling back to it made read/write/edit scripts fail with
SyntaxError on f-strings (#2603).
Resolve only `python3`, and return a clear JSON error when it is
missing instead of invoking Python 2.
Fixes#2603
* style(tools): format Box interpreter error command
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Li-john1021 <[email protected]>
Co-authored-by: Chin Rock <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Set client_info_tag='langbot' on the Valkey Search vector store's glide
client, so CLIENT SETINFO LIB-NAME reports GlidePy(langbot) and operators
can attribute Valkey usage to LangBot. client_name is unchanged.
client_info_tag was added in valkey-glide 2.5.2, so raise the floor from
>=2.4.1 to >=2.5.2 (pyproject, install hint, docs) and regenerate uv.lock.