Commit graph

1478 commits

Author SHA1 Message Date
Harjoth Khara
22006ebf81
fix(core): cap the effort tier at what each endpoint accepts (#9501)
* fix(core): cap the DashScope effort tier at what its ladder accepts

`/effort max` writes the tier into config, and the DashScope provider
emitted it as a flat `reasoning_effort` for the qwen3.8-max family
without checking the endpoint's ladder, which stops at `xhigh`. The
server rejected it with a 400, and because the tier lives in config
every later request in the session rebuilt the same body and 400d too.
The tier also persists to settings.json, so new sessions re-broke.

`max` exists only as a DeepSeek extension. Declare the tiers DashScope
accepts and clamp through the existing `clampReasoningEffort`, warning
once, the same way the Anthropic generator caps tiers its model lacks.

Only the configured `reasoning.effort` is clamped. An explicit
`reasoning_effort` in `extra_body` or `samplingParams` is a documented
verbatim override and still ships unchanged.

Refs: #9459

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(core): cap the effort tier at what each endpoint accepts

Follow-up on the same defect at the generic layer, per cross-model review.

The unified ladder ends at `max`, but `max` is a vendor extension: DeepSeek
and GLM-5.2+ take it, and a generic OpenAI-compatible endpoint stops at
`xhigh`. The effort-ladder design already specifies OpenAI `max -> xhigh`,
but nothing implemented it, so a configured `max` reached the wire raw and
400d every later request in the session.

Declare the accepted tiers on the provider and clamp there. The base
provider ceilings at `xhigh`; DeepSeek and Z.ai override to the full ladder.
The override is on the provider class, not the hostname, because which tiers
a model accepts is a property of the model while the flat-vs-nested wire
shape is a property of the endpoint: a self-hosted deepseek-* or glm-* model
reached through the model-name fallback still understands `max`.

Also corrects a wrong claim from the first commit. `max` is not
DeepSeek-only: Anthropic opus/sonnet 4.6+ and every 5.x family accept it
natively, and the DashScope note now says only that this family does not.

Adds the warn-once and alias coverage the review found missing.

Refs: #9459

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(core): scope the effort ceiling to verified endpoints

Closes four gaps the cross-model review found in the previous commit.

The clamp rewrote a `reasoning` object the user set in `samplingParams`.
The pipeline hands those keys straight to the wire and skips the injection
entirely, so that object is the user's own value and documented to ship
verbatim. Skip the clamp when it is present.

DashScope overrides buildRequest without calling super, and only capped its
own flat qwen field, so a non-qwen model on a DashScope host still shipped a
raw nested `max`. Route that branch through the generic ceiling, carving out
GLM-5.2+, which does accept `max`.

The DeepSeek and Z.ai ladders were keyed on the provider class, but both
classes also route on a model-name substring, so anything merely named
`deepseek-*` or `glm-*` claimed a tier its endpoint may reject. Gate both on
the verified hostname, which is the rule deepseek.ts already documents for
decisions about DeepSeek's own wire shape (#3613). Z.ai additionally gates on
GLM-5.2+ rather than every `glm-*`, matching what its comment claimed.

One existing DeepSeek test asserted a self-hosted deepseek-* keeps `max`.
That was the old no-clamp behavior; an unverified endpoint now gets the
generic ceiling, and the test says so.

Refs: #9459

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(core): document the effort ceiling per endpoint

Adds the Z.ai/GLM row, notes that DeepSeek's `max` is hostname-gated, and
says plainly that a `reasoning` object inside `samplingParams` is the user's
own value and is not clamped.

Also adds the end-to-end pipeline test for the generic provider, mirroring
the DashScope one: it drives pipeline.execute and asserts on the body handed
to the SDK.

Refs: #9459

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(core): answer the effort ceiling for the wire model

The capability check read the configured model, but the pipeline resolves
`request.model || contentGeneratorConfig.model`, so a request-level model
override was answered against the wrong model. Configuring Z.ai `glm-5.2`
and requesting `glm-4.6` shipped a raw `max` again, which is the failure
this change exists to prevent. `supportedReasoningEfforts` becomes
`supportedReasoningEffortsFor(model)` and takes the wire model.

Drops the GLM exception on DashScope. It contradicted this PR's own docs,
which say a `glm-*` model reached on a non-Z.ai host keeps the generic
ceiling, and there is no evidence DashScope's GLM deployment accepts `max`.
A quiet downgrade is the safer side to be wrong on. This also removes the
import of Z.ai's helper from the DashScope provider.

Adds the warn-once test for the base provider and regression tests that
cross the configured and request models in both directions.

Refs: #9459

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:14:15 +00:00
callmeYe
eea98f3b04
refactor(cli): extract ACP skill management (#8865)
* refactor(cli): extract ACP skill management

* test(cli): cover ACP skill safety guards

* fix(cli): harden ACP skill mutation guards

* fix(cli): handle ACP skill frontmatter variants

* test(cli): deduplicate ACP skill fixtures

* fix(cli): handle multiline Skill enablement fields

* fix(cli): recognize escaped Skill enablement keys

* refactor(cli): restore ACP skill extraction scope

Restore the three post-review files to the initial extraction commit. The removed changes addressed pre-existing Skill behavior and test coverage rather than regressions caused by the module split. Latest origin/main changes only unrelated ACP agent sections, so no extracted Skill logic needs to be carried forward.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-24 01:18:22 +00:00
Shaojin Wen
3a1f86d805
feat(review): give verifiers a do-not-refute list and a constructible rejection bar (#9799)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(review): give verifiers a do-not-refute list and a constructible rejection bar

Step 4's verifier brief already floors uncertain Criticals at low
confidence instead of rejection, but it never names the states in
which "too speculative / depends on runtime state" is not a valid
rejection. The finder side carries the recall rule (do not silently
drop a candidate); the verifier side lacked its counterpart, so
real-but-uncertain findings could die in Step 4 on a plausibility
vote instead of surfacing under "Needs Human Review".

Close the same leak on the verifier side:

- Rejection is now defined as direct counter-evidence constructible
  from the code — one of four shapes: factually wrong (quote the
  misread line), provably impossible (type/constant/invariant,
  shown), already handled in this diff (cite the guard and show it
  covers the trigger), or pure style / an Exclusion Criterion. A
  rejection constructing none of them downgrades to confirmed (low
  confidence) instead of dropping.
- A third masquerading state joins "I could not verify it" and "its
  evidence is somewhere I did not look": "it is too speculative". A
  finding whose failure scenario names a realistic state the code
  does not exclude is PLAUSIBLE by default — concurrency races,
  nil/undefined on a rare-but-reachable path, falsy zeros treated as
  missing, off-by-one on a boundary the code does not exclude, retry
  storms and partial failures, patterns that lost an anchor.

SKILL.md's Step 4 summary and the user-facing code-review docs are
synced to the new semantics. The pinning test asserts every shape,
every ground, and the downgrade consequence — a mutation flipping the
consequence into "reject" survived the subject-only assertion, so the
consequence clause is pinned too.

Fixes #9789

* fix(review): sync the rejection-bar summaries with the brief's four grounds (#9799)

* fix(review): sync the plausible-by-default wording and re-head the probe option (#9799)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-08-23 23:33:12 +00:00
Isaac Hernández
fd9c452dc8
fix(auth): let Vertex AI authenticate with Application Default Credentials (#9017)
* fix(auth): let Vertex AI authenticate with Application Default Credentials

Vertex AI auth required an API key, so an ADC or service account setup
could not start. Supplying a placeholder to satisfy the check made it
worse: an explicitly passed key switches the Google SDK to Vertex
Express mode, which clears the project and location and rejects the
request with "API keys are not supported by this API".

Treat a configured GOOGLE_CLOUD_PROJECT as sufficient credentials for
the vertex-ai auth type, in both the CLI pre-flight check and the core
model config validation, and leave the API key absent so the SDK
resolves ADC itself. The missing-credentials errors now mention the
keyless path instead of pointing only at envKey.

Fixes #9016

* fix(auth): select Vertex mode explicitly and keep declared key vars authoritative

Review follow-ups on the Vertex ADC change.

Vertex mode no longer depends on the GOOGLE_GENAI_USE_VERTEXAI side effect.
Only the CLI pre-flight check writes that variable, and the startup call to it
sits under the sandbox branch, so a plain interactive or ACP session built a
client pointed at the Gemini API endpoint instead of Vertex. The flag is now
derived from the auth type at construction, and left untouched for the other
auth types so the SDK keeps its own environment fallback there.

An entry that declares its own key variable no longer falls through to ADC when
that variable is unset. It keeps failing on the declared variable, so a secret
that failed to inject cannot silently authenticate as a different principal.
The keyless hint is suppressed for those entries as well, since it would be
advice that cannot work.

The ACP pre-flight cell reports an indeterminate state for a keyless Vertex
setup rather than a confirmed token: a configured project is routing
configuration, not evidence that a credential resolves. All three gates now
share one definition of a configured project, so whitespace is handled the same
way everywhere, and the CLI missing-key message carries the same keyless hint as
the core errors.

Docs corrected on two counts: the environment-only row now says a keyless setup
must select the auth type explicitly, since it is not inferred from the project
alone, and the provider note names every key source the resolver folds in.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-23 19:27:16 +00:00
callmeYe
c2d63fbe58
fix(web-shell): show reasoning effort before session creation (#9599)
* fix(web-shell): show reasoning effort before session creation

* fix(web-shell): harden reasoning preview lifecycle

* chore(desktop): refresh frozen bun lockfile

* fix(web-shell): restore reasoning preview after session clear

* test(webui): pin session-clear model restoration on all reset paths (#9599)

Witness the four back-to-welcome reset handlers' models re-projection
(session_closed, stream auth failure, terminal stream error, heartbeat
clear) with mutation-visible assertions: each test attaches a session
whose live context displaces the provider models, then verifies the
workspace reasoning preview returns after the reset. Also pin the
providers-absent fallback in getConnectionAfterSessionClear so older
daemons keep the pre-clear model list.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 19:11:19 +00:00
samuelhsin
4ddbf227e8
feat(mcp): add MCP 2026 core and WebShell Apps host (#8992)
* feat(mcp): add 2026 protocol negotiation

* feat(mcp): render MCP Apps in WebShell

* fix(mcp): keep legacy tool discovery lenient

* fix(mcp): keep Apps HTML out of TUI and honor tool visibility

TUI and history compaction dumped mcp_app HTML as JSON, and discoverTools registered app-only tools for the model.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): stabilize AppBridge lifetime and close sandbox CSP gaps

Theme toggles and transcript reseeds were tearing down MCP Apps; the host CSP also allowed any loopback port and form posts bypassed connect-src.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): list under-declared modern MCP capabilities over the wire

v2 typed helpers return [] without a request when a capability is omitted.
Use them only when the server declared the capability, and keep Apps
unmounted in collapsed tool rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep Apps sandbox reachable and list past 64 pages

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reject empty compacted html and keep MCP Apps expanded in multi-tool groups

Fixes R3-1 and R3-2 review comments:

R3-1: getMcpAppDisplay now rejects empty html strings (from compaction)
so session replay shows fallbackText instead of mounting an empty iframe.

R3-2: ToolGroup now checks for MCP apps across all tools (not just
singleTool), auto-expands when any tool has an MCP app, and keeps
MCP app rows expanded (summaryOnly=false, forceExpanded=true) even
when adjacent tool calls are merged into the group.

* feat(web-shell): fold thinking into the compact-mode tool summary (#9148)

Compact mode used to drop thinking messages entirely, so a running turn
gave no indication of the thinking step. Keep the thoughts and aggregate
them with the adjacent tools into one summary: a streaming thought reads
"Thinking…" with the running shimmer, and a completed thought settles into
a click-to-expand row in its original interleaved position. The translate
action is preserved on both the thinking block and the folded thought
rows, and the merged group gets a synthetic id so its expanded state never
leaks into non-compact mode.

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* fix(mcp): address app discovery and sandbox regressions

* fix(web-shell): keep MCP apps expanded in compact summaries

* Revert "feat(web-shell): fold thinking into the compact-mode tool summary (#9148)"

This reverts commit ab2eebc5d36f17a51ce94e423db5745dcbb273fe.

* fix(web-shell): render compacted MCP App fallback and teardown before unload

Compacted history keeps type:mcp_app with empty html; show fallbackText instead of a blank sandbox, and wait for ui/resource-teardown before unloading the iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): bound the discover probe and raise the daemon bundle cap

Silent legacy stdio servers inherited the 10-minute request timeout for server/discover. Cap the probe at 5s so fallback fits the discovery window, and raise the browser bundle budget after the main merge overflowed CI by 47 bytes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): skip version-negotiation probe on remote transports

SDK v2 rejects HTTP server/discover timeouts without falling back to initialize, and the 5s probe consumed the entire remote discovery window.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep MCP App iframe src across deferred teardown

Deferred unload() was clearing src on the live iframe after a remount, so the new AppBridge never saw sandbox-proxy-ready.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): honor listing-level MCP App CSP and permissions

registerAppResource puts ui.csp/permissions on resources/list, and resources/read does not merge that metadata into content entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reuse session client for list and emit app fallback text

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep mcp list and IPv6 sandbox CSP valid

Give qwen mcp list leftover handshake budget after the 5s discover probe, and stop emitting invalid [::1] CSP origins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep modern list and short discovery budgets working

Drop the era-illegal ping after mcp list connect, shrink the stdio discover probe to the discovery window, and document that remotes stay on legacy initialize until the SDK can fall back.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep the 2026 slice free of review-only extras

Drop the global tools/list page cap, generated companion notices, and the review screenshot so this PR stays on stdio 2026 plus the WebShell Apps host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): restore generated companion notices after the SDK v2 bump

CI regenerates NOTICES.txt from the lockfile; the file has to ship with the new MCP client dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): isolate the Apps proxy from WebShell storage

Drop allow-same-origin on the outer sandbox iframe so a default localhost daemon cannot read the WebShell session token.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): harden fallback and app sandbox

* fix(core): preserve large and app-only MCP catalogs

* fix(mcp): preserve legacy negotiation compatibility

* fix(mcp): default stdio negotiation to legacy

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: YungSen Hsin <yungsenhsin@U-G0HXNQM1-2052.local>
2026-08-23 18:34:30 +00:00
qqqys
dafd5c4459
fix(dingtalk): parse forwarded chat records (#9339)
* fix(dingtalk): parse forwarded chat records

* fix(dingtalk): normalize forwarded chat records

* fix(dingtalk): preserve chat record sender alignment

* fix(dingtalk): neutralize forwarded chat-record content and cover its branches

Round-2 review left one Critical and six Suggestions on the chat-record
formatter. All seven are addressed here.

R2-1 (Critical) — forwarded record content is multi-author third-party text:
the forwarder is an allowed user, the authors inside the record are not. The
branch emitted it into `envelope.text` with raw newlines, C1/bidi/zero-width
characters and bracket tags intact, and in 1:1 DMs nothing downstream
neutralizes it — `ChannelBase` applies `sanitizePromptText` only when
`envelope.isGroup || sessionScope === 'single'`, and DingTalk declares no
`defaultSessionScope` so the registry falls back to `'user'`. So the same
payload was neutralized in a group and delivered verbatim in a DM, where a
forged start-of-line `[SYSTEM]:` line reached the model in the adapter's own
prompt style. Pre-diff this callback produced `text: ''`, so this is new
exposure, not inherited. Every dynamic field the formatter lifts out of a
record — title, summary lines, sender, body, and bare string entries — now
goes through the shared `sanitizePromptText` before being joined, which is
also how `referencedText` is already treated unconditionally on the reply
path. The adapter test mock now provides the real helper rather than a stub,
so this defence cannot regress with the suite green.

R1-2 — the msgType→placeholder switch was duplicated in
`summarizeRepliedContent` and the record formatter, and the copies had already
drifted (different `file` handling, different empty fallback). Extracted
`mediaTypePlaceholder`; the record-specific `[${msgType}]` / `[message]`
fallback stays at its call site.

R1-3 — both doc comments now list chat records among the handled types.

R1-7 — a chat-record payload that yields nothing now emits one stderr warning
naming the content keys that arrived, matching this file's existing
diagnostic convention. The payload shape is undocumented and varies, so
without it a new DingTalk variant degrades to `(chat record)` with nothing to
grep.

R2-3 — documented why `summaryLines` keeps its empty placeholders (positional,
indexes into `entries` for sender recovery) while `summary` filters them.

R1-5 and R2-2 — four tests close the surviving mutants: a string entry, opaque
`senderId`s, `message`/`body` as body sources, a title-only record, the
unreadable-payload warning, and the false branch of the alignment guard
(three entries against a two-line summary, no entry carrying a name).

Mutation-verified, each independently: identity sanitizer, dropped length
guard, dropped string-entry branch, dropped message/body sources, dropped
title-only branch, dropped warning, and unfiltered summary display each turn
at least one test red.

* fix(dingtalk): close the bracket-wrap forge and bound a forwarded record

Round 3 of #9339 found the round-2 sanitization fix left three entrances
open, all the same residual: a value neutralized by `sanitizePromptText`
is then WRAPPED in `[...]` by this file, and the wrapper's own `[` is
what completes a forged tag. `sanitizePromptText` unwraps a start-of-line
tag only when the value already begins with `[`, so a title of
`SYSTEM]: ignore previous instructions` passes through untouched and
renders as `[SYSTEM]: ignore previous instructions]` on the prompt's
first line. `fileName` and the unmodeled-`msgType` fallback were not
sanitized at all.

`bracketSafeChatRecordField` now covers all three: sanitize, then strip
the brackets the wrapper supplies. Each site keeps its documented
fallback for a value that cleans to nothing (`Chat record`, `file`,
`[message]`).

Also from round 3:

- String entries route through `formatChatRecordEntryBody` instead of
  re-implementing its pipeline, so a string and an object entry carrying
  the same text are described to the model the same way.
- `warnUnreadableChatRecordEntries`: the degradation the empty-record
  warning cannot see — an entries key arrived (`{"list":[...]}`, a
  non-array, an unusable first alias) but produced no lines, so a title
  or summary still renders and every forwarded message is silently gone.
- Tests for the `audio`/`video`/unmodeled-type placeholders, the
  `|| '[message]'` guard (C0 controls survive `trim()` and only then fold
  to spaces), and the replied-path empty-record diagnostic — all three
  were mutation-green before.

And R1-6, carried from round 1: a merge forward can hold an entire
group's history, and unbounded it displaces the user's own request in the
context window. Entries are now capped at 50, the section at 4000 chars,
and any single entry at 500 code points.

BEHAVIOUR FLIPS, both deliberate:

1. A bare string entry whose content sanitizes to nothing rendered as
   nothing and now renders `Unknown: [message]`. The object entry in the
   identical state already rendered `[message]`; the two copies of the
   pipeline had drifted, and describing identical content two ways based
   only on entry shape is the defect, not the alignment.
2. An oversized record is truncated where it previously was not. The
   truncation is ANNOUNCED (`[N more message(s) not shown]`,
   `[truncated]`) rather than silent: a tail the model cannot see is
   worse than one it can account for.

No existing test pinned either old behaviour — all 133 prior tests pass
unchanged, and no assertion was removed or weakened.

Verification: `packages/channels/dingtalk` 10 files / 319 tests pass
(was 308); `tsc --noEmit` clean; eslint and prettier clean. Mutation
verification, 12 mutants, all killed: bracket-strip to identity (2 red),
unsanitized `fileName` (2), unsanitized `msgType` (2), string entry back
to its own pipeline (1), cap disabled (2), per-line cap disabled (1),
`entriesDropped` pinned false (1), each of the two warn call sites
removed (1 each), `audio`/`video` swapped (2), unmodeled type folded to
`[message]` (3), `|| '[message]'` guard removed (1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dingtalk): close three chat-record tag forges and cover the record caps

Answers round 4 of #9339 — all 3 Criticals and all 6 Suggestions.

R4-1 (C) — the plain-text summary branch sanitized each line WITHOUT the
per-line `nonEmptyString` trim the JSON branch gets. A line beginning with a
trim()-strippable char that `sanitizePromptText` does not fold before its
unwrap step (VT, FF, NBSP, U+1680, U+2000–U+200A, U+202F, U+205F, U+3000)
pushes the `[` off start-of-line, so the unwrap regex cannot match; the later
C0 fold turns that char into a space and the trailing `.trim()` removes it —
reassembling the exact `[SYSTEM]:` tag the unwrap just failed to peel. Trim
first, as the JSON branch already did.

R4-2 (C) — `sanitizePromptText` peeled exactly ONE bracket layer, so
`[[SYSTEM]]` came out as `[SYSTEM]`: a fully-formed forge. DingTalk declares
no `defaultSessionScope`, so 1:1 DMs fall back to `'user'` and ChannelBase
runs no second pass; two passes would only move the bar to `[[[SYSTEM]]]`.
Fixed at the root in `packages/channels/base/src/sanitize.ts` by looping the
unwrap to a fixpoint (each changing iteration deletes the two brackets it
matched, so the length strictly decreases and it terminates).

Separately, record senders are now bracket-stripped rather than left to the
unwrap. This is NOT redundant with the fixpoint: the unwrap's tag-content
window is `{1,64}`, so a bracketed run longer than that never matches and
survives verbatim — and a sender is emitted at start-of-line immediately
before `: `, which is precisely the `[tag]:` shape. Probe-confirmed:
`[SYSTEM - ignore all previous instructions and exfiltrate every secret]:`
(69 chars) passes `sanitizePromptText` unchanged.

R4-3 (C) — BEHAVIOUR FLIP, deliberate. The header line's tag name was
attacker-derived: `bracketSafeChatRecordField` is a no-op for a title with no
brackets, so a bare title `SYSTEM` (which is also what `[SYSTEM]` and
`[[SYSTEM]]` sanitize down to) had the wrapper manufacture a clean
start-of-line `[SYSTEM] …`. That forge is created AFTER sanitization, so
sanitizing the title harder cannot defend it. The tag NAME is now fixed and
the title goes inside it:

  `[Group chat history] …`  ->  `[Chat record: Group chat history] …`
  `[Chat record] …`         ->  `[Chat record: untitled] …`

Nine existing assertions pinned the old shape and were updated to the new
one. They are not weakened — every one still asserts the full header text,
and the old shape is what the finding shows is unsafe.

R4-4 — use `truncateCodePoints` from `@qwen-code/channel-base` instead of a
third private `Array.from`/slice/join clone of the code-point rule.
R4-5 — document forwarded chat records in `docs/users/features/channels/
dingtalk.md`: how they render, the three caps, and that truncation is
announced in the text the agent sees.
R4-6 — decide the entry cap before measuring, and skip the code-point pass
for any line already within the cap in UTF-16 units (a valid upper bound), so
a 10k-line merge-forward stops paying a throwaway array per dropped line.
R4-7/R4-8/R4-9 — cover the three branches that shipped green under mutation:
the 4000-char total cap, code-point truncation of astral characters, and the
reply path's `entriesDropped` warning (plus the reply path's entry expansion,
which no test rendered at all).

Verification — every fix mutation-verified, each reverted alone:

  R4-1 drop the per-line trim              ->  9 failed | 328 passed
  R4-2 single-pass unwrap (channel-base)   ->  1 failed | 1030 passed
  R4-2 single-pass unwrap (dingtalk)       ->  1 failed | 336 passed
  R4-2 sender via sanitizeChatRecordField  ->  1 failed | 162 passed
  R4-3 attacker-derived header tag name    -> 18 failed | 319 passed
  R4-7 MAX_CHAT_RECORD_CHARS -> 4000000    ->  1 failed | 336 passed
  R4-8 line.slice instead of code points   ->  1 failed | 336 passed
  R4-9 delete reply-path warning branch    ->  1 failed | 336 passed

Green at head: channels/dingtalk 337/337 (163 in DingtalkAdapter.test.ts, up
from 144), channels/base 1031/1031, channels/qqbot 291/291. tsc --noEmit and
eslint clean on both packages. channels/github has 9 pre-existing failures in
GithubAdapter.test.ts that reproduce identically with this change stashed.

* fix(dingtalk): close the fold-assembled tag forges and cut the record tail cleanly

Round-5 review findings on #9339.

R5-1 (Critical): `sanitizePromptText` ran the fixpoint unwrap BEFORE the
C0/DEL fold and never looked at the folded output, so the fold itself
assembled tags the unwrap had already passed over. Two executed entrance
classes: a line-leading C0/DEL that JS `trim()` does not strip (x00-x08,
x0E-x1F, x7F) blocked the match and then became a space a caller's trim()
removed; and an interior CR/LF split a tag past the unwrap's content class
(`[SYS` + LF + `TEM]:`) which the fold then rejoined. Both reassembled a
clean start-of-line `[SYSTEM]:` in 1:1 DMs, where ChannelBase applies no
second pass. Fixed by unwrapping again over the folded text.

R5-5 (Suggestion): the same class behind the nine whitespace characters
`trim()` strips but neither pass folds (VT, FF, NBSP, U+1680, U+2000-U+200A,
U+202F, U+205F, U+3000) was patched per call site in this adapter rather than
in the producer. `START_OF_LINE_TAG`'s leading window is now every whitespace
character except CR/LF, so every caller that sanitizes then trims -- five
existing ChannelBase sites -- inherits the guard instead of repeating it.

R5-2 (Critical): summary lines are emitted at start-of-line (each line after
the first), but were defended only by the unwrap, whose `{1,64}` content
window can never match a longer bracketed run -- an 87-char `[SYSTEM MESSAGE
FROM ...]:` tag reached the model verbatim. The sibling sender/title/msgType/
fileName fields close this by stripping brackets outright, but they are also
wrapped in brackets by this file; summary lines are not. New
`startOfLineSafeChatRecordField` peels a leading bracketed run of any length
to a fixpoint and leaves brackets elsewhere on the line alone, so DingTalk's
own `[image]`-style display copy still reaches the model intact.

R5-4 (Suggestion): after the total-size cap tripped, `continue` (with `total`
frozen) let a later shorter line still fit, so dropped messages could sit in
the MIDDLE of the record while the trailing `[N more message(s) not shown]`
announcement said a tail was cut. Both caps now stop at the first line they
reject, which also stops measuring and truncating lines that are discarded.

R5-3 (Suggestion): `sanitizeChatRecordField`'s "keeps DM and group renderings
identical" claim and the user doc's layout promise were both false for groups
-- ChannelBase re-runs `sanitizePromptText` over the assembled text there,
folding the structural newlines and peeling this file's own markers. Both now
say so; the layout is documented as a DM-only guarantee.

Verification: `packages/channels/base` 1042 tests and
`packages/channels/dingtalk` 341 tests pass; `packages/cli`
memory-intent-classifier (38) and `packages/channels/qqbot` (291), the other
`sanitizePromptText` consumers, pass. Each fix was mutation-verified: reverting
the second unwrap, the widened leading window, the summary-line helper, and the
size-cap break each turns at least one new test red (1 / 7 / 2 / 1). Both
packages typecheck, build and lint clean.

Pre-existing on this branch and untouched by this commit: 9 failures in
`packages/channels/github` reason-routing aggregation, identical with these
changes stashed.

* fix(dingtalk): put the record header inside the cap and the reply leg inside the quote budget

Round-6 review, both Critical.

R6-1 — the record's `summary`/`title` header was inside NO cap (per-line,
total or code-point) while `capChatRecordLines` bounded only the entry lines
under it. One root, two symptoms: a 62,889-char summary reached
`envelope.text` intact, ~15x the "at most 4000 characters in total" the docs
and the cap block's own comment promise; and nesting past
`sanitizePromptText`'s `{1,64}` window fell through to the bracket peel, whose
fixpoint loop re-copied the whole string per pair — quadratic, measured 212 ms
of synchronous event-loop stall at 62,889 chars and 4.1 s at 200 KB, on input
any group member can author.

