Skip to content

feat(serve): F1 follow-up — BridgeFileSystem wiring + #4325 channelInfo fix#4334

Merged
doudouOUC merged 7 commits into
daemon_mode_b_mainfrom
feat/f1-followup-fs-adapter-test-split-channelinfo
May 20, 2026
Merged

feat(serve): F1 follow-up — BridgeFileSystem wiring + #4325 channelInfo fix#4334
doudouOUC merged 7 commits into
daemon_mode_b_mainfrom
feat/f1-followup-fs-adapter-test-split-channelinfo

Conversation

@doudouOUC
Copy link
Copy Markdown
Collaborator

@doudouOUC doudouOUC commented May 19, 2026

Summary

Three F1 (#4319) follow-ups, batched into one PR against daemon_mode_b_main:

  1. BridgeFileSystem wiring (9560dfc28) — closes ws.ts:613 TOCTOU thread. ACP writeTextFile / readTextFile now go through PR 18's WorkspaceFileSystem (trust gate + symlink resolution + atomic temp-file write + line/limit windowing + unified audit emit) instead of BridgeClient's inline raw-fs proxy. F1 shipped the BridgeFileSystem seam in BridgeClient; this commit wires the default createServeApp / runQwenServe paths to actually inject the adapter so the seam is live in production.
  2. bug(acp-bridge): closeSession + killSession use module-scoped channelInfo instead of channelInfoForEntry(entry) #4325 channelInfo overlap fix (db8972b81) — closeSession + killSession now use channelInfoForEntry(entry) instead of the module-scoped channelInfo, matching the pattern already in setSessionApprovalMode + requestSessionStatus. Eliminates the latent bookkeeping mismatch during channel-overlap windows.
  3. Mode preservation + atomic write for ACP writes (881133407) — addresses the Copilot review finding (discussion) on commit 1's adapter. Pre-fix, ACP writes routed through wfs.writeText had no mode handling, so new files got umask-default (0o644) instead of the BridgeFileSystem contract's 0o600, and existing target mode wasn't preserved (a 0o600 secret edit would downgrade to umask-default). Fix introduces a new reusable PR 18 primitive WorkspaceFileSystem.writeTextOverwrite — unconditional create-or-overwrite with mode preservation + 0o600 default + atomic temp+rename, via a new WriteMode = 'overwrite' variant that tolerates ENOENT (new file) and skips the expectedHash CAS gate (which doesn't fit ACP's hash-less wire). Adapter switched to it; contract docstring updated to reflect production posture (write-then-rename atomicity, mode preservation, 0o600 default, symlink rejection — a divergence from pre-F1 inline-proxy semantics, now consistent with HTTP POST /file from PR 20).

Files

New (fs adapter, commit 1):

  • packages/cli/src/serve/bridgeFileSystemAdapter.ts (~110 LOC) — thin translation from ACP WriteTextFileRequest / ReadTextFileRequestWorkspaceFileSystem.resolvewriteTextOverwrite / readText. Drops ACP-wire null line/limit (PR 18 wants undefined). Routes labeled 'ACP writeTextFile' / 'ACP readTextFile' so audit consumers can distinguish agent fs from HTTP fs.
  • packages/cli/src/serve/bridgeFileSystemAdapter.test.ts (12 tests after commit 3 added mode-preservation assertions) — happy paths, trust-gate deny, boundary rejection (writes + reads), line/limit window, null→undefined normalization, factory.forRequest audit-context wiring, new-file 0o600 default + existing-target mode preservation through the adapter (commit 3, skipped on Windows).

Modified (fs adapter, commit 1):

  • packages/cli/src/serve/runQwenServe.ts + packages/cli/src/serve/server.tsfsFactory constructed BEFORE the bridge default; bridge gets fileSystem: createBridgeFileSystemAdapter(fsFactory). Same factory instance feeds HTTP fs routes + ACP fs → single operator audit stream covers both.

Modified (#4325 fix, commit 2):

  • packages/acp-bridge/src/bridge.tscloseSession + killSession use channelInfoForEntry(entry).
  • packages/cli/src/serve/httpAcpBridge.test.ts — smoke regression test for channelInfoForEntry routing.

Modified (writeTextOverwrite, commit 3):

  • packages/cli/src/serve/fs/workspaceFileSystem.ts — new writeTextOverwrite public method (~75 LOC); WriteMode extended with 'overwrite'; validateWriteTextAtomicOptions accepts new mode without expectedHash; assertAtomicTargetPrecondition gains 'overwrite' branch that tolerates ENOENT and returns existing target mode for preservation.
  • packages/cli/src/serve/fs/workspaceFileSystem.test.ts — +6 tests for writeTextOverwrite (new file 0o600, preserve mode bits, preserve +x, symlink rejection, trust gate, audit emit).
  • packages/cli/src/serve/bridgeFileSystemAdapter.ts — adapter swapped from wfs.writeTextwfs.writeTextOverwrite. Docstring updated to explain primitive choice (writeText has no mode handling, writeTextAtomic requires expectedHash that ACP can't provide).
  • packages/acp-bridge/src/bridgeFileSystem.ts — contract docstring rewritten to reflect production posture: write-then-rename atomicity, target mode preservation, 0o600 default for new files, symlink rejection (divergence from pre-F1 inline-proxy realpath/readlink follow, now matches PR 18 + HTTP POST /file from PR 20).

Test plan

  • npx vitest run packages/cli/src/serve/bridgeFileSystemAdapter.test.ts packages/cli/src/serve/fs/workspaceFileSystem.test.ts — 81/81 pass on touched files
  • npx vitest run packages/cli/src/serve/ — 18 files, 754/754 pass
  • npx vitest run (packages/acp-bridge/) — 5 files, 62/62 pass
  • Pre-commit hook (prettier --write + eslint --fix --max-warnings 0) clean on all staged files
  • CI green across Linux / macOS / Windows
  • Smoke: qwen serve boot, ACP child agent writeTextFile (new file → 0o600 on disk; overwriting an existing 0o600 file → still 0o600; symlinked target rejected with symlink_escape) + readTextFile + boundary rejection observable via /workspace/audit/events

Backward compat

  • BridgeOptions.fileSystem was already optional in F1 (seam-only). Embeds that don't pass it keep using BridgeClient's inline raw-fs proxy — this PR only changes the default createServeApp + runQwenServe wiring.
  • channelInfoForEntry(entry) returns the same record as the module-scoped channelInfo when there's no channel overlap (the common case), so single-channel callers see identical behavior; the fix only affects overlap windows where prior behavior was the latent bug.
  • WorkspaceFileSystem.writeTextOverwrite is a new method on the interface; pre-existing in-tree consumers of WorkspaceFileSystem are unaffected. Out-of-tree TS consumers implementing the interface manually would need to add the method (no known out-of-tree consumers — @qwen-code/acp-bridge is not yet npm-published, and PR 18 lives in cli/src/serve/ not in a published package).
  • WriteMode union widened from 'create' | 'replace''create' | 'replace' | 'overwrite'. The new value is opt-in via writeTextAtomic({mode: 'overwrite', ...}) or the new writeTextOverwrite method; existing callers using 'create' / 'replace' see no behavior change.
  • Behavior divergence from pre-F1 ACP fs: agent writes through symlinks now reject with symlink_escape (consistent with HTTP POST /file since PR 20). Pre-F1 inline BridgeClient.writeTextFile resolved symlinks via realpath / readlink and wrote through to the target. Agents that previously relied on writing through symlinked dotfiles will need to address the resolved path directly. Documented in bridgeFileSystem.ts contract docstring.

Refs

🤖 Generated with Qwen Code

doudouOUC added 2 commits May 20, 2026 00:48
#4325)

Folds in the deferred fix from F1 (#4319) for #4325. Pre-fix both
methods captured `const ci = channelInfo` — the module-scoped CURRENT
attach target — rather than `channelInfoForEntry(entry)`. The two
diverge during the channel-overlap window (A dying, B freshly spawned
as `channelInfo`), where closing or killing a session whose
`entry.channel = A` would:

  1. Skip `A.sessionIds.delete()` because `B.channel !== A.channel`,
     leaving A's `sessionIds` set pinned past the close;
  2. Call `markSessionClosed` on **B**'s client instead of **A**'s,
     evaluating B's kill condition with stale assumptions about its
     session count — potentially killing B unnecessarily and forcing
     a third spawn cascade.

Other session methods in the same factory (`setSessionApprovalMode`
at ~L2609, `requestSessionStatus` at ~L1245) already use the
`channelInfoForEntry(entry)` helper; this brings `closeSession` and
`killSession` in line with that pattern.

Net change: 2 lines (one in each method) replaced; surrounding
comment blocks updated to document the channel-overlap rationale +
the matching sibling-method consistency argument.

## Why the smoke test rather than a full overlap regression

The exact bug-triggering state is hard to construct deterministically
under the current factory architecture:

  - A only flips `isDying = true` when its `sessionIds` drains to 0
  - The drain path (`killSession` or `closeSession` on the last
    session) also removes the session from `byId` synchronously
  - So by the time `channelInfo` could move to B, every session that
    was on A is gone from `byId` and thus unreachable to a subsequent
    `closeSession`

A faithful overlap regression test requires a test-only factory
inspection seam (manual `channelInfo` override, or a hook into
`aliveChannels` mutation). Adding that seam is non-trivial and
expands the bridge's public surface — out of F1-followup scope.

What this commit ships:

  - The 2-line fix itself (matches the sibling-method pattern; the
    correctness argument is structural, not race-empirical)
  - A smoke regression test at `httpAcpBridge.test.ts` exercising
    `closeSession` on the normal single-channel case and asserting
    the kill-on-last-session cascade fires correctly — would fail
    trivially if a future refactor reverted to module-scoped
    `channelInfo` capture without thinking through the
    `channelInfoForEntry → undefined` case
  - Inline comments at both fix sites + on the new test documenting
    why the full overlap repro is deferred

A follow-up issue can track adding the factory inspection seam +
the deterministic overlap regression test if anyone needs the
empirical guard rather than the structural one.

- 175/175 cli httpAcpBridge tests pass (174 existing + 1 new #4325 smoke)
- 62/62 acp-bridge tests pass (no regression)
- typecheck + eslint clean
- Closes #4325

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…follow-up #4319)

Closes the ws.ts:613 TOCTOU thread that PR 18 (`WorkspaceFileSystem`)
flagged and that F1 (#4319) deliberately left to a follow-up by
shipping only the `BridgeFileSystem` injection seam in `BridgeClient`.

Pre-fix, ACP `writeTextFile` / `readTextFile` calls landed in
`BridgeClient`'s inline `fs.realpath` / `fs.writeFile` / `fs.readFile`
proxy, bypassing PR 18's defensive layer (trust gate, symlink
resolution, atomic temp-file write, line/limit windowing, audit emit).
HTTP `POST /file` / `GET /file` already routed through that layer —
agent fs and HTTP fs diverged in posture.

Changes
- New `bridgeFileSystemAdapter.ts` (~110 LOC): thin translation from
  ACP `WriteTextFileRequest` / `ReadTextFileRequest` to
  `WorkspaceFileSystem.resolve` → `writeText` / `readText`. Drops
  ACP-wire `null` line/limit (PR 18 wants `undefined`). Routes labeled
  `'ACP writeTextFile'` / `'ACP readTextFile'` so the unified audit
  stream can distinguish agent fs from HTTP fs at the consumer side.
- `runQwenServe.ts` + `server.ts`: construct `fsFactory` BEFORE the
  bridge default and pass `fileSystem: createBridgeFileSystemAdapter(fsFactory)`
  into `BridgeOptions`. Same factory instance feeds both HTTP fs
  routes and ACP fs → single operator audit stream covers both.
- New `bridgeFileSystemAdapter.test.ts` (10 tests, all pass): happy
  paths (trusted write + read), trust-gate deny, boundary rejection
  (writes + reads outside workspace), line/limit window, null→undefined
  normalization, `factory.forRequest` audit-context wiring (sessionId
  forwarded, omitted when ACP request lacks one).

Backward compatibility
- `BridgeOptions.fileSystem` was already optional in F1 (seam-only);
  embeds that don't pass it (or that pre-date this commit) keep using
  `BridgeClient`'s inline raw-fs proxy as before. This commit only
  changes the *default* `createServeApp` + `runQwenServe` wiring.

Verification
- `vitest run src/serve/`: 18 files, 746/746 tests pass (includes
  the 10 new adapter tests + the 175-test `httpAcpBridge.test.ts` that
  exercises the seam through `BridgeOptions.fileSystem`).
Copilot AI review requested due to automatic review settings May 19, 2026 17:20
@github-actions
Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR delivers two F1 (#4319) follow-ups: (1) wiring the BridgeFileSystem adapter to close a TOCTOU thread in ws.ts:613, and (2) fixing the #4325 channelInfo overlap bug. The implementation is well-documented, thoroughly tested, and maintains backward compatibility. Overall assessment is positive—the changes close important security and correctness gaps with minimal risk.

🔍 General Feedback

🎯 Specific Feedback

🟢 Medium

  • File: packages/cli/src/serve/bridgeFileSystemAdapter.ts:85-92 - The readText method's null/undefined normalization is correct but could benefit from a small helper function to avoid repetition if similar patterns appear elsewhere. Consider extracting:
    function normalizeWindowParams(
      line: number | null | undefined,
      limit: number | null | undefined,
    ): { line?: number; limit?: number } {
      const opts: { line?: number; limit?: number } = {};
      if (typeof line === 'number') opts.line = line;
      if (typeof limit === 'number') opts.limit = limit;
      return opts;
    }

🔵 Low

  • File: packages/cli/src/serve/runQwenServe.ts:386-402 - The comment about constructing fsFactory BEFORE the bridge is excellent, but consider adding a brief code comment at the fsFactory declaration line itself (not just the block comment) for quicker scanning:

    // Construct fsFactory before bridge so ACP fs calls use PR 18's defensive layer
    const trustedWorkspace = deps.trustedWorkspace ?? true;
    const fsFactory = ...
  • File: packages/cli/src/serve/server.ts:241-258 - Similar to above, the symmetric change in createServeApp could use a one-line inline comment at the fsFactory declaration for consistency with runQwenServe.ts.

  • File: packages/cli/src/serve/httpAcpBridge.test.ts:6420-6475 - The regression test comment acknowledges that the full overlap state isn't deterministically testable without factory inspection seams. Consider filing a follow-up issue (#????) to add those test-only hooks so the complete race condition scenario can be verified, rather than deferring to an unspecified future PR.

✅ Highlights

  • Security win: The BridgeFileSystem wiring closes the TOCTOU vulnerability where agent-side ACP fs calls bypassed PR 18's trust gate, symlink resolution, atomic writes, and audit logging. This is a significant security improvement.
  • Correctness fix: The bug(acp-bridge): closeSession + killSession use module-scoped channelInfo instead of channelInfoForEntry(entry) #4325 channelInfoForEntry fix prevents a subtle but impactful bookkeeping mismatch during channel-overlap windows that could leave session sets pinned and incorrectly kill freshly-spawned channels.
  • Thoughtful audit design: Using route labels ('ACP writeTextFile' / 'ACP readTextFile') to distinguish agent-sourced fs from HTTP-sourced fs in the audit stream is a clean pattern that enables operators to trace both surfaces uniformly.
  • Defensive type handling: The adapter's explicit typeof checks for line/limit normalization (dropping both nulls and undefineds) show good attention to type boundary conditions between ACP schema and PR 18's expectations.
  • Test quality: The test file includes thoughtful edge cases like macOS /var/private/var symlink resolution in the setup, and the audit context tests verify both sessionId presence and absence scenarios.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR follows up on Mode B serve F1 by (1) wiring the BridgeFileSystem seam so ACP writeTextFile/readTextFile go through the serve WorkspaceFileSystem boundary (trust gate, boundary/symlink checks, audit emission), and (2) fixing channel bookkeeping during overlap windows by using channelInfoForEntry(entry) in closeSession/killSession.

Changes:

  • Inject a serve-side BridgeFileSystem adapter into the default createServeApp and runQwenServe bridge construction paths.
  • Add adapter + tests to route ACP fs operations through WorkspaceFileSystemFactory.forRequest(...) with ACP-specific audit route labels.
  • Fix @qwen-code/acp-bridge channel bookkeeping to use channelInfoForEntry(entry) in session close/kill, plus a smoke regression test.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/cli/src/serve/server.ts Constructs fsFactory before the bridge and injects createBridgeFileSystemAdapter(fsFactory) into default bridge creation.
packages/cli/src/serve/runQwenServe.ts Builds fsFactory earlier and injects the adapter into the bridge, ensuring ACP fs shares the same trust/audit pipeline as HTTP fs routes.
packages/cli/src/serve/bridgeFileSystemAdapter.ts New adapter implementing BridgeFileSystem by translating ACP read/write requests to WorkspaceFileSystem.resolve + readText/writeText.
packages/cli/src/serve/bridgeFileSystemAdapter.test.ts New integration tests covering adapter happy paths, trust gate behavior, boundary rejection, line/limit windowing, and audit-context wiring.
packages/acp-bridge/src/bridge.ts Fixes #4325 by using channelInfoForEntry(entry) in closeSession and killSession.
packages/cli/src/serve/httpAcpBridge.test.ts Adds a smoke regression test guarding the channel bookkeeping neighborhood (#4325).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.ts Outdated
…Copilot review)

Adopts Copilot's finding on PR #4334 (security-relevant):
#4334 (comment)

Pre-fix, the adapter routed ACP writeTextFile through
`WorkspaceFileSystem.writeText` which has no mode handling — new files
got umask-default (typically 0o644) and existing-target mode wasn't
preserved. The `BridgeFileSystem` contract requires 0o600 for new
files (NOT umask) and target mode preservation (a 0o600 secret edit
must stay 0o600). The old inline `BridgeClient.writeTextFile` proxy
did this; the adapter regressed it.

Fix: add a new `writeTextOverwrite` primitive to PR 18's
`WorkspaceFileSystem` (Approach B from the design discussion — picked
over CAS-in-adapter because the "unconditional create-or-overwrite
with mode preservation" semantic will recur in F4 TUI/IDE adapters
and future webhook integrations; cleaner to land it as a reusable PR
18 primitive now than retrofit later).

Implementation
- `WorkspaceFileSystem.writeTextOverwrite(p, content, opts?)` —
  unconditional create-or-overwrite, no expectedHash gate. Reuses the
  existing `atomicWriteTextResolvedFile` infrastructure via a new
  `WriteMode = 'overwrite'` variant: tolerates missing target (returns
  empty mode → 0o600 default), rejects symlinks (`symlink_escape`),
  preserves existing mode bits (`chmod` to `targetState.mode ?? 0o600`
  in line 1450). Path-locked the whole window; emits the same
  `fs.access` audit as `writeText` / `writeTextAtomic`.
- `assertAtomicTargetPrecondition` gains an `'overwrite'` branch that
  stats the target, returns its mode for preservation, and tolerates
  ENOENT (new file path); rejects symlinks / non-regular files in
  parity with `'replace'`.
- `validateWriteTextAtomicOptions` accepts `'overwrite'` mode WITHOUT
  expectedHash — that's the whole point of the new primitive (callers
  whose wire format has no client-side hash, like ACP).
- `atomicWriteTextResolvedFile`'s rename branch handles `'overwrite'`
  automatically (falls through to `renameWithRetryLocal` like
  `'replace'`; rename both clobbers existing and creates new).

Adapter switch
- `bridgeFileSystemAdapter.ts:96` — `wfs.writeText(resolved, content)`
  → `wfs.writeTextOverwrite(resolved, content)`. Updated docstring
  explains why this primitive over `writeText` (no mode) or
  `writeTextAtomic` (CAS gate doesn't fit ACP's hash-less wire).

Contract update
- `bridgeFileSystem.ts:61-93` — `writeText` doc now reflects the
  production posture: write-then-rename atomicity, target mode
  preservation, 0o600 default for new files, **symlink rejection**.
  The pre-F1 inline proxy resolved symlinks and wrote through to the
  target; PR 18 + HTTP `POST /file` (PR 20) reject them. The adapter
  now matches that posture, so ACP fs and HTTP fs behave identically
  — a divergence from pre-F1 ACP semantics, called out explicitly.

Tests (+10, 81 passing on touched files)
- workspaceFileSystem.test.ts: writeTextOverwrite creates new file at
  0o600, preserves existing target mode (0o600 secret stays 0o600),
  preserves +x executable bit, rejects post-resolve symlink swap with
  symlink_escape, enforces trust gate, emits fs.access.
- bridgeFileSystemAdapter.test.ts: through-adapter assertions that
  new files land at 0o600 and existing 0o600 secrets stay 0o600 after
  agent overwrite. Skipped on Windows (POSIX permission bits not
  honored). Symlink rejection is covered at the lower workspaceFileSystem
  layer to avoid duplicating the post-resolve-swap setup.
@doudouOUC doudouOUC requested a review from wenshao May 19, 2026 23:46
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: no passing CI checks were found for this PR head (only skipped/no applicable checks reported). — gpt-5.5 via Qwen Code /review

Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts Outdated
Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts
Comment thread packages/cli/src/serve/httpAcpBridge.test.ts
Comment thread packages/cli/src/serve/server.ts Outdated
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve — local real-world verification on PR head 88113340.

Local test results (fresh worktree, tmux, real fs)

Item Result
npm ci exit 0
npx vitest run packages/cli/src/serve/bridgeFileSystemAdapter.test.ts packages/cli/src/serve/fs/workspaceFileSystem.test.ts 81/81 pass (matches PR test plan)
npx vitest run packages/cli/src/serve/ 18 files, 754/754 pass (matches PR test plan)
npx vitest run in packages/acp-bridge/ 5 files, 62/62 pass (matches PR test plan)
npm run typecheck exit 0
npm run lint:ci (--max-warnings 0) exit 0
npm run bundle exit 0

Boot smoke (covers the test plan's unchecked smoke item, HTTP surface)

Booted qwen serve --port 47334 --require-auth against a tmpdir workspace and exercised the HTTP fs route that shares the same fsFactory as the new ACP adapter:

  • POST /file/write (new file) → on-disk mode -rw------- (0o600 contract holds)
  • GET /file → round-trip content matches
  • POST /file/write with path: "../escape.txt"path_outside_workspace (400)
  • POST /file/write against a symlink to /etc/passwdsymlink_escape (400)

The ACP-side writeTextOverwrite shares this same factory + WorkspaceFileSystem defenses; its 0o600-default, mode-preservation, and symlink-rejection invariants are covered by bridgeFileSystemAdapter.test.ts and workspaceFileSystem.test.ts with real-fs harnesses (mkdtemp + chmod + symlink), not mocks.

Note on the prior CHANGES_REQUESTED

My earlier CHANGES_REQUESTED on this same head cited only "no passing CI checks were found". That gate is now satisfied by the local run above — not a code finding. Treating this approval as superseding that request.

Note on serve log diagnostic

The daemon emits fs audit emit is the default no-op when runQwenServe is invoked by the CLI without an injected fsAuditEmit. This is pre-existing PR 18/19 behavior — the emit: line in this PR's diff is moved as-is (no semantic change). Not a regression introduced by this PR. The PR's "single audit stream" claim holds for embedders that wire deps.fsAuditEmit, because both the bridge adapter and the HTTP routes are handed the same fsFactory instance.

…tion)

All four threads from the wenshao review round on PR #4334 (Qwen
`/review`) — adopted as-suggested with the fixes outlined below.

**[Critical] writeTextOverwrite blocks on large/binary existing files**
(`workspaceFileSystem.ts:849` thread r3270664710)

`readExistingTextMeta(p)` reads the existing file just for encoding /
BOM / line-ending hints (best-effort meta). My earlier catch only
swallowed `ENOENT`, so `file_too_large` (>256 KiB) and `binary_file`
errors propagated and **blocked the overwrite entirely**. Pre-PR ACP
`BridgeClient.writeTextFile` never read the existing file at all —
an agent overwriting a 1 MiB log or binary config would have always
succeeded. Bubbling those classified errors regressed that.

Fix: catch ENOENT + file_too_large + binary_file; leave `existingMeta`
undefined and let `mergeWriteMeta` fall back to UTF-8/no-BOM/LF
defaults. New tests cover both scenarios.

Side fix uncovered while writing the tests: `created` was derived from
`existingMeta === undefined` which is wrong after this catch widening
— a binary or too-large existing file would now report `created: true`.
Replaced with an explicit `lstat` to detect target existence
independently of meta-read success.

**[Critical] writeTextAtomic({mode:'overwrite'}) is unsupported**
(`workspaceFileSystem.ts:146` thread r3270664723)

`WriteMode` was widened to include `'overwrite'` and
`validateWriteTextAtomicOptions` accepted it — but
`writeTextAtomic`'s `existingMeta` branch only reads meta for
`mode === 'replace'` AND `created: opts.mode === 'create'` is
hard-coded so `'overwrite'` always reports `created: false` even for
new files. Direct callers of `writeTextAtomic({mode: 'overwrite'})`
would silently lose CRLF on Windows files and misreport new-file
creation. The dedicated `writeTextOverwrite()` method handles both
correctly and is the only supported entry point for
unconditional-overwrite semantics.

Fix (option b from the reviewer): reject `'overwrite'` in
`validateWriteTextAtomicOptions` with a `parse_error` that names the
correct method. The `WriteMode` union still admits `'overwrite'`
internally (so `atomicWriteTextResolvedFile` + `assertAtomicTarget
Precondition`'s 'overwrite' branch compile), but no external caller
can reach those code paths via `writeTextAtomic`. The error message
points to `writeTextOverwrite()` so misuse surfaces an actionable hint.

**[Suggestion] killSession #4325 fix missing symmetric regression test**
(`httpAcpBridge.test.ts:6421` thread r3270664724)

The earlier #4325 fix touched both `closeSession` AND `killSession`
(both `const ci = channelInfo` → `const ci = channelInfoForEntry(entry)`)
but the smoke test only exercises closeSession. A future refactor
reverting `killSession` alone would pass all existing tests.

Fix: add a symmetric `killSession` smoke test mirroring the closeSession
shape — single-channel kill → assert handle.killed + sessionCount = 0.
Same overlap-race caveat documented inline. Future deterministic
overlap test still deferred to the same follow-up that adds factory
inspection seams.

**[Suggestion] createServeApp default `trusted: false` silently rejects
agent writes for embeds** (`server.ts:257` thread r3270664727)

`createServeApp` constructs its default `fsFactory` with
`trusted: false` (test-safe posture), and now wires it into the
bridge via `createBridgeFileSystemAdapter(fsFactory)`. Pre-PR ACP
`writeTextFile` went through the inline raw-fs proxy which had no
trust gate. Any embed using `createServeApp` without providing
`deps.fsFactory` or `deps.bridge` will now have ALL agent writes
silently reject with `untrusted_workspace`. `runQwenServe` consumers
are unaffected (defaults `trusted: true`), but IDE companions /
hosted daemons calling `createServeApp` directly are at risk.

Fix: emit a stderr startup warning when `deps.fsFactory` is not
provided, explicitly naming the asymmetry and the three opt-out
paths (provide fsFactory, provide bridge, or accept the gate). Visible
to operators so the trust-gate-default isn't an opaque "writes
silently fail" mystery in production.

Additional test gaps closed (sub-bullet from r3270664724):
- adapter-level `readText` trust-gate parity check — verifies that
  `trusted: false` does NOT extend to reads (PR 18's trust gate is
  write-only). A future refactor mistakenly gating reads would only
  fail HTTP-fs tests, not adapter ones.
- `writeTextOverwrite` non-regular-file rejection — pins the
  `parse_error` posture for directory targets so a relaxation in
  `assertAtomicTargetPrecondition`'s 'overwrite' branch is caught.

Verification
- `npx vitest run packages/cli/src/serve/` — 18 files, 760/760 pass
  (+6 new tests over the previous 754)
- `cd packages/acp-bridge && npx vitest run` — 5 files, 62/62 pass
- Pre-commit (`prettier --write` + `eslint --fix --max-warnings 0`)
  clean on all 5 staged files
@doudouOUC doudouOUC requested review from chiga0 and yiliang114 May 20, 2026 02:29
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] mapDomainErrorToErrorKind (in packages/acp-bridge/src/status.ts) does not match FsError — it has .name = 'FsError', .kind, .status but no .code property. Every adapter-originated error (symlink_escape, untrusted_workspace, path_outside_workspace, file_too_large, etc.) returns undefined from the classifier, leaving structured monitoring without an errorKind. (Out of PR diff scope — flagging for awareness; consider adding an FsError branch in a follow-up.)

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.ts
Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts
Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts
Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.ts Outdated
Comment thread packages/cli/src/serve/server.ts Outdated
Adopts 4 of 7 wenshao review threads on PR #4334. The remaining 3
(1 Critical + 2 placeholder) are surfaced separately for user
judgment — the Critical's suggested fix doesn't work as-is and needs
a design call; the 2 placeholders look like reviewer-tool tests
("JSDoc test." / "test").

**[Critical] EACCES/EPERM blocks overwrite** (r3270921396, ws.ts:877)

The earlier r3270664710 fix widened the meta-read catch to swallow
ENOENT + file_too_large + binary_file. wenshao caught that EACCES /
EPERM also need to be swallowed — a file the daemon can't read
(0o000, other-user-owned) would abort the overwrite, contradicting
the "best-effort meta read" comment. Also opens an agent-side probe:
an attacker could detect file readability by observing EACCES on
overwrite attempts.

Fix: extend the catch to also swallow EACCES + EPERM. Comment block
expanded to spell out the full set (ENOENT / EACCES / EPERM /
file_too_large / binary_file) and the probing-defense rationale.

Test: `writeTextOverwrite succeeds over an existing 0o000 (unreadable)
file` — pins the posture so a regression here is caught. Skipped on
Windows + when running as root (root bypasses POSIX mode bits).

**[Suggestion] Negative `limit` produces wrong content**
(r3270921401, bridgeFileSystemAdapter.ts:112)

Pre-PR the inline `BridgeClient.readTextFile` returned `{ content:
'' }` for `limit <= 0`. PR 18's `readText` applies `slice(0, limit)`,
which for `limit: -1` returns "all lines except the last" — wrong
content. Same hazard for non-positive `line` (PR 18 rejects with
`parse_error` for `line < 1`, smuggling a 4xx-shaped error to agents
that previously got `''`).

Fix: tighten the adapter's `typeof === 'number'` guard to also
require `> 0`. Comment expanded to call out the divergence and why
"drop and let PR 18 default to no-windowing" is the closest match to
pre-PR empty-content posture without leaking parse_error.

Tests: `drops non-positive limit (negative / zero) instead of
forwarding` + `drops non-positive line (zero) instead of forwarding
parse_error`.

**[Suggestion] Warning fires when deps.bridge is provided**
(r3270921402, server.ts:266)

Earlier r3270664727 fix added a startup stderr warning when
`deps.fsFactory` is not provided. wenshao caught that the warning
also fires when `deps.bridge` IS provided — but in that case the
embed owns its own fileSystem wiring (the default adapter never
runs), so the warning's claim about ACP writes rejecting is false.

Fix: narrow guard to `!deps.fsFactory && !deps.bridge`. Comment
expanded to explain why bridge-injection suppresses the warning.

**[Suggestion] No oversized-payload test for writeTextOverwrite**
(r3270921399, ws.ts:835)

`writeTextOverwrite` calls `enforceWriteSize(decodedSizeBytes)`
mirroring `writeText`'s 5 MiB cap, but the existing oversized-write
test only exercises `writeText`. A regression dropping the check on
the new method would let agents (the primary consumer) write
arbitrarily large files undetected.

Test: `writeTextOverwrite rejects content exceeding MAX_WRITE_BYTES
with file_too_large`.

Verification
- `npx vitest run packages/cli/src/serve/` — 18 files, 764/764 pass
  (+4 new tests over the previous 760)
- Pre-commit (`prettier --write` + `eslint --fix --max-warnings 0`)
  clean on all 5 staged files
Comment thread packages/cli/src/serve/server.ts
Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts
Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.ts
Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.ts Outdated
Comment thread packages/cli/src/serve/httpAcpBridge.test.ts
Comment thread packages/cli/src/serve/fs/workspaceFileSystem.ts
Comment thread packages/cli/src/serve/bridgeFileSystemAdapter.test.ts Outdated
Comment thread packages/cli/src/serve/server.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
… + 3 Suggestion)

Adopts 4 of 5 new threads from the DeepSeek-v4-pro review round on
PR #4334 (Qwen `/review`). The 5th (DWcK8) is a duplicate of a test
already in commit 9f73b83 — declined separately with a pointer.

**[Critical] Trust-default asymmetry between runQwenServe ↔ createServeApp**
(r3270978579, server.ts DWcK4)

`runQwenServe.ts` defaults `trusted: true` (production daemon),
`server.ts` defaults `trusted: false` (test-safe). The asymmetry is
intentional but lives in two places — a future maintainer can break
the alignment without any compile-time signal. The earlier stderr
warning (commit e185409) covers the embed-omits-fsFactory case but
NOT a regression in the runQwenServe → createServeApp pass-through.

Fix: extract `resolveBridgeFsFactory(input)` helper in `server.ts`
(exported alongside `createDefaultFsAuditEmit`). Both call sites use
it. Trust stays a REQUIRED parameter — the policy difference is
preserved at the call sites, but the construction shape (build vs
inject + audit-emit default) is centralized. Defense-in-depth, not
behavior change.

**[Suggestion] adapter JSDoc claim about `mapDomainErrorToErrorKind`
is misleading** (r3270978595, DWcLB)

The docstring at `bridgeFileSystemAdapter.ts:38` says "the bridge's
existing `mapDomainErrorToErrorKind` classifier downstream picks up
`FsError` codes". This is false: `mapDomainErrorToErrorKind` in
`acp-bridge/src/status.ts` checks `instanceof` / `.name` / `.code`
(Node errno names), but has NO branch reading `err.kind` (FsError's
discriminator: `untrusted_workspace` / `symlink_escape` / etc.).
Errors still propagate (the `.kind` field rides through on the
thrown FsError object itself), but a future maintainer debugging
error classification during an incident would chase the wrong code
path.

Fix: rewrite the docstring to describe the actual flow — `FsError`
is thrown unchanged through BridgeClient's ACP handlers; downstream
consumers reading the ACP error payload key on `.kind` directly. The
HTTP `sendFsError` serializes the same `.kind`, so SDK consumers see
the same shape from either surface. Adding a real `instanceof
FsError` branch to `mapDomainErrorToErrorKind` would need cross-
package imports (FsError lives in `cli/src/serve/fs`, classifier in
`acp-bridge`) — explicitly deferred to a separate PR.

**[Suggestion] adapter readText error propagation untested**
(r3270978593, DWcK_)

Read-side errors from `wfs.readText` (`file_too_large`,
`binary_file`, `symlink_escape`) propagate untested through the
adapter — the existing tests cover trust-gate (already write-only),
line/limit forwarding, null/non-positive guards, and boundary, but
not the `FsError` classes themselves. A regression silently
swallowing or wrapping them would only fail HTTP-fs tests.

Fix: add 3 adapter tests pinning `file_too_large` / `binary_file` /
`symlink_escape` propagation surface as-is via the adapter's
re-thrown error.

**[Suggestion] channelInfoForEntry HAZARD comments on bridge.ts fix
sites** (r3270978598, DWcLD)

The regression test for the `#4325` fix
(`httpAcpBridge.test.ts:6421`) is single-channel smoke only — its
own comment acknowledges "a reverted fix that captured `channelInfo`
after the entry was gone from `byId` would also pass this assertion".
The actual overlap-race state isn't deterministically constructable
without factory-internal hooks. Until the deterministic test lands,
the only defense against accidental revert is code-review visibility.

Fix: add `HAZARD(#4325)` comments at both `closeSession` and
`killSession` fix sites in `acp-bridge/src/bridge.ts`, explicitly
flagging that the existing smoke test would not catch a revert and
that the `channelInfoForEntry(entry)` call must NOT be refactored
away without first landing the deterministic overlap test.

Verification
- `npx vitest run packages/cli/src/serve/` — 18 files, 767/767 pass
  (+3 new adapter tests; the prior 760→767 includes runs of multi-
   tick fold-ins on the same branch).
- `cd packages/acp-bridge && npx vitest run` — 5 files, 62/62 pass
- Pre-commit (`prettier --write` + `eslint --fix --max-warnings 0`)
  clean on all 5 staged files
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No high-confidence issues found. The previous review's 4 findings (EACCES/EPERM swallow, writeTextOverwrite size cap test, non-positive line/limit filtering, stderr warning suppression for deps.bridge) were all correctly addressed in commit e185409. Build passes, all 266 tests green. LGTM! ✅ — qwen-latest-series-invite-beta-v34 via Qwen Code /review

Adopts the second round of DeepSeek-v4-pro suggestions on PR #4334.
All 4 are small, targeted improvements without controversy.

**DWrbe — WriteMode admits 'overwrite' at compile time** (r3271063030)

`WriteTextAtomicOptions.mode` was typed as `WriteMode` (which
includes 'overwrite'), but `validateWriteTextAtomicOptions` throws
`parse_error` for that value. The runtime error catches misuse but
TypeScript happily lets the call through.

Fix: introduce `AtomicWriteMode = Exclude<WriteMode, 'overwrite'>`
public type and narrow `WriteTextAtomicOptions.mode` to it. Runtime
validator stays as defense-in-depth.

**DWrbl — boundary tests use bare .rejects.toThrow()** (r3271063040)

Both boundary-enforcement adapter tests asserted "throws" without
pinning the FsError kind. Incidental OS errors (CI container EACCES
on /etc/passwd) or future pre-check additions could pass these
tests trivially while masking that boundary enforcement isn't
firing.

Fix: assert `.kind === 'path_outside_workspace'` for both sides.

**DWrbn — trust warning floods stderr in tests** (r3271063045)

The startup warning fires on every createServeApp call. server.test.ts
calls createServeApp ~25 times, masking genuine failures.

Fix: module-scoped once-per-process guard `warnedDefaultTrust`. Module
scope (not per-app closure) because the warning is a posture statement
about this binary, not per-instance.

**DWrbr — channelInfoForEntry undefined is silent** (r3271063052)

closeSession / killSession's cleanup branches short-circuit silently
when channelInfoForEntry returns undefined (entry's channel torn
down). The "closing session" log fires but the skipped-cleanup fact
is invisible, making zombie-channel debugging harder.

Fix: emit stderr diagnostic naming session id + which method
short-circuited + likely cause. Sibling methods like
requestSessionStatus throw SessionNotFoundError; close/kill are
idempotent so we log instead.

Verification: serve 767/767, acp-bridge 62/62, pre-commit clean.
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — qwen-latest-series-invite-beta-v34 via Qwen Code /review

@doudouOUC doudouOUC merged commit dfa8ca4 into daemon_mode_b_main May 20, 2026
10 checks passed
@doudouOUC doudouOUC deleted the feat/f1-followup-fs-adapter-test-split-channelinfo branch May 20, 2026 05:10
doudouOUC added a commit that referenced this pull request May 20, 2026
Mixed batch: bridge-test backfill from wenshao's APPROVED review
plus 4 DeepSeek/v4-pro suggestions and the 3 typecheck/test
blockers DeepSeek named in CHANGES_REQUESTED #4325674833.

**Pre-merge blockers (DeepSeek #4325674833 body)**

- `server.test.ts:529` `FakeBridge` — added the F3-required
  `permissionPolicy: 'first-responder' as const`. Tests don't
  exercise mediation; the literal pins the pre-F3 default so
  existing assertions stay shape-compatible.

- `server.test.ts:3994` `WorkspaceFileSystemFactory.forRequest()`
  mock — added the missing `writeTextOverwrite` method that PR
  #4334 introduced on `WorkspaceFileSystem` after this branch
  forked.

- 4 vote-context test failures from `fromLoopback` plumbing —
  updated the four `expect(...).toEqual(...)` assertions in
  `POST /session/:id/permission/:requestId` and
  `POST /permission/:requestId` to include `fromLoopback: true`
  on the captured context. The supertest peer is `127.0.0.1`,
  so `detectFromLoopback(req)` correctly stamps the field; the
  pre-F3 expected shape was stale.

**Inline suggestions adopted**

- **3271420267** (wenshao APPROVED, security-critical) — added
  bridge-level test `rejects cancel sentinel injection via
  {selected,'__cancelled__'}` in `httpAcpBridge.test.ts`. Without
  it, a future refactor could silently remove the wire-injection
  guard that closes the policy-bypass attack surface introduced
  in Round 5 (#3271185588). Required `npm run build
  --workspace=packages/acp-bridge` to refresh `dist/` before
  vitest picked up the F3 bridge.ts changes; documented for
  future contributors editing F3 acp-bridge code.

- **3271627444** (DeepSeek) — `request()` JSDoc rewritten to
  drop "Promise contract — never rejects" without qualification.
  The `CancelSentinelCollisionError` synchronous throw is real
  and intentional (a never-settling Promise alongside a thrown
  error is worse than fail-fast), but callers must be aware of
  it. Updated the contract doc to call out the sync-throw
  exception explicitly and documented that async callers get
  the throw via their own Promise machinery.

- **3271627446** (DeepSeek) — fixed "Bounded LRU" comment on
  `MAX_RESOLVED_PERMISSION_RECORDS` to "Bounded FIFO" since
  `rememberResolved` uses `resolvedOrder.shift()` (drop oldest).
  Mirrors the parallel `PermissionAuditRing` correction in
  commit b0242dd.

- **3271627457** (DeepSeek) — added stderr breadcrumbs to all 3
  forbidden-vote sites (voteDesignated / voteConsensus /
  voteLocalOnly). Audit ring is in-memory only (no v1 query
  route), SSE events are transient — operators tailing daemon
  stderr previously had zero indication of permission rejections.
  New `writeForbiddenStderr` helper centralizes the formatting
  + try/catch defensive posture (mirrors the timeout breadcrumb
  pattern from Round 4).

- **3271627459** (DeepSeek) — added a `TODO(forward-compat)`
  comment at `voteConsensus`'s rejection site documenting the
  `designated_mismatch` reason-code overload. The same wire
  string covers two distinct semantic cases: "voter is not the
  prompt originator" (designated policy) and "voter not in
  consensus votersAtIssue snapshot" (consensus). Splitting them
  into distinct codes is deferred to a future PR once an SDK
  consumer needs to disambiguate.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
doudouOUC added a commit that referenced this pull request May 20, 2026
…4335)

* feat(acp-bridge): F3 — multi-client permission coordination (#4175) [rebased onto F1]

Squashed F3 implementation rebased from origin/main onto
daemon_mode_b_main (post-F1 #4319). F1 lifted the bridge core to
@qwen-code/acp-bridge package; F3's edits to the pre-F1
httpAcpBridge.ts BridgeClient class + factory were ported to the
new file locations:

  - BridgeClient.requestPermission rewrite → bridgeClient.ts
  - Factory mediator construction / pendingPermissions deletion /
    cancelPendingForSession refactor / respondTo*Permission
    rewrites / pendingPermissionCount + permissionPolicy getters /
    teardown sites (closeSession, killSession, shutdown drain)
    → bridge.ts
  - Error class re-exports → cli/src/serve/httpAcpBridge.ts shim
    (added CancelSentinelCollisionError, PermissionForbiddenError,
    PermissionPolicyNotImplementedError to the F1 re-export block)

This commit folds 13 logical F3 commits + 4 review fold-ins (Copilot
inline comments + 3 final-pass agent reviews) into a single
post-rebase squash. The full review trail is in
.claude/plans/fluttering-coalescing-kettle*.md (worktree-local).

Strategies (4): first-responder (default, byte-for-byte preserved),
designated, consensus (default N=floor(M/2)+1), local-only.

New SSE events: permission_partial_vote, permission_forbidden.
Capability tag: permission_mediation (always-on with build-supported
modes list); active policy at /capabilities.policy.permission.

Settings: policy.permissionStrategy enum + policy.consensusQuorum
number, both requiresRestart: true (F3 v1 reads at boot).

3 new typed errors: PermissionForbiddenError → 403,
PermissionPolicyNotImplementedError → 501 (forward-compat for future
policy literals), CancelSentinelCollisionError → 500 (agent / daemon
contract violation).

Hardness invariants: N1 synchronous-register, N2 cleanup ordering,
N3 originatorClientId stamping, O5 cancel sentinel pre-publish
collision check, O8 pre-F3 permission_resolved wire shape preserved.

Tests: 35 mediator unit + 10 audit ring + 56 SDK reducer + 6
bridgeClient + 3 bridge integration. Pre-existing
httpAcpBridge.test.ts cross-session-vote suite passes byte-for-byte.

Issue: #4175 (F3)

* fix(f3): build/capability fixes from Copilot review (#4335)

- packages/sdk-typescript/src/daemon/index.ts: re-export the four F3
  permission event types (`DaemonPermissionForbiddenData/Event`,
  `DaemonPermissionPartialVoteData/Event`) so the public package barrel
  at `src/index.ts` (which forwards them via `from './daemon/index.js'`)
  resolves at build time. Without this fix `npm run build
  --workspace=packages/sdk-typescript` failed with TS2305/TS2724;
  vitest passed only because it resolves TS source via tsx and
  bypasses tsc compilation. Reported in PR #4335 review comments
  3270615836 / 3270622302 (wenshao via Qwen Code /review).

- packages/cli/src/serve/server.test.ts: append `'permission_mediation'`
  to `EXPECTED_STAGE1_FEATURES` and adjust `EXPECTED_REGISTERED_FEATURES`
  reordering so the test fixture matches the registry's actual order
  (`...workspace_mcp_restart, require_auth, auth_device_flow,
  permission_mediation`). Without this fix four `serve capability
  registry` tests asserted via `.toEqual` against a stale list.

- docs/developers/qwen-serve-protocol.md: swap `permission_mediation`
  and `auth_device_flow` in the documented capability list so the order
  mirrors `SERVE_CAPABILITY_REGISTRY` declaration order.

- packages/vscode-ide-companion/schemas/settings.schema.json: regenerate
  the IDE-companion JSON schema with the new `policy` section (was
  pending from Commit 5 of the F3 series; checked in here so the
  IDE companion sees the same `permissionStrategy` / `consensusQuorum`
  shape that the CLI accepts).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): wire production audit ring + restore timeout stderr (#4335)

Wenshao review #4335 surfaced two related Critical findings:

1. **Audit publisher silently no-op in production** (3270622298). The
   `bridgeOptions.ts:305` JSDoc claimed "the bridge allocates an
   internal `PermissionAuditRing`" but the actual fallback at
   `bridge.ts:543` is `createNoOpPermissionAuditPublisher()`, and
   `runQwenServe.ts` never wired one. All 5 audit record types
   (`requested`, `voted`, `forbidden`, `resolved`, `timeout`) were
   silently discarded — the forensic audit trail the F3 plan
   committed to ("ring 留给后续 PR 加查询接口") never existed in
   any deployed daemon.

2. **Timeout breadcrumb lost** (3270622304). Pre-F3 wrote
   `"timed out after Xms"` to daemon stderr on every permission
   timeout. F3 removed that direct write and delegated to
   `audit.recordTimeout()`, but the audit publisher is the no-op
   fallback in production (see #1). Operators tailing daemon
   stderr could no longer observe permission timeouts.

Fixes:

- `runQwenServe.ts` allocates a `PermissionAuditRing` (default cap 512)
  + `createPermissionAuditPublisher` and passes the publisher via
  `BridgeOptions.permissionAudit`. The ring is held in the daemon
  host's closure for the lifetime of the daemon — a future
  `GET /workspace/permission/audit` route (out of F3 v1 scope) can
  lift it out for query without further bridge changes.

- `permissionMediator.ts` writes the stderr breadcrumb directly from
  the timer callback, before forwarding to the (potentially no-op)
  audit publisher. Wrapped in try/catch because `process.stderr.write`
  can synchronously throw on EPIPE — losing observability is
  preferable to crashing the timer queue.

- `bridgeOptions.ts` JSDoc rewritten to match reality: the bridge
  falls back to a no-op publisher; production wiring lives in
  `runQwenServe.ts`; the stderr breadcrumb is in the mediator
  (independent of the publisher).

- New unit test `writes a stderr breadcrumb when the timer fires`
  spies on `process.stderr.write` and asserts the breadcrumb format
  contains the requestId, sessionId, and the timeout duration so
  future refactors can't silently drop the line again.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): drop dead helper + propagate originator to F3 view state (#4335)

Two small follow-ups from wenshao review #4335:

- **`bridge.ts:672-682` — dead `_resolutionToAcpResponse` helper**
  (3270622309). Defined and immediately suppressed with `void`. The
  identical `resolutionToAcpResponse` lives at `bridgeClient.ts:41`
  and is the one actually used by `BridgeClient.requestPermission`
  — the bridge-factory copy was a stranded leftover from the lift
  out of inline closures into the mediator pattern. Removed
  declaration, `void` statement, and the now-unused
  `RequestPermissionResponse` (`@agentclientprotocol/sdk`) and
  `PermissionResolution` (`./permission.js`) imports.

- **SDK reducer `mergeOriginator` for F3 events** (3270622311). The
  mediator stamps `originatorClientId` (= prompt originator per
  N3) on the `permission_partial_vote` / `permission_forbidden`
  envelope, but the reducer cases used `next.push({ ...event.data })`
  which only copies `data` fields. SDK consumers reading
  `permissionVoteProgress[reqId]` / `forbiddenVotes[i]` could not
  determine which client's prompt was targeted by the partial-vote
  progress / forbidden vote — same gap PR #4282 fixed for
  approval-mode / tool-toggle / workspace-init / mcp-restart.

  Applied the existing `mergeOriginator` helper to both reducer
  cases. Added `originatorClientId?: string` to both Data
  interfaces with JSDoc explaining the propagation contract
  (preserve any pre-existing `data.originatorClientId`; otherwise
  stamp from the envelope; for forbidden votes the field is
  distinct from `data.clientId` which carries the rejected voter).

  Three new reducer tests:
  1. `permission_partial_vote` propagates envelope originator into
     `permissionVoteProgress`.
  2. `permission_forbidden` propagates envelope originator into
     `forbiddenVotes`, distinct from `data.clientId`.
  3. `mergeOriginator` preserves any pre-existing
     `data.originatorClientId` over the envelope value.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): wenshao Round 4 — defensive stderr, audit accuracy, orphan cleanup (#4335)

Four findings from wenshao review #4324937255 — the Critical one
masked an actual hang scenario; the other three are observability /
correctness fixes that round out F3 v1.

**[Critical] safeEmit / safeAudit stderr breadcrumb wraps** (3271041461).
Both helpers wrote `process.stderr.write` inside their `catch` block
WITHOUT a nested `try/catch`. If stderr itself synchronously throws
(EPIPE during daemon shutdown), the exception escapes the "safe"
wrapper. In `resolveEntry`'s cleanup ladder
(`safeEmit → rememberResolved → safeAudit → pending.resolve`), an
escaping safeEmit exception aborts before `pending.resolve(resolution)`
runs — the request was already deleted from `this.pending` (no
double-resolve guard), so the agent's awaiting Promise never
settles. `requestPermission` hangs until the timeout fires. The
timer callback already wraps its breadcrumb in `try/catch` for the
same reason — applied the matching pattern to safeEmit + safeAudit.

**[Suggestion] Idempotent re-vote audit shows attempted optionId,
not the original** (3271041464). When `client_A` originally voted
for `proceed_once` and later attempts `proceed_always`, the tally
silently keeps `proceed_once` (idempotent) but the audit ring
recorded `optionId: proceed_always`. An operator reading the ring
would see a vote for proceed_always that never counted toward
quorum. Look up the originally-voted option from the tally and
substitute it into the audit record. Added regression test
asserting the audit reflects tally state.

**[Suggestion] SDK reducer leaks `permissionVoteProgress` on
mid-permission reconnect** (3271041465). When an SDK client
reconnects and misses `permission_request`, then receives
`permission_partial_vote` (stored in `permissionVoteProgress`),
then receives `permission_resolved` — the early-return path on
unmatched `requestId` did NOT clear `permissionVoteProgress`. The
orphan progress entry persisted until session end. Both
`permission_resolved` and `permission_already_resolved` reducer
cases now unconditionally clear any orphan entry on the unmatched
path. Two new reducer tests cover the recovery contract; the
misleading "the next `permission_resolved` will clear both"
comment on `permission_partial_vote` is corrected.

**[Suggestion] Document votersAtIssue snapshot timing window**
(3271041469). The snapshot fires synchronously after
`entry.events.publish`, with no event-loop yield between, so a NEW
HTTP client cannot register between publish and snapshot. But an
SSE-only subscriber (no `X-Qwen-Client-Id` registered yet) that
connected BEFORE publish is invisible to the snapshot — `consensus`
silently rejects its later vote as `forbidden`. Documented the
window in `votersForSession` JSDoc; future PRs surfacing
`eligibleVoters[]` on `permission_request.data` should source it
from the same snapshot for consistency. No code change — the
narrow window is acceptable for F3 v1, and the structural fix
(snapshot at publish time) requires bridge-level refactor.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): wenshao Round 5 — sentinel injection guard, observability, /8 loopback (#4335)

Four findings from wenshao review #4325130053. The Critical one is
a real security gap; the others are observability + correctness
hardening.

**[Critical] Cancel sentinel injection bypass** (3271185588). The
mediator's `vote()` recognizes `CANCEL_VOTE_SENTINEL` BEFORE
validating the option against `allowedOptionIds`, so a wire client
sending `{outcome:'selected', optionId:'__cancelled__'}` would
short-circuit ALL policy dispatch (designated originator check,
consensus quorum, local-only loopback gate). The mediator's JSDoc
documented the precondition ("callers MUST NOT forward an
incoming vote.optionId === CANCEL_VOTE_SENTINEL from a wire
client") but the precondition was never enforced — the bridge's
`respondToSessionPermission` mapped the wire optionId straight
through. Added an explicit `InvalidPermissionOptionError` throw
when the wire payload is `{selected, CANCEL_VOTE_SENTINEL}`. The
collision-defense at request issue time
(`CancelSentinelCollisionError`) already prevents agents from
advertising the sentinel as a legitimate option; this closes the
remaining vector.

**[Suggestion] Silent quorum cap + M=0 hang observability**
(3271185594). Two related diagnostic gaps in the consensus
policy:
- When `policy.consensusQuorum` exceeds `votersAtIssue.size`, the
  cap fires silently. Operators investigating "why did consensus
  resolve at N=2 when I configured 5?" had no breadcrumb.
- When `policy === 'consensus'` and `votersAtIssue.size === 0`,
  every vote rejects as `forbidden: designated_mismatch` because
  the empty snapshot can never match any voter clientId. The
  request hangs until `permissionTimeoutMs` with no
  diagnostic signal.

Added stderr breadcrumbs at both points: cap-applied (once per
request via a `consensusQuorumCapNoted` flag on `MediatorPending`)
and at issue time when consensus M=0. No semantic change — the
cap and the timeout-only resolution behavior are intentional per
the F3 plan; the breadcrumbs just make them debuggable.

**[Suggestion] detectFromLoopback misses 127.0.0.0/8** (3271185597).
Per RFC 1122 the entire `127.0.0.0/8` block is loopback. The
exact-match Set of three literals (`127.0.0.1`, `::1`,
`::ffff:127.0.0.1`) silently fail-CLOSED on legitimate
`127.0.0.2` / `127.0.1.1` / `::ffff:127.0.0.2` peers, causing
unexpected `remote_not_allowed` rejections under `local-only`
policy. Switched to a prefix test so the entire `/8` and its
dual-stack mirror are accepted. Direction stays fail-CLOSED for
unrecognized address shapes.

**[Suggestion] VSCode JSON schema integer/min validation**
(3271185604). `runQwenServe.ts` validates
`Number.isInteger(consensusQuorum) && >= 1`, but the generated
`settings.schema.json` declared `"type": "number"` so VSCode's
inline JSON Schema validation accepted `0` / `-1` / `1.5` and
the user only learned the value was invalid on the next daemon
restart. Added `jsonSchemaOverride: {type:'integer', minimum:1}`
to the `consensusQuorum` settings entry and regenerated the
schema. IDE editors now flag invalid values immediately.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): Round 6 — wenshao APPROVED + DeepSeek follow-ups (#4335)

Mixed batch: bridge-test backfill from wenshao's APPROVED review
plus 4 DeepSeek/v4-pro suggestions and the 3 typecheck/test
blockers DeepSeek named in CHANGES_REQUESTED #4325674833.

**Pre-merge blockers (DeepSeek #4325674833 body)**

- `server.test.ts:529` `FakeBridge` — added the F3-required
  `permissionPolicy: 'first-responder' as const`. Tests don't
  exercise mediation; the literal pins the pre-F3 default so
  existing assertions stay shape-compatible.

- `server.test.ts:3994` `WorkspaceFileSystemFactory.forRequest()`
  mock — added the missing `writeTextOverwrite` method that PR
  #4334 introduced on `WorkspaceFileSystem` after this branch
  forked.

- 4 vote-context test failures from `fromLoopback` plumbing —
  updated the four `expect(...).toEqual(...)` assertions in
  `POST /session/:id/permission/:requestId` and
  `POST /permission/:requestId` to include `fromLoopback: true`
  on the captured context. The supertest peer is `127.0.0.1`,
  so `detectFromLoopback(req)` correctly stamps the field; the
  pre-F3 expected shape was stale.

**Inline suggestions adopted**

- **3271420267** (wenshao APPROVED, security-critical) — added
  bridge-level test `rejects cancel sentinel injection via
  {selected,'__cancelled__'}` in `httpAcpBridge.test.ts`. Without
  it, a future refactor could silently remove the wire-injection
  guard that closes the policy-bypass attack surface introduced
  in Round 5 (#3271185588). Required `npm run build
  --workspace=packages/acp-bridge` to refresh `dist/` before
  vitest picked up the F3 bridge.ts changes; documented for
  future contributors editing F3 acp-bridge code.

- **3271627444** (DeepSeek) — `request()` JSDoc rewritten to
  drop "Promise contract — never rejects" without qualification.
  The `CancelSentinelCollisionError` synchronous throw is real
  and intentional (a never-settling Promise alongside a thrown
  error is worse than fail-fast), but callers must be aware of
  it. Updated the contract doc to call out the sync-throw
  exception explicitly and documented that async callers get
  the throw via their own Promise machinery.

- **3271627446** (DeepSeek) — fixed "Bounded LRU" comment on
  `MAX_RESOLVED_PERMISSION_RECORDS` to "Bounded FIFO" since
  `rememberResolved` uses `resolvedOrder.shift()` (drop oldest).
  Mirrors the parallel `PermissionAuditRing` correction in
  commit b0242dd.

- **3271627457** (DeepSeek) — added stderr breadcrumbs to all 3
  forbidden-vote sites (voteDesignated / voteConsensus /
  voteLocalOnly). Audit ring is in-memory only (no v1 query
  route), SSE events are transient — operators tailing daemon
  stderr previously had zero indication of permission rejections.
  New `writeForbiddenStderr` helper centralizes the formatting
  + try/catch defensive posture (mirrors the timeout breadcrumb
  pattern from Round 4).

- **3271627459** (DeepSeek) — added a `TODO(forward-compat)`
  comment at `voteConsensus`'s rejection site documenting the
  `designated_mismatch` reason-code overload. The same wire
  string covers two distinct semantic cases: "voter is not the
  prompt originator" (designated policy) and "voter not in
  consensus votersAtIssue snapshot" (consensus). Splitting them
  into distinct codes is deferred to a future PR once an SDK
  consumer needs to disambiguate.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): Round 7 — error precedence + 7 hardening fixes from wenshao (#4335)

8 findings from wenshao Round 7. The Critical one closes a session-
existence information leak; 6 Suggestions improve observability,
type safety, and test coverage; 1 documents the cancel-sentinel
escape hatch in the local-only setting description.

**[Critical] Error precedence regression in respondToSessionPermission**
(3271978329). When `peekSessionFor(requestId)` returned `undefined`
(timed out / LRU-evicted / never registered), the cross-session
guard at line 2033 didn't fire (`!== undefined` skips it), so
execution fell through to `resolveTrustedClientId` which throws
`InvalidClientIdError` (HTTP 400) when the caller's clientId
isn't registered. Pre-F3 returned `false` (HTTP 404) for unknown
requestIds regardless of clientId validity. Without the explicit
guard, a probe with a fabricated clientId could distinguish
"session exists with these registered clients" (400) from "no
such request" (404). Added an explicit `actualSessionId ===
undefined → return false` short-circuit BEFORE the clientId
validation. The defensive `unknown_request` switch case below
becomes unreachable in practice; left in place for defense-in-depth.

**[Suggestion] Cancel sentinel cross-policy escape hatch under
`local-only`** (3271978336). Documented in `voteLocalOnly` JSDoc
and the settings description that a remote voter can ABORT a
pending permission via `{outcome:'cancelled'}` even though they
cannot RESOLVE one. The F3 plan calls this out as intentional
(cross-policy cancel for consistency with first-responder /
designated / consensus); operators wanting strict-cancel-too need
a dedicated loopback-bound daemon. Doc-only — semantic change
deferred.

**[Suggestion] CapabilitiesEnvelope.policy.permission widens
silently** (3271978342). Replaced the inlined string-literal
union with `import type { PermissionPolicy } from
'@qwen-code/acp-bridge'`. Adding a 5th policy upstream would now
trigger a compile error here instead of silently accepting the
narrower set.

**[Suggestion] M=2 unanimity surprise** (3271978356). Default
quorum `floor(M/2)+1` requires unanimity for even M (M=2 →
quorum=2; both voters must agree). An operator picking
`consensus` with two clients expecting "majority of 2 = 1" gets
unanimity instead — a split vote silently hangs until
`permissionTimeoutMs`. Added stderr breadcrumb at issue time
when the default formula yields unanimity (M ≥ 2 and floor(M/2)+1
== M). Mirrors the existing M=0 / cap-applied breadcrumbs added
in Round 5. Formula stays unchanged (true majority for all M is
mutually exclusive with M=1 → quorum=1). Description in the
settings schema also calls out the M=2 case explicitly.

**[Suggestion] Cancel sentinel adversarial test gap**
(3271978359). The existing "resolves cancelled regardless of
policy" test used the originator under designated and a
votersAtIssue voter under consensus — those would be ACCEPTED by
the policies even without the sentinel bypass. Added two
adversarial tests that pin the cross-policy escape hatch:
non-originator voter under designated and not-in-snapshot voter
under consensus.

**[Suggestion] BridgeClient pre-publish collision test gap**
(3271978365). `bridgeClient.requestPermission` throws
`CancelSentinelCollisionError` BEFORE publishing the SSE
`permission_request` to prevent orphan events (the mediator-level
collision check in `mediator.request` happens too late if publish
goes first). Added test asserting the throw + asserting publish
was NOT called + asserting `pendingPermissionIds` was NOT
incremented.

**[Suggestion] Settings descriptions missing security caveats**
(3271978370). Added explicit caveats to `permissionStrategy`
description: (a) `designated` notes that client identity is
self-declared with no proof-of-possession (impersonation by
observing originatorClientId on SSE frames is possible); (b)
`local-only` notes the cancel-sentinel cross-policy escape hatch.
Schema regenerated to `vscode-ide-companion/schemas/settings.schema.json`.

**[Suggestion] Boot validation error class** (3271978374). Replaced
`err.message.includes('invalid policy.')` substring matching with
a dedicated `InvalidPolicyConfigError` class checked via
`instanceof`. A future reworded validation message would have
silently downgraded operator misconfiguration to "fall back to
defaults" under the previous fragile match.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): Round 8 — close legacy clientId oracle + 5 hardening fixes (#4335)

6 follow-up findings from wenshao Round 8 review #4326742064 (state:
COMMENTED — not blocking but addresses leftover risk surfaces).

**[Suggestion] Legacy `respondToPermission` info leak** (3272493777).
Round 7 closed the cross-session client-registration oracle on the
session-scoped vote route, but the legacy workspace-level route
(`POST /permission/<requestId>`) still called
`resolveAnyTrustedClientId` on unknown-requestId paths, throwing
`InvalidClientIdError` (400) for unregistered clientIds and
returning false (404) for registered ones — the same oracle. The
PR #4231 reasoning ("preserve security boundary") was inverted:
the 400-vs-404 distinction WAS the leak. Removed the call,
deleted the now-unused `resolveAnyTrustedClientId` helper, and
updated the previously-leak-asserting test (`rejects unknown
permission votes with unregistered client ids`) to assert the
new uniform `false` behavior across all 3 input shapes
(unregistered / registered / no-clientId).

**[Suggestion] Error-precedence regression test gap +
observability inconsistency** (3272493792). Two parts:
- Added regression test `returns false (not InvalidClientIdError)
  when session exists but requestId is unknown and clientId is
  unregistered` to lock the Round-7 fix against future refactors.
- Promoted the error-precedence guard's stderr line from
  debug-gated `writeServeDebugLine` to unconditional
  `writeStderrLine`, matching the `writeForbiddenStderr` posture
  in the mediator. Operators tailing stderr at 3 AM no longer
  need `QWEN_SERVE_DEBUG=1` to see unexpected 404s on the
  permission endpoint.

**[Suggestion] Settings description "UNANIMITY for even M" was
factually wrong** (3272493795). `floor(M/2)+1` equals M only when
M=2; for M=4 it gives 3 (supermajority), M=6 gives 4 (~67%).
The mediator's own unanimity warning correctly fires only when
M=2. Settings description now reads "UNANIMITY for M=2 (quorum=2,
both must agree) and supermajority for larger even M (M=4 →
quorum=3; M=6 → quorum=4)". VSCode JSON schema regenerated.

**[Suggestion] runQwenServe.ts inline policy unions** (3272493805).
Same drift-protection rationale as the types.ts fix in Round 7.
Imported `PermissionPolicy` from `@qwen-code/acp-bridge`,
replaced 3 inline unions: the `let` declaration, the `as` cast,
and the `VALID_PERMISSION_POLICIES` Set construction. Used a
typed-array + Set<string> pattern (drift caught at array
construction; runtime Set keeps `.has(string)` ergonomics).

**[Suggestion] InvalidPolicyConfigError discrimination needs
positive tests** (3272493818). Extracted the inline
`policyConfig`-validation logic into an exported
`validatePolicyConfig(policyConfig, onWarning?)` helper and
exported `InvalidPolicyConfigError` itself. Added 7 unit tests
covering: empty config, all 4 valid literals, invalid literal
throws (with class identity check + message regex), 4
non-positive-integer quorum cases throw, valid combination
returns, mismatch (consensusQuorum + non-consensus strategy)
emits warning without throwing, no-warning happy path, and
error messages name the failed field. The boot path in
`runQwenServe` now delegates to the helper (one call site,
DRY).

**[Suggestion] Unanimity breadcrumb spammed per-request**
(3272493829). The Round-7 unanimity stderr line fires inside the
synchronous Promise executor of every `request()` call, which
for a 2-client consensus session is EVERY permission request (M=2
unanimity is the normal operating mode, not a rare edge). Added
`unanimityBreadcrumbEmitted` boolean to the mediator class
(per-mediator dedup, parallel to `consensusQuorumCapNoted` on
`MediatorPending`). One emit per daemon lifetime — visible at
boot, silent thereafter. Comment also corrects the "for even M"
generalization to "for M=2" specifically, matching the actual
condition (`floor(M/2)+1 === M` only for M=1 and M=2).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): Round 9 — terminal-event forbidden cleanup + 7 hardening fixes (#4335)

8 follow-up findings from wenshao Round 9 (4 separate review
records: 4326832742 / 4326833568 / 4326844430 / 4326851074, the
last one a non-blocking comment review). 1 Critical + 7 Suggestions.

**[Critical] Terminal events leaked forbiddenVotes history**
(3272576003). `session_died` / `session_closed` / `client_evicted`
/ `stream_error` reducer cases cleared `pendingPermissions` and
`permissionVoteProgress` but not `forbiddenVotes` /
`forbiddenVoteCount`. Adapters reading view state for a dead
session would render stale rejection data. All 4 cases now
zero out the rejection ring + counter. Parameterized regression
test asserts the cleanup contract.

**[Suggestion] safeAudit JSDoc was orphaned over
writeForbiddenStderr** (3272567323). Two consecutive JSDoc
blocks were stacked back-to-back but the method definitions
followed in the opposite order, so IDE hover and API doc
generation showed `safeAudit`'s docs as `writeForbiddenStderr`'s.
Reordered method definitions so each JSDoc precedes its actual
method.

**[Suggestion] writeForbiddenStderr had no test coverage**
(3272568031). Added a 3-path test (designated / consensus /
local-only) that spies on `process.stderr.write` and asserts each
breadcrumb contains the expected reason fragment plus the
requestId + sessionId for grep-ability. Pins the format so a
future refactor can't silently drop the line.

**[Suggestion] resolveEntry numbered list contradicted code**
(3272581553). The N2-invariant cleanup ladder docstring bundled
"delete from pending + write to resolved" into step 2 ahead of
the SSE emit, but the actual code defers `rememberResolved`
until AFTER `safeEmit` (the I5 inline comment on line 1103
correctly explains this). Split step 2 into two halves around
the emit so the spec faithfully describes the ordering invariant.

**[Suggestion] Dead exports in bridgeClient.ts** (3272581548).
`MAX_RESOLVED_PERMISSION_RECORDS`, `PendingPermission`, and
`PermissionResolutionRecord` were defined and exported but no
longer referenced — the mediator owns the same state under
different names (`permissionMediator.ts:77` / `:319`). The
JSDoc still pointed at deleted closures (`registerPending`,
`resolvedPermissions` map). Removed all three definitions and
the matching re-exports in `cli/src/serve/httpAcpBridge.ts`.

**[Suggestion] detectFromLoopback prefix-match had no direct test**
(3272581557). Supertest in the broader server.test.ts suite
always connects from `127.0.0.1`, so the Round-5 prefix-match
fix for `127.x`-beyond-`.0.0.1`, `::1`, `::ffff:127.*`, and the
fail-closed branches had no coverage. Exported the helper from
`server.ts` (loosened parameter type to a minimal shape so tests
don't need to spin up Express) and added an `it.each` table
covering the variants the fix targets, plus an explicit "does
NOT consult X-Forwarded-For" assertion as a security pin.

**[Suggestion] Validate-policies set is a 4th hardcoded copy**
(3272581563). The policy literals already exist in 3 places —
`PermissionPolicy` type, `SERVE_CAPABILITY_REGISTRY.permission_
mediation.modes`, and `settingsSchema.ts` enum options.
`validatePolicyConfig` now derives its valid-set from
`SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` (single
runtime source of truth). Adding a 5th policy upstream lands in
one place; a future drift between the registry and the type
union would still surface at the `as PermissionPolicy` cast.

**[Suggestion] BridgeClient over-coupled to MultiClientPermissionMediator**
(3272581569). `BridgeClient` only ever calls `mediator.request()`
but its field was typed as the concrete class, forcing every
test stub to fake all 6 mediator members. Narrowed the field type
to `Pick<PermissionMediator, 'request'>` (the frozen interface
from `permission.ts`); the bridge factory still passes the full
`MultiClientPermissionMediator` instance via structural typing.
Test stubs simplified from 6 placeholder members to 1.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(f3): Round 10 — wenshao APPROVED + 3 final polish (#4335)

wenshao APPROVED the PR (review 4327485978: "No issues found in
the latest Round 9 changes... LGTM ✅") with 3 minor follow-up
suggestions in a separate COMMENTED review (4327443147). All
adopted; the 4th suggestion (3273077262) was already addressed
in Round 9.

**[Suggestion] Symmetric stderr breadcrumb on legacy
respondToPermission** (3273077256). The session-scoped sibling
already writes an unconditional `writeStderrLine` on its
`actualSessionId === undefined` rejection path (Round 8 /
3272493792); the legacy `POST /permission/<id>` route returned
`false` silently after the Round-8 oracle removal, leaving an
observability gap. Added matching `writeStderrLine`. Operators
tailing stderr at 3 AM now see legacy-route 404s without needing
QWEN_SERVE_DEBUG=1.

**[Suggestion] consensusQuorum contract mismatch** (3273077270).
The warning text told the operator "the override will be
ignored" but the function still propagated `permissionConsensusQuorum`
to BridgeOptions. The downstream mediator only reads it under
the consensus policy, so behavior was correct — but the public
contract contradicted itself. Adopt option (a): drop the value
to `undefined` when the strategy is not 'consensus' so the
returned struct matches what the warning promises. Updated the
existing `validatePolicyConfig` test to assert the new contract.

**[Suggestion] Stderr-breadcrumb assertion missing from
error-precedence regression test** (3273077272). The Round-8
test pinned the return-value behavior (`false`) but not the
unconditional-stderr promotion that was the primary behavioral
change of that hunk. Added `vi.spyOn(process.stderr, 'write')`
+ assertions for both "rejected permission vote" and the literal
requestId in the test. A future refactor that drops or downgrades
the log line is now caught.

**[Suggestion] _validPolicies underscore-prefix misleading**
(3273077262 — already addressed). Round 9's commit 6793b89
replaced the literal `_validPolicies` array with a single Set
derived from `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`
(per separate suggestion 3272581563). The underscore-prefixed
identifier is gone in current HEAD; replied via PR comment
pointing wenshao at the existing fix.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
doudouOUC added a commit that referenced this pull request May 20, 2026
…x round 2 fold-in)

Adopts Codex review round 2 P2 finding on PR #4360 — fold-in to the
F4 prereq scope per user's "a" decision.

**Problem**: When the `BridgeFileSystem` adapter (introduced in
#4334 fs adapter wiring) throws a structured `FsError` (e.g.
`kind: 'untrusted_workspace'` / `kind: 'symlink_escape'` / `kind:
'file_too_large'`), the `@agentclientprotocol/sdk` default RPC
error serialization only sends `error.message` as JSON-RPC -32603
"Internal error". The structured `kind` / `status` / `hint` fields
on FsError are stripped on the way to the agent.

Downstream impact: SDK consumers receiving the ACP error payload
lose the typed discriminator and have to regex-match the human-
readable message to dispatch UI (auth retry vs file picker vs
proxy hint). This silently regresses what the FsError-typed
contract was supposed to provide.

**Fix**: At the bridge boundary (`BridgeClient.writeTextFile` and
`BridgeClient.readTextFile`), catch errors from `this.fileSystem.
writeText/readText` calls. Duck-type check for FsError shape
(`err.name === 'FsError'` + `typeof err.kind === 'string'`); when
matched, rethrow as ACP `RequestError(-32603, message, {errorKind,
hint, status})`. The agent's RPC client now receives `data.
errorKind` and can branch on the closed-enum kind.

Cross-package note: FsError lives in `cli/src/serve/fs/errors.ts`
and acp-bridge can't `import { FsError }` from cli (dependency
inversion). Same duck-typing pattern that `mapDomainErrorToErrorKind`
(status.ts) already applies to `TrustGateError` / `SkillError` for
the same cross-package bundling reason — `instanceof` would fail
across package boundaries when bundlers don't dedupe.

**Code shape**

```typescript
function isFsErrorShape(err: unknown): err is FsErrorShape {
  return (
    err instanceof Error &&
    err.name === 'FsError' &&
    typeof (err as { kind?: unknown }).kind === 'string'
  );
}

function preserveFsErrorOverAcp(err: unknown): never {
  if (isFsErrorShape(err)) {
    throw new RequestError(-32603, err.message, {
      errorKind: err.kind,
      ...(err.hint !== undefined ? { hint: err.hint } : {}),
      ...(err.status !== undefined ? { status: err.status } : {}),
    });
  }
  throw err;
}
```

Applied at both `if (this.fileSystem) { ... }` blocks (writeTextFile
+ readTextFile) — wrapped the adapter call in try/catch +
`preserveFsErrorOverAcp(err)`. Non-FsError errors are rethrown
unchanged (default ACP serialization is fine for unstructured
errors; only the structured shape needs preservation).

JSON-RPC code stays at -32603 (internal error) rather than mapping
FsError.kind → JSON-RPC code. Rationale: the JSON-RPC standard
defines only a handful of code values (-32700/-32600/-32601/-32602/
-32603 + a reserved range for application errors), and mapping
~10 FsError kinds to that narrow space is lossy. Instead the
structured `data.errorKind` carries the semantic information SDK
consumers need; JSON-RPC code remains the generic "an error happened"
signal.

**Tests** (+5 in `bridgeClient.test.ts`)

- writeTextFile FsError → ACP RequestError with errorKind in data
- readTextFile FsError preserving symlink_escape kind (no hint
  field present → not stamped, spread guard works)
- non-FsError pass-through (plain Error stays plain Error, no
  RequestError wrap)
- hint field preservation when present
- defensive: error with `kind` field but wrong `name` does NOT get
  wrapped (e.g. PermissionForbiddenError happens to have a kind
  field internally — must NOT be confused for FsError)

Verification: 113/113 acp-bridge tests pass (+5 new FsError-
preservation tests). Full serve suite shows pre-existing F3-related
failures unrelated to this change (verified in isolation).
doudouOUC added a commit that referenced this pull request May 21, 2026
…amp / provenance / errorKind / state_resync_required) (#4360)

* feat(serve): stamp serverTimestamp / tool provenance / errorKind on daemon events (#4175 F4 prereq)

Adopts chiga0's three P0 SDK-side blockers from #4175 comment #19 —
the SDK side already consumes these fields (PR #4353), but daemon
hadn't stamped them yet, leaving the corresponding UI affordances
inert. All three stampings are purely additive on the wire and don't
require any SDK type changes (SDK already has forward-compat field
slots).

**#19.1 — `_meta.serverTimestamp` on every SSE frame** (`server.ts`
`formatSseFrame()`)

Stamped at the SSE write boundary (NOT EventBus.publish) so the
in-memory `BridgeEvent` type stays unchanged and internal consumers
don't see `_meta`. Pre-existing `_meta` keys (e.g. tool_call's
`_meta.toolName`) are preserved via spread merge. SDK reads via the
3-location probe in `extractServerTimestamp` (chiga0's PR #4353);
we pick `_meta.serverTimestamp` (Anthropic convention) so top-level
event type stays unpolluted.

Why this matters: pre-fix, multi-client UIs showing "X minutes ago"
or sorting transcript blocks by emit time used each client's local
clock — drifts of tens of seconds to minutes across browsers/tabs/
mobile produced visibly inconsistent timestamps.

**#19.2 — `tool_call` `provenance` + `serverId` on every emitter
event** (`ToolCallEmitter.ts`)

New static `ToolCallEmitter.resolveToolProvenance(toolName,
subagentMeta)` returns `{ provenance: 'builtin' | 'mcp' | 'subagent';
serverId? }`. Resolution rules (per user-confirmed design decision
from issue comment): subagent takes precedence (set when
subagentMeta is present); `mcp__<server>__<tool>` naming heuristic
classifies MCP tools with serverId; everything else is builtin.

Stamped on `emitStart` AND `emitResult` AND `emitError` (all three
emit paths) so a reconnecting client receiving a `tool_call_update`
frame from the replay ring (without the original `tool_call` start
event) can still derive the provenance. Provenance is stable per
tool, so stamping on every event is redundant — but the marginal
serialization cost is tiny and reconnect correctness wins.

Chose the naming heuristic (not ToolRegistry lookup) per user
confirmation: matches the SDK's own fallback (chiga0 PR #4353), no
new ctx-dep on emit hot path, no signature changes.

**#19.3 — `errorKind` on `stream_error`** (`server.ts` line ~1955)

Stamped via `mapDomainErrorToErrorKind(err)` — the 7-value classifier
already lives in `@qwen-code/acp-bridge/status.ts` since #4319. When
the classifier returns `undefined` (generic Error etc.) the field is
omitted — strictly additive. SDK consumers handle "errorKind absent"
as before (fall back to rendering `error` text).

NOT stamped on `session_died` because the 3 emit sites in `acp-bridge/
bridge.ts` don't have a classifiable `err` in scope:
- `channel_closed` carries only exitCode/signalCode (no error)
- `killed` is user-initiated (no domain error)
- `daemon_shutdown` is operator-initiated (no domain error)

A follow-up could thread channel-spawn errors through to the
session_died emit site to enable `errorKind: 'init_timeout'` /
`missing_binary` classification — left for a separate PR to avoid
mixing protocol stamping with lifecycle plumbing.

Verification
- `npx vitest run packages/cli/src/serve/server.test.ts -t "serverTimestamp|stream_error|errorKind"` — 5 pass
- `npx vitest run packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts` — 46 pass (+ 11 new tests for resolveToolProvenance + provenance stamping on all 3 emit paths)
- `npx vitest run packages/cli/src/acp-integration/session/HistoryReplayer.test.ts` — 17 pass
- TypeScript clean on touched regions; pre-existing F3 (#4335 merge) errors elsewhere are unrelated.

Existing test updates
- 15 `_meta: { toolName: 'X' }` assertions in ToolCallEmitter.test.ts updated to include `provenance: 'builtin'` (defensive — catches accidental drift if a future refactor stops stamping). 2 strict-equality assertions in HistoryReplayer.test.ts similarly updated. The first SSE-frame test in server.test.ts switched from `toEqual` to `toMatchObject` since `_meta.serverTimestamp` makes exact equality brittle; a dedicated test pins the new field's shape.

* feat(serve+sdk): detect SSE ring eviction on resume, expose state_resync_required (#4175 F4 prereq)

Closes the multi-client SSE reducer divergence bug Ilya0527 raised in
#4175 comment #15. Pre-fix scenario:

1. Consumer's SSE stream drops; client buffers `Last-Event-ID: N`.
2. Network reconnects long enough later that events `[N+1, ringHead-1]`
   were evicted from the daemon's per-session ring.
3. Daemon's `subscribe({lastEventId: N})` silently replays only the
   surviving suffix.
4. Consumer's SDK reducer keeps applying deltas as if the stream was
   contiguous. Its state has now drifted from the daemon's truth —
   no terminal signal, no warning. The `SessionState` reducer's
   "same event stream in → same state out" purity guarantee is
   broken.

The bug's blast radius is exactly when multi-client matters: F4
brings up the TUI / IDE / web client adapters that share session
state, so divergence becomes visibly inconsistent across clients.

**Daemon side** (`packages/acp-bridge/src/eventBus.ts`)

In `subscribe()`'s replay path, detect ring eviction by comparing
the ring's earliest id against `lastEventId + 1`. When a gap exists,
force-push a synthetic terminal `state_resync_required` frame BEFORE
the surviving replay events:

```
{ v: 1, type: 'state_resync_required',
  data: { reason: 'ring_evicted',
          lastDeliveredId: N,
          earliestAvailableId: M } }
```

Per user-confirmed design (issue comment discussion): the frame has
NO `id` (mirrors the `client_evicted` synthetic terminal pattern so
it doesn't burn a slot in the per-session monotonic sequence). Replay
continues after the resync frame — the SDK reducer auto-skips
subsequent deltas (see below) but the frames stay on the wire so
adapters have the option to compute a "what you missed" diff later.

**SDK side** (`packages/sdk-typescript/src/daemon/events.ts`)

Adds:
- `'state_resync_required'` to `DAEMON_EVENT_TYPES` union
- `DaemonStateResyncRequiredData` + `DaemonStateResyncRequiredEvent`
- `isStateResyncRequiredData` predicate
- `DaemonStreamLifecycleEvent` union widened
- Reducer state fields: `awaitingResync: boolean`,
  `resyncRequiredCount: number`, `lastResyncRequired?`
- Reducer case for `state_resync_required` — sets the flag, increments
  count, records data
- **Top-of-reducer gate**: when `awaitingResync === true`, all non-
  terminal events are auto-skipped (still advance `lastEventId`).
  Terminal lifecycle events (`session_died` / `session_closed` /
  `client_evicted` / `stream_error`) STILL apply — critical end-of-
  stream signals don't depend on prior state being current.
- Re-exported `DaemonStateResyncRequiredData` / Event from
  `daemon/index.ts` and `src/index.ts` (matches surface posture of
  sibling lifecycle types).

Consumer recovery contract: when `state.awaitingResync === true`,
call `loadSession` (out of band) to fetch the daemon's canonical
session snapshot, then reconstruct view state via
`createDaemonSessionViewState({...seed from loaded state})`. The
fresh state defaults `awaitingResync: false` so the seed implicitly
clears the flag.

**Side fix** (`stream_error` errorKind)

`DaemonStreamErrorData.errorKind?: string` typed for the optional
classification field that Commit 1 (`14637cd79`) added daemon-side.
Strictly additive — old daemons omit the field, SDK falls back to
rendering `error` text.

Verification
- `packages/acp-bridge`: 6 files, 108/108 pass (+5 new resync-detection
  tests; 1 existing "default ring size 8000" test updated to acknowledge
  the synthetic resync frame at the head of its replay batch).
- `packages/sdk-typescript`: 13 files, 451/451 pass (+8 new reducer
  resync tests covering set/skip/terminal-passthrough/recovery/
  repeated-resync/malformed-payload).
- TypeScript clean across both packages on touched regions.

* fix(acp-bridge): preserve FsError structure over ACP wire (#4360 Codex round 2 fold-in)

Adopts Codex review round 2 P2 finding on PR #4360 — fold-in to the
F4 prereq scope per user's "a" decision.

**Problem**: When the `BridgeFileSystem` adapter (introduced in
#4334 fs adapter wiring) throws a structured `FsError` (e.g.
`kind: 'untrusted_workspace'` / `kind: 'symlink_escape'` / `kind:
'file_too_large'`), the `@agentclientprotocol/sdk` default RPC
error serialization only sends `error.message` as JSON-RPC -32603
"Internal error". The structured `kind` / `status` / `hint` fields
on FsError are stripped on the way to the agent.

Downstream impact: SDK consumers receiving the ACP error payload
lose the typed discriminator and have to regex-match the human-
readable message to dispatch UI (auth retry vs file picker vs
proxy hint). This silently regresses what the FsError-typed
contract was supposed to provide.

**Fix**: At the bridge boundary (`BridgeClient.writeTextFile` and
`BridgeClient.readTextFile`), catch errors from `this.fileSystem.
writeText/readText` calls. Duck-type check for FsError shape
(`err.name === 'FsError'` + `typeof err.kind === 'string'`); when
matched, rethrow as ACP `RequestError(-32603, message, {errorKind,
hint, status})`. The agent's RPC client now receives `data.
errorKind` and can branch on the closed-enum kind.

Cross-package note: FsError lives in `cli/src/serve/fs/errors.ts`
and acp-bridge can't `import { FsError }` from cli (dependency
inversion). Same duck-typing pattern that `mapDomainErrorToErrorKind`
(status.ts) already applies to `TrustGateError` / `SkillError` for
the same cross-package bundling reason — `instanceof` would fail
across package boundaries when bundlers don't dedupe.

**Code shape**

```typescript
function isFsErrorShape(err: unknown): err is FsErrorShape {
  return (
    err instanceof Error &&
    err.name === 'FsError' &&
    typeof (err as { kind?: unknown }).kind === 'string'
  );
}

function preserveFsErrorOverAcp(err: unknown): never {
  if (isFsErrorShape(err)) {
    throw new RequestError(-32603, err.message, {
      errorKind: err.kind,
      ...(err.hint !== undefined ? { hint: err.hint } : {}),
      ...(err.status !== undefined ? { status: err.status } : {}),
    });
  }
  throw err;
}
```

Applied at both `if (this.fileSystem) { ... }` blocks (writeTextFile
+ readTextFile) — wrapped the adapter call in try/catch +
`preserveFsErrorOverAcp(err)`. Non-FsError errors are rethrown
unchanged (default ACP serialization is fine for unstructured
errors; only the structured shape needs preservation).

JSON-RPC code stays at -32603 (internal error) rather than mapping
FsError.kind → JSON-RPC code. Rationale: the JSON-RPC standard
defines only a handful of code values (-32700/-32600/-32601/-32602/
-32603 + a reserved range for application errors), and mapping
~10 FsError kinds to that narrow space is lossy. Instead the
structured `data.errorKind` carries the semantic information SDK
consumers need; JSON-RPC code remains the generic "an error happened"
signal.

**Tests** (+5 in `bridgeClient.test.ts`)

- writeTextFile FsError → ACP RequestError with errorKind in data
- readTextFile FsError preserving symlink_escape kind (no hint
  field present → not stamped, spread guard works)
- non-FsError pass-through (plain Error stays plain Error, no
  RequestError wrap)
- hint field preservation when present
- defensive: error with `kind` field but wrong `name` does NOT get
  wrapped (e.g. PermissionForbiddenError happens to have a kind
  field internally — must NOT be confused for FsError)

Verification: 113/113 acp-bridge tests pass (+5 new FsError-
preservation tests). Full serve suite shows pre-existing F3-related
failures unrelated to this change (verified in isolation).

* fix: 7 wenshao/copilot review fold-ins on #4360 (1 Critical + 6 Suggestion)

Adopts all 7 review threads from the first wenshao + Copilot review
round on PR #4360. All technical fixes (no judgment calls).

**[Critical] BridgeTimeoutError constructor blocks tsc** (wenshao
PRRT_kwDOPB-92c6DfcRI)

`server.test.ts:4670` called `new BridgeTimeoutError('initialize
timed out')` but the constructor signature is `(label: string,
timeoutMs: number)` — TS2554 blocked `tsc --noEmit` and `npm run
build`. Fixed to `new BridgeTimeoutError('initialize', 5000)` per
suggested fix; resulting message `"HttpAcpBridge initialize timed
out after 5000ms"` still satisfies the existing
`.toContain('timed out')` assertion.

**[Suggestion] Copilot JSDoc package name** (Copilot
PRRT_kwDOPB-92c6De-Sm, ToolCallEmitter.ts:210)

JSDoc referenced `@qwen-code/core/mcp-tool` but the actual package
is `@qwen-code/qwen-code-core` with the file at
`packages/core/src/tools/mcp-tool.ts`. Updated the reference.

**[Suggestion] Copilot errorKind type widening** (Copilot
PRRT_kwDOPB-92c6De-Ro, events.ts:244)

`DaemonStreamErrorData.errorKind` was typed as `string` and the
JSDoc said "7-value" closed enum — but `DAEMON_ERROR_KINDS` actually
has 8 values, and `SERVE_ERROR_KINDS` (daemon-side) has 9 (adds
`stat_failed`). Typed as `DaemonErrorKind | (string & {})` for
forward-compat: SDK consumers get IDE autocomplete on the known 8
kinds while still accepting future daemon-side additions (like
`stat_failed`) without a type error. Updated JSDoc to accurately
list 8 current values + call out the forward-compat widening.

Side observation (NOT in scope of this PR): `DAEMON_ERROR_KINDS`
(SDK) lacks `stat_failed` that exists in `SERVE_ERROR_KINDS`
(daemon). That's a separate drift fix.

**[Suggestion] TERMINAL wording misleading** (wenshao
PRRT_kwDOPB-92c6Dj-JL, eventBus.ts:369)

Comment called `state_resync_required` a "TERMINAL synthetic frame"
but it's emitted FIRST (before replay) and the stream stays OPEN.
Genuine terminals like `client_evicted` close the stream after the
frame. Rewrote the comment per suggestion: "id-less synthetic
frame... Unlike `client_evicted`, the stream stays OPEN" — so an
oncall reading the source at 3am gets the right mental model.

**[Suggestion] `_meta` merge dead code + stale reference** (wenshao
PRRT_kwDOPB-92c6Dj-JF, server.ts:2569)

The `existingMeta` merge reads `event._meta` at BridgeEvent top
level, but ToolCallEmitter's `_meta` lives nested inside
`event.data._meta` (publish path goes through `events.publish({type:
'session_update', data: params})`). In production `existingMeta` is
always undefined — the merge is a forward-compat escape hatch, not
an active merge. Also the comment referenced
`extractServerTimestamp` (sdk-typescript) which grep confirms
doesn't exist yet (it's planned in chiga0 PR #4353).

Rewrote the comment block to (1) acknowledge no current producer
sets `_meta` at the top level — it's a forward-compat hook for
future envelope-level metadata; (2) drop the stale
`extractServerTimestamp` reference and instead note that chiga0
PR #4353 plans the 3-location probe. Code shape unchanged
(forward-compat spread stays).

**[Suggestion] session_closed + client_evicted passthrough tests**
(wenshao PRRT_kwDOPB-92c6Dj-JW, daemonEvents.test.ts:2284)

`RESYNC_PASSTHROUGH_TYPES` has 5 members but only `session_died`
and `stream_error` had passthrough tests. Added two missing tests:
`session_closed` and `client_evicted` while awaitingResync.
Critical because if a future refactor accidentally drops either
from the set, a consumer in resync limbo would silently swallow
the terminal signal and the UI would hang on "loading resync
state…".

**[Suggestion] readTextFile non-FsError passthrough test** (wenshao
PRRT_kwDOPB-92c6Dj-JX, bridgeClient.test.ts:251)

The non-FsError pass-through test only covered `writeTextFile`.
Added a symmetric `readTextFile` test — the two `try/catch` blocks
in `bridgeClient.ts` are independent, so test parity guards against
divergent refactors (e.g. someone adding wrapping on one side but
not the other).

Verification
- `packages/acp-bridge`: 6 files, 114/114 pass (+1 new readTextFile
  non-FsError test).
- `packages/sdk-typescript`: 75/75 pass on daemonEvents.test.ts
  (+2 new session_closed / client_evicted passthrough tests).
- `packages/cli/src/serve/server.test.ts`: 248 tests pass on
  touched cases (5 SSE / serverTimestamp / stream_error tests).
  Pre-existing F3 (#4335 merge) test failures unrelated to this
  PR's changes — verified by stash-test-restore on clean tree.
- TypeScript clean on touched regions; `BridgeTimeoutError`
  2-arg fix unblocks `tsc --noEmit` for the test file.

* fix: 3 wenshao observability fold-ins on #4360 (all Suggestion)

Adopts all 3 threads from wenshao's second review round on PR #4360.
All Suggestion-level — daemon-side observability + 1 missing SDK
reducer test.

**[Suggestion] SSE ring eviction silently emits state_resync_required**
(PRRT_kwDOPB-92c6Dp_Uk, eventBus.ts:394)

Pre-fix: when a consumer reconnects past the ring boundary, the
daemon emits `state_resync_required` with zero stderr breadcrumb.
A 3am oncall chasing "my UI is frozen with stale state" couldn't
grep daemon logs to distinguish (a) ring undersized, (b) client
reconnecting too slowly, (c) network partition causing repeated
reconnects.

Fix: detect `next.value.type === 'state_resync_required'` in the
SSE handler's iter loop in `server.ts` and emit a `writeStderrLine`
with the gap details (`lastEventId`, `earliestInRing`, computed
`gap` count, `reason`). Logged at the route boundary rather than
inside `EventBus.subscribe` to keep the bus implementation pure +
concentrate daemon-side observability in the route handler that
already logs socket errors + heartbeats.

**[Suggestion] Bridge iterator throw forwarded to client but not
logged daemon-side** (PRRT_kwDOPB-92c6Dp_Uo, server.ts:1956)

Pre-fix inconsistency: the adjacent `res.on('error', ...)` handler
at line ~1925 logs SSE socket errors with `writeStderrLine`, but
the bridge-iterator-catch block at line ~1940-1965 sends a
`stream_error` SSE frame to the client AND swallows the error
daemon-side. When the bridge iterator throws (subprocess crash,
channel protocol error, unhandled rejection), distinguishing
"subprocess OOM-killed" from "protocol bug" required attaching a
debugger.

Fix: mirror the adjacent handler's pattern — add `writeStderrLine`
before the `stream_error` SSE frame send, including the classified
`errorKind` (when available) in brackets so operators can grep for
`[init_timeout]` / `[missing_binary]` etc.

**[Suggestion] No SDK reducer test verifying stream_error.errorKind
flowthrough** (PRRT_kwDOPB-92c6Dp_Uq, daemonEvents.test.ts:2331)

The daemon-side wire format is tested in `server.test.ts`
(`parsed.data.errorKind === 'init_timeout'`) and
`DaemonStreamErrorData` now declares `errorKind?`, but the SDK
reducer test suite never fed a `stream_error` event with
`errorKind` and asserted `state.streamError?.errorKind`. A future
refactor stripping `errorKind` from the reducer's data assignment
(e.g. spreading only `{error}`) would silently regress without
test signal.

Fix: added `captures errorKind on stream_error in view state` test
exercising the full pipeline — reducer receives stream_error with
errorKind, view state's `streamError.errorKind` matches.

Verification
- `packages/sdk-typescript`: 76/76 daemonEvents tests pass (+1
  new flowthrough test).
- `packages/cli/src/serve/server.test.ts`: 6 targeted serverTimestamp
  / stream_error / errorKind tests pass — server.ts changes are
  observability-only (no behavior change to wire format).
- Pre-existing F3 (#4335 merge) test failures elsewhere are
  unrelated to this PR's changes.

* test(serve): 2 wenshao observability fold-ins on #4360 (stderr log coverage)

Adopts both threads from wenshao's third review round on PR #4360.
Both Suggestion-level — pin the daemon-side stderr log artifacts that
commit `dce2fed0f` introduced. Pre-fix: the EventBus-level
state_resync_required emission was tested in eventBus.test.ts, and
the SSE wire shape was tested in server.test.ts, but the actual
operator-facing artifacts (the stderr log lines themselves) had no
test coverage. A regression swapping operands in the `gap`
arithmetic, dropping the sessionId from the log, or breaking the
`[errorKind]` suffix would ship silently and only surface when an
operator went grepping during an incident.

**[Suggestion] SSE ring eviction stderr log untested**
(PRRT_kwDOPB-92c6Dqtlb, server.ts:1948)

Added 2 tests:
- `writes a daemon-side stderr log on SSE ring eviction` — yields
  a `state_resync_required` frame from a fake bridge, spies on
  `process.stderr.write`, asserts the captured log contains
  `session sess-A` + `lastEventId=5` + `earliestInRing=12` +
  `gap=6 events` (pins the arithmetic) + `reason=ring_evicted` +
  `loadSession` (the recovery hint).
- `falls back to "?" placeholders when state_resync_required data is
  partial` — yields a frame with empty `data: {}`, asserts every
  `?? '?'` branch fires (lastEventId=? / earliestInRing=? /
  gap=? events / reason=?). Defensive against future daemon schema
  changes that drop one of these fields.

**[Suggestion] Bridge iterator error stderr log untested**
(PRRT_kwDOPB-92c6Dqtlh, server.ts:1993)

Added 2 tests:
- `writes a daemon-side stderr log on bridge iterator error` — fake
  bridge throws plain `Error('agent died')` mid-stream, captures
  stderr, asserts the log contains `session sess-A` + `agent died`,
  and **no** `[…]` suffix (plain Error → `mapDomainErrorToErrorKind`
  returns undefined → no suffix).
- `includes [errorKind] suffix in bridge iterator error log when
  classified` — fake bridge throws `BridgeTimeoutError('initialize',
  5000)`, asserts the log contains `[init_timeout]`. Pins the
  classified-vs-unclassified branch of the conditional suffix
  template.

All 4 tests use `vi.spyOn(process.stderr, 'write').mockReturnValue(
true)` + filter `mock.calls` for the relevant log prefix — same
pattern as the existing `mcp-client-manager.test.ts` stderr-spy
tests in core, plus `startupProfiler.test.ts` in cli.

Verification: 7/7 targeted observability tests pass. Pre-existing
F3 (#4335 merge) test failures elsewhere are unrelated to this
PR's changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants