feat(core): classify retryable transport/provider failures vs deterministic request errors#5
feat(core): classify retryable transport/provider failures vs deterministic request errors#5B-A-M-N wants to merge 11 commits into
Conversation
…ailures Add classifyError() to categorize errors without retrying deterministic request errors (400, 401, 403, 404, 422). Only retry transport/ provider failures: 429, 408, 409 (transient), 500-599, and network errors (ECONNRESET, ETIMEDOUT, socket closed, etc.). Also export isRetryableNetworkError() for use in retry logic.
0f374f3 to
4606f56
Compare
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/core/src/utils/retry.ts">
<violation number="1" location="packages/core/src/utils/retry.ts:406">
P2: The `message.includes('409')` fallback makes most 409 errors look transient, so deterministic conflicts can be retried/classified as retryable.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
…lict
The message.includes('409') fallback made ALL 409 errors appear
transient, preventing deterministic conflict classification. Only actual
transient indicators (lock/contention/conflict) should trigger it.
- geminiChat.ts: delegate shouldRetryOnError to classifyError for 408/409/network errors - retry.ts: deduplicate defaultShouldRetry → classifyError (single source of truth) - retry.ts: update isTransientCapacityError for persistent mode (408 + network errors) - retry.test.ts: add 15 integration tests for retryWithBackoff (408/409/network paths) - retry.test.ts: add 6 tests for updated isTransientCapacityError - geminiChat.test.ts: add 4 tests (408 retry, 409 transient retry, 409 deterministic no-retry, network retry) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove `await` from `expect(promise).rejects.toThrow()` calls that are followed by `vi.runAllTimersAsync()`. In vitest 3.2.4, awaiting the assertion promise before advancing fake timers creates a deadlock: the inner promise can't reject until timers advance, but timers can't advance while the microtask queue is blocked by the await. Also add explicit `initialDelayMs: 10` to tests using default options (7 attempts × 1500ms default = 76s of fake time, triggering the 5s test timeout), and reduce abort-signal test delay from 10s to 100ms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove await from expect(promise).rejects.toThrow() calls that are followed by vi.runAllTimersAsync(). In vitest 3.2.4, awaiting the assertion promise before advancing fake timers creates a deadlock: the inner promise can't reject until timers advance, but timers can't advance while the microtask queue is blocked. Also add explicit initialDelayMs: 10 to tests using default options (7 attempts x 1500ms default = 76s of fake time, triggering the 5s test timeout), and reduce abort-signal test delay from 10s to 100ms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove 'conflict' from isTransientConflict keywords (too broad: matches standard HTTP 409 reason phrase "Conflict"). Keep only 'lock' and 'contention'. - Fix 409 test to use proper Error instance instead of plain object (getErrorMessage returns "[object Object]" for plain objects, masking the bug). - Scope isTransientCapacityError to 429/529 only. 408 and network errors are no longer persistent-retryable since they can indicate permanent config issues (wrong endpoint, firewall) that would hang unattended jobs for hours. - Update isTransientCapacityError tests to match new scope. - Document accepted retryable categories in geminiChat.ts shouldRetryOnError. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/core/src/utils/retry.test.ts">
<violation number="1" location="packages/core/src/utils/retry.test.ts:103">
P2: Don't await the `rejects` assertion before advancing fake timers; it blocks these retry tests before the promise can settle.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Adds an in-flight discovery guard to McpClientManager.discoverMcpToolsForServer() that prevents concurrent re-discovery of the same server. Without this guard, overlapping calls (e.g. from health-check reconnect + incremental discovery) could spawn duplicate MCP child processes because the disconnect→connect sequence is not atomic. Changes: - Add inFlightDiscoveries Set to track per-server discovery in progress - Early-return if discovery is already in flight for the same server - Clean up failed clients in catch block so subsequent retries can proceed - Stop health check timer during re-discovery disconnect path - Clear inFlightDiscoveries in stop() for completeness - Add 2 tests: concurrent discovery deduplication + in-flight cleanup Closes QwenLM#3817 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
retry.test.ts: - Remove erroneous await before expect().rejects.toThrow() in ~15 fake-timer test locations that caused deadlocks (await blocked before runAllTimersAsync) - Update 408/409/network-error tests to use explicit classifyError-based shouldRetryOnError instead of relying on defaultShouldRetry (see below) retry.ts: - Narrow defaultShouldRetry to only retry 429/5xx, matching original behavior. Previously it delegated to classifyError which also retried 408, transient 409, and network errors — silently expanding retry behavior for all callers using the default (baseLlmClient.generateJson, client.generateContent). Callers that want broader retry (geminiChat) now use classifyError directly via a custom shouldRetryOnError. mcp-client-manager.ts: - Fix TOCTOU race: move inFlightDiscoveries.add() to immediately after the has() guard and before await disconnect(), preventing concurrent calls from slipping through during the async gap. - Wrap new McpClient() + connect/discover in try/finally so discoveryDone() runs even if the constructor throws, preventing permanent entry leaks. - Clean inFlightDiscoveries in disconnectServer() to prevent stale entries from blocking future discoveries. - Guard reconnectServer() against running when a discovery is already in flight, preventing false "Successfully reconnected" logs. geminiChat.ts: - Update comment to clarify isSchemaDepthError/isInvalidArgumentError are independent safety nets not covered by classifyError. - Remove redundant if (status === 400) return false (classifyError handles it). - Remove unused getErrorStatus import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… tests Remove await from const assertionPromise = expect(promise).rejects.toThrow() in ~15 locations where the assertion is followed by vi.runAllTimersAsync(). The await blocks the microtask queue before timers can advance, causing deadlocks in vitest 3.2.4. Add eslint-disable-next-line vitest/valid-expect to prevent the pre-commit hook from re-adding the await (the rule doesn't account for this fake-timer pattern where await must come after runAllTimersAsync). All 105 retry tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- removeServer() now cleans inFlightDiscoveries and isReconnecting - reconnectServer() no longer has racy in-flight check; verifies server is actually connected before reporting success - Add test for cleanup state after server removal Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- retry.ts: remove overly-broad ENOTFOUND/EHOSTUNREACH/"network error" substring from isRetryableNetworkError; fix isTransientConflict to use word-boundary regex preventing false positives on "blocked"/"clock"/"flock" - retry.test.ts: update tests to reflect removed retryable codes - geminiChat.test.ts: add test confirming invalid argument errors are not retried - mcp-client-manager.ts: add inFlightDiscoveries guard in reconnectServer() after reconnect delay to prevent unnecessary reconnects Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 12 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="RUN2.md">
<violation number="1" location="RUN2.md:1">
P2: Do not commit raw local agent/terminal transcript files; this adds large non-source noise and leaks machine-specific operational details.</violation>
</file>
<file name="packages/core/src/utils/retry.ts">
<violation number="1" location="packages/core/src/utils/retry.ts:406">
P2: The 409 transient regex does not match "locked", so lock-related transient conflicts can be incorrectly treated as non-retryable.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
| @@ -0,0 +1,599 @@ | |||
| ╭─── Claude Code v2.1.109 ─────────────────────────────────────────────────────╮ | |||
There was a problem hiding this comment.
P2: Do not commit raw local agent/terminal transcript files; this adds large non-source noise and leaks machine-specific operational details.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At RUN2.md, line 1:
<comment>Do not commit raw local agent/terminal transcript files; this adds large non-source noise and leaks machine-specific operational details.</comment>
<file context>
@@ -0,0 +1,599 @@
+╭─── Claude Code v2.1.109 ─────────────────────────────────────────────────────╮
+│ │ Tips for getting started │
+│ Welcome back! │ Run /init to create a CL… │
</file context>
Tip: Review your code locally with the cubic CLI to iterate faster.
| // 'conflict' is excluded because it appears in the standard HTTP 409 reason | ||
| // phrase "Conflict", which would make all standards-compliant 409s transient. | ||
| return ( | ||
| new RegExp('\\bLOCKS?\\b', 'i').test(message) || |
There was a problem hiding this comment.
P2: The 409 transient regex does not match "locked", so lock-related transient conflicts can be incorrectly treated as non-retryable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/utils/retry.ts, line 406:
<comment>The 409 transient regex does not match "locked", so lock-related transient conflicts can be incorrectly treated as non-retryable.</comment>
<file context>
@@ -396,10 +398,14 @@ const RETRYABLE_NETWORK_CODES = new Set([
// phrase "Conflict", which would make all standards-compliant 409s transient.
- return message.includes('lock') || message.includes('contention');
+ return (
+ new RegExp('\\bLOCKS?\\b', 'i').test(message) ||
+ message.includes('contention')
+ );
</file context>
| new RegExp('\\bLOCKS?\\b', 'i').test(message) || | |
| new RegExp('\\bLOCK(?:ED|S)?\\b', 'i').test(message) || |
…e + Agent isolation (QwenLM#4073) * feat(tools): add generic worktree support (Phase A + B of QwenLM#4056) Adds first-class git worktree as a general-purpose capability: Phase A — User-facing tools - enter_worktree: creates `<projectRoot>/.qwen/worktrees/<slug>` on a `worktree-<slug>` branch and returns the absolute path. Slug auto-generated when omitted; validated against path traversal and disallowed characters. - exit_worktree: keeps or removes the worktree (and its branch). Refuses to remove a worktree with uncommitted tracked changes or untracked files unless `discard_changes: true` is set. Phase B — Agent isolation - Agent tool gains an `isolation: 'worktree'` parameter that provisions a temporary `agent-<7hex>` worktree, prepends a worktree notice to the task prompt, and on completion either removes the worktree (no changes) or preserves it and reports its path/branch in the result. Background and foreground execution paths both wired up; rejected for fork agents. - worktreeCleanup.cleanupStaleAgentWorktrees: fail-closed sweep for ephemeral `agent-<7hex>` worktrees older than 30 days with no tracked changes and no unpushed commits. User-named worktrees are never swept. - buildWorktreeNotice helper for fork subagents (parity with claude-code). Arena compatibility - The existing Arena worktree implementation (GitWorktreeService.setupWorktrees, ArenaManager, agents.arena.worktreeBaseDir) is untouched. Arena uses its own batch APIs and `~/.qwen/arena` base dir; the new general-purpose APIs live alongside under `<projectRoot>/.qwen/worktrees/`. Subagent safety - enter_worktree / exit_worktree are added to EXCLUDED_TOOLS_FOR_SUBAGENTS so a subagent cannot mutate the parent session's worktree state. Refs QwenLM#4056 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(worktree): use path.join in expected paths so the test passes on Windows The Windows CI run reported `enter-worktree.test.ts` failing because the expected string was hardcoded with `/` while `getUserWorktreesDir()` uses `path.join`, which returns `\\` on Windows. Build the expected path via `path.join` so the platform-correct separator is compared. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(enter-worktree): treat empty name as auto-generate Some models pass `{ "name": "" }` when calling EnterWorktree, because the schema marks `name` as optional and they emit an empty placeholder. The previous validation rejected the empty string with "Worktree name must be a non-empty string", which surprised users running the auto-slug path. Now both `validateToolParams` and `execute` treat `name: ""` as equivalent to `name: undefined` and fall back to the auto-generated `{adj}-{noun}-{4hex}` slug. Explicit invalid slugs (`'../etc'`, `'a/b'`, etc.) are still rejected as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review findings 1-6 from PR QwenLM#4073 Six issues raised on the initial review; each addressed with a verifiable guarantee. 1. Real isolation for `agent isolation: 'worktree'` Before: subagent's Config still resolved `getTargetDir()` to the parent project root, so Edit/Write/Read workspace checks and Shell's default cwd silently operated on the parent tree. The cleanup helper then saw a "clean" worktree and removed it — destroying the evidence. After: the worktree is provisioned BEFORE `createApprovalModeOverride`, and the resulting agent Config has `getTargetDir`/`getCwd`/`getWorkingDir` rebound to the worktree path. Relative paths, unqualified shell commands, and glob/grep roots all confine to the worktree. 2. `exit_worktree action='remove'` now prompts in default/auto-edit modes Added `getDefaultPermission()` on the invocation: `'ask'` when action is `remove`, `'allow'` when `keep`. Brings it in line with edit, write_file, and run_shell_command. 3. Force-delete no longer silently destroys unpushed commits `removeUserWorktree` now uses `git branch -d` (refuses unmerged) by default and surfaces `branchPreserved: true` when git refuses. Added `hasUnmergedWorktreeCommits` (checks if branch tip is reachable from any other local branch or remote ref). Both the agent isolation cleanup and `exit_worktree action='remove'` use this check: if the branch has work not covered elsewhere, the worktree+branch are preserved even when `discard_changes: true` is set (there is no `discard_commits` flag — committed work is rarely what `remove` means to discard). 4. Both new tools are now deferred behind ToolSearch `shouldDefer: true` + `searchHint` on both. Verified via openai-logging: `enter_worktree` and `exit_worktree` no longer appear in the function- declaration list sent on every API request. 5. Stale-worktree cleanup is wired in `Config.initialize()` fires `cleanupStaleAgentWorktrees(targetDir)` as a non-awaited startup sweep (skipped in bare mode). Picks up orphaned `agent-<7hex>` worktrees left by crashed runs. 6. Foreground isolation no longer leaks on uncaught throw The foreground try block tracks whether the cleanup helper ran on the success path; the finally block invokes it as a fallback when the try bailed early. Mirrors the background path's pattern. Verification: - Unit tests: 83 passed (16 worktree + 64 existing agent + 3 cleanup) — no regressions. - E2E #1: agent told to write `hello.txt` via RELATIVE path — file landed at `.qwen/worktrees/agent-XXXXXXX/hello.txt`, NOT at the parent root. - E2E #3: created worktree, committed work inside it, called exit_worktree with `discard_changes=true` — refused with clear message; worktree and branch both preserved. - E2E #4: openai-logging confirms worktree tools absent from API tool list (7 tools sent instead of 9). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review round 2 findings (1 from tanzhenxin, 7+8 from wenshao) The first round closed the data-loss-class issues. This round addresses follow-ups from a deeper audit: 1. Stale-worktree sweep was inert on common-case repos `cleanupStaleAgentWorktrees` previously ran `git log --branches --not --remotes --oneline` from each worktree's directory — that lists unpushed commits across EVERY local branch, not just the worktree's own branch. On any repo with no remote configured (or with stray unpushed branches), the sweep refused to remove every candidate. Replaced with `service.hasUnmergedWorktreeCommits(slug)` which scopes the check to the worktree branch via `for-each-ref --contains <tip>`. Also added the `branchPreserved` warn log requested in M7 and an `fs.access` shortcut for the empty-worktrees-dir case (M8). 2. `cleanupWorktreeIsolation` and `worktreeIsolation` were inside the inner try (~660 lines from the outer catch). Hoisted both to the top of `execute()` so the outer catch can reap or preserve the worktree when anything between provisioning and the inner try throws (e.g. `createApprovalModeOverride`, agent creation). Closure carries the resolved `repoRoot` so cleanup never has to re-resolve. 3. Background error path discarded the cleanup result. Now captures `formatWorktreeSuffix(...)` and appends it to the registry's failure /cancel message, so users see the preserved path/branch even when the agent crashed before reporting. 4. `cleanupWorktreeIsolation` now treats `result.success === false` as "worktree still on disk" and surfaces it as preserved instead of silently dropping it from the result. 5. Override was incomplete. Several Config methods read `this.targetDir` directly (`getProjectRoot`, `getFileService`, etc.) — own-property getter overrides did not redirect them. Now also shadows `targetDir` and `cwd` as own properties on the agent's Config override, swaps in a `FileDiscoveryService` rooted at the worktree, and rebuilds `WorkspaceContext` to point at the worktree only. Verified end-to-end: shell `pwd > pwd-record.txt` (no directory arg) lands at `.qwen/worktrees/agent-<7hex>/pwd-record.txt`, not the parent root. 6. monorepo subdir issue. Both `enter_worktree` and the agent isolation path now resolve `git rev-parse --show-toplevel` first and anchor `.qwen/worktrees/<slug>` at the repo root. Worktrees created from any subdirectory now end up where the startup sweep can find them. 7. Replaced `git worktree add -B` (silent force-reset of pre-existing branches) with `git worktree add -b` plus an explicit existence check via `git for-each-ref` (NOT `show-ref --quiet`, which simple-git swallows). Pre-existing `worktree-<slug>` branches now trigger a clear error instead of clobbering committed work. 8. First worktree creation in a repo writes `<projectRoot>/.qwen/.gitignore` with `worktrees/` so worktree contents stay out of the parent's `git status`, glob/grep results, and bundle tools. Idempotent: never overwrites an existing file. 9. Logging across the failure paths (`enter_worktree` errors, `agent.ts:failWorktreeProvisioning`, `cleanupWorktreeIsolation`, `hasUnmergedWorktreeCommits` swallowed errors, `cleanupStaleAgentWorktrees`'s `branchPreserved` race). 10. `exit_worktree` no longer suggests `discard_changes: true` when the git status check itself fails — that would be advising the user to bypass a safety check whose precondition is unknown. Now points at the underlying repo problem. 11. `generateAutoSlug` switched from `Math.random()` (4 hex, weak RNG, one-in-65k collision) to `randomBytes` (6 hex, ~16M combinations). Two RNG sources in this file collapsed to one. Pushed back: the TOCTOU swap in `removeUserWorktree` (S6 round 1) is left as-is — `git branch -d` is the real safety, and reordering does not eliminate the window. Windows reserved-name validation (M5 round 2) deferred to a follow-up; the current allowlist already rejects path separators, `..`, leading dot/dash, and the >64-char case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): use randomInt to silence CodeQL biased-modulo finding CodeQL's `js/biased-cryptographic-random` flagged `randomBytes(4)[i] % ARRAY.length` in `generateAutoSlug`. The math is actually exact for the current word-list lengths (256 % 8 == 0), but the lint rule does not know that — and a future contributor changing the list to a non-power-of-two length would silently introduce bias. Switched the index lookups to `crypto.randomInt(0, length)`, which uses rejection sampling and is uniform by construction. Suffix still uses `randomBytes(3).toString('hex')` since hex encoding is unbiased. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review round 3 findings 1-6 from PR QwenLM#4073 The previous round added `getRepoTopLevel` for `enter_worktree`'s provisioning, but missed three sibling call sites that still used the raw cwd. The double-cleanup race in the foreground path also leaked stale `[worktree preserved]` suffixes on rejected promises. All six findings from the deeper audit are addressed: 1. exit_worktree now resolves through `getRepoTopLevel()` before building its `GitWorktreeService`, mirroring `enter_worktree`. Without this, launching `qwen` from a monorepo subdirectory created the worktree under the repo root but exit_worktree looked under the subdir's `.qwen/worktrees/` and always returned "Worktree not found". Verified end-to-end: enter + exit from `packages/core/` works. 2. agent.ts cleanup helper now nulls `worktreeIsolation` immediately after capturing the closure value. The previous structure could reach the helper twice — once in the foreground try's success path and once in the foreground finally fallback (or once in the inner try and once in the outer catch on a thrown rejection). The second call would `hasWorktreeChanges()` against a directory the first call already removed, fail-closed, and emit a bogus `[worktree preserved: <missing path>]` suffix. 3. Config.initialize's startup sweep now resolves `getRepoTopLevel()` before invoking `cleanupStaleAgentWorktrees`. Without this, every subdir launch scanned a non-existent `<subdir>/.qwen/worktrees/` and the 30-day expiry sweep was permanently a no-op. 4. agent.ts's `buildWorktreeNotice` now passes `worktreeIsolation.repoRoot` as `parentCwd` instead of `this.config.getTargetDir()`. The notice's path-translation guidance (≈ "translate paths from <parent> to <worktree>") would otherwise misdirect the subagent in a monorepo subdir launch. 5. Removed dead method `GitWorktreeService.listUserWorktrees`. It had no callers anywhere in the codebase and used `execSync` in a loop (would have blocked the event loop if anyone wired it up). 6. `localBranchExists` no longer swallows git failures silently. The defensive `false` default is preserved (so `git worktree add -b` itself surfaces the conflict if the check missed an existing branch), but the catch now logs via `debugLogger.warn` so disk-full / permission / ref-store-corruption cases are visible in debug output instead of being invisible. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review round 4 findings (data-loss + visibility) Seven actionable findings from a deeper audit, all closed: 1. User worktree slugs could collide with ephemeral-agent shape `validateUserWorktreeSlug` did not reject names starting with `agent-`, so a user-named `agent-1234567` matched the cleanup regex `/^agent-[0-9a-f]{7}$/` and would be silently swept after 30 days along with whatever work was in it. Now reserved — clear error message points users at the cause. 2. Slug producer and consumer were string-coupled across files `agent.ts` hardcoded `agent-${hex(7)}` and `worktreeCleanup.ts` independently hardcoded `/^agent-[0-9a-f]{7}$/`. Future change to hex length on one side would silently break the other. Lifted `AGENT_WORKTREE_PREFIX`, `AGENT_WORKTREE_HEX_LENGTH`, `AGENT_WORKTREE_SLUG_PATTERN`, and `generateAgentWorktreeSlug()` to `gitWorktreeService.ts`; both call sites import them. 3. Startup sweep was invisible at default log level Fire-and-forget sweep used `debug` for errors and discarded the success count. A leak-chasing operator had no log breadcrumb. Errors promoted to `warn`; successful removals (count > 0) logged at `info`. 4. `getRepoTopLevel()` silent catch Returned `null` on any git failure with no log. Combined with `?? cwd` fallback in callers, a flaky git would have made worktree creators and the startup sweep disagree silently about which dir to use. Now logs the underlying error. 5. `hasTrackedChanges()` silent catch Cleanup's fail-closed `return true` had no log. Couldn't tell "has real changes — leave alone" from "git index unreadable — repo may be corrupt". Now logs. 6. `cleanupWorktreeIsolation` claimed `preservedPath` for a removed dir When `removeUserWorktree` returns `{ success: true, branchPreserved: true }` it has already deleted the directory and failed only on `git branch -d`. The helper still reported the (now non-existent) path as preserved. Now returns only `preservedBranch` for that case; `formatWorktreeSuffix` emits a distinct message instructing recovery via `git worktree add <new-path> <branch>`. 7. `removeUserWorktree` swallowed branch-delete failures Both `-d` and `-D` catch blocks were empty. Locked refs, perms, disk full all looked identical to "unmerged commits". Both now `debugLogger.warn` with the underlying error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(worktree): self-review pass — reuse, parallelism, dead code Self-review caught a handful of issues across three categories: Reuse: - `pathExists` in the new code now uses the existing `fileExists` from `utils/fileUtils.ts` instead of duplicating an `fs.access` wrapper. - `worktree-` branch prefix was string-literalled in five places. Added `WORKTREE_BRANCH_PREFIX` and `worktreeBranchForSlug(slug)` exports in `gitWorktreeService.ts`; updated `gitWorktreeService.ts`, `worktreeCleanup.ts`, and `exit-worktree.ts` to use them. Future prefix changes are a single edit. Efficiency: - `Config.initialize` used two `await import(...)` calls inside the startup-sweep IIFE, paying that cost on every CLI start. Switched to static imports at the top of `config.ts` — the modules are tiny and the dynamic indirection bought nothing. - `cleanupWorktreeIsolation` in `agent.ts` ran `hasWorktreeChanges` and `hasUnmergedWorktreeCommits` sequentially. They have no data dependency on each other and each spawns its own `git` invocation; `Promise.all` halves the cleanup wall-clock on the common path. Same fix in `worktreeCleanup.ts`'s per-entry loop. - `ensureWorktreesGitignored` used `fs.access` then `fs.writeFile`, a TOCTOU race when two agent invocations created worktrees concurrently (both could pass the `access` check and the second would clobber the first's `.gitignore`). Now writes with `flag: 'wx'` and treats `EEXIST` as the no-op case — atomic in one syscall. Quality: - Dropped the `worktreeCleanupRan` boolean in the foreground execution path. `cleanupWorktreeIsolation` already nulls its closure variable at the top of every call (see the comment at its definition), so re-entries are no-ops. The boolean and its tracking were dead weight that obscured the real guard. - Trimmed the Phase-2 override comment block to drop the WHAT-stating enumerations (items 3 and 4 just narrated the lines below) and removed a navigation comment about hoisted helpers — the helpers are visible at the top of the same method. 84 unit tests pass; typecheck clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review round 5 — design-doc commitments + correctness Five critical findings + four suggestions, all closed. Critical: 1. Wrong base branch for agent isolation. `createUserWorktree(slug)` with no `baseBranch` arg fell back to `getCurrentBranch()` on the **main** working tree, returning `main` regardless of which branch the user was actually on. A subagent invoked from `feature-x` would silently start from `main` and produce diffs against the wrong baseline. `enter_worktree` had the same bug. Both now resolve the parent's current branch first and pass it explicitly. Verified end-to-end: `git checkout feature-x` → `enter_worktree` → worktree HEAD includes the feature-x commit. 2. `countWorktreeChanges` (used by `exit_worktree`'s dirty-state guard) missed `status.conflicted[]`. In simple-git that array is mutually exclusive with the staged/modified/etc. arrays, so a worktree mid-merge with only conflicts looked `{tracked: 0, untracked: 0}` to the guard and `action='remove'` would proceed without `discard_changes: true`. Added `+ status.conflicted.length`. 3. `exit_worktree` had no session-ownership check, contradicting the design doc's "only operates on worktrees created by THIS session". In yolo mode a prompt injection could enumerate `.qwen/worktrees/` and pass any name to drop another session's work. Now: `enter_worktree` and agent isolation write a `.qwen-session` marker into the worktree at provisioning time; `exit_worktree action='remove'` reads it and refuses if it does not match the current `Config.getSessionId()`. Worktrees from before this guard (no marker file) are treated as "owner unknown" — allowed with a warn log so the change is observable. 4. `enter_worktree` did not refuse nested invocations from inside an existing worktree, contradicting the design doc. Now rejects any cwd containing `.qwen/worktrees/` as a path component, with a clear "Already inside a git worktree…" message. Verified: enter from inside a worktree returns is_error with that text. 6. `hasTrackedChanges` (cleanup sweep) had the same `conflicted[]` gap. Rewrote to use raw `git status --porcelain --untracked-files=no` which lists every tracked change including `UU` conflict markers in a single git call and explicitly skips the untracked walk (the prior comment claimed to skip it, but `status()` always does the scan). Suggestion: 7. `buildWorktreeNotice` now receives the parent agent's actual `getTargetDir()` again (was switched to `repoRoot` in round 3 on a different reviewer's suggestion; round-5 caught that the model's inherited paths reference the parent's cwd, not necessarily the repo root, so the prior behaviour was correct). 8. Startup sweep now does `fs.access(<targetDir>/.qwen/worktrees)` *before* importing GitWorktreeService and spawning `git rev-parse --show-toplevel`. The git probe is reserved for users who actually have a worktrees directory locally — 99% of users pay only one syscall on startup. 9. Tests: - New `exit-worktree.test.ts` covers metadata, validation, `getDefaultPermission` (ask vs allow), and getDescription. - `agent.test.ts` adds three `validateToolParams` cases for the `isolation` parameter (accepted with subagent_type, rejected without, rejected for non-"worktree" values). - `enter-worktree.test.ts` adds round-trip tests for `writeWorktreeSessionMarker` / `readWorktreeSessionMarker` plus a `worktreeBranchForSlug` sanity check. - Total: 101 tests pass (was 86 → +15). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): drop unused @ts-expect-error in exit-worktree.test.ts Empty string `''` is a valid `string` type, so the @ts-expect-error directive on `validateToolParams({ name: '', action: 'keep' })` did nothing — TypeScript correctly accepted the line, and `tsc --build` in CI reported TS2578 ("Unused '@ts-expect-error' directive"). The runtime assertion already covers the case; the directive was leftover from an earlier draft. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): use importActual in ArenaManager mock to preserve new exports The Arena test mocks `gitWorktreeService.js` with a factory that returns only `{ GitWorktreeService }`. PR QwenLM#4073 added several other exports to that module (`AGENT_WORKTREE_SLUG_PATTERN`, `WORKTREE_BRANCH_PREFIX`, `worktreeBranchForSlug`, `generateAgentWorktreeSlug`, `writeWorktreeSessionMarker`, `readWorktreeSessionMarker`, `WORKTREE_SESSION_FILE`). Other modules in the dep graph reach the mocked surface — most notably `worktreeCleanup.ts` imports `AGENT_WORKTREE_SLUG_PATTERN` and `worktreeBranchForSlug`, and now reaches the mock via the static `config.ts` → `worktreeCleanup.ts` import chain added in the self-review pass. The Arena test failed at module-load with: Caused by: Error: [vitest] No "AGENT_WORKTREE_SLUG_PATTERN" export is defined on the "../../services/gitWorktreeService.js" mock. Did you forget to return it from "vi.mock"? Use `importOriginal` to capture every real export, spread it into the return object, and only replace `GitWorktreeService` (the class the test actually needs to mock). The class-level mock keeps its existing static-method shims. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address review round 6 (5 critical + 6 suggestions) The biggest item — #1 — is a self-inflicted regression from round 5: the new agent- prefix reservation in `validateUserWorktreeSlug` rejected EVERY slug that `generateAgentWorktreeSlug` produces, since that helper emits exactly `agent-<7hex>`. Net effect: every `AgentTool isolation: 'worktree'` invocation failed at validation. The reservation now allows the canonical pattern through (everything the helper can produce) and only rejects user-chosen `agent-*` names that don't match it. Added a round-trip regression guard: 50 `generateAgentWorktreeSlug()` outputs are fed back through `validateUserWorktreeSlug` and must all pass. Other critical fixes: 2. `hasWorktreeChanges` (used by agent isolation cleanup) was the one remaining caller relying solely on `status.isClean()`. Defensive `|| status.conflicted.length > 0` so a future simple-git bookkeeping change can't let a mid-merge worktree appear clean and get auto-deleted. 3. `readWorktreeSessionMarker` swallowed every I/O error as "marker missing", which let a disk error / EACCES silently bypass the session-ownership guard. ENOENT is still treated as missing (legitimate); every other code now logs. 4. `exit_worktree` `fs.stat` catch was the same shape — every error collapsed to "Worktree not found". ENOENT → not found; everything else logs and returns a distinct "cannot access" error. 5. `cleanupStaleAgentWorktrees` `fs.stat` catch was again the same. ENOENT → silently skip (entry vanished between readdir and stat); everything else logs. Suggestions: 6. Startup sweep fast-bail was running BEFORE resolving the repo top-level. For monorepo subdir launches, `targetDir/.qwen/worktrees` never exists and the sweep early-returned — permanently a no-op. Now resolves the root first, then fast-bails against the resolved `<root>/.qwen/worktrees`. Also logs the skip case so operators can tell "skipped" from "ran, found nothing". 7. `.qwen-session` marker was visible to `git add -A` inside the worktree. Now writes a `.git/info/exclude` rule (resolved via `git rev-parse --git-dir`, since worktree `.git` is a file pointing at the parent repo's `.git/worktrees/<name>/`). Best-effort: failure to write the rule does not abort provisioning. 8. Agent isolation now refuses to provision when the parent's cwd is already inside a worktree — same regex guard as `enter_worktree`. 9. `exit_worktree`'s wrapper around `hasUnmergedWorktreeCommits` now logs at the call site so the chain (caller → reason it asked → underlying git error) is complete in operator logs. 10. Sweep now logs unconditionally at `info`. Three distinct messages: "skipped (no worktrees dir)", "ran, nothing to remove", "removed N". Tests: 11. New `execute()` coverage: • exit-worktree: session-ownership refusal, keep happy path, legacy/no-marker fallthrough with warn log, missing-worktree error, unmerged-commits guard with `discard_changes: true`, `writeWorktreeSessionMarker` round-trip. • enter-worktree: nested-guard rejection, non-git-repo error. These spin up real temp git repos (no filesystem mocking) and drive the actual tool invocation pipeline. Total: 135 tests pass (was 101 → +34). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(worktree): demote noise startup-sweep logs to debug Self-review pass applying the round-6 review-triage framework (filter #5: "If a log only fires on the happy path, it's noise.") to my own round-6 changes: - "Stale worktree sweep skipped: <dir> does not exist" — fires on every CLI start for ~99% of users who never use worktrees. - "Stale worktree sweep ran under <root>: nothing to remove" — fires on every CLI start for users who have any worktrees but no stale ones at the moment. Both are happy-path noise at `info`. Demoted to `debug` so an operator can opt in via `--debug` when they want to confirm the sweep is wired up, but normal output stays clean. Only the actually-actionable case ("removed N worktrees") stays at `info` — that's the signal someone chasing a worktree leak would grep for. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): close AUTO_EDIT bypass + parent-dirty stale-code hazard Round-7 review caught two correctness gaps: 1. exit_worktree action='remove' was still auto-approved in AUTO_EDIT `getDefaultPermission` returning 'ask' is necessary but not sufficient. `permissionFlow.isAutoEditApproved` auto-approves any tool whose `confirmationDetails.type` is 'edit' OR 'info', and `BaseToolInvocation` returns 'info' by default. So a session in AUTO_EDIT could silently destroy a worktree (with branch deletion) without a confirmation prompt — the data-loss path the round-1 `'ask'` switch was meant to close. Now overrides `getConfirmationDetails` to return `type: 'exec'` for action=remove, which keeps the prompt in AUTO_EDIT. The `keep` action still falls through to the base info-type since it is non-destructive. Regression-guard test asserts the type is 'exec' (not 'info') for remove and that the command field describes both the worktree-remove and branch-delete operations. 2. Agent isolation worktrees ran against parent's HEAD, not its working tree `git worktree add -b <branch> <path> <base>` only checks out the base ref's tip — uncommitted edits in the parent's working tree do NOT propagate. The "edit code → ask review/test agent before committing" workflow silently ran the subagent against the pre-edit HEAD and returned results that looked authoritative but reflected stale code. Reviewer offered two options: overlay parent's dirty state à la Arena (~50 LOC, edge cases), or refuse isolation when parent is dirty (~10 LOC, clear UX). Chose the latter for Phase B scope — simpler, decisive, and matches the design-doc's explicit commitment that dirty-state overlay is Arena-specific. Users can commit/stash before re-invoking agent isolation; overlay can be a follow-up if users complain about the friction. Fail-closed on the dirty-check itself (assume dirty rather than silently launch on a possibly-stale tree). Test exercises both "dirty parent → guard fires" and "clean parent → guard passes" against real temp git repos. 139 unit tests pass (was 135, +4 regression guards). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…enLM#4175 Wave 2.5 PR 10) (QwenLM#4237) * feat(serve): SSE replay sizing + slow_client_warning backpressure QwenLM#4175 Wave 2.5 PR 10. Closes the SSE replay / backpressure knobs called out in QwenLM#3803 §02 so chatty Stage 1 sessions get an honest reconnect window and operators get a heads-up signal before clients are summarily evicted. - **`DEFAULT_RING_SIZE` 4000 → 8000.** Per-session replay ring depth now matches the QwenLM#3803 §02 target for chatty sessions. - **`--event-ring-size <n>`** CLI flag (default 8000) lets operators tune the ring per daemon. Threaded `ServeOptions` → `BridgeOptions.eventRingSize` → both `new EventBus()` construction sites (fresh sessions + restore path). Validation is fail-CLOSED (positive finite integer; 0 / NaN / negative throw at boot). - **`slow_client_warning` SSE frame.** When a subscriber's queue crosses 75% full the bus force-pushes a synthetic `slow_client_warning` to that subscriber once per overflow episode, carrying `{queueSize, maxQueued, lastEventId}`. The flag re-arms after the queue drains below 37.5% (hysteresis, no flap near threshold). If the queue actually overflows after the warning, the existing `client_evicted` terminal frame path still fires. Like `client_evicted`, the warning has no `id` (synthetic frame; must not burn a sequence slot for other subscribers). - **`?maxQueued=N`** query param on `GET /session/:id/events` (range `[16, 2048]`, default 256). Lets cold reconnect clients pre-size their per-subscriber backlog so a large `Last-Event-ID: 0` replay doesn't trip the warning on the first publish. Range rationale: lower bound 16 (smaller is useless for any replay); upper bound 2048 (so a single subscriber can't pin ~1 MB just by asking). Out-of-range / non-decimal returns `400 invalid_max_queued` BEFORE opening the SSE stream — clean 4xx beats half-opening a stream + emitting a `stream_error` (which EventSource would auto-reconnect on). - **`slow_client_warning` capability tag** — single source of truth for the warning frame + `?maxQueued` query param + ring-size knob. Old daemons silently lack all of these; pre-flight via `caps.features`. - **SDK extensions** (`@qwen-code/sdk`): typed `DaemonSlowClientWarningEvent` (added to known event union and `DaemonStreamLifecycleEvent`); schema-validated by a new `isSlowClientWarningData` predicate; reducer (`reduceDaemonSessionEvent`) increments `slowClientWarningCount` + stores `lastSlowClientWarning`. Warning is **non-terminal** — `alive` stays true (only `client_evicted` / `stream_error` / `session_died` close the stream). Re-exported from the public SDK entry. - **Docs**: `qwen-serve-protocol.md` updates the features list (adds `slow_client_warning` and the previously-missing `client_identity` to match reality post-QwenLM#4231), documents the `?maxQueued` query param, adds the warning frame to the event table, and notes the new default ring size. `qwen-serve.md` adds the `--event-ring-size` flag row. Tests: 19 eventBus (4 new: warning at 75%, once per episode, no `id` on the synthetic frame, hysteresis re-arm), 106 bridge (2 new: validate eventRingSize accept/reject), 111 server (4 new: ?maxQueued accept/absent/non-decimal/out-of-range + EXPECTED_STAGE1_FEATURES update), 14 SDK daemonEvents (2 new: schema validation + non-terminal reducer behavior). 321 focused tests total, all green. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 review feedback (eventBus polish) Address the actionable items from the Qwen Code review bot's pass on PR QwenLM#4237: - Pre-compute `warnThreshold` / `warnResetThreshold` per `InternalSub` at `subscribe()` time so `publish()`'s per-event hot path is one integer compare per subscriber instead of a multiply + compare. The `!warned` short-circuit still collapses the steady state to a single boolean read; this just shaves a multiply when the threshold check actually fires. - Document the back-of-queue ordering choice for the synthetic `slow_client_warning` frame in `EventBus.publish()`: front-push was considered but mid-stream front-insertion would mis-count `forcedInBuf` in `BoundedAsyncQueue.next()`, and `forcePush` already short-circuits via `resolvers.shift()` for the active-consumer case — the back-of-queue path only matters for stalled consumers, who can't drain regardless of warning position. - Reuse the existing `collect()` helper in the "default ring size 8000" test for consistency with the rest of the file; the new test also tightens the assertion by checking that the first retained event id is 2 (id=1 dropped by the ring) and the last is 8001. - Soften the "~500 B per session" magic number in `BridgeOptions.eventRingSize`'s JSDoc to a qualitative description (each retained `BridgeEvent` is a reference plus its serialized payload; ceiling scales as `ringSize × average-event-size`). Rejected: - Bot's claim that the error JSON contains `\`...\`` escape sequences — bot misread the JS template-literal source as the wire output; `JSON.stringify` does not escape backticks, and the existing `cwd` error messages use the same style. - Bot's "use `Record<string, never>` instead of `[key: string]: unknown`" suggestion on `DaemonSlowClientWarningData` — every other event-data type in `sdk-typescript/src/daemon/events.ts` carries the same index signature for additive-field compatibility. - Bot's "features list breaks alphabetical order" — the capability list is grouped by protocol lifecycle (health → capabilities → session lifecycle → events → permissions), not alphabetical. Tests: 139 focused tests across eventBus + httpAcpBridge + SDK daemon events — all passing. Behavior unchanged; this is hot-path micro-opt + comment polish only. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve): correct queue tagging + plumb maxQueued through SDK Address both P2 findings from the Codex review pass on PR QwenLM#4237. **Bug 1: `BoundedAsyncQueue.forcedInBuf` position-invariant break** The previous `forcedInBuf` counter only tracked LIVE-vs-FORCED correctly when all forced entries lived at the FRONT of the buffer (subscribe-time `Last-Event-ID` replay). The new mid-stream `slow_client_warning` path force-pushes to the BACK of the queue while the queue is still open, which the existing accounting was not designed for: - publish 6 events at maxQueued=8 → 75% threshold trips → force-push warning at the back → buf=[1..6, warning], forcedInBuf=1. - consumer shifts `1` → forcedInBuf decremented to 0 (incorrect: `1` was a live frame, not the forced one). - consumer drains 2..6 + warning → buf=[], forcedInBuf=0, true live count = 0, but `size` getter and `push()` cap check then use `buf.length - forcedInBuf` which drifts over subsequent refills, causing premature warn / eviction before the cap is actually reached. Replace the position-dependent counter with a per-entry `{value, forced}` tag. `liveCount` is incremented in `push()` / decremented in `next()` only when the shifted entry was non-forced — position becomes irrelevant. `size` getter returns `liveCount` directly. The class doc comment is rewritten to call out that the new tag is the position-independent replacement for the old "forced frames must stay at the front" invariant. Regression test in `eventBus.test.ts` reproduces the codex trace (warn at 75%, drain past warning, refill to cap) and asserts no premature eviction. **Bug 2: SDK does not expose `?maxQueued`** `docs/users/qwen-serve.md` and `docs/developers/qwen-serve-protocol.md` both document `?maxQueued=N` as something SDK clients can request, but `SubscribeOptions` on `DaemonClient` only declared `lastEventId` + `signal`, and `subscribeEvents()` always fetched `/events` without a query string. Typed-SDK consumers had no way to opt in without hand-crafting URLs. - Add `SubscribeOptions.maxQueued?: number` with JSDoc noting the daemon range `[16, 2048]` and the pre-flight requirement on `caps.features.slow_client_warning`. - `DaemonClient.subscribeEvents` builds the URL with an optional `?maxQueued=<n>` segment. No client-side range validation — the daemon's `parseMaxQueuedQuery` is the source of truth and returns structured `400 invalid_max_queued`; duplicating the bounds in two layers would diverge on the next tweak. - `DaemonSessionSubscribeOptions extends SubscribeOptions` so the new field flows through `DaemonSessionClient` automatically. Three new SDK tests: - subscribeEvents appends `?maxQueued=N` when set - omits the query string when absent (existing behavior preserved) - propagates a `400 invalid_max_queued` unchanged Tests: 214 focused tests across eventBus / bridge / SDK DaemonClient / DaemonSessionClient / daemonEvents, plus 111 in the server suite. All green; the new eventBus regression case proves the position-invariant fix. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 copilot review feedback Address 6 of 8 copilot-reviewer findings on PR QwenLM#4237; the other 2 (#1 forcedInBuf live-size corruption, #5 SDK lacks maxQueued) were already fixed in bae42c8 — replied on the threads with the commit hash. - **[2] server.ts:1068** — `?maxQueued=` (present-but-empty) now fails closed with `400 invalid_max_queued` instead of silently falling back to the default queue cap. The API documents fail-closed for any malformed value before opening SSE, so an empty string is unambiguously malformed. New server.test.ts case locks this in. - **[3] commands/serve.ts:93** — CLI help text for `--event-ring-size` no longer mis-shapes `Last-Event-ID` as a query parameter. It is an HTTP header, and the daemon's SSE route does not parse a `?Last-Event-ID=` query. - **[4] docs/developers/qwen-serve-protocol.md:351** — clarify that `?maxQueued=N` controls the LIVE-event backlog cap. Replay frames are force-pushed and exempt from the cap; what consumes it is live events that arrive while the subscriber is still draining a cold-reconnect replay. Bumping for cold reconnects is still the right answer, but for the live tail, not for the replay frames themselves. - **[6] eventBus.ts:214** — stale `ringSize=4000` performance comment updated to the new `ringSize=8000` default with a note about the O(n) `shift()` cost scaling. - **[7] sdk-typescript events.ts:492** — `isSlowClientWarningData` now uses the existing `isFiniteNumber` helper instead of bare `typeof === 'number'`. Mirrors the sibling predicates and rejects `NaN` / `Infinity` payloads as schema garbage. New daemonEvents.test.ts assertions cover both. - **[8] server.ts:127** — `createServeApp`'s default-bridge construction now also forwards `opts.eventRingSize` to `createHttpAcpBridge`, symmetric with the `runQwenServe.ts` path. Direct embeds / tests that called `createServeApp` without supplying their own bridge but did pass `ServeOptions.eventRingSize` were silently getting the default 8000 ring. Tests: 326 focused tests across eventBus / bridge / SDK DaemonClient / DaemonSessionClient / daemonEvents / server. All green; the new server.test.ts case + the extended daemonEvents.test.ts assertions cover the tightened guards. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 wenshao round-2 review feedback Six adopted findings from @wenshao's second review pass on PR QwenLM#4237. The seventh ([10] forcedInBuf 3rd case invariant) was already fixed in bae42c8 — replied on that thread. - **[9] + [14] server.ts** — Sanitize attacker-controlled values before stderr interpolation in both `parseMaxQueuedQuery` and `parseLastEventId`. New `safeLogValue()` helper uses `JSON.stringify` to escape control characters (`\n`/`\r`/…) so a URL-encoded newline in `?maxQueued=%0a` can't inject extra log lines into journald/Loki/Splunk pipelines. Matches the `workspace_mismatch` sanitization style in `sendBridgeError`. Fixed in both helpers (the sibling pre-existing `parseLastEventId` had the same shape) so the file stays consistent. - **[11] httpAcpBridge.ts** — `!Number.isFinite(eventRingSize)` was redundant: `Number.isInteger(NaN)` and `Number.isInteger(Infinity)` both return `false`, so the sibling `!Number.isInteger` already catches both. Drop the dead guard. - **[12] httpAcpBridge.ts** — Add soft upper bound `MAX_EVENT_RING_SIZE = 1_000_000` on `eventRingSize` to catch operator typos (`--event-ring-size 80000000` vs `8000000`). At ~500 B per `BridgeEvent` an 1M-frame ring already pins ~500 MB per session — well past any realistic workload. Not a security boundary (operator-controlled flag), pure typo defense. Existing bridge construction test extended with an `80_000_000` case. - **[13] commands/serve.ts** — CLI `--event-ring-size` flag now sources its default from `DEFAULT_RING_SIZE` (imported from `serve/eventBus.js`) instead of the hardcoded literal `8000`. Without this, a future bump of the bus default would silently not take effect for daemons launched through the CLI because the flag always overrides — single source of truth fixes that. - **[15] eventBus.ts** — Drop unreachable `event.id ?? this.lastEventId` fallback in the `slow_client_warning` frame. `event` is locally constructed at the top of `publish()` with `id: this.nextId++` and is guaranteed defined. Use `event.id as number` directly + an inline note about the invariant. Tests: 197 (eventBus 20 / bridge 107 / SDK DaemonClient 57 / SDK daemonEvents 14) + 112 server. All green; the new upper-bound bridge case + the existing log assertions pin the changed behaviors. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…#4247) * feat(serve): MCP client guardrails (QwenLM#4175 Wave 3 PR 14) Adds an in-process MCP client counter, slot-reservation enforcement at all 3 spawn sites (discoverAllMcpTools / discoverAllMcpToolsIncremental / readResource), new `--mcp-client-budget=N` + `--mcp-budget-mode={enforce,warn,off}` CLI flags forwarded to the ACP child via env, and additive `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields plus `disabledReason: 'budget'` tagging on `GET /workspace/mcp`. Always-on capability tag `mcp_guardrails` with `modes: ['warn', 'enforce']` so SDK clients can pre-flight refusal semantics. Typed SSE push events (`mcp_budget_warning` / `mcp_child_refused_batch`) intentionally deferred to a small follow-up PR — the snapshot already exposes `budgets[0].status: 'warning'|'error'` + `refusedCount` so operator visibility isn't blocked. * fixup(serve): address PR 14 review (QwenLM#4247) findings 1-7 Addresses Codex + Copilot review feedback on QwenLM#4247. Seven functional and forward-compat fixes; (8) `tcp` transport mapper vs createTransport deferred pending @wenshao direction (separate core/protocol decision). 1. **Single-server rediscovery bypass** — add `tryReserveSlot` at the top of `discoverMcpToolsForServerInternal`. Pre-fix a server refused at startup could be brought online later via `/mcp reconnect <name>` and exceed the cap in enforce mode. 2. **Empty `budgets[]` when mode=off** — early `return []` in `buildBudgetCells` when mode is `off`. Protocol docs / SDK types promise empty array; pre-fix emitted a synthetic noisy cell. 3. **runQwenServe validation + env leakage** — mirror CLI budget validation in `runQwenServe` (the embedded entry point); explicitly delete `QWEN_SERVE_MCP_*` env vars when options are undefined so multiple daemons in one process don't leak prior budget config to subsequent ACP children. 4. **Disabled-vs-refused precedence + stale refusal log** — config-disable wins over budget refusal in the per-server cell; `removeServer` + `disconnectServer` drop the entry from `lastRefusedServerNames` so operator action immediately clears the budget tag. 5. **Incremental remove-before-reserve ordering** — process config-removed servers FIRST in `discoverAllMcpToolsIncremental` so freed slots are visible to subsequent `tryReserveSlot` calls. Pre-fix scenario {a,b}→{a,c} with budget=2 wasted a slot. 6. **`scope` forward-compat type widening** — `'workspace' | (string & {})` on both `ServeMcpBudgetStatusCell` and `DaemonMcpBudgetStatusCell` so SDK consumers don't break when PR 23 adds `scope: 'pool'` per the documented no-schema-bump contract. 7. **Test comment alignment** — fix "With budget=1" comment to match `clientBudget: 2` code. Plus 4 new core regression tests covering #1/#2/#4/#5, and 4 new serve tests covering #3 (boot rejection + env cleanup). 237/237 pass across the affected files (36 core mcp-client-manager + 50 acpAgent + 151 serve). * docs(serve): clarify v1 snapshot-based budget warning detection (QwenLM#4247) Address github-actions review-summary finding (I) on PR QwenLM#4247: v1 operators have no SSE push event for budget pressure yet (deferred to PR 14b), so the protocol doc should explicitly say how to detect warning / error states from the snapshot. Adds the three-way mapping `budgets[0].status` ↔ live/refused counts. * fixup(serve): address PR 14 review round 2 (QwenLM#4247 wenshao) Addresses @wenshao review on PR QwenLM#4247. Three critical safety fixes + four suggestion-level improvements. Critical (zombie slot leaks — would break `enforce` mode for the rest of the daemon's lifetime): - C2: `discoverAllMcpTools` connect() catch now releases reservedSlots + clients entry. Pre-fix one failed connect permanently consumed a budget slot. - C3: `readResource` wraps client.connect() in try/catch; on throw the slot + client entry are cleaned up before re-raising. Tracked `weReservedSlot` so the cleanup only fires for newly-created lazy spawns (reused already-CONNECTED clients are untouched). - (wenshao C1 was the rediscovery-bypass also caught by Codex + Copilot — already addressed in fixup 597f011.) Suggestion: - S4: `readBudgetFromEnv` downgrades `mode='enforce'` → `'off'` when no budget is set, mirroring the CLI + `runQwenServe` invariant. Fail-closed on operator misconfiguration rather than silently bypassing enforcement. - S5: extract duplicated `mcp_budget_decision` telemetry into private `emitBudgetTelemetry(configuredCount)`. - S6: rename `BudgetExhaustedError` constructor param `liveCount` → `reservedCount`. `reservedSlots.size` is what's blocking the new server, not the live CONNECTED count (those differ when a reserved server is disconnected). - S7a: bump accounting-failure log level — `debugLogger.debug` (gated on debug=true) replaced by `process.stderr.write` so production daemons surface slot-leak / type-mismatch failures in journald/docker logs. (S7b — expose `reservedSlots[]` on the wire for slot-leak debugging — deferred as additive; will be in PR 14b alongside the typed events.) + 3 new core regression tests (C2 leak release, C3 lazy-spawn leak release, S4 env enforce-downgrade). 626/626 tests pass across the focused suite; typecheck + lint clean. * fixup(serve): address PR 14 review round 3 (QwenLM#4247 wenshao second pass) Addresses @wenshao's second review pass on PR QwenLM#4247 (submitted 15:56Z after round 2 fixup landed). Four code fixes + three doc clarifications. Code: - R3 #5: `readResource` lazy-spawn path now checks `isMcpServerDisabled` BEFORE the budget gate. Pre-existing gap: a server disabled via `mcpServers.<name>.disabled: true` or `/mcp disable <name>` could be resurrected by any resource read. Disabled precedence over budget mirrors the per-server cell logic. - R3 #6: `buildBudgetCells` now receives the post-disabled-filter `refusedCount` so the workspace cell matches the per-server cell precedence. Pre-fix a server disabled after being refused rendered `disabled` on its per-server row but `error: budget_exhausted` on the workspace row. - R3 #7: extract `MCP_BUDGET_WARN_FRACTION = 0.75` constant. Was hardcoded in `acpAgent.buildBudgetCells` AND `commands/serve.ts` stderr breadcrumb (the latter with `Math.ceil` divergence on non-integer multiples). Pre-extract so PR 14b's dual-threshold (0.75 warn + 0.375 rearm) lands in one file. - R3 #1: env-var enforce-without-budget downgrade (already fixed in round 2 ba3e3fe S4 — reply-only on the new thread). Docs: - R3 #2: docstring on `mcpTransportOf` now spells out the `tcp` vs `createTransport` divergence + records the deferred decision (PR 14b / future core). Closes the "comment claims X but code does Y" gap. - R3 #3: comments in both `discoverAllMcpTools` catch (release slot — stop() owns lifecycle) AND `discoverMcpToolsForServerInternal` catch (KEEP slot — operator intent + health-monitor retry). Different paths, different contracts, both explicit. - R3 #4: invariant note in `readResource` lookup→reserve sequence documenting the synchronous no-await guarantee that closes the TOCTOU window. + 3 new core regression tests (readResource disabled gate, disabled-wins-over-budget precedence, MCP_BUDGET_WARN_FRACTION pin). 629/629 tests pass; typecheck + lint clean. * fixup(serve): address PR 14 review round 4 (QwenLM#4247 wenshao second + third pass) Addresses @wenshao's second + third review passes on PR QwenLM#4247. One critical scope-correction (per-session vs per-workspace) + one zombie leak fix shared across three threads. Critical correction — per-session vs per-workspace (wenshao R3 line 117 docs): - Reality check: `acpAgent.newSessionConfig()` constructs a fresh `Config` + `ToolRegistry` + `McpClientManager` for EVERY ACP session. Each manager independently reads `QWEN_SERVE_MCP_CLIENT_BUDGET` env. So `--mcp-client-budget=10` with 5 sessions caps at 5 × 10 = 50 live MCP clients across the daemon, NOT 10. The "per-workspace" framing in v1 docs was incorrect. - Pragmatic v1 path (not the big refactor): rewrite docs + change `scope: 'workspace'` → `scope: 'session'` so the wire contract reflects reality. Wave 5 PR 23 (shared MCP pool) will introduce a workspace-scoped manager and add `scope: 'workspace'` cells alongside. - Files touched: `status.ts` + `sdk types.ts` (cell `scope` field widened to `'session' | 'workspace' | (string & {})` with v1 emitting `'session'`), `acpAgent.buildBudgetCells` (emits `'session'` + new code comment explaining the per-session truth), `docs/users/qwen-serve.md` (CLI flag + budget section relabel +⚠️ v1 limitation callout), `docs/developers/qwen-serve-protocol.md` (capabilities section + JSON example + paragraph rewrite + per-session detection hint). Zombie leak fix — single weReserved-pattern fix in discoverMcpToolsForServerInternal closes wenshao R3 line 546 + R4 line 639 + R4 line 929: - Same pattern as R2 C3 (`readResource`): track `weReservedSlot = reservation === 'reserved' && this.reservedSlots.has(serverName)` (the set-membership guard distinguishes a real fresh reservation from `off`-mode's no-op return). On connect-failure, release slot + drop client only when `weReservedSlot`; an `'already_held'` reconnect keeps its slot so health-monitor retry doesn't compete for capacity. - Pre-fix a brand-new server connecting via /mcp reconnect / health monitor / incremental's serversToUpdate that failed on connect() would permanently consume a budget slot under enforce mode. - Updated R3's "always keep" doc comment to reflect the new two-mode cleanup (release on fresh + keep on reconnect). - Caught and added a tripwire test for the `off`-mode no-op edge case (`tryReserveSlot` returns `'reserved'` without adding to the set in off mode — without the has-guard, my fix would have broken the pre-existing "should restore health checks after failed server rediscovery" test by deleting the failed client even in unbudgeted operation). + 2 new core regression tests (fresh-reserve connect-failure releases slot, reconnect connect-failure keeps slot). 631/631 focused tests pass; typecheck + lint clean. * fixup(serve): address PR 14 review round 5 (QwenLM#4247 wenshao fourth pass) Addresses @wenshao's fourth review pass on PR QwenLM#4247. Two critical zombie-leak / staleness fixes; three reviewer findings deferred or already-addressed (replied + resolved on the threads). Critical fixes: - R5 line 956: `runWithDiscoveryTimeout` timeout handler now releases `reservedSlots.delete(serverName)` and drops the stale `lastRefusedServerNames` entry alongside the existing `clients.delete`. Pre-fix a timed-out server in `enforce` mode permanently held its budget slot; N consecutive timeouts permanently degraded daemon capacity. + regression test. - R5 line 1268-1: `readResource` lazy-spawn path drops the server from `lastRefusedServerNames` when `tryReserveSlot` returns `'reserved'` (a successful late re-reservation). Pre-fix a server refused at discovery but later re-reserved via `readResource` (e.g., after another server freed a slot) kept its stale `disabledReason: 'budget'` tag in the snapshot. + regression test. Reviewer findings deferred / already done (replied + resolved): - R5 line 1268-2 (`no try/catch around connect()` in readResource): stale view — R2 C3 fixup ba3e3fe added the try/catch with the weReservedSlot cleanup pattern. - R5 line 1274 (`BudgetExhaustedError.liveCount` semantic mismatch): R2 S6 fixup ba3e3fe renamed the param + readonly field to `reservedCount`, exactly matching the proposed semantic. - R5 acpAgent.ts null line (`Math.ceil(0.75 * budget)` for small budgets): proposed fix is semantically a no-op for integer liveCount — `liveCount >= 0.75` and `liveCount >= Math.ceil(0.75) === 1` give identical results when liveCount is an integer. The underlying "small budgets jump ok→error" observation is a real but inherent limitation of percentage-based thresholds at small N; design tradeoff, not implementation bug. 46/46 core tests pass (44 prior + 2 new R5 regression). Typecheck + lint clean. * fixup(serve): address PR 14 review round 6 (QwenLM#4247 wenshao fifth pass) Addresses @wenshao's fifth review pass on PR QwenLM#4247. Two critical fixes (one TOCTOU race, one cross-daemon env leak). Critical fixes: - R6 Thread 2 (line 956): remove the duplicate pre-reservation block in `discoverAllMcpToolsIncremental`. The reservation already happens inside `discoverMcpToolsForServerInternal` (R1 fix #1). With both sites reserving, the timeout cleanup raced against the inner connect path — `runWithDiscoveryTimeout`'s timeout handler could release the slot mid-flight while the inner `connect()` later resolved successfully, leaving a CONNECTED client with NO reservation and breaking `enforce`-mode budget enforcement. With pre-reservation removed, the inner call owns the entire reservation lifecycle (reserve → connect → release-on-failure-via-weReservedSlot → cleared-by-timeout-if-fires) at a single site. Refusal behavior is observably identical from outside. - R6 Thread 1 (runQwenServe.ts:216): per-handle env passthrough via new `BridgeOptions.childEnvOverrides` instead of mutating global `process.env`. Pre-fix concurrent embedded `runQwenServe()` handles with different MCP budgets would race on the global env — `defaultSpawnChannelFactory` snapshots `process.env` AT SPAWN TIME, so the last `runQwenServe()` call to set the var would silently win for ALL daemon handles' subsequent ACP child spawns. Wire surface: - `ChannelFactory` signature: `(workspaceCwd, childEnvOverrides?) => Promise<AcpChannel>`. - `BridgeOptions.childEnvOverrides?: Readonly<Record<string, string | undefined>>` — `undefined` value means "scrub this var from the child env" so an embedded caller can wipe a stale inherited var without touching global state. - `defaultSpawnChannelFactory` merges overrides AFTER `SCRUBBED_CHILD_ENV_KEYS` so the daemon-only secret list still wins (operators can't override the scrub). - `runQwenServe` closes over per-handle overrides; never touches `process.env`. + 3 new regression tests (incremental refusal post-pre-reservation-removal, runQwenServe-doesn't-mutate-process.env, bridge forwards childEnvOverrides to channelFactory with two concurrent bridges asserting isolation). 327/327 focused tests pass; typecheck + lint clean. * fixup(serve): address PR 14 review round 7 (QwenLM#4247 wenshao sixth pass) Addresses @wenshao's sixth review pass on PR QwenLM#4247 (glm-5.1 via Qwen Code /review). One critical staleness fix + four real bug fixes + one operator-visibility breadcrumb + one refactor. Critical: - R7 #1 line 612: `discoverMcpToolsForServerInternal` now drops the entry from `lastRefusedServerNames` on successful connect+discover. Pre-fix a previously-refused server that reconnects via `/mcp reconnect` (or health-monitor retry after another server frees capacity) left the snapshot reporting `error / disabledReason: 'budget'` for a CONNECTED, working server until the next discovery pass cleared the per-pass log. Real bugs: - R7 #2 line 528: disabled gate added to `discoverMcpToolsForServerInternal`. Reachable from `/mcp reconnect`, OAuth re-discovery, and health-monitor `reconnectServer` — none of which previously checked `isMcpServerDisabled`. Pre-fix a disabled server could be resurrected through any of these paths, wasting a budget slot and registering tools the operator told us to ignore. Mirrors the bulk-discovery + readResource patterns. Optional-chain on the call to stay defensive against test fixtures missing the method. - R7 #3 line 634: transport leak in the `discoverMcpToolsForServerInternal` connect-failure catch. Pre-fix when `connect()` succeeded (transport established) and `discover()` later threw, the catch deleted the client reference without calling `client.disconnect()`, leaking the stdio child / socket until Node exit. Best-effort `await client.disconnect()` added before the map cleanup. - R7 #4 line 1302: `readResource`'s `weReservedSlot` now uses the same `reservation === 'reserved' && this.reservedSlots.has(serverName)` guard as `discoverMcpToolsForServerInternal`. Distinguishes a real fresh reservation from `off`-mode's no-op return. Maintenance-trap fix; in `off` mode the cleanup branch never fires now. - R7 #5 line 1342: `readResource` re-checks `isMcpServerDisabled` on EVERY call, regardless of whether the client was just lazy-spawned or pre-existing. Pre-fix a server connected pre-disable and then operator-disabled mid-session via settings reload still served resource reads via its existing CONNECTED client until the next incremental discovery pass called `removeServer`. Polish: - R7 #6 line 191: `readBudgetFromEnv` now emits a stderr breadcrumb when env values are invalid (`QWEN_SERVE_MCP_CLIENT_BUDGET=abc`, `QWEN_SERVE_MCP_BUDGET_MODE=foo`). Pre-fix operator typos silently fell through to "no enforcement". Same pattern as the `--require-auth` boot log. - R7 #7 line 464: extracted `dropRefusalEntry` (4 sites) + `refuseAndLog` (3 sites) helpers. Pure refactor, zero behavior change. The `readResource` refusal path now calls `refuseAndLog` before throwing `BudgetExhaustedError` so operators get the same stderr trail as bulk-discovery refusals. + 5 new core regression tests (refusal-cleared-on-success, internal-disabled-gate, discover-throw-disconnects, env-typo-breadcrumb, existing-client-disabled-rejected). 52/52 core tests pass; typecheck + lint clean. * fixup(serve): address PR 14 review round 8 (QwenLM#4247 wenshao seventh pass) Addresses @wenshao's seventh review pass on PR QwenLM#4247 (gpt-5.5 + DeepSeek/deepseek-v4-pro via Qwen Code /review). One critical transport leak + three soundness/consistency fixes; one optional clarity refactor explicitly deferred. Critical: - R8 #1 line 532 (4 duplicate threads): bulk-path transport leak. Mirrors the R7 #3 fix but in `discoverAllMcpTools` instead of the per-server path. Pre-fix: when `connect()` succeeded (transport established) and `discover()` later threw, the bulk catch deleted the client reference without calling `client.disconnect()`, leaking the stdio child / WebSocket / HTTP socket for the rest of the daemon's lifetime (`stop()` can't see what we just removed from `this.clients`). Best-effort `await client.disconnect()` added before `clients.delete` + `reservedSlots.delete`. Updated the doc comment that misleadingly claimed `stop()` was the lifecycle owner — true only for slot bookkeeping, not transports. Soundness: - R8 #2 line 431: tighten `readBudgetFromEnv` mode-without-budget downgrade. Originally only `enforce` got downgraded to `off` when no budget was set; `warn` mode without a budget threshold reached `emitBudgetTelemetry` with `clientBudget: undefined`, contradicting the JSDoc invariant `mode !== 'off' ⇒ clientBudget defined`. Now both `enforce` AND `warn` downgrade to `off` when no budget is configured. The invariant comment was also weakened to match the actual `?? 0` defense-in-depth (the new R8 #5 constructor downgrade closes the remaining edge case). - R8 #5 line 302: constructor mirrors the `readBudgetFromEnv` downgrade for the direct `budgetConfig` parameter. All production callers (CLI, `runQwenServe`, env-var fallback) validate upfront, but a future code path that injects `budgetConfig` directly without re-validating would re-introduce the silent fail-open. Defense in depth. - R8 #4 line 1221: distinguish fresh vs `'already_held'` reservations in `runWithDiscoveryTimeout`'s timeout handler. New private `freshReservations: Set<string>` field marked when `weReservedSlot === true` inside `discoverMcpToolsForServerInternal` and cleared in finally / catch / success. Timeout handler now releases the slot ONLY when `freshReservations.has(serverName)` — meaning the slot was freshly reserved by THIS in-flight call. `'already_held'` reconnect timeouts (a previously-healthy server's transient hiccup) keep the slot so health-monitor retry doesn't have to compete for capacity with new servers admitted during the timeout window. Aligns the timeout handler with the connect-failure catch's `weReservedSlot` semantics — closes the asymmetry wenshao R8 #4 caught. Deferred: - R8 #3 line 332 (`tryReserveSlot` `'observed'` return value clarity): optional, non-blocking style improvement that ripples through 3 call sites + many tests for zero behavior change. Worth doing in a focused refactor PR; flagged as deferred polish, not in this fixup. + 3 new core regression tests (bulk discover-throw disconnects, warn-no-budget downgrade, constructor enforce downgrade). 679/679 focused tests pass; typecheck + lint clean. * fixup(serve): address PR 14 review round 9 (QwenLM#4247 wenshao eighth pass) Addresses @wenshao's eighth review pass on PR QwenLM#4247 (glm-5.1 via Qwen Code /review). Six actionable findings adopted; two threads explained as not-actionable (one stale-view, one reviewer hallucination). Critical / real bugs: - R9 #2 line 1534: `readResource` lazy-spawn connect-failure catch now does best-effort `await client.disconnect()` BEFORE `clients.delete` + `reservedSlots.delete`. Mirror of R7 #3 (per-server discovery) and R8 #1 (bulk discovery) — closes the same transport-leak class for the third spawn path. Pre-fix: connect() establishing the transport but throwing on a later handshake step would orphan the stdio child / socket. - R9 #6 line 1521: `readResource` lazy `client.connect()` now wraps in `Promise.race` against `discoveryTimeoutFor(serverConfig)` — same per-server timeout the bulk + incremental paths use. Pre-fix a hung MCP server during a resource-read spawn would block forever and permanently consume a budget slot under enforce mode, cascading into total budget exhaustion. `serverConfig` lookup hoisted to the top of `readResource` so both lazy-spawn and existing-client branches use identical timeout behavior. - R9 #8 line 1514: `readResource` lazy spawn now calls `this.startHealthCheck(serverName)` after a successful connect. Pre-fix a lazy-spawned server that later disconnected (crash, network) had no automatic reconnect — sat DISCONNECTED until the next readResource or incremental pass. Mirrors `discoverMcpToolsForServerInternal`'s finally-block pattern. Operator-visibility: - R9 #7 (general): `readBudgetFromEnv` now writes a stderr breadcrumb when the `(enforce|warn)`-without-budget downgrade fires. Pre-fix a Docker Compose / k8s env that set `QWEN_SERVE_MCP_BUDGET_MODE=enforce` but forgot the matching `_BUDGET=N` would silently boot with enforcement off and `mcp_guardrails` capability advertised — operator only signal was the snapshot's `budgetMode: 'off'`. Now mirrors the R7 #6 invalid-value breadcrumb pattern. Doc fixes: - R9 #4 line 81: `McpBudgetConfig.clientBudget` JSDoc now reflects the R4 per-session scope correction. The doc was a leftover from the original "per-workspace" framing — every other doc surface (protocol doc, user doc, type comments on the snapshot cell, capability tag) was rewritten in R4 except this one. - R9 #5 line 870: `acpAgent.buildBudgetCells` now spells out the `liveCount` (`accounting.total`, CONNECTED only — operator observability) vs `reservedSlots.size` (all reserved including in-flight — enforcement) semantic distinction. The intentional gap was undocumented in the type signatures, JSDoc, and protocol doc; future PR 14b SSE event payloads should reference both. Not adopted: - R9 #1 acpAgent:15: claimed "MCP_BUDGET_WARN_FRACTION not exported + getMcpClient* methods don't exist + 4 tsc errors" — verified incorrect: the constant IS exported (mcp-client-manager.ts:61), the 3 methods ARE class members (lines 379, 407, 412), and `npm run typecheck` is clean across all 4 workspaces. Reviewer's tool hallucinated this critical finding. - R9 #3 mcp:410: reported the bulk-path transport leak that R8 #1 (commit 7228813) had already closed. Reviewer was on the pre-R8 commit view. + 2 new core regression tests (readResource lazy connect-fail disconnects + R9 #7 stderr breadcrumb). 57/57 core tests + 679/679 focused suite pass. Typecheck + lint clean. * fixup(serve): address PR 14 review round 10 (QwenLM#4247 wenshao ninth pass) Two non-blocking 🟢 nits — both adopted for symmetry / explicitness. - R10 line 357: constructor downgrade now emits the same stderr breadcrumb the env-var path got in R9 #7. Pre-R10 the `(enforce|warn)`-without-budget downgrade was silent for the direct-`budgetConfig` path, so a future caller bypassing CLI / env-var validation would have shipped a daemon advertising `mcp_guardrails` while silently disabling enforcement. Now boot logs surface the misconfiguration uniformly across all three resolution paths. - R10 line 1572: documented the `McpClient.disconnect()` cancel-pending-connect contract that the timeout-race cleanup relies on across all three spawn paths (lazy `readResource`, bulk `discoverAllMcpTools`, per-server `discoverMcpToolsForServerInternal`). The bulk path's production stability since QwenLM#3889 is implicit evidence the contract holds; comment makes the assumption discoverable to the next reader and notes a follow-up unit test would be valuable. No behavior change. 57/57 core tests pass. Typecheck + lint clean.
…R 18) (QwenLM#4250) * refactor(serve): add FileSystemService boundary (QwenLM#4175 PR 18) Introduce a per-request workspace filesystem boundary inside the `qwen serve` daemon. The boundary centralizes path canonicalization, symlink-aware boundary checks, ignore/trust policy, size/binary limits, and audit hooks behind a single typed surface — preparing PR 19 (read-only file routes) and PR 20 (write/edit routes) to share a guarded chokepoint instead of re-implementing path safety per route. Wave 4 PR 18 of QwenLM#4175 — pure refactor, no new HTTP routes; depends on PR 12 (QwenLM#4241) and PR 15 (QwenLM#4236), both merged. New module under `packages/cli/src/serve/fs/`: - `paths.ts` extracts `canonicalizeWorkspace` from `httpAcpBridge.ts` (re-exported there for backward compatibility) and adds: - `ResolvedPath` brand and `Intent` union (read/write/edit/list/ glob/stat) with exhaustiveness checks at the trust gate - `hasSuspiciousPathPattern` — detects NTFS ADS, 8.3 short names, long-path prefixes, UNC paths, trailing dots, DOS device names, and three-or-more-dot path components (claude-code-style) - `findExistingAncestor` with explicit ENOTDIR rejection so a regular file in a path component throws `parse_error` rather than passing boundary inspection and 500-ing later - `resolveWithinWorkspace` running a chain-aware realpath check with ENOENT-tolerant ancestor walk for write/stat intents - `errors.ts` defines `FsError` / `FsErrorKind` plus `wrapAsFsError`, which categorizes raw `fs.promises` errnos (EACCES → permission_ denied, ELOOP → symlink_escape, ENOTDIR → parse_error, etc.) so body-level failures emit audit events instead of escaping uncategorized - `policy.ts` carries `MAX_READ_BYTES` (256 KiB), `MAX_WRITE_BYTES` (5 MiB), `BINARY_PROBE_BYTES` (4 KiB), `shouldIgnore` (file/ directory aware), and `assertTrustedForIntent` with an exhaustive switch over `Intent` - `audit.ts` emits typed `fs.access` / `fs.denied` `BridgeEvent` frames with SHA-256-hashed paths, optional raw-path passthrough via `QWEN_AUDIT_RAW_PATHS=1`, and discriminator `kind` fields so SDK consumers can exhaustively narrow `event.data` - `workspaceFileSystem.ts` — `WorkspaceFileSystem` interface + `createWorkspaceFileSystemFactory` with eight methods (resolve, stat, readText, readBytes, list, glob, writeText, edit). Every body method funnels failures through `recordAndWrap`, which wraps raw fs errors and always emits an `fs.denied` audit event before rethrowing. `readText` enforces `MAX_READ_BYTES` *before* delegating to the slurping core service so unbounded requests against multi-gigabyte files can no longer OOM the daemon. `glob` realpath-checks each hit against the canonical workspace and reports filtered escapes via a single aggregated `fs.denied` event with the dropped count - `index.ts` is the barrel re-export PR 19/20 will import from Modified files: - `packages/cli/src/serve/httpAcpBridge.ts` — extracted `canonicalizeWorkspace` to `fs/paths.ts`; the bridge re-exports it so existing callers in `server.ts` and `runQwenServe.ts` keep working - `packages/cli/src/serve/server.ts` — added `fsFactory?: WorkspaceFileSystemFactory` to `ServeAppDeps`; `createServeApp` builds a strict default (`trusted: false`, warn-once no-op `emit`) when none is injected so a future refactor that forgets `fsFactory` injection cannot silently allow writes against an untrusted workspace; factory parked on `app.locals` for PR 19/20 route handlers - `packages/core/src/index.ts` — re-exports `Ignore`, `loadIgnoreRules`, and `LoadIgnoreRulesOptions` from `utils/filesearch/ignore.js` for cli consumption 411 serve tests pass; typecheck clean. Engineering principles checklist: - [x] Independently mergeable (no new routes, no new capability tag) - [x] Backward compatible (no removed routes / event fields / CLI behavior) - [x] Default off (no public surface change; PR 19/20 will activate routes) - [x] qwen serve Stage 1 routes preserved - [x] Gradual migration (PR 19/20 will adopt the boundary) - [x] Reversible (single PR rollback) - [x] Tests-first (101 unit tests across the new module + contract test) 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): address PR review feedback (QwenLM#4250) Codex + Copilot review found 8 substantive issues against 7b0db4c; this commit fixes all of them. The first two are P1 build-breakers introduced by the pre-commit `eslint --fix` auto-promoting value imports to `import type` — 31 fs tests were failing post-commit until this fix. Issue list (links to PR comments at QwenLM#4250): 1. Import type erased runtime values — workspaceFileSystem.ts:10-15. eslint's consistent-type-imports rule rewrote `import { Ignore, StandardFileSystemService, loadIgnoreRules, type WriteTextFileOptions }` -> `import type { ... }` because it saw type-only usage of `Ignore`. That erased `loadIgnoreRules` (called at runtime) and `StandardFileSystemService` (constructed at runtime), causing TS1361/TS2206 and runtime ReferenceErrors. Restored as a value import with inline `type` modifiers per-symbol and an eslint-disable line + comment so future autofixes don't repeat the regression. 2. Same import-type erasure in contract.test.ts:14 — `isFsError` was lumped under `import type` even though it's called at runtime. Same fix shape. 3. edit() OOM hole — workspaceFileSystem.ts. The earlier review pass added a pre-stat MAX_READ_BYTES gate to readText but missed edit, which fsp.readFile's the whole file before any size check. Multi-GB targets inside the workspace could OOM the daemon. Now stat-first; refuses above the cap with file_too_large; also rejects binary files (string indexOf over arbitrary bytes is meaningless). 4. glob accepted absolute / device patterns — workspaceFileSystem.ts. The ..-segment check stopped lexical traversal but /etc/** / C:\\Users\\foo\\** / \\\\server\\share\\** / //server/share/** still reached globAsync, walking outside the workspace before per-hit filtering dropped the results. Now rejects these patterns up-front with parse_error so no I/O happens outside. 5. glob ignore filter probed every hit as `file` — workspaceFileSystem.ts. The underlying `ignore` library needs a trailing-slash probe for dist/ / .git/-style directory patterns; probing as `file` silently leaked directory matches. Now lstats each hit and routes 'directory' vs 'file' to shouldIgnore so dir-only ignore rules actually match. 6. ReadTextOptions.line off-by-one — workspaceFileSystem.ts. The public option was documented as 1-based but forwarded as-is to readFileWithLineAndLimit, which is 0-based. A request with line: 1 returned content starting at the second line. Now converts 1-based -> 0-based at the boundary; doc clarified; truncation check uses the converted index. 7. ServeAppDeps.fsFactory JSDoc said trusted=true — server.ts:96. Stale from before the strict-default refactor in the same review pass. Rewrote to match the actual trusted: false + warn-once emit behavior. 8. MAX_READ_BYTES JSDoc said reads above cap return truncated — policy.ts:18. Stale from before the hard-cap refactor; now correctly states the cap throws file_too_large and that soft truncation only applies under the cap via enforceReadSize. 7 new tests cover the new behaviors: - POSIX-absolute pattern rejection - Win32 / UNC pattern rejection (4 variants) - directory-pattern ignore (dist/) - edit file-too-large - edit binary refusal - readText line: 1 returns from first line - readText line: 2 starts from second line 418/418 serve tests pass; typecheck + eslint clean. Deferred follow-ups (per PR review reply): - glob maxResults is applied after globAsync materializes every match. A streaming iterator (glob.iterate) would bound the walk too. Non-trivial; tracked as a separate hardening follow-up since current behavior is correctness-safe (just not optimal under huge trees). - Per-path glob escape hash in audit hint (currently aggregated count) — can revisit once PR 19 wires the routes and we see real audit volume. - EVENT_SCHEMA_VERSION migration mechanism — orthogonal; the whole BridgeEvent schema lacks one and that's a Wave 5+ concern. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 3 review-round-2 findings (QwenLM#4250) The wenshao + DeepSeek reviewer pass on a81ada4 surfaced 3 more issues; this commit fixes them. 1. Dangling-symlink write escape (paths.ts) — Critical security bug. A request like `write /ws/escape` where `<ws>/escape` is a symlink whose target doesn't exist YET would pass the ENOENT-tolerant ancestor walk: realpath fails ENOENT, the walk-up returns `<ws>` as ancestor, the canonical becomes `<ws>/escape`, containment passes — but the eventual write follows the symlink and creates the file at the symlink target outside the workspace. lstat-then-readlink before the ancestor walk catches this; the symlink target is itself resolved via the deepest existing ancestor so macOS /private/var canonicalization stays consistent with boundCanonical (an absolute target inside the workspace tmpdir would otherwise have been false-flagged on macOS). 2. glob realpath catch over-reported symlink_escape (workspaceFileSystem.ts) — every realpath failure inside the per-hit boundary loop was counted as `symlink_escape`. EIO, EACCES, ENAMETOOLONG, EBUSY are environmental failures, not security events; mislabeling them poisoned the audit signal for operators trying to investigate genuine escape attempts. Now distinguished: ENOENT/ELOOP count as escapes; other errnos count as transient errors and emit a separate aggregated `fs.denied` with errorKind: 'permission_denied'. 3. policy.ts:enforceReadSize JSDoc said the boundary "intentionally does NOT throw" — stale after a81ada4's hard-cap refactor. Rewrote to clarify the helper is the soft truncation gate that only fires under the hard cap; readText itself enforces the hard cap with file_too_large via its pre-stat check. The readme/contract is now consistent with workspaceFileSystem.ts. 2 new tests: - dangling symlink targeting outside-workspace path → symlink_escape - dangling symlink targeting future-inside-workspace path → succeeds (ahead-of-mkdir flow for atomic-write-via-rename) 420/420 serve tests pass; typecheck clean. Remaining tracked follow-ups (per PR review reply): - list/glob brand cast (P2 deferred per PR description) - glob audit pathHash hashes pattern not paths - edit() TOCTOU read-modify-write race (atomic-via-temp + rename) - wrapAsFsError ENOSPC/EIO mapping to a distinct kind - runQwenServe → fsFactory injection integration test - glob maxResults streaming (glob.iterate) 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): cross-platform ENOTDIR detection (QwenLM#4250 CI fix) Windows CI test failure on a81ada4 surfaced a real cross-platform bug in `findExistingAncestor`. POSIX returns `ENOTDIR` when `fs.stat` traverses through a non-directory in a path component (e.g. `${ws}/file.txt/leaf` where `file.txt` is a regular file). **Windows returns `ENOENT` for the same case.** The errno-based guard added in a81ada4 only branched on `ENOTDIR`, so the Windows path silently fell through to the ancestor walk and the boundary returned a "canonical" the eventual write could not honor — `WorkspaceFileSystem - audit always emits on body errors > rejects ENOTDIR ancestor walk with parse_error rather than passing boundary` failed with `expected false to be true` on the windows-latest runner. Fix: switch from errno-based detection (platform-divergent) to dirent-kind detection. After `fs.stat` succeeds during the walk-up, if the existing ancestor is NOT a directory AND there are unresolved tail components, throw `parse_error`. Both `ENOENT` and `ENOTDIR` from `fs.stat` are now treated as "the *current* path doesn't resolve, keep walking" — the post-walk kind check fires regardless of which errno surfaced. Cross-platform-safe. The local 110/110 fs tests still pass on macOS/Linux; the Windows case will exercise the kind-check branch on next CI run. macOS CI failures on the same workflow run (`InputPrompt.test.tsx` placeholder reuse, `SettingsDialog.test.tsx` 5s timeout) are pre- existing flaky UI tests, NOT touched by this PR. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 4 review-round-3 findings (QwenLM#4250) DeepSeek's third review pass (after b38e821) flagged four more issues; this commit fixes all of them. 1. Multi-hop dangling symlink bypass (paths.ts) — Critical security fix. The earlier single-readlink fix at efd7a46 was bypassed by chained dangling symlinks: <ws>/leak -> <ws>/middle -> /scratch/evil where every layer is a symlink and the final target doesn't exist. The fix only readlink'd the first hop (<ws>/middle), saw it was inside the workspace via findExistingAncestor, and let the chain through. The OS write at <ws>/leak would then follow both hops and create /scratch/evil. Now loops lstat + readlink up to MAX_ANCESTOR_HOPS, tracks visited inodes for cycle detection, and only validates containment on the fully-dereferenced leaf. Cycle detection rejects with symlink_escape; chains that exceed the hop bound also surface as symlink_escape with a "too long or contains a cycle" hint. 2. opts.line validation (workspaceFileSystem.ts) — the docstring committed to "1-based positive integer" but Infinity / floats / negative values flowed through to readFileWithLineAndLimit and degraded silently. Now enforces Number.isSafeInteger + line >= 1 at the boundary; everything else throws parse_error. Test covers Infinity, -Infinity, 0, -1, 1.5, NaN. 3. io_error FsErrorKind (errors.ts) — wrapAsFsError previously conflated EIO/EBUSY/ENAMETOOLONG/EMFILE/ENFILE/ENOSPC/ETXTBSY under permission_denied. Monitoring pipelines that key on errorKind for security alerting would page oncall on a full disk. New io_error kind (HTTP 503) maps the environmental- failure errnos with distinct hints. EACCES/EPERM stay on permission_denied (literal access denial); EIO (failing disk), EBUSY (busy file), ENAMETOOLONG (PATH_MAX), EMFILE/ENFILE (fd exhaustion), ENOSPC (df -h reporting 100%), ETXTBSY (text-busy) all route to io_error. 4. glob audit kind taxonomy (workspaceFileSystem.ts) — three-way classification mirrors wrapAsFsError so the per-hit realpath catch surfaces ENOENT/ELOOP -> symlink_escape, EACCES/EPERM -> permission_denied, everything else -> io_error. Each class emits its own aggregated fs.denied event. 5. edit() matchedIgnore (workspaceFileSystem.ts) — readText and writeText both stamp matchedIgnore in their access audit; edit didn't, so operators monitoring fs.access events couldn't distinguish edits to .gitignore'd files (build artifacts, logs) from edits to tracked source. Added the same shouldIgnore + matchedIgnore plumbing that readText uses. 8 new tests: - multi-hop dangling symlink (security) - symlink cycle (security) - ENOSPC/EIO/EBUSY/ETXTBSY/ENAMETOOLONG -> io_error mapping - io_error -> HTTP 503 - EMFILE/ENFILE updated to io_error (was permission_denied) - opts.line rejects Infinity/-Infinity/0/-1/1.5/NaN - edit() audit records matchedIgnore on .log file 426/426 serve tests pass; typecheck clean. Remaining tracked follow-ups (per PR review reply): - list/glob brand cast (P2 deferred per PR description) - glob audit pathHash hashes pattern not paths - edit() TOCTOU read-modify-write race (atomic-via-temp + rename) — pinned to PR 20 - runQwenServe → fsFactory injection integration test — pinned to PR 19 - glob maxResults streaming (glob.iterate) 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 3 review-round-4 critical findings (QwenLM#4250) DeepSeek's fourth review pass surfaced three more critical bugs; all are fixed in-PR. 1. TOCTOU symlink substitution in readText/readBytes/edit (workspaceFileSystem.ts) — Critical. fsp.stat(p) and the subsequent lowFs.readTextFile(p) (or fsp.readFile / fsp.readFile for edit) are two separate syscalls. An attacker who can write into the workspace can swap p from a regular file to a symlink pointing outside between the two calls; pre-stat sees the original, the read follows the swap. Fix: assertInodeStableAfterRead(p, preIno) — after each read, re-lstat p; reject with symlink_escape if the path is now a symlink (post.isSymbolicLink()) or its inode changed (preIno !== post.ino, with a 0-ino fallback for procfs / virtual mounts that don't report meaningful inodes). Catches the swap-and-leave attack and the swap-and-keep-swapped attack. Residual race (attacker swaps back AFTER our read but BEFORE our lstat) is much smaller than the original window and outside PR 18's threat model; fd-based reading via fsp.open + fileHandle.read would close it entirely but requires a new variant of lowFs that takes a FileHandle — tracked as a follow-up. 2. UTF-8 truncation corruption in readText (workspaceFileSystem.ts) — Critical. buf.subarray(0, sizeOutcome.bytesToRead).toString ('utf-8') silently emits U+FFFD when bytesToRead falls mid-codepoint (CJK, emoji). A downstream consumer parsing JSON or source code over the truncated content would see broken trailing bytes; meta.truncated would be true but the prefix is corrupt. A subsequent edit() with the corrupted string as oldText would also fail to match the on-disk content. Fix: safeUtf8Truncate(buf, maxBytes) walks back from maxBytes through any continuation bytes (0b10xxxxxx), then verifies the leading byte's full sequence fits within the cap; drops the leading byte if it doesn't. The result is always a clean prefix at a valid codepoint boundary. Test pins '中文测试' (12 bytes / 3 bytes per char) truncated at 7 bytes -> '中文' (no U+FFFD). 3. glob opts.cwd bypasses workspace boundary (workspaceFileSystem.ts) — Critical. opts.cwd was used directly as the glob root with no validation against boundWorkspace. ResolvedPath is a brand cast and a stale or forged value lets a glob('**/*', { cwd: '/etc' }) enumerate files outside the workspace. The pattern-side absolute / UNC checks added in a81ada4 only constrain the *pattern*; cwd is the actual hazard. Fix: at the entry point of glob(), path.resolve cwd and isWithinRoot-check against boundWorkspace. Throws path_outside_workspace if cwd is outside, even when the pattern itself is harmlessly relative. Test pins the case with cwd: scratch (outside workspace). 3 new tests: - readText with mid-operation symlink swap -> symlink_escape - safeUtf8Truncate keeps CJK codepoints intact at 7-byte cap - glob with opts.cwd outside workspace -> path_outside_workspace 429/429 serve tests pass; typecheck + eslint clean. Remaining tracked follow-ups (Post-PR-18 hardening, in QwenLM#4175): - list/glob brand-cast contract (PR 19) - runQwenServe → fsFactory injection contract test (PR 19) - edit() write-side TOCTOU + atomic-via-temp + expectedHash (PR 20) - glob audit pathHash (independent audit.ts commit) - glob maxResults streaming (independent hardening) - glob pattern preflight refactor to reuse hasSuspiciousPathPattern (cosmetic) 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 7 review-round-5 findings (QwenLM#4250) DeepSeek round-5 surfaced 9 comments; 7 are real findings (the other 2 — UTF-8 truncation + glob opts.cwd — already fixed in 5468342 from round-4 and replied-to inline). Stack on round-4. 1. edit() empty-oldText silent-prepend (workspaceFileSystem.ts) — Critical silent data corruption. JS `''.indexOf('')` returns 0, so without an empty-string guard `current.slice(0,0) + newText + current.slice(0)` = `newText + current` — silently prepends `newText` to the whole file with a success audit event. PR 20 routes that thread user-supplied `oldText` verbatim must not be able to trigger this. Now throws `parse_error` BEFORE the read with a hint explaining why empty matches are rejected. 2. DOS device name regex misses bare names + first-extension forms (paths.ts) — Windows attack surface. The earlier /\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i only caught the last-extension form (file.CON). NTFS reserves these names regardless of extension: CON, NUL, CON.txt, NUL.dat, CON.foo.bar are ALL reserved device handles. New regex /(^|\.)(CON|...|LPT[1-9])(\.|$)/i covers all four positions (bare / first-ext / last-ext / middle-ext) while still admitting legitimate substrings (BACON, concat.txt, precon.go). 3. Buffer.byteLength replaces Buffer.from for size-only checks (workspaceFileSystem.ts) — 5 MB heap allocation per write eliminated. `writeText` and `edit()` previously materialized the entire UTF-8 payload (up to MAX_WRITE_BYTES) just to read `.length`; `Buffer.byteLength(content, 'utf-8')` returns the count without allocating. 4. edit() error message includes oldText snippet (workspaceFileSystem.ts) — Production debuggability. The earlier hint was just "edit() expects oldText to appear verbatim in the file" — at 3 AM an operator can't tell whether the mismatch is whitespace, a stale file, or a wrong target. Now includes a JSON-quoted truncated snippet (max 80 chars + ellipsis) of the searched-for text. 5. recordAndWrap forwards FsError message into fs.denied audit (workspaceFileSystem.ts + audit.ts) — Audit observability gap. Audit consumers debugging an incident saw `errorKind` + `hint` but lost the underlying OS error detail (path, errno text, byte count). FsDeniedAuditPayload now carries an optional `message` field; recordAndWrap forwards `fs.message` automatically. 6. EISDIR / ENOTDIR distinct hints (errors.ts) — UX. Both shared the same hint "a path component is not a directory where one was expected" — for EISDIR (path IS a directory but a file was expected) the wording was reversed. Now distinct hints with the errno name explicitly cited. 7. kindFromStats / kindFromDirent merged into kindFromStatLike (workspaceFileSystem.ts) — duplicate function bodies removed. Both fs.Stats and fs.Dirent expose the same isFile / isDirectory / isSymbolicLink interface, and both targets (FsStat['kind'], FsEntry['kind']) are the same 4-value union. Single helper avoids drift if the union grows. 4 new tests: - bare/multi-ext DOS device names (CON, NUL, CON.txt, CON.foo.bar) + legitimate substrings (BACON, concat, precon, contemplating) - edit() empty oldText -> parse_error + file unchanged - edit() not-found error includes searched snippet in hint - fs.denied audit payload carries FsError message Plus the 2 already-fixed items (UTF-8 boundary, glob opts.cwd) have new test coverage from round-4. 433/433 serve tests pass; typecheck + eslint clean. Stack: 7b0db4c → a81ada4 → efd7a46 → b38e821 → 911cb8e → 5468342 → THIS 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 7 review-round-6 findings (QwenLM#4250) Round-6 review (wenshao + gpt-5.5) flagged 7 items including two Critical and one privacy regression I introduced in round-5. 1. writeText / edit pre-write symlink guard (workspaceFileSystem.ts) — Critical, two reviewers (wenshao #CsBcq + gpt-5.5 #CsB3M) independently flagged. `atomicWriteFile` (`packages/core/src/utils/atomicFileWrite.ts`) resolves symlinks at write time, so a swap between the boundary's `resolve()` and `lowFs.writeTextFile()` would let the write follow the symlink to outside the workspace. `writeText` had no read phase, so its window was wider than `edit()`'s. New helper `assertNotSymlinkBeforeWrite(p)` lstats the path immediately before each `lowFs.writeTextFile` call; ENOENT is fine (ahead-of-create flow), but an actual symlink throws `symlink_escape`. Used in `writeText` AND `edit()`. Residual race after this guard but before the write completes is the deferred PR 20 atomic-via-temp follow-up. 2. recordAndWrap message field bypassed privacy mode (audit.ts) — Critical privacy regression I introduced at ebd9e78 (round-5). The new `FsDeniedAuditPayload.message` field forwarded `FsError.message` unconditionally — and many throw-sites embed `${p}` (absolute paths) or user-supplied `oldText` snippets into the message. Privacy-mode operators (without `QWEN_AUDIT_RAW_PATHS=1`) saw paths leak through the message field even though they explicitly disabled raw-path logging. Fixed: `message` is now gated behind `includeRawPaths` alongside `relPath`. Privacy mode = no path-bearing fields, period. Operators wanting forensic context opt in via `QWEN_AUDIT_RAW_PATHS=1` and accept both fields together. 3. glob opts.cwd via symlink (workspaceFileSystem.ts) — gpt-5.5 #CsB3P. Textual `path.resolve(cwd) + isWithinRoot` admits `<ws>/link` even when `<ws>/link → /etc` is a symlink to outside; `globAsync` walks `/etc` before the per-hit filter drops results. Switched to `fsp.realpath(path.resolve(cwd))` so the containment check sees the actual walk root. ENOENT on cwd surfaces as `path_not_found`. 4. readText OOM via concurrent file growth (workspaceFileSystem.ts) — wenshao #CsBeE. The pre-stat `MAX_READ_BYTES` gate only sees the size at stat time; a concurrent writer can grow the file before the actual `readFileWithLineAndLimit` slurp. Added post-read `Buffer.byteLength(result.content) > MAX_READ_BYTES` check. The proper fix (fd-based read tying size + read to the same inode) is a hardening follow-up; this byte-length check is the defense-in-depth layer. 5. readBytes maxBytes can widen past MAX_READ_BYTES (policy.ts) — wenshao #CsBj5. `enforceReadBytesSize(st.size, opts.maxBytes)` used the caller-supplied `maxBytes` as the ceiling, replacing rather than clamping `MAX_READ_BYTES`. A future PR 19/20 route forwarding `req.query.maxBytes` could blindly bypass the daemon's 256 KiB safety cap. Now clamps via `Math.min(maxBytes, MAX_READ_BYTES)`. 6. ENOENT_TOLERATING_INTENTS docstring + test (paths.ts) — wenshao #CsBk3. The Intent docstring only mentioned `'write'` tolerating ENOENT; `'stat'` was in the set undocumented. A future maintainer removing `'stat'` thinking it was a copy-paste error would silently change behavior (stat on a concurrently-deleted path would throw `path_not_found` from the resolver instead of letting `fsp.lstat` throw `ENOENT` naturally). Amended docstring to call out `'stat'`'s rationale explicitly + added contract corpus case. 6 new tests: - glob cwd via symlink to outside → path_outside_workspace - writeText with mid-operation symlink swap → symlink_escape + outside file unchanged - edit with mid-operation symlink swap → symlink_escape + outside file unchanged - readBytes opts.maxBytes attempting widening → file_too_large - fs.denied message field absent in privacy mode (default) - fs.denied message field present in raw-paths mode (forensic) - contract corpus: resolve('newdir/leaf', 'stat') succeeds for ENOENT path 439/439 serve tests pass; typecheck + eslint clean. Stack: 7b0db4c → a81ada4 → efd7a46 → b38e821 → 911cb8e → 5468342 → ebd9e78 → THIS 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 5 review-round-7 findings (QwenLM#4250) glm-5.1 round-7 review surfaced 6 items; 5 are real fixes, 1 was already addressed in 7f4f30d (writeText pre-write symlink guard — reviewer looked at ebd9e78 snapshot ~8h before the round-6 fix). Stack on round-6. 1. edit() bypassed lowFs.readTextFile (workspaceFileSystem.ts) — Critical encoding round-trip corruption. The earlier `fsp.readFile(p, 'utf-8')` included the UTF-8 BOM verbatim in `current`, breaking `oldText` matching even when the user passed the exact source text from a previous read; lost iconv-supported codepage handling (GBK / Big5 / Shift_JIS), so non-UTF-8 files would mojibake into `current` and round-trip-corrupt on write-back; and the subsequent `lowFs.writeTextFile` passed no `_meta`, stripping the BOM and forcing UTF-8 on write-back even when the original was BOM'd or non-UTF-8. Fix: read via `lowFs.readTextFile` AND forward `readResult._meta` into the write-back. Test pins UTF-8 BOM round-trip end-to-end. 2. hasSuspiciousPathPattern multi-digit and POSIX false positive (paths.ts) — `/~\\d/` only matched single-digit; NTFS allocates `~10+` on >9 collisions. POSIX false-positives: editor swap files, backup tools used `~N` legitimately. Fixed to `/~\\d+/` and gated behind `process.platform === 'win32'`, matching the ADS-colon check. 3. canonicalizeWorkspace redundant per-request syscall (paths.ts) — Performance. Factory canonicalizes once at build; every `resolveWithinWorkspace` also ran realpathSync on the same path, blocking the event loop. Added CANONICAL_BOUND_CACHE Map keyed on the input string; steady-state size = 1 per `1 daemon = 1 workspace`. 4. readBytes opts.maxBytes API contract (workspaceFileSystem.ts) — Semantic mismatch. Parameter name promised window-read; impl only used it as a hard reject gate. Now truncates the buffer post-read so `readBytes(p, { maxBytes: 1024 })` on a 200 KB file returns 1 KB. Hard `MAX_READ_BYTES` cap still throws for files above it. 5. glob walks node_modules and .git unnecessarily (workspaceFileSystem.ts) — Performance. Without an `ignore` option, `globAsync` traversed every file under those dirs before our per-hit `shouldIgnore` filter. Now passes `ignore: ['**/node_modules/**', '**/.git/**']` to short-circuit traversal. Post-filter via `shouldIgnore` remains authoritative. 5 new tests: - 8.3 short-name regex Windows / POSIX split - readBytes truncates 2048-byte file to 1024 with maxBytes - readBytes throws file_too_large only above hard cap - edit() preserves UTF-8 BOM round-trip - glob prunes node_modules and .git 442/442 serve tests pass; typecheck + eslint clean. Stack: 7b0db4c → a81ada4 → efd7a46 → b38e821 → 911cb8e → 5468342 → ebd9e78 → 7f4f30d → THIS 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 10 review-round-8/9 findings (QwenLM#4250) Two more review passes (gpt-5.5 + DeepSeek + wenshao) flagged 13 items; 10 are real fixes, 3 are reviewer-stale-snapshot or already-tracked. Stack on round-7. Critical (5): 1. paths.ts symlink-escape hint embedded the symlink target (gpt-5.5) — Privacy regression sibling to round-6 audit `message` gate. `recordDenied` always forwards `hint` into `fs.denied` even with `QWEN_AUDIT_RAW_PATHS` off; the hint `'symlink points to /Users/alice/secret'` leaks the attacker's intended exfiltration path through audit. Hint is now path-free; operators wanting the resolved target enable `QWEN_AUDIT_RAW_PATHS=1` and read it from `relPath` / `message`. 2. paths.ts dangling-symlink chain discarded its verified canonical (DeepSeek) — After the multi-hop walk validated `cursor → canonicalTarget` was inside the workspace, the code fell through to `findExistingAncestor(absolute)`, re-walking from the original input and discarding the verified result. An attacker swapping an intermediate symlink between the verification and the re-walk could produce a different canonical than the one validated. The verified `canonicalTarget` is now captured in `symlinkResolvedCanonical` and used directly; the `findExistingAncestor(absolute)` fallthrough only runs when no symlink was traversed. 3. workspaceFileSystem.ts readBytes missing post-read size check (DeepSeek) — Same TOCTOU shape as `readText`'s round-6 fix. The pre-stat `enforceReadBytesSize` sees the size at stat time; a concurrent appender keeps the same inode but grows the file past the cap before `fsp.readFile` returns. `assertInodeStableAfterRead` catches inode changes but not same-inode growth. Added a post-read `buf.length > MAX_READ_BYTES` check matching `readText`'s defense-in-depth pattern. 4. errors.ts wrapAsFsError default = permission_denied (DeepSeek) — Misclassified non-errno errors (`TypeError`, programmer-error throws, native module exceptions) as security denials, paging security oncall for what should be a developer ticket. New `internal_error` kind (HTTP 500) is the new default; `permission_denied` reserved for actual `EACCES`/`EPERM`. 5. audit.ts AuditContext.sessionId not forwarded to BridgeEvent (DeepSeek) — Multi-session daemons couldn't trace audit events back to the session that triggered them. `originatorClientId` identifies the client, not the session. Added optional `sessionId` field to both `FsAccessAuditPayload` and `FsDeniedAuditPayload`, forwarded from `ctx.sessionId` when present. Improvements (4): 6. workspaceFileSystem.ts glob cwd realpath redundant when cwd === boundWorkspace (wenshao) — `boundWorkspace` is already canonicalized by the factory (`realpathSync.native` at build time), so calling `fsp.realpath` per-request when no `opts.cwd` was supplied is a redundant async syscall. Added a short-circuit. 7. workspaceFileSystem.ts kindFromStatLike JSDoc orphaned (wenshao) — Inserting `assertNotSymlinkBeforeWrite` between the JSDoc and `kindFromStatLike` left the doc floating above the wrong function. IDE hovers showed the wrong description. Moved the doc back to its function. 8. workspaceFileSystem.ts shared mutable Ignore object (DeepSeek) — `createWorkspaceFileSystemFactory` builds one `Ignore` instance and shares it across every `WorkspaceFileSystemImpl` returned by `forRequest()`. `Ignore.add(): this` is a public mutator. A future "per-session ignore rules" feature calling `.add()` from a request handler would silently corrupt all concurrent sessions. `Object.freeze` turns the cross-request mutation into a `TypeError` rather than a silent leak. 9. server.ts createDefaultFsAuditEmit one-shot warned (DeepSeek) — Permanent silent no-op after the first event; only logged the event `type` with no pathHash / errorKind / intent. If PR 19 forgets the real factory injection, every write 403s and audit is silent past the first warning — exactly the regression the warning exists to surface. Periodic warning (every 100th drop) + first-event context (errorKind, intent, pathHash) makes the regression actionable in production logs. Cleanup (1): 10. workspaceFileSystem.ts safeUtf8Truncate dead code (DeepSeek noted as "off-by-one") — The lead-byte seqLen-check block was dead code: `subarray(0, end)` already excludes the leading byte at `end`, so no further adjustment is ever needed. Removed the block; function is now 4 lines and still produces a valid codepoint prefix. Reviewer's suggested fix (`buf[end-1] → buf[end]`) was technically correct but redundant with the subarray cut. Already-fixed (3 reviewer-stale-snapshot, reply + resolve): - writeText pre-write symlink guard — fixed in 7f4f30d - edit() read-modify-write race — already deferred to PR 20 atomic-via-temp follow-up - glob maxResults walk-bound — already follow-up #5 3 new tests + 2 updated: - wrapAsFsError unknown errno → internal_error (default change) - internal_error has HTTP 500 - non-Error throwables → internal_error (not permission_denied) - readBytes post-stat growth → file_too_large - existing wrapAsFsError test updated for new default 445/445 serve tests pass; typecheck + eslint clean. Stack: 7b0db4c → a81ada4 → efd7a46 → b38e821 → 911cb8e → 5468342 → ebd9e78 → 7f4f30d → 1dc9d22 → THIS 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve/fs): close 3 review-round-10 findings (QwenLM#4250) Round-10 review (wenshao) flagged 4 items, all marked non-blocking by the reviewer. 3 are landed in-PR; 1 (glob withFileTypes optimization) is moved to issue QwenLM#4175 follow-ups since the reviewer themselves recommended "test it on a 100K-file workspace after PR 19 lands." 1. canonicalizeWorkspace docstring (paths.ts) — Documentation. The earlier FIXME warned about sync syscall blocking the event loop without mentioning the `CANONICAL_BOUND_CACHE` added in 1dc9d22 brings steady-state cost to zero. Future reviewers reading the FIXME again would re-flag the already-mitigated concern. Added a paragraph noting cache hit rate = 100% under the `1 daemon = 1 workspace` model so per-request blocking only happens at boot or on fresh workspace values (e.g. tests). 2. enforceReadBytesSize dead `maxBytes` parameter (policy.ts) — Round-7 changed `readBytes` to use post-read truncation for the soft window cap, leaving the function's `maxBytes` parameter unused at the only callsite. The `Math.min(maxBytes, MAX_READ_BYTES)` clamp branch became dead code. Reviewer's option 1: tighten the signature to `enforceReadBytesSize(fileBytes: number): void`. Done — the function is now purely the hard-cap enforcer its name implies; soft-window truncation lives in the orchestrator's `buf.subarray(0, opts.maxBytes)` post-read step where it's visible alongside the cap check it complements. 3. relForAudit cross-drive sentinel (audit.ts) — Windows-only privacy edge case. `path.relative('C:\\ws', 'D:\\evil')` returns `'D:\\evil'` (an absolute path) because Win32 can't express cross-drive relatives. Even when raw-paths mode is ENABLED, the audit `relPath` field would carry the off-drive absolute path, exposing the attacker's drive letter + directory. Added a `path.isAbsolute(rel)` post-check that substitutes a `<cross-drive>` sentinel — audit consumers see the cross-drive case distinctly without leaking the offending path. This was previously P2 deferred in PR 18's description ("Windows cross-drive path.relative"); reviewer's "few lines beats deferring" assessment was right. Tracked as follow-up (not in this commit): 4. glob withFileTypes optimization — Replace per-hit `lstat` (line ~664) with `glob` v10's `withFileTypes: true` so each hit comes back as a `Path` object with `isDirectory()` / `isFile()` / `isSymbolicLink()` already available. Saves N syscalls in large workspaces. Non-trivial restructure (return type changes from `string[]` to `Path[]`). Reviewer themselves marked "[performance / non-blocking]" and said "test it on a 100K-file workspace after PR 19 lands so we know whether it's worth it." Added to issue QwenLM#4175 body's Post-PR-18 hardening follow-ups. 446/446 serve tests pass; typecheck + eslint clean. Stack: 7b0db4c → a81ada4 → efd7a46 → b38e821 → 911cb8e → 5468342 → ebd9e78 → 7f4f30d → 1dc9d22 → a33d459 → THIS 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…M#4255) * feat(serve): auth device-flow route Implements issue #4175 Wave 4 PR 21. Brokers OAuth 2.0 Device Authorization Grant (RFC 8628) through the `qwen serve` daemon so a remote SDK client can trigger a Qwen-account login whose tokens land on the **daemon** filesystem, not on the client. The daemon polls the IdP itself; the client's only job is to display the verification URL + user code. Runtime locality (#4175 §11): the daemon NEVER spawns a browser or calls `open(url)` — even when running locally. Static-source grep test fails the build on `node:child_process` / `open` / `xdg-open` / `shell.openExternal` / `execa` / `shelljs` / `process.spawn` and their dynamic-import / require variants. - `POST /workspace/auth/device-flow` — strict mutation gate; returns 201 fresh / 200 idempotent take-over with `attached: true`. Per per-`providerId` singleton: a second POST while pending takes over rather than allocating a new `device_code`. - `GET /workspace/auth/device-flow/:id` — public state read. Pending entries echo `userCode/verificationUri/expiresAt/intervalMs`; terminal entries (5-min grace) drop them and surface `status/errorKind/hint`. - `DELETE /workspace/auth/device-flow/:id` — strict; idempotent (terminal → 204 no-op; unknown → 404). - `GET /workspace/auth/status` — pending flows + supported providers snapshot. v1 stub for `providers: []` (populated in fold-in 1). `DeviceFlowRegistry` (`packages/cli/src/serve/auth/deviceFlow.ts`) is the in-memory state holder: - per-`providerId` singleton with idempotent take-over - workspace-wide cap of 4 active flows (abuse defense) - 5-min terminal grace so SDK reconnects can still observe results - TTL sweeper evicts grace-expired entries every 30s - in-flight `Promise` map coalesces concurrent `start()` calls so two parallel POSTs don't double-allocate IdP `device_code` - `transitionTerminal` returns `boolean` so caller-side emit/audit guard prevents sweeper × poll-tick double-fire - `dispose()` wired into `runQwenServe.close()`'s shutdown drain; cancels `provider.poll()` mid-flight via `cancelController`, records `lost_success` audit when an IdP-minted token is dropped by transition `DeviceFlowProvider` interface accepts `start({signal})` + `poll(state, {signal})`. `QwenOAuthDeviceFlowProvider` wraps the existing `QwenOAuth2Client.requestDeviceAuthorization` / `pollDeviceToken` primitives directly (NOT `authWithQwenDeviceFlow`, which calls `open(url)`). PKCE is provider-required by Qwen but optional in the interface for future non-PKCE providers. `success.persist()` writes to disk FIRST, then updates the in-process client — a failed disk write no longer leaves the daemon with a zombie in-memory token. Maps RFC 8628 errors via an anchored regex (`^Device token poll failed: (expired_token|access_denied|invalid_grant)`) so an `error_description` containing one of those literals can't mis-classify an unrelated upstream error. `BrandedSecret<T extends string>` holds the `device_code` and PKCE verifier. Earlier draft used `new String()` wrapper which leaked through `+` / template literals (`Symbol.toPrimitive` → `valueOf` returned the primitive). Final shape: frozen plain object + `WeakMap` indirection + 4-way redaction (`toString` / `toJSON` / `Symbol.toPrimitive` / numeric coercion → `'[redacted]'` or `NaN`) + `unique symbol` brand. 6 leak-path tests: `JSON.stringify` / `String()` / concat / template / `+x` / reveal-roundtrip. 5 new daemon events (workspace-scoped, fanned out to every active session bus via `bridge.broadcastWorkspaceEvent`): - `auth_device_flow_started` — `{deviceFlowId, providerId, expiresAt}` (no userCode/verificationUri — see PR 21 design §3) - `auth_device_flow_throttled` — `{deviceFlowId, intervalMs}`, emitted only on upstream `slow_down` interval bumps - `auth_device_flow_authorized` — `{deviceFlowId, providerId, expiresAt?, accountAlias?}`; `accountAlias` is best-effort non-PII (never email/phone) - `auth_device_flow_failed` — `{deviceFlowId, errorKind, hint?}` with `errorKind ∈ {expired_token, access_denied, invalid_grant, upstream_error, persist_failed}` - `auth_device_flow_cancelled` — `{deviceFlowId}` (DELETE on pending) Workspace-scoped reducer `reduceDaemonAuthEvent` produces `DaemonAuthState { flows: Partial<Record<ProviderId, ...>> }` — parallel to `reduceDaemonSessionEvent`. Session reducer no-ops on auth events (workspace-scoped state belongs in its own reducer). `bridge.broadcastWorkspaceEvent` is intentionally distinct from PR 16's `publishWorkspaceEvent` to avoid merge conflict; collapses to the shared helper as a fold-in once #4249 lands (~25 LoC). `@qwen-code/sdk` (`packages/sdk-typescript/`): - 4 new `DaemonClient` methods: `startDeviceFlow`, `getDeviceFlow`, `cancelDeviceFlow`, `getAuthStatus` — typed against the wire shapes, errors mapped through the existing `DaemonHttpError`. - High-level `client.auth` getter (lazy `DaemonAuthFlow` singleton) exposes a `start(...).awaitCompletion()` shape mirroring `gh auth login`'s UX: print code first, let the SDK consumer decide where to open the browser. `awaitCompletion` polls GET on the daemon-supplied `intervalMs`, honors `slow_down` bumps, and fall-back-recovers from 404 (entry evicted post-grace). POST + DELETE flow through PR 15's `mutate({strict: true})` — 401 `token_required` on token-less loopback defaults. GET routes use only the global `bearerAuth`. Every state transition (`started/authorized/failed/cancelled/expired/lost_success`) records a structured stderr breadcrumb (`[serve] auth.device-flow: provider=... deviceFlowId=abc12... clientId=... status=...`) since `mutate()` doesn't carry an audit hook — events alone aren't enough since SDK can silently drop them; stderr → journald/docker logs is the unfalsifiable record. `auth_device_flow` advertised unconditionally on `/capabilities.features`. Supported providers list lives on `/workspace/auth/status` to keep the registry descriptor uniform. - `packages/core/src/qwen/qwenOAuth2.ts`: - exports `cacheQwenCredentials` (was a private function; needed by the daemon's device-flow registry) - `cacheQwenCredentials` now calls `SharedTokenManager.clearCache()` after writing, folding what was previously a paired call site at L820+L829. Idempotent change. - file mode `0o600` on `oauth_creds.json` (was default 0o666 + umask). Mirrors opencode's `auth/index.ts`. - `packages/cli/src/serve/runQwenServe.ts`: device-flow registry `dispose()` wired into the shutdown drain (BEFORE `bridge.shutdown()`). - `auth/deviceFlow.test.ts` — 21 tests: BrandedSecret leak paths, state machine (slow_down / success / error), terminal grace, concurrent-start coalescing, dispose, cancel idempotency, static- source grep against browser-spawn primitives. - `server.test.ts` — 10 device-flow integration tests: POST 201/200 take-over, strict 401, 400 `unsupported_provider`, GET / DELETE / `/workspace/auth/status`, 502 `upstream_error` mapping, sweeper-driven auto-expiry with controlled clock, capability advertisement. - `daemonEvents.test.ts` — 5 SDK reducer tests: type guards, per- provider state projection, `failed` always → `status: 'error'` (errorKind carries the kind, including new `persist_failed`), session reducer no-ops on auth events. 369/369 serve + SDK tests pass; typecheck + `eslint --max-warnings 0` clean across 14 PR 21 files. - [x] Independently mergeable (depends only on merged PR 4 / PR 7 / PR 12 / PR 15) - [x] Backward compatible (4 new routes + 1 capability tag + 5 typed events + 4 SDK helpers; existing routes/events untouched) - [x] Default off (capability advertised but no client is forced to use it; CLI `qwen` OAuth flow unchanged) - [x] `qwen serve` Stage 1 routes / SDK behavior preserved - [x] Gradual migration (v1 only `qwen-oauth`; future providers register through the `DeviceFlowProvider` interface) - [x] Reversible (revert removes 4 routes + 1 tag + 5 events with no schema migration) - [x] Tests-first (28 new tests across 3 layers) - Inline `bridge.broadcastWorkspaceEvent` → fold-in to PR 16 (#4249) `publishWorkspaceEvent` once that lands - `/workspace/auth/status` vs PR 12 `/workspace/providers` boundary — separate route in v1; merge alternative discussed - Wave 4 PRs 17/19/20 should adopt the same mutate-strict + workspace event-fan-out pattern 5 items from pre-PR specialist passes parked for a focused follow-up: `DeviceFlowEntry` discriminated union, single-source SDK status / ProviderId unions, `awaitCompletion` memoization, broadcast-100%-fail stderr elevation, SDK 404 → `not_found_or_evicted` errorKind. Refs: #4175 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 round-1 review feedback Eleven items from copilot-pull-request-reviewer's round-1 pass on #4255 — 4 inline threads + 7 from the PR-level review summary. ## Adopted (11 items, code/doc changes) - **`lastSeenAt` → `lastSeenEventId`** (`events.ts`, `DaemonDeviceFlowReducerState`). The field was set from `rawEvent.id` (SSE event id) but documented as "epoch ms" — a real semantic mismatch that would mislead consumers into time-based logic against a monotonic counter. Rename + tighten the JSDoc to describe it as an event-id counter; reducer cases updated. - **`DEVICE_FLOW_EXPIRY_GRACE_MS = 30_000` extracted** in `DaemonAuthFlow.ts` (was a magic number on `start.expiresAt + 30_000`). `AwaitCompletionOptions.timeoutMs` doc now describes the actual grace-past-expiry behavior + the rationale (clock skew + daemon sweeper interval + network latency) instead of the wrong "defaults to expiresAt - Date.now()" claim. - **Explicit `chmod 0o600`** in `cacheQwenCredentials` after every write. `fs.writeFile`'s `mode` only applies on file creation; a pre-existing `oauth_creds.json` written under a broader umask kept its old permissions across upgrades. The chmod now tightens it on every write; chmod failure (Windows / hardened FS) surfaces via `debugLogger.warn` instead of silently dropping the invariant. - **`SharedTokenManager.clearCache()` failure now logs** `debugLogger.warn` (was a silent `try { } catch { }`). In production a swallowed clearCache means in-process callers serve stale credentials until the SharedTokenManager mtime watcher catches up — a recoverable degradation worth a log line. - **Protocol doc** lists `persist_failed` in the `auth_device_flow_failed.errorKind` union (was added to the type but missed in the doc). - **`pollDeviceToken({signal})`** plumbed through `IQwenOAuth2Client` interface + `QwenOAuth2Client` impl + the Qwen device-flow provider. Cancel / dispose during a slow IdP response now aborts the in-flight HTTP socket immediately instead of waiting for the upstream timeout. Two new registry tests assert `cancel()` / `dispose()` propagate abort to the signal observed by `provider.poll`. - **`revealSecret` error message** clarified: was "secret has been GC-evicted" (impossible — WeakMap doesn't evict reachable keys). Now points at the actual reachable failure modes (forged shape / serialize+reparse losing the WeakMap binding). - **`transitionTerminal` JSDoc** clarifies that the PRIMARY guard against late timer secret leaks is the `entry.status !== 'pending'` check at the top of `runPollTick`; secret-clearing here is defense-in-depth. - **`DeviceFlowErrorKind` JSDoc'd per variant** so consumers can tell when each fires (RFC 8628 distinctions + `persist_failed` vs `upstream_error` boundary). - **Stale "PR 16 / PR 21 §3" temporal references** in `DaemonAuthFlow.ts:124` rephrased to be timeless ("workspace-scoped events fan out through whatever session buses happen to be live" — no PR number references that rot when those PRs merge). ## Not adopted (4 items, replied to in-thread) - **`authWithQwenDeviceFlow` browser-launch separation** — correct architectural advice but out of #4255 scope (would refactor a CLI auth UX module that PR 21 only touched additively). Tracked as a Wave 5 follow-up. - **Copyright header year range** — repo-wide convention "2025"; not introduced by this PR. - **Spread `...(x ? {x} : {})` → `x: x ?? undefined`** — the two are not semantically equivalent. The current form omits the key entirely on falsy `x`; the suggested form always includes the key. Tests assert object shape and would break under the change. - **Eager `client.auth` getter** — public API boundary. Lazy construction matches `DaemonSessionClient` precedent + saves the module load for SDK consumers that never touch auth. Refs: #4175 #4255 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-1 review feedback 15 items from @wenshao's review batches on #4255. Catches a handful of real bugs that the earlier round (commit 3d9f082f5) didn't surface. ## Critical fixes - **C1 — `pollUntilTerminal` providerId pass-through** (`DaemonAuthFlow.ts:185`). The synthetic 404 fallback hardcoded `providerId: 'qwen-oauth'`; the parent `awaitCompletion` already receives the real providerId via `start.providerId` but `pollUntilTerminal`'s parameter type stripped it. Add the field to the param type, propagate. - **C2 — open `errorKind` allowlist** (`events.ts`). The closed 5-value union in the type guard silently dropped any `failed` event whose errorKind the daemon added without mirroring SDK-side (e.g. a future `rate_limited`). The flow's reducer state would never transition to terminal, leaving SDK consumers stuck on `pending` forever. Open the union with `(string & {})` and accept any non-empty string in the runtime guard. Updated test asserts forward-compat behavior + still rejects the truly-malformed empty-string case. - **C3 — `persist()` timeout + signal** (`deviceFlow.ts`). A wedged disk I/O (NFS stall, encrypted-volume contention) without bounds would pin the entry in `pending` until the upstream `expires_in` elapsed (potentially minutes). The registry now passes its `cancelController.signal` AND arms a hard `DEVICE_FLOW_PERSIST_TIMEOUT_MS = 30_000` timer; persist failure surfaces as `persist_failed` immediately. The `DeviceFlowPollResult` `success` variant signature changed to `persist({signal})`. - **C4 — cancel × success race rollback** (`deviceFlow.ts` + Qwen provider). Today, if `cancel()` transitions while `persist()` is in flight, the credentials get written but the flow's status is `cancelled`. User sees cancelled, daemon disk has a valid token. `DeviceFlowPollResult.success` gains an optional `unpersist()` callback the registry calls when `transitionTerminal(authorized)` fails — the Qwen provider wires it to `clearQwenCredentials()`. Rollback failure is audited but not propagated (re-running auth would overwrite anyway). - **C5 — don't `unref()` the `awaitCompletion` sleep timer** (`DaemonAuthFlow.ts`). On a standalone Node CLI/script doing just `client.auth.start().awaitCompletion()`, the unref'd between-poll timer was the only event-loop handle, so Node could exit before the user finished authorization. The poll wait is foreground work the caller explicitly awaits — keep it ref'd. ## Information-leak fixes - **S1 — sanitize `persist_failed` hint**. `err.message` from `cacheQwenCredentials` embeds the full `~/.qwen/oauth_creds.json` path. Broadcast via SSE, that path leaks the daemon's home layout to every connected session subscriber. Replace user-facing hint with `"credentials could not be written to the daemon filesystem — check disk space and permissions"`; full err goes to stderr audit only. - **S2 — sanitize upstream `pollDeviceToken` hint**. The class embedded the entire raw IdP response body (which can be an HTML error page from a reverse proxy) into the thrown message. Same broadcast leak path. Replace upstream-error hint with `"unexpected response from identity provider"`; RFC 8628 errors use `"Qwen IdP returned ${kind}"`. ## Cleanup / forward-compat - **D1 — drop duplicate `clearCache()`** at `qwenOAuth2.ts:840`. The paired call became redundant once `cacheQwenCredentials` folded the clearCache in (PR #4255 fold-in 1). The fold-in 1 message said this would be done; the duplicate slipped through. - **S3 — drop unused `DeviceFlowNotFoundError`** (`deviceFlow.ts`). Exported but never imported; route handlers do inline 404 JSON. - **S4 — single-source SDK status / errorKind unions** (`types.ts`). `DaemonAuthDeviceFlowSdkStatus` / `DaemonAuthDeviceFlowSdkErrorKind` were parallel literal copies of the canonical events.ts definitions — drift waiting to happen. Now imported + aliased as type-only re-exports. - **S5 — broadcast 100% fail elevates to stderr** (`httpAcpBridge.ts`). Per-session bus failures stay debug-only, but a broadcast where EVERY session bus refused is operationally interesting (clients won't see the event). Track success / fail counts; `writeStderrLine` when `successCount === 0`. - **S6 — `this.disposed` check after `await provider.start()`** (`deviceFlow.ts`). `dispose()` mid-start would orphan the freshly- inserted entry (`schedulePoll` guards on `disposed` so no poll fires; the entry never transitions). Throw post-await if disposed. - **W1 — thread `signal` into `requestDeviceAuthorization`** (`qwenOAuth2.ts` + Qwen provider). `start()` had the same cancellation gap that `pollDeviceToken` had — a slow device-authorization request couldn't be aborted during shutdown. Now plumbed end-to-end. - **W2 — split `invalid_request` from `unsupported_provider`** (`server.ts`). Conflating them surfaced misleading remediation hints to SDK consumers branching on `code` ("this provider isn't supported here" when the real cause was a serializer dropping the field). Bad-shape now returns `code: 'invalid_request'`; unknown-but-well-formed stays `unsupported_provider`. - **W3 — drop never-populated `accountAlias`** (Qwen provider). The field was wired through types / events / reducer / audit but the Qwen IdP's token response doesn't carry one (no `name` / `email` / `sub`). Returning only `{expiresAt}` makes the field type-honestly absent rather than always-undefined. Future provider with an alias-bearing response can populate it. - **W4 — `DaemonAuthFlow` JSDoc accuracy**. Doc claimed "first attempts to consume an SSE event stream … falls back to GET-based polling"; actual is GET-only with SSE as a real-time hint for clients already subscribed to a session stream. - **W5 — clearer unit arithmetic** in interval normalization. The `(_INTERVAL_MS / 1000) * 1000` cancelation hid the s↔ms boundary; expanded form makes both branches unit-explicit. ## Test changes - `daemonEvents.test.ts` updated to match the now-OPEN errorKind union (forward-compat assertion + empty-string still rejected). - `deviceFlow.test.ts` `FakeProvider.poll` aligned with the new `persist({signal})` signature + optional `unpersist`. ## Validation - `npm run typecheck --workspace packages/cli --workspace packages/sdk-typescript --workspace packages/core` — clean - `npx vitest run packages/cli/src/serve/ packages/sdk-typescript/test/unit/daemonEvents.test.ts` — 368/368 - `npx eslint --max-warnings 0` over the 11 PR 21 surface files — clean Refs: #4175 #4255 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-2 review feedback 10 new threads from @wenshao's second deep-review pass on #4255. Verified status: 5 real issues, 1 improvement, 3 stale (already fixed; comments lagged), 1 false alarm (typecheck demonstrably clean). ## Critical fixes - **fold-in 2 C4 REVERSED**: when `provider.poll()` returns success AND `cancel()` / `dispose()` transitioned the entry mid-`persist()`, the registry now FORCES the entry to `authorized` and keeps the on-disk credentials. The earlier rollback (`unpersist()`) wasted the user's IdP approval because the RFC 8628 `device_code` is single-use — re-running the flow would force them through the whole browser-prompt + paste-code dance again for a click whose intent was likely "stop the wait" rather than "undo my already- completed approval". Aligns with gh CLI / Auth0 SDK / git- credential-manager. Audit captures the race via `hint: 'lost_success_kept ...'`. `DeviceFlowPollResult.success.unpersist` field + Qwen provider's `clearQwenCredentials` rollback removed. - **#1 GET /workspace/auth/device-flow/:id strict gate**: this GET surfaces `userCode` / `verificationUri` for pending entries, which on the loopback no-token default were readable by any local process. POST + DELETE were already strict; aligning GET closes the information-disclosure asymmetry. `/workspace/auth/status` stays bearer-only (its `pendingDeviceFlows` entries intentionally omit `userCode`). - **#2 `inFlightStarts` hard timeout**: a hung `provider.start()` (network partition, unresponsive IdP) used to leave the per- `providerId` slot in `inFlightStarts` occupied forever, blocking every subsequent POST until daemon restart. New `DEVICE_FLOW_START_TIMEOUT_MS = 30_000` arms a timer that `cancelController.abort()`s the start; the rejected promise unwinds through the `try/finally` clearing the slot. - **#10 chain-completing the C3 persist-timeout**: the earlier C3 fix armed a 30s timer that fired `cancelController.abort()` then `await result.persist({signal})`, but the chain ended at the registry boundary — `cacheQwenCredentials` didn't take a signal, so `fs.writeFile` couldn't be aborted. Now `cacheQwenCredentials` accepts an optional `{signal}` and threads it into `fs.writeFile(..., {signal})` (Node native). The Qwen provider's `persist({signal})` forwards the entry's `cancelController.signal` end-to-end. ## Improvement (#4): 404 fallback errorKind `pollUntilTerminal`'s 404 catch used to synthesize `{status: 'expired'}` for ALL evicted entries — conflating "your flow expired during your disconnect", "the daemon was restarted", and "your deviceFlowId was wrong". Now returns `status: 'error'` + `errorKind: 'not_found_or_evicted'` + a `hint` so SDK consumers branching on errorKind can distinguish. ## Information leak (#9): start() path raw IdP message S2 (fold-in 2) sanitized `poll()`'s upstream-error hint, but `start()` still embedded the raw `err.message` (full IdP response, potentially HTML from a reverse proxy / WAF) into the `UpstreamDeviceFlowError` that flowed to SDK clients via the 502. Now uses static messages for the SDK-visible errors; raw detail goes through `writeStderrLine` for operator audit only. Mirrors S2's approach. ## Stale comments cleaned (#5, #7) `qwenDeviceFlowProvider.ts:177` claimed `cacheQwenCredentials` "doesn't currently take a signal — that's a follow-up". After #10 above, that's no longer true; the comment is replaced with the actual end-to-end signal-threading note. ## Not adopted (1 false alarm) - Thread on `types.ts:330` claimed type-only-import-after- declarations breaks `tsc` and fails `daemonEvents.test.ts:670` with TS2345. Demonstrably false: `npx tsc -p packages/sdk-typescript/tsconfig.json --noEmit` exits 0; `daemonEvents.test.ts` is the post-fold-in-2 file with the open-allowlist assertion (test 28/28 passes). The reviewer may have been looking at a transient state during their analysis. ## Validation - `npm run typecheck --workspace packages/cli --workspace packages/sdk-typescript --workspace packages/core` — clean - `npx vitest run packages/cli/src/serve/ packages/sdk-typescript/test/unit/daemonEvents.test.ts` — 398/398 pass - `npx eslint --max-warnings 0` over the PR 21 surface — clean Refs: #4175 #4255 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-3 review feedback 5 new threads from the third deep-review pass on #4255. 3 real issues fixed; 1 stale (already done in fold-in 3); 1 deferred as non-blocking design suggestion. - **A — `expiresIn` / `interval` non-finite guard** (`deviceFlow.ts`). The provider contract types both as `number`, but a misbehaving / future provider could hand `undefined` / `NaN` / `Infinity`. `Math.max(0, NaN) * 1000` is `NaN`, then `now() + NaN` is `NaN`, then `now >= NaN` is always `false` — the sweeper would NEVER evict the entry, pinning an upstream `device_code` slot until daemon restart. Same hazard on `interval * 1000` (NaN → `setTimeout(NaN)` fires immediately, Infinity → scheduler clamps to TIMEOUT_MAX). Now both fields go through `Number.isFinite(x) && x > 0`; missing/bad values fall back to RFC 8628's recommended ceilings (10 min for expiry, 5s for interval). - **D — typed `app.locals` accessor** (`deviceFlow.ts` + writer/reader call sites). The `app.locals['deviceFlowRegistry']` string key was shared between `createServeApp` (writer) and `runQwenServe` (reader); a typo on either side would compile cleanly and the shutdown dispose call would silently no-op, leaving polling timers running until the `unref()` rescue. New `setDeviceFlowRegistry(app, registry)` / `getDeviceFlowRegistry(app)` pair gives both call sites type-checked access; the string literal is encapsulated in one module. - **E — `UnsupportedDeviceFlowProviderError` docstring** (`deviceFlow.ts`). After fold-in 2's W2 fix split `invalid_request` from `unsupported_provider`, the route layer screens unknown ids against `DEVICE_FLOW_SUPPORTED_PROVIDERS` before reaching the registry — so this error is now reachable ONLY on a daemon-internal invariant violation (id is declared supported but not registered in the runtime provider map). Docstring + thrown message updated to reflect that this branch signals a programmer error, not user input. - **B** claimed `cacheQwenCredentials(credentials)` doesn't forward signal to `fs.writeFile`. Verified: fold-in 3 (#10) at `qwenDeviceFlowProvider.ts:204` calls `cacheQwenCredentials(credentials, { signal: persistOpts.signal })` and the core helper threads it into `fs.writeFile(..., {mode, signal})`. The reviewer was looking at the comment block above (lines 174-181) without scrolling to the actual call site. - **C — SDK `cancelDeviceFlow` lossy 204/404 collapse**. Suggested returning `{existed: boolean; alreadyTerminal: boolean}` instead of resolving void on both 204 and 404. Real signal-loss but tagged "[非阻塞]" by the reviewer; changing requires a daemon route shape change (200 + body instead of 204) which is better as a focused follow-up PR. Acknowledged in-thread; deferred to a fold-in PR after #4255 lands. - `npm run typecheck` — clean across `packages/{cli,sdk-typescript,core}` - `npx vitest run packages/cli/src/serve/ packages/sdk-typescript/test/unit/daemonEvents.test.ts` — 398/398 - `npx eslint --max-warnings 0` over the PR 21 surface — clean Refs: #4175 #4255 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-4 review feedback 4 threads from the fourth review pass on #4255. 3 adopted + 1 deferred (out-of-scope rename of PR 15's `mutate` helper). ## Adopted ### #1 — `persistInFlight` flag suppresses cancel × persist event-stream UX trap When `provider.poll()` returns success and we await `persist()`, a concurrent `cancel()` would synchronously transition the entry to `cancelled` and emit `auth_device_flow_cancelled` — then `persist()` resolves and (per fold-in 3 C4) force-overrides to `authorized` + emits `auth_device_flow_authorized`. The reducer state correctly last-write-wins on `authorized`, but DIRECT event-stream consumers (close-dialog handlers, telemetry, UI cleanup) race onto an unmounted UI when the second event lands. Now: while persist is in-flight, `cancel()` and the sweeper SKIP the state transition + event emit. They register intent (set `cancelRequestedDuringPersist=true` for cancel; sweeper just no-ops) and let the persist resolution decide: - persist succeeds → `authorized` (IdP wins per fold-in 3 C4) - persist fails AND cancel was requested → `cancelled` - persist fails AND `now >= expiresAt` → `expired` / `expired_token` - persist fails otherwise → `error` / `persist_failed` Result: at most one terminal event per flow. Imperative SSE consumers no longer see oscillating terminal states. Audit captures the race (`hint: 'lost_success_kept ...'`) for incident-response correlation. ### #2 — `revealSecret` → `unsafeRevealSecret` rename The earlier JSDoc claimed "the `unsafeReveal_` naming is intentional: greppable in code review, easy to allowlist in lint rules, hard to invoke by accident" — but the actual function was named `revealSecret`. The promised safety properties didn't exist; a code reviewer wouldn't single out `revealSecret` as suspicious, and a `no-restricted-syntax` ESLint rule wouldn't flag it. Renamed to `unsafeRevealSecret` so the JSDoc-promised "greppable" / "lintable" property is now actually true. Two call sites in the Qwen provider + 4 test references updated. Internal symbol; not exposed through the SDK package. ### #4 — `QwenOAuthPollError` typed class replaces substring regex The earlier RFC 8628 error mapper used an anchored regex against the thrown error message text — an implicit cross-file string contract between `qwenOAuth2.ts` (throws) and `qwenDeviceFlowProvider.ts` (matches). If `qwenOAuth2.ts` ever changed its message format, ALL RFC 8628 errors (`expired_token` / `access_denied` / `invalid_grant`) would silently fall through to `upstream_error` — wrong errorKind flowing through telemetry with no test or type-system check to catch the drift. Now `QwenOAuth2Client.pollDeviceToken` throws a structured `QwenOAuthPollError extends Error` with `oauthError` / `description` / `status` fields. The provider branches on `instanceof QwenOAuthPollError` and reads `.oauthError` directly via a dedicated `mapRfc8628OAuthCode(code)` switch. The drift hazard is gone: a future code change that touches the typed class will fail tsc until both sides are updated. Message format preserved for any pre-existing log-parsing / substring matchers. ## Not adopted ### #3 — `mutate({strict:true})` semantic awkwardness on GET Reviewer correctly noted that `mutate` is named for state-changing routes, but `GET /workspace/auth/device-flow/:id` uses it for an information-disclosure defense (only reachable code path is reading state). Suggested rename: `mutate` → `strictHttpGate`. Deferred: the rename touches PR 15's helper which has many call sites in `server.ts` and is shared infrastructure for Wave 4 PRs 17/19/20. PR 21 is the first / only consumer of the strict-on-GET form so far; widening the rename to a Wave 4 follow-up keeps the fold-in scope tight. Replied in-thread. ## Validation - `npm run typecheck` — clean across `packages/{cli,sdk-typescript,core}` - `npx vitest run packages/cli/src/serve/ packages/sdk-typescript/test/unit/daemonEvents.test.ts` — 544/544 - `npx eslint --max-warnings 0` over the PR 21 surface — clean Refs: #4175 #4255 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-5 review feedback Five small adopt items from the round-5 review pass; one stale thread already addressed in b5b77ee90 (fold-in 5). #2 — `as const` + derived type for DEVICE_FLOW_SUPPORTED_PROVIDERS so adding/removing a provider id requires touching exactly ONE site. Mirrors `SERVE_ERROR_KINDS` / `ServeErrorKind` in `status.ts`. #3 — Clarify `DEVICE_FLOW_EXPIRY_GRACE_MS` JSDoc to distinguish the daemon's 30s SWEEP cadence (what the grace tracks) from the 5-min TERMINAL_GRACE_MS reconnect window (which awaitCompletion does NOT need to wait through). #4 — Add `@remarks` block on `DeviceFlowProvider.poll()` warning future provider authors that thrown `err.message` flows verbatim into the SSE-broadcast `auth_device_flow_failed` hint, and must be sanitized. Two equally-correct paths documented (typed `error` result vs sanitized thrown message). #5 — Truncate raw IdP detail in `qwenDeviceFlowProvider.ts` stderr audit lines to 2 KiB. WAFs / reverse proxies can return MB-sized HTML error pages, and container log aggregators (Loki, Fluent Bit, Stackdriver) typically truncate or drop lines past 4-32 KiB — losing the useful prefix downstream. 2 KiB retains structured JSON envelopes while staying well below every aggregator's per-line cap. #6 — Track latest `originatorClientId` on per-provider singleton take-over via new `entry.lastOriginatorClientId` field + `recordTakeover()` helper. When a second SDK client posts `POST /workspace/auth/device-flow` for an already-pending provider (or one being created in `inFlightStarts`) with a different `initiatorClientId`, an audit breadcrumb records the take-over so incident response can correlate "client A started, client B took over at 12:34". Event-routing intentionally still uses the original `initiatorClientId` (events are workspace-broadcast and changing the originator field mid-flow would break SDK reducers that key on it). Two new tests cover the differing-id audit + same-id no-op. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-6 review feedback Six "Critical" findings from a gpt-5.5 /review pass — all real liveness/correctness defects in the daemon's auth device-flow path and the SDK's awaitCompletion polling loop. #1 — Make `provider.start()` timeout authoritative via `Promise.race` in `DeviceFlowRegistry.doStart`. The earlier shape only ABORTED the signal on timeout; a provider that ignores `signal` (non-abortable I/O, future implementer who forgets to thread it to `fetch`) would leave the `await` hanging until daemon restart, pinning the `inFlightStarts` slot for that providerId. Race against a rejecting timer makes the timeout authoritative regardless of provider cooperation; abort still fires for cooperative cleanup. #2 — Same shape for `result.persist()` in the success branch of `runPollTick`. A future provider whose persist performs non-abortable steps (mkdir/chmod/mv outside the abortable fs.writeFile path) would otherwise hang the poll tick until process restart. Race against rejecting timer; rejection maps to `persist_failed`. #3 — Clamp `expiresIn` and `interval` upper bounds. Previous `Number.isFinite + > 0` guards stopped NaN/Infinity but a finite extreme like `1e12` was still accepted — pinning the per-provider singleton for ~30,000 years (`expires_in`) or scheduling a TIMEOUT_MAX-clamped poll that never fires within `expiresAt` (`interval`). Two new constants (`DEVICE_FLOW_MAX_EXPIRES_IN_SEC = 3600`, `DEVICE_FLOW_MAX_INTERVAL_MS = 60_000`) cap the worst case. #4 — Extract `getDeviceFlowOrSynthetic404(...)` helper in `DaemonAuthFlow.ts` and route BOTH the loop body and the timeout-ceiling final read through it. Previously the ceiling read went directly through `client.getDeviceFlow` and a 404 at the boundary (entry evicted just as the timeout fired) would reject with `DaemonHttpError(404)` instead of returning the structured `{ status: 'error', errorKind: 'not_found_or_evicted' }` that the rest of `awaitCompletion` promises. #5 — Validate `AwaitCompletionOptions.timeoutMs` and `pollOverrideMs` with `Number.isFinite + > 0`. NaN slipped past the previous `?? default` form (NaN is truthy-ish in that position) and produced a `ceiling` of `NaN` (loop runs forever — `now >= NaN` always false) or a `setTimeout(NaN)` (Node clamps to 1ms — tight polling loop). Sanitize to `undefined` so the documented defaults take effect. #6 — Thread `signal` into `DaemonClient.getDeviceFlow` and forward to `fetchWithTimeout` (which already composes caller + timeout signals). awaitCompletion now passes `opts.signal` from both GET sites. Without this, an `awaitCompletion` caller that aborts mid- poll could not cancel an in-flight stalled GET; it would have to wait for the daemon-side `fetchTimeoutMs` (30s default) to fire. Four new tests in `deviceFlow.test.ts` pin the new behaviors: hanging-start timeout (#1), hanging-persist → persist_failed (#2), extreme-expiresIn clamp (#3), extreme-interval clamp (#3). FakeProvider gained a `startHangs` flag for the non-cooperative provider scenario. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-7 review feedback Two findings from a DeepSeek /review pass; both small but legitimate defense-in-depth gaps. #1 — `runPollTick`'s catch block forwarded `err.message` verbatim into the SSE-broadcast `hint`. The provider's `@remarks` contract (fold-in 6 #4) requires throwers to sanitize, but if violated the unbounded raw payload would reach every SSE subscriber. Added `DEVICE_FLOW_POLL_HINT_MAX_LEN = 256` + `truncatePollHint()`, applied to the catch's `result.hint`. Full raw `err.message` is still routed to the audit trail (`audit?.record({hint: 'provider.poll() threw (raw): ...'})`) so operator visibility for incident response is preserved. Belt-and-suspenders: the contract is now structurally enforced rather than relying on every future provider author to read the JSDoc. #2 — `updateMatchingFlow` (and the `started`/`authorized` handlers in `reduceDaemonAuthEvent`) unconditionally overwrote state without comparing `rawEvent.id` against the existing flow's `lastSeenEventId`. The field's JSDoc documented it as a monotonic counter to prevent stale frames from overwriting newer state, but the code didn't enforce that contract. SSE reconnect with `Last-Event-ID < terminal-frame-id` would replay older frames; if any of them were for the same `deviceFlowId` (e.g. a delayed `failed` arriving after `authorized`) the stale frame would overwrite the terminal. Daemon-side `transitionTerminal` makes the exact reachable scenario thin, but the documented contract should match the code. Threaded `rawEventId` into `updateMatchingFlow` and added the gate there + in the `started` and `authorized` handlers (the two cases that don't go through `updateMatchingFlow`). Synthetic frames without an envelope `id` (`rawEventId === undefined`) bypass the gate — they originate inside SDK reducer machinery and aren't subject to replay ordering. Three new tests pin the contracts: - `runPollTick catch truncates the SSE hint and preserves raw on the audit (fold-in 8 #1)` — `pollThrowsWith` flag on FakeProvider models a non-conforming provider; SSE hint < 400 chars + contains 'truncated'; audit hint contains the full 4_000-char raw. - `reduceDaemonAuthEvent rejects out-of-order frames (fold-in 8 #2 monotonicity)` — stale `failed`(id=7) does NOT overwrite `authorized`(id=10); stale `started`(id=4) for a different flow also rejected. - `reduceDaemonAuthEvent passes synthetic frames (no envelope id) through the gate` — SDK-internal frames without `id` are honored. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-8 review feedback Twelve correctness + structural fixes from a wenshao + DeepSeek + gpt-5.5 review pass. Tests deferred to fold-in 10 (separate, larger commit). CRITICAL CORRECTNESS #7 — `provider.persist()` Promise.race could publish `persist_failed` to SSE while a non-cooperative provider was still committing credentials to disk. Added an independent tracker on the original persist promise: if the race timed out (`persistTimedOut === true`) AND the underlying persist later resolved successfully, audit a `lost_success_after_timeout` breadcrumb so operators see the inconsistency. Tightened the persist `@remarks` contract to require signal honoring end-to-end. Qwen provider already complies (fold-in 3 #10); this is forward-defense for future providers. #11 — auth surface (`DaemonAuthFlow`, `reduceDaemonAuthEvent`, `createDaemonAuthState`, `DEVICE_FLOW_EXPIRY_GRACE_MS`, all event / data / state types) was re-exported from `src/daemon/index.ts` but NEVER from the published SDK entry `src/index.ts`. SDK consumers got `undefined` for everything except `client.auth.start()` (which traveled through the already-exported `DaemonClient`). Added the missing exports and pinned via `daemon-public-surface.test.ts`. #12 — `core/src/qwen/qwenOAuth2.ts:373`'s `debugLogger.debug('Device authorization result:', result)` writes the raw `device_code` (RFC 8628 bearer-equivalent credential) to stderr / journald, bypassing the `BrandedSecret` redaction layer. Pre-existing on main but PR 21 expanded the exposure surface. Sanitized to log only `{ ok, expires_in }` on success / `{ ok, error }` on error. #13 — `runPollTick` success-branch persist-failure × past-`expiresAt` classified as `expired_token` instead of `persist_failed`, routing operators toward "tell user to retry" (RFC 8628 expiry) when the actual root cause was disk I/O. Reclassified to `persist_failed` with a `persist_also_failed_past_expiry` audit hint to preserve the timing detail for incident response. SMALL CORRECTNESS #1 — `runPollTick` catch hint replaced with a STATIC bounded message ("provider.poll() failed; see daemon audit log for details"). The fold-in 8 truncated-prefix approach could still leak the first 256 chars of provider-templated raw text including secret material. Full raw still routed to audit channel for operator visibility. #5 — `cancellerClientId` field added to `DeviceFlowEntry`; deferred- cancel branch in `cancel()` now stamps it on the entry, and the persist-resolution `cancelled` event publish uses `entry.cancellerClientId ?? entry.initiatorClientId`. SSE consumers that suppress self-emitted events can now attribute the cancel correctly. #6 — `AwaitCompletionOptions.timeoutMs === 0` (the documented "settle immediately, return current daemon view" contract) was treated as falsy by the `?` ternary, falling back to the default. `sanitizePositiveMs` now takes an `allowZero` opt-in; the ceiling computation uses `!== undefined` instead of truthy check. #8 — `EventBus.publish()` returns `undefined` for closed buses (it does NOT throw). `broadcastWorkspaceEvent` previously counted that path as success, hiding the all-buses-dropped operator alarm. Folded the closed-bus-as-failure check into the canonical `publishWorkspaceEvent` (see #X below). #9 — start-timeout Promise.race rejected with a plain `Error`, falling through `sendBridgeError` to a generic 500. Switched to `UpstreamDeviceFlowError` so a hung IdP correctly surfaces as 502 (matching the envelope every other IdP start failure uses). STRUCTURAL #3 — Three identical `transitionTerminal + publish + audit` expired_token blocks in `runPollTick`/`sweep`/(removed by #13) deduplicated into a private `expireEntry()` helper. Future event- shape changes are now a one-edit operation. #X — PR 16 (#4249) merged on 2026-05-18 06:27Z. Per the inline comment at httpAcpBridge.ts:501, PR 21's `broadcastWorkspaceEvent` was kept distinct only to avoid the merge conflict; once PR 16 landed, it became a fold-in candidate. Folded the closed-bus + all-failed-stderr-escalation operator-visibility features (PR 21's S5 + fold-in 9 #8) INTO `publishWorkspaceEvent`; dropped `broadcastWorkspaceEvent` from the bridge interface + impl + test mocks. PR 21's deviceFlowEventSink now calls `bridge.publishWorkspaceEvent` — single canonical workspace fan-out. DOC #16 — Added a "Cross-client take-over" paragraph to `docs/users/qwen-serve.md` explaining that two clients on the same daemon for the same provider get the per-provider singleton with `attached: true`/`false` distinguishing them; no separate event fires (both eventually observe the same `auth_device_flow_authorized`). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao round-9 review feedback Two small non-blocking items from the round-9 pass; defensive shape + docs only. The 4 deferred test-coverage threads (#1-4 of round-8) are still tracked for fold-in 10. #6 — `lastSeenEventId` typed `number` with `?? 0` defaults in the `auth_device_flow_started` reducer case. The daemon-side `EventBus` assigns ids ≥ 1 so the `0` sentinel has no real-traffic meaning, but the monotonic gate (`rawEventId <= flow.lastSeenEventId`) would reject any future SDK-internal synthetic frame using `id: 0`. Changed the field type to `number | undefined` and dropped the `?? 0` from the started case. The `updateMatchingFlow` / `auth_device_flow_authorized` guards already short-circuit on `existing.lastSeenEventId !== undefined`, so undefined is safe end-to-end. Existing 34 reducer tests still pass unchanged. #7 — Added `@remarks` block to `DeviceFlowErrorKind.persist_failed`'s JSDoc explaining the lost-success retry UX. When fold-in 9 #7's `lost_success_after_timeout` audit fires (non-conforming provider violates signal contract; disk write succeeds after registry published `persist_failed`), a naive SDK retry hits the IdP a second time with a fresh `device_code` and prompts the user twice — but the first credential set is already valid. JSDoc now documents the mitigation: SDK consumers writing retry logic on `persist_failed` should call `client.auth.getStatus()` BEFORE re-prompting; operators can grep stderr/audit for `lost_success_after_timeout` to detect occurrences. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * test(serve): fold-in 10 — auth device-flow test bundle (#4255) Lands the four deferred test-coverage items the round-8 review flagged (and round-9 re-surfaced) as a hard merge prerequisite. Net +41 tests across registry / SDK helper / client HTTP / HTTP route layers. #1 — `deviceFlow.test.ts` `persist failure paths` describe (3 tests, +3). The success arm's three terminal mappings — pure `persist_failed`, `cancelled` (cancel during persist), and `persist_failed` past `expiresAt` (the fold-in 9 #13 reclassification with `persist_also_failed_past_expiry` audit hint) — were 0-covered. Now pinned. Test #2 also asserts the fold-in 9 #5 cancellerClientId routing on the deferred `cancelled` event. #2 — new `DaemonAuthFlow.test.ts` (+14 tests). Mock DaemonClient with sequenced `getDeviceFlow` replies. Covers happy-path polling → `authorized`; `slow_down`-driven `intervalMs` bump firing `onThrottled`; `signal.abort()` rejection; `signal` propagation through `client.getDeviceFlow` (fold-in 7 #6); `timeoutMs` ceiling final-read; `timeoutMs:0` immediate-return (round-9 #6); NaN/Infinity → `sanitizePositiveMs` fallback to default ceiling (fold-in 7 #5); 404 → synthetic `error`/`not_found_or_evicted` (fold-in 3 #4) at BOTH the loop body AND the timeoutMs ceiling read (fold-in 7 #4); non-404 DaemonHttpError rethrown; `cancel()` and top-level `status()`/`cancel()` wrappers forward correctly. #3 — `DaemonClient.test.ts` `device-flow methods` describe (+11 tests). POSTs `/workspace/auth/device-flow` happy path + clientId header + body shape; 200/201 acceptance; non-2xx → `DaemonHttpError`. GETs URL-encode the deviceFlowId; forward `opts.signal` to `fetchWithTimeout`'s composed signal (fold-in 7 #6 — verified by aborting caller signal and observing the fetch's signal flip to `aborted`); 404 throws. DELETEs swallow 204 + 404 (idempotent, mirrors `closeSession`); non- 204/404 throws. `getAuthStatus` plain GET. `client.auth` lazy-instantiated singleton. #4 — `server.test.ts` 5 supplementary contract tests (+5). The existing 8 `it()`s cover happy paths + take-over + 401 POST + DELETE pending/terminal/unknown + 502 upstream + sweeper. This commit plugs gaps: 400 `invalid_request` for missing / non-string providerId (fold-in W2 split this from `unsupported_provider`); 409 `too_many_active_flows` (via injected fake registry); 401 `token_required` on DELETE without bearer; the asymmetric GET posture (`/workspace/auth/device-flow/:id` IS strict-gated to prevent peer-process userCode shoulder-surf; `/workspace/auth/status` stays read-only because its `pendingDeviceFlows` entries intentionally redact `userCode`). Validation: cli serve 631/631 (+8 from #1, #4); sdk 384/384 (+25 from #2, #3, +/- some pre-existing churn). Typecheck + lint clean. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(qwen): atomic temp+chmod+rename in cacheQwenCredentials (PR #4255 round-11 #2) gpt-5.5 /review flagged a real correctness/security gap: the post-write `chmod` ordering left a window where freshly-written credentials could land in a broadly-readable existing `oauth_creds.json` before the chmod tightened it. On POSIX, a chmod failure additionally degraded to a debug warning while the broadly-readable tokens stayed on disk. New shape mirrors the standard atomic-write idiom: 1. Write `${filePath}.tmp.${pid}.${randomUUID()}` with `mode: 0o600`. The temp path doesn't exist beforehand, so the `mode` flag actually applies on creation (it doesn't on existing files, which was the root of the original race). 2. Defensive `chmod` on the temp file. POSIX failure is now a HARD ERROR (refuses to publish broad-perm credentials to the canonical filename). Windows logs a debug breadcrumb and proceeds, since chmod is a no-op on most NTFS volumes (perms go through ACLs). 3. Atomic `fs.rename` over `filePath`. The canonical path is ALWAYS at `0o600` from the moment it contains the new tokens; readers see either the old creds or the new creds, never a partially-written or broadly-readable state. 4. Best-effort `fs.unlink` of the temp file on any failure path so failed writes don't leave `.tmp.<pid>.<uuid>` litter on disk. Test mock in `qwenOAuth2.test.ts` extended with `chmod` + `rename` no-op stubs so the existing 158 core/qwen tests still pass; no test behavior change beyond the mock surface. Validation: typecheck clean (cli + core + sdk-typescript); core qwen 158/158; cli serve 643/643; sdk 384/384. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR #4255 wenshao + gpt-5.5 round-12 review feedback Eight findings from a wenshao + gpt-5.5 /review pass: 1 critical correctness, 2 real defensive defects, 4 edge cases / minor hardening, 1 test gap. All adopted. CRITICAL CORRECTNESS #1 CzSpN — `dispose()` race: after `await provider.poll(...)` the post-await guard checked only `entry.status !== 'pending'`, NOT `this.disposed`. `dispose()` clears the registry maps and aborts the entry's signal but doesn't mutate `entry.status`, so a provider whose poll already resolved (or doesn't honor abort) could enter the success branch and call `result.persist({...})` — committing credentials on a shutting-down daemon. Added the `if (this.disposed) return;` guard symmetric with the top-of-method check. REAL DEFENSIVE DEFECTS #2 Cy_ZG — sync-throw escape: the `result.persist({signal})` call happens BEFORE the `new Promise` constructor that captures it (`persistTracker` is closed-over inside the constructor). A non-conforming provider whose persist throws synchronously (e.g. top-of-function validation) would escape past the outer `try/catch (await new Promise(...))` and become an `unhandledRejection` since `runPollTick` is fire-and-forget via `void`. Wrapped the persist invocation in a try/catch that routes the sync-throw into the same `persistError` branch. #3 CzSpe — runtime provider map: provider validation hardcoded `DEVICE_FLOW_SUPPORTED_PROVIDERS` even though `deps.deviceFlowProviders` is the documented extension hook for tests/future providers. Switched both POST validation and `/workspace/auth/status` `supportedDeviceFlowProviders` to derive from `deviceFlowProviderMap.keys()` — single source of truth matches the registry's `resolveProvider`. EDGE CASES / MINOR HARDENING #4 Cy_Y9 — `slow_down` re-clamp: `intervalMs += SLOW_DOWN_BUMP_MS` can push past `DEVICE_FLOW_MAX_INTERVAL_MS` (the bound that keeps `setTimeout` from clamping to TIMEOUT_MAX). Wrapped in `Math.min(MAX_INTERVAL_MS, ...)` symmetric with the doStart clamp. #5 Cy_ZF — `expiresInSec` lower bound: `0.5` was finite-positive and produced `expiresAt = now() + 500 ms` — first poll (clamped at ≥1 s) fires AFTER expiresAt → flow expires before any user could authorize. Added `DEVICE_FLOW_MIN_EXPIRES_IN_SEC = 30` (RFC 8628 §3.2 calls 5–30 minutes "reasonable"; sub-30s is non-compliant). #6 CzHOK — take-over response privacy: `initiatorClientId` was echoed to ANY take-over POST caller, including those with no `X-Qwen-Client-Id` header. Bearer-gated already, but the asymmetry "anonymous caller learns who started it" violated the no-header-as-privacy-signal contract. Now only echoed when the caller's id matches the entry's initiator. #7 CzSpd — production audit visibility: production audit sink dropped `line.hint`, but the registry uses hints for operator-only breadcrumbs (`provider.poll() threw (raw)...`, `lost_success_after_timeout`, `persist_also_failed_past_expiry`, take-over correlation, `deferred (persist in flight; ...)`). The documented troubleshooting trail was invisible in production stderr. Now included with a 1 KiB bound + JSON-quoted so multi- word hints stay parseable. TEST GAP #8 Cy_ZH — `lost_success_after_timeout` audit: the fold-in 9 #7 split-brain detector for non-cooperative providers had no test pinning it. Added a controllable `latePersist` Promise + test that drives poll → success → enters persist race → fires PERSIST_TIMEOUT (registry publishes persist_failed) → resolves persist late → asserts the lost_success audit fires. Validation: typecheck + lint clean; cli serve 644/644 (+1 from the new test); sdk-typescript 384/384. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): close concurrent multi-provider cap bypass (PR #4255 round-13 #1) gpt-5.5 /review caught a real workspace-wide cap bypass: `countActive()` only counted entries already installed in `byProvider`, but the cap check at the top of `start()` runs before any provider's `inFlightStarts` slot completes `provider.start()`. A burst of fresh starts for `DEVICE_FLOW_MAX_CONCURRENT + 1` distinct providers all run synchronously to the cap check (each `start()` is async but runs to its first await — the await happens AFTER the cap check), all observe `count === 0` (no `byProvider` entries installed yet), and all pass — eventually installing more than the documented four pending flows. Fix: include `inFlightStarts.size` in `countActive()`. The two maps are disjoint by construction (the existing-pending fast-path catches any provider with both), so simple addition cannot double-count. The second concurrent caller sees count=1, the third count=2, …, and the (MAX+1)th caller is rejected with `TooManyActiveDeviceFlowsError`. Test: `caps at DEVICE_FLOW_MAX_CONCURRENT under CONCURRENT distinct-provider starts`. Fires `MAX+1` concurrent starts via `Promise.allSettled`, asserts exactly `MAX` fulfilled + exactly 1 rejected with the typed error. Pre-fix this test fails (all `MAX+1` succeed); post-fix it passes. Validation: typecheck clean across all 4 workspaces; deviceFlow.test.ts 35/35 (was 34); cli serve 645/645. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Summary
Add
classifyError()to categorize errors without retrying deterministic request errors (400, 401, 403, 404, 422). Only retry transport/provider failures: 429, 408, 409 (transient), 500-599, and network errors.Behavior
classifyError(),isRetryableNetworkError()Scope
Does NOT change retry behavior or add "big reliability system." Only classifies errors.
Validation
npm run buildpassesnpx vitest run src/utils/retry.test.ts— 91 tests pass (16 new)Risk
Low. Isolated to
packages/core/src/utils/retry.ts.Summary by cubic
Classifies errors and centralizes retry decisions so we only retry transient transport/provider failures and never deterministic request errors. Also hardens MCP client discovery to prevent duplicate processes during concurrent reconnect/discovery.
New Features
classifyError(error)returning{ retryable, reason, status }; never retries 400/401/403/404/422; retries 429, 408, transient 409 (lock/contention only), 5xx, and targeted network errors (e.g., ECONNRESET, ETIMEDOUT, ESOCKETTIMEDOUT, ECONNREFUSED, EAI_AGAIN; “socket closed”/“stream ended”).isRetryableNetworkError()and tightened matches (removedENOTFOUND,EHOSTUNREACH, and broad “network error” substring).GeminiChatnow delegatesshouldRetryOnErrortoclassifyError;defaultShouldRetrystays limited to 429/5xx so broader retries are opt‑in. ScopedisTransientCapacityError()to 429/529 only in unattended mode.Bug Fixes
includes('409'); use word‑boundary lock/locked and “contention”) and stabilized fake‑timer tests to avoid deadlocks.Written for commit 1c45e5b. Summary will update on new commits.