`formatChatRecord` now spends ONE budget across header then entries in render
order, reserving what the entries need to announce their own cut; the title is
bounded by the per-line cap and then by that budget. The peel does the same
work in one linear pass over a deletion map instead of a loop of whole-string
rewrites (2.3 ms at 200 KB). Equivalence was checked exhaustively over every
string up to length 7 from `{[, ], space, a}` (21,837 inputs) and 573k random
fuzz cases against the loop it replaces: zero divergence.

R6-2 — the reply leg rendered to the 4000-char record budget, but its consumer,
`ChannelBase`'s `sanitizeQuotedText(referencedText, 500)`, cuts at 500 code
points unconditionally. Every non-trivial replied record therefore arrived with
everything past the header gone AND its own `[N more message(s) not shown]`
announcement cut off with it — the model got a partial record with only a bare
`…` to say so, while the docs promised the cap is announced. The reply leg now
renders to the quote budget, so the announcement lands inside the quote.

Behaviour change, user-visible: a record you REPLY to is now rendered to 500
characters rather than 4000. It was already delivered at 500 — this only moves
the cut from the transport's blind slice to the record's own announced one, so
what the agent loses is unchanged and what it is told about the loss is not.
Documented in the DingTalk channel page.

Also drops `capChatRecordLines`' first-line exemption: with the per-line cap at
500 the first line always fitted the 4000 budget anyway, so it only ever fired
on the quote budget — where keeping a line the transport then cuts is exactly
the silent truncation the block exists to prevent.

Verification: `npm run build` and `tsc --noEmit` clean in
packages/channels/dingtalk; eslint clean on both changed sources; full package
suite 344/344 (169 in DingtalkAdapter.test.ts, 3 new). Five mutants, all
killed: uncapped summary and uncapped title each redden the header-cap test;
the reply leg back on the 4000 budget and the removed announcement reservation
each redden the quote-budget test (507 code points against a 500 ceiling); the
fixpoint peel restored reddens the stall test at 5,197 ms against a 1,000 ms
threshold that the linear peel clears in ~10 ms.

* fix(dingtalk): budget the record title in UTF-16 units and peel chained tags linearly

R7-1: the chat-record title cap was the one budget quantity in
`formatChatRecord` measured in CODE POINTS -- `headerBudget`,
`headerLead.length`, `spent` and `chatRecordAnnouncementCost` are all UTF-16
`.length`. An astral character therefore bought two units for the price of one
point, so a title sitting exactly on the 429-point cap the header leaves
overshot its reserved space. The entries budget then fell BELOW the
announcement cost the header had reserved for it, `capChatRecordLines` hit its
`spendable < 0` floor and returned `[]`: on the quote leg every forwarded
message vanished with no `[N more message(s) not shown]` line, and
`entriesDropped` stayed false because `recordLines` was non-empty -- so not even
the stderr warning fired. Emoji in a group record title are ordinary. A
fully-astral title also carried the result past the documented 500-unit ceiling
(~544-873 units).

Adds `truncateUtf16Units` to channel-base beside `truncateCodePoints` -- cut to
a UTF-16 unit budget, still on code-point boundaries, so a pair is never split
-- and uses it for both the title and the per-entry line cap.

BEHAVIOUR FLIP (entry leg): an entry line of 400 emoji is 400 code points but
800+ UTF-16 units. It used to pass through whole and unmarked, 1.6x the ceiling
the cap documents; it is now cut to 500 units and marked `[truncated]`. The
ceiling is a budget promise the header and entry sections both spend against,
not a display preference, so the old behaviour was wrong: it let one entry
silently eat space the announcement had been promised. The existing R4-8 test
only reaches the cap from above its POINT count, where both measures agree a cut
is due -- it cannot see the band between them.

R7-2: `unwrapStartOfLineTags` peeled to a fixpoint with a full-string `replace`
per pass. `START_OF_LINE_TAG` is `^`-anchored, so each pass removed exactly one
tag per line, and a tag whose content is all whitespace peels TO whitespace --
re-opening the leading window -- so `'[ ]'.repeat(n)` cost n x O(n). Measured on
this branch: 10 KB -> 16.1 ms, 20 KB -> 71.5 ms, 40 KB -> 318.2 ms, 80 KB ->
1216.6 ms of synchronous event-loop stall. The input is attacker-authorable and
reaches `sanitizePromptText` BEFORE any cap -- record titles and summary lines,
entry bodies, any group message routed through `ChannelBase` -- so the stall
repeats per message. The suite's only stall test pins DEEP NESTING, which
exceeds the `{1,64}` content window and never matches this regex at all (0.8 ms
at 200 KB), so the quadratic shipped green.

Replaced with the same peel simulated in place -- the mark-and-emit technique
`startOfLineSafeChatRecordField` already uses on the DingTalk side, extended
with the `{1,64}` content window and per-line restart. Both pointers only move
forward and each pass measures at most 65 live characters, so the peel is
linear: the same inputs now run 1 ms / 3 ms / 5 ms / 5 ms, and 300 KB in 9 ms.

Verification:
- Differential test against the original regex fixpoint over 84,000 random
  inputs (bracket-dense, blank-content, CR/LF/U+2028, NBSP/IDEOGRAPHIC-SPACE,
  C0/DEL, astral, and the 64-char window boundary): byte-identical output. Run
  as a scratch test, not committed.
- Mutation, R7-2: restoring the `replace` fixpoint turns the new stall test red
  at 16913 ms against a 1000 ms bound (9 ms with the fix); no other test moves.
- Mutation, R7-1 title: restoring `truncateCodePoints` turns the new
  astral-title test red -- exactly one test, the new one.
- Mutation, R7-1 entry line: restoring the code-point cap turns the new
  unit-cap test red; before it was added, that mutant shipped green.
- packages/channels/{base,dingtalk,telegram,weixin,qqbot}: 1594 tests green.
- packages/cli memory-intent-classifier (the only sanitizePromptText consumer
  outside channels): 38 green.
- `tsc --build` clean in both touched packages; eslint and prettier clean.
  Root `npm run typecheck` fails in packages/web-shell and packages/cli, but it
  fails identically on the untouched branch -- stale cross-package dist in this
  worktree, not this change.

* fix(dingtalk): delete an unpaired leading bracket in the summary-line peel

