* chore(changelog): Sync the change log information of 2.2.0
* chore(changelog): update the change log information of 2.2.0
* chore(changelog): sync the change log information of 2.2.0
* Add entry for pull request #7585 in CHANGELOG_zh.md
* fix(tui): show picker labels as written instead of parsing markup
SelectScreen passed thread titles and model display names to Textual as
plain str, which Textual parses as content markup. A thread title such as
'Fix [/bold] in parser' raised MarkupError and exited the app whenever
/threads opened, and '[a]' vanished from the list. Wrap the title and
option labels in Rich Text so they render literally.
* docs(changelog): reference #7631 for the TUI picker markup fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): give DingTalk AI cards the channel token, not the SDK fetch
AICardReplier's async card calls read dingtalk_client.get_access_token()
inline, and dingtalk-stream refreshes it with requests.post and no
timeout. Card creation and card streaming both run on the Gateway loop,
so the first card and every SDK refresh after it (about every two hours)
blocked the loop for the whole token request, without bound if DingTalk
stalled.
Hand the replier a _CardTokenClient that serves the channel's own
httpx token (10s timeout), set before card creation and before every
streamed update. A token failure while streaming now raises into send's
existing sampleMarkdown fallback instead of being skipped silently.
Pin it with a strict blocking-I/O anchor over a real DingTalkStreamClient
and AICardReplier, a gate meta-check, and unit tests for token rotation
and failure. LocalHttpServer also records each request's method, path
and headers.
* docs(changelog): reference #7597 for the DingTalk AI card token fetch
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scripts): read the checkout .env as data in serve.sh
serve.sh sourced the root .env under set -a, so bash ran it as a script:
$$ became the shell's PID, backticks and $(...) ran commands, and an
unquoted value with a space ran its second word as a command (exit 127
under set -e, stopping make dev). The exports also beat the Gateway's
own load_dotenv(override=False), so e.g. a $$ in AUTH_JWT_SECRET changed
the signing secret on every launch.
Parse .env with a stdlib-only scripts/dotenv_exports.py on the host
Python and eval its shell-quoted export lines. It tokenizes like
python-dotenv, so values match what the Gateway reads, and keeps the
launcher's precedence over inherited variables and its $NAME /
${NAME:-x} / leading-~ expansion that support_bundle.py mirrors. $$ is
now one token in both. Unparseable lines and invalid names are skipped
with warnings that never echo values; a non-UTF-8 file stops the
launcher.
Tests run serve.sh's real .env block: no execution, parity with
python-dotenv, precedence, warnings, the non-UTF-8 stop and a stdlib-only
run; the support-bundle launcher cases now exercise the parser.
* docs(changelog): reference #7606 for serve.sh reading .env as data
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): resolve reply artifacts off the event loop
_handle_chat_on_thread and _handle_streaming_chat called
_prepare_artifact_delivery inline. For every presented file it resolves
the virtual path (realpath), resolves the outputs root, and checks the
file with is_file and stat, all on the Gateway loop that also serves
every other channel and request.
Run it through asyncio.to_thread on both reply paths, and anchor both
through the real manager under the strict blocking-I/O gate.
* docs(changelog): reference #7594 for the off-loop reply artifact resolution
* docs(changelog): drop the duplicate #7580 link definition in the zh changelog
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): write each Feishu thread alias once per streamed reply
Every outbound to a Feishu running card re-sent set_thread_id for all of
the reply's aliases (source message, card, topic, root, parent and Feishu
thread ids), although only updated_at changed after the first chunk.
That was about five store writes per streamed chunk: a full store.json
rewrite under the store lock each, or a database upsert.
Remember the aliases written for each in-flight reply and skip them on
non-final chunks. A failed write is not remembered, so the next chunk
retries it, and the final outbound still writes every alias, so the
store ends up as before.
* docs(changelog): reference #7593 for the Feishu streamed thread alias writes
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(wizard): replace retired Claude Sonnet 4 default with claude-sonnet-5-5
The setup wizard's Anthropic default model is now `claude-sonnet-5-5` instead of the retired `claude-sonnet-4-20250514`. A new per-model `model_omitted_config` field makes the wizard leave out the extended-thinking (`budget_tokens`) and thinking-disabled templates for that model only, since Sonnet 5.5 rejects them; the other Claude models are unchanged. The Anthropic examples in `config.example.yaml` and `configuration.mdx` (which used `claude-3-5-sonnet-20241022`) now use `claude-sonnet-5-5` without those settings, and the change also adds a regression test and a CHANGELOG entry.
Notice anthropic-retired-models-2026-10-27 about api.anthropic.com's API describes the change. It was read from a file and is not signed by the provider.
* Update CHANGELOG with PR reference #7585
Added a reference to pull request #7585 in the CHANGELOG.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(runtime): present outputs before a loop wrap-up and explain step-limit stops
Loop-detection warnings now ask the agent to present the files it already
created in /mnt/user-data/outputs before its final answer, when
present_files is available.
A run that exhausts recursion_limit now ends with stop_reason
recursion_capped and a plain message that lists outputs it created but
did not present. An active goal stands down without an evaluator call.
The /wait error envelope keeps the GraphRecursionError name, and IM
channels reply with the step-limit message instead of a generic failure.
* docs(changelog): note run-end delivery and step-limit stops (#7588)
* test(runtime): stop pinning LangGraph's step count in the step-limit test
The produced paths and the message already follow the observed model
calls, so the exact count only pinned LangGraph's per-step accounting.
Also note on the step-limit goal branch that its caller passes no
evaluator factory, so it has to stay ahead of the evaluator section.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): keep IM binding codes on screen and report goal state in replies
Connecting Slack, Discord, Feishu, DingTalk, WeCom, Buzz or QQ now opens a
dialog that keeps the /connect command on screen with a copy button, its
expiry time and the binding status, instead of a toast that disappears.
The bots' /connect replies and the IM /goal replies now use the owner's
web UI language (else channel_connections.notification_locale), and /goal
reports the goal's state as the web goal bar does: in progress, why it
stopped, or that it was met.
* docs(changelog): note the IM binding dialog and localized IM replies (#7591)
* test(channels): check IM reply tables against every notification locale
The connect-reply check compared a fixed en-US/zh-CN pair, and the /goal
reply table had none. Both now match NOTIFICATION_LOCALES with the same
keys in each, like the existing _GOAL_REASON_TEXT check, so a new locale
fails here instead of raising KeyError at reply time.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
* fix(community): resolve async tool config off the event loop
Agent runs execute on the Gateway event loop, and the async community
tools resolved their tool config through get_app_config() inline. That
call stats and hashes config.yaml and extensions_config.json on every
call and reparses them after an edit, so each tool call held the loop
for it, up to three times per call: Tavily, SearXNG and Firecrawl read
the config again to build their client, InfoQuest read it three times,
and the browser tools read the launch config for every action.
Resolve it through asyncio.to_thread once per call, as image_search and
groundroute already do, and build the client from that same read. This
covers jina_ai, crawl4ai, browserless, tavily, searxng, firecrawl,
infoquest, ragflow, lightrag and browser_automation.
* docs(changelog): reference #7580 for the off-loop community tool config reads
* docs(changelog): use the existing community-tools label in the zh entry for #7580
* fix(community): resolve the jsonify dataset_rows config off the event loop
dataset_rows_tool is async and called _load_settings() inline, so every
call ran get_app_config()'s stat + sha256 revalidation on the loop.
Read it through asyncio.to_thread like the other async community tools,
and add the tool to the off-loop anchors.
* docs(changelog): include Jsonify in #7580 and drop trailing whitespace
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): read the live config off the event loop on public auth routes
/register, /setup-status, /providers, /oauth/{provider} and
/callback/{provider} read the live auth config through get_app_config()
inline. That call stats and hashes config.yaml and extensions_config.json
on every request and reparses them after an edit, and these routes are
unauthenticated, so any client could keep that file IO on the event
loop. /login/local already reads its throttle policy in a worker.
Read the registration gate through asyncio.to_thread, and the SSO
routes' auth.oidc section through a shared _oidc_config() helper that
does the same.
* docs(changelog): reference #7576 for the off-loop auth route config reads
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): leave SIGTERM/SIGINT teardown to the Gateway lifespan
AIO and E2B providers built on the event-loop thread installed SIGTERM/SIGINT
handlers that force-destroyed every sandbox synchronously, before uvicorn
started the run drain. The Gateway lifespan now holds sandbox shutdown
ownership from its first step, so providers skip those handlers and the
existing hook closes the provider after run drain and MCP teardown. SIGHUP
keeps the provider fallback. AIO acquire now refuses once shutdown began.
* docs(sandbox): document Gateway-owned sandbox shutdown and AIO admission fence
* test(gateway): inspect the lifespan body now that lifespan wraps it
* docs(changelog): reference #7560 for the sandbox SIGTERM drain-order fix
* ci: retrigger sandbox image smoke after a registry pull timeout
* fix(frontend): return SSO sign-ins to their next path
The (auth) layout redirected every signed-in request to /workspace. The
Gateway sets the session cookie on the same redirect that lands on
/auth/callback?next=..., so the callback page never rendered and every SSO
sign-in landed on the workspace instead of the page that asked for it. A
signed-in visit to /login?next=... lost its target the same way.
Layouts cannot read searchParams, so the layout now renders the page for a
signed-in session and each page sends the user on: the callback and login to
the validated next path, setup to the workspace. The login page shows a
short wait message instead of the form while it redirects, and uses
router.replace so Back does not return to it.
* docs(changelog): reference #7467 for the SSO next-path fix
* fix(auth): reject control characters in the next redirect path
Browsers delete tab, LF and CR from a URL before parsing it, so a next of
"/\n//evil.example" passed both validators' "//" check and then navigated
to //evil.example. The login page's post-sign-in push already followed it,
and the signed-in login and SSO callback redirects reach it too.
validateAuthNextPath and validate_next_param now reject every C0 control
character and DEL. Percent-encoded forms are left alone: URL parsers do
not decode them, so they stay on the local origin.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read the route authorization config off the event loop
AuthMiddleware resolves route permissions for every authenticated
request, and resolve_route_permissions read the hot-reloaded
authorization config inline. get_app_config() stats and hashes
config.yaml and extensions_config.json on every call and reparses them
after an edit, so that file IO ran on the event loop per request.
Read it through asyncio.to_thread, like the plugin authorization path
already does. The context copy keeps runtime config overrides visible.
* docs(changelog): reference #7557 for the off-loop route authorization config read
* docs(config): note the get_app_config read cost where it is documented
Move the off-loop note from gateway/AGENTS.md, which is 12 bytes under
its hard limit, to the Config Caching section that documents what each
get_app_config() read costs.
* fix(memory): flush pre-compaction memory off the event loop
The async compaction path fired the before-summarization hooks inline.
memory_flush_hook hands the summarized messages to add_nowait, which is
sync HTTP for mem0 and Honcho (inherited default -> add) and OpenViking
(its own override), so every automatic compaction with an HTTP memory
backend blocked the Gateway event loop for the whole request.
acompact_state now runs the hooks through asyncio.to_thread, as
MemoryMiddleware.aafter_agent already does for its writes. Ordering,
failure isolation, summary-first timing and context propagation are
unchanged; the sync compact_state path is untouched.
* docs(changelog): reference #7530 for the pre-compaction memory flush fix
* test(memory): anchor the OpenViking pre-compaction flush off the event loop
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): honor the configured recursion limit in IM runs
Every IM channel run asked the Gateway for recursion_limit 100, because
the channel manager seeded each run config with it. The Gateway honors a
request value, so the top-level recursion_limit never reached IM runs,
and a budget of 100 could stop a run after a handful of tool-calling
turns (six in one measurement with the default lead-agent middleware;
the count changes with the middleware chain).
IM runs now send no recursion_limit of their own and get the Gateway's
configured default, clamped by max_recursion_limit, like Gateway API
runs. A session config value still takes precedence, and GitHub channel
runs keep their floor of 250. The README and config.example.yaml IM
examples no longer pin session.config.recursion_limit to 100.
* docs(changelog): reference #7521 for the IM recursion-limit fix
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): derive the AIO sandbox ID off the event loop in async acquire
_acquire_internal_async offloads every acquire layer except the sandbox ID
derivation, which ran inline. _sandbox_id_for_thread checks the thread's
skills_view directory with exists(), and resolving it goes through
Paths.base_dir, so each async thread acquire made filesystem syscalls on the
event loop. The strict blocking-I/O gate raises on it.
Run the derivation through the same owned-worker helper as the reuse and
warm-pool layers around it, and anchor it in the blocking-I/O suite for both
the default and the policy-scoped ID.
* docs(changelog): reference #7507 for the AIO async acquire sandbox ID fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(threads): reject an assistant_id that names no agent at thread creation
POST /api/threads stored assistant_id unchecked. Every state read then built
a run config from it, and build_run_config's ValueError answered 500 on GET
thread, state and history until the thread was deleted.
Validate it at creation with the rule run creation already applies, now
shared as custom_agent_name(), and answer 422. Records written before this
change are read with the lead agent's schema instead of failing.
* docs(changelog): reference #7445 for the thread-creation assistant_id fix
* fix(threads): clear invalid legacy assistant ids in branches
* docs(changelog): note the branch assistant_id behavior for #7445
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): stop re-validating account emails the backend accepted
userSchema checked email with zod's email(), which rejects many addresses
pydantic EmailStr registers (a&b@example.com, a@b.c, Unicode). /auth/me for
such an account parsed as malformed: SSR answered gateway_unavailable, the
offline banner never recovered, and the admin user list failed for every
administrator. The backend owns address validation; keep a non-empty string.
* docs(changelog): reference #7449 for the account email schema fix
* fix(persistence): auto-create the Postgres database only when it is missing
init_engine retried bootstrap through CREATE DATABASE whenever the error
text contained "does not exist". Postgres words missing relations,
columns, functions and roles the same way, so a bootstrap that failed
inside an existing database (for example a migration reading a dropped
column) went on to CREATE DATABASE. Startup then failed with
DuplicateDatabaseError, or with "permission denied to create database"
for a role without CREATEDB, and the original error appeared nowhere in
the traceback.
Recognize a missing database by SQLSTATE 3D000 (invalid_catalog_name)
instead, following __cause__ for a wrapped copy, and re-raise every
other bootstrap error unchanged. A live PostgreSQL test covers both the
missing-database path and an existing database whose migration fails.
* docs(changelog): reference #7410 for the Postgres auto-create fix
* fix(persistence): walk orig, __cause__ and __context__ for the missing-database SQLSTATE
Match _is_unique_violation's chain walk, with its cycle guard, so a wrapper
that keeps the driver error only on `orig` or `__context__` still triggers
auto-create. Drop the live test's English message assertion; the SQLSTATE
check already proves the original error surfaced.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mcp): let ordinary users save bundled personal connections
The personal MCP policy read `server.cwd`, which McpServerConfigResponse
does not declare. A bundled launcher definition (DingTalk, WeCom,
HubSpot) never carries `cwd`, so for a non-administrator the check
raised AttributeError and every create, edit or enable of one returned
500. The web UI installs catalog connections with scope "user", so an
ordinary user could not install any of the three.
Read a supplied `cwd` from the model's extras instead. A bundled
launcher with a working directory still requires an administrator.
* docs(changelog): reference #7400 for the bundled personal MCP fix
* docs(changelog): say #7400 makes bundled personal installs work, not work again
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(examples): keep the extension example's tests passing as the contract grows
The README's package-test commands failed one test on main:
isinstance(registry, ExtensionRegistry) is False because the test's
FakeRegistry does not implement knowledge_provider(), which
extension-api 0.2.7 added to the runtime-checkable protocol. The same
assertion broke earlier when plugin() was added. Nothing in CI runs
these tests, so each addition to the contract broke them silently.
FakeRegistry now records knowledge_provider() and plugin() calls, and
the test asserts the example registers neither, as its README says.
Extension Package Check, which already runs when the contract changes,
now installs each bundled example that has a tests/ directory next to
the checkout's extension-api and runs the README's pytest and ruff
commands. A backend test pins that step and checks that every tested
example declares those tools in its dev extra.
The example README and the extensions guide now count nine
contribution kinds, including knowledge providers.
* docs(changelog): reference #7364 for the extension example test fix
* test(extensions): name the missing package-test step in the wiring pin
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): answer malformed run bodies with 4xx instead of 500
RunCreateRequest types config, context and input as free-form dicts, so
some bodies it accepts still failed during run admission with an
exception nothing mapped, and the run routes answered 500:
- a non-object config.configurable (build_run_config's dict.update, and
.get in the stateless thread selector)
- a non-object config.context (build_run_config's own ValueError)
- an assistant_id that does not normalize to letters, digits and hyphens
- a non-string agent_name in config.configurable, config.context or
context (_load_scope_agent_config)
- an AI message whose tool_calls is a string, object or list of strings
(AttributeError from LangChain's validator)
The request model now requires config.configurable and config.context to
be objects or null (422 invalid_run_config_section), and
build_run_config checks configurable as it already checked context.
start_run maps build_run_config's ValueError to 422, as it does for
validate_thread_id. Message conversion also maps AttributeError to its
existing 400, and a non-string agent_name gets the existing 422 for an
unresolvable agent. All of these fail before admission, so no run is
created.
* docs(changelog): reference #7362 for the malformed run body fix
* docs(changelog): note the configurable shapes #7362 now rejects
* fix(tui): let quit skip an interrupted run's unfinished step
After Ctrl+C the synchronous agent stream keeps running until its next
event. The run was a Textual thread worker, i.e. a thread on asyncio's
default executor, which App.run() joins on shutdown (up to 300 s on
3.12) and interpreter exit joins again with no timeout. Quitting while
a long step was still running therefore hung silently after the
terminal had been restored, and further Ctrl+C presses only printed a
traceback.
Run workers on a daemon thread instead. Quit now waits visibly for
running workers (an interrupted step still checkpoints, a finished run
still saves its title) and refuses sends meanwhile; a second quit
request exits at once. Report unexpected worker exceptions as an error
row and end the run, since Textual no longer catches them.
* docs(changelog): reference #7326 for the TUI quit-after-interrupt fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(tui): append streamed assistant deltas verbatim
The reducer merged each delta by comparing it with the row text: an
equal delta was a no-op, a delta starting with the row replaced it, and
a delta the row started with was dropped. Those rules absorbed re-sends,
replayed history and cumulative snapshots, none of which
DeerFlowClient.stream emits any more, so they only deleted real tokens
("ha" + "ha" -> "ha", "1" + "10" -> "10") and every standalone Markdown
marker an answer opened with (closing **, list bullets, table pipes,
closing code fences).
Append every delta on both the id-keyed and anonymous paths, and drop
_merge_stream_text. Rewrite the tests that pinned the re-send premise
as append contracts, run the merge cases through reduce() on both
paths, and drive the real client stream into the reducer.
* docs(changelog): note TUI streamed-delta fix (#7325)
* docs(changelog): drop trailing spaces after the #7325 entries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): release the E2B ownership lease when acquire finds the VM gone
The in-process reuse path (dead or unreachable cached client) and the
warm-pool reclaim path (reconnect reports "sandbox not found") dropped
their local bookkeeping but kept the VM's ownership lease. Nothing later
sees that ID: the warm sweep only walks the warm pool, reconciliation
only walks the remote inventory, and the lease renewer keeps renewing
(and re-claims a lapsed lease) until shutdown. Every reaped VM left one
entry in _owned_sandbox_ids and one renewed ownership key behind.
Call _release_ownership in both branches. Both act only on E2B's trusted
not-found signal, the store's release is a no-op for a peer's lease, and
a backend error still drops the local ID so the shared lease expires on
its TTL.
* docs(changelog): reference #7277 for the E2B dead-VM lease fix
* test(sandbox): pin that transient E2B errors keep the ownership lease
The dead-VM release is only safe because the gone signatures match
narrowly. Pin the other side: a non-gone ping error keeps reusing the
cached VM, and a non-gone warm reconnect error leaves the lease to
eviction.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): map malformed OIDC provider bodies to OIDCError
The SSO login route answers OIDCError with 502 and the callback with
an sso_failed redirect, but oidc.py raised OIDCError only for transport
and HTTP status errors. A provider answering 200 with an HTML page or
any non-object JSON raised JSONDecodeError, KeyError or AttributeError
instead, so both routes failed with a 500. A malformed UserInfo body
also aborted the login, although UserInfo is best-effort.
Decode discovery, token, JWKS and UserInfo bodies through one helper
that requires a JSON object, require string endpoints and issuer, reject
a non-list JWKS keys field, skip non-object JWK entries, and wrap a
malformed ID token header as OIDCValidationError. Build discovery
metadata before caching it so a rejected document is not served from
the cache.
* docs(changelog): reference #7258 for the OIDC malformed body fix
* fix(auth): report a falsy non-string OIDC issuer as wrong-typed
Check the issuer's type before its presence so 0, false and [] get the
non-string message instead of "missing the issuer field"; an absent or
empty issuer still reports missing.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(uploads): keep OSError text out of upload, delete and attach 500s
Thread upload, upload delete and shelf attach-to-thread put str(exc) in
their 500 detail. A staging or unlink OSError names its file, so a
permission error, or a disk too full for mkstemp, returned the absolute
host path of the thread's uploads directory, user and thread IDs included.
Return a fixed detail that still names the caller's file. The existing
error log lines keep the full message for operators.
* docs(changelog): reference #7217 for the upload 500 detail fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agents): keep host paths out of agent and user-profile error details
The custom-agent and user-profile routes put str(e) in their 500
detail. Agent config parse errors and filesystem OSErrors name the
absolute host path under users/<user_id>/, so a caller saw it in the
response. Return the fixed "Failed to <action>" detail the import and
export routes already use and keep the full error in the existing log
lines. PUT also handles a parse error from its initial config load.
The scheduled-task assistant resolver echoed the same parse error as
its invalid_assistant 422 message, which reached the UI and the
scheduler tool result. It now logs the error and returns a fixed
message under the same code.
* docs(changelog): reference #7255 for the agents error detail fix
* fix(skills): create a missing extensions_config.json in the project root
When no extensions config resolved, the skill toggle created one at
Path.cwd().parent while the MCP writers used project_root(). Unless the
Gateway runs from backend/ with the project root at its parent, the
toggle's file landed outside the resolver's search path: the toggle was
lost on reload and the next MCP edit created a second file.
Add ExtensionsConfig.default_config_path() (the project root, the first
location resolve_config_path searches) and use it from the skill toggle
and both MCP create paths, so a created file is always the one the next
resolution reads back.
* docs(changelog): reference #7048 for the extensions config create path
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances
MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.
- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
per refresh_token connection keyed (scope, owner_user_id, server_key):
('deployment', '', name) or ('personal', owner, personal runtime
name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
ciphertext; a config fingerprint (token_url, client_id, grant, scope,
audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
peer's valid token, and otherwise claims the refresh with a lease plus
a version compare-and-set (database clock), fetches outside any
transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
LangGraph runtime for sqlite/postgres; personal managers pass their
owner. client_credentials stays in process; an unloadable key falls
back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
declared multi-instance deployment needs the shared key.
Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).
* docs(changelog): link the MCP OAuth token store entry to #7207
* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant
Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
save removes (deployment: _commit_mcp_config_write; personal:
_mutate, keeping each connection's enabled/disabled runtime-name
variants so a toggle keeps its chain). Best effort; no startup sweep,
since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
McpOAuthReauthorizationRequired: the tool error names the server and
asks for a new refresh token, plus the shared-key hint when this
instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.
* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters
Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
holder now renews its lease every third of REFRESH_LEASE_SECONDS while
the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
waiters outlast a live holder. Regression uses a real loopback endpoint
that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
(unsaved_base_version): they are written back before an older stored
token is adopted or refreshed with, retried every few seconds while the
access token is still valid, and dropped once a peer stored a newer
chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
a stored access token is reused only under the same request parameters;
otherwise the stored refresh-token chain is refreshed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* chore(doc):sync the chinese changelog info
* chore(doc):sync the changelog with 2.2.0 milestone
* chroe(doc):sync the Chinese changelog with the english one
* fix(memory): persist the DeerMem extraction watermark across restarts and replicas
The updater's per-thread extraction watermark lived only in an in-process
LRU, so a restart or a second Gateway replica on the same storage root fed
already-extracted turns to the memory LLM again.
FileMemoryStorage now keeps it in users/{user}/agents/{agent}/.metadata/
watermarks.json, keyed by thread id, written atomically under the per-user
lock without touching memory.json and capped by watermark_max_keys. Each
update feeds after the furthest of the LRU entry and the persisted one, and
writes through after an extraction or a pre-screen skip; bypass_watermark is
unchanged and sidecar IO failures fall back to the LRU. Memory clears remove
the file with the other .metadata sidecars. Content-based message identities
are stored as SHA-256 digests so the sidecar holds no conversation text.
* docs(changelog): link the memory watermark entry to #7204 and #7205
* fix(memory): never replace an unreadable watermark sidecar
set_extraction_watermark rewrites the whole watermarks.json, and the shared
sidecar reader turns an OSError into an empty mapping, so a failed read could
replace every other thread's watermark with the current one (or delete the
file). The watermark sidecar now has its own reader: a missing file reads as
empty, malformed content reads as empty (nothing to keep; the next write
repairs it), and any other OSError propagates before anything is written. The
updater already logs that and keeps the extraction successful.
* fix(memory): keep the persisted watermark when the last message has no identity
_message_identity returns None for a last message with neither an id nor
text. The updater wrote that None through, which deleted the thread's entry
from watermarks.json, losing a watermark a peer may have advanced and forcing
a full feed on every replica. The write-through now skips None: the persisted
entry still marks an extracted prefix and the furthest-of-both read keeps
using it.
* fix(skills): keep load error text out of user-facing skill read 500s
GET /api/skills, /api/skills/custom and /api/skills/{name} are open to
every signed-in user, but their 500 detail echoed str(e). A malformed or
unreadable extensions_config.json (re-read on every load by the
user-scoped storage) made all three return the file's absolute host path,
plus the [Errno 13] text for a permission error.
Return a fixed detail like the diagnostics and reload routes already do,
chain the cause, and keep the full error in the Gateway log.
* docs(changelog): reference #7155 for the skill read 500 detail fix
* test(skills): pin capability skill discovery 500 through the real Gateway app
GET /api/capabilities/installations/skills reads the same user-scoped
storage as the skill routes but has no handler of its own. Its 500 body
is TraceMiddleware's fixed fallback, so drive create_app() with a
malformed extensions_config.json and assert no host path reaches the
body on any of the four reads.
* fix(skills): answer invalid custom-skill history names with 400
GET /api/skills/custom/{skill_name}/history let the ValueError from
validate_skill_name fall through to its catch-all, so a malformed name
returned 500 and logged a traceback on every request, while delete and
rollback answer 400.
Validate the name up front instead of adding a handler-wide
except ValueError: a corrupt history line raises JSONDecodeError, also a
ValueError, and must stay a server error. Both outcomes are pinned.
* fix(skills): keep corrupt history a 500 on rollback, validate delete names early
Sibling sweep of the history fix. Rollback's handler-wide except ValueError
also caught json.JSONDecodeError, so a corrupt history record answered 400
with the parser message and no log; it is now a logged 500 like history.
Delete only failed an invalid name inside the drained mutation tail, after
taking the owner's mutation lock, and logged it as a tail error although
_drain_skill_mutation keeps caller 4xx unlogged. It now rejects the name
before any storage work. Each guard is pinned by a mutation-checked test.
* docs(changelog): reference #7123 for the skill history status fix
* fix(skills): treat non-UTF-8 skill history as a server fault on rollback
UnicodeDecodeError is a ValueError too, so a non-UTF-8 history file still
reached rollback's ValueError -> 400 mapping while GET .../history answered
the same file with 500. Catch it alongside JSONDecodeError and pin both
corruption shapes on both routes.
Also drop the whitespace-only separator lines the changelog merge added
between other entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): run each IM message once across instances, restarts and late redeliveries
Inbound IM messages were deduplicated only by the 10-minute inbound dedupe
store (#7103). Discord direct messages had no key at all, a Discord thread
reply got an instance-dependent key, a Discord thread was created before
dedupe ran, a redelivery past the window started a second run, and two
instances racing on a conversation's first messages bound it to two
threads.
- Discord messages are keyed (discord, guild_id or "dm", posting channel,
message_id); the posting channel is the same on every instance.
- MessageBus.claim_inbound lets an adapter record the manager's dedupe key
before a side effect; Discord claims it on the Gateway loop before
_create_thread, marks the published message dedupe_claimed, and gives the
claim back when the message is never committed.
- runs.wait / runs.stream / runs.create send an Idempotency-Key derived from
the dedupe key, and keyed human message ids are uuid5 of it (a uuid4 per
attempt made every keyed retry a 409). A reused run's status-only /wait or
stream gap is resolved to this message's own turn via threads.get_state;
409s about the message's own keyed run keep the key instead of reading as
a busy thread; an ended reused GitHub run gets no follow-up watcher.
- A conversation's first thread binding is claimed insert-if-absent
(claim_thread_id on channel_conversations and channel_thread_bindings);
the loser adopts the winner and deletes the empty thread it minted.
/new and /agent use still rebind.
Docs: IM_CHANNEL_CONNECTIONS.md, CONFIGURATION.md, channels AGENTS.md,
CHANGELOG (en/zh).
* fix(channels): give back Discord dedupe claims for messages never processed
Review round 1 on #7104. A Discord claim records the manager's dedupe key
before the message is queued, so every path that drops the message unrun
must release it, or a peer refuses the redelivery for the rest of the TTL:
- The hand-off task releases the claim on cancellation too (stop() drains it
while it resolves the connection identity), via await_drained so the
cancellation still propagates.
- ChannelManager._discard_pending_inbound releases the key of a claimed
message that shutdown drops from the queue, after the grace period and on
a cancelled shutdown.
- The adapter tracks claims until they are committed. A release refused
because stop() already closed cross-loop intake, or a hand-off cancelled
before it ran, leaves the claim tracked, and stop() releases it last.
* fix(gateway): keep IM run idempotency stable when /new rebinds the conversation
Review round 2 on #7104. The Gateway scoped every Idempotency-Key by owner
and thread. /new rebinds an IM conversation to a fresh thread, so a late
redelivery of a message admitted on the old thread was requested on the new
one, got a different scoped key and started a second run.
- Keys from trusted internal callers (the IM channel manager) are scoped by
owner only (`internal-run:` prefix); browser and API keys keep the
owner-and-thread scope (`http-run:`).
- A key that resolves to a run of another thread or user raises
RunIdempotencyKeyMismatch, a RuntimeError subclass so in-process launchers
see the same type, and start_run answers it with 409 "Idempotency-Key
already used with a different request" instead of a 500. The channel
manager already treats that 409 as the message's admitted run.
Docs: API.md, IM_CHANNEL_CONNECTIONS.md, gateway AGENTS.md (reworded to stay
under its budget), CHANGELOG (en/zh).
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create
Two leftovers from the multi-instance work (#7097).
The agent's skill_manage tool refreshed only its own process's skills
prompt cache after a create, edit, patch, delete, write_file or
remove_file, so a Gateway replica sharing the home volume kept rendering
that user's previous skill list. Every mutation now runs one drained tail,
as the skill routes do: the write and its history record, the local
refresh, then publish_skills_cache_reset(user_id=...) off the event loop.
A run cancelled while the write is in flight still refreshes and
publishes before the cancellation propagates. A marker that cannot be
written is logged inside the tail and raised as SkillCacheResetPublishError
with a message saying the change was applied, so the model does not retry
a write that succeeded.
Two provisioner replicas starting together against a missing
K8S_NAMESPACE both read 404 and both create; the loser's 409 escaped the
lifespan and crashed it. A 409 on create now counts as the namespace
existing, as the Pod and Service creates already do.
Also drops a stale duplicate of the multi-instance "declaration also
drives" note in CONFIGURATION.md.
* docs(changelog): reference #7099 for the multi-instance leftovers
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(projects): check the project before staging shelf uploads
The shelf upload and save-from-thread routes staged the body under
users/{user_id}/projects/{project_id}/ before checking the project. A
missing, foreign, or archived id left an empty {project_id}/documents/
directory that the retention sweep keeps forever, and an id longer than
one path component failed with ENAMETOOLONG (500).
Both routes now require an owned, active project first and answer the
same 404 without writing. The shelf insert still re-checks under the
project row lock.
* test(projects): pin the shelf insert's re-check behind the early project check
With the early check in place, no route test reached the insert's
row-lock re-check any more. Archive the project between the check and
the insert and assert both routes still answer 404 and leave no staging
file.
* docs(changelog): reference #7040 for the shelf upload project check
* docs(changelog): drop the whitespace-only line after the zh #7040 entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): exempt awk field arithmetic from the shell-variable path guard
Since #6930 every `$NAME/` in a host-bash command was treated as a
variable-prefixed host path unless both sides were digits, so legitimate
awk programs such as `awk '{print $NF/2}' data.txt` or
`awk '{print $sum/$n}' data.txt` on files under /mnt/user-data were
rejected with "Unsafe absolute paths".
Exempt a `$` only inside the single-quoted inline program of awk, gawk,
mawk or nawk (after `-F`, `-v` and `--`), where bash expands nothing and
awk reads `$NAME` as a field reference. awk string literals, option
values, input operands, double-quoted programs, text re-read by `sh -c`
or `eval`, heredocs, awk functions/aliases, unknown options such as
gawk's `--assign`, `-f` programs, and programs whose `"` may sit in a
regex or comment keep the strict scan.
Fixesbytedance/deer-flow#7021
* fix(sandbox): read awk string literals and comments before judging quotes
Review follow-up: the quote-reliability check gave up the awk exemption
for any `/` not preceded by an identifier, number or `]`, including a `/`
inside a plain string literal, so `awk '{n=split($0,p,"/"); print
$1/p[n]}'` was still rejected. Walk the program once with the same
string-literal tracker that computes the code spans: `/` and `#` inside
a literal are text, `#` outside one starts a comment to end of line, and
only a `/` outside a literal that cannot be division (regex literal)
forfeits the exemption.
* fix(sandbox): scan the awk exemption the way the shell parses the command
Review follow-up for the awk field-arithmetic exemption.
Bypasses closed: a backslash-newline let `aw\<nl>k() { eval "$1"; }`
define an awk function past the literal blocker regex, so any
continuation now forfeits the exemption; the word scanner emitted `)`,
`$(` and `>|` as command separators, so an `awk` that was only an
argument (`echo $(true) awk '...'`) looked like a command; `$'...'`,
comments and quotes nested in `"$(...)"` could desynchronise quote
pairing; the alias/function blocker only matched literal spellings and
ignored eval, source, hash, PATH, autoload and zsh's
`functions[awk]=` / `aliases[awk]=` arrays.
The scanner now follows bash: single and double quotes with escapes,
`#` comments at a word start, fd redirections, the usual separators,
and `$(...)` as a nested command context with fresh quoting. Anything
it does not model (backslash-newline, backticks, `$'...'`, heredocs, an
unmatched `)`, unbalanced quotes) returns no exemption. The command
word is found like `_validate_local_bash_shell_tokens` finds it:
after assignments, prefix keywords and wrappers, reduced to its
basename, inside every `$(...)`. A command that could redefine awk or
re-read quoted text gets no exemption at all, and the docstring says
the enumeration is best-effort.
False positives removed: `($2+$3)/2` and `length($0)/2` (a `/` after
`)` is division unless the `)` closes an if/while/for condition, and
the absolute-path scan is now exempt inside awk code too),
`/usr/bin/awk`, `LC_ALL=C awk`, `echo "$(awk ...)"`, `"<<"` inside an
awk string. The awk spans are computed lazily, only when a `$NAME/` or
absolute-path match needs them, and the division check scans backward
instead of re-slicing the program.
* fix(sandbox): exempt only single-segment absolute-path matches in awk code
Review follow-up. Inside awk code spans the absolute-path scan now skips
only a match with no second `/` (`/2`, `/n`, `/NR`), so a `/a/b` shape
is still rejected even if a span were ever misjudged; previously
`awk '{print ($1)/etc/passwd}'` and `awk 'BEGIN{x = 1 /etc/shadow}'`
were accepted.
`enable` joins the commands that withhold the exemption, since
`enable -f` can load a builtin under any name. The division check reads
the preceding `)` through the backward scan instead of re-slicing the
program for every `/`.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(workflows): stop idle agent waits from consuming worker turns
While a script step waited on a child Agent run, the supervisor sent an
empty response frame to the QuickJS worker every 20 ms and each round
trip counted toward the 10,000-turn cap, so any workflow whose Agent
calls kept it waiting for about 209 s failed with "Worker turn limit"
regardless of `timeout_seconds`.
The worker now reports when its job queue has drained, and the
supervisor waits on the pending calls locally while the worker is idle,
still re-checking cancellation and faults every 20 ms, without
exchanging frames. `timeout_seconds` is the only wall-clock bound again;
scripts that spin the job queue still hit the turn cap. The cap is a
module constant so tests can lower it.
Fixes bytedance/deer-flow#7022
* fix(workflows): default a frame without idle to the polling path
Review follow-up: read `idle` with `frame.get("idle", False)` so an
outcome-less frame that lacks the key degrades to the previous poll
behaviour instead of failing the run with a KeyError, and give the
cancel-latency regression test a 3 s budget so store writes and process
reaping on a loaded CI runner cannot flake it.
* fix(workflows): detect worker exit during idle Agent waits
Check worker exit in the existing local poll and clean up pending child runs. Adapt the work-frame budget regression from PR #7025 while retaining precise idle detection.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(channels): share encrypted runtime channel config across Gateway instances
IM bot credentials entered in the web UI lived in each host's plaintext
channels/runtime-config.json, and the router cached the merged config on
app.state for the life of the process. With several Gateway instances a
save or disconnect served by an instance that does not hold the
provider's leader lease never reached the transport holder: it kept the
old credentials until it next started the channel, and a disconnect
could leave the old bot connected.
- New shared table channel_runtime_configs (migration 0044): one row per
provider with the config block encrypted under DEER_FLOW_CREDENTIALS_KEY,
a plaintext disconnect tombstone, and a version bumped by every save.
- SqlChannelRuntimeConfigStore replaces the JSON file for sqlite/postgres
(memory keeps the file); it is synchronous on app_sync_sqlalchemy_url
because its callers already run in worker threads.
- The router re-reads the store on every request instead of caching the
merged config, and refuses (503) to save credentials without a key.
- The leader-lease supervisor compares the versions on each renewal tick
and restarts or stops the holder's transport when a provider's config
changed; a takeover reads a disconnect instead of reviving cached
credentials.
- The first start imports an existing runtime-config.json (rows already
in the table win) and deletes the plaintext file.
* docs(changelog): reference #6988 for the shared runtime channel config
* ci: rerun checks after #6999 fixed the frontend guidance budget on main
* fix(channels): start a held transport that is down when its runtime config changes
When the lease holder's transport was down (a start that failed on the
old credentials), applying a peer-saved config only updated the cached
config, and the transport started on the next lease round: two renewal
ticks instead of the one the docs promise. The apply step now starts it
on the held lease right away, with the lease round's backoff bookkeeping.
* fix(browser): never replace an existing output file with browser_screenshot
browser_screenshot published straight onto outputs/<name> through the
replacing atomic publisher, so a screenshot saved as report.png silently
replaced the user's own report.png, an earlier capture, or another
default-named capture taken in the same second (the default stem has
second resolution). The sibling web_capture tool already keeps existing
files and takes a -1, -2, ... suffix.
Stage the complete capture under a hidden sibling and hard-link it onto
the first free candidate name. The link is the whole no-overwrite guard:
it fails with FileExistsError when the name exists as anything, so no
name is checked before it is claimed and a concurrent writer cannot be
replaced; the final name never shows a partial file. Staging shares the
outputs directory, so the link is same-filesystem, and like Gateway
uploads other link failures propagate instead of falling back to
replacement. New files keep the publisher's normal creation mode.
Hidden per-action progress frames keep the replacing _write_screenshot:
their names carry microsecond timestamps in a server-owned directory.
* docs(changelog): reference #6913 for the browser_screenshot no-overwrite fix
* fix(browser): refuse a staged screenshot swapped before it is linked
The sandbox can write outputs/, so it can see the hidden staged capture
and replace it with a symlink before the hard link. os.link follows
symlinks by default, which would publish a hard link to the host file as
the screenshot artifact (the replacing publisher this PR moved away from
never followed one).
Stage the capture with an exclusively created descriptor and keep its
identity from fstat, link without following symlinks where the platform
supports it (decided once at import), and require the published name to
be that inode; anything else is removed and the capture fails, like
Gateway uploads' identity check. A failed staging write still leaves no
files behind.
* docs(readme): note that browser_screenshot never replaces an output file
* fix(browser): keep the staged screenshot open until its link is verified
Linux filesystems reuse a freed inode number at once. Closing the staged
capture before linking let a file swapped in at the staged name take the
same number and pass the samestat check, so the symlink and planted-file
swap tests failed on Linux CI while passing on macOS. Hold the staged
descriptor through the link and identity check, as Gateway uploads do,
and require the published name to be a regular file.
* test(browser): race the peer claim against the real publication step
The claimed-name test patched _publish_bytes_atomically, which the
screenshot path no longer calls, so its hook never fired and the race
assertions passed vacuously. Plant the peer file right before os.link or
os.replace lands bytes on a visible name, and assert that the claim
happened, the capture moved to report-1.png, and the peer file survived.
* refactor(capture): share one output-name candidate sequence
browser_screenshot hand-copied web_capture's probe bound and suffix
order, and the copies had already drifted: Browserless retried a held
timestamped name five times, the screenshot tool once. Move the bound,
the retry count and the candidate order into utils/output_names.py and
have both tools iterate it, so the naming cannot drift again. Browserless
behavior is unchanged; its tests now patch the shared module.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): let local file tools read the /mnt/user-data root
The local provider maps the aggregate /mnt/user-data root so listing it
matches AIO, but the tool layer only accepted paths below it: ls, glob
and grep on the root answered "Permission denied" (with a trailing
slash, from the resolved-path "path traversal detected" check).
validate_local_tool_path now accepts the bare root for reads, and the
user-data resolver accepts the shared parent of workspace/uploads/
outputs itself when the caller opts in. Only ls and the shared read
path opt in; writes to the root and files placed directly in it stay
rejected.
* docs(changelog): reference #6991 for the /mnt/user-data root fix
* fix(sandbox): list the /mnt/user-data root through its allowed dirs only
The first version let glob, grep and ls start at the user-data root
after validating only the root itself. The walkers then descended into
everything under the host user-data/ directory, so a file or directory
stored directly under it was listed and searched although read_file
rejects it.
ls now lists the root one allowed directory at a time (workspace,
uploads and outputs, each one level deep) through the sandbox's own
list_dir, so the host root is never walked and list_dir's existing
per-directory containment check still filters symlinks. The resolver
is back to main's behavior, so glob, grep and read_file keep rejecting
the root.
* fix(sandbox): reject shell-variable path prefixes in the host-bash guard
The local path guard scans commands for absolute paths with a regex whose
lookbehind skips a `/` that follows a word character. `~/`, `${HOME}/` and
`"$HOME"/` were rejected, but the bare `$HOME/...` form (quoted or not)
was never matched, so `cat $HOME/.ssh/id_rsa` ran on the host when
`sandbox.allow_host_bash` was enabled.
Scan `$NAME/...` separately and reject it, since the variable's value is
unknown at validation time. Variables inside URLs and non-ASCII prose keep
the existing exemptions. `$PWD/...` stays allowed because it names the same
file as the equivalent relative path, which the guard already accepts,
unless the command rebinds `PWD` itself.
* docs(changelog): reference #6930 for the host-bash variable path fix
* fix(sandbox): cover positional parameters and CDPATH in the host-bash guard
Review follow-up. `cat $1/etc/passwd` slipped both scans: the new variable
scan only matched names, and the absolute-path regex skips a `/` after a
digit. `bash -c` gets no positionals, so `$1` expands to nothing and the
shell opens `/etc/passwd`. Match `$0`-`$9` as well, but keep awk-style
arithmetic such as `$1/1024` allowed, since it was accepted before and
standard host roots do not start with a digit.
`CDPATH=$HOME cd docs` moved a relative `cd` outside the workspace, taking
both `$PWD/x` and a plain relative `x` with it. Reject any command that
mentions CDPATH in the working-directory check, so the `$PWD` exemption's
equivalence with relative paths holds.
* fix(sandbox): cover named homes and non-ASCII variable paths in the host-bash guard
- Reject `~name/...`, which expands to that user's home and slips the
absolute-path scan the same way `$HOME/...` did.
- Stop applying the non-ASCII prose exemption to the variable scan:
`$HOME/文档/...` is a host path.
- Allow awk field division `$1/$2`, which main accepts, and capture the
remainder in a lookahead so skipping `$1/` does not hide a later
`$2/etc/passwd`.
* docs: add a Korean README translation
* docs(changelog): reference #6909 for the Korean README
* docs(readme-ko): restore blank lines before headings and sync with README.md
Six headings sat directly under the preceding paragraph or code fence where
README.md keeps a blank line. Also translate the README.md additions from
#6898 (Codex tool_choice), #6886 (mixed-provider citation cleanup), and #6701
(authenticated HTML preview resources).