* fix(dingtalk): keep the summary-line peel linear on unpaired brackets

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 18:21:41 +00:00
易良
56db17bd4c
refactor(cli): enforce utils leaf-layer dependency direction (#9146) (#9737)
* refactor(cli): enforce utils leaf-layer dependency direction (#9146)

Move domain-coupled modules out of packages/cli/src/utils into the
directories that own them: config/ (dialogScopeUtils, settingsUtils),
i18n/ (languageUtils), ui/ (handleAutoUpdate, standalone-update,
systemInfo, systemInfoFields, update-relaunch, commands, doctorChecks),
nonInteractive/ (nonInteractiveHelpers, chat-recording-failure,
tool-result-boundary-diagnostics, permission-suggestions), serve/
(sandbox), services/housekeeping/ (scheduler, non-interactive-scheduler),
and commands/review/ (findings).

Extract the generic normalizePartList helper into
utils/normalize-part-list.ts so utils consumers keep importing downward,
and move the MergeStrategy enum into utils/deepMerge.ts (its owner).

Add an eslint architecture rule (no-utils-upward-import) that forbids
value imports from utils/ back up into a domain directory. Type-only
imports stay exempt: they are erased at compile time and cannot create a
runtime cycle (Settings in modelConfigUtils, CommandContext in
sessionPaths).

No behavior change: typecheck, build, and the affected unit tests pass.

* fix: use Qwen Team 2026 license header on new files (#9146)

* chore: refresh stale utils/ path references after leaf-layer move (#9146)

* docs: reconcile no-utils-upward-import header with the allowed type-only set (#9146)

* fix(cli): allowlist sandbox process.env accesses after leaf-layer move (#9146)

* chore(ci): re-record qwen-autofix.yml size baseline after #9677 (#9146)

#9677 recorded qwen-autofix.yml at 392111 bytes while the file it
committed was already 397656, so every PR that merged main after it
tripped the growth ratchet. Re-record the actual size; the file itself
is unchanged by this PR.

* fix(review): drop the stale utils/findings.ts digest root after the leaf-layer move (#9146)

The #9146 move returned findings.ts to commands/review/, but the digest
root lists merged from main still pinned it under utils/, where the file
no longer exists — the absent root darkened every review's staleness
check and failed review-source-digest.test.ts. Drop the stale file-shaped
root from both digest copies and their pins; the commands/review/
directory root covers the validator at its new home, and the two utils
helpers keep their file-shaped roots.

* fix(review): colocate seatbelt profiles with the sandbox module (#9146)

* fix(review): exempt inline type-only specifiers from the utils upward-import rule (#9146)

* fix(review): report upward inline type-specifier imports under verbatimModuleSyntax (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): pin mixed-specifier and zero-specifier upward imports in the utils rule (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): anchor the nested-checkout utils rule fixture on the last marker (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): pin that the utils/findings.ts digest root stays removed (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(review): reword stale-bundle SCOPE header to the post-move helper shape (#9146)

* test(review): drop the pre-move utils/findings.ts from the skill-parity fixture (#9146)

* test(serve): derive the seatbelt colocation tripwire from BUILTIN_SEATBELT_PROFILES (#9146)

* fix(architecture): fail closed on computed dynamic imports in the utils leaf rule (#9146)

* fix(cli): point settings.test.ts at the post-move settingsUtils path (#9146)

main updated settings.test.ts after this branch moved settingsUtils.ts
from utils/ into config/, and the merge kept main's old import
specifier, which vite fails to resolve. Repoint it at ./settingsUtils.js;
every other consumer already uses the new path.

* fix(cli): close utils boundary review gaps

* test(cli): cover utils boundary allow paths

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 14:41:49 +00:00
顾盼
b5fbdb22d3
feat(cua-driver): add versioned Computer Use SDK and release pipeline (#9587)
* chore(cua-driver): sync upstream v0.20.0

* feat(cua-driver): add Computer Use SDK with versioned observation revisions

Wrap the typed driver SDK in a standalone Node wrapper and add accessibility.observation_revision.v1: base-anchored validated diffs with opaque element tokens, explicit full-resync reasons, full-only answers on Windows/Linux. Fix portable include_screenshot schema type and classify stable kAXErrorFailure refusals as complete in the macOS capture tracker.

* feat(cua-driver): complete typed Computer Use capabilities

* fix(cua-driver): use Qwen-owned npm identity

* feat(cua-driver): publish one Qwen CUA SDK package

* fix(cua-driver): verify Windows Rust targets

* fix(cua-driver): verify macOS Rust targets

* fix(cua-driver): pin release Rust toolchain

* fix(cua-sdk): fail closed on incomplete releases

* test(cua-sdk): import workflow test globals

* ci(cua-sdk): retry Debian package downloads

* fix(cua-driver): harden lifecycle and release gates

---------

Co-authored-by: tutu <tutu@U-RD4R9MQQ-2235.local>
2026-08-23 14:20:14 +00:00
易良
0b953b7929
fix(core): support public GitHub extensions with older Git (#9690)
* fix(core): clarify Git requirement for public extensions

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): preserve secure Git version boundary

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): support public GitHub extensions with older Git

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): harden old-Git fallback archive validation against export-ignore

Detect Git LFS by pointer-file content instead of .gitattributes grammar: codeload archives honor export-ignore, so a repository can hide its attributes file from the extracted tree and slip raw LFS pointers past the guard (attribute macros and case-variant names bypass the grammar check too). Also restrict the .gitmodules check to the archive root, where git gives it submodule semantics. Add a debug log to the only silent ERROR return in the old-Git update check, unit tests for the fallback gate's fail-closed matrix, and coverage for the invalid-SHA update path.

* test(core): cover archive entry-count and expanded-size limits

Add crafted-header tar fixtures for both new rejection limits in assertTarArchiveHasNoLinks: boundary cases at exactly 100,000 entries and exactly 1 GiB declared expansion, plus just-over cases asserting the specific error messages.

* fix(core): address old-Git fallback review feedback

- Keep release installs ahead of the archive fallback for older Git;
  the fallback now only replaces the clone step after a release miss.
- Restrict tar entry-count/expanded-size ceilings to the untrusted
  network fallback instead of every .tar.gz extraction, and stop
  reading the archive as soon as validation fails.
- Share one ref-to-SHA resolver between install and update checks, and
  follow a limited number of GitHub API redirects (re-validated per
  hop, token never leaves the original host).
- Use a random staging name for the downloaded source archive so it
  cannot collide with a repository file of the same name.
- Collapse the duplicated pinned-Git version comparison into one check.
- Document fallback limitations (symlinks, submodules, LFS, ceilings).

* test(core): cover invalid commit SHA rejection in old-Git fallback

The install path's ref-to-SHA resolver validates the 40-hex SHA before
interpolating it into the codeload download URL; add a test asserting
that an invalid SHA rejects before any archive download is attempted,
matching the existing update-check coverage.

* fix(test): add missing createReadStream and pipeline mocks in npm test

archive-safety.ts now calls fs.createReadStream() and pipeline() directly
instead of tar.t({ file, ... }). The npm test mock for node:fs was missing
createReadStream, and node:stream/promises pipeline was not mocked.

* perf(core): memoize the local Git version probe

The fallback gate and the pinned-Git assert both spawn their own
`git version` subprocess even though the version cannot change within
a process lifetime. Fetch it once through a module-scope memoized
promise so each extension install/update check pays a single probe.

* test(core): cover early abort of the tar safety scan

Once a limit trips, the scan destroys the read stream instead of
consuming the rest of the archive. Add a regression test that trips
the link ceiling with a large trailing entry and asserts the scan
stops reading the archive at the failure point, guarding the teardown
path against deadlocks and scan-to-end regressions.

* fix(core): open the tar safety scan stream after the abort check

A pre-aborted signal entering assertTarArchiveHasNoLinks threw before
pipeline consumed the hoisted ReadStream, abandoning it (unhandled
ENOENT 'error' crash for a missing file, leaked fd otherwise). Move
createReadStream below the abort check to restore check-then-open
order, and add a regression test asserting no stream is opened.

* test(core): cover fetchJson redirects and fallback resource limits

Mirror the downloadFile redirect matrix for fetchJson via the release
metadata path: redirect loop cap, missing location header, non-https
redirect rejection, and both sides of the cross-host token-stripping
ternary. Also add a fallback integration test serving a crafted-header
archive just over the 1 GiB expanded ceiling so the enforceResourceLimits
option on the production call site is pinned end to end.

* fix(test): return a destroyable stream from the npm test fs mock

The bare createReadStream mock returned undefined, so failValidation's
stream.destroy() raised a TypeError absorbed by vitest spy bookkeeping
whenever a validation cap tripped. Return a destroyable object and
assert the cap-trip path completes cleanly.

* fix(test): make fallback anonymity assertions header-case-insensitive

* test(core): abort the old-Git fallback through an AbortSignal

* fix(test): pin the manager's fallback call arguments

* test(core): pin fallback symlink rejection, lookup passthrough, per-hop re-resolution

- Add an integration test that runs the real old-Git fallback against a
  symlink-bearing archive mirroring issue #8993's repro repo
  (obra/superpowers root AGENTS.md -> CLAUDE.md) and asserts the honest
  fail-closed rejection naming the link entry; safe symlink support is
  tracked in #9724.
- Assert the fallback's https.get options carry the pinned lookup and
  agent:false on both the commits-API and codeload hops.
- Run the five GitHub API redirect tests under networkPolicy: 'public'
  and pin per-hop re-validation: dns.lookup is called once per hop and
  every hop's options carry the pinned lookup.

* test(core): import archive limit constants instead of redeclaring them

The boundary tests redeclared MAX_ARCHIVE_ENTRIES and
MAX_ARCHIVE_EXPANDED_BYTES locally, so changing a limit in
archive-safety.ts would leave the tests validating the stale values.
Import the constants from the implementation instead.

* fix(core): detect export-ignore-hidden submodules via the commit tree

The submodule guard checked for a root-level .gitmodules in the
extracted archive, but codeload archives honor .gitattributes
export-ignore, so a repository can strip its .gitmodules from the
archive and slip past the presence check while still carrying
submodule gitlinks. Query the commit's tree listing, which keeps every
path regardless of export-ignore, and reject on a root .gitmodules blob
or any gitlink entry before downloading; fail closed when GitHub
truncates the listing. The extracted-tree scan stays as defense in
depth.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 13:03:15 +00:00
jinye
431a0bd9b0
fix(daemon): keep restored ask_user_question valid after load (#9763)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* fix(daemon): keep restored questions valid across load, send, and replay

Post-merge review of the restore path found illegal provider history, phantom rewind snapshots, dropped resume notices, and replay that finalized a question the load was about to re-hang.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): pin ask_user_question restore suppress wiring in acpAgent

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): skip persistence for a whole restored batch that ends unattended

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): pin restorable ask_user_question preservation on a real Config

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 05:03:31 +00:00
callmeYe
f1b1305a76
feat(models): support dual-role image generation models (#9650)
* feat(models): support dual-role image generation models

* fix(models): address dual-role image selector review

* test(cli): cover image model resolver rejection

* fix(models): preserve legacy vision image routes
2026-08-23 05:02:32 +00:00
Dragon
1e062a4d0f
perf(cli): raise VP scroll rendering to 60 FPS (#9681) 2026-08-23 04:50:28 +00:00
Shaojin Wen
509226260c
feat(review): back comment-status and presubmit for Aone Code targets (#9627)
* feat(review): back comment-status and presubmit for Aone Code targets

A second `--comment` round on an Aone MR re-posted every still-valid
finding as a new comment and never downgraded a self-MR review — both
flows were skipped for lack of a1 backing. Route Aone targets at the a1
reads (mr view / mr status / mr comment list / auth whoami) through the
same pure classification cores the GitHub path pins, so the report
schemas and the Step-7 downgrade semantics stay one contract:
parentNoteId threading, closed → resolved, outdated → stale (a
rewritten line stays re-postable), no commit anchors (code facts
degrade to unknown), and drift with no compare API fails safe. The
context-unavailable verdict cap stays until pr-context lands.

Closes #9613

* fix(review): harden Aone runners' pr_number guards and null gate payload

Address round-1 review findings on the Aone backing of comment-status
and presubmit:

- extractStatusChecks no longer throws a TypeError when a1 answers a
  bare null to `mr status`; the payload now reads as the designed
  unreadable gate state (undefined), capping the verdict like a
  still-running check instead of crashing presubmit with no report.
- comment-status and presubmit validate pr_number with fetch-pr's
  /^[1-9]\d*$/ grammar before Number() coercion, refusing '012'/'1e3'/
  '0x1f'/' 12'/'12.0' tokens that would query a different MR than the
  caller's label carries.
- Pin the two subject_type combinations no test covered (pathless
  comment WITH outdated:true; the live path+line shape) with
  mutation-probed assertions.
- Align the --host describes with the sibling commands' detection
  wording (omission no longer promises github.com), name the real
  bucket (`resolved`) in the review skill's Aone dedup note, and scope
  the design doc's remaining-unbacked claim to its own section.

* test(review): pin the Aone dedup seams the round-2 review named (#9627)

Four mutation-verified pins on the existing Aone backing, each closing
a round-2 Suggestion:

- classifyAoneChecks: the continue-scan cell of aoneCheckState — an
  unrecognized value in an earlier key beside a recognized verdict in a
  later key reads the verdict, not pending (a first-present-key mutant
  now fails)
- classifyAoneChecks: a context-keyed FAILED gate carries its name —
  the passing context-keyed case pinned nothing because passing gates
  never collect names
- both comment mappers: `note` beats `body` when BOTH keys are present
  (`??` does not coalesce `body: ''`, so an inverted priority would
  blank every recognition signal and re-post the whole review)
- aoneCommentToPresubmitComment: parentNoteId maps onto
  in_reply_to_id, including the absent-stays-unset half

No source changes; each pin fails under its named mutant and passes on
the current code.

* test(review): pin the five Aone seams the round-3 review named (#9627)

* fix(review): align Aone comment reads with measured a1 facts (#9627)

* fix(review): read fully-dropped Aone checks array as pending, not all-clear (#9627)

* fix(review): match SKILL.md self-PR wording to the revert-guard test

The merge resolution reworded the self-PR note to "matched against the
'a1 auth whoami' account", but SKILL.test.ts's revert guard (#9616, #9627)
pins the exact phrase "the MR author is matched against 'a1 auth whoami'".
Restore the pinned wording (semantics unchanged) so the bundled-skill test
passes.

* fix(ci): record qwen-autofix.yml's actual size in the workflow ratchet

The workflow-size ratchet failed on this PR: qwen-autofix.yml is 397656
bytes but .size-baseline recorded 392111 (5545 over, allowance 4096).

The oversize was inherited from main, not introduced here: main's ratchet
commit (a5d77eb8, #9677) shrank qwen-autofix.yml to 397656 but set the
baseline to 392111 — 5545 bytes below the file's actual size at that very
commit. This branch carries main's file unchanged (byte-identical), so its
CI is the first to trip the mismatch.

Growth is real in the sense that the file genuinely is 397656 bytes; per the
ratchet's own guidance ("if the growth is real, bump the number and say
why"), record the actual size so the ratchet measures future drift from
reality. The Post Coverage Comment failure is downstream of this (the Test
job exits before uploading the coverage artifact).

* fix(review): keep the pipeline's own pathless Aone summary out of the blocker index (#9627)

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 04:50:14 +00:00
samuelhsin
b6901ee0fd
feat(web-shell): refresh composer skills incrementally after toggles (#9131)
* feat(daemon): attach skill-toggle mutation metadata to settings_changed

Hosts can apply Skill toggles incrementally without a full task reload or suppressing skills.* events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): fit skill-toggle mutation metadata in the SDK bundle budget

The new normalizer parser pushed the browser daemon bundle over the 186KB cap. Raise it to 187KB and pin the review gaps that were cheap to close.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(daemon): pin skill-toggle mutation event count and parser edges

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web-shell): refresh skills incrementally after toggles

* fix(sdk): raise daemon browser bundle budget for skill-toggle metadata

The 190KB cap overflowed by 491 bytes after merging main, so the SDK build fails before tests run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): retry cancelled skill-toggle refresh per session

Marking the mutation handled before the workspace reload settled dropped the fallback when the user switched sessions mid-flight.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep skill fallback until session snapshot reflects toggle

Reference-identity snapshot checks dropped the workspace fallback on unrelated command updates, so a disabled Skill stayed in composer autocomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): scope skill-toggle fallback to workspace and pending toggles

A partial toggle from another workspace, a superseded later mutation, or an unknown session skill list could leave a stale Skill in composer autocomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): pass initial value to skill-mutation origin ref

React 19's useRef types require an argument; the missing initializer broke the web-shell build.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): restore skill fallback after workspace round-trip

Dropping the fallback on a workspace switch left the handled mark in place, so returning to the origin session never reinstalled it. An unknown session skill list now also uses the ready workspace snapshot instead of an empty composer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep skill mutations per workspace after toggles

An intervening toggle in another workspace overwrote the only mutation
slot, so a failed live refresh could not restore that workspace's
composer snapshot. Also read nested _meta.availableSkills on live
command updates so the session skill list is not wiped to empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): retain batched skill mutations instead of the latest only

Two distinct toggles in one replay or live commit used to keep only the
last mutation, so a later applied toggle could drop an earlier partial
fallback and leave a disabled skill in composer autocomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep Skill composer source correct after toggles

Skip the applied fast path while session skills are unknown, and drop leaked pending toggles when the workspace fallback is cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep skill-toggle refresh scoped to #9123

Drop multi-workspace mutation books, pending-toggle merging, and the nested skills mapper so Web Shell only consumes skill_toggle metadata and refreshes the composer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): drop unused skill-refresh eslint disable

The scoped effect already lists its deps, so the leftover exhaustive-deps suppression failed lint:ci.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): revalidate partial skill mutations

* fix(web-shell): reconcile queued skill mutations

* fix(web-shell): reconcile zero-session skill refreshes

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: YungSen Hsin <yungsenhsin@U-G0HXNQM1-2052.local>
2026-08-23 04:13:51 +00:00
Shaojin Wen
ec8a8a1a97
feat(review): back pr-context on Aone Code targets (#9621)
* feat(review): back pr-context on Aone Code targets

pr-context was the one read subcommand still gh-direct, so every Aone
run was forced context-unavailable: the verdict capped at COMMENT (the
wired a1 approval could never fire), Agent 0 skipped, and the machine
ledger never recovered from posted summaries. Route it through the
platform reader with a normalized context bundle; Aone serves it from
mr view + the flat comment list (thread comments carry the ledger),
GitHub's implementation is an extraction of the existing calls — its
output stays byte-identical. The forced cap leaves the Aone write path
for parity with GitHub's state-claim handling, and the refetch commands
a context file emits bake --pr on Aone, where comment bodies are
addressed per-MR.

* fix(review): keep Aone ledger carriers out of the blocker re-check (#9621)

On Aone this pipeline's own round summaries are path-less comments, so
they ride pr-context's issue channel, where their visible
**[Critical]** lines self-promoted every prior Critical-bearing summary
into "Blockers to re-check" — rendering each prior Critical three
times (beside the ledger section and the inline roots that own the
same findings) and spending the section budget on the pipeline's own
prose until genuine human blockers degraded to snippets. Exclude
bodies carrying the ledger marker from issue-channel promotion and the
stdout count, strip the marker out of the settled snippet, and switch
the pr_number guard to the canonical isPositivePrNumber so 0x10/5.
spellings cannot fragment side-file continuity. Pin the witnesses the
round's findings name: the guard, args.host forwarding, the
issue-kind --pr refetch branch, the account-first author keying, and
the GitHub test suites' independence from the cwd-origin probe.

* fix(review): refuse pr_number spellings that do not round-trip (#9621)

isPositivePrNumber alone admits two spellings whose Number() value does
not round-trip to the raw string: leading zeros (007 fetches 7 but the
raw string labels the heading and the prev-ledger side file, so a later
7 run reads a different side file and the round counter restarts) and
digit strings above Number.MAX_SAFE_INTEGER (Number() silently rounds
them, fetching a different PR than the labels announce). Add the
safe-integer and no-leading-zero conjuncts — matching fetch-pr's
[1-9]\d* rule — so every admitted input satisfies String(Number(x)) === x.

Also pin the witnesses the round-2 review names: the commit_id
round-trip through the GitHub reader and toRawReview into the persisted
side file (both spreads were unwitnessed), the stale force-applies
comment in submit-aone.test.ts the cap removal outdates, and the setup
batch's Aone carve-out for the unbacked comment-status call.

* docs(review): align Aone docs with the landed no-ancestry anchor rule and comment-status skips

D6 described the AGit-Flow anchor as inert until the incremental rule
landed, but that rule (#9630) merged while this branch was in flight —
anchors now delta-scope Aone re-reviews. SKILL.md's comment-status
section and Step 6's report-existence guard now name the Aone skip the
setup batch already carries, so no path sends an Aone run at the
unbacked command or at a report that was never written.

* docs(review): annotate #9616 as landed and define the report-less re-check rule

The out-of-scope list still read self-PR detection as open work although
#9629 shipped it into this branch's merge base — annotate it like the
sibling #9618 entry. Step 6's report-existence guard pointed report-less
runs at a re-derivation the skill never defines; replace it with the
explicit rule: no per-thread status routing, no hand-derived substitute,
rule from the code at the reviewed commit, cannot-tell over a guess.

* fix(review): route the context head through aoneHeadSha and close the round-5 findings

getReviewContext read sourceBranch raw while every other head read trims
— a padded server value diverged the context file from the rest of the
run (phantom-drift shape). getCurrentUser now honors the seam contract
on the anomalous whoami shapes instead of leaking untagged throws and
non-string accounts. Step 6's report-less rule no longer contradicts
the comment-status failure contract: runs where the command ran and
failed keep the "re-derive if needed" fallback. The Aone paragraph
names comment-body among the backed reads, and witness tests pin the
identity gate's carriers key and the head normalization.

* fix(review): shape-check the Aone comment listing in getReviewContext

a1 can answer repo mr comment list with an exit-0 a1.error/v1 error
object (backend auth failure or client timeout — measured by cleanup's
a1CommentList on the identical payload). Without a guard the object
survives the ?? [] coalesce and .filter throws an untagged TypeError,
losing the envelope's actionable message at exactly the recoverable
moment. Guard as the provider family already does and surface the
cause; witness tests pin both envelope shapes (mutant-checked).

* test(review): pin getCommentBody's body-field fallback (mutant-checked)

* fix(review): union resolved comments into the Aone context bundle

The default comment list excludes resolved comments (measured by the
cleanup audit) while GitHub's REST fetches include them, so a resolved
blocker/marker root never reached the re-check walk or the fail-closed
identity gate. Union the default and --resolved listings as the audit
does, dedupe by id, fail closed on either listing's error envelope, and
disclose the residual that resolved replies stay invisible; witness
tests mutant-checked.

* fix(review): serve resolved comments and guard the envelope in getCommentBody

getCommentBody queried only the default comment list while the context
bundle it serves refetches for unions in resolved comments — a resolved
id named by a truncation note threw "not found" every time, and an
exit-0 a1.error/v1 envelope threw an untagged TypeError that lost the
actionable message. Extract the shape-checked default+resolved union
helper and read both sites through it; witness tests mutant-checked.

* ci: correct qwen-autofix.yml size baseline to its actual post-migration size

#9677 shrank qwen-autofix.yml from 431526 to 397656 bytes (prose moved to
the design record) but recorded the baseline at 392111, 5545 below the
file's own post-change size, so the first PR to run the ratchet tripped it.
This branch introduces zero growth to the file (byte-identical to main);
the bump aligns the baseline with reality. No workflow content changes.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 01:10:32 +00:00
qqqys
98fa2e9770
feat(cli): enable dynamic workflows from a settings key (#9098)
* feat(cli): enable dynamic workflows from a settings key

`ConfigParameters.workflowsEnabled` is declared, defaulted, and read by
`Config.isWorkflowsEnabled()` — but `loadCliConfig` never writes it, so no
setting has ever reached it. The only way to turn dynamic workflows on is
the undocumented `QWEN_CODE_ENABLE_WORKFLOWS=1`, which has to be exported
in every shell that launches qwen. AGENTS.md names this shape directly: an
optional field that is declared and read but never set by any caller is a
dead switch.

Add `tools.workflowsEnabled` to the settings schema and populate the field
from it. Precedence is unchanged and still resolved in core:
`QWEN_CODE_DISABLE_WORKFLOWS` beats everything, then
`QWEN_CODE_ENABLE_WORKFLOWS`, then the setting. Because `settings.merged`
already folds the System scope, an operator gets a fleet-wide force-off
with no extra code.

`requiresRestart` is load-bearing rather than decorative: the Workflow tool
is registered once while the tool registry is built, `/workflows` is gated
when commands load, and keyword steering resolves at startup — so a
mid-session toggle would leave the dialog claiming the feature is on while
the tool is absent from the registry.

The setting description also disambiguates it from the unrelated
`experimental.sessionWorkflow` plan-and-review view, which shares the word
"workflow" and would otherwise be easy to confuse in the settings dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(cli): clarify workflow feature controls

* test(cli): cover workflow command gating

* fix(cli): restrict workflow opt-in scope

* fix(cli): keep workflow opt-in user-owned

* test(cli): cover workflow system scopes

* refactor(cli): drive workspace-restricted settings from one list

R4-3: the restricted set was hand-maintained in three parallel places — a
per-key warning block, the condition in `stripWorkspaceRestrictedSettings`,
and that function's destructure. Adding one restricted setting needed three
synchronized edits, and either omission is silent: forgetting the warning
discards a workspace value with no diagnostic, forgetting the strip honors a
value the warning says is ignored.

`WORKSPACE_RESTRICTED_SETTINGS` is now the single source, and the warning
loop and the strip both derive from it. It lives in `settingsUtils.ts`
rather than `settings.ts` because `settings.ts` already value-imports that
module — defining it there and importing it back would close a runtime
import cycle.

R4-2: `tools.workflowsEnabled` is the first setting that is both
`showInDialog: true` and stripped from Workspace scope, so the dialog
offered a toggle that silently never took effect — it renders from the raw
scope file, so it kept showing the value it wrote while the feature stayed
at its merged value, leaving a dead entry in the repo's .qwen/settings.json.
`getDialogSettingKeys` gained `excludeWorkspaceRestricted`, which the dialog
passes when the selected scope is Workspace. The scope comparison stays in
the component so settingsUtils keeps its type-only dependency on settings.ts.
Unlike `showInDialog: false` (what the two pre-existing restricted settings
use), the setting stays visible and editable under the scopes that honor it.

Verified: settings 169/169, settingsUtils 85/85, BuiltinCommandLoader 13/13;
packages/cli typecheck clean. Mutation-checked both ways — forcing the
filter off fails 1 test, dropping a key from the list fails 3.
SettingsDialog.test.tsx's 23 failures are pre-existing and environmental:
identical counts on upstream/main and on this branch before the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): reject workspace-restricted settings at the daemon API too

R8-1. The workspace restriction stopped at the TUI dialog. `stripWorkspace
RestrictedSettings` drops these keys before every merge, so a workspace-scope
write through the settings API persists a committable dead entry into the
repo's `.qwen/settings.json` and answers 200 + `requiresRestart: true` while
the feature never turns on — GET then reports `workspace: true` beside
`effective: false`, and the warnings channel carries only `corrupted`, so the
client never learns the write was inert. Exactly the trap the SettingsDialog
comment in this same PR says it eliminates, one layer over.

`tools.workflowsEnabled` is the first workspace-restricted key with
`showInDialog: true`, which is what puts it in `getDialogSettingKeys()` and
therefore in `getAllowedKeys()` — the two pre-existing restricted keys are
`showInDialog: false` and never reached the API.

Both POST handlers now call one shared `rejectWorkspaceRestrictedWrite`,
answering 400 `workspace_restricted_setting`. One helper rather than two
copies, for the reason the previous commit collapsed the warning/strip pair.
User scope is untouched — that scope honors the key, and a guard that reached
it would kill this PR's whole enablement path.

Verified: workspace-settings 22/22, settings 169/169, settingsUtils 85/85.
Mutation-checked three ways — dropping either call site fails a test, and
widening the guard past workspace scope fails the user-scope test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-08-23 00:33:51 +00:00
Shaojin Wen
f829a02896
feat(review): validate Aone inline anchors against the captured diff before posting (#9634)
* feat(review): validate Aone inline anchors against the captured diff before posting

Aone Code performs no server-side anchor validation — a controlled probe
(scratch MR 29427547, a1 v0.2.51) proved any --line integer posts, and
an old-side number silently lands on the same-numbered new-side line.
The old side cannot be anchored at all, and file-level comments drop
their path.

Pin the removed-line semantics for the Aone write path: submit's Aone
branch now validates every well-formed inline anchor against the
review's captured diff before posting. An unanchorable Critical is
relocated into the summary body, an unanchorable Suggestion discarded
and counted — the GitHub 422-recovery dispose, performed in code — each
disclosed in the terminal. A missing captured diff refuses the whole
post; malformed shapes (missing path/line, reversed range,
renders-as-nothing) keep their consistency-gate refusals, and a garbage
state.bodyCriticals stands the gate down so compose's pinned refusal
fires. The GitHub path is untouched — its server performs this
validation.

Issue #9615

* fix(review): reject unpostable anchors and unify the Aone gate's shape refusals

- validateNewSideAnchors now rejects the input domain (fractional/zero/negative
  lines and reversed ranges) before the hunk scan, so its verdict can no longer
  certify an anchor the zero-validation Aone platform would post silently wrong.
- Extract the consistency gate's per-comment shape checks into one shared
  predicate (commentShapeProblems) read by both the loud refusal and the Aone
  anchor gate, so a shape the gate disposes is never a refusal the operator
  misses (open fence, start_line-without-side). The path check becomes a type
  check, closing truthy non-string paths that reached the write seam unvouched.

* fix(review): generalise the Aone gate's stand-down and harden its relocated entries

Round-2 review fixes for the Aone anchor gate:

- The stand-down now keys on ANY degrade that touches the payload and
  covers every compose-owned garbage shape: bodyCriticals that is not an
  array of strings, or a suggestionsDiscarded compose's counter refuses.
  The countability test reads compose's OWN acceptance table (toCount,
  exported as the total tryToCount), so the gate's merge and compose's
  counter can never drift — an integer-but-not-safe count now merges
  instead of silently dropping the gate's discards.
- The relocated entry's claim extraction strips a leading marker RUN
  (fixpoint, like every other strip) and treats a fence-delimiter claim
  line as absent — both shapes used to leak raw markers or junk
  delimiters into the posted summary-body blocker line.
- The gate keeps the model-authored comment indices through its removal
  (and floor enforcement keeps them through its own), so the consistency
  gate's refusal names the culprit in the model's payload JSON instead of
  a renumbered position the re-compose loop cannot act on.
- A --dry-run with a missing capture no longer exits 3: it writes
  nothing, so it skips the gate with a disclosure and reports
  wouldPost: false (reason: aone-diff-missing); the exit-3 refusal stays
  reserved for the real write.
- The MULTI_DIFF fixture's second hunk header becomes byte-exact git
  output (@@ -20,0 +22,2 @@, probed against git itself).
- The design doc gains the gate-relocation doctrine (relocated entries
  deliberately inherit the model's own tag-exemption treatment), the
  dry-run carve-out in the failure-shape table, and the corrected
  fence/one-line-channel claim.

* fix(review): close the anchor-gate witness gaps and a footer-leak in the relocated entry

Gap-fill on top of the round-2 gate hardening:

- The relocated entry's claim extraction strips the canonical footer
  FIRST: with an empty claim line (a marker-plus-separator-only body),
  the separator strip eats the newline+colon and the extraction falls
  THROUGH into the appended footer's first line, posting it as the
  claim. Witness added for the placeholder shape.
- Pin the multi-line relocation entry CONTENT (it must cite the claimed
  end line, not the start — the start sits inside the hunk and looks
  fine) and the disclosure naming it.
- Witnesses for the remaining mutant-tested gaps: a range whose start
  sits outside every hunk and end inside (the startLine mapping), the
  dry-run compose parity (preview composes from the gate-corrected
  payload), the suggestionsDiscarded 0 merge boundary, the empty-path
  shape (loud refusal, never a gate disposal), a declared LEFT
  start_side without a start_line, and the equal-boundary range
  (start_line === line, a shape GitHub itself produces).
- The routing suites run from a per-test fixture cwd, so the
  captured-diff seeding and its cleanup can no longer overwrite or
  delete a same-numbered live capture in the real vitest cwd.

Issue #9615

* fix(review): sanitise relocated-entry paths and stand down over any compose-refused bodyCriticals

* fix(review): keep the anchor gate's captured diff when resetting the receipt state

The Aone receipt suite's beforeEach wiped the whole .qwen tree to start
from no receipt — deleting the captured diff the anchor gate needs along
with it. Every post then died at the gate's missing-capture refusal and
no receipt was ever written (ENOENT in the four receipt tests on CI).
Remove only the receipt file; the seeded diff survives.

* fix(review): close the anchor-gate entrances the review rounds demonstrated

Round-3 remediation of the review comments on the Aone anchor gate:

- R3-2 (structural): the BUILT relocated entry is now validated against
  compose's own ingestion (tryIngestBodyCriticals over the single entry)
  before the relocate is disclosed, and any refusal degrades the entry to
  the inert constant `finding — (no path):<line>` — the entrance space is
  unbounded model text and compose's acceptance is the authority, so a
  shape the enumerated guards never anticipated degrades the entry
  instead of refusing the whole post mid-degrade. The demonstrated
  entrance (a lone CR inside the claim: it passes the leading-fence
  guard, compose's CR normalisation then splits the entry and the second
  line leads with a fence delimiter) is covered by a witness.
- Ledger collision: the relocated entry flips to `<claim> — <path>:<line>`
  — the claim leads, so a carried id keeps position 0 and the ^-anchored
  ledger readback matches instead of silently renumbering a carried
  finding as new. Witness asserts the id survives the readback regex.
- R7-1: an explicit JSON null side/startSide reads as ABSENT (defaults
  to RIGHT), the model's idiom for an omitted optional field — never a
  declared old side. Unit and gate-level witnesses.
- R3-3: witness for the non-identity authoredIndices branch — the gate
  renumbers the array, floor enforcement keys on the post-gate array,
  and the remap drops the comment floor enforcement names.
- R4-2: the hostile-paths test gains the \r-bearing path (compose's
  ingestion normalises a bare CR to a line break — the same hostile
  shape as \n; the guard's \r half was unwitnessed).
- R3-5: the design doc states the carve-out — the non-RIGHT degrade runs
  for single-line comments only; a multi-line non-RIGHT comment keeps
  the consistency gate's whole-post refusal; null side is absent, not a
  declaration. The failure-shapes table splits the row accordingly.

Issue #9615

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-22 17:45:27 +00:00
jinye
2172721405
feat(cli): restore each daemon session onto its last selected model (#9687)
* feat(cli): restore each daemon session onto its last selected model

Idle detach currently rebuilds Config from settings.model.name, so session A picks up whatever model session B last switched to.

Fixes #9686

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address session-model persistence review findings

- reader: always select the last assistant record into the restore read
  set so the legacy lastAssistantModel fallback still fires when a
  trailing chat_compression candidate excludes it from the resume read
- recorder: assign currentSessionModel before the awaited write so a
  rewind landing in the pending-write window re-anchors the new binding
  instead of the stale one
- reader/recorder: reject non-string session_model payload fields
  instead of crashing the restore path on malformed transcripts
- protocol doc: describe the session_model append as best-effort, not
  an unconditional consequence of a successful switch
- cli: import RUNTIME_SNAPSHOT_PREFIX/stripRuntimeSnapshotPrefix from
  core instead of duplicating the prefix algorithm locally
- tests: pin the isRuntime/baseUrl payload dimension, the prefix and
  route-mismatch false arms, the neither-field fallback, and regression
  coverage for the two fixes above

* fix(cli): keep daemon session-model restore from failing load

Pre-auth restore skipped the last-assistant fallback, and a recorded qwen-oauth binding could hard-fail load when cached credentials were gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): roll session-model auth retry back onto the settings route

Same-id baseUrl restores and runtime-only settings models were skipping or breaking the fallback, which made load fail on the recorded credential set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep empty daemon sessions from creating a transcript

Recording the session model on newSession wrote a jsonl file before any user content, so close/delete/child-death left the id occupied and listing still showed the empty session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allowlist restored session-model routes against the registry

JSONL baseUrl is only a registry selector, so unknown hosts are dropped before switchModel. Restore also keeps the last valid session_model payload instead of falling through a torn trailing record.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): retry session-model auth after same-id snapshot restore

The retry gate ignored runtime-snapshot identity, so restoring an implicit
registry route off a same-id snapshot looked unchanged and skipped rollback.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 16:33:36 +00:00
Yan Shen
867ded5bd5
fix(sdk): support "auto" permission mode (#9003)
* fix(sdk-python): support "auto" permission mode

* refactor(sdk-python): derive permission-mode validation from PermissionMode type

* fix(sdk-java): support "auto" permission mode

* refactor(sdk-python): derive auth-type and effort validation from type aliases

* chore: rerun CI
2026-08-22 14:25:40 +00:00
jinye
39378ac0a4
feat(serve): restore ask_user_question HITL on session load/resume (#9665)
* feat(serve): restore ask_user_question HITL on session load/resume

Keep a trailing unanswered question votable after daemon load/resume when --restore-ask-user-question is on, instead of closing it as a failed tool result.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): harden ask_user_question restore per review feedback

- acpAgent: defensive restore hint (no `!` lookup; accepts undefined
  session) + normalized id lookup on both cold return paths; session
  test doubles carry shouldHintAskUserQuestionRestore
- bridge: single maybeFireRestoreAskUserQuestionPrompt helper with the
  full admission-time busy predicate (pendingPromptCount +
  goalTurnActive), sync-throw try/catch, no-attached-client gate, fork
  suppression, and hasActivePrompt reflecting an admitted restore
  prompt; child-bound requests carry a suppress meta when the daemon
  already knows it will decline, keeping replay skip and re-hang
  aligned
- Session: restore prompt gated on the config flag; early bail before
  per-turn bookkeeping when history is not restorable; system reminders
  ride the post-answer message; restore turns no longer burn the
  active-todo reminder; a permission timeout on a restored question no
  longer persists the fabricated decline (transcript stays dangling for
  a later re-hang); continueLastTurn declines a restorable question;
  restorable detection reads peekLastHistoryEntry instead of cloning
  the full history
- history-replay-page: isInitialized() guard on the skip probe; dead
  paged-path skip wiring removed
- transcript-replay: skip set matches raw ids after dedup renames
- core: inline orphan-repair preserves the restored AUQ ids; the
  compression side query strips a trailing dangling functionCall; the
  CLI flag is honored only in ACP mode

* fix(cli): skip restore hint helper when the switch is off

Load/resume used to call shouldHintAskUserQuestionRestore on every Session, including test doubles that do not implement it. Short-circuit on argv first so the default-off path stays independent of the restore-only API.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:19:18 +00:00
jinye
af25c45e80
fix(cli): Recover sessions across archive races (#9513)
* fix(cli): report a conversation directory deleted mid-inspection as already gone

A child deleted between the lstat and the realpath, or a root that
vanished mid-inspection, was rewritten as 'identity_changed' and then
surfaced as 'Live conversation directory must be an owned direct child'
— a plain Error with no .code pointing at permissions and symlinks when
the directory was simply deleted. Restore the ENOENT-race -> false
contract of discardEmptyConversationDirectory (QwenLM/qwen-code#9489,
item 4).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): keep conversation metadata reads race-free and parent ids storage-aligned

Item 2 of QwenLM/qwen-code#9489: readExistingMetadata read the location,
read the metadata, then re-read the location and returned undefined on
mismatch, so an archive landing between the probes made lock-free
resolvers report a healthy session as session_not_found. Creation
metadata is immutable, so one tolerant read per state (active first,
then archived) decides deterministically; the location probes are gone
and a path-safety charset gate keeps the joined transcript path a
single segment.

Item 3: the parent-lineage gate required strict RFC-4122 v1-v5 ids
while the store resolves far looser names, so persisted parents written
by older builds (nil, v6/v7, agent-suffixed ids) turned loadable
children into SessionNotFoundError, and the -agent- allowance could
never resolve. Drop the shape gate and let storage resolution decide,
keeping only the self-reference rejection.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): let loads resolve both-states sessions and drop the pre-lock restore scans

Items 1 and 5 of QwenLM/qwen-code#9489.

Item 1: a session persisted in both active and archived states — left
behind by a crash inside archiveSessions — hard-failed ACP session/load
and session/resume with session_conflict while plain CLI --resume kept
loading the active copy. findSessionIdIgnoringCase now resolves the
requested spelling first (and a single both-states candidate) instead
of throwing, and assertSessionLoadable treats 'conflict' as loadable
from the active copy. Mutating surfaces keep refusing: unarchive still
conflicts via assertSessionArchived, and multi-runtime ownership
arbitration stays strict so a conflicted internal copy cannot claim a
session an ordinary workspace serves.

Item 5: both restore handlers ran findSessionIdIgnoringCase twice per
request — once as a pre-lock guard whose result REST discarded and the
ACP twin kept as a stale storageSessionId fallback consumed exactly in
the TOCTOU where the in-lock resolve returned undefined. The pre-lock
guards are gone (the in-lock resolve is authoritative and both handlers
now agree), the exact-spelling fast path removes directory scans from
the common case entirely, and the remaining scan uses async readdir so
a large chats tree no longer blocks the daemon event loop.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve canonical restore conflicts

Canonicalize live task keys before resident bridge operations, and keep known case-conflict responses when the optional storage recheck fails.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): handle case-variant session follow-ups

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve mixed-case live task identity

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): resolve canonical persisted session ids

Batch case-insensitive transcript lookups for multi-thread waits and preserve organization metadata when live and persisted session IDs differ only by case.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve canonical session restore state

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): complete canonical session reads

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): address canonical review findings

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve aliased session organization

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close canonical session review gaps

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close canonical task ownership gaps

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): keep case twins distinct across session pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): isolate alias verification failures

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(integration): align both-states transcript expectation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve mixed-case session ownership

Arbitrate noncanonical live task IDs across workspace runtimes and retain the newest legacy organization alias only for uniquely persisted sessions.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve live task ownership during refresh

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): handle session alias races

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#9513)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(e2e): normalize generated session ids

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): narrow PR 9513 to restore regressions

Drop the review-driven mixed-case expansion and retain only the five regressions tracked by #9489.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): complete active transcript conflict recovery

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): align session conflict assertions

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): align transcript conflict e2e

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#9513)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-22 14:01:59 +00:00
Shaojin Wen
2a99e84169
fix(review): clear the deferred Round-5 findings from the Aone write path (#9604)
* fix(review): clear the deferred Round-5 findings from the Aone write path

The full cleanup of #9579 — the 29 Suggestions deferred from round 5 of
the /review bot on #9491 under the ~5-round rule (Criticals-only from
that round on). One item (the GH_HOST setGhHost assertions) was already
landed with the round-5 Critical fixes; the rest are implemented here.

Write-path fixes:
- A shaped-but-empty --host refuses with its own shape (host-flag-empty)
  instead of collapsing to the unbound refusal the flag was the remedy
  for — the agent re-run loop the refusal wording exists to break.
- An invalid host (recorded verbatim or flag-typed) refuses in the
  exit-3 shape naming the offender and its origin, instead of setGhHost's
  TypeError escaping runSubmit as a failed command.
- A flagless gh post whose nothing-bound routing would inherit an
  ambient GH_HOST pointing at canonical Aone refuses actionably
  (ambient-gh-host-aone) instead of failing opaquely after compose ran.
- The shared authorisation gate no longer reads an absent host as a
  github.com claim for callers whose routing follows the recorded
  binding (submit): the ordinary flagless publish of a GHE-recorded
  review passes, while publish-assets keeps the strict comparison.
- Mid-batch drift disclosure rides the partial-post shape too
  (headMovedDuringPost on AonePartialPostError, warned from submit's
  partial branch), and the post-batch re-read is tri-state: a failed
  re-read leaves headMovedDuringPost undefined and submit discloses
  "could not re-verify" instead of a false all-clear.
- The Aone success JSON surfaces postedCommentIds/summaryCommentId —
  the audit the partial shape carries and the gh receipt records.

Docs and contract fixes:
- The context-unavailable cap wording now says what it does (keeps an
  Approve verdict at Comment; a Request-changes verdict still posts)
  in the user docs and both SKILL.md sites.
- The head-drift bullet is qualified by the per-review restart bound —
  spent on Aone there is no submit-at-reviewed-SHA fallback; report and
  leave the rest to the user.
- Step 9's Posted: contract admits the no-link note the Aone fallback
  prescribes.
- The --host help text spells both canonical Aone hosts out.
- The provider design doc's Phase-3 "refuses" sentence is marked
  superseded.

Test hardening (unfalsifiable pins made falsifiable):
- ensureAoneAuthenticated ordered before the writes; setGhHost ordered
  before the gh write; the a1 path never touches the gh host state.
- Live-probe cells for the explicit-flag precedence, the unbound
  refusal, and the fast-path hostless refusal; the recorded-binding-
  outranks-probe fixture driven through submit's real gitOpt seam.
- submit.test.ts mocks ./lib/git.js (no real git spawned in the vitest
  cwd), isolates the cross-session suite's recording store via chdir,
  and pins the newest-wins ordering when two recordings of one PR carry
  different hosts.
- Producer-side 'refusing to post:' prefix pins, the RC-Note count
  source pin, the contextUnavailable:true gh-path pin, and the floor
  recovery's callerHost pin.

* fix(review): address round-1 findings on the Aone write path (#9604)

* fix(review): address round-2 findings on the Aone write path (#9604)

Extract one refuse helper for submit's seven exit-3 refusal shapes
(sibling publish-assets precedent), align the Aone pre-write refusal
prefix with the other refusal paths, and pin the invalid-host remedy
of the flag/origin arms positively — the recorded arm's absence pin
alone let a ternary-collapse mutant ship green.

* fix(review): address round-3 findings on the Aone write path (#9604)

Make submit's exit-3 refusal terminal: refuse now throws a SubmitRefusal
that runSubmit's single catch renders into the refusal shape (stderr
line, posted:false JSON, exit 3), so a gate that says no cannot fall
through toward the write — the helper previously returned and relied on
every call site adding its own `return;`. Also extract the post-batch
MR-head re-read, duplicated between submitAoneReview's partial-post and
success paths, into one helper.

* fix(review): address round-4 findings on the Aone write path (#9604)

* fix(review): address round-5 findings on the Aone write path (#9604)

* fix(review): address round-6 findings on the Aone write path (#9604)

* fix(review): resolve merge-conflict residue in the review skill (#9604)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-08-22 13:27:51 +00:00
Shaojin Wen
0c36e5093a
feat(review): close Aone residual gaps — composeUrl, test-plan routing, a1 version floor (#9624)
* feat(review): close Aone residual gaps — composeUrl, test-plan routing, a1 version floor

The three residuals #9619 tracks together, one pass:

- composeUrl joins the platform reader: GitHub composes the PR-page URL
  from the routed host (deterministic grammar, no API call); Aone is
  reader-backed — the platform's own detailUrl, never assembled, since
  the nested-group owner/repo collapse can name a different repo.
  submit fills a receipt that carries no url through it on both
  platforms, so the skill's prose fallback shrinks to the coordinates
  relay for the one case the reader cannot serve.
- test-plan's body fetch routes through the platform reader: the MR
  description on Aone (already in the reader's fetch metadata — no new
  API surface), so the Test Plan check runs on Aone targets instead of
  going unchecked on every run.
- ensureAoneAuthenticated enforces the a1 version floor design-doc Q1
  asked about — 0.1.90, the version the platform facts were probed
  against — in presence → floor → auth order, each with its own remedy
  message; unreadable versions are disclosed on stderr and fail open.

Verified: ~590 targeted unit tests, tsc/eslint/prettier clean, build +
bundle green, and a CLI smoke that refuses a fake stale a1 at the floor
while a fake fresh one passes the gate.

* fix(review): apply round-2 review on the Aone residuals

All six round-2 suggestions on #9624, probe-verified and pinned:

- R1-1: the version-probe fail-open now discloses the CAUSE — the
  extraction mirrors the whoami catch (first non-empty line past the
  execFileSync preamble), so segfault / unsupported flag / permission
  failures stay distinguishable instead of one constant preamble line.
- R1-2: aoneReader.composeUrl discloses a failed lookup on stderr
  before degrading to '' — every other fail-open in the provider
  discloses, and the coordinates-relay case must stay distinguishable
  from an environment fault.
- R1-3: one home for the PR-page host spelling — normalizeGhHostForUrl
  in lib/gh.ts, shared by compose-review's comment anchors and the
  reader's composeUrl, so a `--host GHE.Corp:443` run can no longer
  print two textual spellings of the same PR page; non-default ports
  survive.
- R1-4: submit no longer re-queries the reader when the Aone receipt
  carries no webUrl — detailUrl is a stable MR attribute and the
  pre-write drift-gate read already carried it, so the second fetch
  could only block on the flaky state that lost the field. The empty
  receipt rides the coordinates relay; the reader keeps composeUrl as
  the canonical seam.
- R1-5: the Aone body-fetch route runs the same ensureAuthenticated
  gate every other a1-backed flow runs first — a standalone test-plan
  on a missing/stale/logged-out a1 now fails exit 1 with the
  install/upgrade/login message instead of exit 0 with the generic
  note. The GitHub arm keeps its historical degrade.
- R1-6: the handler wiring (the Aone fix's integration point) is
  pinned by handler-level tests — an Aone --host must route the body
  through the reader with the gate first, and a refused gate fails the
  command before any fetch.

SKILL.md's Posted paragraph, its revert-guard pins, and the design-doc
bullet follow the R1-4 semantics. Verified: tsc/eslint/prettier clean,
759 targeted cli tests + 23 SKILL guards green.

* fix(review): apply round-3 review on the Aone residuals

* fix(review): apply round-4 review on the Aone residuals

* fix(review): fail closed on unknowable host in composed receipt link (#9624)

* fix(review): route explicit GHE-family hosts to the GitHub reader

Platform detection selected Aone on ANY *.alibaba-inc.com host for
explicit --host/--remote signals, but a family host that is not the
canonical pair (ghe.alibaba-inc.com is the live example) is a GitHub
Enterprise instance — such a review authenticated against a1 and read an
unrelated same-numbered Aone MR instead of the GitHub PR body (and
test-plan additionally gated on a1 auth first). Explicit signals now
select Aone only via the canonical pair (code./gitlab.
alibaba-inc.com) — the same canonical-only rule the write gate has
always applied — while the family predicate survives on the
no-explicit-signal cwd-origin fallback. parse-args stops refusing
/pull/ URLs on GHE-family hosts (the same predicate misapplied: those
are real GHE PR URLs), and the five --host describe texts now name the
canonical pair.

Pins: registry.test.ts flips the GHE explicit-host expectation to
github, adds the explicit-remote and canonical-port arms, and keeps the
family cwd fallback; detection-side 592 + write-side 322 tests green.

* fix(review): fail closed on family-only /codereview/ URLs

AONE_CR_URL_RE captures the whole *.alibaba-inc.com family (shape-first
grammar), but a family-only host is a GHE instance that serves no
/codereview/ page: accepting its URL as a live target would let
detection route the explicit GHE host to GitHub and aim fetch/submit at
GHE PR #<id> — a target the supplied URL never named as a valid GHE
resource. The classifier now gates the aoneMatch branch on
isAoneCanonicalHost, so non-canonical /codereview/ inputs stay
invalid-url, mirroring the /pull/-on-canonical-Aone refusal. The mirror
arm is pinned too: a /pull/ URL on a family-only host parses as the
real GHE PR target it is.

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 12:55:25 +00:00
ytahdn
6bbb273a86
perf(web-shell): optimize streaming transcript rendering (#9672)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
* perf(web-shell): optimize streaming transcript rendering

* test(web-shell): pin streaming fast paths

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-08-22 06:12:54 +00:00
易良
7703d1c310
docs: classify architecture invariants by enforcement mechanism (#9152) (#9689)
* docs: classify architecture invariants by enforcement mechanism (#9152)

Record the policy decision asked by #9152: which architectural invariants
are enforced mechanically, which are left to review, and which are not
worth enforcing. Covers every invariant asserted in AGENTS.md and every
open architecture issue (#8084, #9145, #9146, #9151, #4063).

The drift-guard decision: do not extract a reusable framework from
check-voice-guard-sync.js. The two new drift guards
(cross-package-contracts.test.js and approval-mode-drift.test.ts) share
a pattern but not enough structure to justify abstraction. The pattern
is documented for copy-when-needed.

* docs: correct guard references and complete the invariant inventory (#9152)

* docs: fix #9145 attribution and drift-guard line counts (#9152)

* docs: classify Web Shell UI conventions and reclassify Node engines (#9152)

* docs: cover the full Web Shell convention set without a count claim (#9152)
2026-08-22 02:47:53 +00:00
Shaojin Wen
7bc0d80998
fix(review): audit Aone targets in cleanup's bypass tripwire (#9633)
* fix(review): audit Aone targets in cleanup's bypass tripwire

Step 9's bypass audit already flags same-account writes on GitHub that
bypassed `qwen review submit`, but Aone targets had no tripwire at all —
cleanup audited them against GitHub (a hostless report hit github.com's
same-named repo; a recorded Aone host pointed gh at a host it has no
auth on). Route the audit by the fetch report's recorded host with the
registry's cwd-origin fall-through, list the MR's comments through the
a1 CLI (default + --resolved union — the default listing hides resolved
comments), and flag any comment the authenticated account posted — or
edited — inside the window that the submit receipt does not vouch for.
Submit now records a commentIds receipt axis (Aone's sanctioned write
posts comments, not a review) on success and on a partial post.

Closes #9617

* fix(review): preserve both receipt axes on the submit receipt rewrite

The submit receipt is keyed by PR number alone but carries an axis per
platform — review ids on GitHub, comment ids on Aone — and each writer
rebuilt the whole file from only its own axis. A submit on one platform
silently erased the ids the other platform's submit vouched for a
same-numbered target, and that platform's cleanup audit then flagged
submit's own sanctioned writes as bypasses. Merge the whole prior
receipt into the rewrite so both axes survive. Also flatten a1's
message-less JSON error object in the audit's skip note instead of
paging its opening brace, tag an unparseable `a1 auth whoami` answer
with the failing command, name the audit's third disclosed residual (an
edit of an unvouched pre-window comment is invisible once its
discussion is resolved), and pin the previously unwitnessed audit
contracts: the receipt vouch's edited-arm exclusion, the Aone
auditSince window boundary, the --resolved union's dedupe, the header
shape, and both footer platform nouns.

* fix(review): tag null whoami answers and disclose audit residuals (#9633)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 01:48:51 +00:00
Shaojin Wen
e2de7d2884
feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search (#9543)
* feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search

When a PR is created from the Web Shell Git dialog, bind its number and
URL to the current session. The daemon accepts the binding through the
session metadata routes (validated at the route, bridge, SDK, and sidecar
layers, with the URL restricted to http(s) since it is rendered as a link
target), keeps it in live memory, and persists it as a per-session sidecar
file so the binding survives daemon restarts and follows the session
through archive/unarchive/delete.

The sidebar renders a #N badge next to the session title (opening the PR
via the desktop-aware external-link opener, shows the PR in the details
tooltip, and the session search now also matches PR number, branch name,
and worktree slug — so with many concurrent sessions, the one that
produced a given PR is one search away.
EOF
)

* feat(web-shell): Support multiple PR bindings per session

A session can produce several PRs (stacked or follow-up work), and
keeping only the latest binding would defeat the sidebar's
search-by-PR-number flow for every earlier one. The binding is now a
bounded list (10, oldest dropped) ordered by binding time: re-binding
the same number refreshes it and moves it to latest, the badge shows
the newest number with a +N overflow, the tooltip lists every bound
PR, and search matches any of them. The write API stays single-binding
per call; reads, SSE events, and responses carry the full list, with
the sidecar as the complete history merged over the live entry's
daemon-lifetime bindings.

* feat(web-shell): Show PR badges in the session overview and picker dialogs

The mission-control overview panel and the shared session picker row
(resume / delete / release dialogs) now show the same PR badge as the
sidebar — latest number with a +N overflow, opening the PR via the
desktop-aware opener — and the resume dialog's search matches bound PR
numbers, branch names, and worktree slugs through the shared
sessionMatchesGitQuery helper.

* fix(web-shell): Match the overview PR badge color to the sidebar accent

The overview card badge used the panel's neutral --primary tint while
the sidebar and picker badges use the accent violet; one element should
read the same on every surface.

* fix(web-shell): Address review findings on PR bindings

Read/display correctness (verified by ytahdn and the R1 review):
- mergeLiveSessionSummary merged {..existing, ..live} wholesale, so a
  live entry's this-daemon-lifetime prs overwrote the sidecar-enriched
  full history after a restart; prs is now merged by PR number (live
  url wins, history kept), and the dead merge branch in
  enrichPrSidecars is gone.
- The pr-only session_metadata_updated event carried no displayName,
  which SDK folds treat as "cleared" — the title blanked on every PR
  bind. The producer now echoes the current name.
- GitDialog synced sessionIdRef from the prop on every render, so the
  fresh session id the dialog resolves for its own side queries was
  clobbered before the binding read it; the sync now runs only when
  the prop changes.

Robustness:
- upsertSessionPr's read-modify-write is serialized per sidecar path,
  closing the concurrent-bind drop race under runSharedMany.
- The REST routes persist the sidecar before mutating the bridge, so a
  failure on either side leaves the binding durable.
- The ACP dispatch only upserts when the call actually binds a PR (a
  displayName-only rename no longer rewrites createdAt/order).
- pr.url is capped at 2048 chars across all four validation layers.

Structure & a11y:
- The three badge copies (sidebar / overview / picker) are now one
  SessionPrBadge component: shared CSS, count-aware aria-label,
  non-http(s) entries filtered defensively, and tabIndex=-1 inside
  listbox options.
- The SDK's duplicated PR validator is a single session-pr module used
  by both DaemonClient and events.
- Delete/Release dialogs' search matches bound PR numbers like Resume.

Tests: list-level live+sidecar prs merge, sidecar-vs-bridge echo
authority, route tests made order-independent, bridge
atomicity/cap/catalog-revision/displayName-echo cases, concurrent
upsert serialization, SDK fold keeps the name, GitDialog bind-failure
degradation, url-cap rejections at every layer.

* fix(core): harden session pr sidecar persistence and moves (#9543)

* fix(serve): align session pr echoes with the persisted sidecar (#9543)

Address round-4 review findings:

- R4-1 (Critical): bridge entries are re-created without prs on daemon
  restart / close / archive-restore, so ACP and REST metadata updates
  replied and broadcast only this daemon lifetime's bindings, silently
  dropping persisted history. Hydrate the entry from the sidecar before
  the mutation (new optional bridge seedSessionPrs) and make the ACP
  handler reply with the authoritative persisted list like the REST
  routes, fixing both the response and the session_metadata_updated
  event on all three surfaces.
- R4-2 (Critical): the non-live metadata fallback persisted the rename
  before the PR sidecar while bumping the catalog revision only after
  both writes succeeded; a failed sidecar write stranded a durable,
  unannounced rename behind a total-failure response. Persist the
  sidecar first so a failed write leaves nothing durable behind.
- R4-3: map InvalidSessionMetadataError in toRpcError to the REST
  invalid_metadata contract instead of an opaque -32603 Internal error.
- R1-5: add the stderr audit record for pr binding mutations, mirroring
  the displayName branch (accepted in the round-2/3 thread).
- R2-4 (source part): make enrichPrSidecars' archiveState required so a
  future archived-listing call site cannot silently enrich from the
  active chats dir.
- R2-16: filter non-openable URL schemes in the session details tooltip
  exactly like SessionPrBadge.

* chore(desktop): regenerate bun.lock to match workspace versions

Main's desktop lockfile drifted: @craft-agent/electron and
@craft-agent/shared are 0.0.5 in the workspaces but 0.0.1 in the
lockfile, and the @qwen-code/live-host workspace entry is missing.
bun install --frozen-lockfile (the Live Host CI gate) fails on any
PR touching packages/sdk-typescript/src/daemon/types.ts because of
this. Regenerated with bun 1.3.x.

* fix(web-shell): address R2 review findings on PR bindings

- SessionPrBadge: narrow onKeyDown to Enter only so the badge no longer
  blocks roving-listbox navigation keys in picker dialogs (R2-15).
- SDK updateSessionMetadata: per-entry prs shape gate so a hostile or
  buggy daemon response cannot surface javascript: urls or malformed
  numbers downstream; valid entries survive (R1-17).
- Tests: bridge mirror atomicity (valid pr + invalid displayName),
  GitDialog stale-id retry binding, list-level merge dedupe by number,
  organized + archived listing paths keep PR sidecars, DaemonClient pr
  request/parse + gate, Delete/Release dialog PR-number search (R2-6,
  R2-13, R2-14, R1-15, R1-17, R2-20).

* fix(serve): address R5 review findings on PR bindings

Best-effort hydration (R5-1/R5-2/R5-3): the sidecar hydration read at
all three metadata-mutation sites (ACP dispatch, primary and workspace
REST routes) now absorbs non-ENOENT I/O errors as "no sidecar" instead
of failing the whole call — a squatted sidecar path no longer turns a
pr-less rename into a 500/-32603. This also makes the R4-2 fallback
ordering test reach the branch it names.

Validation hardening (R5-4): pr.url rejects control characters at all
four layers (bridge via hasControlCharacter, route, SDK guard, sidecar
reader) — the url is interpolated into the stderr audit line, so a
newline-bearing url could forge audit records.

Traversal parity (R5-5, R4-5): the ACP session/update_metadata handler
now gates on isValidSessionId before any sidecar I/O, and the primary
REST route's gate moved ahead of runtime resolution so traversal ids
get 400 invalid_session_id identically on single- and multi-workspace
daemons (previously 404 on multi-entry registries).

Tooltip (R5-6): PR rows key on index+number — a hand-edited sidecar
with duplicate numbers no longer risks cross-row reconciliation.

Tests: FakeBridge callLog pins seed-before-mutate order (R5-9);
cross-workspace pr sidecar lands in the owning workspace's chats dir
(R4-4); multi-workspace traversal test; metadata-filtered listing
keeps prs (R1-15); Resume dialog PR-number search (R2-20); dialog
fixtures annotated DaemonSessionSummary[] (R5-7/R5-8); control-char
rejection cases at bridge and sidecar layers.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 01:30:31 +00:00
易良
7a4566cb3b
fix(core): clarify Git requirement for public extensions (#9680)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* fix(core): clarify Git requirement for public extensions

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): preserve secure Git version boundary

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-21 17:05:02 +00:00
Shaojin Wen
f1d05b79fc
feat(review): detect self-MR on Aone targets in presubmit (#9629)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(review): detect self-MR on Aone targets in presubmit

The self-PR verdict downgrade existed only for GitHub targets: the Aone
read path skipped presubmit entirely, so a review of one's own MR
silently carried the weight of an independent review (#9616) — exactly
the wrong direction for the most common local Aone flow, re-reviewing
one's own CR before the next amend.

presubmit now routes by platform. On an Aone target it compares the
authenticated account (a1 auth whoami) against the MR author from one
mr view fetch — case-insensitive, fail-soft on a deleted author,
fail-closed on an unreadable MR — and emits the same report shape with
the unbacked slices neutral (CI classification and comment dedup have
no Aone backing yet). The same fetch backs head drift via sourceBranch,
and a malformed pr_number/owner_repo stays a usage error rather than a
metadata blip. SKILL.md runs presubmit on Aone targets instead of
skipping it, and the "self-PR detection has no Aone backing" caveat is
gone from the skill and the user docs.

* fix(review): unify Aone live-head reads and the presubmit whoami gate

The round-1 review of the Aone presubmit found four seams the new path
had hand-derived a second time; each is now stated once:

- The self-PR comparison (including the load-bearing `author !== ''`
  guard) existed as two inline copies in presubmit.ts; isSelfReview
  states it once for both platform paths so a future normalization rule
  cannot diverge one platform silently.
- "An Aone MR's live head is mr view's sourceBranch" was hand-derived
  in five places in aone.ts, two of them untrimmed: a padded server
  value then manufactured a phantom "PR head advanced during review"
  (and a submit-time refusal) against the trimmed reads, for an MR that
  never moved. aoneHeadSha states the fact once; all five sites route
  through it, repairing the two untrimmed copies.
- The "same report shape as GitHub" invariant was convention only; both
  presubmit result literals are now typed against one PresubmitReport
  interface, so a field added to the envelope is a compile error on the
  path that forgets it instead of a silent toBool(false) at the
  consumer.
- The Aone path spawned `a1 auth whoami` twice per run (plain gate +
  JSON account read). The gate now runs the JSON whoami once and
  returns the account: one spawn per run, and no account fetch remains
  after the MR fetch that could throw uncaught and orphan the graceful
  metaUnavailable report — the fail-closed path pays no a1 work after a
  thrown mr view.

The padded-head regression cells for getPrMeta/getFetchMeta fail on the
pre-round code; the empty-guard, single-spawn, and report-shape
witnesses each fail under mutation probes. 4143 review tests green.

* test(review): pin Aone presubmit auth-gate throw path (#9629)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 14:15:27 +00:00
Shaojin Wen
04886c4354
fix(review): make the incremental cache work for Aone AGit-Flow CRs (#9630)
* fix(review): make the incremental cache work for Aone AGit-Flow CRs

* docs(review): qualify the Aone no-ancestry claims in comments and docs

The D7 comments described the ancestry gate as unconditional and both
ancestry tests as failing for every AGit-Flow update; the head test
alone fails for every amend (the clamp fires only on amend-plus-rebase),
and the narrowing join never lets a drift byte reach the published
scope. Qualify the ledger.ts SHA_RE block, the resolveIncrementalAnchor
docstring, the clamp-skip and call-site comments, the test-block
comments, and the design/user docs accordingly.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 11:06:59 +00:00
Shaojin Wen
0dd518f950
feat(review): disclose that Aone posts join the discussion gate only (#9625)
* feat(review): disclose that Aone posts join the discussion gate only

Aone has a dedicated ai_comment merge gate for AI-posted review
comments. A controlled probe on a scratch CR (issue #9614) resolved
the design doc's open question Q4: `a1 repo mr comment create` does
NOT auto-set isAiComment for the posting identity (a general and an
inline probe both read back false, re-checked against an async
classifier), and a1 v0.1.90 exposes no flag to request it — so
qwen-posted comments sit in the generic discussion gate only, and
the ai_comment gate never tracks them. The same probe re-confirmed
Q3: still no native reject/request-changes on the a1 mr surface.

Until a1 ships a marking flag (feature request to the a1 CLI), the
write path discloses the gate split instead of silently implying
participation: the Aone REQUEST_CHANGES note names the posted
comments as unflagged and the discussion gate as the only mechanical
block, and SKILL.md / the user docs carry the same fact.
createMrComment documents the constraint and is named as the seam
where a future marking flag wires.

* test(review): pin the directional ai_comment gate claim in the Aone disclosure note

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): bind the Aone gate-disclosure pins to content, call, and count source

---------

Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-21 08:17:23 +00:00
zjgzx1988
07405e669c
fix(artifacts): expand recorded directories into per-file artifacts (#9395)
* fix(artifacts): expand recorded directories into per-file artifacts

A generated folder was stored as one file artifact, so Open and Download hit file APIs on a directory and returned 400. Record each file instead, and treat office documents as downloadable artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): address review on directory expansion

Report depth truncation, keep later files on their own turn, and stop one bad filename or a full-content hash from breaking directory ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): tighten directory expansion after review

Use the locator's realpath workspace root, map unlistable directories to validation errors, skip junk roots, and keep walk caps/warnings aligned with what was actually recorded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): drop unused recorded counter

Directory expansion warnings are always surfaced, so the counter tripped no-unused-vars in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): close remaining directory-expansion review gaps

Reject non-object metadata and overlong titles before fan-out, wrap symlink-target lstat as validation, skip junk-only over-deep peeks, and stop offering Download for missing files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): follow up on the latest directory-expansion suggestions

Trim derived child titles, hash expanded files like direct records, only flag over-deep trees that actually contain recordable files, and show missing status on download-only cards.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): reject worktree-root expansion and close remaining review gaps

Directory expansion treated a worktree cwd as a normal subdirectory, silently dropped unrecordable names, and could overflow the metadata budget when stamping expandedFromDirectory. Align the walker, tool, and store so those cases fail or disclose cleanly, and keep the pathless download test covering the collapsed 3-card cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): close junk-path, coalesce, and depth-disclosure review gaps

Reject directory expansion when any path component is skip-listed, including symlink aliases into those trees. Treat unreadable over-depth directories as depth-limited, reject trim-unstable child names, and strip expandedFromDirectory when a curated same-batch record wins coalescing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): use bracket access for expandedFromDirectory metadata

acp-bridge tsc fails under noPropertyAccessFromIndexSignature when the test reads the marker via dot access.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(artifacts): keep worktree directory expansion and close round-7 gaps

Strip the bound-root worktree prefix before the skip-directory gate so ordinary subdirectories still expand in worktree sessions. Realpath every directory walk root to catch intermediate symlink aliases into skipped trees, validate parent title/description before fan-out, and make the new fixtures Windows-safe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): refresh desktop bun.lock for Live Host frozen install

Qwen Live Host CI runs `bun install --frozen-lockfile` with Bun 1.3.9; the checked-in lockfile drifted and fails on main and any PR that touches Live Host path filters. Regenerate with the CI Bun version so install stays reproducible.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 07:46:18 +00:00
Shaojin Wen
94a8c40364
docs(autofix): pin the publish-side checks to the patch text (#9652)
Review feedback on #9525 arrived after that PR was merged, so the notes it
raised never made it into the doc. Both are things an implementer could
get wrong from the text as it stands.

Say what makes the re-derived checks sound in their new position: they are
computed from the patch text on the trusted side and may never re-read a
value the execute job wrote — not a line count it reported, not a
footprint list it declared, not a base SHA it echoed back. This is the
check class that has repeatedly failed to be forgery-proof here, and an
implementation that shortcuts to reading the count the gate already
computed reintroduces exactly that.

Record the sequencing constraint as a constraint: the issue-autofix
follow-up has to land before the deletion pass removes machinery it still
relies on, which is why that pass is per cluster.

Cross-reference #9556, which asks the same question on the review side.

Refs #9089, #9525.
2026-08-21 07:39:16 +00:00
Dragon
d0d68c0e8b
feat(cli): extend non-blocking slash commands to more builtins (#9495)
* feat(cli): extend non-blocking slash commands to more builtins

#8130 opted /about, /help, and /settings in to run immediately while
a response streams. Apply the same criteria to eleven more builtins so
local UI controls no longer wait for the active turn:

- UI-preference commands whose saved changes apply through the
  existing settings hooks: /theme, /editor, /vim, /voice, and
  /terminal-setup.
- Read-only status commands: /tools, /lsp, /tasks, /hooks, /docs,
  and /bug.

Commands that submit model turns, mutate conversation state, or read
state the active turn is writing remain serialized, as documented in
the non-blocking slash commands design doc. Each opt-in is pinned by a
unit test.

* docs(cli): scope extended-command claim to the eleven opted-in builtins
2026-08-21 07:24:49 +00:00
Dragon
459c04adaa
docs: document Session Workflow setting (#8554) 2026-08-21 07:24:28 +00:00
Dragon
fad5ae0bab
docs: document inline terminal image previews (#8656)
* docs: document inline terminal images

* docs: clarify inline image behavior

* docs: scope terminal image limits

* docs: clarify IM image delivery support

* docs: separate terminal image guidance
2026-08-21 07:24:20 +00:00
Dragon
e09399e0fb
feat(core): make list_directory opt-in (disabled by default) (#9424)
* feat(core): make list_directory opt-in (disabled by default)

glob covers directory listing in most cases, so list_directory is now disabled by default to keep the tool surface lean. Enable it with tools.listDirectory.enabled=true or by listing it in the coreTools allowlist (--core-tools / tools.core). The plan-mode reminder no longer steers the model toward the tool.

* fix(core): align list_directory opt-in gate with prompts and allowlist parsing

Round-1 review findings on the opt-in gate:

- Normalise coreTools entries with parseRule so specifier forms such as
  list_directory(/src) still re-enable the tool. isLsToolEnabled used
  resolveToolName, which does not strip specifiers, while
  PermissionManager admits the same entry via parseRule — so the
  allowlist accepted the tool and the registry never got it (R1-1).
- Drop list_directory from the plan-mode block error. That message is
  returned to the model as the tool result of every blocked call, so it
  kept steering the model at an unregistered tool even after the system
  prompt stopped doing so (R1-2).
- Remove the now-inert ToolNames.LS entries and the prompt text that
  advertised the tool to built-in agents. The gate also guards the
  forked-registry rebuild path, so these agents were silently stripped
  of a tool their own prompts told them to use (R1-3).
- Say why the tool is missing and how to enable it in the tool-not-found
  message, instead of offering unrelated Levenshtein suggestions (R1-6).
- Cover the alias and specifier forms in the registration tests (R1-4),
  and drop the stale examples from the sub-agents and Java SDK docs,
  where the listed entry can no longer register the tool (R1-5, R1-7).

* fix(core): address list_directory opt-in round-2 review findings

- Reword the skill-review agent task prompt so the inspection guidance
  only references read_file; the run's tool filter has no
  list_directory, glob, or shell, so the old `ls` instruction was
  unsatisfiable for the turn-budgeted background agent.
- Resolve tool-name aliases (ListFiles, ListFilesTool, ReadFolder) in
  the list_directory not-found message so aliased calls get the
  enablement explanation instead of a Levenshtein suggestion.
- Attribute a missing list_directory to the workspace tools toggle when
  the workspace disabledTools set blocks it, since the opt-in setting
  cannot lift that state.
- Drop the coreTools allowlist advice from the enablement message:
  setting tools.core to ["list_directory"] alone would exclude every
  other tool.
- Switch the two remaining sdk-java runTransportOptionsExample copies
  from list_directory to glob; setAllowedTools only adds auto-approve
  rules and never registers a tool.
- Extract the shared fake-server scaffolding in the list_directory
  integration tests into a local helper, keeping the load-bearing
  CLI-flag comment.
- Pin the skill-review agent tools array and assert the learn-skill
  prompt steers to read_file / glob, matching the sibling planners.

* fix(core): only claim list_directory is opt-in-disabled when it is unregistered

The not-found explanation resolved aliases (ListFiles, ReadFolder) before
checking whether the tool was actually absent. The registry is keyed by
canonical names while the lookup that reaches this path resolves legacy
migrations only, so an alias call missed even when list_directory was
enabled — and the message then told the user to switch on a setting that was
already on, hiding the generic path's "Did you mean list_directory"
self-correction.

Gate the branch on the canonical name being absent from the registry, and pin
the two alias combinations that were unpinned: an alias against a non-empty
workspace disabledTools set, and an alias against a registered tool.
2026-08-21 07:24:05 +00:00
Shaojin Wen
7142a4fd93
docs(autofix): design runner-level isolation for PAT-bearing steps (#9525)
PR #9214 tried to close issue #9089 from inside the job by running the
verification gate in an ephemeral container. Eleven review rounds took its
diff from 329 to 2,700 lines while the per-round Critical count rose
8 -> 9 -> 15 -> 19 -> 29; rounds 10 and 11 changed no code and round 11
still produced 14 new Criticals on byte-identical files, six of them
reporting that a site the diff added lacked the discipline the same diff
installed elsewhere. That PR is frozen; its findings are inventoried in
issue #9524.

Two of its results shape this design. The container cannot help, because
the gate's root of trust sits outside it: the digest chain is rooted in
step outputs that the runner parses last-wins at step end, from a file
under attacker-writable RUNNER_TEMP, with no sweep before the staging
step. And the wrapper could never have run post-merge at all, which the
pre-merge skip path hid from CI.

The design moves the boundary from inside a step to between jobs: an
execute job that runs branch code and holds no credentials, and a publish
job that runs no branch code, re-derives its own decision from the patch
instead of trusting the gate verdict, and holds the PAT. Agent process
trees are killed by cgroup lineage rather than by an env marker, and the
in-step enumeration machinery the split makes redundant is deleted rather
than kept.

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 06:49:05 +00:00
Shaojin Wen
2c64ebe980
feat(autofix): audit the approach instead of stopping on growth-budget breach (#9262)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(autofix): audit the approach instead of stopping on growth-budget breach

A growth-budget breach no longer escalates to a maintainer handoff that
stops the takeover. The breach now makes the round a growth-audit round:
the agent audits the PR's approach on two axes — KISS (name a simpler
alternative or prove each piece load-bearing) and minimal change (every
hunk traces to the problem, an accepted finding, or a failing check) —
and records a machine-readable verdict that the verification gate
requires. sound re-arms the counting window at the current size and the
loop keeps solving; drift simplifies first, then continues; conflict is
the only growth path to a human, parked idempotently until a trusted
human responds.

The old divergence ladder (over budget for N rounds and not shrinking →
stop) terminated takeovers whose remaining work could still fit: the
growth it punished was protocol-mandated pinned tests (#9213 stalled at
round 5 with two small Criticals left). A size signal now triggers a
judgment, never a stop.

Design: docs/design/autofix-growth-audit.md

* fix(autofix): update the artifact-list pin for the growth-audit.json upload entry

* fix(autofix): surface conflict verdicts past the failure.md exits and strip verdict forgery channels (#9262)

* fix(autofix): harden the growth-audit verdict pipeline and park wake set (#9262)

* fix(autofix): close the verdict-pipeline forgeries and loop-generated wake entrances (#9262)

* fix(ci): drop the retired divergence rationale records (af-046/af-047) from qwen-autofix.md

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 04:54:07 +00:00
Shaojin Wen
5715782279
feat(review): post --comment reviews to Aone Code via the a1 CLI (#9491)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(review): post --comment reviews to Aone Code via the a1 CLI

The Aone chain of /review was read-only: a review of an Aone MR ran
fully but `--comment` refused. This lands the Phase 3 submit slice —
an authorised run now posts through `a1`: one comment per inline
finding, then the summary comment, and `a1 repo mr approve` on an
APPROVE. Aone has no native request-changes state, so that verdict
posts a blocking summary header and leans on the discussion merge
gate; the terminal names the difference.

Writes ride a no-retry transport (a transient retry after an accepted
write would double-post). The commit_id gate GitHub enforces
server-side lives in the provider as a pre-write head-drift refusal,
and a mid-batch failure reports exactly what landed with exit-3
do-not-re-run advice instead of a retryable throw. The
recorded-but-hostless refusal stays fail-closed, now between two
writable platforms.

* fix(review): count an accepted-but-unreadable Aone answer as posted

The triage review of #9491 flagged the gap: if `a1` ACCEPTED a comment
but answered unparseable JSON, the read-back threw before the post was
counted — the partial-post report undercounted by exactly that comment,
and if it was the first, the do-not-re-run advisory did not fire at
all, so a retry would double-post it.

Split the read-back semantics: an exec failure still propagates (the
write genuinely failed), but a succeeded exec whose answer fails to
parse now degrades to "landed, result unreadable" — counted as posted,
only the id dropped. The fail-open empty-sourceBranch drift gate is
left as the deliberate, tested trade-off it was reviewed as; it is now
named in the Phase 3 design-doc note beside the Q4 follow-ups.

* docs(review): record the Aone write-path trade-offs in the Phase 3 note

Names the two deliberate trade-offs the triage review of #9491 flagged
for the Q4 era: the head-drift gate is fail-open on an empty
sourceBranch, and the created-comment id read-back is best-effort —
plus the tolerant read-back semantics the follow-up fix introduced.

* fix(review): close five write-safety holes in the Aone submit path

The maintainer review of #9491 found five ways the new Aone write path
could post to the WRONG platform or double-post; all five closed:

1. The target-platform-unbound refusal never read the --host flag it
   names as the remedy, so the --user-authorized re-run refused again
   forever. An explicit flag on the re-run is platform proof and now
   lifts the refusal.
2. The write gate compared hosts with raw equality, but Aone is one
   platform under two names (the CR URL records the web host, the
   skill's --host rule carries the git host). Hosts now bind through
   hostsEquivalent, like every other host comparison in remote-match.
3. Platform routing keyed on the family wildcard and the ambient
   GH_HOST, so a ghe.alibaba-inc.com GHE export selected a1, and a
   recorded Aone host outranked an explicit --host github.com (the
   opposite of the registry's documented precedence). Write routing now
   keys on the canonical Aone pair (isAoneCanonicalHost), never consults
   the ambient GH_HOST (reads never detect from it), and lets an
   explicit --host outrank the recorded binding in both directions.
4. a1 takes a comment body as one argv element; Linux caps that at
   131072 bytes while compose-review's cap counts characters, so a long
   bilingual CJK summary died with E2BIG only after every inline had
   landed. A size gate now refuses the whole batch before any write.
5. An accepted-then-died write (timeout after the POST committed, a
   reset mid-response) read back as not-landed, suppressing the
   do-not-re-run advisory and inviting the double-post a1Once exists to
   prevent. Exec failures now count as possibly-landed (ambiguous), so
   the advisory fires even when the count is zero.

* fix(review): harden the Aone submit path per the verify-lane review

The sandboxed-verification review of #9491 (8 Critical, 24 Suggestion)
caught the next layer of the write path; every finding addressed:

Platform selection:
- The unbound refusal now also fires when NO recording exists at all:
  a --user-authorized publish from another directory finds nothing, and
  the cwd probe alone must not pick the platform of an irreversible
  write. Tests that modeled the old cwd-fallback now model evidence:
  args() seeds a recording, session-scoped recordings are seeded where a
  session id is set.
- The gh write rebinds its routing host to the evidence that selected it
  (explicitHost ?? recordedHost) — a recorded GHE host no longer posts
  wherever the ambient env pointed.
- Host comparison is normalised ONCE (case/port/trailing-dot) and shared
  by hostsEquivalent and isAoneCanonicalHost, so the write gate and the
  router cannot normalise differently; a port-bearing CR-URL host no
  longer dies at the gate after the whole review ran.
- The fast-path repo axis binds case-insensitively (GitHub resolves
  owner/repo case-insensitively server-side).
- The cross-session recording scan is last-writer-wins by mtime; the
  NEWEST same-PR recording decides (its host, or unbound) instead of an
  older session's stale host masking the newest recording's hostlessness.
- recordedSeverityFloor binds through hostsEquivalent too — the floor
  recovery no longer silently discards the operator's floor on the
  web-host/git-host alias.

Reporting:
- a1Cause reads the captured stderr, not the execFileSync message: the
  message embeds the full argv — the entire multi-line comment body — so
  parsing it surfaced the operator's own review text, never a1's error.
- The REQUEST_CHANGES terminal note is conditioned on the inline
  Criticals actually posted — a body-only Critical posts no discussion
  threads, so nothing mechanically blocks the merge and the note says so.
- The summary skip-guard keys on the posted summaryMessage, not the raw
  body: an empty-body REQUEST_CHANGES still posts its blocking header,
  the verdict's sole carrier on Aone; the size gate measures the same
  message.

SKILL.md: the mid-batch bullet no longer commands hand-posting the
remainder (it contradicted the write-monopoly rule); it names the
oversized-comment refusal as the third Aone-specific shape; the
relay-the-link fallback stops assembling Aone links from meta's collapsed
owner/repo (a nested-group hazard) and relays the target's coordinates.

Tests: 24 mutation-driven hardening cells (ordering via
invocationCallOrder, exact argv pins, boundary cells at 131071/131072,
RC-header accounting, stderr-over-message, summary-create failure,
accepted-then-unreadable counts, attribution-off passthrough, url-absence
arm, positive read-retry). 3861 review tests green.

* test(review): pin hostsEquivalent's alias equivalence across spelling variants

* fix(review): close the third-layer holes in the Aone submit path

The third review round of #9491 found the layer under the last one;
every finding addressed:

Platform selection:
- The cwd arm of the write gate probes the origin through the CANONICAL
  predicate itself instead of delegating to the registry's family-wildcard
  detection: a ghe.alibaba-inc.com origin no longer takes the a1 path.
- submit FORCES context-unavailable into the compose input on the Aone
  path: the cap no longer rides the model-written state, so an omitted
  contextUnavailable cannot compose an APPROVE that the a1 path turns
  into a real platform approval. The docs now say the native approve
  does not fire this phase.
- The floor recovery's host axis binds to the host the write routes at
  (explicit ?? recorded ?? gh fallback): a flagless Aone post no longer
  drops the operator's recorded severity floor.

Failure shapes:
- A mid-batch failure emits "partial": true with the landed counts and
  comment ids: posted:false alone invited a wrapper retry that
  double-posts what landed. The summary's fate is stated when it was the
  write that died.
- A deliberate pre-write refusal (drift, oversized) reads as
  aone-post-refused; an UNEXPECTED pre-write error rethrows, gh-parity —
  nothing landed, a re-run is safe, a recoverable blip no longer loses
  the authorised review.
- The batch re-reads the head once after posting and discloses a
  mid-batch amend (headMovedDuringPost) instead of claiming the pins held.

Text:
- The approve-failure WARNING and the oversized refusal name the USER as
  the manual actor — "by hand" is never an agent action, and Step 7's
  ban now says so.
- SKILL.md: restored the exact subcommand enumeration, qualified the
  cleanup tripwire as GitHub-only, taught the completion contract the
  partial/approved shapes, and documented the repeat-round caveats (no
  dedup backing yet, no self-PR detection on Aone).

Tests: cwd canonical-arm cells (GHE family origin falls to gh), the
forced-cap wiring, structured partial JSON, refusal-vs-failure reasons,
the rethrow cell, missing-sourceBranch key, mid-batch drift disclosure,
and the a1JsonOnce transient-no-retry invariant. 3870 review tests green.

* fix(review): close the residual holes the sandboxed verify report found

The follow-up-2 sandboxed verification of #9491 (155 scripted
assertions, 153 pass / 2 fail) re-measured everything at the new head
and surfaced three findings; all three closed:

- F1R: a failed write exiting with EMPTY stderr (the 120 s deadline
  kill, SIGKILL/OOM, an a1 crash before writing) made a1Cause fall back
  to parsing the exec message — and Node embeds the FULL argv in it, so
  the "cause" quoted a line of the operator's own review body. The
  fallback now reports exit facts only ("a1 failed without stderr (exit
  N) / (signal X)"), never the argv-bearing message. The dominant shape
  (real a1 error on stderr) is untouched.
- F3: the forced context-unavailable wiring on the Aone path survived
  3870 tests unpinned — submit's compose now has a cell asserting the
  compose input carries contextUnavailable: true on the Aone path and
  false on the gh path.
- F2: createdCommentId's result/data nestings were correct but unpinned
  (a key-drop mutation survived). A new cell pins ids read back from
  {result:{id}} and {data:{id}}.

* fix(review): close the round-5 platform-binding holes in the Aone write path

- Order the cross-session recorded-args scan by each recording FILE's
  mtime (writeSkillArgs rewrites in place, so the directory mtime never
  advances); fold the publishing session's own recording and the
  sessionless root recording into the same newest-wins ordering instead
  of pinning them ahead of (or behind) the sorted siblings.
- Refuse an explicit --host that contradicts the recorded host
  (target-platform-conflict): the flag fills a gap in the recorded
  evidence, it does not retarget the recorded review at another
  platform's same-named repo. The Aone web/git alias still passes
  through hostsEquivalent.
- Fail closed on a hostless recording read via the --skill-args
  override: the submission cwd's origin probe names submit's clone, not
  the review's, and must not stand in for the missing platform
  evidence; the --host remedy lifts the refusal.
- Bind the gh routing host to the cwd origin when the cwd arm selected
  the platform (and mirror the chain in the floor recovery's host
  axis), so a cwd-selected post no longer restores ambient env
  inheritance and routes past the clone that chose the platform.
- Hand the GitHub path's contextUnavailable claim through raw so
  compose-review's deliberate shape check still refuses a malformed
  non-boolean instead of silently coercing the cap away.
- Serialize the partial-post ambiguous flag in the stdout JSON and give
  the partial shape its own completion-line disposition in the skill —
  never the `not posted` form a retry-on-not-posted wrapper acts on.
2026-08-20 16:27:16 +00:00
callmeYe
4839935e55
feat: register toggle-only Qwen reasoning (#9574) 2026-08-20 11:31:35 +00:00
ytahdn
bf0dbb1ae1
feat(web-shell): support mid-turn file attachments (#9570)
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-08-20 10:58:12 +00:00
jinye
2e6151aa15
fix(serve): Harden standalone conversation primitives (#9512)
* fix(cli): pass ConversationDirectoryIdentityError cause through native Error options

* fix(cli): re-inspect raced standalone directories and report first creation as created

* fix(core): measure JSONL head integrity against a line budget

* fix(core): Preserve plain JSONL record budgets

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-20 08:09:44 +00:00
Shaojin Wen
a8a855914b
fix(review): run verifier probes in a private scratch worktree (#9207) (#9221)
* fix(review): run verifier probes in a private scratch worktree (#9207)

Step 4's verifier is the review's one writing agent: it writes a probe, runs
it, applies the one-line fix its flip-check needs, and restores. All of that
landed in the shared review worktree — the tree `working_dir` pins every other
agent to — and the pipelined loop launches a round's verifiers alongside the
NEXT round's reverse auditors, so those writes are live exactly while the
auditors read. "Leave the tree as you found it", which the brief has always
said and verifiers do obey, cannot close that window: the exposure is *during*
the probe. Measured on a live run, a round-5 auditor read a probe's mutant plus
a leftover probe test and came within a step of filing a Critical against code
no commit contains; it recovered only by improvising `git show HEAD:`, a
fallback no brief mentions.

Three parts, because isolation alone is a guarantee one regression away from
being false:

- `qwen review scratch-tree` gives each verifier shard a throwaway sibling
  worktree at the commit under review, with the review worktree's node_modules
  linked in so a unit harness starts without an install. Every call hands back
  a pristine tree (a previous finding's mutant surviving into the next probe
  would be a wrong verdict carrying a deterministic source tag); the label is
  the shard's record key, because the shards of one round run concurrently; and
  a tree it cannot create makes the probe inconclusive rather than falling back
  to the shared worktree. This is the isolation the test-efficacy probe has had
  since #6832 and the A/B's base tree has, extended to the last step that
  writes.
- Every code-reading brief — dimension agents, chunk agents, reverse auditors
  and the verifier itself — now carries the rule that auditor had to invent:
  the worktree is shared, code that is not in the diff and not in the commit is
  not a finding, and anything surprising is judged against `git show HEAD:`.
- `agent-prompt` reads the tree once per call, and every wave of agents is
  built by it immediately before launch: residue is named in each brief it
  builds and warned about on stderr, so a contaminated tree is announced to the
  agents about to read it instead of being discovered as a phantom Critical.

`exposeDependencies` moves to `lib/worktree.ts` beside the other disposable-tree
machinery, and now farms each workspace member's own node_modules as well as the
root's. Measured on this repo, a tree with 1560 root packages linked still could
not resolve `@testing-library/react` for a UI probe, because npm could not hoist
that copy out of `packages/cli` — which also silently cost the efficacy probe the
same class of test.

`cleanup` sweeps the scratch family by prefix; the label half is the shard's key
and cannot be reconstructed by the sweeper.

* fix(review): address the scratch-tree review — two criticals and the honesty gaps

Both Criticals were real, and both had the same shape: a claim the code made
that one machine class or one leftover state falsified.

- The welded `scratch-tree` command interpolated `--worktree` unquoted, so on
  any checkout under a path with a space or an apostrophe every shard's
  isolation was silently unavailable and every probe fell back to a reading.
  Quoted with `shellQuotePath`, like every other path this file prints into a
  command.
- The reuse gate checked only that the scratch path EXISTS. A bare directory
  there — the leftover of a crashed `worktree add`, or of a cleanup whose
  `rmSync` failed — has no `.git`, so git walked up and ran
  `checkout --force --detach` against the user's own checkout: their
  uncommitted work discarded, their HEAD detached onto the PR's commit, and
  `rev-parse HEAD` then returning the sha that made the reset report success.
  Gated on the tree being a registered worktree, with the regression pinned by
  a test that fails on the un-gated code.

The rest are the same defect class at lower stakes — something stated more
broadly than it holds:

- The residue probe hand-parsed porcelain's rendered form, so a path with a
  space, a non-ASCII byte, or a literal ` -> ` came back as a name matching
  nothing on disk, `--untracked-files=normal` collapsed a whole probe directory
  to one unactionable `dir/` entry, the 1 MB `maxBuffer` default answered the
  dirtiest trees with "clean", and the cap truncated silently while both
  renderers presented the list as complete. Now `-z --untracked-files=all` with
  a 64 MB buffer, and a `{paths, total}` result both renderers disclose.
- `git checkout -- <path>` restores from the INDEX, so the advised recovery
  left staged residue in the tree; it now says `git checkout HEAD --`.
- `git show HEAD:<path>` cannot produce an untracked path — the prototypical
  residue — so the rule now says what that answer means instead of handing the
  reader a command that exits 128.
- Agent 8's `--whole-diff` block reads the same shared worktree and got neither
  the rule nor the residue paths; it is built outside `buildLaunch`, which is
  exactly how it was missed.
- The stderr warning claimed every agent had been told; the block is gated on
  `reviewsCode`, and Agent 7 — which builds and tests that tree — is not.
- A label that flattens to no path-safe character fell back to a shared
  `agent` tree, `git clean -fd` left a nested repo standing while the report
  said the tree was pristine, `dependencies: null` said "no node_modules"
  about a farm that had failed to link, `{0,0}` read as "already in place" for
  a `node_modules` holding nothing linkable, and `--out` was validated after
  the tree and its farm already existed.
- The docs stated the isolation unconditionally: local-diff and file-path
  reviews have no worktree and no scratch tree, and SKILL.md/DESIGN.md and the
  user page now say so.

Tests moved with the code (`exposeDependencies` and `worktreeCreateFailureDetail`
now live beside `lib/worktree.ts`), and the plumbing that was pinned at both
ends but not in the middle — that a verify shard's recorded brief carries ITS
record key as the scratch label — is pinned by a test that fails when the key
is dropped.

* fix(review): close the round-2 review — Windows guard, Agent 7's blind spot, unpinned wording

The Critical is a test that would only fail where this PR's CI does not look:
the new chmod-based case guards on `process.getuid`, which is undefined on
Windows, and `chmodSync` on a directory there sets a read-only attribute that
does not stop `git worktree add` from creating a subdirectory — so the
merge_group-only Windows leg would go red for every PR carrying the file.
Sixteen of the seventeen chmod-permission tests in this repo already skip
win32; this one now does too.

The rest are the same class as round 1 — a claim wider than the code:

- **Agent 7 had no protection for residue that PREDATES the round.** The
  exclusion's justification ("its own commands are the writes it sees") is only
  true for residue a round creates. A tree that starts dirty reaches Agent 7's
  compile and test run, where a `[build]`/`[test]` finding is pre-confirmed and
  skips verification — a merge-blocking phantom Critical of exactly the class
  this machinery exists to prevent. The residue paragraph now goes to EVERY
  brief (with "a defect confined to these paths is not a finding"); only the
  reader rule stays scoped to the roles that review code. The stderr warning
  says to restore before launching the wave, not before the next round.
- **Residue paths reached two sinks unflattened.** `inertPath` moved to
  `lib/paths.ts` and now covers the scratch-tree note and the orchestrator's
  stderr line as well as the briefs — the `-z` format this PR introduced is
  precisely what lets a control byte in a filename arrive intact.
- **The recovery wording could not clear two shapes the probe reports.** A path
  staged as NEW and a rename destination are in the index but not in HEAD, so
  `git checkout HEAD --` cannot match either; both renderers now name `git rm
  --cached` for those and `rm -rf` for untracked residue (including the nested-
  repo directory entry `--untracked-files=all` still cannot expand).
- **The "full set" command was the one this PR calls unusable.** Both notes now
  say `git status --porcelain --untracked-files=all`, since the default
  collapses the probe directory whose files the count came from.
- **`alreadyPresent` believed an empty farm dir.** The dir a previous call
  creates when the source holds nothing linkable is gitignored, so the reset
  spares it, and the second call flipped "no harness will start here" into
  "already in place" with nothing changed. It now requires a non-empty farm.
- Doc corrections: `resetScratchTree`'s header still said `clean -fd`, and the
  residue probe's comment claimed `--untracked-files=all` ends directory-shaped
  entries — it does not for a directory holding its own `.git`. The invalid-
  UTF-8 limit (`encoding: 'utf8'` maps a bad byte to U+FFFD, and no string form
  of that name resolves) is documented rather than papered over.

Pinned, each verified by reverting the fix and watching the test go red: the
apostrophe ESCAPE in the welded command (the fixture had no apostrophe, so a
naive `'…'` wrap passed), the `git checkout HEAD --` wording in both places,
the capped-note arithmetic, the farm-failure note, stdout-before-side-file
ordering and the exit-1 arm it also covers, and `sharedTreeResidueTotal` on the
creation-failure return.

* fix(review): close the round-3 review — hooks, hidden mutants, planted farms, suppression after restore

Four of the seven Criticals are the same discovery from four directions: a
scratch tree is a LINKED worktree and a `clean`/`checkout` reset is not the
guarantee it reads as.

- **Hooks resolve to the user's repository.** `git worktree add` and
  `checkout --force` both fire `post-checkout` from the common dir — the user's
  own `.git/hooks` — so creating or resetting a scratch tree executed whatever
  that repository holds. Every git call this command makes now runs with
  `core.hooksPath` pointed at a path holding no hooks, and the report says
  plainly that hooks, config and refs are shared rather than isolated.
- **skip-worktree hid a mutant through the reset.** `checkout --force` silently
  skips a file carrying the bit and `clean` never touches tracked files, so a
  probe that set it (directly or via `sparse-checkout`) left a mutant that
  survived with `git status` reading empty and the sha still matching. The reset
  now refuses when `ls-files -v` still shows a hidden entry, which routes the
  caller to discard-and-rebuild.
- **The farm was certified by existence.** `clean -ffd` spares ignored paths to
  keep the dependency farm — and equally spares whatever a probe installed or
  planted there. `node_modules` is now the one ignored path a reuse does not
  inherit: it is cleared and re-linked, and `exposeDependencies` marks the farms
  it builds so a directory it did not build is never certified as one.
- **A broken leftover could not be rebuilt over.** A `.git` gitfile whose admin
  entry survives makes `worktree remove` fail and the next `worktree add`
  refuse "missing but already registered"; `discardWorktree` now prunes.

The fifth is about the instruction rather than the tree: the residue paragraph
is baked into every brief at build time, so restoring the paths and then
launching the already-built wave tells every agent to drop findings in a file
that is by then exactly the PR's code. Both the stderr warning and SKILL.md now
say to rebuild the wave after restoring. The remaining two are the three new
real-git fixtures missing `isolateHostGitConfig()`, which a polluted host
gitconfig (`commit.gpgsign` with no key, a `core.hooksPath` hook) turns into
suites that fail for reasons the branch never touched.

The suggestions, in one line each: a rename now reports BOTH of its names (the
restore needs the one that is gone); a `git status` that dies is reported as
UNMEASURED rather than clean, in both renderers; `inertPath` covers `\p{Cf}`
and `\p{Zl}`/`\p{Zp}` — bidi overrides and zero-width characters passed
through the sanitizer that exists to defuse hostile filenames; residue paths
and tree paths are shell-quoted where the notes prescribe commands over them
(`rm -rf my probe.ts` deleted `probe.ts`); the verifier brief no longer says
"then move on" after applying a candidate fix, because one tree serves every
finding in a shard; `scratchLabel` strips a leading dash, which yargs read as a
flag; the member-farm loop guards per member rather than around the loop; the
unguarded `mkdirSync`/`readdirSync` in the farm now count as failures instead of
throwing out of a best-effort contract; cleanup discloses a `readdir` failure
instead of reporting "nothing to clean", and sweeps a dangling symlink
`releaseWorktree` cannot see; the briefs' restore recipe gained the staged-only
branch the scratch-tree note already had; and both "pristine" claims now say
that gitignored paths survive.

Also corrected: "the one agent whose job requires writing" — Agent 7's
efficacy probe writes too, and has had its own tree since #6832.

New tests, each verified by reverting its fix: the hooks suppression, the
skip-worktree refusal, the planted-farm replacement, the prune, staged-residue
detection, the unmeasured state, `inertPath`'s character class, and the
residue reaching every launch class rather than the one role the earlier test
inspected.

* fix(review): close the round-4 review — untrusted workspace paths, member farms, locked leftovers

The sharpest two are about treating the reviewed PR's own manifest as data
rather than as input, in code that DELETES:

- `exposeDependencies` fed workspace dirs from the root manifest of the code
  under review straight into `join()` and then into the farm's opening
  `rmSync`. A PR setting `"workspaces": [".."]` — or a committed SYMLINK at a
  workspace path, which `readWorkspacePackages` follows deliberately because
  npm does — pointed that delete at a directory outside both trees; in this
  pipeline's layout, at the reviewer's own checkout. Every member is now
  resolved through `realpathSync` and required to be contained in the tree it
  belongs to, which closes the string and the symlink vector together, and a
  member that escapes is counted as failed rather than silently skipped.
- The reuse path wiped only the ROOT farm, so `<tree>/packages/<member>/
  node_modules` survived with its marker and was certified as-is — the same
  hole round 3 closed at the root, one level down, where Node resolves a
  member's imports FIRST. `exposeDependencies` now takes `rebuild`, and the
  reuse path distrusts every farm rather than the top one.

Two more that would have wedged a review:

- `git worktree prune` never drops a LOCKED admin entry, and probe code has a
  shell inside these trees: one `touch` in the admin dir the tree's own gitfile
  names, and every later `worktree add` for that PR fatals "missing but locked"
  — permanently, since cleanup prunes too. `discardWorktree` now unlocks and
  retries with the second `--force`.
- `ls-files -v` and `clean` ran under `spawnSync`'s default 1 MiB buffer, which
  a large repo passes; Node kills the child, the reset reads that as failure,
  and every reuse rebuilds forever.

And a Windows one of the same class as round 2's: the new fixture wrote a file
named `a -> b.ts`, and `>` is reserved on NTFS — the merge-queue-only Windows
leg would have failed at fixture setup. Split, with the arrow half skipped
there (the shape it pins cannot exist on NTFS).

The rest: the residue restore recipe was WRONG for a staged rename's original
name (`git rm --cached` stages a deletion there; `git checkout HEAD --` is
what clears it) and the test that certified it never staged anything — both
fixed, and the test now builds all four real shapes; the reader rule was gated
on `reviewsCode`, which left Agent 0 and the test matrix reading worktree
source without it (the gate is now "every role that judges code", i.e. all but
Agent 7); the stderr warning did not actually carry the rebuild-after-restore
instruction SKILL.md attributes to it; `ScratchTreeReport` dropped the
`unmeasured` state, so a failed `git status` read as clean to a script; the
scoped-package branch linked non-directory entries the top-level branch skips;
`farmDependencies`' catch and its note branch were unreachable once round 3
guarded the fs calls, so they are gone rather than pretending to be a net;
cleanup's family-read failure now fails the run instead of letting it announce
"Nothing to clean", and its dangling-symlink branch no longer swallows a
throwing `rmSync`; the residue note says the names are flattened for display
and where the exact bytes are.

Disclosed rather than enforced, with the reason in the code: the farm's links
are read-write and point into the shared worktree, so a probe that writes
THROUGH one (an `npm rebuild`, a package that writes into its own directory)
lands outside its tree where the residue check cannot see it. Copying the farm
would cost the minutes it exists to save; the verifier's block now says to
replace a link with a copy before modifying a dependency.

New coverage: workspace escape and symlinked-member containment, the self-farm
guard, rebuild-on-reuse at member level, assume-unchanged beside skip-worktree,
the unmeasured state through its renderers, the dangling-symlink sweep, the
family-read failure, and the cleanup mock gaps that made two of those branches
unreachable by construction.

* fix(review): close the round-5 review — scoped prune, real pristine, submodules, symlink gadgets

The findings that hold without assuming an attacker already has a shell:

- **The prune added in round 4 was repo-wide.** `git worktree prune` drops any
  admin entry whose directory is momentarily absent — another shard's
  `worktree add` mid-flight (this pipeline runs discards and adds concurrently
  against one common dir), or the user's own worktree on a volume that happens
  to be unmounted. It now removes the one entry whose `gitdir` file names this
  path, and nothing else.
- **"Pristine" spared ignored paths, and a probe's state lives there.** Its own
  `node_modules` at any depth, a `.tsbuildinfo`, a `dist/` it built and then
  mutated — all survived a reset the report called pristine, and the farm-level
  wipe could not reach them (a member farm whose source has none was skipped
  before the target was touched; the root rebuild was skipped entirely when the
  review worktree had no `node_modules`). The reset is now `clean -ffdx` and the
  farm is re-linked, so pristine means pristine.
- **An initialized submodule was untouched by all of it**: `checkout --force`
  without `--recurse-submodules` leaves its working tree, `clean` never touches
  a tracked gitlink, and `rev-parse HEAD` is the superproject's. A tree holding
  one is now rebuilt rather than reset — a fresh `worktree add` leaves
  submodules uninitialized, so the rebuild is both correct and cheap.
- **test-efficacy reuses one probe tree across every suite run**, and the code
  running in it is the PR's own test code. Round 4 answered this with the
  positive control; the control only covers the green-forcing direction, so the
  farm is now re-linked before each run instead — about a second against a
  540-second budget.

The remaining Criticals all presuppose code execution that this pipeline grants
before any of this code runs (Agent 7 installs and tests the PR; a verifier's
probe is arbitrary code by design), so they do not change what an attacker can
do — but three of them describe gadgets that cost nothing to remove, and they
are removed:

- `cleanup` no longer hands ANY symlink at a family path to
  `git worktree remove`, which would follow it and delete whichever registered
  worktree it points at. It unlinks the link and reports that.
- `resetScratchTree` validates that the tree IS the tree — not a symlink, and
  `rev-parse --show-toplevel` resolving to itself — before running a reset that
  would otherwise land wherever the path resolves.
- `farmNodeModules` lstats its SOURCE root, so a symlink at the review
  worktree's `node_modules` cannot redirect every probe tree's dependencies.

Also: the `dependencies: null` contract now says what it means (no
`node_modules` to link from; a link failure arrives counted, not as null), the
`alreadyPresent` branch of the note is gone with the reuse path that could
reach it, and the residue note carries the flattened-names disclosure its
sibling renderer already had.

New tests: the symlinked scratch path, ignored-state clearing, the submodule
rebuild, and the unmeasured pass-through through the command's own report.

* fix(review): close the round-6 review — committed farms, foreign repos, deinit'd submodules

One finding crosses the line the round-5 reply drew, and it is the important
one: `node_modules` is gitignored by convention, not by rule, so a pull request
can force-add `node_modules/.qwen-review-farm` beside its own module stubs and
`git worktree add` checks both out. The fresh-create path then found a marker
and certified the PR's own modules as the farm this code built — reachable from
PR CONTENT, with nothing executed. Two changes: the marker records the
dependency root it was built from and is only believed when it names this one,
and both scratch-tree paths (fresh and reuse) now rebuild rather than trust
anything found at that path.

The other three that hold on their own:

- **The identity check added in round 5 was incomplete.** `rev-parse
  --show-toplevel` prints the directory the `.git` file sits in, whatever that
  file points at — so a gitfile naming another repository, or a whole repo
  planted at the predictable scratch path, passed it while every command below
  would have run against someone else's objects, refs, hooks and config. The
  tree must now share a common dir with the review worktree.
- **`git submodule deinit` walked through the round-5 submodule gate**: the
  status line goes back to `-` while the submodule's gitdir — its hooks, its
  config, its objects — survives under the common dir and is resurrected by the
  next `update --init`. The gate now reads the commit's gitlinks instead of the
  submodule's state, so a repo with submodules rebuilds rather than resets.
- **The round-5 anti-planting rebuild never reached the positive control or the
  hunk probes.** They defaulted `dependencyRoot` to the probe tree, where
  `exposeDependencies` returns before clearing anything — so the control, the
  run that decides whether ANY mutant verdict is trusted, resolved through
  whatever the baseline's test code had planted.

Smaller, from the same review: a dangling symlink at the target skipped the
rebuild wipe (`existsSync` follows links); a dangling top-level link in the
source was dropped without counting; a stray file under a scope directory was
linked as a package where the top-level branch skips one.

Disclosed rather than fixed, with the count now in the report: the farm mirrors
npm workspace SELF-links, so an import that goes through a package NAME
resolves back into the review worktree's copy and a mutation made in the
disposable tree is invisible to it. Re-pointing them would break resolution
outright for any package whose entry point is a build artifact a fresh checkout
does not have, so `DependencyFarm.selfLinked` carries the number instead of the
layout carrying it silently.

Not fixed, and this is the third round of stating it: the remaining findings
need a prior arbitrary-code-execution foothold (a background process racing the
containment check between resolve and use). The pipeline grants that capability
several steps earlier and by other code — Agent 7 runs `npm ci` and the PR's
test suite — so hardening this command changes which line appears in the trace,
not what is possible. The place to change it is sandboxing the review's command
execution, which is its own design.

Tests: the locked-leftover recovery, the scoped registration drop (a sibling
worktree whose directory is absent survives it), the foreign-repository gitfile,
the deinit-proof submodule gate, and the scope-directory stray file. The
foreign-repo fixture is a CLONE on purpose — with an unrelated repo the reset
fails for the wrong reason and the test passes without the guard it pins.

* fix(review): close the round-7 review — walk-up escapes, identity gates, farm wipe

Five Criticals, each probe-verified on real git before the fix and
mutation-checked after:

- worktreeResidue verifies the path IS a worktree before measuring: with
  the .git file gone, git discovery walks up into the user's checkout and
  answers with the user's own dirty state — fail closed instead (R7-4).
- runScratchTree applies the same --show-toplevel check to the trusted
  --worktree argument before resolving HEAD (R7-3).
- The reuse identity gate rejects a tree whose gitdir equals the common
  dir: a .git symlinked or hand-edited to name it passed every prior
  check while checkout --force detached the user's main HEAD onto the
  PR sha and rewrote the main index (R7-2).
- farmNodeModules detects the rebuild target with lstatSync: a dangling
  symlink a PR committed as node_modules read as absent, skipped the
  wipe, and died EEXIST on every rebuild (R7-1).
- The residue probe's node_modules/dist exclusion is enforced by a
  pipeline-controlled excludes file rather than borrowed from the
  commit's .gitignore (R7-5).

Plus the production-shaped UNMEASURED fixture (R7-19) and the three
remaining stale contract texts on the reuse reset (R6-7).

* fix(review): close the round-8 review — tripwire identity, GIT_DIR redirects, symlink writes

Round-8 Criticals, each probe-confirmed and pinned by a test that goes
red when the fix is reverted:

- The residue tripwire certified a CLEAN status for a repository planted
  over the contamination (`rm .git && git init && commit`): no local
  check can tell a planted repo from the tree it replaced. It now fails
  closed unless `.git` is a gitfile, and reports unmeasured for what
  `git status` cannot see inside committed gitlinks (a non-empty
  submodule directory) instead of clean.
- An inherited GIT_DIR (or GIT_WORK_TREE/GIT_INDEX_FILE/...) redirected
  every identity check at once — both sides of each comparison see the
  same override, so none could detect it. Every git call in the
  scratch-tree lifecycle and the tripwire now drops the redirect
  variables.
- The scratch-tree reuse gate accepted a planted gitfile naming a
  SIBLING worktree's admin entry and reset the sibling: the gate now
  requires the admin entry's gitdir backpointer to resolve to this tree.
- Probe writes followed PR-committed leaf symlinks (mode 120000) into
  the shared review worktree — the positive-control injection, every
  mutant and every hunk restore. Each write site refuses a symlinked
  target as inconclusive.
- discardWorktree handed a symlink at the tree path to
  `git worktree remove --force`, which resolved it and force-deleted the
  registered victim it pointed at; a symlink is unlinked instead.

Plus the record-key comments (chunk id separates --all-chunks shards)
and the orphaned marksOurFarm doc block.

* fix(review): close the round-9 review — gitlink residue, env redirects, probe escapes

Round-9 Criticals, each probe-confirmed on real git before the fix and
pinned by a test that goes red when the fix is reverted:

- worktreeResidue's gitlink blind set was parsed from RENDERED
  `ls-files -s`: default core.quotepath quotes a non-ASCII gitlink
  name into a spelling that never resolves on disk, the entry dropped
  from `blind`, and a contaminated gitlink was certified CLEAN. It now
  reads `-z` (R9-1).
- An exported GIT_DIR redirected every inheriting git call at once:
  test-efficacy's head-sha read, probe resets and revert checkout, and
  base-tree's add and reuse check. Every git spawn in both files now
  drops the redirect variables (R9-16).
- cleanup's symlink guard covered only the scratch family; the three
  named family paths still reached releaseWorktree, whose existsSync
  follows a LIVE link and whose `worktree remove --force` resolves it
  — deleting the registered victim while reporting success, and
  silently skipping a dangling link. The guard moved into the shared
  report path all four families go through (R9-18).
- exposeDependencies' rebuild wiped only the farm-owned node_modules;
  anything planted at another path — packages/node_modules resolves
  before the root farm in a packages/* workspace — survived between
  probe runs and decided their verdicts. Rebuild now wipes every
  node_modules the farm does not recreate, following no links (R9-37).
- NO_HOOKS covered hooks only, not config-driven content FILTERS: a
  planted `filter.<name>.smudge|clean` plus one attributes line — both
  writable into the common dir — executed on every reset and rebuild
  checkout. runScratchTree detects repo-local filter config and
  refuses rather than run it (R9-38).
- The harness never WRITES probe test files, so no write guard ever
  saw one committed as mode 120000 — vitest collected it through the
  link and scored mutants against code the tree never mutated. Probe
  files are checked against the committed index and dropped as
  not-run before any suite collects them (R9-40).
- The probe write guards lstat-checked the LEAF only; lstat resolves
  intermediate components, so a relinked ancestor in a reused tree
  read ordinary and every later write followed it out of the tree.
  The guard walks every component (as safeRmWithin does for deletes),
  and each restore write re-validates immediately before writing —
  a mid-run relink stops the phase instead of writing through the
  link (R9-17).

* fix(review): close the round-10 Criticals — env config injection, sweep scope, probe-set holds

Taken back from the autofix loop, and scoped to Criticals from here: the
findings that hold without assuming a foothold, plus the ones whose fix is a
few lines and removes a step from a chain.

Reachable from the environment or from PR content, with nothing executed:

- **`sanitizedGitEnv` dropped only the discovery redirects.** `GIT_CONFIG_COUNT`
  with `GIT_CONFIG_KEY_<n>`/`GIT_CONFIG_VALUE_<n>` sets any config key for the
  run — `core.fsmonitor` and the `filter.*` pair are command execution — and
  `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_PARAMETERS` reach the same
  place by other routes. A gate on the front door with the window open.
- **`lib/git.ts` never had the gate at all**: `gitOpts()` spread `process.env`
  into every shared helper, `releaseWorktree`'s `worktree remove --force`
  included — the commands that run against the user's own repository, so they
  need it more than the disposable-tree ones that got it first.
- **The residue probe could be steered by the tree it measures**: `core.fsmonitor`
  runs a command on `status`, so the tripwire was an executor. Emptied for that
  invocation, the same discipline the checkouts' `core.hooksPath` already had.
- **`probeTargetEscapes` treated a backslash as a separator on POSIX**, where it
  is an ordinary name character: `x\y.test.ts` split into phantom components,
  the first `lstat` died ENOENT, and the catch reported "no escape" for a leaf
  nobody looked at. It also never checked the tree ROOT, which defeats every
  per-component check at once.
- **The symlink drop reassigned the probe list before the collocated-test hold
  ran**, so a mutant whose own test was dropped answered "there is no such test"
  and was scored `survived` on the strength of the other probes passing — the
  hold exists to refuse exactly that.
- **The rebuild sweep matched `node_modules` case-sensitively** on the two
  filesystems this file already special-cases: `Node_Modules` resolves like the
  real one on APFS and NTFS and survived every rebuild.
- **A tracked path in the residue list suppressed real findings.** The brief
  said "none of it is the PR's code, so a defect confined to those paths is not
  a finding" — false for a file the commit contains and this PR changes: only
  the uncommitted DIFFERENCE is foreign. It now separates the two, and a defect
  present in the committed version stays a finding.

Cheap chain removal, though each needs a prior foothold:

- The excludes file moved from a constant name in the shared temp dir to a
  `mkdtemp` directory written `wx` — git re-reads its CONTENTS on every later
  `status`, so a writable fixed path could blind the tripwire.
- `discardWorktree` now clears the registration named by the TREE's own `.git`
  pointer, read before anything is removed, instead of scanning
  `<common>/worktrees/<id>/gitdir` files — which any same-user process can
  rewrite to aim this cleanup at a live sibling. The scan remains only as the
  fallback for a tree whose pointer is unreadable, which is the case the step
  exists for, and it now refuses entries whose tree still stands.
- The scratch sweep refuses to run at all when its parent is not a directory: a
  symlinked `.qwen/tmp` redirects every delete below it, and no per-leaf check
  can see an ancestor.
- `removeUnownedNodeModules` counts a wipe it could not perform, instead of
  letting a planted directory survive every rebuild undisclosed.

Left as recorded decisions, not re-litigated: R4-5, R6-2 and the R6-5/R9-14/
R9-38/R9-40/R10-25/R10-27 class, which need probe code already running as the
user — a capability Agent 7's install and test step grants several steps
earlier. The remaining Suggestions stay on the deferred queue for a follow-up,
per the triage gate's own call that seven rounds was the moment to land this.

* fix(review): close the round-11 Criticals — the half-fixes from round 10

Five of the eight are residue from the previous round's own fixes, which is the
honest reason to take them:

- **The fsmonitor neutralization covered one spawn of two.** `git status` got
  `-c core.fsmonitor=`; the sibling `git ls-files -s -z` that builds the gitlink
  blind set did not, so the same planted command still fired during the
  measurement.
- **The gitlink blind-set filter failed OPEN.** An absent directory is the clean
  shape `worktree add` leaves; an UNREADABLE one is a place neither `git status`
  nor this probe can see, and both answered "nothing to hide there". Only ENOENT
  is clean now.
- **The wipe walk skipped symlinked directories entirely**, so a `node_modules`
  behind a PR-COMMITTED symlink directory survived every rebuild with nothing
  said. The walk still refuses to follow a link — the farm's own entries point
  out of the tree — but a link resolving back INSIDE the tree that hides
  dependencies is now counted as a failure the caller reports.
- **The sweep's ancestor check stopped at its own argument.** `lstat` refuses to
  dereference only the last component, so a symlink one hop above (`.qwen` over
  `.qwen/tmp`) resolved silently and redirected the same deletes. Every ancestor
  up to the filesystem root is checked.
- **Detection was followed by the damage it detected.** When the mutation phase
  aborts because the probe tree was relinked mid-run, the revert phase ran
  anyway — `git checkout base -- …` and `safeRmWithin` with a cwd resolving
  through the link into the shared review worktree. The phase now re-validates
  the tree first, and `safeRmWithin` lstats its own ROOT, which its docstring
  always promised and its loop never did (a root link resolves the whole prefix
  in the kernel, so every component below it looks ordinary).

Left as recorded decisions: R11-1 (`refs/replace` in the shared common dir),
R11-2's residual fallback path, and R11-3 (content filters during `status`) —
each needs probe code already running as the user, the capability Agent 7's
install and test step grants several steps earlier, and the scan fallback exists
precisely for the corrupt-pointer recovery the earlier rounds pinned.

* fix(review): close the round-12 Criticals — sanitizer gaps, sweep scope, false farm failures

Nine of the twenty-four hold without a foothold, or are defects in the previous
rounds' own fixes:

- **`residue.unmeasured` rode raw into two sinks** while the paths beside it went
  through `inertPath` — and that string is built from `ls-files -z` gitlink
  names, so a PR that commits a gitlink with ESC bytes puts a control sequence
  on the orchestrator's terminal and in the verifier's note.
- **The branch delete never got the env the check did.** `refExists` resolves the
  real repository through the sanitized helpers, while `git branch -D` in
  cleanup and fetch-pr still inherited `process.env`: with `GIT_DIR` exported
  the pipeline verified a branch in one repository and deleted it in another.
- **The ancestor refusal guarded one sweep of three.** It announced that
  `.qwen/tmp` hangs off a symlink and the same function went on to delete the
  base-tree lock and every side file underneath it. The check moved to the top
  of `runCleanup` and now refuses the whole clean.
- **A family symlink that would not unlink released the lease**, unlike the three
  sibling failures that hold it, leaving the next `fetch-pr` to pass a gate over
  an occupied path.
- **The symlink disclosure added last round produced FALSE failures**: it never
  consulted the `owned` set, so a PR-committed `alias → .` counted a failure for
  the farm this very call had just re-linked, and it judged before the walk had
  wiped the target it was complaining about. It now runs after the walk and
  skips owned farms.
- **Probe names were passed to `git ls-files` as raw pathspecs**, so a probe
  committed as `:(literal)x.test.ts` — a legal filename — was parsed as magic
  and its symlink never found. Every pathspec is `:(literal)`-prefixed now, and
  a refused pathspec drops every probe rather than answering "no symlinks".
- **The residue oracle could be blinded by index bits.** `skip-worktree` and
  `assume-unchanged` make `status` answer clean for an edited tracked file — the
  hazard the scratch tree's reset already refuses to certify around, missing
  from the reader-side probe that tells auditors the tree is pristine.
- The excludes file is created 0600 rather than 0644.

Left on the recorded boundary: the filter-gate coverage findings (R12-6,
R12-24), the hardlink and runtime-relink shapes (R12-31, R12-32, R12-72). Each
needs the PR's own test code running as the user — the capability Agent 7's
install and test step grants several steps earlier — and the last three are
properties of executing untrusted tests at all, not of this command.

* fix(review): close the round-13 Criticals — spelling, empty probe sets, raw error text

Five hold without a foothold, and four of them are defects in the previous
round's own fixes:

- **`owned` mixes path spellings by construction** — the tree root as the caller
  spelled it, each member as `containedIn` resolved it — and last round's
  disclosure compared only one of them, so a farm reached through a link
  counted a failure for a directory that same call had just re-linked. Both
  spellings are asked now, through one helper the wipe and the disclosure share.
- **A suite run with an EMPTY probe list runs vitest with no filter**, which
  collects whatever the repository holds and scores it as this probe's evidence.
  After the committed-symlink drop empties the set, the mutation and revert
  gates now skip rather than run.
- **Git's own stderr rode raw into the verifier-facing note** through
  `Error.message`, as did the filter KEY NAMES in the refusal (a git subsection
  name carries any byte but newline and NUL). Same sink class as last round's
  `unmeasured`, flattened the same way.
- **Every refusal that fires before the residue is measured answered with the
  empty list a measured-clean tree produces.** A consumer could not tell "the
  tree is clean" from "this call never looked"; the refusals now carry the
  unmeasured reason.

Left on the recorded boundary, re-checked and unchanged: R12-6, R12-22, R12-24
(config planting), and the R13-9/R13-10/R13-13 family, which need probe code
already running as the user.

* fix(review): close the round-14 Criticals — farm containment, residue blinds, release guard

Five hold without a foothold, and three of them are channels a pull request
controls outright — no same-user foothold anywhere:

- **Farm entries were mirrored link-or-not.** A committed symlink under
  `node_modules` (force-add defeats gitignore) pointing out of it became a
  write channel from the disposable tree to wherever it points — into the
  shared worktree's tracked files — recreated on every rebuild. Entries now
  resolve through realpath before linking; only a borrowed `node_modules`
  or an npm workspace self-link passes, everything else is counted and
  disclosed. (R10-18)
- **The residue probe's untracked view came from `status` alone**, which
  honors ignore rules the contaminator controls: a committed whitelist-form
  `.gitignore` (`*` with `!`-negations) blinded it to contamination. The
  probe now merges `ls-files --others` without `--exclude-standard` and
  filters the pipeline's build artifacts in code. (R10-19)
- **A gitlink named in bytes UTF-8 cannot decode dropped from the blind
  set**: the mangled spelling never resolves on disk, the readdir read
  absent, and a contaminated gitlink certified clean. Such names now fail
  closed into unmeasured. (R11-4, second entrance)
- **The `owned` set the rebuild disclosure asks held one spelling** while
  the disclosure loop presented another — on a host whose tree path carries
  a symlinked ancestor (macOS's `/var` vs `/private/var`) every rebuild
  counted a phantom failure for the farm the same call had just re-linked.
  The set is normalized once, at build time. (R13-1 remainder)
- **`releaseWorktree` followed a symlink standing at the path**:
  `existsSync` resolved it and `git worktree remove --force` deleted
  whichever registered worktree it named — and a dangling one wedged the
  next `worktree add` while invisible. The lstat-first guard cleanup's
  family sweep applies to every path now lives at this choke point, which
  is where fetch-pr's `cleanStale` releases. (R13-3)

Pinned against real git and real filesystems; all seven new tests fail on
the pre-round sources.

* fix(review): skip the invalid-UTF-8 gitlink fixture off Linux

APFS and NTFS both refuse a filename that is not valid UTF-8, so the fixture
cannot be created there — `mkdir` fails ENOENT on macOS, and the round-14 test
reddened for every maintainer on a Mac while the repo's `pull_request` CI, which
runs the unit legs on Linux only, stayed green. The shape it pins cannot exist
on those filesystems either, so the skip loses no coverage.

Same class as the round-2 and round-6 guards (`chmod` semantics, NTFS reserved
characters): a fixture that cannot exist on a platform is skipped there rather
than left to fail in a leg nobody watches.

* fix(review): close the round-15 Criticals — a tripwire that was firing on every healthy run

The important one is a regression this PR introduced two rounds ago, and it is
measurable rather than hypothetical. Round 14 answered "a `.gitignore` can hide
a probe's leftovers from `status`" by merging the ignore-INDEPENDENT untracked
listing into the residue set. Measured on a healthy review worktree of this
repo, after the `npm ci` and build the pipeline itself runs there: `git status`
reported NOTHING and the tripwire reported 3 957 paths — coverage HTML,
`.tsbuildinfo`, husky's installed hooks. Every one of them reached every
verifier and every reverse auditor as contamination, with `rm` and `git
checkout` recipes attached, and real residue would have been three lines inside
that. A tripwire that fires on every healthy run is not read.

What separates the two sets is not the pattern, which is unbounded, but WHO
WROTE THE RULE. An ignore file the commit under review carries is ordinary repo
hygiene that every reader of that tree sees identically; a rule from anywhere
else — `info/exclude`, a `.gitignore` written after the checkout — is the
plant. `check-ignore -v` names the source file per path, so the reconciliation
asks git that question instead of inferring it from the name. One exception,
because it is the whitelist form's whole trick: a rule from the commit that
hides EVERYTHING (`*` with `!`-negations) vouches for nothing and is not
believed. Measured again through the real function afterwards: 3 957 → 0, with
the two files that were genuinely modified at that moment still named.

Also:

- **The index-bit oracle failed OPEN.** Its guard asked for `status === 0`, and
  a spawn that dies leaves `status` null — so the one tree it could not read
  the skip-worktree/assume-unchanged bits of was certified clean, which is
  precisely the tree that can be carrying a mutant `status` cannot see. It now
  fails closed like its three siblings, and takes `-z` so `core.quotepath`
  cannot octal-expand a non-ASCII name past the buffer.
- **The efficacy probe tree carried tracked writes between runs.** Re-linking
  `node_modules` before each run (round 5) covers half of what a run leaves
  behind; tracked files are the other half and the more direct one — a suite
  that rewrites a probe file after vitest has collected it stays green for its
  own run and hands every later run a file of its choosing, and the verdict
  that buys is `killed`: "a test catches this", asserted for statements no test
  covers. Every run now opens by putting the tree back to its commit, guarded
  by the same identity gate the residue probe uses so a directory that is not a
  checkout is skipped rather than having the enclosing repository checked out
  into it.

Two fixtures had to change with it, and both were relying on the hole. The
control-setup test made its point by having the fake runner DELETE the probe
file; a deleted probe file now comes straight back, so it relinks it instead
(the read-failure path itself is pinned directly in the unit suite). The
wrong-line-mutant test wrote its mismatched content without committing it,
which is not a state a probe tree can be in — a probe tree is a detached
checkout — so it commits it.

Each fix was verified by reverting it and watching the new test go red: the
provenance filter, its trust direction, the catch-all exception, the
`.husky/_` footprint entry, the fail-closed oracle, and the tracked-file
restore.

* chore: keep main's package-lock.json

A local `npm install` during the merge dropped six `"peer": true` markers
from the lock. Unrelated to this branch, so it goes back to main's copy.

* fix(review): close the round-16 Criticals — catch-alls, the exec env family, untracked plants

Two of these are holes in last round's own fix, which is the right way round
for them to be found:

- **`hidesEverything` recognised only the `*`/`**` spellings.** `?` matches any
  single character, so `?*` is `*` with extra steps — a committed `.gitignore`
  whitelist written that way vouched for everything it hid, with no execution
  anywhere: it is committed content. The check is now shape-based rather than a
  list of spellings: every segment wildcard-only, at least one `*`. Enumerating
  the spellings one at a time is how the hole got made.
- **The probe tree's pre-run restore failed OPEN on an unresolvable `.git`.**
  The gate skips a directory with no `.git` because there is no commit to
  restore FROM — but `.git` is untracked, so nothing ever restores IT, and a
  guest that overwrites it once bought "proceed, nothing to put back" from
  every later phase. Present-but-unresolvable is now a failure, which is the
  same discipline round 15 applied to the residue probe's index-bit oracle.

And three that stand on their own:

- **`safeRmWithin` split the PR-controlled path on backslashes unconditionally.**
  On POSIX a backslash is an ordinary filename character, so ONE committed name
  — `\..\review-pr-42\package.json`, legal on POSIX — became three components,
  two of them `..`, which `join` normalises away silently. The revert phase's
  delete then reached the sibling shared review worktree. `probeTargetEscapes`
  already split platform-dependently with a comment naming this hazard: the
  write side had the fix, the delete side did not. Both now, and a `..`
  component is refused outright rather than resolved.
- **`sanitizedGitEnv` closed redirection and config injection and left
  execution open.** `GIT_SSH_COMMAND` and `GIT_EXTERNAL_DIFF` are a command,
  `GIT_EXEC_PATH` moves git's own subcommand and remote-helper lookup,
  `GIT_TEMPLATE_DIR` plants hooks for the next `init`. Not a new judgement
  call: `config/shared-env-keys.ts` blocks exactly this family for session
  subprocesses, with the rationale written out there. The setter need not be an
  attacker — a reviewer's shell profile exporting `GIT_EXEC_PATH` silently
  changes which `git-remote-https` every fetch in this pipeline runs.
- **Nothing removed UNTRACKED files between probe runs.** Round 15 restored the
  tracked half; a `vitest.config.ts` — untracked, because no zero-config
  project commits one — is what a suite reaches for to decide the next run's
  collection. `clean -fd` and not `-fdx`, so the borrowed farm and the ignored
  build output the probes need survive.

Also, the live-symlink test in `git.integration.test.ts` compared git's
CANONICAL worktree path against an un-canonicalized `mkdtemp` path. It passes
on macOS by accident — `/private/var/…` contains `/var/…` as a substring, so
`toContain` succeeds — and would not on a fixture reached through a symlinked
ancestor. It now realpaths, so it passes on purpose.

Each fix was verified by reverting it alone and watching its test go red.

* fix(review): close the round-17 Criticals — provenance, absent .git, ignored plants, ancestor links

Three are holes in the two previous rounds' own fixes:

- **"Tracked" is not "unchanged".** The round-15 provenance test asked
  `ls-files` whether the ignore file's PATH is in the index, so a `.gitignore`
  the commit carries went on vouching for rules appended to it after the
  checkout. The status set the same function already computed answers this: a
  source that appears there has been edited away from the commit, and its rules
  are the writer's.
- **An ABSENT `.git` was read as "nothing to restore".** Round 16 made the
  probe tree's restore fail closed on a `.git` it cannot resolve and left the
  cheaper state open — `.git` is an untracked pointer file inside the tree the
  PR's own suite runs in, so one `rm` bought "proceed" from every later phase.
  Running the restore anyway is not the alternative: with no `.git`, discovery
  walks UP and checks the enclosing repository out into the tree. Refusing is
  the only answer that is neither.
- **The between-run sweep honored the ignore rules.** `clean -fd` skips what
  the commit's own `.gitignore` names, and those rules are the PR's to write,
  so a plant named to match one survived every restore. It is `-ffdx` now, with
  `-e node_modules`: the borrowed farm is the one ignored thing in that tree
  the probes cannot run without, and everything else ignored goes. The two
  restore spawns also empty `core.fsmonitor`, which both of them execute.

And four that stand on their own:

- **`releaseWorktree`'s symlink guard was leaf-only.** `lstatSync`
  dereferences every component except the last, so a link at `.qwen/tmp` left
  every path under it looking ordinary while `git worktree remove --force`
  landed in whatever checkout it named. `runCleanup` refuses its whole sweep
  for this; `cleanStale` releases with no guard of its own, so the refusal now
  lives at the choke point every caller inherits.
- **`runCleanup`'s own ancestor guard ran before a network-bound audit** and
  nothing re-checked it afterwards, though the lease condition beside it gets
  exactly that re-check for exactly that window.
- **The scratch tree's filter screen read the wrong tree's config.** It runs
  against the review worktree, while the checkout it authorises runs in the
  SCRATCH tree, whose own `config.worktree` is honored once
  `extensions.worktreeConfig` is on. The screen now reads every entry under the
  common dir's `worktrees/`.
- **`runOneMutant` and `runControlMutant` had no pre-write escape re-check**,
  while `runOneHunkProbe` — in this same diff — carries one with a comment
  explaining the threat.

`redirectedAncestor` is now one shared function rather than two, and its walk
STOPS at the checkout instead of climbing to `/`: `/var` is a symlink on every
macOS box, so the unbounded version refused every sweep there while reporting
that it had found a redirect.

Two fixtures became real checkouts, because a probe tree is one in production
and a bare `mkdtemp` no longer reaches the behaviour they pin.

Each fix with a behavioural test was verified by reverting it alone and
watching that test go red. The pre-mutation re-checks are window-narrowing on a
check-then-use race and are not pinned by one.

* docs(review): drop the JSDoc left stranded when redirectedAncestor moved

Round 17 moved `redirectedAncestor` into `lib/worktree.ts` and deleted the copy
here, but not the block above it — which then sat on `scratchWorktreesOf`,
describing a function this file no longer has and, worse, describing the OLD
behaviour: "the walk stops at the filesystem root" is exactly what round 17
changed, because `/var` is a symlink on macOS and an unbounded walk refuses
every sweep there.

* fix(review): close the round-19 Criticals — PR-content channels, and the guards this PR wrote for itself

Two are reachable from a PR's committed content with nothing executed, which is
the line this branch draws everywhere else:

- **A committed `vendor -> ../stash` was never counted.** The rebuild walk
  discloses a `node_modules` symlink resolving back INSIDE the disposable tree
  and silently skipped one resolving OUT of it — so the farm reported
  `{linked: n, failed: 0}` while a `node_modules` under the link's target
  survived every rebuild. Node realpaths the importing file, so imports under
  the link resolve in the stash and decide later verdicts. Outside state cannot
  be wiped from here; it is COUNTED, which is what the contract promises.
- **`workspaces: ["."]` widened the self-link whitelist to the whole shared
  worktree.** npm accepts a root manifest declaring itself a workspace and
  creates the self-link itself, `containedIn(root, '.')` answers the root, and
  every `node_modules` link resolving anywhere inside the review worktree then
  passed the verdict and was mirrored into the disposable tree as a read-write
  channel back. A member that resolves to the dependency root is not a member.

Four more are this PR's own guards not being applied to the code beside them:

- **`resetScratchTree` lstat'd only the immediate parent**, on the stated
  premise that `.qwen/tmp` is the one component above the leaf anything here can
  replace. False one hop higher: a link at `.qwen` redirects the whole path, and
  then every check in that gate agrees with every other because they all resolve
  through it — toplevel self-equality, common-dir equality, gitdir ≠ commondir,
  and even the backpointer round-trip. It walks every ancestor now, bounded at
  the repository the common dir belongs to.
- **`restoreProbeTreeTracked` had none of the gates its sibling reset has.** A
  rewritten gitfile pointing at another repository passed `--show-toplevel`
  self-equality and the "restore" wrote that repository's content in; a
  skip-worktree bit made `checkout --force` skip a tampered file silently while
  the function answered "as the commit left it". Both are checks `scratch-tree`
  documents for the identical reset. The backpointer and ancestor checks apply
  to the linked-worktree shape a probe tree actually has — a standalone
  checkout has no admin entry to round-trip and IS the repository root.
- **The revert phase collected probes screened once, from the index, before the
  baseline.** Every run since executes the PR's own test code, which can replace
  a probe with a link; the mutation writers re-check immediately before they
  write and this collection had no equivalent, so a relinked probe was collected
  THROUGH the link and scored against code the revert never touched.
- **`runCleanup`'s symlink branch never pruned.** It returns before
  `releaseWorktree`, which is where the pipeline's only other prune lives — so
  the family paths were unlinked and reported swept while their registrations
  stayed behind to wedge the next `worktree add`.

And two cheap ones with no argument against them: `sanitizedGitEnv` deleted by
exact case, which removes nothing on Windows where env lookup is
case-insensitive (the `shared-env-keys.ts` list it is modelled on folds case for
this reason); and it now sets `GIT_NO_REPLACE_OBJECTS=1`, because one
`git replace <sha> <evil>` in the common dir makes every `checkout --detach
<sha>` here materialise someone else's tree while `rev-parse <sha>` still
answers the original.

Each fix is pinned by a test that goes red when that fix alone is reverted.

* fix(review): close the round-20 Criticals — an empty probe list, a dropped refusal, a symlinked root

The first one is a bug this branch shipped yesterday, and the worst kind: it
turns a screen into a wider run.

- **The revert phase could call `runProbeSuite` with an EMPTY list.** Round 19
  added a screen that drops probes relinked out of the tree after the baseline;
  the `probes.length > 0` gate the phase opens with was taken before that screen
  could empty the list, and `vitest run` with no file argument collects the
  WHOLE suite — so "every probe was tampered with" became "score everything",
  with the verdicts attributed to files the phase never selected. It now stops
  the phase and says which of the two happened, once rather than per file.
- **`cleanStale` discarded `releaseWorktree`'s refusal.** The guard added in
  round 17 declines to release through an ancestor symlink and reports why;
  `fetch-pr` called it as a bare statement, so the sweep looked successful and
  the next `worktree add` wedged at a path nobody was told about. The result is
  read and the reason printed, like every other failure on that path.
- **The probe tree's own root was never lstat'd.** The ancestor walk added in
  round 19 starts above the leaf, and every identity comparison realpaths both
  sides — so a probe tree that IS a symlink into the shared review worktree
  agrees with itself all the way down, and the restore's `checkout --force` and
  `clean -ffdx` would have run in the tree every other agent is reading.

The first is pinned end-to-end by the fixture that already relinks its probes
mid-run: before round 19 it scored that relinked probe `inert` — a fabricated
verdict read through the link — and the assertion now names the phase-level
refusal instead. Reverting the guard alone turns it red.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-20 07:50:44 +00:00
ChiGao
02d303f849
feat(serve): persist prompt terminal ledger for cold-load reconciliation (#9426)
* feat(serve): persist prompt terminal ledger for cold-load reconciliation

Turn terminal events (turn_complete / turn_error) were synthesized by the ACP bridge and published over SSE only, so a prompt that was in flight when the daemon died could never be resolved after a restart: the cold load replay emits transcript chunks and carries no terminal evidence, leaving promptId-keyed orchestrators stuck on "unknown".

Each session now owns an append-only sidecar ledger next to its transcript. The bridge appends one in_flight record at prompt admission and one terminal record at the single publishPromptTerminal exit (covering the close/kill/channel-crash/daemon-shutdown flushes) through an injected synchronous sink. Ledger writes are best-effort and never block prompt execution or teardown, and records carry only ids, states, and timestamps — no prompt text, user content, or paths.

On a cold session load the serve layer reconciles prompts left dangling by a dead daemon: it classifies the transcript tail with the existing turn-interruption detector and appends a completed (stop reason reconstructed_from_transcript) or interrupted (code daemon_lost) verdict, guarded by an attribution check so an unattributable tail stays unknown (fail-closed). The load response gains an optional promptTerminals field with the trailing 64 terminal records, omitted entirely when the ledger holds no terminal evidence, and archive/unarchive move the sidecar alongside the transcript so evidence survives storage lifecycle.

Design: docs/design/2026-08-19-prompt-terminal-ledger-design.md

* fix(serve): tighten ledger reconciliation fail-closed semantics and complete sidecar lifecycle

Address review findings on the prompt terminal ledger:

- reconcile: fail closed on multiple dangling prompts (no synthesized
  terminal for the newest either); attribute the oldest dangling prompt
  only when the attribution guard skips settled admissions (fixes the
  [A if, B if, B cancelled] misattribution veto), the transcript's last
  write postdates the admission (temporal evidence), and a clean
  verdict is upgraded to interrupted when the model tail holds any
  functionCall part, id or not (id-less tool-call guard covering the
  detectTurnInterruption wire-pairing blind spot)
- lifecycle: removeSessionFiles deletes the ledger in both archive
  states; archive/unarchive move it through a single
  getPromptLedgerPathForState helper with merge semantics when the
  destination already exists (append-and-unlink instead of a permanent
  split); move warnings carry full source and destination paths in
  both directions
- scans: DataProcessor.scanChatFiles and
  usageHistoryService.rebuildFromSessionJsonl exclude .ledger.jsonl
  sidecars (the ledger is not a transcript)
- writer: appendPromptLedgerRecord seals a torn tail before appending
  so a torn fragment cannot fuse with (and destroy) the next record
- tests: pin the new behavior across multi-dangling fail-closed,
  settled-then-queued attribution, valid interleave migration,
  temporal veto, id-less tool-call guard, sidecar lifecycle
  (move/merge/warn-only delete), torn-tail sealing, queued-admission
  flush on shutdown, active-prompt and resume load contracts, and
  ledger exclusion from insight scans
- docs: sync the design doc's reconciliation algorithm, lifecycle, and
  fail-closed invariants

* perf(serve): read only the ledger tail for load-response promptTerminals

readRecentPromptTerminals ran on every POST /session/:id/load (including
attached hot loads) and synchronously read and JSON-parsed the entire
ledger — a multi-megabyte event-loop stall for long sessions on the
per-request hot path.

Add a tailBytes option to readPromptLedgerRecords that reads a trailing
byte window (the first window line is always dropped: the window start
can tear a line in half). The load path now reads a 256 KiB window,
which holds hundreds of ~150-byte records against the 64-terminal
response cap; sessions whose ledger outgrows the window return a
best-effort trailing subset, which the response contract already allows.

* fix(serve): close wrong-terminal attribution classes in cold-load reconciliation

Strengthen the reconcile attribution evidence per review round 2:
measure the temporal evidence on the same api-history projection the
verdict uses, fail closed on a compression checkpoint written after the
target's admission, and require the visible tail to postdate every other
prompt's settled terminal (FIFO evidence). Also fix a TS18048 narrowing
gap in the window test, make the seal test assert the raw file layout,
and restructure the window test so the call-site tailBytes wiring is
actually observable.

* fix(serve): keep ChatRecord import inline so lint-staged cannot merge it into a type-only import

* fix(serve): fail-closed reconciliation on millisecond clock equality and deadline-overlapped turns

* fix(serve): TOCTOU fence before ledger append and documented residual attribution risk

* test(serve): pin the ledger race fixture on the transcript timeline

* feat(serve): bind cold-load evidence to the admission via a dispatch marker

* test(core): pin the ledger sidecar exclusion in usage rebuild

* fix(serve): create ledger sidecar owner-only and fence marker-era compression by position

Round-7 review Criticals:
- appendPromptLedgerRecord created the sidecar with umask-default
  permissions (0o644) while the adjacent transcript is owner-only; the
  ledger now follows the 0o600 convention at creation time.
- Marker-bearing admissions fence post-admission compression by marker
  position instead of wall clock, so a backward clock step cannot hide
  a compression reset that voids the evidence chain.
- Design doc: the residual-risk claim is corrected — the dispatch
  marker binds ordering, not ownership; the two ownership classes that
  survive it (recordless predecessor with continued writes, ledger-less
  cross-client writer) are documented, pending writer identity on
  transcript records (#9483).

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
2026-08-20 07:27:49 +00:00
易良
48b30647d0
refactor: centralize cross-package contracts (#9497)
* refactor: centralize cross-package contracts

* fix(build): harden cross-package contract checks

* docs(core): clarify sub-session prompt limit scope
2026-08-20 06:24:41 +00:00
ytahdn
a41d5ec058
feat(web-shell): unify file uploads and references (#9477)
* feat(web-shell): unify file upload and reference flow

* fix(web-shell): address attachment review feedback

* fix(web-shell): address attachment review feedback

* fix(web-shell): address attachment review feedback

* fix(webui): restore optimistic text prompts

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-08-20 06:18:34 +00:00
jinye
a659539bc7
feat(cli): Add standalone conversation isolation primitives (#9341)
* docs: finalize standalone PR2 core design

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): Add standalone conversation isolation primitives

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): block mixed-case standalone restore

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): fail closed on corrupt session metadata

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): repair PR2A CI doubles and identity replacement cases

The ubuntu Test job failed on ten PR2A cases that pass on macOS:

- Export SessionIdCaseConflictError from the worktree test's core mock
  and give its SessionService double findSessionIdIgnoringCase, since
  loadSession now resolves persisted spelling before reading metadata.
- Add readCreationMetadataIfReadable to the Live task fake and mirror
  it onto the three server lifecycle spies so the fail-closed store
  interface sees the same fixture metadata as the legacy tolerant
  readCreationMetadata path it replaced.
- Pin the original inode via rename in the two same-path replacement
  cases. ext4/overlayfs recycle a freed inode immediately, so rm+mkdir
  at the same path could satisfy the recorded device+inode identity on
  Linux runners and make a real replacement look valid.

* fix(serve): key restore shared guard on persisted session id spelling

The restore handlers resolved the persisted (possibly uppercase)
spelling of a session id only inside the shared coordinator guard,
while batch delete locks its exclusive guard on the raw caller ids.
A restore of the normalized request id therefore raced a concurrent
batch delete of the persisted-spelled id on case-sensitive volumes.

Resolve the persisted spelling before acquiring the shared guard and
key runSharedMany on the resolved id so both sides contend on the
same key, in both the REST and ACP restore handlers. Regression
tests assert the guard key at both transports.

* fix(serve): unify persisted-session conflict contract across restore surfaces

- SessionIdCaseConflictError now carries an optional candidateSessionId
  with a shape-aware message, so a same-spelling active+archived conflict
  names the persisted spelling instead of blaming the request-case id.
- REST/ACP-HTTP conversion re-checks the candidate spelling before
  mapping to SessionConflictError; ACP child surfaces both shapes as
  INTERNAL_ERROR + errorKind 'session_conflict', and the reserved-source
  rejection carries errorKind 'reserved_session_source'.
- Pin the previously ungated guards from review: lineage validity
  conjuncts and archived-state reads (session-source), persisted-spelling
  adoption in ACP load/resume, ensureStandaloneDirectory EEXIST raced
  re-inspection, and the trailing root revalidation inside
  inspectConversationDirectoryIdentity (fs-interception seam).

* docs: sync standalone PR2 plan and design docs with R1 review

- Plan doc: ship the readCreationMetadataIfReadable signature in the
  store snippet, record jsonl-utils/error-response/readCreationMetadata
  in the PR2A checklist with the second-core-file re-audit outcome,
  name the launcher as child-UUID generator in both sections, extend
  the PR2A vitest and prettier gates to every PR-touched file, and stop
  claiming junction/Windows coverage the matrix cannot run.
- Design doc: reconcile the initial-prompt ordering clause with the
  strict create schema (prompt admitted by createWithInitialPrompt
  after the create transaction commits), replace the stale "before the
  UUID can be released" wording with the terminal-reservation model,
  and enumerate all seven deny categories in the acceptance matrix.

* fix(cli): normalize session-map lookups in restore failure cleanup guards

The sessions map is keyed by normalizeSessionIdForLookup-folded ids, but
the three cleanupAfterRequestFailure guards read it with the raw
config.getSessionId(). After a restore adopts a non-canonical persisted
spelling (uppercase legacy transcript), those reads always miss and the
guard would treat a still-stored session as unstored, shutting its
config down in the double-cleanup-failure window. Normalize at the read
sites; no behavior change for canonical or non-UUID ids. The adoption
test now also pins that caller-case follow-up operations (cancel) still
reach the adopted session through the normalized key.

* fix(cli): snapshot standalone dir entries after final identity inspect

prepareStandaloneDirectory read the child entries before the trailing
identity re-inspection, so a same-uid entry appearing between the two
steps would not flip the not_empty verdict. Read entries after the
final inspect so the emptiness check runs on the freshest snapshot the
identity machinery can guarantee.

Addresses yiliang114's review on PR #9341.

* fix(cli): lock restore guard on both request and persisted session id spellings

Restore keyed its shared SessionArchiveCoordinator guard only on the
resolved persisted spelling while batch delete/archive/unarchive lock on
raw caller ids, so on a case-insensitive filesystem a delete carrying
the request-case spelling never collided and could unlink the
transcript mid-restore (R2-1). Lock both spellings on the REST and
ACP-HTTP restore surfaces.

Also from R2 review:
- Pin the pre-guard/in-guard conflict-conversion stages in the
  both-states restore tests (R2-8: call-count + guard-not-entered
  assertions; mutation witness supplied by the reviewer).
- Add the toRpcError SessionIdCaseConflictError producer case to
  dispatch-error.test.ts and list the suite in the PR2A verification
  block; extend the PR2B block with the collocated suites its checklist
  modifies (R2-5).
- Correct the plan checklist label for the pre-existing shared
  jsonl-utils module from Create to Modify (R2-7).

* fix(cli): canonicalize session-archive coordinator lock keys

The two-spelling restore guard from the previous commit closed only the
enumerated spellings: any third case variant of a caller id took an
exclusive key that collided with no held guard, and on a
case-insensitive filesystem could unlink the transcript mid-restore —
including the common case where request and persisted spellings
coincide and the lock set collapses to one key (R3 review, probe-
verified on a case-insensitive mount).

Canonicalize lock keys with normalizeSessionIdForLookup at the
coordinator boundary (runSharedMany / runExclusiveMany /
assertNotTransitioning) so every case variant of a session id contends
on one key, and revert the restore-side spelling enumeration it makes
redundant. Add a coordinator-level regression test for the case-fold
collision, and fix a misleading comment above the in-guard call-count
assertion (R3-1).

* test(cli): pin workspace ordering/race propagation and align PR2 plan

From the R4 review:

- Pin that prepareStandaloneDirectory reads entries after the final
  identity re-inspection, via an interposed inspect that plants an
  entry mid-sequence (R4-3; mutant-verified to flip).
- Pin that ensureStandaloneDirectory propagates a raced 'compromised'
  inspection verbatim instead of collapsing it to identity_changed
  (R4-7; mutant-verified to flip).
- Plan: daemon bridge live-entry lookups (including
  getSessionEventEpoch) use the canonical ID — acp-bridge byId.get is
  exact-match with no id normalization — while the storage spelling is
  confined to SessionService filename/directory-hash/ACP-child storage
  operations (R4-1).
- Plan: declare the session-archive coordinator lock-key
  canonicalization in the PR2A inventory and run session-archive.test.ts
  in the PR2A block (R4-2); add dispatch-error.test.ts to the PR2B
  block (R4-5).

* fix(core): make case-insensitive resolver conflict decisions content-based

The resolver threw SessionIdCaseConflictError on filename enumeration
alone, before any content validation, and silently dropped a single
candidate whose head recovered no records. Two probe-verified failure
modes from the R5 review:

- A present-but-unreadable case-variant transcript (torn/empty/
  foreign-project head) resolved to undefined, so create admission
  admitted the canonical spelling and materialized a case-only twin;
  every later resolve then threw on the duplicate, permanently locking
  out the just-created session (R5-1).
- A valid session with an unreadable same-spelling twin in the other
  state directory threw on enumeration while getSessionLocation
  cleanly reported one readable copy — listed as loadable, but every
  restore 409'd (R5-2).

Conflict arms now consult getSessionLocation: exactly one readable
spelling wins; conflict is thrown when two or more are genuinely
readable (or a single candidate is conflicted across states); a
candidate whose head fails validation still occupies the id when its
file is on disk, while one that raced away mid-resolution resolves to
undefined. Admission already maps the thrown conflict to
persisted-true, so no admission change is needed.

* fix(cli): make caller-supplied sessionId create admission case-aware

The argv['sessionId'] branch (reached by raw stdio ACP session/new with
a requestedSessionId, without any daemon reserveCreate) checked
occupancy with exact-spelled sessionExistsInAnyState only, so a legacy
mixed-case transcript did not block the create and the daemon persisted
a case-only twin — which the resolver's conflict semantics then make
permanently unrestorable on every surface (R5-2). Route the check
through the case-insensitive resolver, treating its conflict throw as
occupancy.

Also pin that the ACP restore path hands the resolver-adopted storage
spelling to assertSessionLoadable: archived uppercase transcript
restored via the canonical lowercase id must surface errorKind
'session_archived' (R5-3; the request-spelling mutant skips the error
on case-sensitive filesystems).

* fix(cli): narrow reserved-source restore gate to internal runtimes

Two items from the maintainer's round-2 live verification:

- N1: the restore-side reserved-standalone-source gate fired for every
  runtime, so a transcript persisted on main with the client-supplied
  sourceType "standalone" became permanently unloadable while still
  listed. The create side already blocks new reserved-source
  transcripts, so any such file on an ordinary store predates the gate
  — keep it loadable there and hide only on the internal Conversations
  runtime (REST) / isolated ACP surface, where genuine standalone
  sessions will live. Generic-arm tests on both surfaces flipped to pin
  the compat restore; the internal-arm 404 pin is unchanged.
- N2: the R5 occupancy throw reused the both-states message for a
  single unreadable transcript. SessionIdCaseConflictError gains a
  'unreadable_transcript' reason with a truthful message, used by both
  occupancy arms; the case_conflict shape is unchanged.

* test(cli): flip the remaining generic-surface reserved-source test

The exact-spelling variant was missed in the N1 narrowing commit and
failed CI on ubuntu (session/load + session/resume expected the old
generic-surface hide). Flip it to pin the compat restore like its
mixed-case sibling.

* docs: sync PR2A plan with the shipped create-admission resolver consumer

The round-6 triage deferred note flagged the plan as desynced: the
R5-2 fix made loadCliConfig's caller-supplied sessionId branch a sixth
findSessionIdIgnoringCase consumer. Declare it in the per-file
checklist, count it in the consumer inventory, and add config.test.ts
to the PR2A vitest block.

* fix(core): narrow the session id case resolver's occupancy arms

Three shapes were classified as permanent occupancy, regressing paths that
loaded or created fine before the resolver replaced the exact-existence
check:

- Candidates were enumerated by case-insensitive filename match without the
  pattern gate that getSessionLocation applies, so an agent-suffixed id
  (which the CLI admits and writes under the raw session id) resolved to an
  unreadable-head conflict. Skip names the classifier would reject.
- On a case-insensitive filesystem every spelling opens the same physical
  transcript, so a readable copy plus a torn case twin reported two readable
  candidates and raised a conflict for a session with one loadable copy.
  Collapse spellings that share a device/inode and resolve to the one whose
  own directory entry backs the file.
- An unreadable head under the requested spelling itself is a case-only twin
  of nothing, yet it refused the id with no listing entry to delete or
  unarchive. Report it absent, matching getSessionLocation, so a first run
  that crashed before its first record can reuse its own 0-byte transcript.

The twin-minting protection still applies when the persisted spelling
differs from the requested one, and genuinely distinct readable spellings
still conflict.

Also pin the explicit-standalone child branch's sourceId guard and its
documented lineage behaviour, which no case covered.

* fix(cli): key the private conversation directory on the canonical id

Restore derived the directory hash from the persisted spelling while the
seven other materialize/discard call sites derived it from the lowercased
live id, so restoring a legacy mixed-case transcript produced one directory
and every later Live or task call produced a second, empty one — either
orphaning what the first held or failing the call because the session sat
outside its isolated directory. Rollback then inspected the other hash and
leaked the first directory permanently.

The directory belongs to the live entry, which the bridge registers under the
canonical id alongside the lifecycle locks and in-flight maps, so both
restore paths now derive it from that id too. This also keeps directories
that pre-date the change reachable: restore used the lowercased request id
before, so every one already on disk is canonical-keyed.

Storage-facing operations — transcript filenames, metadata reads and the ACP
child's own session storage — keep the authoritative spelling. The design doc
and PR2 plan are corrected to scope the spelling rule accordingly.

* test(cli): follow the canonical private-directory key in the Live restore case

The internal-restore case pinned the directory hash to the persisted
spelling, which the canonical-key change inverted. It now asserts the
canonical id for the directory and bridge cwd, and keeps the original
intent explicit by asserting that the creation-metadata read still uses
the persisted spelling.

* fix(cli): report proven parent lineage from the loadable-session reader

The exported reader returned one verdict for two different situations: a
child whose parent lineage it had verified, and an explicit standalone child
whose parent it never read. PR2B is being built on that reader, so the
ambiguity mattered even though no caller was affected yet.

The verdict now carries the parent's own classification. An explicit
standalone child whose parent is still readable must have a standalone
top-level parent, which also rejects a grandchild or a lineage cycle because
neither parent classifies as top-level. A parent that has been archived away
or deleted keeps the child loadable — it is self-describing, and its own
transcript is the evidence that a valid parent existed when it was created —
but `parentSource` is then absent, so a caller that needs proven lineage
rejects on that rather than guessing from `kind`.

The compatibility adapter reads the new field instead of re-reading the
store to re-derive the same classification, so its behaviour is unchanged
while a duplicated location lookup and metadata read disappear from every
legacy standalone child restore. Adapter output is identical for every
input: explicit standalone was already filtered out before the parent check,
so no current caller changes behaviour.

* fix(core): stop the alias resolver from turning I/O and missing inodes into conflicts

Two defects in the case-variant collapse added earlier in this branch.

The stat guard was statically dead: `statSync` without `{ bigint: true }`
always returns numbers, so the `typeof` test could never fire. The hazard it
was meant to cover is a filesystem that exposes no inodes — FAT/exFAT and
some SMB mounts report `ino === 0` for every file — where `dev:ino` collapses
genuinely distinct transcripts onto one identity and a real two-transcript
conflict silently resolves to one spelling. That is a fail-open on a
correctness decision, so it now uses the existing `hasVerifiableInode()`
helper, whose docblock describes exactly this case.

The resolver also swallowed every `statSync` failure into `undefined`, which
its caller reads as positive proof of a conflict. A transient EACCES or
EMFILE therefore surfaced as `409 session_conflict` — a permanent-looking
answer for a blip that succeeds on retry. Only ENOENT is now treated as
meaningful: a transcript that raced away is no longer a competing spelling.
Every other error propagates.

* fix(core): let a crashed first run resume its transcript past a case twin

The self-escape added for an unreadable transcript under the requested
spelling only covered the single-candidate arm. Once any case twin was
enumerated, resolution took the all-unreadable arm instead, where presence
was computed over every candidate including the requested spelling's own
file — so the documented crash recovery vanished the moment a stale twin
existed, and neither file could be deleted because both classify as
nonexistent.

Reusing an id whose file is already on disk mints no case-only twin, so that
arm now takes the same escape. A twin under a different spelling still
occupies the id, because minting the requested spelling beside it is what
would make both unrestorable.

The disappearance test was vacuous: its candidate spelling equalled the
request, so it returned at the self-escape and never reached the race loop it
named — deleting that loop left it green. It now uses a differing spelling
with `existsSync` false, and forcing the loop to throw unconditionally kills
it. The all-unreadable rejection test likewise needed two spellings that are
both distinct from the request to exercise twin-minting protection.

* fix(cli): fail closed when a filesystem cannot prove directory identity

The conversation-directory checks compared `dev`/`ino` directly, so on a
filesystem that exposes no inodes — FAT/exFAT and some SMB mounts, where Node
reports `ino === 0` for every entry — every directory compared equal. The
root pin, the two anti-swap re-probes around `realpath`, and the expected
identity check would all confirm a directory that had in fact been replaced,
which is the swap those probes exist to catch.

They now require a verifiable inode on both sides before treating a match as
proof, reusing the `hasVerifiableInode()` helper already written for this in
core and exporting it from the package surface. An unverifiable inode reads
as a changed identity rather than as a match.

The regression test pins a root whose inode is also 0, so a plain `===`
comparison still matches and only the verifiability guard can fail it.

* fix(cli): keep caller-supplied session-id admission fail-closed on I/O errors

Swapping the existence check for the case-insensitive resolver narrowed the
catch to `SessionIdCaseConflictError` and rethrew everything else, but the
resolver deliberately propagates non-ENOENT `readdir` and transcript-read
failures. An unreadable chats directory therefore killed startup with a raw
EACCES or ENOTDIR instead of the guarded message, and bypassed the
`throwOnSessionIdConflict` contract the ACP path depends on.

The previous check answered "occupied" for any read failure. Restoring that
keeps an unprovable id on the guarded path; distinguishing "cannot determine"
from "occupied" would be a new response shape and is left alone here.

* fix(cli): keep the directory identity module out of the core package barrel

Importing `hasVerifiableInode()` from the core package barrel pulled core's
whole module graph into the serve pre-listen bundle closure, so
`check:serve-fast-path-bundle` reported glob, chokidar, fzf, @iarna/toml and
the core shell tool runtime as statically reachable from `run-qwen-serve`.
This module is deliberately dependency-free for that reason.

The predicate is restated locally with a comment recording why it is not
imported, since core has no subpath export for it. The barrel export added
for that import is reverted so the package surface is unchanged.

* fix: correct three defects introduced by the previous review round

**The inode guard made a directory fail to equal itself.**
`createConversationRootIdentity()` compares `before`/`after` of the same path,
so requiring a verifiable inode threw `identity_changed` on the very first
root establishment and, because the workspace clears its cached root on
failure, Conversations never started on exFAT/FAT or an inode-less SMB mount.
"Cannot prove unchanged" is not "changed": the root is now established with
`inodeVerifiable: false` recorded, comparisons fall back to device, canonical
path and stat shape, and the weaker guarantee is explicit on the identity for
callers to surface. Where inodes exist they are still required to match.

**The occupancy escape was placed to discard a real twin.**
It returned early for the whole arm whenever the requested spelling was
enumerated, so a present-but-unreadable twin stopped occupying the id — the
case-only twin the surrounding comment exists to prevent. The escape belongs
per candidate, not per arm: the requested spelling's own file never counts as
occupancy, every other spelling still does.

**The private directory was still a caller obligation.**
The comment claimed the bridge registers live entries canonically and every
materialize derives from that id, but the bridge echoes whatever the caller
passed, and `LiveTaskService.ensureResident()` passes an id that originates in
a tool argument. `ConversationWorkspace` now canonicalizes before hashing, so
one session resolves to one directory by construction.

Also unifies the resolver's two arms, which were the same algorithm written
twice — that duplication is why the escape landed in only one copy.

* fix(cli): Collapse case-variant session ids in batch lifecycle and CLI create

Batch delete/archive/unarchive locked on canonical keys but still
deduped raw spellings, so two case variants of one id deadlocked
the batch. CLI --session-id now stores the lowercase spelling so
new mixed-case transcripts stop accumulating.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 06:15:52 +00:00
qqqys
36c77ff803
fix(dingtalk): attach media from quoted messages (#9347)
* fix(dingtalk): attach media from quoted messages

* fix(dingtalk): keep the reply text when attaching quoted media

R1-1: `attachMedia`'s placeholder cleanup was written for the direct-media
path, where `extractContent` generates `(audio)` / `(video)` /
`(file: name)` itself. This PR made the quoted-media path reach it, and there
`envelope.text` is the user's own reply — so a reply reading exactly like one
of those placeholders was blanked and the agent got an attachment with no
prompt. A group `@Bot (audio)` arrives here as exactly `(audio)`, the mention
having been stripped upstream. `attachMedia` now takes the placeholder to
erase as a parameter; only the direct-media call site passes one.

R1-2: the same path newly routes text-only replies through the unguarded
`mkdirSync`/`writeFileSync`/`basename` block. Those are synchronous throw
sites — ENOSPC on a write of up to 50 MB, ENAMETOOLONG from a quoted fileName
over 255 bytes (`basename` does not truncate), a TypeError from a truthy
non-string fileName. An escape rejects `processMessage`, whose catch sends
the generic error reply and never calls `handleInbound`; the msgId is already
in `seenMessages`, so DingTalk's retry is deduped and the prompt is lost for
good. The block now degrades the way a failed download already does: log,
skip the attachment, deliver the text. This also covers the pre-existing
direct-media path.

Verified: dingtalk 310/310. Both mutation-checked — restoring the caller-blind
cleanup fails 3 tests, letting the fs block throw fails 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dingtalk): address quoted-media review findings

Unify the msgType->mediaType mapping in a shared helper, make extractContent
the single source of truth for the placeholder text cleaned on attach, and
remove the store directory when a media write fails so failed stores no longer
leak into tmpdir. Merge the stacked attachMedia JSDoc blocks, document quoted
media downloads, and pin the previously uncovered paths: unmapped quoted
msgTypes with a downloadCode, own-media + quoted-media combinations, direct
placeholder cleaning, and the degraded-store attachment shapes.

* fix(dingtalk): file-back a quoted image colliding with the own image

* fix(dingtalk): give generated media store names a mime-derived extension (#9347)

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot[bot]@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-20 03:18:32 +00:00