mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
feat(cli,sdk): qwen serve daemon (Stage 1) (#3889)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(cli): scaffold `qwen serve` HTTP daemon (Stage 1, #3803) Adds a `serve` subcommand that boots an Express 5 listener with bearer auth, host allowlist, and CORS modeled on `vscode-ide-companion/src/ ide-server.ts`. Ships only `/health` and `/capabilities` to begin with; session/prompt/event routes will land in follow-up PRs once the per- session ACP child-process bridge in `httpAcpBridge.ts` is wired. Defaults to 127.0.0.1 with auth disabled so local development needs no configuration. Binding beyond loopback (e.g. `--hostname 0.0.0.0`) refuses to start without a token (`--token` or `QWEN_SERVER_TOKEN`). Capabilities envelope versioned at v=1 with a `features` array — clients should gate UI off `features`, never off `mode`, so subsequent PRs can add capability tags without breaking older clients. Per design issue's Stage 1 scope (~700-1000 LOC). Adds ~430 LOC of implementation + tests in this scaffold; the remaining budget belongs to the route wiring + bridge implementation in follow-ups. * feat(cli): wire HttpAcpBridge + POST /session for `qwen serve` (#3803) Stage 1 follow-up to the scaffold. Implements the bridge between the HTTP daemon and the existing ACP child agent, plus the first session endpoint. `HttpAcpBridge.spawnOrAttach`: - Spawns `node $cliEntry --acp` per workspace via an injectable `ChannelFactory` (default uses `process.argv[1]`; tests use an in-memory `TransformStream` pair so they don't fork real processes). - Drives the ACP `initialize` + `newSession` handshake via the SDK's `ClientSideConnection`, with a 10s timeout that kills the channel. - Under `sessionScope: 'single'` (default), reuses the live session when the same canonical workspace cwd is requested again — backs the `attached: true` flag. - The `Client` impl on the bridge side proxies file reads/writes to local fs (daemon and agent share the host) and buffers `sessionUpdate` notifications for the SSE wiring in the next PR. `requestPermission` returns `cancelled` until the `/permission/:requestId` route lands. `POST /session`: - 400 on missing or relative `cwd`. - 200 with `{sessionId, workspaceCwd, attached}` on success. - 500 on bridge failure (the failing channel is killed, not leaked). `runQwenServe` constructs the bridge and ties `bridge.shutdown()` into the listener-close path so SIGINT/SIGTERM drain children before the socket closes. Tests (14 new, 0 regressions in the 4967-test baseline): - 9 bridge cases over an in-memory channel — fresh spawn, single-scope reuse, cross-workspace isolation, thread-scope independence, path canonicalization, relative-path rejection, init failure cleanup, init timeout, multi-channel shutdown. - 4 route cases for /session (missing/relative/200/500). - 1 lifecycle case asserting `runQwenServe.close()` calls `bridge.shutdown()` before closing the listener. Verified end-to-end: `qwen serve` boots, `POST /session` spawns a real `qwen --acp` child and returns the SDK-assigned `sessionId`, repeat calls under the same cwd return `attached: true`, `SIGTERM` reaps the child along with the listener. * feat(cli): wire POST /session/:id/prompt + /cancel for `qwen serve` (#3803) Stage 1 follow-up after the bridge scaffold. Adds the two routes a client needs to actually run a turn against the daemon. Bridge: - `sendPrompt(sessionId, req)` looks up the session, FIFO-queues the call against the per-session prompt queue, and forwards through the SDK `ClientSideConnection.prompt`. Concurrent calls observe ACP's "one active prompt per session" invariant — second waits for first. - A failed prompt does NOT poison the queue; the tail catches and keeps draining so the next caller still runs (the original caller still sees its own rejection). - `cancelSession(sessionId, req?)` bypasses the queue and forwards the ACP notification immediately. ACP semantics: the agent winds down the *currently active* prompt; queued work is unaffected. - Both methods throw `SessionNotFoundError` (a typed Error subclass) when the id is unknown so route handlers can map cleanly to 404 without brittle message matching. - Both methods overwrite the `sessionId` field in the request body with the routing id — a stale or spoofed body would otherwise be dispatched to the wrong agent process. Routes: - `POST /session/:id/prompt` → 200 with PromptResponse, 400 on missing/non-array prompt, 404 on unknown session, 500 on agent error. - `POST /session/:id/cancel` → 204 always (cancel is a notification), 404 on unknown session. Tests (14 new — 7 bridge + 7 route, 0 regressions in the 4981 baseline): - sendPrompt: success forwards & returns response · routing-id overrides body sessionId · concurrent prompts FIFO-serialize (verified via per-prompt start/end ordering with a release latch) · failed prompt doesn't block subsequent prompts · 404 for unknown id. - cancelSession: forwards with routing id · 404 for unknown id. - Routes: 200/400/404/500 paths for prompt; 204 with body or empty + 404 for cancel. Verified end-to-end against a real `qwen --acp` child: - POST /session/:id/prompt with `[{type:'text',text:'hi'}]` → 200 `{"stopReason":"end_turn"}` in ~3.4s. - POST /session/:id/cancel → 204. - POST /session/does-not-exist/prompt → 404 with the unknown id surfaced in the body. * feat(cli): wire SSE streaming for `qwen serve` events (#3803) Stage 1 follow-up that turns prompt into a real streaming experience. Replaces the in-memory `notifications: SessionNotification[]` buffer on each session with a per-session EventBus and exposes it through `GET /session/:id/events` as an `text/event-stream` SSE feed. EventBus (`packages/cli/src/serve/eventBus.ts`): - Monotonic per-session ids (`v: 1` schema). Each `publish` chains an id, returning the materialized BridgeEvent. - Bounded ring (default 1000) backs `Last-Event-ID` reconnect — a consumer that drops can resume from `lastEventId` and replay any still-buffered events before live events flow. - Per-subscriber bounded queue (default 256). When a slow consumer overruns its queue, the bus appends a synthetic `client_evicted` terminal frame and closes that subscription so it can't hold the daemon hostage. Other subscribers are unaffected. - `subscribe()` returns an AsyncIterable — registration is synchronous so events `publish`ed immediately after the subscribe land in the queue (a generator-style implementation deferred registration to first `next()` and raced with publishes). - AbortSignal-aware: aborting the signal closes the iterator promptly. Bridge (`httpAcpBridge.ts`): - `BridgeClient.sessionUpdate` now publishes onto the session's EventBus instead of pushing to a plain array — every ACP notification the agent emits becomes a stream event automatically. - New `subscribeEvents(sessionId, opts?)` returns the bus's AsyncIterable; throws `SessionNotFoundError` for unknown ids. - Shutdown closes every live event bus before killing channels so pending consumers unwind cleanly. Route (`server.ts`): - `GET /session/:id/events` sets the SSE content type, advertises a 3s reconnect hint, and writes a 15s heartbeat comment frame to keep proxy/NAT connections alive. - Forwards the `Last-Event-ID` header to the bus. - `req.on('close')` triggers an AbortController that propagates into the bridge subscription so disconnects don't leak subscribers. - 404 when the bridge can't find the session. Capabilities envelope: `STAGE1_FEATURES` now advertises `session_create`, `session_prompt`, `session_cancel`, `session_events` in addition to `health`/`capabilities` so clients can light up UI for the routes that have actually shipped. Tests (16 new, 0 regressions in the 4995 baseline): - 9 EventBus unit cases — id sequencing, live delivery, replay, replay+live splice, fan-out to N subscribers, eviction on overflow, abort-signal unsubscribe, bus.close() drains subscribers, ring-size eviction. - 4 bridge subscribe cases — 404, sessionUpdate→event publishing via real ACP fake-agent, shutdown closes live subscriptions. - 4 SSE route cases against a live HTTP listener — frame format, Last-Event-ID forwarding, 404, abort propagation on disconnect. Verified end-to-end against a real `qwen --acp` child: - Subscribed to `/session/$SID/events`, fired `POST /session/$SID/prompt` with text content. Captured 13 distinct `event: session_update` SSE frames in real time during the model's response — `available_ commands_update` metadata, 9 `agent_thought_chunk` frames carrying the model's chain-of-thought, 3 `agent_message_chunk` frames with the actual reply, and a final usage frame with token totals. - Frames carry monotonic ids 1..13, the daemon-side counter, and are valid SSE per the EventSource spec. * feat(cli): wire POST /permission/:requestId for `qwen serve` (#3803) Stage 1 follow-up that turns `BridgeClient.requestPermission` from a hardcoded `cancelled` placeholder into a real first-responder vote loop, and ships the HTTP route any attached client uses to cast the deciding vote. Bridge: - `requestPermission` generates a UUID requestId, registers a pending entry on a daemon-wide map (and the owning session's `pendingPermissionIds` set), publishes a `permission_request` event onto the session's EventBus (so SSE subscribers see it), and awaits the resolution. - New `respondToPermission(requestId, response)` resolves the pending promise with the supplied outcome. First call wins — subsequent calls return false. On success the bridge publishes a `permission_resolved` event so other attached clients can update their UI when the race is decided. - `cancelSession` and `shutdown` both resolve every still-pending permission for the affected session(s) as `{ outcome: { outcome: 'cancelled' } }` per the ACP spec requirement that a cancelled prompt MUST resolve outstanding requestPermission calls with cancelled. - New `pendingPermissionCount` getter exposes inflight count for inspection / tests. Route (`server.ts`): - `POST /permission/:requestId` validates the body's `outcome` is either `{ outcome: 'cancelled' }` or `{ outcome: 'selected', optionId: string }`, then forwards to `bridge.respondToPermission`. - 200 on accepted vote, 404 when the requestId is unknown or already resolved (Stage 1 doesn't differentiate), 400 on a malformed outcome. Capabilities envelope: STAGE1_FEATURES gains `permission_vote`. Tests (14 new — 9 bridge + 5 route, 0 regressions in the 5011 baseline): - Bridge: publishes permission_request with a generated requestId and waits; respondToPermission first-responder wins; publishes permission_resolved on vote; respondToPermission false for unknown requestId; cancelSession resolves outstanding as cancelled; shutdown resolves outstanding as cancelled. - Route: 200 on selected outcome; 200 on cancelled outcome; 404 on unknown requestId; 400 on malformed outcome; 400 on missing outcome. Verified end-to-end against a real `qwen --acp` child: - Subscribed to /session/$SID/events, sent a prompt asking the agent to write a file at /tmp/qwen-serve-permission-e2e-test.txt. - The agent triggered a permission_request via the bus, surfacing the three options Qwen Code presents (Allow Always / Allow / Reject) with their option ids. - POSTed `{outcome:{outcome:"selected",optionId:"proceed_once"}}` to /permission/$requestId — got HTTP 200. - Bus published the matching permission_resolved event. - Agent proceeded with the writeTextFile tool call; file was actually created on disk with the expected content. * feat(sdk): add DaemonClient for the qwen serve HTTP API (#3803) Stage 1 follow-up that proves the cross-mode protocol-isomorphism design assumption: an SDK client can drive the daemon's HTTP routes end-to-end without going through ProcessTransport's stdio + stream-json path. DaemonClient is a sibling of ProcessTransport, not a replacement. The two speak different protocols (ACP NDJSON over HTTP vs stream-json over stdio). Existing `query()` users keep getting subprocess-mode unchanged; applications that want daemon-mode (cross-client attach, shared MCP pool, network reachability, first-responder permissions) opt in by constructing a DaemonClient against a running `qwen serve`. API surface (`packages/sdk-typescript/src/daemon/`): - `new DaemonClient({ baseUrl, token?, fetch? })`. The `fetch` override is for tests; defaults to `globalThis.fetch`. Trailing slashes on `baseUrl` are stripped. - `health()`, `capabilities()` — discovery. - `createOrAttachSession({ workspaceCwd, modelServiceId? })` — `attached: true` on the response indicates a session was reused under sessionScope:single. - `prompt(sessionId, { prompt: ContentBlock[] })` — returns PromptResult with stopReason. - `cancel(sessionId)` — tolerates 204; throws on 404. - `subscribeEvents(sessionId, { lastEventId?, signal? })` — async iterator over parsed SSE frames; AbortSignal-aware. Native Node AbortController only — jsdom polyfills are incompatible with undici. - `respondToPermission(requestId, response)` — first-responder vote; returns true on 200, false on 404 (lost the race or unknown id), throws on 400/500. `DaemonHttpError` is thrown for any non-2xx (besides the 404 "already-resolved" case on permission votes); carries `status` and `body` so callers can branch on standard daemon HTTP semantics. `parseSseStream(body)` is the underlying SSE parser; exported separately so applications can consume daemon SSE outside the DaemonClient surface. Handles split-chunk frames, comment/retry directives, malformed JSON (skip), trailing frame without final newline. Wire types live SDK-side (no SDK→CLI dep); the capabilities envelope's `v` field signals breaking changes. Tests (26 new, 0 regressions in the 201 baseline): - 7 SSE parser cases — single frame, multiple frames, comments, chunked-split frame, malformed JSON skip, trailing frame on close, empty stream. - 19 DaemonClient cases — health success/error, capabilities, bearer auth presence/absence, createOrAttachSession success/400, prompt body shape + sessionId url-encoding, cancel 204/404, permission 200/400/404, subscribeEvents header forwarding + 404, baseUrl normalization. Verified end-to-end against a real `qwen serve` daemon driving a real `qwen --acp` child: - `client.capabilities()` returned `{v:1, mode:"http-bridge", features: [...7 tags]}`. - First `createOrAttachSession` returned `attached:false`; second returned `attached:true` with the same sessionId. - `client.prompt(...)` with text content yielded `{stopReason: "end_turn"}` while the parallel `subscribeEvents` iterator streamed 10 distinct frames during the same turn. - AbortController on the events iterator cleanly severed the SSE connection. * feat(cli,sdk): list workspace sessions + set session model (#3803) Closes the §04 Stage-1 routes table for `qwen serve` with the two remaining endpoints, plus matching SDK methods. `GET /workspace/:id/sessions` - `:id` is the URL-encoded canonical absolute workspace path (Express decodes path params automatically; clients pass `encodeURIComponent(cwd)`). - Returns `{ sessions: [{ sessionId, workspaceCwd }, ...] }` for live sessions whose canonical workspace matches. - Empty array (not 404) when the workspace is idle so picker UIs don't have to special-case "no sessions yet". - 400 when the decoded path isn't absolute. `POST /session/:id/model` - Body: `{ modelId: string, ... }`. The route's `:id` overrides any spoofed sessionId in the body. - Forwards to ACP's `unstable_setSessionModel` and publishes a `model_switched` event onto the session bus so cross-client UIs update. - 200 with the agent's response on success, 400 on missing/empty modelId, 404 on unknown session. - The SDK method is currently unstable; documented in the bridge comment in case the spec renames the method when it stabilizes. Bridge: - New `listWorkspaceSessions(workspaceCwd)` iterates `byId.values()` and filters by canonical workspace path; works for both `single` and `thread` session scopes. - New `setSessionModel(sessionId, req)` forwards through `connection.unstable_setSessionModel`, normalizes sessionId, publishes `model_switched`, throws SessionNotFoundError on unknown ids. `STAGE1_FEATURES` capabilities envelope grows to 9 tags, adding `session_list` and `session_set_model`. SDK (`DaemonClient`): - `listWorkspaceSessions(workspaceCwd)` URL-encodes the cwd and returns the parsed `sessions` array directly. - `setSessionModel(sessionId, modelId)` POSTs the body and returns the agent response (currently opaque per ACP unstable spec). - Wire types `DaemonSessionSummary` and `SetModelResult` exported from the SDK barrel. Tangential cleanup: `sendBridgeError` now extracts a useful message from non-Error values via a small `errorMessage` helper. JSON-RPC errors from the agent (`{code, message, data}`) used to surface as `"[object Object]"` in the 500 response body; they now show the inner `message` field. Caught while running the model-set e2e. Tests (17 new — 9 bridge + 7 route + 4 SDK, 0 regressions in the 5022 + 227 baselines): - Bridge listWorkspaceSessions: matching cwd returns the live sessions; canonicalizes the lookup; empty for relative paths. - Bridge setSessionModel: forwards modelId + overrides body sessionId; publishes model_switched event; 404 unknown session. - Route /workspace/:id/sessions: returns the bridge list; empty for idle workspace; 400 for relative path. - Route /session/:id/model: 200 success; 400 missing modelId; 400 empty modelId; 404 unknown session. - SDK listWorkspaceSessions: URL-encodes the cwd; throws on 400. - SDK setSessionModel: posts body; throws on 404. Verified end-to-end against a real `qwen serve`: - SDK reports 9 capability features, list returns the existing session, attached:true on repeat create, and `setSessionModel` rejects with HTTP 500 when the modelId isn't registered (with the daemon now surfacing "Internal error" instead of "[object Object]"). - 404 path through SDK on unknown sessionId works. * fix(cli,sdk): audit round 1 follow-ups for `qwen serve` (#3803) Self-review pass on PR #3889. Two real correctness bugs and an ergonomics gap, plus the test-coverage holes the audit surfaced. The loudest finding ("host allowlist no-op when bind=localhost") was a false positive — the conditional was misread; existing tests already prove the validator is active on `localhost` binds. Real fixes: - Bearer-auth timing-attack: `parts[1] !== token` short-circuits per byte, leaking which prefix is correct via response latency. Replace with SHA-256 of both sides + `crypto.timingSafeEqual` so comparison is constant-time regardless of token length. - Concurrent `spawnOrAttach` race in single-scope: two parallel callers for the same workspace both passed the `byWorkspace.get` check, both spawned, and one entry ended up orphaned in `byId` while the other won `byWorkspace`. Violates the "at most one session per workspace" invariant. Coalesce via an `inFlightSpawns` map: parallel callers attach to the in-flight promise and report `attached: true`. The slot is cleared on both success and rejection so a failed spawn doesn't poison the workspace forever. New test asserts ONE channel spawns under parallel calls and that retry works after rejection. - `Number.parseInt('1.5e10z', 10)` returns 1, so a malformed `Last-Event-ID` header silently passes through. Tighten `parseLastEventId` to `^\d+$` so anything not a pure decimal integer is dropped. New test exercises 'abc', '-1', '1.5e10z'. Ergonomics: - `LOOPBACK_BINDS` and `LOOPBACK_HOST_BINDS` now include `::1` and `[::1]`. IPv6 loopback users no longer have to set a token. Host-allowlist allows `[::1]:port` Host headers. Documentation: - `BridgeClient` doc-comment now states the Stage 1 trust model explicitly: agent runs as the same UID, the file-proxy methods are NOT a workspace-cwd sandbox, restricting them would be theatre. The audit flagged this as a "design gap" but the daemon-and-agent-on-same-host posture makes a sandbox here redundant — Stage 4+ remote-sandbox swaps the Client for a sandbox-aware variant. SDK fix: - `DaemonClient.failOnError` previously called `res.json()`, which consumes the body even on parse-failure; the subsequent `res.text()` returned empty. New impl reads once as text and attempts JSON-parse; raw text is the fallback. New test asserts a `text/plain` 502 surfaces the body verbatim. Test gap fills (audit-flagged): - Bridge: in-memory file-proxy tests for `BridgeClient.{read,write} TextFile` including line/limit slicing. - SSE route: `stream_error` synthetic frame on iterator throw mid-stream; numeric Last-Event-ID forwarded; malformed Last-Event-ID dropped. - DaemonClient: text/plain error body coerced to `body` field; `respondToPermission` 5xx throws; `subscribeEvents` null-body throws; `cancel`/`respondToPermission` URL-encode session/request ids that contain slashes. Verified end-to-end with a token-required daemon: right token → 200, wrong/missing/malformed → 401. All paths return uniform 401 messages so a side-channel can't distinguish between "no header", "bad scheme", and "wrong token". Test counts: cli serve **89** (was 81, +8), sdk daemon **35** (was 30, +5). Full suites still green. * fix(cli): audit round 2 follow-ups for `qwen serve` (#3803) Second self-review pass on PR #3889. Three real bugs (one correctness, one resource-cleanup, one cosmetic) plus consolidation of the loopback bindings into a single source of truth. Real fixes: - Shutdown could hang forever on a long-lived SSE consumer: `server.close` waits for every in-flight connection to drain, and a paused EventSource client never disconnects. Added a `SHUTDOWN_FORCE_CLOSE_MS` (5s) timer that calls `server.closeAllConnections()` to force-destroy stuck sockets, then resolves so `process.exit(0)` can run. New test asserts close completes well under 5.5s even when an SSE GET is in flight. - Signal-handler race during shutdown: round 1 detached the SIGINT/SIGTERM listeners *up front* in `handle.close()`. If a second SIGTERM arrived during the drain, no handler existed and Node's default termination ran, orphaning agent children. Switch to detaching at the *end* of the close path (in `finish()`): during the drain window the handler is still attached and the `if (shuttingDown) return` guard makes a second signal a no-op; after drain completes we can safely remove the listeners (this also fixes a test-suite MaxListenersExceededWarning that fired once we ran the runQwenServe tests >10 times in a single process). - SSE response had no `error` listener. When the underlying TCP socket died (RST, kill -9 on the client), the next `res.write` threw EPIPE and Express forwarded it to the default error handler, logging noisily. Added `res.on('error', cleanup)` so the failure is absorbed and triggers the same teardown path the `req.on('close')` handler uses. Validation: - `createHttpAcpBridge` now throws on invalid `sessionScope` (anything other than `'single'` or `'thread'`) and on `initializeTimeoutMs <= 0`. Misconfigured callers used to silently degrade to thread behavior; now they fail loudly. Cleanup: - The `LOOPBACK_BINDS` set was duplicated between `auth.ts` and `runQwenServe.ts` (round 1 missed this). Extracted into `packages/cli/src/serve/loopbackBinds.ts` with a single `isLoopbackBind(hostname)` helper. Both files now import; drift is impossible. - `res.flushHeaders?.()` lost the optional chaining. The method is on `http.ServerResponse` since Node 1.6; our `engines` floor is 20. Tests added: - bridge: `sessionScope` validation, `initializeTimeoutMs` validation. - server: shutdown force-close timeout, SIGINT/SIGTERM listener detach-after-drain. False positives from the round 2 audit (verified and dismissed): - "EventBus nextId overflow at 2^53" — theoretical only (would require ~9 quadrillion publishes per session). No code change. - "Subscribe-during-close race" — JS is single-threaded; the close() flag is set synchronously before the loop touches state. - "Queued prompts on shutdown" — by design; documented via the promptQueue tail comment. - "10MB body parser limit" — design choice for Stage 1's in-memory buffering model; revisit if ACP streaming lands in Stage 2. - "Unbounded body read in DaemonClient.failOnError" — daemon is local in Stage 1; the threat surface for adversarial-large error bodies is the same as the daemon's other unbounded buffers. Test counts: cli serve **93** (was 89, +4), full cli **5047** (no regressions), sdk **236** (no regressions). * docs(cli): audit rounds 3 + 4 follow-ups for `qwen serve` (#3803) Two more self-review passes on PR #3889. No correctness bugs surfaced this time — round 3 found a HIGH-severity Windows-path claim that turned out to be a false positive (`path.win32.isAbsolute('/foo/bar')` returns true; verified against Node 20). Round 4 confirmed every prior decision and surfaced one latent-but-not-currently-triggered concurrency note. Changes are pure documentation + a tiny optional-chain cleanup: - Drop `?.` on `server.closeAllConnections()` in runQwenServe.ts — the method exists since Node 18.2 and our `engines` floor is 20. The optional chain dated from before round 2's force-close timer landed; clean it up. - Help text for `qwen serve --port` now documents that port 0 means "OS-assigned ephemeral port" (which the implementation has always supported but never advertised). - `defaultSpawnChannelFactory` gains a comment near the spawn site documenting the FD-budget implication (~3 FDs per session, bump `ulimit -n` for many concurrent sessions) and the `stdio: ['pipe', 'pipe', 'inherit']` choice (child stderr lands in the daemon's stderr, interleaved across sessions). Both are Stage-1-accepted; Stage 2/4+ revisit each. - Comment on the bridge's `byWorkspace`/`byId` Maps documenting the known gap that a child crashing between requests leaves a garbage SessionEntry until daemon shutdown — surfaced as a per-prompt failure when the dead session is touched, not a hang. Stage 2's in-process bridge eliminates the spawned-child failure mode entirely so this gap goes away naturally. - `EventBus.subscribe` doc-comment now states explicitly that the returned iterator is NOT safe to drive from concurrent `.next()` callers — the underlying queue isn't atomic. Daemon usage is the sequential `for await ... of` inside the SSE route, so this is safe in production. Documented so a future fan-out consumer doesn't accidentally rely on undefined behavior. False positives verified and dismissed (round 3 + 4 combined): - `path.isAbsolute('/foo/bar')` Windows breakage — `path.win32. isAbsolute('/foo/bar')` is true; verified empirically. - "Windows drive divergence" causing duplicate sessions — different drives are different on-disk paths; sessions intentionally differ. - "parseSseStream early-break leaks reader" — `for await ... break` triggers `iterator.return()` which runs the generator's `finally` that calls `releaseLock`. Standard JS semantics. - "Promise executor sync-throw fragility in requestPermission" — sync throws inside `new Promise(executor)` reject the outer promise; functionally correct, just stylistic. - "Force-close timeout test elapsed assertion flakiness" — assertion is `< 5500ms` but the natural happy-path is sub-100ms. Generous headroom; not flake-prone in practice. - "fetch reference stale after polyfill" — `globalThis.fetch.bind` captures at construction; tests inject `opts.fetch` instead of polyfilling, which is the correct pattern. Test counts unchanged (cli serve **93**, sdk **236**); typecheck + lint clean. STAGE1_FEATURES still matches every implemented route 1:1, fakeBridge in tests implements every HttpAcpBridge method. * fix(cli): PR #3889 review round 1 — critical correctness (#3803) Addresses the four critical findings from the PR #3889 reviewer pass: 1. ACP `ReadTextFileRequest.line` is 1-based per spec, but the bridge's `BridgeClient.readTextFile` was treating it as a 0-based slice index. A client asking for `{line:1, limit:2}` ("first two lines") was getting lines 2-3 — a sign-off-by-one bug that breaks every editor / SDK client following the ACP schema. Convert to 0-based via `Math.max(0, line - 1)`. The existing slice test was asserting the wrong behavior; updated to expect the spec-correct result and added a second `line:3, limit:2` case to lock in the offset. 2. `modelServiceId` was accepted by the SDK + server `POST /session` path, forwarded into `bridge.spawnOrAttach`, and then silently dropped: `doSpawn` never wired it into the agent. Callers requesting a specific model got the agent's default and no indication anything was wrong. Now `doSpawn` issues `unstable_setSessionModel` immediately after `newSession`. If the agent rejects the model id, the half-initialized session is torn down and the spawn rejects so the caller can retry cleanly instead of inheriting silent drift. Three new bridge tests: happy path, omit-when-undefined, agent-rejection cleanup. 3. The CORS middleware used `cors({ origin: (o, cb) => cb(new CORSError(...), false) })` for browser-Origin requests. `cors` flows the Error into Express's error chain; without an explicit error handler that produces a 500 + HTML body, which is misleading for what is really a deterministic 403 denial. Replace with a tiny `RequestHandler` that checks `req.headers.origin` directly and returns `403 { error: 'Request denied by CORS policy' }` JSON. Drops the `cors` and `@types/cors` dependencies — there's no other consumer in the cli package. 4. The SSE `stream_error` synthetic frame hard-coded `id: 0`, which would regress the client's `Last-Event-ID` tracker and trigger duplicate replays on reconnect. The frame is terminal and daemon-emitted — it has no place in the per-session monotonic sequence. Refactor `formatSseFrame` to omit the `id:` line when the input event has no id field, and emit `stream_error` without one. Test updated to assert `frames[1].id === undefined` while the preceding `session_update` still carries its monotonic id. Tangential cleanup: `errorMessage` now formats the SSE error body (was `err.message` only — would have shown `[object Object]` for JSON-RPC errors mid-stream, mirroring the round-1 SDK fix). Test counts: cli serve **96** (was 93, +3 modelServiceId cases); existing readTextFile slice test rewritten in place. Full typecheck + lint + suite green. * fix(cli,sdk): PR #3889 review round 2 — SSE robustness + EventBus polish (#3803) Second batch of reviewer-flagged fixes for PR #3889. Addresses 7 robustness issues across the daemon's SSE pipeline + the bus + the SDK's stream parser. Daemon SSE (`server.ts`): - SSE writes now respect backpressure. `res.write` returns false when the kernel send buffer is full; the previous code ignored that and Node accumulated payloads in user-space memory unboundedly. A slow consumer on a chatty session could balloon daemon RSS. New `writeWithBackpressure` helper awaits `drain` (or `close`/`error`) before scheduling the next write — for both per-frame writes and heartbeats. - `parseLastEventId` rejects values > `Number.MAX_SAFE_INTEGER`. With the prior `^\d+$` regex a malicious 25-digit value would parse to a number that loses precision and confuses replay comparisons. EventBus (`eventBus.ts`): - `Last-Event-ID` replay events now `forcePush` past `maxQueued`. A client reconnecting with a 1000-event gap on a subscriber whose cap is 256 was silently losing entries 257-1000 — a sign-off-by- nothing breakage of the resume contract. Live publishes still go through the normal cap (slow live consumer must be evictable); historical replay is bypassed. - `onAbort` now disposes the subscription immediately instead of only closing the queue. An aborted-but-never-iterated subscriber used to linger in `bus.subs` until the consumer drove `next()` / `return()`. New tests cover both abort-after-subscribe and already-aborted-at-subscribe paths. - `BoundedAsyncQueue.next` now checks `buf.length > 0` before shifting instead of `buf.shift() !== undefined`. The bus never pushes `undefined` today but the queue is generic — the prior pattern would mis-handle a queue whose element type legitimately includes undefined. SDK SSE parser (`sse.ts`): - Now flushes the TextDecoder on stream close. Without the final `decoder.decode()`, an incomplete multi-byte UTF-8 sequence at the tail of the last chunk was silently dropped — corrupting any frame whose JSON ended mid-character. New test feeds a stream split mid-byte through "中" (3-byte UTF-8) and asserts the character round-trips. - Frame separators now accept both `\n\n` and `\r\n\r\n`. SSE spec allows CRLF, and intermediaries (corporate proxies, some Node http servers) sometimes normalize. Frame field splitter also accepts `\r?\n`. Two new tests cover pure CRLF + mixed-LF/CRLF. Test counts: cli serve **99** (was 96, +3 EventBus); sdk daemon-sse **10** (was 7, +3). Full typecheck + lint + suite green. * docs(cli,sdk): PR #3889 review round 3 — minor + docs (#3803) Last batch from the PR #3889 reviewer pass: mostly docs + a ReDoS-tooling-silencing rewrite + a yargs-key cleanup. - `commands/serve.ts` ServeArgs interface dropped the camelCase `httpBridge` mirror; the handler now reads `argv['http-bridge']` matching the declared option name. The dual surface relied on yargs's camelCase expansion behavior — fragile if yargs config ever changes. - `DaemonClient` constructor's `baseUrl.replace(/\/+$/, '')` (which is end-anchored and linear, but CodeQL's polynomial-regex detector flags any `\/+$` pattern on attacker-controlled input) swapped for a hand-rolled `stripTrailingSlashes` loop. Same behavior, no rule trigger. - `defaultSpawnChannelFactory`'s `cwd: workspaceCwd` flow into `spawn` is the second CodeQL finding ("uncontrolled data used in path expression"). It IS user-controlled, by design — that's the Stage 1 trust model. Added a `// lgtm[js/shell-command- constructed-from-input]` suppression with a comment explaining the model and pointing at issue #3803 §11 for the Stage 4+ remote- sandbox replacement. - Stale doc comment on `createServeApp` that still listed only `/health`, `/capabilities`, `POST /session` as shipped — now enumerates all 9 routes that match §04 of the design. - Stale doc comment on `HttpAcpBridge` saying "Stage 1 buffers them in-memory; SSE wiring lands in the next PR" — SSE wiring landed in commit41aa95094. Replaced with a description of the actual flow through EventBus + SSE. No behavior change; tests + lint + typecheck still green. cli serve still **99**, sdk **38** (was 30 before this batch — daemon-sse +3, DaemonClient +5 from rounds 1+2). Full e2e against built daemon re-verified: CORS denial returns 403 JSON (was 500 HTML), bad `modelServiceId` now causes spawn to fail with HTTP 500 (was: silent default-model substitution), `POST /session` without modelServiceId unaffected. * fix(cli,sdk): self-audit round 5+ — close orphaned EventBus + DaemonEvent.id optional (#3803) Two more fixes from a final post-review-comment audit pass on PR #3889. Both are subtle correctness gaps that fell out of the round-1 critical fixes (modelServiceId apply + SSE id-less stream_error). - In `httpAcpBridge.ts:doSpawn`, when `unstable_setSessionModel` rejects after `newSession` succeeded, we tear down the entry from `byWorkspace` + `byId` (round 1 fix) but did NOT close the EventBus we'd just constructed for that entry. The agent could have published a session_update notification during init that queued in the (now unreachable) bus's ring buffer; without an explicit close the bus + buffer linger until the next GC cycle. Bounded leak (1 bus per failed spawn × 1000-event ring) but cleaner to close it. New regression test exercises the retry path after a model-rejection failure to lock in that we don't reuse the orphan and that subscribers on the fresh session see an empty iterator on immediate abort. - SDK `DaemonEvent.id` is now `id?: number` instead of `id: number`. The round-1 SSE fix made the daemon emit `stream_error` frames *without* an `id:` line so they don't pollute the per-session monotonic sequence. The SDK parser correctly returns `undefined` for the missing field, but the type still advertised `id: number` — TypeScript consumers persisting `lastSeenId = event.id` would accidentally store `undefined`. Made the field optional and added a doc comment instructing consumers to skip frames without an id. Plus one more false-positive verified and dismissed: - "writeWithBackpressure Promise double-settle race": the auditor flagged that `res.write(chunk, callback)` could fire its callback after the synchronous `ok=true` resolve. Verified harmless — Promise double-settle is a no-op, the callback only rejects on error (caught separately by `res.on('error', cleanup)`), and multiple parallel writes register independent listener sets that each remove their own pair after firing. Test counts: cli serve **100** (was 99, +1 retry-after-model-rejection regression). SDK unchanged at 239. Full typecheck + lint + suites green; flow re-verified end-to-end. * fix(cli,sdk): PR #3889 review round 4 — child-crash recovery + SSE/permission/SSE polish (#3803) Fourth and final batch of reviewer-flagged fixes for PR #3889. 14 inline threads addressed, plus 8 spam threads up for resolution. Critical correctness: - `eventBus.test.ts`'s ring-eviction test wrapped its assertion in a `void (async () => { … })()` IIFE that returned synchronously to vitest — the inner `expect` could fail without ever surfacing. Hoisted to a top-level `await` so the harness actually waits and a broken eviction would now fail loudly. - `runQwenServe.ts handle.close()` is now idempotent. Concurrent callers (test harness + signal handler firing simultaneously, explicit caller + finally-block fallback) used to each construct a new shutdown promise, arm a fresh force-close timer, and call `bridge.shutdown` redundantly. Cache a single `closePromise`; repeat calls return it. New test exercises 3 overlapping callers + a post-settle call → exactly one bridge.shutdown. - `POST /permission/:requestId` now rejects `outcome.selected` with an empty `optionId`. The string-typeof check passed `""` through; bridge would forward an opaque "unknown option" error from the agent. Tighten the validator + add a 400 test. - `denyBrowserOriginCors` now has explicit unit tests (3 cases: Origin-bearing GET → 403 JSON, no-Origin GET → 200, Origin-bearing POST → 403 + bridge untouched). The CSRF defense was previously implicit-only. Channel-exit recovery: - `AcpChannel` interface gains an `exited: Promise<void>` that resolves on either planned `kill()` or unexpected child crash. Bridge subscribes via `channel.exited.then(...)`: if the entry is still in `byId` when exit fires (i.e. unexpected crash), it cancels pending permissions, publishes a `session_died` event so SSE subscribers get notified, closes the bus, and removes the entry from `byWorkspace`/`byId`. Without this, a crashed child used to leave its `SessionEntry` stuck — under `sessionScope:'single'` (default) the whole workspace was unreachable until daemon restart. - `defaultSpawnChannelFactory` now wires `child.once('error', …)` in addition to `'exit'`. Without an `error` listener Node treats an async spawn failure (ENOMEM, EACCES, …) as an unhandled error and crashes the daemon. - Two new bridge tests: `crash()` simulates an unexpected exit → asserts `session_died` event + entry removed + retry spawns a fresh child; planned shutdown asserts the cleanup handler no-ops when the entry is already gone (no double-publish). SSE robustness: - SDK `parseSseStream` now calls `reader.cancel()` (not just `releaseLock`) in its `finally`. Early-break consumers were leaving the underlying HTTP body stream open; cancel propagates upstream so the connection drops promptly. New test asserts the underlying ReadableStream's `cancel()` runs. - SDK `parseSseStream` accepts `data:` (no space after colon) AND multiple `data:` lines per frame (joined by `\n` per spec). Two new tests cover both cases. - SDK `DaemonClient.subscribeEvents` now validates response Content-Type before delegating to the parser. A misconfigured proxy returning 200 + JSON was silently producing zero events; now throws `DaemonHttpError` with the actual mime type. - Daemon SSE route's initial `retry: 3000` write now `.catch(()=>{})`s. A socket that errors before the first write would have surfaced as an unhandled rejection. Documentation (deferred items now noted in code): - `EventBus.publish` ring shift is O(n) when full. Comment notes the deferral; circular-buffer refactor only if profiling flags it. - SSE heartbeat doesn't detect dead connections without TCP RST. Comment notes Stage 2 may add an explicit idle timeout. - `defaultSpawnChannelFactory` won't run a `.ts` entry directly — `npm run dev` users must build first. Comment in the spawn site. Test counts: cli serve **107** (was 100, +7), SDK daemon **42** (was 38, +4). Full typecheck + lint + suite green. * test(integration): qwen serve daemon — routes + streaming + recovery (#3803) Persists the e2e validation of every PR #3889 fix as vitest integration tests under `integration-tests/cli/`. Two files split by auth requirement: `qwen-serve-routes.test.ts` (18 cases, no LLM credential needed) - Bearer auth timing-safe compare: right token / wrong-same-length / wrong-shorter / missing / Basic-scheme. - CORS browser-Origin denial: GET-with-Origin → 403 JSON; no-Origin → 200. - Capabilities envelope: all 9 Stage 1 features advertised in order. - POST /session validation: relative cwd → 400; two parallel POSTs same workspace coalesce; bad modelServiceId tears down half-init. - POST /permission/:requestId validation: empty optionId → 400; missing optionId → 400; valid vote on unknown id → 404. - SDK SSE Content-Type guard: throws DaemonHttpError when upstream returns 200 + JSON. - Last-Event-ID strict parsing: malformed value accepted but ignored (`'1abc'` doesn't get parsed as 1). - Cancel idempotent + listWorkspaceSessions returns the live session. `qwen-serve-streaming.test.ts` (3 cases, gated by SKIP_LLM_TESTS) - Real `qwen --acp` child SIGKILL → daemon publishes `session_died`, removes the entry from `byWorkspace`/`byId`, next createOrAttachSession spawns fresh. Uses `pgrep -P` to locate the daemon's direct child by PID. - Two SSE subscribers + a tool requiring permission: both observe the same `permission_request` requestId; two concurrent POST votes resolve as exactly one 200 + one 404 (first-responder wins). - SSE reconnect with `Last-Event-ID: N` after consuming N frames yields events with `id > N` from the bus's replay ring. Both files spawn `node packages/cli/dist/index.js serve --port 0 --token …` per `beforeAll` and clean up in `afterAll`. Use the existing `@qwen-code/sdk` alias the integration-tests vitest config already wires to the built SDK bundle. Run with the existing `npm run test:integration:cli:sandbox:none` (or any of the integration-tests target). The streaming file is skip-able via `SKIP_LLM_TESTS=1` for environments without auth. Verified locally: 18/18 routes pass in ~6.8s; 3/3 streaming pass in ~23s against a real model. * fix(cli): PR #3889 review round 5 — claude-opus-4-7 audit (#3803) Seven new substantive findings from a `/qreview` pass on PR #3889. Six real bugs + one type-safety gap; all addressed. Critical correctness: - **EventBus replay overflow + eviction race**. Round 4's `forcePush` for `Last-Event-ID` replay bypassed the per-subscriber cap, but `BoundedAsyncQueue.push`'s cap check was `buf.length >= maxSize` — so the very next live publish saw the inflated buf, rejected, and triggered the `client_evicted` terminal frame. Concrete sequence the audit walked through: client reconnects after 300+ events, replay force-pushes 300 entries, next live event evicts them. Defeats the resume contract. Fix: track force-pushed items separately (`forcedInBuf` counter). `push()` cap is now on `(buf.length - forcedInBuf)`. `next()` decrements `forcedInBuf` as the consumer drains (force-pushed entries are FIFO at the front of `buf` since `forcePush` only runs at subscribe time, before any live `push`). Two new regression tests: (1) live publish after a >cap replay does NOT evict; (2) eviction triggers only after the LIVE backlog (excluding replay) hits the cap. Performance + UX: - **Eager express import on every `qwen` invocation**. The `serve` subcommand statically imported `../serve/index.js`, which transitively pulled express + body-parser + qs into cold-start path of every CLI invocation (interactive, mcp, channel, etc). ~50ms tax on the 99% of invocations that never run `serve`. Defer to dynamic `import()` inside the handler; types are still imported for the builder shape. - **Middleware order**: `express.json({limit:'10mb'})` ran BEFORE `bearerAuth`. Unauth POST got full JSON.parse before 401. Trivial DoS amp on non-loopback deployments. Reorder so auth + Host allowlist + CORS run first; body parser runs only for requests that pass the gate. - **`sendPrompt` no AbortSignal**. A stuck/dead child poisons the per-session FIFO; HTTP client disconnect didn't propagate so daemon CPU stayed tied up. `HttpAcpBridge.sendPrompt` now accepts `signal?: AbortSignal`. Route handler creates an AbortController and wires `req.on('close')` to abort it. On abort, bridge sends an ACP `cancel` notification; the agent winds down → prompt resolves with `stopReason: 'cancelled'` → next queued prompt can run. New test exercises real socket disconnect via `node:http` (jsdom AbortSignal isn't compatible with undici). Security: - **`--token` on argv leaks via `/proc/<pid>/cmdline`**. Default Linux permissions allow any local user to `ps auxww | grep 'qwen serve'` and read the bearer token. Daemon now warns to stderr when `--token` is used and recommends `QWEN_SERVER_TOKEN` (which uses `/proc/<pid>/environ`, owner-only). - **Token inherited by spawned `qwen --acp` child**. `env: process.env` in `defaultSpawnChannelFactory` passed `QWEN_SERVER_TOKEN` into the child. The agent runs user-supplied prompts with shell-tool access — leaving the token in env enables prompt-injection-into-self-call attacks. Strip `QWEN_SERVER_TOKEN` from the child's env before spawn. Robustness: - **`BridgeClient` publishes lacked try/catch on closed bus**. `BridgeClient.requestPermission` and `sessionUpdate` called `entry.events.publish(...)` directly. Shutdown closes the bus *before* killing the channel, so a late `sessionUpdate` from a not-yet-dead agent throws. For `requestPermission` the throw was particularly bad: `registerPending` had already mutated the daemon-wide map, so the throw left the registry inconsistent. Cleaner fix: make `EventBus.publish` a no-op on closed bus (returns undefined) instead of throwing. Removes the need for try/catch at every call site and keeps state consistent. Type safety: - **`STAGE1_FEATURES: readonly string[]`** widened the inferred tuple-of-literals back to `string[]`. A typo'd feature (`'sesion_set_model'`) compiled silent. Drop the annotation + add `as const`; export `Stage1Feature` literal-union for SDK-side `features.includes(...)` checks to narrow against. Test counts: cli serve **112** (was 105, +7); SDK unchanged at 243. Full typecheck + lint + suite green. * fix(cli): PR #3889 review round 6 — gpt-5.5 audit (#3803) Four new findings from a `/review` pass on PR #3889. Three real correctness bugs + one Stage 1 design-gap documentation. Critical: - **`[::1]` bind ENOTFOUND**. `LOOPBACK_BINDS` accepts `[::1]` for the auth gate, but `app.listen()` wants the unbracketed `::1`; `qwen serve --hostname [::1]` passed the gate and then crashed with ENOTFOUND. Strip brackets at bind-time, keep them for the printed URL. New test asserts the listener actually binds when the operator types `[::1]`. - **`sendPrompt` no transport-close detection**. The chained `entry.connection.prompt()` could hang indefinitely if the `qwen --acp` child wedged or the underlying stream broke mid-flight (the SDK's pending JSON-RPC promise never delivers a response). Because the per-session FIFO tail derives from that promise, a single stuck prompt poisoned every subsequent caller for the same session. Round 4's `channel.exited` is already wired to remove the entry, but the in-flight prompt itself wasn't racing it. Fix: race `entry.connection.prompt(...)` against `entry.channel.exited` inside `sendPrompt`; when the transport closes mid-flight, the prompt fast-fails with a descriptive error rather than hanging the queue. New test exercises this via a stuck fake agent + manual `crash()`. Real correctness: - **`spawnOrAttach` attach-path ignored modelServiceId**. Under `sessionScope:'single'` (default) a client requesting a specific model on attach got `attached:true` while continuing to use whatever model the shared session already had — a silent contract drift. Refactor the per-session `unstable_setSessionModel` call into a shared `applyModelServiceId(entry, modelId)` helper that runs both at create-time (existing path) AND on attach-with-model. Same helper publishes the `model_switched` event so cross-client UIs see the change. New tests cover apply-on-attach and the omit-modelServiceId-on-attach no-op case. Stage 1 design: - **`BridgeClient.{readTextFile, writeTextFile}` raw fs proxy**. The audit flagged that the bridge reimplements file I/O with `fs.{read,write}File` instead of delegating to core's filesystem service — divergence on BOM handling, non-UTF-8 encodings, original line endings. Wiring core's FileSystemService through the bridge is invasive (constructor dep, reaches into core's runtime), and Stage 2's in-process bridge eliminates the proxy entirely. Documented as a known gap with the exact user-visible scenarios; no behavior change in this PR. Test counts: cli serve **116** (was 112, +4); full cli **5070** (was 5066, +4); SDK unchanged at 243. Lint + typecheck green. * fix(cli): PR #3889 review round 7 — match CodeQL suppression to fired query (#3803) Single new CodeQL alert (#201) on `workspaceCwd → spawn({cwd})`. The round-3 suppression I added (`lgtm[js/shell-command-constructed-from- input]`) referenced the WRONG query id — the alert fires the `js/path-injection` query, not the shell-command one. The misnamed suppression also lived 30+ lines above the actual flagged spawn call, out of CodeQL's annotation scope. Move the suppression onto the line immediately preceding the spawn call and use the matching query id `js/path-injection`. The function-level comment block above still documents the Stage 1 trust model rationale (operator-controlled cwd is intentional; agent runs as same UID with shell-tool access; Stage 4+ remote sandbox replaces this factory entirely). Defense-in-depth note added: `workspaceCwd` is canonicalized via `path.resolve()` in `spawnOrAttach` before reaching this factory, and spawn's `cwd` doesn't pass through any shell. No behavior change. Test counts unchanged (cli serve 116, full cli 5070). * fix(cli): self-audit round 8 — concurrency + listener leak + IPv6 + CodeQL honesty (#3803) Multi-round audit pass on PR #3889 commits 5/6/7. Four findings, one real high-severity. High: - Attach-with-modelServiceId had no error recovery and no FIFO. If the agent rejected the new model on attach, `applyModelServiceId` threw, the route 500'd, and the existing session kept running the OLD model — caller sees a 500 with no easy way to detect the state. Worse, two simultaneous attaches with different modelServiceIds would race the `unstable_setSessionModel` calls with no serialization. Add a per-session `modelChangeQueue` (parallel to `promptQueue`); `applyModelServiceId` now chains through it. On failure publishes a `model_switch_failed` event to the bus so OTHER attached clients can see what happened (the failed-caller still gets the 500). Two new bridge tests cover rejection observability + concurrent FIFO. Medium: - `sendPrompt` was adding a `.then` listener to `entry.channel.exited` PER CALL, accumulating linearly with prompt count over a session's lifetime. ~hundreds of bytes per prompt; trivially observable on chatty long-running sessions. Cache a single `transportClosedReject` lazy-init promise on SessionEntry; every subsequent prompt's race uses the same promise. Low: - `[host]:port` IPv6 syntax in `--hostname` was being naively bracket-stripped to `host]:port`, which Node rejects with a cryptic ENOTFOUND at startup. Tighten the strip to only accept pure `[addr]` forms; reject the URL-with-port form upfront with a useful error pointing at `--port`. - `BoundedAsyncQueue.forcedInBuf` invariant comment was wrong: it claimed force-pushed items were always at the front of `buf`, but the eviction-frame path force-pushes at the BACK. The miscount that follows is functionally inert (`close()` blocks the next cap check), but the comment was actively misleading. Rewrote it to honestly describe both call paths and explain why the eviction-case miscount is harmless. CodeQL honesty: - Round 7's `// lgtm [js/path-injection]` comment doesn't actually suppress alerts — GitHub Code Scanning ignores inline `lgtm` annotations (LGTM.com retired 2021). Replaced the misleading `// lgtm` line with a NOTE block stating the constraint explicitly: suppression requires UI dismissal or `.github/codeql/codeql-config.yml`, both out of scope for a code-only PR. The function-level comment that explains the Stage 1 trust model rationale stays. Test counts: cli serve **119** (was 116, +3); full cli **5073** (was 5070, +3, no regressions). * fix(cli): self-audit round 9-10 — reject empty-bracket --hostname (#3803) Final fix from rounds 9-10 of the audit chain. One real concern + three nice-to-have test gaps that the code already handles correctly. - `--hostname '[]'` (empty brackets) used to slip past the bracket validator: `slice(1, -1)` produced `''`, which Node interprets as "bind to all interfaces". An operator typing `[]` clearly meant something specific, not wildcard. Reject the empty-inner case upfront with the same useful error as the `[host]:port` case. New test asserts the rejection. Round 10 ran a clean convergence pass and signed off: - Cross-cutting state invariants (byWorkspace, byId, inFlightSpawns, pendingPermissions, plus all per-entry queues and caches) — all mutations paired and async holes safe. - All test names match assertions. - Public type surface clean (DaemonEvent.id?, Stage1Feature CLI-only, DaemonClientOptions.fetch shape correct). - Production paths verified: non-executable child times out at 10s init, multiple-daemon EADDRINUSE rejects cleanly via `server.once('error', reject)`. - Three "missing test" notes (transportClosedReject cache sharing, full subscribe-publish-evict sequence, modelChangeQueue failure isolation) are diagnostic gaps — the code paths are correct and covered by adjacent tests. Test counts: cli serve **120** (was 119, +1 empty-bracket); SDK unchanged at 243. * docs(cli): note SSE single-line data emit vs multi-line parser (#3803) formatSseFrame emits the payload as a single `data:` line. The EventSource spec also allows a frame to span multiple `data:` lines (joined by `\n` on parse), and the SDK receive-side parser handles that variant — but we never emit it because the JSON payload has no embedded newlines after JSON.stringify. Document the in/out asymmetry so future readers don't mistake the absence of newline splitting for a bug. Closes review thread AMgP0. * fix(cli,sdk): close 11 #3889 review threads — race + leak + IPv6 + SSE Critical correctness: - setSessionModel now serializes through `entry.modelChangeQueue` so POST /session/:id/model can't race with the attach-with-different- modelServiceId path that already chains on the same queue. Without this two concurrent model changes interleave and the published `model_switched` event may not match the agent's actual model. - POST /session reaps the spawned child when the client disconnected during the 1-3s spawn window (`req.aborted && !session.attached`). Without this, every aborted request leaks one orphan child the daemon can't address by sessionId. Attached sessions skip the kill — another client legitimately owns them. - spawnOrAttach refuses dispatch once shutdown has started (`shuttingDown` flag set at the top of `shutdown()`). Late-arrivers on already-established HTTP connections that pass `server.close`'s rejection of NEW connections would otherwise spawn children the shutdown snapshot already missed. Late re-check inside `doSpawn` (after `connection.newSession` resolves) catches the in-flight case and tears down the half-built channel. - sendPrompt early-aborts pre-aborted callers before queuing — saves a queue trip and gives a clean trace for retry-after-abort flows. Defensive: - parseSseStream caps the unread buffer at 16 MiB. Without this, an upstream that returns non-SSE (misconfigured proxy, long-lived non-streaming body) feeds `buf` until the consumer OOMs. - parseSseStream now accepts an optional AbortSignal that is checked at each iteration, and DaemonClient.subscribeEvents forwards `opts.signal` into it. Post-200 aborts now actually stop iteration instead of buffering frames until the upstream closes. - DaemonClient.fetchTimeoutMs (30s default) wraps every short-poll method (health/capabilities/createOrAttachSession/listWorkspaceSessions/ setSessionModel/cancel/respondToPermission) with `AbortSignal.timeout`. Composes with caller-provided signals via `AbortSignal.any`. `prompt` is intentionally exempt (long-lived: model + tool turns can take minutes); `subscribeEvents` is exempt (long-lived SSE). - New `bridge.killSession(sessionId)` API mirrors the shutdown teardown for a single session — used by POST /session orphan-reap above and exposed for future routes that need targeted cleanup. Stale + cosmetic: - Bridge map header comment said "no path that removes a session... when its child process crashes between requests" — out of date since the `channel.exited` cleanup landed in an earlier audit round. Rewritten to describe the actual cleanup chain. - runQwenServe now wraps IPv6 hostname literals in brackets when building the URL (`http://[::1]:4170` not `http://::1:4170`). The bracket-stripping logic on `listenHostname` already handled `app.listen()` correctly; this fixes the printed/copy-paste URL. - Dead `mode: ServeMode` variable in serve.ts removed (the runQwenServe call hardcodes `mode: 'http-bridge'`); the warning condition is now inlined. Test plan: - `vitest run` cli/serve: 120/120 + 49/49 (httpAcpBridge) pass - `vitest run` sdk-typescript daemon: 42/42 pass - tsc --build packages/cli packages/sdk-typescript: clean - ESLint: clean * chore(lint): allow mime/lite in import/no-internal-modules (#3803) `packages/core/src/utils/fileUtils.ts` and its test import `mime/lite`, which is mime@4's documented public sub-export (a smaller bundle that omits the legacy mime DB) — not an internal module. The rule has been flagging these on PR CI runs even though main's CI happens to pass (likely stale-cache vs fresh-install timing). Add `mime/lite` to the allowlist so lint is consistent across main and PR runs. * fix(cli,sdk): close 14 review threads — env whitelist + races + Windows tests + structured errors (#3803) Critical correctness: - registerPending now resolves orphaned permissions as cancelled when the entry has been torn down between the agent's `requestPermission` decision and the bridge handler firing. Previously the permission would hang the agent forever (killSession's pendingPermissionIds iteration didn't include the just-orphaned id, shutdown's clear() dropped it without resolving). - Workspace key now goes through `realpathSync.native` (with a resolved-but-uncanonicalized fallback for non-existent paths) so case-insensitive filesystems (macOS APFS, Windows NTFS) don't silently degrade `sessionScope: 'single'` into "one session per spelling". Matches how `config.ts` / `settings.ts` / `sandbox.ts` resolve workspace paths. - killChild gets a hard 10s deadline after SIGKILL so a child stuck in uninterruptible sleep (D-state, e.g. NFS read on a dead server) can't block `bridge.shutdown()`'s `Promise.all` forever. `SHUTDOWN_FORCE_CLOSE_MS` in `runQwenServe` only covers `server.close()` — without this hard kill, daemon shutdown hangs. - setSessionModel now races the agent call against `transportClosedReject` and wraps in `withTimeout`, matching what `sendPrompt` and `applyModelServiceId` already do. Without the race, a wedged child blocks `POST /session/:id/model` forever. Also publishes a `model_switch_failed` SSE event on rejection so passive subscribers see the failure (matches `applyModelServiceId`). - shutdown() now awaits `inFlightSpawns` so the late-shutdown re-check inside `doSpawn` finishes its half-built channel teardown before `bridge.shutdown()` resolves. Without the await, `runQwenServe.close()` returns and `process.exit(0)` is queued before the orphan tears itself down, surfacing a stderr error AFTER the daemon claimed graceful shutdown. - sendPrompt re-checks `signal.aborted` immediately after `addEventListener` so a microsecond-window synchronous abort that fires between the early-exit check and listener registration still triggers the agent `cancel` notification. Security: - `defaultSpawnChannelFactory` now passes an *allowlisted* environment to the spawned `qwen --acp` child instead of `{ ...process.env }` with `QWEN_SERVER_TOKEN` deleted. The agent runs user-supplied prompts with shell-tool access; anything in its env (OPENAI/ ANTHROPIC/DASHSCOPE keys, AWS/GCP credentials, DB passwords, OAuth tokens) is reachable by prompt injection. Allowlist covers HOME/PATH/USER/LOGNAME/LANG/LC_*/TMPDIR/TEMP/TMP/NODE_PATH plus Windows essentials (SYSTEMROOT/USERPROFILE/APPDATA/...). The explicit `delete childEnv['QWEN_SERVER_TOKEN']` stays as defense-in-depth — anyone grepping for the token name finds the scrub explicitly named. Observability: - 5xx responses now carry structured `code` and `data` fields when the underlying error has them (JSON-RPC errors from the ACP SDK forward as `{code, message, data}`). Without this, every distinct failure (quota / rate-limit / auth / crash) collapses to the same opaque "Internal error" string at the client. - 5xx errors log to stderr (via `writeStderrLine`, not `console.error`, to keep the no-console lint rule happy). Stop-gap until structured access/error logging lands. - Eviction frame on EventBus subscriber overflow no longer consumes a `nextId` slot. The synthetic frame burning a sequence id meant healthy subscribers saw gaps (3 → 5) that the resume ring couldn't back-fill — silently broke the `BridgeEvent.id` "monotonic per- session" contract. `BridgeEvent.id` is now optional on the type to make the absence honest. Same pattern as `stream_error`. Cross-platform: - httpAcpBridge.test.ts now derives expected paths via `path.resolve(path.sep, 'work', 'a')` (factored out as `WS_A`/ `WS_B`/`SESS_A` constants) instead of hardcoded POSIX literals like `/work/a`. On Windows `path.resolve('/work/a')` returns `D:\work\a` so the literal expectation drifted; the bridge's internal canonicalization to that form was correct, the tests were wrong. Fixes 3 Windows CI matrices that have been red since the PR opened. Compatibility: - `DaemonClient.fetchWithTimeout` now feature-detects `AbortSignal.timeout` and `AbortSignal.any` with polyfills, so the SDK actually works on its declared minimum runtime (Node >=18.0.0). `AbortSignal.any` was added in Node 20.3 — without the fallback every non-streaming call throws on Node 18.0–20.2. Documentation: - `cancelSession` now explicitly documents that cancel only affects the currently active prompt; previously POST'd queued prompts continue to execute. Multi-prompt queueing is a daemon-introduced behavior (not in ACP spec), so the contract for queued prompts is ours to define and was previously implicit. - Removed misleading "still reliable on Node 20" comment around `req.aborted` and switched the orphan-cleanup signal to `res.writable` — the right "can we still send a response to this client?" check (`req.destroyed` is too eager: clients close their writable end after sending the body even though they're still listening for the response). * fix(cli): close 3 more review threads — case-insensitive Host, trim token, sliceLineRange (#3803) - hostAllowlist now lowercases the Host header before comparison. Per RFC 7230 §5.4 Host is case-insensitive; Express normalizes header *names* but not values, so a Docker proxy that capitalizes the hostname (`Host: Localhost:4170`) or a platform with case-preserving DNS (`HOST.docker.internal`) was getting 403 with an exact-match compare. - `runQwenServe` now `.trim()`s the token from both `--token` and `QWEN_SERVER_TOKEN`. Common gotcha: `export QWEN_SERVER_TOKEN=$(cat token.txt)` keeps the file's trailing `\n`, so the hashed-then- compared token never matches what well-behaved clients send. Every request returns the generic 401, no breadcrumb pointing at the whitespace, operators chase ghosts. - `BridgeClient.readTextFile` partial-read path no longer `content.split('\n')`s the entire file. New `sliceLineRange` walks `indexOf('\n', …)` forward only to the end-of-range boundary and returns a single substring. For a 100 MB file with `{line: 1, limit: 2}` this avoids a ~100 MB `String[]` allocation. * fix(sdk): close 2 #3889 polyfill leaks — abortTimeout + composeAbortSignals Two copilot review threads on commit 11567a43c's AbortSignal polyfill code: - `abortTimeout` polyfill scheduled `setTimeout` but never cleared it. Even after the awaited fetch resolved, the pending timer kept the event loop alive until it fired; on a heavily-used client the per-call timers accumulated. Fix: `.unref()` the handle (so a fast-resolving fetch doesn't pin the loop) AND clear it on the controller's `abort` event (so the composed-signal-aborted-first path also drops the timer). Defensive `typeof handle.unref` so the polyfill works in any runtime that returns a non-NodeJS Timeout shape. - `composeAbortSignals` polyfill added an `abort` listener to every input signal but never removed them. Long-lived caller signals (e.g. a session-scope cancel signal that lives for the whole SDK client) accumulated one listener per SDK call — slow leak that retained the closure + controller of every prior call. Fix: track per-input cleanups in an array, detach all on the first abort (whichever input fires) AND on the composed controller's own abort path (defense-in-depth for callers that abort the composed signal independently). Both leaks only fire on the polyfill path — runtimes with native `AbortSignal.timeout` / `AbortSignal.any` (Node 20.3+) take the early-return path and bypass the leak surface entirely. 29/29 DaemonClient.test.ts pass; tsc + ESLint clean. * fix(cli,sdk): close 13 deepseek review threads — error handling + race + log noise (#3803) Correctness: - `applyModelServiceId` now races against `transportClosedReject` like `setSessionModel` and `sendPrompt` already do, so a child crash during attach-with-different-model fails fast instead of waiting the full 10s `withTimeout`. - `POST /session` disconnect guard now handles the `attached` case: previously `!res.writable && session.attached` fell through to `res.json` and threw EPIPE through Express's default handler. - `POST /session/:id/prompt` now drops `AbortError` silently. When the HTTP client closes mid-prompt the bridge re-throws as `AbortError`; routing it through `sendBridgeError` produced a noisy 500 + stderr stack trace that under active use generated dozens of misleading log lines per second. - `POST /session/:id/prompt` now rejects empty arrays (`[]`) and non-object elements with a 400 instead of letting the ACP SDK surface 500s on degenerate input. - `readTextFile` rejects `limit <= 0` up front (previously `sliceLineRange` hit the `end < start` path with surprising results). - `inFlightSpawns` tracks ALL `doSpawn` promises now, not just single-scope ones. Under `thread` scope, `shutdown()` previously resolved before in-flight spawns finished their child cleanup, surfacing stderr noise after the daemon claimed graceful shutdown. Use a unique `${workspaceKey}#${randomUUID()}` key per thread-scope spawn so simultaneous spawns don't collide. Shutdown ordering: - The 5s force timer is now armed AFTER `bridge.shutdown()` resolves, so it only races `server.close()` (the listener drain) — not the bridge's own 10s `KILL_HARD_DEADLINE_MS` child cleanup. The earlier arrangement could resolve this promise while the bridge was still killing children, orphaning anything not yet at the deadline. Express error handling: - Final 4-arg error middleware catches `express.json()`'s `SyntaxError` on malformed bodies and returns JSON `400` instead of Express's default HTML page (which trips SDK clients that expect a JSON body on every response). - SSE `res.on('error')` handler now logs the error before cleanup, so operators get a breadcrumb for flaky-network triage instead of silent disconnect. Performance: - `ALLOWED_CHILD_ENV_KEYS` moved to module scope so the 22-element Set is allocated once at load instead of rebuilt on every `defaultSpawnChannelFactory` call. (Renamed from `ALLOWED_ENV_KEYS` for clarity.) Documentation: - `canonicalizeWorkspace` now explicitly notes the cross-module contract with `config.ts`/`settings.ts`/`sandbox.ts`. A shared utility was considered but deferred — the call sites use slightly different fallback policies and Stage 2 in-process collapses the bridge into core, removing the bridge-side path resolution entirely. Tests: - Two new DaemonClient tests exercise `fetchWithTimeout`'s AbortSignal.timeout / composeAbortSignals polyfill paths against a never-resolving fetch promise. Previously every test used `recordingFetch` with synchronous resolution, so those polyfills shipped untested — a logic error there would only surface when a real daemon became unresponsive. * docs(serve): close §08 Stage 1 doc gap — user guide + protocol reference + DaemonClient example (#3803) Stage 1 of issue #3803 §08 budgeted "Documentation + examples + e2e tests" as the closing 1d task. The e2e tests landed (22 cases under integration-tests/cli/), the docs did not. After merge, anyone who discovers `qwen serve` via `qwen --help` had nowhere in-repo to read about it — the only complete description lived on the PR page itself. This commit fills that gap with three complementary docs and a README mention: - `docs/users/qwen-serve.md` — operator-facing quickstart: 5-step curl walkthrough (start → /health → /capabilities → /session → /prompt → /events), CLI flag table, default-deployment threat model summary, and a pointer to the orchestrator-shaped multi-session future. - `docs/developers/qwen-serve-protocol.md` — full HTTP protocol reference: per-route request/response shapes, auth contract, error envelope, SSE frame format and event-type table, Last-Event-ID reconnect semantics, environment variables, source layout. - `docs/developers/examples/daemon-client-quickstart.md` — TypeScript end-to-end snippet with the SDK's DaemonClient: capabilities probe, spawn-or-attach, subscribe-before-prompt event handling, reconnect via Last-Event-ID, first-responder permission voting, shared-session collaboration between two clients, auth, cancel. - README.md — "Daemon mode" added to the 5-way usage list + a short section under Usage with three doc links. - `docs/users/_meta.ts` and `docs/developers/_meta.ts` — sidebar entries for the new pages. No code changes; no test changes. * docs(serve): close 8 deepseek doc-review findings (#3803) Inline doc review on the Stage 1 doc set caught real issues: - `qwen-serve-protocol.md`: `session_died` (and `client_evicted`, `stream_error`) now explicitly marked as terminal — SSE stream closes after the frame; subscribers should reconnect via POST /session for `session_died`. - `qwen-serve-protocol.md`: documented coalesced spawn failure path — when the underlying spawn fails, all coalesced callers receive the same error and the in-flight slot is cleared so a follow-up call can retry. - `qwen-serve-protocol.md`: clarified the `modelServiceId` (back-end provider, picked at session create) vs `modelId` (model within an already-bound service, picked via POST /session/:id/model) distinction, and explained why `/capabilities`'s `modelServices` array is always `[]` in Stage 1. - `qwen-serve-protocol.md`: typo "Re-races" → "Races" on the model switch description. - `qwen-serve.md`: reordered quickstart so SSE subscribe (now step 4) comes before the prompt POST (now step 5). Previously, step 4's blocking prompt resolved before step 5's `curl -N` was open, so readers following the steps verbatim never saw a streaming event. Also expanded the event-types paragraph to call out which frames are terminal. - `daemon-client-quickstart.md`: closed a TOCTOU race in the example — `sendPrompt` fired before the SSE handshake completed, so fast-starting agents could emit events into the ring before the iterator was actually pulling. Pass `lastEventId: 0` so the daemon's replay buffer covers the gap; comment in the example explains the rationale. - README.md: "Loopback bind has no auth" → "no auth by default" (since the user can opt into bearer auth on loopback by setting `QWEN_SERVER_TOKEN`). * fix(cli,sdk,docs): close 21 review threads — env regression + races + doc accuracy (#3803) CRITICAL regression fix: - Child env scrub flipped from allowlist back to denylist (just QWEN_SERVER_TOKEN). The earlier allowlist was overzealous: it dropped OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY / QWEN_* / DASHSCOPE_API_KEY / custom modelProviders[].envKey, all of which the agent legitimately needs to authenticate to the LLM. Daemon-mode users with env-only auth would start the daemon, attach a session, then watch every prompt fail with auth errors. Threat- model rationale documented at the call site: prompt-injected shell tools can already read ~/.bashrc, ~/.aws/credentials, etc., so env passthrough isn't the security boundary; the user-as-trust-root is. QWEN_SERVER_TOKEN stays scrubbed to prevent agent → its own daemon escalation. Other code fixes: - doSpawn no longer tears down the session when create-time model switch fails. The session is still operational on the agent's default model; tearing it down left the caller with a 500 and no sessionId to retry against. The model_switch_failed SSE event is the visible signal; caller can retry via POST /session/:id/model once they have the sessionId. - doSpawn now uses applyModelServiceId for the create-time model switch (was raw conn.unstable_setSessionModel + withTimeout). The helper races against transportClosedReject too, so a child crash during model switch fails fast instead of consuming the full init timeout. - sendPrompt's abort handler now calls cancelPendingForSession before the ACP cancel notification (matching cancelSession). A client disconnecting mid-permission was leaving the agent stuck waiting on a vote that no SSE subscriber would ever cast. - shutdown() and killSession() now publish a terminal `session_died` SSE event before closing the bus. Previously the channel.exited handler's "byId.get(...) !== entry" guard short-circuited (entry already removed), so SSE subscribers couldn't tell daemon shutdown from a transient network error. - Express error middleware now special-cases `status: 413` (EntityTooLargeError from body-parser when a request exceeds the 10 MB JSON limit) and returns a JSON 413 instead of a misleading 500. - /health is now registered BEFORE bearerAuth middleware, so liveness probes work without credentials when the daemon was started with --token. CORS deny + Host allowlist still apply. - SSE writes serialize through a per-connection chain so the heartbeat interval can no longer interleave with the main event- write loop. Two concurrent res.write calls would otherwise bypass the backpressure guard and could interleave bytes between SSE frames on the wire. SDK: - abortTimeout / composeAbortSignals exported for direct unit testing. The existing test claimed to cover the polyfill paths via subscribeEvents, but subscribeEvents calls _fetch directly (not fetchWithTimeout), so composeAbortSignals never ran in the test. New tests exercise the helpers directly across native + polyfill runtimes. Doc accuracy fixes: - daemon-client-quickstart.md: createOrAttachSession({ cwd: ... }) → ({ workspaceCwd: ... }) (SDK type), client.sendPrompt → prompt, client.cancelSession → cancel. The example wouldn't typecheck. - qwen-serve.md: "binds one workspace" claim removed — a single daemon hosts sessions for any cwd the caller passes; the per-instance constraint is per-user / scale, not per-workspace. Auth verification example switched from /health to /capabilities (since /health is now exempt from bearer auth). - qwen-serve-protocol.md: env var was QWEN_E2E_LLM, real var is SKIP_LLM_TESTS (inverted polarity). Streaming test count was 4, actually 3. Added Stage 1 limitation notes for "no DELETE /session" and "no permission timeout". Added client-side ring-buffer gap detection guidance for Last-Event-ID reconnect. Test updates: - httpAcpBridge.test.ts: rewrote two tests for the new doSpawn-on-model-switch-fail contract (publish event, keep session). Updated shutdown-closes-subscriptions test to expect the new terminal `session_died` frame. - server.test.ts: switched bearer-auth rejection probes from /health to /capabilities (since /health is now exempt). Added a test that locks /health's exemption. * docs(serve): close 2 last review threads — prompt timeout limitation note (#3803) A05Yk (deepseek): document that `POST /session/:id/prompt` has no server-side timeout. The bridge only races against the agent child exiting + the caller's HTTP-disconnect AbortSignal; a wedged-but-alive agent blocks the per-session FIFO. Long-running prompts are legitimate (deep research / large-codebase analysis) so a default deadline is deliberately not set; Stage 2 will expose a configurable opt-in. Callers should set their own client-side timeout and disconnect / POST /session/:id/cancel on expiry. AyoUy (copilot): same env-allowlist concern as A09HB — already addressed by the allowlist→denylist revert in the previous commit (e74aa9919). No additional code change needed; the resolve here just acks that the upstream fix covers it. * fix(serve): close 3 copilot review threads — SSE envelope shape + integration test ordering (#3803) A8uSe / A8uSt — the SSE frame examples in qwen-serve.md and qwen-serve-protocol.md showed `data:` containing only the inner ACP payload (e.g. `{"sessionUpdate": ...}`). The daemon actually emits the full event envelope — `{id?, v, type, data, originatorClientId?}` — JSON-stringified on a single line. Readers copying the curl output and writing parsers against the documented shape would extract garbage or fail JSON-shape validation. Both docs now show the real envelope and call out the SSE-level `id:` / `event:` lines as EventSource convenience that duplicates fields already inside the JSON envelope. A8uSz — integration `qwen serve — bearer auth` tests probed `/health` for 401 assertions, but `/health` is now intentionally registered BEFORE the bearer middleware (per the A8dZT fix in the previous commit) so liveness probes work without credentials. Switched probes to `/capabilities`, plus added a `/health exempt` test that locks the exemption so a future middleware ordering change can't silently break liveness probes. Also: integration `bad modelServiceId tears down half-init session` asserted the OLD doSpawn-on-model-switch-fail behavior (throw + clear maps). Per #3889 review A05Ym the new behavior keeps the session operational on the agent's default model and surfaces the failure via the `model_switch_failed` SSE event. Test renamed to `bad modelServiceId keeps the session alive on the default model` and rewritten to assert the new contract. * fix(serve): close 3 copilot review threads — sync write throw, polyfill name, blockquote (#3803) A800o (server.ts:360): `res.write(chunk, cb)` callback isn't documented to receive an error argument in Node — errors come on the `'error'` event, which the surrounding code already wires up. The dead `(err) => if (err) reject(err)` branch was misleading. The real concern was that `res.write()` can throw synchronously when the socket is already destroyed (typical EPIPE shape), and the throw escaped the promise executor. Wrapped the `res.write` call in try/catch so that surfaces as a rejection on the returned promise instead of an unhandled exception. A8008 (DaemonClient.ts:375): `abortTimeout` polyfill called `new DOMException('TimeoutError')`, which sets the *message* to "TimeoutError" and leaves `name` at its default ("Error"). Native `AbortSignal.timeout()` aborts with `name === 'TimeoutError'` (per WHATWG), so callers doing `if (err.name === 'TimeoutError')` to distinguish timeout from user-abort would see the polyfill behave differently from the native runtime. Constructor signature is `new DOMException(message, name)` — fixed both args. A801J (qwen-serve-protocol.md:254): blockquote was broken — one line in the middle of the multi-line `>` block was missing the `>` prefix, which dropped the rest of the list out of the quote and rendered awkwardly. Added the missing `>`. * fix(cli,sdk): close 8 review threads — DoS cap + SDK plumbing + cleanup (#3803) Critical: - A9UEi — `EventBus` had no subscriber cap and evicted subscribers lingered in the `subs` Set until the consumer drove `next()`. An attacker opening thousands of SSE connections to one session would amplify each `publish()` (O(N) over subs) into a CPU/memory DoS, with each evicted-but-stalled connection's `BoundedAsyncQueue` pinned in memory forever. Two fixes: per-bus subscriber cap of 64 (refuses new subs at the limit by returning an empty iterable), AND `subs.delete(sub)` immediately when a subscriber is evicted so subsequent publishes don't pay the dead-sub iteration cost. Also set `server.maxConnections = 256` on the listener to bound socket descriptors against connections that never finish their headers. SDK: - A9UEv — `prompt()` now accepts an optional `AbortSignal`. Caller cancellation forwards through the underlying TCP close, which the daemon already translates into an ACP `cancel` notification. The bridge's `sendPrompt(sessionId, req, signal)` always supported it; only the SDK surface was missing the parameter. - A9UEn — `subscribeEvents` now applies `fetchTimeoutMs` to the CONNECT phase only (request → headers received). The SSE body itself stays uncapped (it's long-lived by design), but a daemon that's TCP-open but never returns headers no longer blocks callers indefinitely. Implementation: a setTimeout-driven AbortController composed with the caller's signal, cleared in `finally` once `_fetch` returns. - A9UEr — `respondToPermission` now drains the response body via `res.body?.cancel()` on both 200 and 404. undici keeps the underlying socket pinned waiting for an unconsumed body; long- running clients with frequent permission votes would exhaust the connection pool. Cleanup: - A9UNF — `MAX_BUF_BYTES` renamed to `MAX_BUF_CHARS` (the guard checks `buf.length`, which is UTF-16 code units, not bytes). The cap's job is "stop runaway non-SSE bodies", not exact accounting, so the proxy is intentional — but the name now matches the unit. Error message updated. - A9UNb / A9UNp — both integration tests' boot-timeout `setTimeout` is now stored and `clearTimeout`'d on success and on early exit. Without the clear the un-cancelled 10s timer outlived the spawn promise and could keep the vitest event loop alive past the test, manifesting as intermittent timeouts on slow CI. A9UEy was already addressed by the prior commit's `status === 413` branch in the Express error middleware (body-parser sets both `status: 413` and `type: 'entity.too.large'` on body-too-large errors); resolve only. * fix(cli,test): close 2 copilot review threads — case-insensitive bearer + Windows skip (#3803) A9sCe (auth.ts:88): bearer scheme parsing was case-sensitive (`parts[0] !== 'Bearer'`). Per RFC 7235 §2.1 / RFC 7230 §3.2.6 the auth scheme token is case-insensitive — `Bearer` / `bearer` / `BEARER` are all valid, and conformant clients may send any. The old code returned 401 on those. Switched to a regex-based split that also tolerates runs of whitespace between scheme and credentials, then `.toLowerCase()`s the scheme before comparing. The token value itself stays case-sensitive (it's user-defined opaque material). A9sCw (qwen-serve-streaming.test.ts): the streaming integration suite shells out to `pgrep` / `kill -KILL` to simulate child-process crashes for the `SIGKILL → session_died` test. Those binaries are POSIX-only — on Windows runners the suite would fail even when `SKIP_LLM_TESTS` is unset. Added `process.platform === 'win32'` to the SKIP gate. A Windows-equivalent (`taskkill /F /PID …`) needs different scaffolding; deferred. * fix(cli,sdk,docs): close 6 review threads — CodeQL regex, body cancel, env doc (#3803) A90nk (auth.ts:93): CodeQL flagged the new bearer-scheme regex `^(\S+)\s+(.+)$` as a polynomial-regex risk on user-controlled input — `\s+` and `.+` overlap on whitespace-heavy adversarial headers (the alert example: `'!\t' + '\t'.repeat(N)`). Replaced with a hand-rolled split (`indexOf(' ')` + manual whitespace skip) so there's no backtracking. Behavior unchanged: scheme is still case-insensitive, runs of whitespace between scheme and credentials still tolerated, scrubs `header.charCodeAt() === 0x20` explicitly so we don't accidentally consume tab/newline as scheme separator. A90oi / A96Q8 (qwen-serve.md:117): the threat-model bullet still claimed the spawned child runs with an "allowlisted environment" (HOME / PATH / USER / LOGNAME / LANG / etc), but the prior commit flipped the implementation to a denylist (only `QWEN_SERVER_TOKEN` scrubbed) so the agent could authenticate to LLM providers. Doc now matches code: explicit pass-through with a one-key scrub, plus the threat-model rationale (user-as-trust-root, env passthrough is not the boundary). A90ou (qwen-serve-protocol.md:300): `stream_error` example showed the inner ACP-style payload `{"error":"<message>"}` instead of the full envelope `{v, type, data:{error}}` that other SSE-frame examples in the same doc already use. Updated to match. A96RL (DaemonClient.ts:352): `subscribeEvents` threw on a 200 with the wrong content-type without consuming the response body first. On undici-backed `fetch` an unconsumed body keeps the underlying socket pinned waiting for the consumer; long-running clients hitting this path repeatedly would exhaust the connection pool. Same `await res.body?.cancel()` pattern as `respondToPermission`. A96RR (server.ts:167): prompt-element validation accepted any non-null object, but `typeof [] === 'object'`, so `prompt: [[]]` slipped past with a confusing 500 from the ACP SDK layer downstream. Added `!Array.isArray(item)` so the 400 actually catches array elements. * fix(cli,sdk,docs): close 10 review threads — DoS observability + race + tests (#3803) Code: - A-Ur8 (httpAcpBridge.ts:1319): SCRUBBED_CHILD_ENV_KEYS gets a prominent WARNING that the denylist-only design is correct ONLY because the agent has unrestricted shell-tool access. Any future sandbox-locked variant MUST switch back to allowlist or expand the denylist to cover provider/CI/cloud secret prefixes. - A-XfH (auth.ts:60): Host allowlist now accepts the no-port form (`localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`) when the bind port is 80. Per RFC 7230 §5.4 clients may legitimately omit the port suffix when it matches the URI scheme default. - A-UsJ (httpAcpBridge.ts:564): unify model-switch failure handling. The create-session path swallows the error to keep the session alive on its default model; the attach path now does the same (was: throwing a 500 with no sessionId, denying the caller any way to recover). Both paths surface failure via the `model_switch_failed` SSE event. - A-UsN (httpAcpBridge.ts:621): extracted the lazy-init `transportClosedReject` pattern into `getTransportClosedReject` helper. Three call sites (`applyModelServiceId`, `sendPrompt`, `setSessionModel`) collapsed to one, single-listener invariant documented at one place. - A-UsH (eventBus.ts:194): subscriber-cap rejection is now observable. EventBus.subscribe throws a typed `SubscriberLimitExceededError` (was: silent empty iterable). SSE route catches it, logs to stderr, and emits an SSE-shaped `stream_error` terminal frame so the rejected client sees a readable failure rather than a closed-with-no-frames stream. - A-UsO (server.ts:72): `/health` is now exempted from bearerAuth ONLY on loopback binds. On non-loopback the route is registered AFTER bearerAuth so probes must carry the token — otherwise an unauthenticated caller could probe arbitrary IP:port to confirm a `qwen serve` exists. Doc updated. Tests added: - A-UsP: new test sends an 11 MB body to verify the 413 path in the Express error middleware returns the actionable "Request body too large" JSON instead of a generic 500. - A-UsQ: new test for `DaemonClient.prompt(sessionId, req, signal)` AbortSignal forwarding through to fetch. - A-UsS: two new tests for `subscribeEvents` connect-timeout (never-resolving fetch aborts; fast-resolving fetch clears the timer so it doesn't leak as a dangling handle). - A-UsU: new test for `sendPrompt` abort path resolving pending permissions as cancelled — the bug being regressed: an HTTP client disconnecting mid-permission would leave the agent stuck waiting on a vote that no SSE subscriber would ever cast. Test contract updates: - `publishes model_switch_failed and surfaces the error when the agent rejects` rewritten for the new attach-path swallow contract: attach now returns the existing session with `attached: true` and the `model_switch_failed` event is the visible failure signal instead of a thrown error. * fix(serve): add missing v field on subscriber-limit stream_error frame (#3803) `tsc --build` (which CI runs as part of the lint job) caught what `tsc --noEmit` (the local typecheck script) missed: the new `stream_error` frame in `server.ts:344` was constructed without the `v` field, but `OmitId<BridgeEvent>` requires it. Local typecheck in the previous commit was clean; the build's stricter project graph reported `error TS2345` and broke both Lint and Test (Ubuntu) jobs. Set `v: 1` to match the existing `stream_error` construction in the SSE iterator-throw path in the same file. * docs(users): close 1 copilot review thread — GitHub canonical casing in nav (#3803) A_U2e: nav label "Github Actions" was inconsistent with the canonical "GitHub" casing used elsewhere in the repo (skills, README, etc.). Rename to "GitHub Actions" for consistent branding. Pre-existing entry in `docs/users/_meta.ts` adjacent to the `'qwen-serve'` line this PR added — flagged in the diff context. * fix(serve): close 4 deepseek review threads — closed-bus race + per-session stderr + entry override (#3803) BBb9H (correctness): `BridgeClient.requestPermission` could orphan a pending permission if the bus closed between `registerPending` and `entry.events.publish` (the shutdown path closes per-session buses BEFORE awaiting `channel.kill()`, so the agent can still issue `requestPermission` in that window). Pending was registered in the daemon-wide map but `publish()` returned `undefined` (closed bus) → no SSE subscriber ever saw the request → no client voted → agent's `requestPermission` hung forever, blocking the daemon's `Promise.all` over child kills. Now: check publish's return; if `undefined`, roll back the pending via a new `rollbackPending` callback that resolves it as `cancelled`. BBb8e (Critical observability): child stderr was `'inherit'` — all sessions' stderr interleaved on the daemon's stderr stream unattributed. Switched to `'pipe'` and forward each line with a `[serve pid=<n> cwd=<dir>]` prefix; operators can now `grep pid=12345` to pull one session's trace cleanly. Updated the now-stale doc comment that claimed inherit was current. BBb8- (deployability): `process.argv[1]` is brittle — fails on non-`qwen` launchers (bundled binaries, npx wrappers, `node -e`, `tsx`, container images that relocate the script). Added `QWEN_CLI_ENTRY` env override as the higher-priority resolution path. Improved the failure message to suggest the env var as the actionable fix. BBb82 (documented limitation): `withTimeout` REJECTS but doesn't ABORT the underlying ACP op. For `unstable_setSessionModel` this means a timed-out caller perceives failure while the agent may eventually complete the switch — drift between caller's perceived model and agent's actual model + contradictory SSE events. Documented as a Stage 1 limitation in the `withTimeout` JSDoc; acceptable because (1) ACP doesn't expose a cancel signal for `unstable_setSessionModel` yet so we couldn't abort even if we wanted to, (2) model switches complete in milliseconds in practice — a timeout means genuinely wedged, not just slow. Stage 2 will add abort plumbing once ACP exposes the hook. * ci(noop): re-trigger workflow forf8509dde5(#3803) * fix(cli,sdk): close 8 review threads — sse abort + queue drain mode + perf + doc engine drift (#3803) Correctness: - BCcd6 (sse.ts:80): trailing flush at EOF used `splitFrames(buf)` which returned `[buf]` — a multi-byte split that completed multiple frame separators in the final `decoder.decode()` would merge the frames into one parse and silently drop events. Switched the EOF flush to `consumeFrames()` (same walker the main loop uses), then attempt one more `parseFrame` on any trailing fragment. Removed the now-unused `splitFrames` helper. - BCybH (sse.ts:67): `parseSseStream` only checked `signal.aborted` before each `reader.read()`, leaving the generator parked inside a pending `read()` if the upstream went idle right when the caller aborted — contradicting the docstring's "AbortSignal cleanup is prompt" claim. Added a one-shot abort listener that calls `reader.cancel()` (cleared in `finally`), so abort reliably terminates even on a stalled stream. - BCce_ / BCycT (eventBus.ts:391/253): subscribe documented "abort closes the iterator promptly" but `BoundedAsyncQueue.next()` drained any items already in `buf` before honoring `closed`. Aborted SSE subscribers could keep yielding hundreds of queued events to a closed socket. Added a `close({drain: false})` mode that truncates `buf` immediately, used by the abort path; the default drain-on-close behavior is preserved for the eviction path (which needs the synthetic `client_evicted` terminal frame to reach the consumer before the iterator unwinds). Performance: - BCcfe (auth.ts:72): `hostAllowlist` was allocating a fresh `Set` + 4 interpolated strings on every request. Cache once per resolved port (relevant because tests bind to ephemeral 0 and the port is only known after `listen()`); SSE heartbeats and high-frequency probes now skip the allocation. - BCcgJ (DaemonClient.ts:137): `fetchWithTimeout` used `AbortSignal.timeout()` — the timer fires regardless of whether the fetch resolved early. On a fast-resolving request with the default 30s timeout, the pending timer hangs around. Switched to `AbortController` + `setTimeout` + explicit `clearTimeout` in `finally`, so each timer is released the moment its fetch settles. Also `.unref()`s the timer so it doesn't pin the event loop on its own. Doc accuracy: - BCyc0 (DaemonClient.ts:468): the `abortTimeout` / `composeAbortSignals` JSDoc claimed Node 18-20.2 polyfill compatibility, but `engines.node` is `>=22.0.0` now. Reframed as a generic feature-detect for non-Node runtimes (browsers / edge workers) so future maintainers don't reason about the wrong floor. - BCydi (server.ts:368): "Always present in Node >= 20" → "on the supported Node versions (engines.node >=22)". CodeQL alert #207 (httpAcpBridge.ts:1342, `js/path-injection` on `cwd: workspaceCwd`) is the renumbered version of the already-accepted #201 — same trust-model rationale documented at the call site, same need for maintainer UI dismiss / config exclusion. * feat(serve): close 3 chiga0 audit items — ringSize 4000, --max-sessions, /health?deep=1 (#3803) Three "30-minute" items from chiga0's external architecture audit (2026-05-11). All actionable within Stage 1 scope; remaining items in chiga0's review (SaaS positioning, multi-token to Stage 1.5, acp-bridge package extraction, reference orchestrator) are larger scoping decisions deferred to Stage 1.5/2. DEFAULT_RING_SIZE 1000 → 4000 (Risk 4): - A single long turn can emit hundreds of frames (test plan reports 13 for a SHORT turn, real workloads can be 10× that). 1000 was exhausted by a moderate turn before a 5s reconnect window finished. 4000 gives ~30× headroom over a typical busy turn at the cost of a few hundred KB RAM/session. Updated user + protocol docs and the daemon-client-quickstart example. --max-sessions <n> (default 20) (Rec 3): - New `ServeOptions.maxSessions` + matching `BridgeOptions`. Bridge throws `SessionLimitExceededError` when `byId.size + inFlightSpawns.size >= max` BEFORE issuing a fresh spawn. Attaches to existing sessions (single scope) bypass the cap so an idle daemon's reconnects keep working at-capacity. `0` disables. Default of 20 sized below the design's N≈50 cliff (per-session ~30–50 MB RSS + FD pressure). HTTP route maps to 503 with `Retry-After: 5` and `code: session_limit_exceeded`. Tests cover: cap rejection under thread scope, attach-not-counted under single scope, `0` disables. Documented in CLI flags table + protocol Common-error section. /health?deep=1 (Risk 3): - Default `/health` stays cheap (no bridge access). With `?deep=1` the response includes `sessions` and `pendingPermissions` from the bridge — touches state so a wedged bridge surfaces as 503 `{status: "degraded"}` instead of "200 ok" on a zombie daemon (the `k8s rolling deploy will see healthy` failure mode chiga0 flagged). Loopback-vs-non-loopback bearer-exempt logic from the earlier A8dZT fix is preserved via a shared handler. Tests cover: cheap default, deep response shape, throwing-getter → 503. * fix(serve,sdk,docs): close 9 review threads — req.on('close') prompt-cancel bug + doc + types (#3803) Critical correctness: - BQAnZ (server.ts:225): `POST /session/:id/prompt` wired cancellation to `req.on('close')` — but Node's `IncomingMessage` fires that event when the request body has been fully consumed, even when the client is still listening for the response. Result: ordinary prompt calls were getting cancelled the moment their upload finished, returning `{stopReason: "cancelled"}` instead of completing. Switched to `res.on('close')` guarded by `!res.writableEnded` (the documented "client gave up before we could send the response" pattern, same as the POST /session disconnect-detection from earlier in the PR). Already addressed earlier — resolve as ack: - BQAna (httpAcpBridge.ts:767): no global session cap. Already shipped in commit66ffd7cc6— `--max-sessions` flag + bridge enforces with `SessionLimitExceededError` mapped to 503; both in-flight spawns and live sessions count against the cap. Doc fixes: - BDAOf (DaemonClient.ts:49): `fetchTimeoutMs` JSDoc said it applies to "every non-streaming method including prompt", but `prompt()` actually bypasses fetchWithTimeout (model+tool turns are minutes-scale, can't be 30s-capped). Doc now lists the short-lived methods explicitly and notes prompt's exemption. - BDAPY (qwen-serve-protocol.md:283): blockquote was broken — the `POST /session/:id/cancel` line was missing the leading `>` and a stray "- POST /session/:id/cancel." rendered orphaned outside the quote. Reformatted as a single coherent quote. Reviewer-tooling resilience: - BQAnf / BQAng (integration-tests/...:325/185): added explicit `DaemonSessionSummary` type to two `.find` / `.every` callbacks. Local typecheck infers the type fine via the SDK's source declarations; the reviewer's environment resolves `@qwen-code/sdk` against a possibly-stale `dist/index.d.ts` (per `integration-tests/tsconfig.json` `paths` mapping) and the `s` parameter widens to `any`. Annotation makes both envs happy. Reviewer-only artifacts (no code action): - BQAnb / BQAnc (integration-tests/...:26/30) — same SDK-dist staleness; the imports are correct and resolve fine when `packages/sdk-typescript` has been built. - BQAni (server.test.ts:8 supertest module not found) — Node 20 setup blocker the reviewer noted; resolves cleanly under Node >=22 (our declared engines floor) with `npm install`. * fix(serve,sdk,test): close 7 review threads — fetchTimeoutMs negative + bridge-error context + perm scope contract (#3803) Real fixes: - BQPRo (DaemonClient.ts:136): `fetchTimeoutMs` accepted any number, including negatives that would slip past the `Number.isFinite` check inside `fetchWithTimeout` and fire `setTimeout(-1)` → immediate abort, killing every request before it could complete. Coerce non-positive / non-finite to 0 (the documented disable sentinel) at the constructor so call-site math stays simple. - BQLdO (server.ts:725): `sendBridgeError` now accepts a `ctx` arg `{ route, sessionId }` folded into the stderr log line. Bare `ECONNRESET` / `ENOMEM` traces are no longer unattributable on a busy daemon — operators see `qwen serve: bridge error (POST /session/:id/prompt session=abc-123): ...`. All five route call sites pass context. - BQI-6 (qwen-serve-streaming.test.ts:123): `sseFrames` test helper forwards `opts.signal` into `parseSseStream` so post-connect abort terminates iteration immediately (the parser's own abort- -wired-to-reader.cancel landed earlier; this just plumbs through the test harness). Doc / contract: - BQNqL / BQNqM (httpAcpBridge.ts:692, server.ts:199): `cancelPendingForSession` cancelling all session permissions on client disconnect is intentional under the per-session FIFO + ACP spec — permissions are issued inline DURING an active prompt, the agent awaits them, so the only outstanding permissions at any moment belong to the prompt being cancelled. Cross-client caveat (B's vote 404s when A disconnects mid-A's-prompt) is the right behavior — a vote on a cancelled-prompt's permission wouldn't drive the agent forward. Documented the scope contract + multi-client caveat in `cancelPendingForSession` JSDoc. Already addressed (resolve as ack): - BQI-c (qwen-serve-protocol.md): blockquote was already reformatted in the previous round (`POST /session/:id/cancel` now sits inline on a single quoted line); copilot reviewed an older commit. - BQI-v (DaemonClient.ts): `fetchTimeoutMs` JSDoc was already updated last round to explicitly note `prompt()` is excluded; copilot reviewed the older shape. * fix(serve,test,docs): close 6 review threads — TEST_CLI_PATH + Stage 2 markers + SSE phantom-conn warning (#3803) Real fix: - BQpu6 / BQpvW (integration-tests/cli/...): both qwen-serve test files hardcoded `../../packages/cli/dist/index.js`, while the rest of the integration suite reads `process.env.TEST_CLI_PATH` (set by `globalSetup.ts` to the root `dist/cli.js` bundle). The difference made our tests sensitive to which build step (`build` vs `bundle`) ran last. Now read `TEST_CLI_PATH` first, fall back to per-package dist for direct vitest invocations that bypass globalSetup. Operator-facing doc: - BQsOD (server.ts:497 KNOWN GAP): added an operator warning to `docs/users/qwen-serve.md`'s threat-model section about phantom SSE connections behind NATs that swallow TCP RSTs (kernel keepalive ~2h Linux default → can accumulate to the 256-conn ceiling on `--hostname 0.0.0.0` deployments). Stage 2 will add application-level idle deadline; until then operators on such networks may want to lower `server.keepAliveTimeout` via reverse proxy. Stage 2 maintenance markers (no code change, just visible TODOs): - BQsOA (httpAcpBridge.ts:1247): added `FIXME(stage-2)` on the sync `realpathSync.native` call so the Stage 2 in-process refactor doesn't ship without removing this event-loop-blocking syscall. - BQsOB (server.ts:243): added a SECURITY NOTE on the `...(body as object)` passthrough explaining the spec-defined `_meta` forwarding contract + the rule that an explicit pick is required if any new bridge field starts being trusted by name. Pattern repeats on cancel/model — note covers all four sites. - BQsOF (httpAcpBridge.ts:1041): `FIXME(stage-2)` noting that `setSessionModel` reuses `initTimeoutMs` (default 10s) for the in-flight model swap — conceptually distinct from cold-start init, currently sharing only by coincidence; Stage 2 should split into `modelSwitchTimeoutMs` and remove the no-abort `withTimeout` race-condition once ACP exposes a cancel signal for `unstable_setSessionModel`. * fix(serve): close 4 review threads — unhandled rejection + maxSessions plumbing + 2 docs - httpAcpBridge.sendPrompt: attach .catch(() => {}) to the abort-listener cleanup chain. The chain is `racedPromise.finally (...)` and we never await it; if `racedPromise` rejects, the finally returns a rejected promise that surfaces as an unhandled rejection (Node's default behavior on unhandled rejection is process termination). The route's own catch handles the original rejection — only the cleanup chain needs the swallow. - httpAcpBridge.sendPrompt: FIXME(stage-2) for absolute prompt deadline — buggy agent ignoring cancel + alive channel = slow prompt-promise leak. - server.createServeApp: forward opts.maxSessions when constructing the default bridge. Direct callers (tests, embeds) were silently falling back to DEFAULT_MAX_SESSIONS (20); only the runQwenServe path piped the option through. - docs/users/qwen-serve.md: clarify Host allowlist is loopback-only; non-loopback binds rely on bearer + operator-managed front proxy. * docs(sdk): close 1 review thread — sse.ts MAX_BUF_CHARS docstring lead-line said "bytes" Doc lead-line claimed "Hard cap on accumulated unread bytes" while the implementation enforces the cap via `buf.length` (UTF-16 code units), which the rest of the same docstring already correctly explained. Fix the lead-line so a reader skimming the first sentence isn't misled. The runtime error message and constant name (MAX_BUF_CHARS) already say "code units" — only the docstring lead-line needed alignment. * fix(serve,sdk): close 5 review threads — disconnect/attach race + 3 spec fixes + 1 doc - httpAcpBridge: add SessionEntry.attachCount + new killSession({requireZeroAttaches:true}) opt to fix the BQ9tV race. When client A spawned (attached:false) but disconnected mid-spawn, A's disconnect-reaper (server.ts) could tear down a session that client B had just attached to. spawnOrAttach now bumps attachCount on each attached:true return, and killSession with the new opt bails when attachCount > 0. The check + the eager byId/byWorkspace deletes both run in killSession's synchronous prefix, so the guard is atomic across the await boundary. - server.ts disconnect-reap path now passes requireZeroAttaches:true. - loopbackBinds.ts: lowercase the operator-supplied hostname before Set lookup so --hostname Localhost / LOCALHOST aren't forced to require a token. Aligns boot-time detection with the runtime Host-header check (auth.ts already lowercases). - auth.ts bearer parsing: accept HTAB (0x09) in addition to SP between scheme and credentials per RFC 7230 §3.2.6 BWS. - sdk sse.ts parseFrame: guard against `null` / primitive JSON parses so the AsyncGenerator<DaemonEvent> contract isn't violated by a misbehaving proxy emitting `data: null`. Daemon itself never emits these — defense-in-depth only. - docs/developers/qwen-serve-protocol.md: document the modelServiceId-rejection-on-fresh-session corner case + tell subscribers to pass Last-Event-ID:0 to replay the spawn-time model_switch_failed event from the ring. - 3 new unit tests: BQ9tV positive + negative race paths, BQ9ze parseFrame null guard. * fix(serve): close 4 review threads — 2 critical (NaN cap, stderr buffer) + IPv6 zone-id + deep doc - httpAcpBridge maxSessions normalization (BRApy [Critical] gpt-5.5): NaN / negative values previously fell through `!Number.isFinite(...)` to `Infinity`, silently disabling the daemon's session cap (fail-OPEN on a typo). Now throw TypeError on NaN / negative; explicit 0 and Infinity remain valid "unlimited" sentinels. - httpAcpBridge stderr line buffer (BRAp3 [Critical] gpt-5.5): the per-spawn `buf` accumulating stderr until `\n` had no length cap; a child that wrote a huge line or never emitted a newline could grow daemon memory unboundedly per session. Cap at 64 KiB per line and force-flush with a `[truncated]` marker — keeps the prefix-attributed log line, bounds memory, no content drop. - runQwenServe.formatHostForUrl (BQ-6V copilot): RFC 6874 requires `%` in IPv6 zone IDs (e.g. `fe80::1%lo0`) to be percent-encoded as `%25` in URLs. Now encode on the raw-IPv6 path; already-bracketed input is the operator's responsibility. - /health?deep=1 (BQ-6F copilot): the 503 path is unreachable for the real bridge (counter getters are simple Map-size accessors that don't throw). Reframed in code + protocol doc as INFORMATIONAL observability ("capacity dashboards, not real liveness"); keep the try/catch as defense-in-depth for custom bridge impls. - 2 new unit tests: BRApy NaN/negative throws + 0/Infinity ok; BQ92B Localhost case-insensitive boot. * fix(sdk): close 1 review thread — sse parseFrame tighter shape guard (BREsR followup to BQ9ze) The previous parseFrame guard only rejected null/primitive JSON; arrays and shape-incomplete objects still cast through to DaemonEvent. Tighten to require: non-null non-array object with v === 1 and type: string. Now the generator's static AsyncGenerator<DaemonEvent> type is a genuine runtime guarantee instead of a structural hope. Daemon never emits malformed frames (formatSseFrame always serializes {v: 1, type: string, ...}); guard remains defense-in-depth against misbehaving proxies / alternate implementations. Existing test fixtures already conform to the shape so no other tests needed updating. * fix(sdk): close 1 review thread — fetchWithTimeout keeps timer alive through body consumption (BRN1o) Pre-fix: `fetchWithTimeout` cleared the timer in `finally` the moment the underlying `fetch` resolved. But `fetch` resolves at headers, not at body completion. A daemon or proxy that sent headers and then stalled mid-body left `await res.json()` (and `failOnError`'s `res.text()`) without any deadline — calls to `health()`, `capabilities()`, `createOrAttachSession()`, `listWorkspaceSessions()`, `setSessionModel()`, `cancel()`, `respondToPermission()` could hang indefinitely past `fetchTimeoutMs`. Refactor `fetchWithTimeout<T>` to take an optional `consume(res)` callback whose execution is included in the timer scope. The composed abort signal still flows through to fetch's body stream, so an in-progress `res.json()` rejects cleanly when the timer fires. All JSON-returning routes updated to pass the body-read code as the callback. SSE (subscribeEvents) + prompt are unchanged: they bypass fetchWithTimeout intentionally (long-lived). Regression test: response with a never-emitting body that errors via the composed AbortSignal — pre-fix would hang for 5s+, post-fix rejects within ~80ms (configured timeout). * fix(serve,sdk): close 8 review threads — coalescing race fix + --max-connections + 5 docs/cleanups - httpAcpBridge spawnOrAttach (BRSCi [Critical] DeepSeek): the BQ9tV attachCount fix was incomplete for the in-flight coalescing path. When two callers await the same doSpawn and the second has a modelServiceId, the attach-bump landed AFTER an extra await for applyModelServiceId — leaving a microtask window in which A's killSession sync-prefix would still see attachCount==0 and reap a session B was about to receive. Move the bump to the very first sync step after `await inFlight` (and same in the direct-attach branch) so the bump-before-killSession ordering holds even when the model-switch yields. Test added for the coalescing-race path. - commands/serve + serve/types + runQwenServe (BRQQb): add `--max-connections` flag (default 256), wired through ServeOptions and `server.maxConnections`. Operators with high-concurrency deployments can now tune the listener-level cap without waiting for Stage 2. - commands/serve (BRQQZ): wrap `new Promise<never>(() => {})` in a named `blockForever()` helper so a future maintainer doesn't read the bare expression as a never-resolving-promise bug. - auth.ts (BRQQd): rewrite the comment about HTAB BWS — clarify that the scheme→credentials separator is `1*SP` per RFC 9110 §11.6.2, and HTAB is only accepted in the BWS *after* the SP. `Bearer\t<token>` (pure HTAB) is intentionally rejected. - types.ts + qwen-serve-protocol.md (BRQQf): document `modelServices: []` is always empty in Stage 1 so SDK consumers don't build off it. - qwen-serve.md (BRQQl + BRQQm): add operator note about subscribing to /events BEFORE posting modelServiceId on attach (otherwise the model_switch_failed event is missed). Document the four-layer load cap stack near --max-sessions so operators can size the related knobs together. - sdk index (BRSCv): drop the historical `Daemon`-prefixed type aliases (`DaemonPromptRequest` / `DaemonSubscribeOptions`) for consistency with the other un-prefixed daemon-type exports. SDK is Stage-1-experimental with no shipping consumers. * fix(sdk): close 1 review thread — sse parseFrame must not drop frames whose first line is a comment/retry (BRgq-) Per the EventSource spec, comment lines (`:` prefix) and `retry:` are line-level fields, not frame-level. The previous early return at the top of `parseFrame` dropped the entire frame when its first line was a comment or retry directive — meaning an intermediary that prepends `: keep-alive` or `retry: 5000` to every frame would cause the embedded `data:` payload to be silently lost. Removed the `startsWith` guard. The line-level `data:` collection loop already produces an empty `dataLines` array for pure-comment / pure-retry frames, so the existing `if (dataLines.length === 0) return undefined` branch still skips them — without dropping real events that just happen to be preceded by a comment line. Existing test still pins the standalone-comment / standalone-retry behavior; new test pins the leading-comment + data-line case. * docs(sdk): close 1 review thread — sse MAX_BUF_CHARS comment was overpromising byte-equivalence (BRker) The previous wording suggested "one code unit ≈ one byte" for mostly-ASCII content, then qualified it with mixed BMP / supplementary caveats. Reviewer flagged that JS string.length isn't a reliable byte proxy in either direction — engine string representation (V8 Latin-1 path vs UTF-16) makes the actual memory cost vary in ways the comment didn't capture cleanly. Rewrote to state plainly: cap measures code units, not bytes; intent is "stop runaway non-SSE bodies", not exact memory accounting; byte-precise bounds belong at a front proxy. Threshold and code unchanged — only the comment. * fix(serve): close 7 review threads — atomic write, read-size cap, force-exit on 2nd signal, doc fixes - httpAcpBridge.writeTextFile (BSA0D): atomic write-then-rename via `<path>.<pid>.<ts>.tmp` + `fs.rename`. Closes the SIGKILL-mid-write truncation hole. Tmp file lives in the target's directory so the rename can't cross filesystem boundaries; cleaned up on rename failure. - httpAcpBridge.readTextFile (BSA0E): `fs.stat` pre-check rejects files past 100 MiB so a `{ line: 1, limit: 10 }` against a 500 MB log doesn't allocate 500 MB of RSS just to return 10 lines. - runQwenServe SIGINT/SIGTERM (BSA0K): second signal during drain forces `process.exit(1)` with a stderr message instead of silently no-oping. Standard daemon behavior — `^C^C` works. - commands/serve --hostname help text (BRqFe): now mentions the full loopback set (127.0.0.1, localhost, ::1, [::1]) so IPv6 users aren't misled into thinking ::1 needs a token. - runQwenServe boot-refusal error (BRqFy): same correction — error message now lists all loopback aliases the operator can rebind to. - httpAcpBridge withTimeout doc (BSA0C): explicit Stage 2 follow-up marker for the modelSwitchTimedOut / model_switch_late_success observability gap (already a known limitation). - server.errorPayload (BSA0G): documented the multi-tenant info-leak trade-off (Stage 1 single-user/small-team trust model accepts verbatim ACP error data) and pointed to a Stage 2 --redact-errors follow-up. - 2 new tests: writeTextFile leaves no tmp turd; readTextFile rejects 200 MiB sparse file via the size cap. * fix(sdk): close 1 review thread — sse parseFrame must validate optional `id` (BSP1-) The previous shape guard only validated `v === 1` and `type: string`, leaving `DaemonEvent.id: number | undefined` unchecked. A misbehaving proxy emitting `data: {"id":"1","v":1,"type":"x",...}` would survive the cast and break consumer resume logic — Last-Event-ID resume does numeric comparisons against the monotonic counter, and a string id silently corrupts that math. Reject the frame entirely when `id` is present but not a finite safe integer (`Number.isSafeInteger`). Negative integers and missing-id both still pass; the daemon never emits negative ids in practice but the guard's responsibility is the type-cast contract, not the daemon's id-allocation policy. New test covers: string id, float id, > MAX_SAFE_INTEGER id (all rejected); negative-id, no-id, plain integer (all pass). * docs(serve): Stage 1.5 markers from chiga0 follow-up architecture review (#3889 c4427773706) chiga0's follow-up review explicitly states "None of the findings here block Stage 1. That holds." All 6 findings are Stage 1.5 convergence work for when downstream consumers attach. None require code changes for this PR. Adding inline FIXME(stage-1.5) markers at the natural pivot points so the future refactor has clear breadcrumbs back to the audit comment, instead of Stage 1.5 implementers having to re-discover the convergence story: - types.ts STAGE1_FEATURES → finding 5 (capability registry + extMethod HTTP route). - eventBus.ts EventBus class → finding 2 (lift to packages/event-bus, multi-consumer subscribe). - httpAcpBridge.ts BridgeClient.requestPermission → finding 3 (PermissionMediator + policy plugin point; closes prior chiga0 Risk 2 too). - httpAcpBridge.ts BridgeOptions → findings 1 + 4 (split into AcpChannel + Transport packages; thread FileSystemService through BridgeOptions). No behavior change. Each marker links to the audit comment for traceability. * docs(serve): tighten Stage 1 scope framing + durability + Stage 1.5 must-haves (#3889 c4427875644) chiga0's third review walks three downstream-consumer scenarios (IM bot, mobile companion, IDE extension) against Stage 1's runtime guarantees. The bottom-line concern is framing: the PR body promises "real workloads" but the protocol surface is sized for demo / single-user / never-crashes. Reviewer offers two paths — tighten the framing or add 7 must-haves to Stage 1.5. Author classifies all 10 must-haves as Stage 1.5/2, none as Stage 1 changes. In-scope action for this PR (doc-only, no behavior change): - `docs/users/qwen-serve.md` "Status" block: explicit scope-honesty note — Stage 1 is sized for prototyping clients + local single-user/small-team. Production-grade multi-client / mobile / flaky-network workloads need Stage 1.5+ guarantees. - New "Durability model" section spelling out sessions-are-ephemeral (closes must-have 10): no resume on child crash / daemon restart, ring-overflow on long disconnects, writeTextFile atomic across crash but not across restart. - New "Stage 1.5+ runtime guarantees" section listing the 10 must-haves (blockers 1-3, reliability 4-7, ergonomics 8-10) with a link back to the audit comment for traceability. - `httpAcpBridge.ts` BridgeOptions.sessionScope: FIXME(stage-1.5) marker referencing must-have 1 (per-request override), since this is the most prominent client-facing lock-in risk. No code behavior changes — this is roadmap commentary surfaced into the artifacts where downstream integrators will look (user docs + code pivot points). * fix(serve): close 2 correctness findings from tanzhenxin review Two bugs surfaced in the CHANGES_REQUESTED review: Issue 1 — `--max-connections 0` silently bricks the daemon on Node 22: - Docs say "Set to 0 to disable" and the code did `server.maxConnections = opts.maxConnections ?? 256`, but on Node 22.15.0 setting `server.maxConnections = 0` makes the listener refuse EVERY connection (every fetch → SocketError other side closed). The operator following the documented disable path got a daemon that boots cleanly, logs "listening on …", and then silently rejects health/session/SSE. - Fix: treat 0 / Infinity / non-finite as "leave the property unset" (Node's default = unlimited at this layer). Reviewer verified the Node 22 quirk; verified locally that 100 still binds the cap, 0 and Infinity now both accept connections. Issue 2 — Orphan agent child when both coalesced spawnOrAttach callers disconnect: - The BQ9tV `attachCount` race guard is monotonic. Once B's `spawnOrAttach` bumps it (synchronously, before the route handler can see `!res.writable`), the spawn-owner A's disconnect-reaper sees attachCount > 0 and skips the reap — permanently. If B then also disconnects, neither A nor B's route handler does anything, and the agent child stays alive with no client knowing the id. - Fix: add `bridge.detachClient(sessionId)` that decrements attachCount and reaps iff (attachCount == 0 && subscriberCount == 0). Server's `POST /session` handler calls it on the `!res.writable && session.attached === true` branch (symmetric to the existing spawn-owner-disconnect reap). - Subscriber-count check prevents reaping when a third client C is already on SSE — `detachClient` only fires when the session has no live consumers at all. 2 new tests for issue 1 (max-connections 0 + Infinity still accept connections; 100 still binds as supplied). 2 new tests for issue 2 (detach reaps when alone; detach preserves when SSE subscriber exists). fakeBridge updated with the new method. * fix(serve): close 3 review threads — maxConnections NaN/negative validation + doc fix + close-contract honesty - runQwenServe maxConnections validation (BUF9-): NaN / negative values previously slipped through `cap > 0 && Number.isFinite(cap)` to "leave unset = unlimited", silently fail-OPEN on a CLI typo and weakening the DoS / FD-exhaustion guard. Now throw TypeError upfront (before `app.listen()`) so a malformed cap fails the `runQwenServe` promise instead of escaping as an uncaught exception from the listen callback. - types.ts maxConnections doc (BUb7C): comment said "Node treats 0 as unlimited" but the runtime fix treats 0 as a sentinel and leaves `server.maxConnections` unset (Node 22 quirk). Updated to match. - runQwenServe close()/force-timeout (BUb7h): the 100ms eager `setTimeout(() => finish(), 100)` after `closeAllConnections()` resolved the close promise WITHOUT waiting for `server.close()`'s callback — breaking the "fully closed" contract. Now: force-close just accelerates `server.close` by killing sockets; we still wait on the close callback. A secondary 2s deadline handles the pathological "server.close never fires" case (kernel-stuck socket) with a logged warning, so shutdown stays bounded. * docs(serve): close 8 review threads — code-comment clarity + 3 new Stage 1 known gaps 8 threads in a single Claude Opus 4.7 review pass — 4 duplicate existing chiga0 finding FIXME markers, 1 code-comment clarity, 3 real new doc-worthy Stage 1 known gaps. Code clarity (BUy4U): - The shutdown re-check at doSpawn (`if (shuttingDown) { kill; throw }`) is the LOAD-BEARING correctness contract, not a band-aid as the reviewer framed it. Updated comment to explain: shutdown() runs tear-down in parallel with awaiting `inFlightSpawns` (faster fan-out); the re-check catches spawns whose `newSession` returns AFTER the flag flipped. The alternative — await all inflight to settle BEFORE snapshotting byId — is cleaner to reason about but serializes shutdown by up to `initTimeoutMs` (10s) before any live session starts tearing down. Documented the trade-off. New Stage 1 known gaps in docs/users/qwen-serve.md threat model: - BUy4H (permission auth daemon-global): cross-session vote risk acceptable under Stage 1 single-user / small-team trust model; Stage 1.5 will scope to `POST /session/:id/permission/:requestId` + session-scoped pending map + per-client identity (closes must-have #3 from the downstream review). - BUy4L (10 MB body limit on /prompt): multimodal content past 10 MB hits a cliff; workaround via path reference; Stage 1.5 accepts chunked encoding. - BUy4e (CORS deny blocks `packages/webui`): document explicit deployment options (Electron/Tauri shell, same-origin reverse proxy); Stage 1.5 adds `--allow-origin <pattern>` for opt-in named frontends. Already-marked duplicates (BUy4O, BUy4P, BUy4X, BUy4b) — covered by existing `FIXME(stage-1.5, chiga0 finding N)` / `FIXME(stage-2)` markers from prior rounds. * fix(serve): close 1 review thread — catch --hostname localhost:4170 typo upfront (BU-sh) The previous code path for unbracketed `host:port` typos went: 1. Loopback check fails (`localhost:4170` doesn't match the loopback set after lowercase normalization). 2. Throw "Refusing to bind localhost:4170:0 without a bearer token" — misleading because the operator's real bug is the colon in the hostname, not the missing token. Alternative path if a token IS supplied: hostname flows through to `formatHostForUrl` which sees the `:` and treats as IPv6, wrapping to `[localhost:4170]:port` in the printed URL. Then `app.listen()` fails with ENOTFOUND. Triple-unhelpful failure mode. Fix: catch the typo BEFORE the loopback/token check. Unbracketed input with exactly one `:` is unambiguously the host:port shape — raw IPv6 literals always have ≥2 colons (shortest is `::`), and bracketed IPv6 is handled by its own form check below. Error message suggests the corrected form (`--hostname localhost --port 4170`). * docs(serve): two new Stage 1 scope boundaries (option A + option iii) from LaZzyMan reviews LaZzyMan's two-part review surfaced two structural framing concerns distinct from the chiga0 roadmap items. Neither requires code changes in this PR — they want explicit scope honesty in the user docs: 1. TUI super-client framing (option A from the review): TUI UI is strictly larger than the wire protocol. The ~15 Ink dialogs and `local-jsx` slash commands are local-only; mutating commands like `/approval-mode`, `/memory`, `/mcp`, `/agents`, `/tools`, `/auth`, `/init` change agent behavior but emit no wire event. Documenting remote clients as sharing the agent↔user conversation axis only, NOT the full TUI session state. Implementers told to re-fetch state on reconnect, not rely on incremental events. 2. N parallel sessions cost N× (option iii from the comment): the "1 daemon = 1 session" axiom means N concurrent sessions on one workspace = N daemons with zero resource sharing. Concrete cost table at N=5 (~1.5-2.5 GB RSS, 15 MCP processes, 5× OAuth refresh) so users hit the wall with eyes open. Won't-fix on the main-line Stage 1/1.5/2 roadmap; alternatives (#3803 §21 Path A/B, in-project sidecars) materially change the architecture in ways we won't commit to mid-Stage-1. Peer-agent comparison noted (Cursor / Continue / Claude Code / OpenCode / Gemini CLI all do single-process multi-session). Both choices are intentionally the less-ambitious option; the substantive alternative (option B for taxonomy, option i/ii for N:1) moves to #3803 if real-usage data ever justifies it. * docs(serve): clarify option-A across Mode 1 (headless) vs Mode 2 (TUI co-host) Previous wording treated "TUI is a super-client" as universal truth. But Stage 1's actual shipping configuration is HEADLESS — no TUI shell runs inside the daemon — and in that mode the slash commands listed (`/approval-mode`, `/memory`, `/mcp`, `/agents`, `/tools`, `/auth`, `/init`) simply don't exist. Session state is boot-time- frozen from settings + disk, with only `/model` mutable via HTTP. Restructured the section to split the consequences: - **Mode 1 (headless `qwen serve`, this PR)**: no TUI exists; session state is boot-time-frozen + `model_switched` over HTTP; remote clients see the FULL session state; no drift possible. - **Mode 2 (Stage 1.5 `qwen --serve` co-hosted TUI, future)**: TUI exists alongside remote clients; TUI slash commands mutate session state with no wire events; remote clients see a strict subset; drift possible — re-fetch state on reconnect. The original "super-client" framing applies cleanly only to Mode 2. Mode 1 has no asymmetry — same option-A choice, different consequences. * fix(serve,sdk): close 12 review threads — 6 critical bugs + 6 follow-ups Six critical correctness fixes from the latest review pass: - httpAcpBridge.readTextFile (BX8YO): reject non-regular files via `stats.isFile()`. Char devices / FIFOs / procfs entries report `size: 0` but stream unbounded data; the 100 MiB cap wasn't enough. New `describeStatKind()` helper for human-readable error message ("named pipe (FIFO)" / "character device" / etc.). - httpAcpBridge.writeTextFile (BX8Yp + BX9_h): temp filename now includes randomUUID + exclusive flag `wx`. PID + Date.now() alone collides under concurrent writes within the same ms (sessionScope: 'thread' or coalesced spawns on same workspace). Exclusive mode fails fast on any residual collision instead of silent overwrite. - httpAcpBridge.writeTextFile (BX8Yw): resolve via `fs.realpath` before write-then-rename so symlinks are preserved. Pre-fix rename replaced the symlink with a regular file, leaving the real target unchanged while the write appeared successful. Test added covering both regular targets and symlink targets. - server.parseLastEventId (BX9_I): log a stderr breadcrumb when rejecting a non-empty non-decimal Last-Event-ID header. Pre-fix, clients with a malformed resume header silently resumed from 0 and lost every event buffered during the disconnect with zero evidence in logs. - httpAcpBridge channel.exited (BX9_P): thread {exitCode, signalCode} from the spawn factory through `session_died` event payload. Operators triaging a crash can now read the cause from the SSE frame instead of grepping daemon stderr for the child's pid. - httpAcpBridge spawnOrAttach in-flight coalesce path (BX9_U): defensive re-check that `byId.get()` is still defined after attachCount++ — if a concurrent kill tore down the entry, throw `SessionNotFoundError` instead of returning `attached: true` with a zombie sessionId. Six follow-ups in the same diff: - httpAcpBridge attachCount comment (BVryk + BWGSL): outdated "monotonic, we never decrement" claim — detachClient() now decrements. Comment rewritten to state the actual invariant ("reflects clients whose response was written or is about to be"). - runQwenServe.close() contract (BV-qW): bridge.shutdown errors are now propagated through the close promise (was: silently caught + resolved success). onSignal exits 1 instead of 0 when teardown fails. Server.close error takes precedence; bridge error is the fallback. - sdk sse parseFrame id guard (BX8Y1): require id >= 1 (was: any safe integer including negative). The daemon's Last-Event-ID parser only accepts non-negative decimals and EventBus emits ids starting at 1; negative ids on the wire diverge from resume math. Existing test updated. - runQwenServe server error listener (BX9_i): swap `server.once('error', reject)` for a persistent `server.on('error', log)` after listening. Pre-fix, a post-boot error (EMFILE etc.) was unhandled and crashed the daemon. Tests: +2 for BX8YO (FIFO) and BX8Yw (symlink preserve). Test infrastructure updated for the new `channel.exited` Promise<ExitInfo | undefined> signature. * fix(serve,sdk): close 4 more review threads — frame-scan perf + publish contract + AbortError narrowing + cross-module doc - sse consumeFrames perf (BX9_a): short-circuit the LF path first. In the common LF-only case the CRLF scan was traversing the entire remaining buffer for nothing; now CRLF is only scanned when LF is absent or potentially appears later than a CRLF separator (mixed-encoding edge). - EventBus.publish contract (BX9_p): explicit JSDoc says publish NEVER THROWS (closed-bus returns undefined, subscriber-enqueue errors caught internally). Historical try/catch wrappers in httpAcpBridge.ts are defense-in-depth, not load-bearing; new callers should not add them. - canonicalizeWorkspace doc (BX9_q): elevate the cross-module contract from "undocumented" to explicit — config.ts / settings.ts / sandbox.ts / this file all canonicalize the same way for sessionScope: 'single' re-attach. A divergence silently forks sessions per spelling. The Stage 1.5 @qwen-code/acp-bridge lift (chiga0 finding 1) is the natural place to extract a shared primitive; until then, any change to those modules needs a matching change here. - POST /session/:id/prompt AbortError swallow (BX9_k): narrow the swallow to only fire when `abort.signal.aborted` is true. The previous blanket `err.name === 'AbortError'` would also silently drop AbortErrors raised internally by the bridge (e.g. child process aborting mid-prompt), leaving the client with no response and no log trace. * docs(serve): correct N:1 framing — qwen-code's ACP agent natively supports multi-session Maintainer feedback (verified against the code): the ACP agent in packages/cli/src/acp-integration/acpAgent.ts:194 has `private sessions: Map<string, Session>` — one `qwen --acp` child natively hosts multiple sessions, and yiliang114's VSCode plugin already uses this pattern. The earlier "qwen-code is the only entry treating no multi-session resource sharing as a feature" framing (from the LaZzyMan reply + docs) was wrong. Stage 1 bridge in this PR doesn't yet leverage that capability — it spawns one `qwen --acp` child per session for simplicity (easier debugging, no cross-session interference during initial stabilization). That's a bridge-side design choice, not an ACP limitation. Revised docs/users/qwen-serve.md: - "N parallel sessions cost N×" section now distinguishes Stage 1 bridge (current N× cost) from Stage 1.5 bridge (multi-session per child, ~1/5th the cost at N=5). Cost table extended with the Stage 1.5 column. No more "won't fix on main-line roadmap" framing — the fix is a bridge refactor that pairs naturally with chiga0 finding 1 (`@qwen-code/acp-bridge` package lift), NOT the #3803 §21 Path A/B/C intra-daemon multi-session workstream (qwen-code already does that at the agent layer). - Status block's "Scope honesty" note: removed the implicit permanent-cost framing; replaced with explicit "Stage 1 bridge pays N×; Stage 1.5 refactor closes the gap" pointer. - Peer-agent comparison rewritten: qwen-code's *agent* matches Cursor / Continue / Claude Code / OpenCode / Gemini CLI on single-process multi-session; the bridge is the artifact. `httpAcpBridge.ts:doSpawn`: inline `FIXME(stage-1.5)` marker explaining the refactor (keep one child per workspace, call `connection.newSession()` multiple times on the same channel), with the link to `acpAgent.ts:194` so a future maintainer doesn't re-derive the discovery. * feat(serve): Stage 1 bridge now multiplexes sessions on one qwen --acp child per workspace Per LaZzyMan / tanzhenxin reviews + maintainer feedback verified against `packages/cli/src/acp-integration/acpAgent.ts:194` (the agent's `private sessions: Map<string, Session>`): qwen-code's ACP agent natively supports multi-session in one child process. The Stage 1 bridge previously spawned one child per session for simplicity, paying N× memory / OAuth / file-cache cost. Now refactored to leverage the agent's existing multi-session capability — one `qwen --acp` child per workspace, N sessions share it via `connection.newSession({cwd, mcpServers})`. Cost at N=5 sessions on same workspace: - Before: 300-500 MB RSS (5 children), 5× OAuth refresh, 5× file cache, 5× CLAUDE.md parse, 5× cold start - After: 60-100 MB RSS (one child), one OAuth path, shared FileReadCache, parsed once, <200ms cold start after first session Architecture changes: - New `ChannelInfo` type holds the shared channel + connection + BridgeClient + the set of session ids multiplexing on it. - New `byWorkspaceChannel: Map<workspace, ChannelInfo>` + new `inFlightChannelSpawns` coalesce-map for concurrent channel creation. - New `getOrCreateChannel(workspaceKey)` helper: reuse existing channel or spawn one (with `initialize` happening exactly once per channel, not once per session). Coalesced via `inFlightChannelSpawns` so two parallel callers don't both spawn. - `doSpawn` now calls `getOrCreateChannel` + `connection.newSession` separately (was: spawn+initialize+newSession together per session). - `BridgeClient` updated: `resolveEntry(sessionId?)` dispatches by the sessionId ACP carries in each request — one BridgeClient now serves all sessions on its channel. `sessionUpdate`, `requestPermission`, etc. all pass `params.sessionId`. - `channel.exited` cleanup moved into `getOrCreateChannel` and now tears down ALL sessions on the channel (not one). Each session gets its own `session_died` event so SSE subscribers learn the bad news on their own stream. - `killSession` now removes session from `channelInfo.sessionIds` and kills the channel ONLY when its sessionIds set drops to zero. Other sessions on the same channel keep running. - `shutdown` tears down channels (the deduplicated set) and awaits both inFlightSpawns and inFlightChannelSpawns. Cross-workspace channel sharing intentionally NOT done — `acpAgent.ts: 601 (this.settings = loadSettings(cwd))` reloads settings on each newSession call with a different cwd, so different workspaces in one child would step on each other. One channel per workspace is the safe scope. MCP server children stay per-session for now (each session can have different mcpServers config). Stage 1.5 follow-up: refcount MCP children by (workspace, config-hash) so identical configs share. Tests: - Updated `spawns fresh per call under sessionScope:thread` → now expects `handles.length === 1` (channel reused) but `sessionCount === 2` (distinct sessions). - New: `Stage 1.5 multi-session: N sessions on same workspace share ONE channel` (5 sessions, 1 factoryCalls). - New: `Stage 1.5: killSession on one of N sessions does NOT kill the shared channel` (kill 2 of 3, channel still alive; kill 3rd, channel killed). - New: `Stage 1.5: channel.exited tears down ALL multiplexed sessions` (each gets its own session_died). - FakeAgent.newSession suffixes call-count so multiple newSession calls on the same channel return distinct ids (matches real ACP behavior). Docs: - `docs/users/qwen-serve.md` N:1 section rewritten — no longer "Stage 1 pays N×, Stage 1.5 fixes". Cost table reflects current shared-channel architecture; MCP refcount called out as the one remaining Stage 1.5 follow-up; "1 daemon = 1 session" framing removed from related sections. * fix(serve,sdk): close 12 review threads — 6 critical bugs + 6 follow-ups Critical fixes: - server.ts safeBody() helper (BZ9uv/va/vs/wD + Bd10m + Bd1zz): prototype-pollution sanitization at the body-spread boundary. `__proto__` / `constructor` / `prototype` keys are stripped and the result is an Object.create(null) target. Replaces 5 sites of copy-pasted `typeof req.body === 'object'...` preamble + makes the `...(body as object)` spread sites safe. - httpAcpBridge requestPermission (Bd1yh): per-request wall-clock deadline (default 5 min, configurable via `BridgeOptions.permissionResponseTimeoutMs`). Without this, an agent calling requestPermission with no SSE subscriber connected would hang the per-session FIFO forever. After deadline, resolve as cancelled + log stderr warning. - httpAcpBridge requestPermission (Bd1z5): per-session pending permissions cap (default 64, configurable via `BridgeOptions.maxPendingPermissionsPerSession`). New requests past the cap resolve as cancelled with stderr warning. Prevents a chatty agent from growing pendingPermissions unboundedly. - runQwenServe onSignal double-signal force-exit (Bd1y6): new `bridge.killAllSync()` + `AcpChannel.killSync()` method synchronously SIGKILLs every live qwen --acp child BEFORE `process.exit(1)`. Previously double-Ctrl+C bypassed the async bridge.shutdown() and left children running as orphans. - server.ts SSE subscriber-limit response (Bd1zJ): 429 + Retry-After instead of 200 + stream_error frame. EventSource treats 4xx as terminal (no auto-reconnect); the previous 200+close-stream triggered EventSource's reconnect loop, amplifying the load the limit existed to prevent. - doSpawn ghost sessionId guard (Bd1zc): re-check byId.has() after applyModelServiceId(). The model-switch yields and can race channel.exited; without this, caller got HTTP 200 with a sessionId that 404s on every subsequent request. Follow-ups in the same diff: - sse.ts consumeFrames CRLF scan comment (BcRh_): the comment claimed the CRLF scan was bounded to `[cursor, lf)`, but Node's `indexOf` has no upper bound. Rewrote to describe what the code actually does (scan full remainder; only USE the result if it falls before `lf`). - sse.ts SseFramingError export (Bd10T): typed error class for framing-level failures so SDK consumers can distinguish "upstream isn't SSE" from generic network errors via instanceof check. Re-exported from @qwen-code/sdk. - protocol doc /health auth (Bctum): document the loopback exemption — `/health` doesn't require Authorization on loopback binds even when a token is configured. Matches `createServeApp`'s registration order. Bd1xz (cross-session permission escalation) acknowledged as duplicate of BUy4H — already documented as a known Stage 1 gap under the single-user / small-team trust model; fix is Stage 1.5 must-have #3 (per-client identity + per-session permission scope). Tests: - New: prototype-pollution test verifies `__proto__` spread doesn't pollute `Object.prototype`. - All 70 server + 55 bridge + 16 daemon-sse + 60 DaemonClient tests pass (203 total). `killSync()` stubbed on every inline test channel fake; fake bridge has `killAllSync()`. * fix(sdk): close 2 review threads — consumeFrames CRLF scan now actually bounded (BeFHR + BeFId) Previous attempt at the BX9_a perf optimization left the CRLF scan running over the full remainder of `buf` on every loop iteration where an LF separator existed — only the LF-not-found fallback path was actually bounded. Comments claimed the CRLF scan was restricted to `[cursor, lf)` or "only fires when needed", but Node's `String.indexOf` doesn't accept an end index. Bound the scan via a `buf.slice(cursor, lf)` window before `indexOf` so the assertion is now true: in the common LF-only case we pay one full scan (for LF) plus one bounded scan over the matched frame's bytes (small). * fix(serve): close 3 review threads + Windows test skip — dangling symlink, no-sessionId throw - httpAcpBridge.writeTextFile BfFvO: dangling-symlink case. `fs.realpath` throws ENOENT for a symlink whose target doesn't exist, and the blanket catch silently fell back to writing through the symlink itself — `rename(tmp, params.path)` then replaced the symlink with a regular file, exactly the bug BX8Yw was supposed to fix. Use `fs.readlink` to disambiguate "truly non-existent" from "dangling symlink"; resolve the dangling target manually and write through to it so the symlink stays a symlink. Regression test added. - httpAcpBridge BridgeClient resolveEntry BfFut: defensive throw on no-sessionId ACP call against a multi-session channel. ACP today carries sessionId on every per-session call, but if a future no-sessionId call lands, silently dropping it on a multi-session channel would be invisible. - httpAcpBridge.test.ts BX8YO Windows skip: hard-skip via `process.platform === 'win32'`. Git-Bash etc. ship a `mkfifo` binary that degenerates on Windows (creates a regular file or silently no-ops), making the assertion match the wrong error shape. Linux + macOS coverage is sufficient for a platform- agnostic `!stats.isFile()` check. BfFvW (CRLF scan comment) was already addressed in0a4146a02— the reviewer's diff was against the pre-fix version. * fix(serve): close 6 review threads — 4 critical bugs + 2 doc updates Critical fixes: - httpAcpBridge.doSpawn newSession-failure cleanup (BkwQA): if `connection.newSession()` throws on a freshly-created channel whose sessionIds set is empty, tear the channel down rather than leaking the empty `qwen --acp` child in `byWorkspaceChannel` (invisible to `sessionCount` / `maxSessions`). Channels with other live sessions still survive — only the truly-empty case reaps. - httpAcpBridge.detachClient + killSession tombstone (BkwQP): detachClient no longer reaps live sessions. Scenario: A spawns (attached: false, hasn't opened SSE yet), B attaches (attachCount: 1), B disconnects → previous code reaped A's still-valid session. New behavior: * killSession({ requireZeroAttaches: true }) sets `entry.spawnOwnerWantedKill = true` when it bails on attachCount > 0 (instead of just returning). * detachClient ONLY decrements attachCount. It completes the deferred reap only when (spawnOwnerWantedKill && attachCount === 0 && subscriberCount === 0). * Both-disconnected case still works (reap completes via B's detachClient seeing the tombstone). Spawn-owner-alive case no longer reaps. Existing tanzhenxin-issue-2 test rewritten; new test pins the spawn-owner-alive case. - httpAcpBridge.writeTextFile mode preservation (BkwQW): stat the target before writing; if it exists, chmod the tmp file to the preserved mode (and chown owner/group — best-effort, EPERM ignored for non-root). Previously a 0600 secret/config edit would downgrade to umask-default 0644, exposing contents to other local users. - bridge.respondToPermission option-ID validation (BkwQI): new `InvalidPermissionOptionError` thrown when the voter's `optionId` isn't in the set of options the agent originally offered in the `permission_request` event. PendingPermission now carries `allowedOptionIds`. Server route catches the error → 400 (vs. 404 for unknown requestId). Prevents authenticated clients from forging hidden outcomes like `ProceedAlways*` when the prompt's `hideAlwaysAllow` policy intentionally suppressed them. Doc fixes: - httpAcpBridge top-of-file (BkdCg) + types.ts ServeMode (BkdC8): rewrite the "each session spawns its own qwen --acp child" framing to match the actual Stage 1.5 multi-session-per-channel architecture (one child per workspace, sessions multiplex via `connection.newSession()`). * fix(serve): close 4 review threads — close write-mode race + 2 missing tests + 1 doc - writeTextFile mode-bits race (Blehd): the BkwQW fix preserved mode via `chmod` AFTER `fs.writeFile`, leaving a brief window where a `0600` secret-edit was readable at the directory's umask default (commonly `0644`). Now pass `mode` to writeFile directly so the file is CREATED with the preserved mode atomically via the `open(O_CREAT, mode)` syscall. The post-write `chmod` remains as belt-and-suspenders against a tight operator umask (POSIX `mode & ~umask` could drop bits we wanted preserved). - httpAcpBridge.test.ts: new bridge-level test for the BkwQI `InvalidPermissionOptionError` path (Blehk). Forge a vote with an `optionId` not in the agent-offered set; assert the throw AND that the pending permission survives so a valid vote can still resolve it. - server.test.ts: new route-level test for the BkwQI 400 mapping (Blehl). Fake bridge throws `InvalidPermissionOptionError`; assert response is 400 with `code: 'invalid_option_id'`, `requestId`, and `optionId` in the body. - commands/serve --http-bridge help text (Bk59I): updated to reflect Stage 1.5 multi-session — "one `qwen --acp` child per workspace, with multiple sessions multiplexed via the agent's native `newSession()`" (was: "per-session child"). * fix(sdk): close 1 review thread — parseSseStream abort path catches body-read rejection (BlqF_) Some fetch impls (undici on abort) reject the in-flight `reader.read()` with an AbortError after `reader.cancel()` fires. Pre-fix that rejection bubbled to the consumer's `for await`, contradicting the "abort cancels cleanly" public contract — code that called `controller.abort()` to wind a subscription down saw an unexpected throw on the next iteration. Wrap `reader.read()` in try/catch: - if `signal?.aborted` is true → treat the rejection as clean completion (return from the generator) - otherwise re-throw, so real upstream failures (network drop, unexpected close, malformed body) still reach the consumer Two regression tests pin the guard's scope: signal-aborted mid-stream returns cleanly with the frames received so far; a non-abort `streamController.error(...)` still bubbles via `rejects.toThrow`. * fix(serve): close 1 review thread — eventBus eviction detaches abort listener (BmJT1) Pre-fix: `publish()`'s eviction path deleted the sub from `this.subs` but never invoked `dispose()`, leaving the AbortSignal abort-listener registered in `subscribe()` attached. Because the consumer is by definition stalled (that's what caused the overflow), `next()` / `return()` never fire to detach the listener through the iterator path. Closures over the queue + sub stayed live until the AbortSignal itself went out of scope. Under attack (thousands of opened-then-stalled SSE clients), this amplified into significant heap retention. Fix: store `dispose` on `InternalSub` and invoke `sub.dispose()` from the eviction path. The same closure used by the abort listener / the iterator's `next()`/`return()` cleanup now runs through the eviction path too — idempotent through `disposed` so a post-eviction abort or iterator-return is still safe. Regression test pins the post-eviction abort + publish path producing zero side effects. * fix(serve): close 1 review thread — restore double-Ctrl+C force-kill broken by multi-session refactor (BkUyD) The Bd1y6 design promised a second SIGINT/SIGTERM during graceful drain synchronously SIGKILLs every live agent child via `bridge.killAllSync()` before `process.exit(1)` — the operator- visible "kill it now" path for a wedged child ignoring SIGTERM. The Stage 1.5 multi-session refactor (commit6a170ef8) inadvertently broke this. `shutdown()` snapshots `byWorkspaceChannel` then CLEARS the map BEFORE awaiting the per-child SIGTERM-grace kills (up to ~10s each). If the operator double-taps mid-window, `killAllSync()` snapshotted from the now-empty `byWorkspaceChannel.values()` and silently no-op'd — the for-loop iterated nothing, `process.exit(1)` fired, and any child still inside its SIGTERM grace window was left orphaned with dangling pipes. Exactly the scenario the force-kill path was added to handle. Fix: introduce a separate `liveChannels: Set<ChannelInfo>` as the source of truth for "channels with potentially-alive child processes". Added in `getOrCreateChannel` alongside `byWorkspaceChannel.set(...)`; removed only when `channel.exited` fires (the OS-level "really dead" signal). `killAllSync()` now iterates `liveChannels`, so a mid-shutdown second signal still sees every still-alive child regardless of where the graceful drain currently is. Other paths (`killSession` last-session reap, `channel.exited` crash handler) automatically remove via the same exit-handler hook. Regression test: - Builds two sessions on different workspaces - Replaces each channel's `kill()` with a never-resolving Promise (simulating stuck SIGTERM grace) - Calls `bridge.shutdown()` to enter mid-drain state - Yields twice so shutdown's sync prefix runs (clears byWorkspaceChannel, starts the never-resolving awaits) - Calls `bridge.killAllSync()` — pre-fix this saw an empty `byWorkspaceChannel` and the spy array would have been empty; post-fix both channels' `killSync` is invoked. (tanzhenxin's other observation — channels-package duplicate ACP bridge — is the same architectural concern as chiga0 finding 1+5, already tracked under existing FIXME(stage-1.5) markers. No code change in this commit for that.)
This commit is contained in:
parent
fd53527aad
commit
870bdf2a9d
32 changed files with 12784 additions and 5 deletions
17
README.md
17
README.md
|
|
@ -428,12 +428,13 @@ and adjust it to the context length configured on your local server.
|
|||
|
||||
## Usage
|
||||
|
||||
As an open-source terminal agent, you can use Qwen Code in four primary ways:
|
||||
As an open-source terminal agent, you can use Qwen Code in five primary ways:
|
||||
|
||||
1. Interactive mode (terminal UI)
|
||||
2. Headless mode (scripts, CI)
|
||||
3. IDE integration (VS Code, Zed)
|
||||
4. SDKs (TypeScript, Python, Java)
|
||||
5. Daemon mode — `qwen serve` exposes ACP over HTTP+SSE so multiple clients share one agent (experimental)
|
||||
|
||||
#### Interactive mode
|
||||
|
||||
|
|
@ -461,6 +462,20 @@ Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
|
|||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
|
||||
|
||||
#### Daemon mode (`qwen serve`, experimental)
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen serve
|
||||
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge)
|
||||
```
|
||||
|
||||
Run Qwen Code as a local HTTP daemon so IDE plugins, web UIs, CI scripts and custom CLIs all share **one** agent session over HTTP+SSE — instead of each spawning their own subprocess. Loopback bind has no auth by default (set `QWEN_SERVER_TOKEN` to enable bearer auth even on loopback); remote binds (`--hostname 0.0.0.0`) **require** a token — boot refuses without one. See:
|
||||
|
||||
- [Daemon mode user guide](https://qwenlm.github.io/qwen-code-docs/en/users/qwen-serve)
|
||||
- [HTTP protocol reference](https://qwenlm.github.io/qwen-code-docs/en/developers/qwen-serve-protocol)
|
||||
- [DaemonClient TypeScript quickstart](https://qwenlm.github.io/qwen-code-docs/en/developers/examples/daemon-client-quickstart)
|
||||
|
||||
#### SDKs
|
||||
|
||||
Build on top of Qwen Code with the available SDKs:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default {
|
|||
|
||||
'channel-plugins': 'Channel Plugin Guide',
|
||||
tools: 'Tools',
|
||||
'qwen-serve-protocol': 'qwen serve HTTP protocol',
|
||||
|
||||
examples: {
|
||||
display: 'hidden',
|
||||
|
|
|
|||
199
docs/developers/examples/daemon-client-quickstart.md
Normal file
199
docs/developers/examples/daemon-client-quickstart.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# DaemonClient quickstart (TypeScript)
|
||||
|
||||
A minimal end-to-end example: start a `qwen serve` daemon in another terminal, then drive it from a Node script with the SDK's `DaemonClient`. See also: [Daemon mode user guide](../../users/qwen-serve.md) and [HTTP protocol reference](../qwen-serve-protocol.md).
|
||||
|
||||
## Setup
|
||||
|
||||
In one terminal:
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen serve --port 4170
|
||||
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge)
|
||||
```
|
||||
|
||||
In another:
|
||||
|
||||
```bash
|
||||
npm install @qwen-code/sdk
|
||||
```
|
||||
|
||||
## Hello daemon
|
||||
|
||||
```ts
|
||||
import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk';
|
||||
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://127.0.0.1:4170',
|
||||
// token: process.env.QWEN_SERVER_TOKEN, // required for non-loopback binds
|
||||
});
|
||||
|
||||
// 1. Confirm we can reach the daemon and gate UI on its features.
|
||||
const caps = await client.capabilities();
|
||||
console.log('Daemon features:', caps.features);
|
||||
|
||||
// 2. Spawn-or-attach a session for the current workspace.
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: process.cwd(),
|
||||
});
|
||||
console.log(`session=${session.sessionId} attached=${session.attached}`);
|
||||
|
||||
// 3. Subscribe to the event stream. Pass `lastEventId: 0` so the daemon
|
||||
// replays everything from the session's start — without it, there's
|
||||
// a TOCTOU window between `subscribeEvents()` returning the iterator
|
||||
// and the underlying SSE connection actually opening (one fetch
|
||||
// round-trip), during which a fast-starting agent can emit events
|
||||
// that go into the per-session ring but won't be streamed to a fresh
|
||||
// no-cursor subscriber. `lastEventId: 0` makes the replay buffer
|
||||
// cover that gap (and any reconnect later — see below).
|
||||
const abort = new AbortController();
|
||||
const subscription = (async () => {
|
||||
for await (const event of client.subscribeEvents(session.sessionId, {
|
||||
signal: abort.signal,
|
||||
lastEventId: 0,
|
||||
})) {
|
||||
handleEvent(event);
|
||||
}
|
||||
})();
|
||||
|
||||
// 4. Send a prompt and wait for it to settle. (Order-of-operations
|
||||
// note: even if `prompt()` fires before the SSE handshake
|
||||
// completes, step 3's `lastEventId: 0` guarantees every event
|
||||
// lands in the iterator.)
|
||||
const result = await client.prompt(session.sessionId, {
|
||||
prompt: [{ type: 'text', text: 'Summarize src/main.ts in one sentence.' }],
|
||||
});
|
||||
console.log('stop reason:', result.stopReason);
|
||||
|
||||
// 5. Tear down the subscription so the script can exit.
|
||||
abort.abort();
|
||||
await subscription;
|
||||
|
||||
function handleEvent(event: DaemonEvent): void {
|
||||
switch (event.type) {
|
||||
case 'session_update': {
|
||||
const data = event.data as {
|
||||
sessionUpdate: string;
|
||||
content?: { text?: string };
|
||||
};
|
||||
if (data.sessionUpdate === 'agent_message_chunk' && data.content?.text) {
|
||||
process.stdout.write(data.content.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'permission_request':
|
||||
// See "Voting on permissions" below for first-responder semantics.
|
||||
console.log('\n[needs permission]', event.data);
|
||||
break;
|
||||
case 'permission_resolved':
|
||||
console.log('\n[permission resolved]', event.data);
|
||||
break;
|
||||
case 'session_died':
|
||||
console.error('\n[agent crashed]', event.data);
|
||||
break;
|
||||
default:
|
||||
console.log(`\n[${event.type}]`, event.data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reconnect with `Last-Event-ID`
|
||||
|
||||
If your client process restarts mid-session, replay events you missed:
|
||||
|
||||
```ts
|
||||
let cursor: number | undefined;
|
||||
|
||||
for await (const event of client.subscribeEvents(session.sessionId, {
|
||||
signal: abort.signal,
|
||||
lastEventId: cursor, // resume from after this id; undefined = live only
|
||||
})) {
|
||||
if (typeof event.id === 'number') cursor = event.id;
|
||||
handleEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
The daemon retains the last 4000 events per session in a ring buffer; gaps beyond that window won't be re-deliverable.
|
||||
|
||||
## Voting on permissions
|
||||
|
||||
When the agent asks for permission to run a tool, every connected client sees the `permission_request` event. **First responder wins** — once one client votes, the rest get `404` if they try to vote on the same `requestId`.
|
||||
|
||||
```ts
|
||||
case 'permission_request': {
|
||||
const req = event.data as {
|
||||
requestId: string;
|
||||
options: Array<{ optionId: string; name: string; kind: string }>;
|
||||
};
|
||||
// Pick whichever option you want — `proceed_once`, `allow`, etc.
|
||||
const choice = req.options.find((o) => o.kind === 'allow_once') ?? req.options[0];
|
||||
const accepted = await client.respondToPermission(req.requestId, {
|
||||
outcome: { outcome: 'selected', optionId: choice.optionId },
|
||||
});
|
||||
if (!accepted) {
|
||||
console.log('Another client voted first; nothing to do.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
## Shared-session collaboration
|
||||
|
||||
Two clients pointed at the same daemon and `cwd` end up on the same session:
|
||||
|
||||
```ts
|
||||
// Client A (e.g. an IDE plugin)
|
||||
const a = await clientA.createOrAttachSession({ workspaceCwd: '/work/repo' });
|
||||
console.log(a.attached); // false — A spawned the agent
|
||||
|
||||
// Client B (e.g. a web UI on the same machine)
|
||||
const b = await clientB.createOrAttachSession({ workspaceCwd: '/work/repo' });
|
||||
console.log(b.attached); // true — B joined A's session
|
||||
console.log(a.sessionId === b.sessionId); // true
|
||||
```
|
||||
|
||||
Both clients see the same `session_update` / `permission_request` stream. Either can send a prompt; they FIFO-queue per the agent's "one active prompt per session" guarantee.
|
||||
|
||||
## Authentication
|
||||
|
||||
When the daemon was started with a token (any non-loopback bind requires one):
|
||||
|
||||
```ts
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'https://your-host:4170',
|
||||
token: process.env.QWEN_SERVER_TOKEN,
|
||||
});
|
||||
```
|
||||
|
||||
Wrong / missing tokens return `401` with a uniform body — the SDK throws `DaemonHttpError` on any 4xx/5xx from a route handler.
|
||||
|
||||
```ts
|
||||
import { DaemonHttpError } from '@qwen-code/sdk';
|
||||
|
||||
try {
|
||||
await client.health();
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonHttpError) {
|
||||
console.error(`Daemon error ${err.status}:`, err.body);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cancel an in-flight prompt
|
||||
|
||||
If your user hits Esc:
|
||||
|
||||
```ts
|
||||
await client.cancel(session.sessionId);
|
||||
// In the event stream you'll see the prompt resolve with stopReason: "cancelled"
|
||||
```
|
||||
|
||||
Cancel only winds down the **active** prompt — anything you'd already POSTed and that's still queued behind it will continue to run. (See protocol reference for the rationale.)
|
||||
|
||||
## What's next
|
||||
|
||||
- [HTTP protocol reference](../qwen-serve-protocol.md) — full route spec with status codes
|
||||
- [Daemon mode user guide](../../users/qwen-serve.md) — operator-side docs
|
||||
- Source: `packages/sdk-typescript/src/daemon/`
|
||||
362
docs/developers/qwen-serve-protocol.md
Normal file
362
docs/developers/qwen-serve-protocol.md
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
# `qwen serve` HTTP protocol reference
|
||||
|
||||
Stage 1 of the [qwen-code daemon design](https://github.com/QwenLM/qwen-code/issues/3803). All routes live under the daemon's base URL (default `http://127.0.0.1:4170`).
|
||||
|
||||
## Authentication
|
||||
|
||||
When the daemon was started with `--token` or `QWEN_SERVER_TOKEN`, **every route except `/health` on loopback binds** must carry:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Without a configured token (loopback dev default) the header is optional. Token comparison is constant-time. 401 responses are uniform across `missing header` / `wrong scheme` / `wrong token`.
|
||||
|
||||
**`/health` exemption** (Bctum): on loopback binds (`127.0.0.1` / `localhost` / `::1` / `[::1]`) `/health` is registered BEFORE the bearer middleware, so liveness probes inside the pod don't need to carry the token even when the daemon was started with `--token`. Non-loopback binds (`--hostname 0.0.0.0` etc.) gate `/health` behind the bearer like every other route — see the [`GET /health`](#get-health) section for the rationale.
|
||||
|
||||
## Common error shape
|
||||
|
||||
5xx responses carry the original error's `code` and `data` when present (JSON-RPC style — the ACP SDK forwards `{code, message, data}` from the agent):
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Internal error",
|
||||
"code": -32000,
|
||||
"data": { "reason": "model quota exceeded" }
|
||||
}
|
||||
```
|
||||
|
||||
Malformed JSON in a request body returns:
|
||||
|
||||
```json
|
||||
{ "error": "Invalid JSON in request body" }
|
||||
```
|
||||
|
||||
with status `400`.
|
||||
|
||||
`SessionNotFoundError` for an unknown session id returns:
|
||||
|
||||
```json
|
||||
{ "error": "No session with id \"<sid>\"", "sessionId": "<sid>" }
|
||||
```
|
||||
|
||||
with status `404`.
|
||||
|
||||
`POST /session` past the daemon's `--max-sessions` cap returns `503` with a `Retry-After: 5` header and:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Session limit reached (20)",
|
||||
"code": "session_limit_exceeded",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
Attaches to existing sessions are NOT counted toward the cap, so an idle daemon's reconnects keep working even when at-capacity.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Every Stage 1 daemon advertises 9 feature tags. Clients **must** gate UI off `features`, not off `mode` (per design §10).
|
||||
|
||||
```
|
||||
['health', 'capabilities', 'session_create', 'session_list',
|
||||
'session_prompt', 'session_cancel', 'session_events',
|
||||
'session_set_model', 'permission_vote']
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
> **Stage 1 limitation — no `DELETE /session/:id`.** Sessions live until
|
||||
> the agent child crashes (`session_died`), the daemon process exits, or
|
||||
> a server-side `killSession` (used internally by orphan-cleanup) fires.
|
||||
> HTTP clients have no explicit "close one session" route in Stage 1.
|
||||
> An explicit `DELETE /session/:id` is on the Stage 2 polish list.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Liveness probe. Default form returns `200 {"status":"ok"}` if the listener is up — cheap, no bridge access, suitable for high-frequency k8s/Compose liveness probes.
|
||||
|
||||
Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that exposes bridge **counters** (informational only, not a true liveness check):
|
||||
|
||||
```json
|
||||
{ "status": "ok", "sessions": 3, "pendingPermissions": 1 }
|
||||
```
|
||||
|
||||
> ⚠️ The deep probe is **informational**, not a real liveness verification. It reads counter accessors (`bridge.sessionCount`, `bridge.pendingPermissionCount`) which are simple Map-size getters; they don't ping individual child processes / channels and so won't detect a wedged-but-still-counted session. Use it for capacity dashboards (current concurrency vs. `--max-sessions`, queue depth) rather than as the trigger for "pull this daemon out of rotation". A `503 {"status":"degraded"}` response is theoretically possible if a custom bridge implementation's getters throw, but the real bridge's getters never do — under normal operation the deep probe always returns 200. For real liveness, rely on whether the listener accepts a TCP connection at all (i.e. the default `/health` without `?deep`).
|
||||
|
||||
**Auth:** required **only on non-loopback binds**. On loopback (`127.0.0.1`, `::1`, `[::1]`) `/health` is registered before the bearer middleware so k8s/Compose probes inside the pod don't need to carry the token. On non-loopback (`--hostname 0.0.0.0` etc.) the route is registered after the bearer middleware and returns 401 without a valid token — otherwise an unauthenticated caller could probe arbitrary addresses to confirm a `qwen serve` exists, a low-severity info leak that combines poorly with port scanning. CORS deny + Host allowlist still apply on the loopback exemption.
|
||||
|
||||
### `GET /capabilities`
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"mode": "http-bridge",
|
||||
"features": ["health", "capabilities", "..."],
|
||||
"modelServices": []
|
||||
}
|
||||
```
|
||||
|
||||
Stable contract: when `v` increments the frame layout has changed in a backwards-incompatible way.
|
||||
|
||||
> **`modelServices` is always `[]` in Stage 1.** The agent uses its single default model service and doesn't enumerate it over the wire. Stage 2 will populate this from registered model adapters so SDK clients can build service-pickers; until then, do NOT rely on this field being non-empty.
|
||||
|
||||
### `POST /session`
|
||||
|
||||
Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default).
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"cwd": "/absolute/path/to/workspace",
|
||||
"modelServiceId": "qwen-prod"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `cwd` | yes | Absolute path. Relative paths return `400`. Workspace paths are canonicalized via `realpathSync.native` (with a resolve-only fallback for non-existent paths) so case-insensitive filesystems don't fork sessions per spelling. |
|
||||
| `modelServiceId` | no | Selects which configured _model service_ the agent will route through (the back-end provider — Alibaba ModelStudio, OpenRouter, etc). If omitted the agent uses its default. If the workspace already has a session, this calls `setSessionModel` on the existing one and broadcasts `model_switched`. Distinct from `modelId` on `POST /session/:id/model`, which selects the model **within** an already-bound service. The `modelServices` array on `/capabilities` is reserved for advertising configured services; in Stage 1 it is always `[]` (the agent's default service is used and not enumerated over HTTP). |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "<uuid>",
|
||||
"workspaceCwd": "/canonical/path",
|
||||
"attached": false
|
||||
}
|
||||
```
|
||||
|
||||
`attached: true` means a session for that workspace already existed and you're now sharing it.
|
||||
|
||||
Concurrent `POST /session` calls for the same workspace are **coalesced** to one spawn — both callers get the same `sessionId`, exactly one reports `attached: false`. If the underlying spawn fails (init timeout, malformed agent output, OOM), **all coalesced callers receive the same error** — the in-flight slot is cleared so a follow-up call can retry from scratch.
|
||||
|
||||
> ⚠️ **`modelServiceId` rejection on a fresh session is silent on the
|
||||
> HTTP response.** A bad `modelServiceId` (typo, unconfigured service)
|
||||
> does NOT 500 the create — the session stays operational on the
|
||||
> agent's default model so the caller still gets a `sessionId` they
|
||||
> can retry the model switch against (via `POST /session/:id/model`).
|
||||
> The visible failure signal is a `model_switch_failed` event on the
|
||||
> session's SSE stream, fired between the spawn handshake and your
|
||||
> first subscribe. **Subscribers that need to observe this event
|
||||
> should pass `Last-Event-ID: 0` on their first `GET
|
||||
/session/:id/events`** to replay from the ring's oldest available
|
||||
> event (covers the spawn-time `model_switch_failed` even if the
|
||||
> subscribe lands a few ms after the create response).
|
||||
|
||||
### `GET /workspace/:id/sessions`
|
||||
|
||||
List all live sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd).
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [{ "sessionId": "<uuid>", "workspaceCwd": "/canonical/path" }]
|
||||
}
|
||||
```
|
||||
|
||||
Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle.
|
||||
|
||||
### `POST /session/:id/prompt`
|
||||
|
||||
Forward a prompt to the agent. Multi-prompt callers FIFO-queue per session (ACP guarantees one active prompt per session).
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": [{ "type": "text", "text": "What does src/main.ts do?" }]
|
||||
}
|
||||
```
|
||||
|
||||
Validation: `prompt` must be a non-empty array of objects. Other failures return `400` before reaching the bridge.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "stopReason": "end_turn" }
|
||||
```
|
||||
|
||||
Other stop reasons: `cancelled`, `max_tokens`, `error`, `length` (per ACP spec).
|
||||
|
||||
If the HTTP client disconnects mid-prompt, the daemon sends an ACP `cancel` notification to the agent, which winds the prompt down with `stopReason: "cancelled"`.
|
||||
|
||||
> **Stage 1 limitation — no server-side prompt timeout.** The bridge
|
||||
> only races the agent's `prompt()` against `transportClosedReject`
|
||||
> (the agent child crashing) and the caller's HTTP-disconnect
|
||||
> AbortSignal. A wedged-but-alive agent (e.g. a model call that
|
||||
> hangs) blocks the per-session FIFO until the HTTP client times out
|
||||
> on its end and disconnects. Long-running prompts are legitimate
|
||||
> (deep research, large-codebase analysis) so a default deadline is
|
||||
> deliberately not set; Stage 2 will expose a configurable
|
||||
> `promptTimeoutMs` opt-in. Until then, callers should set their own
|
||||
> client-side timeout and disconnect (or call
|
||||
> `POST /session/:id/cancel`) on expiry.
|
||||
|
||||
### `POST /session/:id/cancel`
|
||||
|
||||
Cancel the **currently active** prompt on the session. ACP-side this is a notification, not a request — the agent acknowledges by resolving the active `prompt()` with `cancelled`.
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4170/session/$SID/cancel
|
||||
# → 204 No Content
|
||||
```
|
||||
|
||||
> **Multi-prompt contract:** cancel only affects the active prompt. Any prompts the same client previously POSTed and are still queued behind the active one will continue to execute. Multi-prompt queueing is a daemon-introduced behavior (not in ACP spec); the contract for queued prompts is "they keep running unless you cancel each, or kill the session via channel exit".
|
||||
|
||||
### `POST /session/:id/model`
|
||||
|
||||
Switch the active model **within** the session's currently bound model service. Serialized through the per-session model-change queue.
|
||||
|
||||
(For switching the _service_ itself — Alibaba ModelStudio vs OpenRouter etc — pass `modelServiceId` on `POST /session` for a fresh session. Stage 1 has no live service-switch route.)
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "modelId": "qwen-staging" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "modelId": "qwen-staging" }
|
||||
```
|
||||
|
||||
On success, publishes `model_switched` to the SSE stream. On failure, publishes `model_switch_failed` (so passive subscribers see the failure, not just the caller). Races against the agent channel exit so a wedged child can't block the HTTP handler.
|
||||
|
||||
### `GET /session/:id/events` (SSE)
|
||||
|
||||
Subscribe to the session's event stream.
|
||||
|
||||
Headers:
|
||||
|
||||
```
|
||||
Accept: text/event-stream
|
||||
Last-Event-ID: 42 ← optional, replays from after id 42
|
||||
```
|
||||
|
||||
Frame format. The `data:` line is the **full event envelope**, JSON-stringified on a single line — `{id?, v, type, data, originatorClientId?}`. The ACP-specific payload (`sessionUpdate`, `requestPermission` arguments, etc.) sits under the envelope's `data` field; the envelope's own `type` matches the SSE `event:` line.
|
||||
|
||||
```
|
||||
id: 7
|
||||
event: session_update
|
||||
data: {"id":7,"v":1,"type":"session_update","data":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"…"}}}
|
||||
|
||||
id: 8
|
||||
event: permission_request
|
||||
data: {"id":8,"v":1,"type":"permission_request","data":{"requestId":"<uuid>","sessionId":"<sid>","toolCall":{...},"options":[...]}}
|
||||
|
||||
: heartbeat ← every 15s, no payload
|
||||
|
||||
event: client_evicted ← terminal frame, no id (synthetic)
|
||||
data: {"v":1,"type":"client_evicted","data":{"reason":"queue_overflow","droppedAfter":42}}
|
||||
```
|
||||
|
||||
The SSE-level `id:` / `event:` lines duplicate `envelope.id` / `envelope.type` for EventSource compatibility. Raw-`fetch` consumers (the SDK's `parseSseStream`) read everything off the JSON envelope and ignore the SSE preamble lines.
|
||||
|
||||
| Event type | Trigger |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `session_update` | Any ACP `sessionUpdate` notification (LLM chunks, tool calls, usage) |
|
||||
| `permission_request` | Agent asked for tool approval |
|
||||
| `permission_resolved` | Some client voted on a permission via `POST /permission/:requestId` |
|
||||
| `model_switched` | `POST /session/:id/model` succeeded |
|
||||
| `model_switch_failed` | `POST /session/:id/model` rejected |
|
||||
| `session_died` | Agent child crashed unexpectedly. **Terminal: SSE stream closes after this frame; the session is gone from `byId`.** Subscribers should reconnect via `POST /session` to spawn a fresh one. |
|
||||
| `client_evicted` | Subscriber-local: queue overflow. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). Other subscribers on the same session continue. |
|
||||
| `stream_error` | Daemon-side error during fan-out. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). |
|
||||
|
||||
Reconnect semantics:
|
||||
|
||||
- Send `Last-Event-ID: <n>` to replay events with `id > n` from the per-session ring (default depth 4000)
|
||||
- **Gap detection (client-side):** if `<n>` predates the oldest event still in the ring (e.g. you reconnect with `Last-Event-ID: 50` but the ring now holds 200–1199), the daemon replays from the oldest available event without raising. Compare the first replayed event's `id` against `n + 1`; any difference is the size of the lost window. Stage 2 will inject an explicit `stream_gap` synthetic frame on the daemon side; in Stage 1 detection is the client's responsibility.
|
||||
- IDs are monotonic per session, starting at 1
|
||||
- Synthetic terminal frames (`client_evicted`, `stream_error`) intentionally omit `id` so they don't burn a sequence slot for other subscribers
|
||||
|
||||
Backpressure:
|
||||
|
||||
- Per-subscriber queue defaults to `maxQueued: 256` live items (replay frames during reconnect bypass the cap)
|
||||
- On overflow the bus emits the `client_evicted` terminal frame and closes the subscription
|
||||
|
||||
### `POST /permission/:requestId`
|
||||
|
||||
Cast a vote on a pending `permission_request`. **First responder wins** — once one client answers, every other client trying to answer the same id gets `404`.
|
||||
|
||||
> **Stage 1 limitation — no permission timeout.** A `permission_request`
|
||||
> stays pending until: (a) some client votes here, (b) `POST /session/:id/cancel`
|
||||
> fires, (c) the HTTP client driving the prompt
|
||||
> disconnects (mid-prompt cancel resolves outstanding permissions as
|
||||
> `cancelled`), (d) the session is killed, or (e) the daemon shuts
|
||||
> down. **In a fully-headless deployment with no SSE subscriber,
|
||||
> `requestPermission` blocks the agent indefinitely** — there's nothing
|
||||
> to time out the wait. Stage 2 will add a configurable
|
||||
> `permissionTimeoutMs`. Until then, headless callers should keep an
|
||||
> SSE subscription open or wrap their prompt loop in their own timeout
|
||||
>
|
||||
> - `POST /session/:id/cancel` on expiry.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": {
|
||||
"outcome": "selected",
|
||||
"optionId": "proceed_once"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Outcomes:
|
||||
|
||||
- `{ "outcome": "selected", "optionId": "<one-of-the-options>" }` — accept / reject / proceed-once / etc, per the agent's offered choices
|
||||
- `{ "outcome": "cancelled" }` — drop the request (matches what `cancelSession` / `shutdown` do internally)
|
||||
|
||||
Response:
|
||||
|
||||
- `200 {}` — your vote was accepted
|
||||
- `404 { "error": "..." }` — the requestId is unknown (already resolved, never existed, or session torn down)
|
||||
|
||||
After a successful vote, every connected client sees `permission_resolved` with the same `requestId` and the chosen `outcome`.
|
||||
|
||||
## Streaming wire format
|
||||
|
||||
Events are emitted as standard EventSource frames. The daemon writes one `data:` line per frame (the JSON has no embedded newlines after `JSON.stringify`); the SDK parser at `packages/sdk-typescript/src/daemon/sse.ts` handles both that and the spec-allowed multi-`data:` form on the receive side.
|
||||
|
||||
## Error frames during streaming
|
||||
|
||||
If the bridge iterator throws while serving an SSE subscriber, the daemon emits a terminal `stream_error` frame (no `id`). The `data:` line is the full envelope (same shape as every other SSE frame in this doc); the actual error message lives under `envelope.data.error`:
|
||||
|
||||
```
|
||||
event: stream_error
|
||||
data: {"v":1,"type":"stream_error","data":{"error":"<message>"}}
|
||||
```
|
||||
|
||||
The connection then closes.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_SERVER_TOKEN` | Bearer token. Stripped of leading/trailing whitespace at boot. |
|
||||
| `SKIP_LLM_TESTS` | Set to `1` to **skip** LLM-required integration tests in `integration-tests/cli/qwen-serve-streaming.test.ts` (default-on for CI envs that lack provider API keys). |
|
||||
|
||||
## Source layout
|
||||
|
||||
| Path | Purpose |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `packages/cli/src/commands/serve.ts` | yargs command + flag schema |
|
||||
| `packages/cli/src/serve/runQwenServe.ts` | listener lifecycle + signal handling |
|
||||
| `packages/cli/src/serve/server.ts` | Express routes + middleware |
|
||||
| `packages/cli/src/serve/auth.ts` | bearer + Host allowlist + CORS deny |
|
||||
| `packages/cli/src/serve/httpAcpBridge.ts` | spawn-or-attach + per-session FIFO + permission registry |
|
||||
| `packages/cli/src/serve/eventBus.ts` | bounded async queue + replay ring |
|
||||
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | TS client |
|
||||
| `packages/sdk-typescript/src/daemon/sse.ts` | EventSource frame parser |
|
||||
| `integration-tests/cli/qwen-serve-routes.test.ts` | 18 cases, no LLM |
|
||||
| `integration-tests/cli/qwen-serve-streaming.test.ts` | 3 cases, real `qwen --acp` child (skipped when `SKIP_LLM_TESTS=1`) |
|
||||
|
|
@ -13,7 +13,8 @@ export default {
|
|||
'integration-vscode': 'Visual Studio Code',
|
||||
'integration-zed': 'Zed IDE',
|
||||
'integration-jetbrains': 'JetBrains IDEs',
|
||||
'integration-github-action': 'Github Actions',
|
||||
'integration-github-action': 'GitHub Actions',
|
||||
'qwen-serve': 'Daemon mode (qwen serve)',
|
||||
'Code with Qwen Code': {
|
||||
type: 'separator',
|
||||
title: 'Code with Qwen Code', // Title is optional
|
||||
|
|
|
|||
268
docs/users/qwen-serve.md
Normal file
268
docs/users/qwen-serve.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# Daemon mode (`qwen serve`)
|
||||
|
||||
Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, CI scripts, custom CLIs) share one agent session over HTTP + Server-Sent Events instead of each spawning their own subprocess.
|
||||
|
||||
> **Status:** Stage 1 (experimental). The protocol surface is locked at the §04 routes table from issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803). Stage 1.5 (`qwen --serve` flag — TUI co-hosts the same HTTP server) and Stage 2 (in-process refactor + `mDNS`/OpenAPI/WebSocket/Prometheus polish) are immediately downstream.
|
||||
>
|
||||
> **Scope honesty:** Stage 1 is sized for **developers prototyping clients against the protocol surface** and for **local single-user / small-team collaboration**. Production-grade multi-client / long-running / network-flaky workloads (mobile companions, IM bots reaching 1000+ chats) need Stage 1.5+ guarantees that aren't in this release. See [Stage 1.5+ runtime guarantees](#stage-15-runtime-guarantees) for the full gap list and #3803 for the convergence roadmap.
|
||||
|
||||
## What it gives you
|
||||
|
||||
- **One agent process, many clients** — under the default `sessionScope: 'single'`, every client connecting to the same workspace shares one ACP session. Live cross-client collaboration on the same conversation, the same file diffs, the same permission prompts.
|
||||
- **Reconnect-safe streaming** — SSE with `Last-Event-ID` reconnect lets a client drop and pick up exactly where it left off (within the ring's replay window).
|
||||
- **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Start the daemon (loopback, no auth)
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen serve
|
||||
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge)
|
||||
# → qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
|
||||
```
|
||||
|
||||
The default bind is `127.0.0.1:4170`. Bearer auth is **off** on loopback so local development "just works".
|
||||
|
||||
### 2. Sanity-check it
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4170/health
|
||||
# → {"status":"ok"}
|
||||
|
||||
curl http://127.0.0.1:4170/capabilities
|
||||
# → {"v":1,"mode":"http-bridge","features":["health","capabilities","session_create",...]}
|
||||
```
|
||||
|
||||
### 3. Open a session
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4170/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"cwd":"'"$PWD"'"}'
|
||||
# → {"sessionId":"<uuid>","workspaceCwd":"…","attached":false}
|
||||
```
|
||||
|
||||
A second client posting to `/session` with the same `cwd` gets `"attached": true` — they're now sharing the agent.
|
||||
|
||||
### 4. Subscribe to the event stream (in another terminal first)
|
||||
|
||||
```bash
|
||||
SESSION_ID="<from step 3>"
|
||||
curl -N http://127.0.0.1:4170/session/$SESSION_ID/events
|
||||
# → id: 1
|
||||
# event: session_update
|
||||
# data: {"id":1,"v":1,"type":"session_update","data":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"…"}}}
|
||||
```
|
||||
|
||||
The `data:` line is the **full event envelope** — `{id?, v, type, data, originatorClientId?}` — JSON-stringified on a single line. The ACP payload (the `sessionUpdate` block in this example) sits under `data` inside that envelope. The SSE-level `id:` / `event:` lines are convenience for EventSource clients; the same values appear inside the JSON envelope so raw-`fetch` consumers get them too.
|
||||
|
||||
Open this **before** sending the prompt — the SSE replay buffer holds the
|
||||
last 4000 events so a late subscriber can catch up via `Last-Event-ID`,
|
||||
but for the simple "watch a single prompt" case it's easiest to subscribe
|
||||
first and let it stream live.
|
||||
|
||||
The stream emits `session_update` (LLM chunks, tool calls, usage),
|
||||
`permission_request` (tool needs approval), `permission_resolved`
|
||||
(someone voted), `model_switched`, `model_switch_failed`, and the terminal
|
||||
frames `session_died` (agent child crashed — SSE then closes) and
|
||||
`client_evicted` (your queue overflowed — SSE then closes).
|
||||
|
||||
### 5. Send a prompt (back in the original terminal)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4170/session/$SESSION_ID/prompt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"prompt":[{"type":"text","text":"What does src/main.ts do?"}]}'
|
||||
# → {"stopReason":"end_turn"}
|
||||
```
|
||||
|
||||
The `curl -N` from step 4 will print frames as they arrive.
|
||||
|
||||
## Authentication
|
||||
|
||||
For anything beyond loopback, you **must** pass a bearer token:
|
||||
|
||||
```bash
|
||||
export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"
|
||||
qwen serve --hostname 0.0.0.0 --port 4170
|
||||
# → boot refuses without QWEN_SERVER_TOKEN
|
||||
```
|
||||
|
||||
Clients then send `Authorization: Bearer $QWEN_SERVER_TOKEN` on every request. `/health` is exempted **only on loopback binds** so k8s/Compose liveness probes inside the pod (where the daemon listens on `127.0.0.1`) don't need credentials. On non-loopback binds (`--hostname 0.0.0.0` etc.) `/health` requires the token like every other route — otherwise an attacker can probe arbitrary addresses to confirm the daemon's existence. Use `/capabilities` to verify your token is correct end-to-end (it always requires auth):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" http://your-host:4170/capabilities
|
||||
# → {"v":1,"mode":"http-bridge","features":[...],"modelServices":[]}
|
||||
# Wrong token → 401
|
||||
```
|
||||
|
||||
The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 responses are uniform across "missing header", "wrong scheme", and "wrong token" so a side-channel can't distinguish.
|
||||
|
||||
## CLI flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| ----------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--port <n>` | `4170` | TCP port. `0` = OS-assigned ephemeral port. |
|
||||
| `--hostname <addr>` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. |
|
||||
| `--token <str>` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). |
|
||||
| `--max-sessions <n>` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). |
|
||||
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
|
||||
| `--http-bridge` | `true` | Stage 1 mode: per-session `qwen --acp` child process. Stage 2 native in-process becomes available later. |
|
||||
|
||||
> **Sizing the load knobs.** `--max-sessions` is the **new-child** cap.
|
||||
> Three other layers also limit load — when sizing for a high-concurrency
|
||||
> deployment, tune them together:
|
||||
>
|
||||
> - **listener-level**: `--max-connections` / `server.maxConnections=256`
|
||||
> bounds raw TCP connections (slow-client back-pressure).
|
||||
> - **per-session subscribers**: the EventBus caps SSE subscribers at
|
||||
> 64 per session by default; the 65th client gets a terminal
|
||||
> `stream_error` and is closed.
|
||||
> - **per-subscriber backlog**: a 256-frame queue per SSE client; an
|
||||
> over-capacity client gets a terminal `client_evicted` frame and is
|
||||
> closed (one slow consumer can't pin the daemon).
|
||||
>
|
||||
> The four caps interact: `--max-sessions × 64 subscribers × 256 frames`
|
||||
> is the worst-case in-flight memory at the EventBus layer. Default
|
||||
> sizing assumes single-user / small-team load; raise progressively
|
||||
> (and watch RSS) for multi-tenant deployments.
|
||||
|
||||
## Default deployment threat model
|
||||
|
||||
- **127.0.0.1 only** — loopback bind, no auth needed.
|
||||
- **`--hostname 0.0.0.0` requires a token** — boot refuses without one.
|
||||
- **`LOOPBACK_BINDS` includes IPv6** — `::1` and `[::1]` count as loopback for the no-token rule.
|
||||
- **Host header allowlist** — on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** — the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy.
|
||||
- **CORS denies any browser Origin** — returns `403` JSON. **Implication for browser-served webuis** (BUy4e): any `packages/webui`-style frontend that lives on a separate origin will get 403 at the wire. Stage 1 options for browser-style consumption: (a) package the webui as a native shell (Electron/Tauri) so no `Origin` header is sent, or (b) front the daemon with a same-origin reverse proxy that strips/rewrites `Origin` for a known frontend. Stage 1.5 will add `--allow-origin <pattern>` for opt-in named frontends.
|
||||
- **Spawned `qwen --acp` child inherits the daemon's environment** with one explicit scrub: `QWEN_SERVER_TOKEN` is removed before the child starts (the daemon's own bearer; the agent doesn't need it). Everything else — `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `QWEN_*` / `DASHSCOPE_API_KEY` / your custom `modelProviders[].envKey` / etc. — passes through, because the agent legitimately needs those to authenticate to the LLM. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc` / `~/.aws/credentials` / `~/.npmrc` is reachable by prompt injection regardless. The env passthrough is not the security boundary; the user-as-trust-root is. Don't run `qwen serve` under an identity that has env-resident credentials you wouldn't trust the agent with.
|
||||
- **Per-subscriber bounded SSE queues** — a slow client that overflows its queue gets a `client_evicted` terminal frame and is closed; one stuck consumer can't pin the daemon.
|
||||
- **Graceful shutdown** — SIGINT/SIGTERM drain the agent children before closing the listener (10s deadline per child).
|
||||
|
||||
> ⚠️ **Stage 1 known gap — permissions are daemon-global, not per-session (BUy4H).** `pendingPermissions` lives at daemon scope; any client holding the bearer token can vote on any `requestId` for any session it can see (and SSE `permission_request` events carry the requestId in their payload). This is acceptable under the single-user / small-team trust model where every authenticated client is the same human or collaborators they trust. Stage 1.5 will move to `POST /session/:id/permission/:requestId` + session-scoped pending map + per-client identity (must-have #3 from the downstream review); until then, don't run `qwen serve` behind a bearer shared with untrusted parties.
|
||||
>
|
||||
> ⚠️ **Stage 1 known gap — `POST /session/:id/prompt` body capped at 10 MB (BUy4L).** Multimodal prompts containing images / PDFs / audio that exceed 10 MB will fail at body-parse time before route logic runs (no streaming, no mid-upload abort). Workaround: shrink the content client-side, or pass a path reference and let the agent read the file via `readTextFile`. Stage 1.5 will accept `multipart/form-data` or chunked encoding on `/prompt` so large prompts don't hit a cliff.
|
||||
>
|
||||
> ⚠️ **Stage 1 known gap — phantom SSE connections behind NAT.** The
|
||||
> daemon detects dead clients via TCP back-pressure on heartbeats
|
||||
> (15s interval). A client that vanishes WITHOUT a TCP RST (e.g. a
|
||||
> NAT box silently dropping idle flows) keeps the kernel-level socket
|
||||
> "alive" until Node's keepalive probes time out — typically ~2 hours
|
||||
> on Linux defaults. On `--hostname 0.0.0.0` deployments behind such
|
||||
> NATs, phantom SSE connections can accumulate and eventually hit the
|
||||
> 256 `server.maxConnections` ceiling. Stage 2 will add an
|
||||
> application-level idle deadline (last-byte-written tracking +
|
||||
> per-connection timeout). Until then, operators on networks that
|
||||
> swallow RSTs may want to lower `server.keepAliveTimeout` via a
|
||||
> reverse proxy or accept periodic daemon restarts.
|
||||
|
||||
## Multi-session & remote deployment
|
||||
|
||||
A single `qwen serve` process can manage sessions for any workspace path passed via `cwd` on `POST /session` — under the default `sessionScope: 'single'` it keeps one ACP session per canonicalized workspace, sharing it across every client that posts the same `cwd`. So one daemon will happily host sessions for many workspaces at once.
|
||||
|
||||
> **Subscribe BEFORE posting `modelServiceId` on attach.** When a client `POST /session` with a `modelServiceId` and the workspace already has a session running a different model, the daemon issues an internal `setSessionModel` call — failures are NOT propagated as an HTTP error (the session stays operational on its current model). The visible failure signal is a `model_switch_failed` event on the session's SSE stream. If you call `POST /session` and only THEN open `GET /session/:id/events`, you'll miss the failure event and silently keep talking to the wrong model. Open the SSE stream first, or pass `Last-Event-ID: 0` on subscribe to replay the ring's oldest available event.
|
||||
|
||||
To handle multiple **users** (each with their own quota, audit log, sandbox) or to scale beyond one process's reach (cold-start budget, FD count, RSS), you spawn multiple daemon instances behind an external orchestrator. That orchestrator (multi-tenancy / OIDC / Quota / Audit / k8s) is **out of scope** for the qwen-code project — see issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803) "External Reference Architecture" for the design pointers.
|
||||
|
||||
## Durability model
|
||||
|
||||
**Sessions are ephemeral in Stage 1.** Plan accordingly:
|
||||
|
||||
- A child process crash publishes `session_died` and removes the session from the daemon's maps. There is **no resume** — clients must `POST /session` again.
|
||||
- A daemon restart loses every in-flight session. ACP's `loadSession` / `unstable_resumeSession` are **not exposed via HTTP** in Stage 1; sessions don't outlive the daemon.
|
||||
- Long client disconnects (>5 min on a chatty turn) can outrun the SSE replay ring (default 4000 frames) — `Last-Event-ID` reconnect succeeds but state may be incoherent. For mobile / flaky-network clients, plan to re-create the session and re-open SSE on long drops.
|
||||
- File operations (`writeTextFile`) are atomic across crashes (write-then-rename); they aren't atomic across daemon restarts in the sense of replaying — the file write either landed or it didn't.
|
||||
|
||||
If your integration needs cross-restart durability, you need either Stage 1.5+ (`loadSession` over HTTP, persistence layer) or your own application-level state recovery. Don't hold long-running, restart-sensitive state inside the daemon's session.
|
||||
|
||||
## Stage 1.5+ runtime guarantees
|
||||
|
||||
Stage 1's contract is sized for prototyping. Per [#3889 chiga0 downstream-consumer review](https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427875644), the following are **not** in Stage 1 — production-grade integrations need Stage 1.5+ before relying on them:
|
||||
|
||||
**Blockers for serious downstream use:**
|
||||
|
||||
1. **Per-request `sessionScope` override** on `POST /session` — today the daemon-wide default is the only setting; a VSCode extension can't say "I want a private session for this window" against a daemon configured for shared sessions.
|
||||
2. **`loadSession` / `unstable_resumeSession` over HTTP** — without this, no integration can survive a child crash or daemon restart, and any orchestrator coordinating the daemon can't recover state either.
|
||||
3. **Persistent client identity (pair tokens + per-client revocation)** — Stage 1 uses one shared bearer; a leaked token revokes everyone, and `originatorClientId` is client-self-declared rather than daemon-stamped from authenticated identity.
|
||||
|
||||
**Reliability baseline:**
|
||||
|
||||
4. **Client-initiated heartbeat path** — distinguish "agent thinking" from "daemon dead" without waiting for the 15s server heartbeat.
|
||||
5. **`permission_already_resolved` event** when a vote loses the first-responder race — currently UIs have to infer state from a `404`.
|
||||
6. **Larger / per-session-configurable replay ring** — default 4000 covers short drops; mobile / chatty-turn workloads need 8000+ or per-session config.
|
||||
7. **`slow_client_warning` event before `client_evicted`** — soft backpressure so well-behaved slow clients can self-throttle (trim render depth, drop chunks) before being terminated.
|
||||
|
||||
**Integration ergonomics:**
|
||||
|
||||
8. **`POST /session/:id/_meta` for IM-style context** — per-session key-value attached to subsequent prompts (chat id, sender, thread id) replaces the per-channel improvisation.
|
||||
9. **`/capabilities` actual feature negotiation** — `protocol_versions: { acp: '0.14.x', daemon_envelope: 1 }` so clients can detect drift instead of falling through to "unknown frame, ignore".
|
||||
10. **First-class durability documentation** (this section) — already shipped above.
|
||||
|
||||
The full convergence roadmap is tracked on [#3803](https://github.com/QwenLM/qwen-code/issues/3803).
|
||||
|
||||
## Stage 1 scope boundaries — what we won't fix in Stage 1.5
|
||||
|
||||
Two structural choices are explicit non-goals for the Stage 1 / 1.5 / 2 main-line roadmap. If your use case depends on either, plan around them rather than waiting for us.
|
||||
|
||||
### Session state is local-mutation-only (per [LaZzyMan review #4270256721](https://github.com/QwenLM/qwen-code/pull/3889#pullrequestreview-4270256721))
|
||||
|
||||
The Stage 1.5 plan describes TUI as an in-process EventBus subscriber. In practice **TUI UI is strictly larger than the wire protocol**:
|
||||
|
||||
- **Local-only UI** — the ~15 Ink dialog components (`ModelDialog`, `MemoryDialog`, `PermissionsDialog`, `SessionPicker`, `WelcomeBackDialog`, `FolderTrustDialog`, …) and the `local-jsx` slash commands (`/ide`, `/auth`, `/init`, `/resume`, `/rename`, `/delete`, `/language`, `/arena`, …) render terminal-specific Ink JSX. Remote clients on HTTP/SSE can't equivalently render Ink, and these flows emit no wire event.
|
||||
- **Session-state mutations without wire events** — `/approval-mode`, `/memory add`, `/mcp add-server`, `/agents`, `/tools enable/disable`, `/auth`, `/init` (writing `CLAUDE.md`) all change agent behavior, but only `/model` currently publishes an event (`model_switched`).
|
||||
|
||||
**Stage 1 choice — option (A) from the review**: don't promote these mutations to wire events. The two deployment modes have different consequences.
|
||||
|
||||
#### Mode 1 — headless `qwen serve` (this PR)
|
||||
|
||||
No TUI shell runs inside the daemon. The slash commands listed above **don't exist** in this mode — there's no terminal UI to issue them from. Session state is therefore:
|
||||
|
||||
- **Boot-time-frozen** for `approval-mode` / `memory` / `mcp servers` / `agents` / `tools` allowlist / `auth` — all loaded from settings + disk when the daemon's `qwen --acp` child starts; immutable for the session's lifetime.
|
||||
- **Mutable over HTTP** only via the routes this PR exposes — primarily `POST /session/:id/model` (publishes `model_switched`). Permission votes (`POST /permission/:requestId`) are per-request, not per-session-state.
|
||||
|
||||
**Consequence:** remote clients in headless mode see the **full session state**. No TUI hides additional state; no drift is possible. If you want to change `approval-mode` or add an MCP server, restart the daemon with new settings — the daemon doesn't expose runtime mutation for those today.
|
||||
|
||||
#### Mode 2 — Stage 1.5 `qwen --serve` co-hosted TUI (not in this PR)
|
||||
|
||||
When Stage 1.5 lands `qwen --serve` (TUI process co-hosts the same HTTP server), the TUI **does** exist alongside remote clients. A local operator typing `/approval-mode yolo` or `/mcp add-server` mutates session state, and remote clients on HTTP have no event to observe the change.
|
||||
|
||||
In this mode, TUI is a **"super-client"** — it observes the same agent conversation remote clients see, AND can mutate session state remote clients can't. The asymmetry is:
|
||||
|
||||
- ✅ Both TUI and remote clients see the same agent messages, tool calls, file diffs, permission prompts.
|
||||
- ❌ Only TUI sees / mutates approval-mode / memory / MCP server list / agents / tools allowlist / auth state.
|
||||
|
||||
**Consequence in Mode 2:** if a remote-client UI tries to mirror session settings, it can drift after any TUI slash command. Remote clients should **re-fetch state on attach / reconnect** (use `Last-Event-ID: 0` to replay the ring's oldest event for things like `model_switched`); they should NOT rely on incremental events for TUI-side mutations.
|
||||
|
||||
#### Why (A) and not (B) (promote mutations to `session_state_changed` event family)
|
||||
|
||||
(B) is the more ambitious answer but locks Stage 1.5 into a substantially larger wire surface that must also pass cleanly through the planned in-process refactor. We'd rather walk the smaller scope honestly. The session-state-event taxonomy work — enumerating which TUI flows are local-only by design vs. could plausibly graduate to wire under a future opt-in (B)-flavor extension — moves to [#3803](https://github.com/QwenLM/qwen-code/issues/3803), not Stage 1.5 code.
|
||||
|
||||
### N parallel sessions share one `qwen --acp` child
|
||||
|
||||
Multiple sessions on the same workspace **share one `qwen --acp` child process** via the agent's native multi-session support (`packages/cli/src/acp-integration/acpAgent.ts:194: private sessions: Map<string, Session>`). The bridge calls `connection.newSession({cwd, mcpServers})` for each session — the agent stores them in its sessions map and demultiplexes per-call sessionId.
|
||||
|
||||
Concrete cost at N=5 sessions on the same workspace:
|
||||
|
||||
| Resource | Per session | At N=5 |
|
||||
| ------------------------------------ | ----------- | ---------------------------- |
|
||||
| Daemon Node process | one | **30–50 MB** (one daemon) |
|
||||
| `qwen --acp` child | shared | **60–100 MB** (one child) |
|
||||
| MCP server children | per-session | 3×N if configs differ |
|
||||
| `FileReadCache` (in-child heap) | shared | parsed once |
|
||||
| `CLAUDE.md` / hierarchy memory parse | shared | parsed once |
|
||||
| OAuth refresh-token state | shared | **one refresh path** |
|
||||
| Auto-memory learned facts | shared | one knowledge base per child |
|
||||
| Cold start | first only | <200 ms after first session |
|
||||
|
||||
The bridge keeps **one channel per workspace** (cross-workspace sharing is intentionally not done — different workspaces have different settings/auth scope, and `acpAgent.ts:601` reloads settings per newSession `cwd`, which would interfere). The channel stays alive while at least one session is live; the last `killSession` (or a channel-level crash) kills the child.
|
||||
|
||||
**MCP server children** are still per-session today — each session's config can specify different servers, so they're independently spawned. Stage 1.5 follow-up: refcount MCP server children by `(workspace, config-hash)` so identical configs share. Not in scope for this PR.
|
||||
|
||||
**Peer agents (Cursor / Continue / Claude Code / OpenCode / Gemini CLI) all do single-process multi-session.** qwen-code matches them at the agent layer; the Stage 1 bridge in this PR makes the same architecture visible over HTTP.
|
||||
|
||||
## What's next
|
||||
|
||||
- **Build a client?** See the [DaemonClient TypeScript quickstart](../developers/examples/daemon-client-quickstart.md) and the [HTTP protocol reference](../developers/qwen-serve-protocol.md).
|
||||
- **Reading the source?** Bridge code lives at `packages/cli/src/serve/`; SDK client at `packages/sdk-typescript/src/daemon/`.
|
||||
- **Tracking the roadmap?** Stage 1.5 / Stage 2 progress is tracked on issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803).
|
||||
|
|
@ -121,6 +121,7 @@ export default tseslint.config(
|
|||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'mime/lite',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**',
|
||||
|
|
|
|||
347
integration-tests/cli/qwen-serve-routes.test.ts
Normal file
347
integration-tests/cli/qwen-serve-routes.test.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* `qwen serve` daemon — HTTP route + middleware integration tests.
|
||||
*
|
||||
* These exercise the daemon end-to-end without needing a working model
|
||||
* credential: they spawn a real `node packages/cli/dist/index.js serve`
|
||||
* (which itself spawns real `qwen --acp` children), then probe the HTTP
|
||||
* surface. The agent's `initialize` + `newSession` handshake works
|
||||
* without auth, so session creation, listing, cancellation, validation,
|
||||
* SSE wiring, the CORS guard, the bearer-auth guard and shutdown all
|
||||
* run here.
|
||||
*
|
||||
* Tests that require an actual model call (streaming prompts, real
|
||||
* permission flows, Last-Event-ID resume across a real reconnect) live
|
||||
* in `qwen-serve-streaming.test.ts` and skip when no auth is set.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
type DaemonSessionSummary,
|
||||
} from '@qwen-code/sdk';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Match the rest of the integration suite: prefer the bundled CLI
|
||||
// path that `globalSetup.ts` configures via `TEST_CLI_PATH` (root
|
||||
// `dist/cli.js`), falling back to the per-package output for direct
|
||||
// `vitest run integration-tests/...` invocations that bypass
|
||||
// globalSetup. Without this two-tier resolution the suite became
|
||||
// sensitive to which build step (`npm run build` vs `npm run bundle`)
|
||||
// last ran.
|
||||
const CLI_BIN =
|
||||
process.env['TEST_CLI_PATH'] ??
|
||||
path.resolve(__dirname, '../../packages/cli/dist/index.js');
|
||||
const TOKEN = 'integration-test-token';
|
||||
const REPO_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
let daemon: ChildProcess;
|
||||
let port = 0;
|
||||
let base = '';
|
||||
let client: DaemonClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
CLI_BIN,
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--token',
|
||||
TOKEN,
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
// Read stdout until we see the listening line + parse the port.
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
let buf = '';
|
||||
// Capture the timeout handle so we can clear it on success — an
|
||||
// un-cleared 10s timer outlives the spawn promise and keeps the
|
||||
// vitest event loop alive past the test, manifesting as
|
||||
// intermittent `Test timed out` retries on slow CI.
|
||||
const bootTimer = setTimeout(
|
||||
() => reject(new Error('daemon boot timeout')),
|
||||
10_000,
|
||||
);
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
const m = buf.match(/listening on http:\/\/127\.0\.0\.1:(\d+)/);
|
||||
if (m) {
|
||||
daemon.stdout?.off('data', onData);
|
||||
clearTimeout(bootTimer);
|
||||
resolve(Number(m[1]));
|
||||
}
|
||||
};
|
||||
daemon.stdout!.on('data', onData);
|
||||
daemon.once('exit', (c) => {
|
||||
clearTimeout(bootTimer);
|
||||
reject(new Error(`daemon exited with ${c}`));
|
||||
});
|
||||
});
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
client = new DaemonClient({ baseUrl: base, token: TOKEN });
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!daemon || daemon.exitCode !== null) return;
|
||||
daemon.kill('SIGTERM');
|
||||
await new Promise((r) => daemon.once('exit', r));
|
||||
}, 15_000);
|
||||
|
||||
describe('qwen serve — bearer auth (timing-safe compare)', () => {
|
||||
// Probe `/capabilities` for the rejection cases instead of `/health`
|
||||
// — `/health` is intentionally registered before the bearer middleware
|
||||
// so liveness probes work without credentials. `/capabilities` is the
|
||||
// cheapest route still gated by the bearer chain.
|
||||
it('right token → 200', async () => {
|
||||
const res = await fetch(`${base}/capabilities`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('wrong same-length token → 401', async () => {
|
||||
const res = await fetch(`${base}/capabilities`, {
|
||||
headers: { Authorization: `Bearer ${'X'.repeat(TOKEN.length)}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('wrong shorter token → 401', async () => {
|
||||
const res = await fetch(`${base}/capabilities`, {
|
||||
headers: { Authorization: 'Bearer x' },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('missing Authorization header → 401', async () => {
|
||||
const res = await fetch(`${base}/capabilities`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('Basic scheme (not Bearer) → 401', async () => {
|
||||
const res = await fetch(`${base}/capabilities`, {
|
||||
headers: { Authorization: `Basic ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('/health exempt: missing Authorization header → 200', async () => {
|
||||
// Locks the auth-bypass exemption documented in
|
||||
// docs/developers/qwen-serve-protocol.md so a future middleware
|
||||
// ordering change can't silently break liveness probes.
|
||||
const res = await fetch(`${base}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — CORS browser-origin denial', () => {
|
||||
it('GET with Origin header → 403 + JSON', async () => {
|
||||
const res = await fetch(`${base}/health`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
Origin: 'https://evil.example.com',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get('content-type')).toMatch(/application\/json/);
|
||||
expect(await res.json()).toEqual({
|
||||
error: 'Request denied by CORS policy',
|
||||
});
|
||||
});
|
||||
|
||||
it('GET without Origin header → 200', async () => {
|
||||
const res = await fetch(`${base}/health`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — capabilities envelope', () => {
|
||||
it('advertises all 9 Stage 1 features', async () => {
|
||||
const caps = await client.capabilities();
|
||||
expect(caps.v).toBe(1);
|
||||
expect(caps.mode).toBe('http-bridge');
|
||||
expect(caps.features).toEqual([
|
||||
'health',
|
||||
'capabilities',
|
||||
'session_create',
|
||||
'session_list',
|
||||
'session_prompt',
|
||||
'session_cancel',
|
||||
'session_events',
|
||||
'session_set_model',
|
||||
'permission_vote',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — POST /session validation + concurrent coalescing', () => {
|
||||
it('rejects relative cwd', async () => {
|
||||
const res = await fetch(`${base}/session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ cwd: 'relative/path' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('two parallel POSTs same workspace coalesce to one session', async () => {
|
||||
const cwd = REPO_ROOT;
|
||||
const [a, b] = await Promise.all([
|
||||
client.createOrAttachSession({ workspaceCwd: cwd }),
|
||||
client.createOrAttachSession({ workspaceCwd: cwd }),
|
||||
]);
|
||||
expect(a.sessionId).toBe(b.sessionId);
|
||||
// Exactly one of the two reports `attached: false` (the spawn owner).
|
||||
expect([a.attached, b.attached].sort()).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('bad modelServiceId keeps the session alive on the default model', async () => {
|
||||
// Per #3889 review A05Ym: when the requested model is rejected at
|
||||
// create-session time, the session stays operational on the
|
||||
// agent's default model. The caller gets a sessionId they can
|
||||
// retry the model switch against (via POST /session/:id/model).
|
||||
// Tearing the session down on model-switch failure would force
|
||||
// the caller into a 500 with no way to recover. The
|
||||
// `model_switch_failed` SSE event is the visible failure signal.
|
||||
const cwd = '/tmp';
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: cwd,
|
||||
modelServiceId: 'definitely-not-a-real-model',
|
||||
});
|
||||
expect(session.sessionId).toBeTypeOf('string');
|
||||
expect(session.attached).toBe(false);
|
||||
const sessions = await client.listWorkspaceSessions(cwd);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]?.sessionId).toBe(session.sessionId);
|
||||
// No teardown — Stage 1 has no DELETE /session route, and the
|
||||
// session persists in `byId` until daemon shutdown. The other
|
||||
// tests in this file use unique workspace cwds so the surviving
|
||||
// session here doesn't interfere.
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — POST /permission/:requestId validation', () => {
|
||||
it('400 on empty optionId', async () => {
|
||||
const res = await fetch(`${base}/permission/req-1`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcome: { outcome: 'selected', optionId: '' },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 on missing optionId', async () => {
|
||||
const res = await fetch(`${base}/permission/req-1`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ outcome: { outcome: 'selected' } }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('404 when valid vote targets unknown requestId', async () => {
|
||||
const res = await fetch(`${base}/permission/never-existed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — SSE Content-Type guard (SDK side)', () => {
|
||||
it('throws DaemonHttpError when upstream returns 200 + JSON', async () => {
|
||||
const ghostFetch = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const ghost = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch: ghostFetch,
|
||||
});
|
||||
let threw: unknown = null;
|
||||
try {
|
||||
const it2 = ghost.subscribeEvents('s-1');
|
||||
await it2.next();
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(DaemonHttpError);
|
||||
expect((threw as DaemonHttpError).message).toMatch(/text\/event-stream/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — Last-Event-ID strict parsing', () => {
|
||||
it('malformed Last-Event-ID accepted but ignored', async () => {
|
||||
// Spawn a session so /events has somewhere to attach.
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
const res = await fetch(`${base}/session/${session.sessionId}/events`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
Accept: 'text/event-stream',
|
||||
'Last-Event-ID': '1abc',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
await res.body?.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — cancel + list', () => {
|
||||
it('cancel called twice does not throw', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
await client.cancel(session.sessionId);
|
||||
await client.cancel(session.sessionId);
|
||||
});
|
||||
|
||||
it('listWorkspaceSessions returns the live session', async () => {
|
||||
await client.createOrAttachSession({ workspaceCwd: REPO_ROOT });
|
||||
const sessions = await client.listWorkspaceSessions(REPO_ROOT);
|
||||
expect(sessions.length).toBeGreaterThanOrEqual(1);
|
||||
// Explicit `s` type because the reviewer's tsc run resolves
|
||||
// `@qwen-code/sdk` against a possibly-stale dist .d.ts (per
|
||||
// integration-tests/tsconfig.json `paths` mapping); without
|
||||
// the annotation `s` widens to `any` in that environment and
|
||||
// trips strict-mode TS7006.
|
||||
expect(
|
||||
sessions.every((s: DaemonSessionSummary) => s.workspaceCwd === REPO_ROOT),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
362
integration-tests/cli/qwen-serve-streaming.test.ts
Normal file
362
integration-tests/cli/qwen-serve-streaming.test.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* `qwen serve` daemon — streaming / multi-client / recovery integration.
|
||||
*
|
||||
* These tests need a working model credential because they fire real
|
||||
* prompts and observe the resulting SSE stream. They cover three flows
|
||||
* that unit tests can't fully exercise:
|
||||
*
|
||||
* 1. Real `qwen --acp` child crash → daemon publishes `session_died`,
|
||||
* removes the dead entry from the maps, and a subsequent
|
||||
* `createOrAttachSession` for the same workspace spawns fresh.
|
||||
* 2. Two SSE subscribers + a tool that needs permission → both see
|
||||
* the SAME `permission_request` event (cross-client fan-out);
|
||||
* two concurrent votes resolve as 200/404 (first-responder wins).
|
||||
* 3. SSE consumer disconnects after seeing N events; reconnect with
|
||||
* `Last-Event-ID: N` resumes the stream from id N+1 via the bus's
|
||||
* replay ring.
|
||||
*
|
||||
* Skip on CI / no-auth via `SKIP_LLM_TESTS=1`.
|
||||
*/
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DaemonClient, parseSseStream } from '@qwen-code/sdk';
|
||||
import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Match the rest of the integration suite: prefer `TEST_CLI_PATH`
|
||||
// from `globalSetup.ts` (root `dist/cli.js` bundle), fall back to
|
||||
// the per-package output for direct vitest invocations. See the same
|
||||
// note in qwen-serve-routes.test.ts for full rationale.
|
||||
const CLI_BIN =
|
||||
process.env['TEST_CLI_PATH'] ??
|
||||
path.resolve(__dirname, '../../packages/cli/dist/index.js');
|
||||
const TOKEN = 'streaming-integ-secret';
|
||||
const REPO_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
// Skip when:
|
||||
// - explicit `SKIP_LLM_TESTS=1` (CI envs without provider API keys), OR
|
||||
// - Windows: this suite shells out to `pgrep` / `kill -KILL` to
|
||||
// simulate child-process crashes for the SIGKILL → `session_died`
|
||||
// test, and those binaries are POSIX-only. A Windows-equivalent
|
||||
// (`taskkill`) would need different test scaffolding; deferred to
|
||||
// a follow-up rather than smuggling shell-shape divergence into
|
||||
// the existing assertions.
|
||||
const SKIP =
|
||||
process.env['SKIP_LLM_TESTS'] === '1' || process.platform === 'win32';
|
||||
const describeLLM = SKIP ? describe.skip : describe;
|
||||
|
||||
let daemon: ChildProcess;
|
||||
let port = 0;
|
||||
let base = '';
|
||||
let client: DaemonClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (SKIP) return;
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
CLI_BIN,
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--token',
|
||||
TOKEN,
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
let buf = '';
|
||||
// Capture the timeout handle so we can clear it on success — an
|
||||
// un-cleared 10s timer outlives the spawn promise and keeps the
|
||||
// vitest event loop alive past the test, manifesting as
|
||||
// intermittent flakes on slow CI.
|
||||
const bootTimer = setTimeout(
|
||||
() => reject(new Error('daemon boot timeout')),
|
||||
10_000,
|
||||
);
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
const m = buf.match(/listening on http:\/\/127\.0\.0\.1:(\d+)/);
|
||||
if (m) {
|
||||
daemon.stdout?.off('data', onData);
|
||||
clearTimeout(bootTimer);
|
||||
resolve(Number(m[1]));
|
||||
}
|
||||
};
|
||||
daemon.stdout!.on('data', onData);
|
||||
daemon.once('exit', (c) => {
|
||||
clearTimeout(bootTimer);
|
||||
reject(new Error(`daemon exited with ${c}`));
|
||||
});
|
||||
});
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
client = new DaemonClient({ baseUrl: base, token: TOKEN });
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (SKIP || !daemon || daemon.exitCode !== null) return;
|
||||
daemon.kill('SIGTERM');
|
||||
await new Promise((r) => daemon.once('exit', r));
|
||||
}, 15_000);
|
||||
|
||||
/** Open an authenticated SSE stream and yield parsed frames. */
|
||||
async function* sseFrames(
|
||||
sessionId: string,
|
||||
opts: { signal?: AbortSignal; lastEventId?: number } = {},
|
||||
): AsyncGenerator<DaemonEvent> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
Accept: 'text/event-stream',
|
||||
};
|
||||
if (opts.lastEventId !== undefined) {
|
||||
headers['Last-Event-ID'] = String(opts.lastEventId);
|
||||
}
|
||||
const res = await fetch(`${base}/session/${sessionId}/events`, {
|
||||
headers,
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`SSE open failed: ${res.status}`);
|
||||
// Forward the abort signal into parseSseStream so a post-connect
|
||||
// abort stops iteration immediately. Without this, the parser
|
||||
// stays parked on `reader.read()` until the upstream actually
|
||||
// closes — fine for happy-path tests but flaky for any test that
|
||||
// wants to abort mid-stream.
|
||||
yield* parseSseStream(res.body!, opts.signal);
|
||||
}
|
||||
|
||||
describeLLM('qwen serve — child-crash recovery (real SIGKILL)', () => {
|
||||
it('publishes session_died after the qwen --acp child is SIGKILL-ed', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
|
||||
// Find the daemon's direct `--acp` child PID.
|
||||
const childPids = execSync(`pgrep -P ${daemon.pid} -f "qwen.*--acp"`, {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
expect(childPids.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const ac = new AbortController();
|
||||
const collected: DaemonEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
try {
|
||||
for await (const e of sseFrames(session.sessionId, {
|
||||
signal: ac.signal,
|
||||
})) {
|
||||
collected.push(e);
|
||||
if (e.type === 'session_died') break;
|
||||
}
|
||||
} catch {
|
||||
/* aborted */
|
||||
}
|
||||
})();
|
||||
|
||||
// Kill the child outright.
|
||||
for (const pid of childPids) {
|
||||
try {
|
||||
execSync(`kill -KILL ${pid}`);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
// Wait up to 5s for the daemon to detect + publish session_died.
|
||||
const deadline = Date.now() + 5000;
|
||||
while (
|
||||
Date.now() < deadline &&
|
||||
!collected.some((e) => e.type === 'session_died')
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
ac.abort();
|
||||
await consumer;
|
||||
|
||||
const died = collected.find((e) => e.type === 'session_died');
|
||||
expect(died).toBeDefined();
|
||||
expect((died?.data as { sessionId?: string })?.sessionId).toBe(
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
// Listing must NOT show the dead session.
|
||||
const remaining = await client.listWorkspaceSessions(REPO_ROOT);
|
||||
// Explicit `s` type for resilience against a stale dist .d.ts
|
||||
// in the reviewer's tsc env (see same note in routes.test.ts).
|
||||
expect(
|
||||
remaining.find(
|
||||
(s: DaemonSessionSummary) => s.sessionId === session.sessionId,
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Retry must spawn fresh, not reuse the corpse.
|
||||
const fresh = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
expect(fresh.sessionId).not.toBe(session.sessionId);
|
||||
expect(fresh.attached).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describeLLM('qwen serve — multi-client first-responder permission', () => {
|
||||
it('fans out permission_request to both subscribers; only one vote wins', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
|
||||
const ac1 = new AbortController();
|
||||
const ac2 = new AbortController();
|
||||
const seen1: DaemonEvent[] = [];
|
||||
const seen2: DaemonEvent[] = [];
|
||||
const sub1 = (async () => {
|
||||
try {
|
||||
for await (const e of sseFrames(session.sessionId, {
|
||||
signal: ac1.signal,
|
||||
})) {
|
||||
seen1.push(e);
|
||||
if (e.type === 'permission_resolved') break;
|
||||
}
|
||||
} catch {
|
||||
/* aborted */
|
||||
}
|
||||
})();
|
||||
const sub2 = (async () => {
|
||||
try {
|
||||
for await (const e of sseFrames(session.sessionId, {
|
||||
signal: ac2.signal,
|
||||
})) {
|
||||
seen2.push(e);
|
||||
if (e.type === 'permission_resolved') break;
|
||||
}
|
||||
} catch {
|
||||
/* aborted */
|
||||
}
|
||||
})();
|
||||
// Let the subscribers register before firing the prompt.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const tmp = `/tmp/qwen-serve-mc-${Date.now()}.txt`;
|
||||
const promptTask = client.prompt(session.sessionId, {
|
||||
prompt: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Please create a file at ${tmp} with contents "fan-out". After the tool runs, stop.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for both subscribers to see permission_request.
|
||||
const t0 = Date.now();
|
||||
let req1: DaemonEvent | undefined;
|
||||
let req2: DaemonEvent | undefined;
|
||||
while (Date.now() - t0 < 30_000 && (!req1 || !req2)) {
|
||||
req1 = req1 ?? seen1.find((e) => e.type === 'permission_request');
|
||||
req2 = req2 ?? seen2.find((e) => e.type === 'permission_request');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(req1).toBeDefined();
|
||||
expect(req2).toBeDefined();
|
||||
const data1 = req1!.data as {
|
||||
requestId: string;
|
||||
options: Array<{ optionId: string; kind: string }>;
|
||||
};
|
||||
const data2 = req2!.data as { requestId: string };
|
||||
expect(data1.requestId).toBe(data2.requestId);
|
||||
|
||||
const optionId =
|
||||
data1.options.find((o) => o.kind === 'allow_once')?.optionId ??
|
||||
data1.options[0]?.optionId;
|
||||
|
||||
// Race two concurrent votes — exactly one should win.
|
||||
const [voteA, voteB] = await Promise.all([
|
||||
fetch(`${base}/permission/${data1.requestId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ outcome: { outcome: 'selected', optionId } }),
|
||||
}),
|
||||
fetch(`${base}/permission/${data1.requestId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ outcome: { outcome: 'selected', optionId } }),
|
||||
}),
|
||||
]);
|
||||
expect([voteA.status, voteB.status].sort()).toEqual([200, 404]);
|
||||
|
||||
// Wait for the prompt to complete (either succeed or time out).
|
||||
await Promise.race([
|
||||
promptTask.catch(() => undefined),
|
||||
new Promise((r) => setTimeout(r, 30_000)),
|
||||
]);
|
||||
ac1.abort();
|
||||
ac2.abort();
|
||||
await Promise.all([sub1, sub2]);
|
||||
try {
|
||||
execSync(`rm -f ${tmp}`);
|
||||
} catch {
|
||||
/* file may not exist if the tool didn't run */
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describeLLM('qwen serve — Last-Event-ID resume', () => {
|
||||
it('reconnect with Last-Event-ID:N yields events with id > N', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: REPO_ROOT,
|
||||
});
|
||||
|
||||
// Fire a short prompt to populate the bus.
|
||||
await client.prompt(session.sessionId, {
|
||||
prompt: [{ type: 'text', text: 'just say hi briefly, no tool calls' }],
|
||||
});
|
||||
|
||||
// First connection: replay everything from lastEventId=0; pick up 2.
|
||||
const ac1 = new AbortController();
|
||||
const replay: DaemonEvent[] = [];
|
||||
for await (const e of sseFrames(session.sessionId, {
|
||||
lastEventId: 0,
|
||||
signal: ac1.signal,
|
||||
})) {
|
||||
replay.push(e);
|
||||
if (replay.length === 2) break;
|
||||
}
|
||||
ac1.abort();
|
||||
expect(replay.length).toBe(2);
|
||||
expect(replay[0].id).toBeDefined();
|
||||
expect(replay[1].id).toBeDefined();
|
||||
expect(replay[1].id!).toBeGreaterThan(replay[0].id!);
|
||||
|
||||
// Reconnect with Last-Event-ID = the second frame's id; first event
|
||||
// received MUST have id > that.
|
||||
const lastId = replay[1].id!;
|
||||
const ac2 = new AbortController();
|
||||
let resumedFirst: DaemonEvent | undefined;
|
||||
for await (const e of sseFrames(session.sessionId, {
|
||||
lastEventId: lastId,
|
||||
signal: ac2.signal,
|
||||
})) {
|
||||
resumedFirst = e;
|
||||
break;
|
||||
}
|
||||
ac2.abort();
|
||||
expect(resumedFirst).toBeDefined();
|
||||
expect(resumedFirst!.id).toBeDefined();
|
||||
expect(resumedFirst!.id!).toBeGreaterThan(lastId);
|
||||
}, 60_000);
|
||||
});
|
||||
185
package-lock.json
generated
185
package-lock.json
generated
|
|
@ -2187,6 +2187,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -2761,6 +2773,15 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
|
|
@ -3883,6 +3904,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
|
|
@ -4047,6 +4074,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
|
@ -4223,6 +4256,28 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
|
||||
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tar": {
|
||||
"version": "6.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz",
|
||||
|
|
@ -5507,6 +5562,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
|
|
@ -6438,6 +6499,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||
|
|
@ -6598,6 +6668,12 @@
|
|||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
|
|
@ -7029,6 +7105,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
|
|
@ -8523,6 +8609,12 @@
|
|||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
|
|
@ -8833,6 +8925,23 @@
|
|||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -11587,7 +11696,6 @@
|
|||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -11606,6 +11714,18 @@
|
|||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
|
|
@ -12934,6 +13054,7 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
|
|
@ -15072,6 +15193,64 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
|
|
@ -17048,6 +17227,7 @@
|
|||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"express": "^5.2.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
@ -17082,18 +17262,21 @@
|
|||
"@types/command-exists": "^1.2.3",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"express": "^5.2.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
@ -87,18 +88,21 @@
|
|||
"@types/command-exists": "^1.2.3",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
|
|
|||
124
packages/cli/src/commands/serve.ts
Normal file
124
packages/cli/src/commands/serve.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Argv, CommandModule } from 'yargs';
|
||||
// Type-only imports — no runtime cost. The serve module pulls in express +
|
||||
// body-parser + qs + the daemon transport stack; static-importing it from
|
||||
// here would tax every `qwen` invocation (interactive, mcp, channel, etc.)
|
||||
// with ~50ms of cold ESM resolution. The runtime import is deferred to the
|
||||
// handler below so it only loads when the user actually runs `qwen serve`.
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
|
||||
/**
|
||||
* Pause the current async function indefinitely. Used after the daemon
|
||||
* listener is up so yargs `parse()` never resolves — if it did, the
|
||||
* top-level CLI would fall through to the interactive (TUI) entry point
|
||||
* in `gemini.tsx`. SIGINT / SIGTERM in `runQwenServe` is the sole exit
|
||||
* route. Named so a future maintainer doesn't read the bare
|
||||
* `new Promise<never>(() => {})` as a bug (BRQQZ).
|
||||
*/
|
||||
function blockForever(): Promise<never> {
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
|
||||
interface ServeArgs {
|
||||
port: number;
|
||||
hostname: string;
|
||||
token?: string;
|
||||
'max-sessions': number;
|
||||
'max-connections': number;
|
||||
// Read from the kebab-case key only — the camelCase mirror that yargs
|
||||
// synthesizes is convenient for handlers but type-confusing here. The
|
||||
// handler reads `argv['http-bridge']` directly.
|
||||
'http-bridge': boolean;
|
||||
}
|
||||
|
||||
export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
command: 'serve',
|
||||
describe:
|
||||
'Run Qwen Code as a local HTTP daemon (Stage 1 experimental: --http-bridge)',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.option('port', {
|
||||
type: 'number',
|
||||
default: 4170,
|
||||
description:
|
||||
'TCP port to bind (use 0 for an OS-assigned ephemeral port)',
|
||||
})
|
||||
.option('hostname', {
|
||||
type: 'string',
|
||||
default: '127.0.0.1',
|
||||
description:
|
||||
'Interface to bind. Loopback (127.0.0.1, localhost, ::1, [::1]) is auth-free; anything else requires a token.',
|
||||
})
|
||||
.option('token', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Bearer token required on every request. Falls back to the QWEN_SERVER_TOKEN env var.',
|
||||
})
|
||||
.option('max-sessions', {
|
||||
type: 'number',
|
||||
default: 20,
|
||||
description:
|
||||
'Cap on concurrent live sessions. New spawn requests beyond this return 503; ' +
|
||||
'attach to existing sessions still works. Set to 0 to disable.',
|
||||
})
|
||||
.option('max-connections', {
|
||||
type: 'number',
|
||||
default: 256,
|
||||
description:
|
||||
'Listener-level TCP connection cap (server.maxConnections). Bounds raw ' +
|
||||
'sockets — slow/phantom SSE clients get rejected at accept time once full. ' +
|
||||
'Set to 0 to disable.',
|
||||
})
|
||||
.option('http-bridge', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Stage 1 mode: one `qwen --acp` child per workspace behind the HTTP routes, ' +
|
||||
"with multiple sessions multiplexed onto each child via the agent's native " +
|
||||
'`newSession()`. Stage 2 native in-process mode is not yet implemented; ' +
|
||||
'this flag will become opt-in then.',
|
||||
}) as unknown as Argv<ServeArgs>,
|
||||
handler: async (argv) => {
|
||||
if (!argv['http-bridge']) {
|
||||
writeStderrLine(
|
||||
'qwen serve: --no-http-bridge (native mode) is not yet implemented; ' +
|
||||
'falling back to http-bridge.',
|
||||
);
|
||||
}
|
||||
if (argv.token) {
|
||||
// `--token` is visible to any local user via `/proc/<pid>/cmdline`
|
||||
// (Linux default; only suppressed under `hidepid=2`). Steer
|
||||
// operators toward the env-var path which uses
|
||||
// `/proc/<pid>/environ` (owner-only).
|
||||
writeStderrLine(
|
||||
'qwen serve: --token is visible in the process command line; ' +
|
||||
'prefer the QWEN_SERVER_TOKEN env var for any non-trivial ' +
|
||||
'deployment.',
|
||||
);
|
||||
}
|
||||
// Lazy-load the serve module so non-serve invocations don't pay for
|
||||
// express + body-parser + qs in their startup path.
|
||||
const { runQwenServe } = await import('../serve/index.js');
|
||||
try {
|
||||
await runQwenServe({
|
||||
port: argv.port,
|
||||
hostname: argv.hostname,
|
||||
token: argv.token,
|
||||
mode: 'http-bridge',
|
||||
maxSessions: argv['max-sessions'],
|
||||
maxConnections: argv['max-connections'],
|
||||
});
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await blockForever();
|
||||
},
|
||||
};
|
||||
|
|
@ -57,6 +57,7 @@ import { mcpCommand } from '../commands/mcp.js';
|
|||
import { channelCommand } from '../commands/channel.js';
|
||||
import { authCommand } from '../commands/auth.js';
|
||||
import { reviewCommand } from '../commands/review.js';
|
||||
import { serveCommand } from '../commands/serve.js';
|
||||
|
||||
// UUID v4 regex pattern for validation
|
||||
const SESSION_ID_REGEX =
|
||||
|
|
@ -965,7 +966,9 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// Register Channel subcommands
|
||||
.command(channelCommand)
|
||||
// Register /review skill helpers (presubmit checks, cleanup)
|
||||
.command(reviewCommand);
|
||||
.command(reviewCommand)
|
||||
// Register `qwen serve` (Stage 1 daemon — see issue #3803)
|
||||
.command(serveCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
|
|
@ -991,6 +994,9 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
result._[0] === 'channel' ||
|
||||
result._[0] === 'review')
|
||||
) {
|
||||
// Note: `serve` is intentionally NOT in this list. Its handler blocks
|
||||
// forever (after the listener is up); SIGINT/SIGTERM in runQwenServe
|
||||
// drives shutdown. Hitting `process.exit(0)` here would kill the daemon.
|
||||
// MCP/Extensions/Auth/Hooks/Channel/Review commands handle their own
|
||||
// execution and exit. Returning here would let the main interactive
|
||||
// flow run, which would prompt for stdin input despite the user
|
||||
|
|
|
|||
161
packages/cli/src/serve/auth.ts
Normal file
161
packages/cli/src/serve/auth.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { isLoopbackBind } from './loopbackBinds.js';
|
||||
|
||||
/**
|
||||
* Reject any request that carries an `Origin` header. CLI/SDK clients never
|
||||
* set Origin; only browsers do. Returning a deterministic 403 JSON keeps
|
||||
* the daemon from CSRF-ing itself (and is more useful to clients than the
|
||||
* 500 HTML default that the `cors` package's error-callback path produces
|
||||
* when no Express error middleware is registered).
|
||||
*/
|
||||
export const denyBrowserOriginCors: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (req.headers.origin) {
|
||||
res.status(403).json({ error: 'Request denied by CORS policy' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject requests whose Host header isn't one of the bound interfaces.
|
||||
* Defense against DNS rebinding when the daemon is on loopback.
|
||||
*
|
||||
* `bind` is the hostname the listener was started with. `getPort` is read
|
||||
* lazily on each request because callers commonly request port 0 (ephemeral)
|
||||
* and only learn the actual port once `listen()` has resolved.
|
||||
*/
|
||||
export function hostAllowlist(
|
||||
bind: string,
|
||||
getPort: () => number,
|
||||
): RequestHandler {
|
||||
if (!isLoopbackBind(bind)) {
|
||||
// For non-loopback binds the operator chose the surface area; trust the
|
||||
// bearer token gate to cover Host header spoofing.
|
||||
return (_req: Request, _res: Response, next: NextFunction) => next();
|
||||
}
|
||||
// Cache the allowed-Host Set per port. `getPort()` is invoked
|
||||
// lazily because tests bind to ephemeral port 0 — the actual port
|
||||
// is only known after `listen()` resolves and tests can call
|
||||
// through with a placeholder port that flips later. SSE
|
||||
// heartbeats and high-frequency probes go through this middleware,
|
||||
// so allocating a fresh Set + 4 interpolated strings per request
|
||||
// is wasted work. Rebuild only when the port changes.
|
||||
let cachedPort = -1;
|
||||
let cachedAllowed: Set<string> = new Set();
|
||||
const allowedFor = (port: number): Set<string> => {
|
||||
if (port === cachedPort) return cachedAllowed;
|
||||
cachedPort = port;
|
||||
cachedAllowed = new Set([
|
||||
`localhost:${port}`,
|
||||
`127.0.0.1:${port}`,
|
||||
`[::1]:${port}`,
|
||||
`host.docker.internal:${port}`,
|
||||
]);
|
||||
// RFC 7230 §5.4: clients may omit the port suffix when it matches
|
||||
// the URI scheme's default. http → 80, https → 443. The qwen
|
||||
// serve daemon is plain HTTP, so accept the no-port forms when
|
||||
// we're listening on port 80 (uncommon but valid for an operator
|
||||
// who points at a privileged port for clean URLs).
|
||||
if (port === 80) {
|
||||
cachedAllowed.add('localhost');
|
||||
cachedAllowed.add('127.0.0.1');
|
||||
cachedAllowed.add('[::1]');
|
||||
cachedAllowed.add('host.docker.internal');
|
||||
}
|
||||
return cachedAllowed;
|
||||
};
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const port = getPort();
|
||||
// Per RFC 7230 §5.4, Host is case-insensitive. Express normalizes
|
||||
// header *names* to lowercase but NOT values, so a Docker-proxy
|
||||
// that capitalizes the hostname (`Host: Localhost:4170`) or a
|
||||
// platform with case-preserving DNS (`HOST.docker.internal`) would
|
||||
// get 403 with an exact-string compare. Lowercase both sides.
|
||||
const host = (req.headers.host || '').toLowerCase();
|
||||
if (!allowedFor(port).has(host)) {
|
||||
res.status(403).json({ error: 'Invalid Host header' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer token middleware. When `token` is undefined the gate is open — used
|
||||
* for the loopback-only developer default. `runQwenServe` enforces that any
|
||||
* non-loopback bind has a token.
|
||||
*/
|
||||
export function bearerAuth(token: string | undefined): RequestHandler {
|
||||
if (!token) {
|
||||
return (_req: Request, _res: Response, next: NextFunction) => next();
|
||||
}
|
||||
// Pre-hash the configured token once. Per-request we hash the candidate and
|
||||
// constant-time compare; this avoids leaking byte positions through string
|
||||
// inequality short-circuiting.
|
||||
const expected = createHash('sha256').update(token, 'utf8').digest();
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
// Per RFC 7235 §2.1 / RFC 7230 §3.2.6 the auth scheme token is
|
||||
// case-insensitive — `Bearer` / `bearer` / `BEARER` are all valid.
|
||||
// Lowercase the scheme before comparing; the token value itself
|
||||
// stays case-sensitive (it's user-defined opaque material).
|
||||
//
|
||||
// Hand-rolled split rather than a regex like `^(\S+)\s+(.+)$`
|
||||
// because CodeQL flags the latter as a polynomial-regex risk on
|
||||
// user-controlled input (the `\s+` / `.+` overlap can backtrack
|
||||
// on adversarial whitespace-heavy headers). Two indexOf calls
|
||||
// are O(n) total with no backtracking.
|
||||
const schemeEnd = header.indexOf(' ');
|
||||
if (schemeEnd <= 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const scheme = header.slice(0, schemeEnd).toLowerCase();
|
||||
if (scheme !== 'bearer') {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
// After the initial SP separator (the scheme→credentials boundary
|
||||
// matches RFC 9110 §11.6.2's `1*SP`), skip any extra BWS before
|
||||
// the credentials. RFC 7230 §3.2.6 BWS allows both SP (0x20)
|
||||
// and HTAB (0x09); accept both so a client emitting
|
||||
// `Authorization: Bearer \t<token>` (SP then HTAB) doesn't 401.
|
||||
// Pure-HTAB-as-separator (`Bearer\t<token>`) is still rejected
|
||||
// because the scheme parse uses `indexOf(' ')` — that's
|
||||
// intentional per RFC 9110, not an oversight.
|
||||
let credStart = schemeEnd + 1;
|
||||
while (
|
||||
credStart < header.length &&
|
||||
(header.charCodeAt(credStart) === 0x20 ||
|
||||
header.charCodeAt(credStart) === 0x09)
|
||||
) {
|
||||
credStart++;
|
||||
}
|
||||
const credentials = header.slice(credStart);
|
||||
if (credentials.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const candidate = createHash('sha256').update(credentials, 'utf8').digest();
|
||||
if (!timingSafeEqual(candidate, expected)) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
319
packages/cli/src/serve/eventBus.test.ts
Normal file
319
packages/cli/src/serve/eventBus.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
EventBus,
|
||||
EVENT_SCHEMA_VERSION,
|
||||
type BridgeEvent,
|
||||
} from './eventBus.js';
|
||||
|
||||
async function collect(
|
||||
iter: AsyncIterable<BridgeEvent>,
|
||||
count: number,
|
||||
): Promise<BridgeEvent[]> {
|
||||
const out: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
out.push(e);
|
||||
if (out.length >= count) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('EventBus', () => {
|
||||
it('assigns monotonic ids and the right schema version', () => {
|
||||
const bus = new EventBus();
|
||||
const a = bus.publish({ type: 'foo', data: 1 });
|
||||
const b = bus.publish({ type: 'foo', data: 2 });
|
||||
expect(a?.id).toBe(1);
|
||||
expect(b?.id).toBe(2);
|
||||
expect(a?.v).toBe(EVENT_SCHEMA_VERSION);
|
||||
expect(bus.lastEventId).toBe(2);
|
||||
});
|
||||
|
||||
it('delivers live publishes to a subscriber', async () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ signal: abort.signal });
|
||||
|
||||
// Need to start consuming before publishing so the subscriber is
|
||||
// registered in the loop below.
|
||||
setTimeout(() => {
|
||||
bus.publish({ type: 'foo', data: 'a' });
|
||||
bus.publish({ type: 'foo', data: 'b' });
|
||||
}, 5);
|
||||
|
||||
const events = await collect(iter, 2);
|
||||
expect(events.map((e) => e.data)).toEqual(['a', 'b']);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('replays events newer than lastEventId from the ring', async () => {
|
||||
const bus = new EventBus();
|
||||
bus.publish({ type: 'foo', data: 'a' });
|
||||
bus.publish({ type: 'foo', data: 'b' });
|
||||
bus.publish({ type: 'foo', data: 'c' });
|
||||
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ lastEventId: 1, signal: abort.signal });
|
||||
const events = await collect(iter, 2);
|
||||
expect(events.map((e) => e.id)).toEqual([2, 3]);
|
||||
expect(events.map((e) => e.data)).toEqual(['b', 'c']);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('replay + live: new events follow the replay tail', async () => {
|
||||
const bus = new EventBus();
|
||||
bus.publish({ type: 'foo', data: 'a' });
|
||||
bus.publish({ type: 'foo', data: 'b' });
|
||||
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ lastEventId: 0, signal: abort.signal });
|
||||
|
||||
setTimeout(() => bus.publish({ type: 'foo', data: 'c' }), 5);
|
||||
|
||||
const events = await collect(iter, 3);
|
||||
expect(events.map((e) => e.data)).toEqual(['a', 'b', 'c']);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('fan-outs to multiple subscribers in parallel', async () => {
|
||||
const bus = new EventBus();
|
||||
const aborts = [new AbortController(), new AbortController()];
|
||||
const it1 = bus.subscribe({ signal: aborts[0].signal });
|
||||
const it2 = bus.subscribe({ signal: aborts[1].signal });
|
||||
|
||||
setTimeout(() => {
|
||||
bus.publish({ type: 'foo', data: 1 });
|
||||
bus.publish({ type: 'foo', data: 2 });
|
||||
}, 5);
|
||||
|
||||
const [a, b] = await Promise.all([collect(it1, 2), collect(it2, 2)]);
|
||||
expect(a.map((e) => e.data)).toEqual([1, 2]);
|
||||
expect(b.map((e) => e.data)).toEqual([1, 2]);
|
||||
aborts.forEach((c) => c.abort());
|
||||
});
|
||||
|
||||
it('evicts a slow subscriber when its queue overflows', async () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ maxQueued: 2, signal: abort.signal });
|
||||
|
||||
// Publish 3 events without draining the iterator. Queue cap is 2; the
|
||||
// 3rd should trip the eviction path and append a `client_evicted`
|
||||
// terminal frame.
|
||||
bus.publish({ type: 'foo', data: 1 });
|
||||
bus.publish({ type: 'foo', data: 2 });
|
||||
bus.publish({ type: 'foo', data: 3 });
|
||||
|
||||
const collected: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
collected.push(e);
|
||||
}
|
||||
expect(collected).toHaveLength(3);
|
||||
expect(collected[0]?.data).toBe(1);
|
||||
expect(collected[1]?.data).toBe(2);
|
||||
expect(collected[2]?.type).toBe('client_evicted');
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('eviction detaches the abort listener from a stalled consumer (BmJT1)', async () => {
|
||||
// Pre-fix the eviction path only did `this.subs.delete(sub)`,
|
||||
// leaving the AbortSignal abort-listener attached because the
|
||||
// dispose() closure was never invoked (consumer is stalled
|
||||
// BY DEFINITION — that's what caused the overflow). Retention
|
||||
// amplifies under a thousands-of-stalled-clients attack.
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
// Capture the listener count via the AbortSignal — we add a
|
||||
// sentinel listener and assert our own listener fires (proving
|
||||
// the signal isn't pinned by leaked closures); the eviction
|
||||
// path now invokes dispose() so the bus's own listener
|
||||
// detaches. Use the public `aborted` flag as the proxy for
|
||||
// "after eviction, can I successfully abort and have no
|
||||
// dangling closures keep the bus subscription alive?"
|
||||
const iter = bus.subscribe({ maxQueued: 1, signal: abort.signal });
|
||||
bus.publish({ type: 'foo', data: 1 });
|
||||
bus.publish({ type: 'foo', data: 2 }); // triggers eviction
|
||||
// Bus dropped the subscriber via dispose():
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
// The abort listener is gone — firing abort now should NOT
|
||||
// re-enter the bus's onAbort (which would no-op via the
|
||||
// `disposed` flag, but the listener shouldn't be attached at
|
||||
// all). We can't directly assert listener count without
|
||||
// patching internals, but firing abort + a subsequent publish
|
||||
// should produce zero extra side effects:
|
||||
abort.abort();
|
||||
bus.publish({ type: 'foo', data: 3 });
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
// Drain to make sure the iterator unwinds cleanly with the
|
||||
// terminal frame from the original eviction.
|
||||
const collected: BridgeEvent[] = [];
|
||||
for await (const e of iter) collected.push(e);
|
||||
expect(collected[collected.length - 1]?.type).toBe('client_evicted');
|
||||
});
|
||||
|
||||
it('unsubscribes when the abort signal fires', async () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ signal: abort.signal });
|
||||
|
||||
setTimeout(() => abort.abort(), 5);
|
||||
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
events.push(e);
|
||||
}
|
||||
expect(events).toEqual([]);
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
});
|
||||
|
||||
it('closes all subscribers on bus.close()', async () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ signal: abort.signal });
|
||||
|
||||
setTimeout(() => bus.close(), 5);
|
||||
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
events.push(e);
|
||||
}
|
||||
expect(events).toEqual([]);
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
});
|
||||
|
||||
it('force-pushes replay events past maxQueued so Last-Event-ID is honored', async () => {
|
||||
const bus = new EventBus();
|
||||
for (let i = 1; i <= 10; i++) bus.publish({ type: 'foo', data: i });
|
||||
|
||||
const abort = new AbortController();
|
||||
// Subscribe with maxQueued:2 — way smaller than the replay backlog.
|
||||
// Replay must NOT be silently truncated (a generic queue.push would
|
||||
// drop entries 4-10), otherwise the consumer thinks they caught up
|
||||
// when they didn't.
|
||||
const iter = bus.subscribe({
|
||||
lastEventId: 0,
|
||||
maxQueued: 2,
|
||||
signal: abort.signal,
|
||||
});
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
events.push(e);
|
||||
if (events.length === 10) break;
|
||||
}
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('a live publish AFTER a large replay does NOT evict the resumed subscriber', async () => {
|
||||
// Regression: the original `forcePush` impl bypassed the cap, but the
|
||||
// very next live `push()` saw `buf.length >= maxSize` and triggered
|
||||
// the eviction path — which is exactly the contract `Last-Event-ID`
|
||||
// is supposed to honor. The fix tracks force-pushed items separately
|
||||
// so the cap applies only to the LIVE backlog.
|
||||
const bus = new EventBus();
|
||||
for (let i = 1; i <= 10; i++) bus.publish({ type: 'replay', data: i });
|
||||
|
||||
const abort = new AbortController();
|
||||
// Replay backlog (10) is well above the cap (2). Without the fix,
|
||||
// the next live publish below would evict the subscriber.
|
||||
const iter = bus.subscribe({
|
||||
lastEventId: 0,
|
||||
maxQueued: 2,
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
// Now publish a LIVE event. Reviewer's concrete sequence:
|
||||
// - push() check `buf.length - forcedInBuf >= maxSize`
|
||||
// - = (10 - 10) >= 2 → false → push accepted, buf becomes 11.
|
||||
bus.publish({ type: 'live', data: 'after-replay' });
|
||||
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
events.push(e);
|
||||
if (events.length === 11) break;
|
||||
}
|
||||
// The live frame must arrive — NOT a `client_evicted` terminal.
|
||||
expect(events.find((e) => e.type === 'client_evicted')).toBeUndefined();
|
||||
expect(events.at(-1)?.type).toBe('live');
|
||||
expect(events.filter((e) => e.type === 'replay')).toHaveLength(10);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
it('drops live publishes only after the LIVE backlog (excluding replay) hits maxQueued', async () => {
|
||||
const bus = new EventBus();
|
||||
for (let i = 1; i <= 5; i++) bus.publish({ type: 'replay', data: i });
|
||||
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({
|
||||
lastEventId: 0,
|
||||
maxQueued: 2,
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
// Two live pushes fit (live cap = 2); the third overflows the LIVE
|
||||
// cap (5 replay don't count) and triggers eviction.
|
||||
bus.publish({ type: 'live', data: 'a' });
|
||||
bus.publish({ type: 'live', data: 'b' });
|
||||
bus.publish({ type: 'live', data: 'c' });
|
||||
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) events.push(e);
|
||||
// 5 replay + 2 live + 1 eviction terminal = 8 frames; the third live
|
||||
// is the one that triggered overflow.
|
||||
expect(events.find((e) => e.type === 'client_evicted')).toBeDefined();
|
||||
const liveCount = events.filter((e) => e.type === 'live').length;
|
||||
expect(liveCount).toBe(2);
|
||||
});
|
||||
|
||||
it('disposes the subscription immediately when the abort signal fires', async () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ signal: abort.signal });
|
||||
expect(bus.subscriberCount).toBe(1);
|
||||
|
||||
abort.abort();
|
||||
// Without an explicit dispose-on-abort path, the subscriber would
|
||||
// linger in `bus.subs` until the consumer drove next() or return().
|
||||
// Here the consumer never iterates — the abort alone must clean up.
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
|
||||
// The iterator still resolves cleanly when it eventually runs.
|
||||
const events: BridgeEvent[] = [];
|
||||
for await (const e of iter) events.push(e);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('disposes immediately when the signal is already aborted at subscribe', () => {
|
||||
const bus = new EventBus();
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
bus.subscribe({ signal: abort.signal });
|
||||
expect(bus.subscriberCount).toBe(0);
|
||||
});
|
||||
|
||||
it('drops the oldest events from the ring beyond ringSize', async () => {
|
||||
const bus = new EventBus(3);
|
||||
for (let i = 1; i <= 5; i++) bus.publish({ type: 'foo', data: i });
|
||||
// Internal: only the last 3 should be replayable.
|
||||
// Subscribe with lastEventId=0 — only ids 3, 4, 5 should be queued.
|
||||
const abort = new AbortController();
|
||||
const iter = bus.subscribe({ lastEventId: 0, signal: abort.signal });
|
||||
|
||||
// Must `await` the iteration: the prior `void (async () => …)()` form
|
||||
// returned synchronously to vitest, so the assertion below could
|
||||
// silently pass even if the ring eviction logic was broken.
|
||||
const out: BridgeEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
out.push(e);
|
||||
if (out.length === 3) break;
|
||||
}
|
||||
expect(out.map((e) => e.id)).toEqual([3, 4, 5]);
|
||||
abort.abort();
|
||||
});
|
||||
});
|
||||
473
packages/cli/src/serve/eventBus.ts
Normal file
473
packages/cli/src/serve/eventBus.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event-bus for the daemon's per-session NDJSON stream.
|
||||
*
|
||||
* Design notes (from issue #3803 §04 / threat-model):
|
||||
* - Each event carries a monotonic `id` (per session) so the SSE
|
||||
* `Last-Event-ID` reconnect protocol can pick up where the client left
|
||||
* off. Backed by a bounded ring of recent events for replay.
|
||||
* - Subscribers use bounded async queues. A slow subscriber that blows
|
||||
* past its queue limit is sent a final `client_evicted` event and
|
||||
* closed; this keeps a stuck client from holding the daemon hostage
|
||||
* (per the resource-exhaustion entry in the threat-model summary).
|
||||
* - The bus is push-based; consumers iterate the returned AsyncIterable.
|
||||
* Aborting the supplied AbortSignal closes the iterator promptly.
|
||||
*/
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
/** A single frame published on the bus. */
|
||||
export interface BridgeEvent {
|
||||
/**
|
||||
* Monotonic per-session id, starting at 1. Absent on synthetic
|
||||
* terminal frames (e.g. `client_evicted`) so they don't burn a slot
|
||||
* in the sequence other subscribers observe — the gap would be
|
||||
* visible on the live stream and the resume ring wouldn't have the
|
||||
* skipped id either, silently breaking contiguity.
|
||||
*/
|
||||
id?: number;
|
||||
/** Schema version; bumped on breaking frame changes. */
|
||||
v: typeof EVENT_SCHEMA_VERSION;
|
||||
/** Frame type: `session_update`, `client_evicted`, or daemon-pushed events. */
|
||||
type: string;
|
||||
/** Frame payload — opaque JSON. */
|
||||
data: unknown;
|
||||
/**
|
||||
* Identifier of the client that triggered the event, when known. Used by
|
||||
* fan-out consumers to suppress echoes of their own actions.
|
||||
*/
|
||||
originatorClientId?: string;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/**
|
||||
* Resume from after this event id. Events with `id <= lastEventId` are
|
||||
* skipped (already delivered); newer events still buffered in the ring
|
||||
* are replayed before live events flow.
|
||||
*/
|
||||
lastEventId?: number;
|
||||
/** Aborts the subscription cleanly. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Per-subscriber backlog cap. When exceeded the subscriber is evicted
|
||||
* with a final `client_evicted` event. Defaults to 256.
|
||||
*/
|
||||
maxQueued?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_QUEUED = 256;
|
||||
/**
|
||||
* Default replay-ring depth per session. Sized for a 5-second
|
||||
* reconnect window over a chatty turn — a single long-running prompt
|
||||
* can emit hundreds of frames (test plan reports 13 for a short
|
||||
* turn, real workloads can be 10× that or more once tool-call /
|
||||
* thought streams pile up). 1000 was the original default and could
|
||||
* be exhausted by a moderate turn before the client reconnected;
|
||||
* 4000 gives ~30× headroom over a typical-but-busy turn at the cost
|
||||
* of a few hundred KB of RAM per session.
|
||||
*/
|
||||
const DEFAULT_RING_SIZE = 4000;
|
||||
/**
|
||||
* Per-bus subscriber cap. With per-subscriber `maxQueued` defaulting to
|
||||
* 256 frames, 64 concurrent subscribers caps the per-session subscriber
|
||||
* memory at ~64 × 256 = 16k queued frames (worst case). Keeps a single
|
||||
* session from being opened thousands of times by an attacker to amplify
|
||||
* each `publish()` (which is O(N) over subscribers) into a CPU/memory
|
||||
* DoS. Daemon's HTTP listener also wants `server.maxConnections`
|
||||
* configured at the listener level — see `runQwenServe.ts`.
|
||||
*/
|
||||
const DEFAULT_MAX_SUBSCRIBERS = 64;
|
||||
|
||||
interface InternalSub {
|
||||
queue: BoundedAsyncQueue<BridgeEvent>;
|
||||
evicted: boolean;
|
||||
/**
|
||||
* BmJT1: cleanup hook for the eviction path (overflow → close queue
|
||||
* → remove from `subs`). Without this, the abort listener registered
|
||||
* in `subscribe()` would stay attached against the consumer's
|
||||
* AbortSignal — and the consumer is by definition stalled (that's
|
||||
* what caused the overflow), so `next()` / `return()` / consumer's
|
||||
* own abort never fire to detach it. Closures over the queue +
|
||||
* signal stay live until the AbortSignal itself goes out of scope.
|
||||
* The eviction path calls this to break that retention.
|
||||
*/
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `EventBus.subscribe()` when the per-bus subscriber cap
|
||||
* has been reached. The SSE route catches this and surfaces a
|
||||
* `stream_error` frame so rejected clients see a readable failure
|
||||
* rather than a silent empty stream.
|
||||
*/
|
||||
export class SubscriberLimitExceededError extends Error {
|
||||
readonly limit: number;
|
||||
constructor(limit: number) {
|
||||
super(`EventBus subscriber limit reached (${limit})`);
|
||||
this.name = 'SubscriberLimitExceededError';
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(stage-1.5, chiga0 finding 2):
|
||||
// `EventBus` is currently private to the SSE route handler. Stage 1.5
|
||||
// should lift it to a top-level building block (likely
|
||||
// `packages/event-bus`) so other agent-exposing surfaces
|
||||
// (`channels/`, `dualOutput/`, `remoteInput/`, future TUI co-host
|
||||
// and WebSocket transports) subscribe through the same bus instead
|
||||
// of running parallel event streams. The `BridgeEvent` shape is
|
||||
// already close to what's needed; what's missing is the bus being
|
||||
// publicly addressable. Reference:
|
||||
// https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427773706
|
||||
export class EventBus {
|
||||
private nextId = 1;
|
||||
private readonly ring: BridgeEvent[] = [];
|
||||
private readonly subs = new Set<InternalSub>();
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly ringSize: number = DEFAULT_RING_SIZE,
|
||||
private readonly maxSubscribers: number = DEFAULT_MAX_SUBSCRIBERS,
|
||||
) {}
|
||||
|
||||
/** Most recent id ever assigned by `publish`. 0 if no events published. */
|
||||
get lastEventId(): number {
|
||||
return this.nextId - 1;
|
||||
}
|
||||
|
||||
/** Snapshot of the live subscriber count. */
|
||||
get subscriberCount(): number {
|
||||
return this.subs.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to the bus. Returns the constructed `BridgeEvent`
|
||||
* (with `id` + `v` assigned) on success, or `undefined` when the
|
||||
* bus is closed.
|
||||
*
|
||||
* **Never throws** (BX9_p contract). Closing the bus mid-publish
|
||||
* is the only abnormal path and is handled as a return-undefined
|
||||
* no-op; subscriber-enqueue failures are caught internally and
|
||||
* translated to per-subscriber eviction. Call sites can rely on
|
||||
* this — the historical `try { publish(...) } catch {}` blocks in
|
||||
* `httpAcpBridge.ts` are defense-in-depth, not load-bearing, and
|
||||
* may be removed in a future cleanup pass without changing
|
||||
* behavior. Don't add new try/catch wrappers around `publish()`.
|
||||
*/
|
||||
publish(input: Omit<BridgeEvent, 'id' | 'v'>): BridgeEvent | undefined {
|
||||
// Publishing against a closed bus is a no-op rather than a throw.
|
||||
// The shutdown path closes per-session buses *before* awaiting
|
||||
// `channel.kill()`, which leaves a small window where the agent can
|
||||
// still emit a `sessionUpdate` notification or fire a
|
||||
// `requestPermission`. Throwing here would force every call site to
|
||||
// wrap publish in try/catch — and would corrupt state in
|
||||
// `BridgeClient.requestPermission`, where the daemon-wide pending
|
||||
// map mutation runs *before* the publish (see executor in
|
||||
// `httpAcpBridge.ts`). Returning undefined keeps callers
|
||||
// straightforward; nobody can observe a frame nobody can subscribe
|
||||
// to anyway.
|
||||
if (this.closed) return undefined;
|
||||
const event: BridgeEvent = {
|
||||
id: this.nextId++,
|
||||
v: EVENT_SCHEMA_VERSION,
|
||||
...input,
|
||||
};
|
||||
this.ring.push(event);
|
||||
// Eviction-by-shift is O(n) once the ring is full. With ringSize=4000
|
||||
// and per-publish work measured in hundreds of microseconds even on
|
||||
// chatty sessions, this isn't a real hotspot today. A circular-buffer
|
||||
// refactor would push it to O(1) but adds index bookkeeping; deferred
|
||||
// until profiling actually flags it.
|
||||
if (this.ring.length > this.ringSize) this.ring.shift();
|
||||
// Snapshot the subscribers so an in-loop `this.subs.delete(sub)`
|
||||
// (the new immediate-eviction cleanup below) doesn't mutate the
|
||||
// Set we're iterating.
|
||||
for (const sub of Array.from(this.subs)) {
|
||||
if (sub.evicted) continue;
|
||||
if (!sub.queue.push(event)) {
|
||||
sub.evicted = true;
|
||||
// Synthetic terminal frame: NO `id` field. Otherwise it would
|
||||
// burn a slot in the per-session monotonic sequence (`nextId++`)
|
||||
// visible to every OTHER subscriber as a gap (3 → 5, missing 4).
|
||||
// Healthy subscribers would see the gap on the live stream and
|
||||
// on `Last-Event-ID: 3` resume the ring has no record of 4
|
||||
// either — silently broken contiguity contradicts the
|
||||
// `BridgeEvent.id` doc-comment. Same pattern as `stream_error`
|
||||
// in server.ts; `formatSseFrame` omits the `id:` line when
|
||||
// `id` is absent.
|
||||
const evictionFrame: BridgeEvent = {
|
||||
v: EVENT_SCHEMA_VERSION,
|
||||
type: 'client_evicted',
|
||||
data: { reason: 'queue_overflow', droppedAfter: event.id },
|
||||
};
|
||||
// Force-push the eviction frame; close immediately after so the
|
||||
// consumer iterator unwinds with a final synthetic event.
|
||||
sub.queue.forcePush(evictionFrame);
|
||||
sub.queue.close();
|
||||
// BmJT1: dispose the subscription cleanly. `sub.dispose()`
|
||||
// both removes from `this.subs` AND detaches the
|
||||
// AbortSignal listener that `subscribe()` registered. Pre-
|
||||
// fix the eviction path only did `this.subs.delete(sub)`,
|
||||
// leaving the abort listener attached against the stalled
|
||||
// consumer's signal — the queue + sub closures were
|
||||
// retained until the AbortSignal itself went out of scope.
|
||||
// Under attack (thousands of stalled SSE clients) this
|
||||
// amplified into significant heap retention.
|
||||
sub.dispose();
|
||||
}
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: registration is synchronous — by the time `subscribe()` returns,
|
||||
* the subscriber is already attached and will receive any subsequent
|
||||
* `publish()` even if the consumer hasn't started iterating yet. (A
|
||||
* generator-style implementation would defer registration to the first
|
||||
* `next()` call, which races with publishes that happen before the
|
||||
* consumer's first await.)
|
||||
*
|
||||
* The returned iterator is NOT safe to drive from concurrent callers —
|
||||
* two simultaneous `.next()` calls would race for the same event from
|
||||
* the underlying queue. Daemon usage is sequential (`for await ... of`
|
||||
* inside the SSE route), so this is safe in production. Callers that
|
||||
* fan an iterator out to multiple consumers must serialize themselves.
|
||||
*/
|
||||
subscribe(opts: SubscribeOptions = {}): AsyncIterable<BridgeEvent> {
|
||||
if (this.closed) {
|
||||
return emptyAsyncIterable<BridgeEvent>();
|
||||
}
|
||||
// Per-bus subscriber cap: refuse rather than admit a subscriber
|
||||
// that would push us past the limit. An accepted-but-immediately-
|
||||
// evicted alternative would still pay the `BoundedAsyncQueue`
|
||||
// allocation + the per-publish iteration cost. Throw a typed
|
||||
// error so the SSE route can surface a `stream_error` frame to
|
||||
// the rejected client (rather than returning an empty iterable
|
||||
// that closes silently — that left oncall blind to "some
|
||||
// clients get events, some don't" under load).
|
||||
if (this.subs.size >= this.maxSubscribers) {
|
||||
throw new SubscriberLimitExceededError(this.maxSubscribers);
|
||||
}
|
||||
const queue = new BoundedAsyncQueue<BridgeEvent>(
|
||||
opts.maxQueued ?? DEFAULT_MAX_QUEUED,
|
||||
);
|
||||
|
||||
// `dispose` is assigned below (mutable so the closure can reference
|
||||
// `sub.dispose`); placeholder no-op covers the brief window between
|
||||
// `subs.add(sub)` and the real assignment so an absurdly fast
|
||||
// `publish() → forcePush → close → dispose()` race can't crash.
|
||||
const sub: InternalSub = { queue, evicted: false, dispose: () => {} };
|
||||
this.subs.add(sub);
|
||||
|
||||
if (opts.lastEventId !== undefined) {
|
||||
// Force-push replay frames so they bypass the per-subscriber size
|
||||
// cap. The cap protects against a slow live consumer; replay is
|
||||
// already historical and silently dropping it would undermine the
|
||||
// `Last-Event-ID` resume contract (the consumer would think they
|
||||
// caught up). If the gap really is enormous, the queue will be
|
||||
// primed with a long backlog the consumer drains at its own pace.
|
||||
for (const e of this.ring) {
|
||||
// The ring only ever contains live events (publish() always
|
||||
// assigns an id before pushing to ring), so `e.id` is never
|
||||
// undefined here — but the type system can't see that since
|
||||
// BridgeEvent.id is optional for synthetic terminal frames.
|
||||
// Guard explicitly to keep narrow typing without runtime cost.
|
||||
if (e.id !== undefined && e.id > opts.lastEventId) {
|
||||
queue.forcePush(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
this.subs.delete(sub);
|
||||
opts.signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
sub.dispose = dispose;
|
||||
|
||||
// Abort tears the subscription down immediately, even if the consumer
|
||||
// never iterates again — without this the entry would linger in
|
||||
// `this.subs` until somebody called `next()`/`return()`. Idempotent
|
||||
// through `disposed`, so a double-abort or race with `return()` is
|
||||
// safe.
|
||||
//
|
||||
// `{ drain: false }` so the consumer doesn't keep yielding
|
||||
// already-queued events after the abort — the subscribe doc says
|
||||
// abort closes the iterator "promptly". Draining first contradicts
|
||||
// that contract and adds post-abort work to the SSE route (each
|
||||
// drained event ends up serialized over a socket nobody is
|
||||
// listening to). The eviction path keeps default (drain=true) so
|
||||
// the synthetic `client_evicted` terminal frame still reaches the
|
||||
// consumer.
|
||||
const onAbort = () => {
|
||||
queue.close({ drain: false });
|
||||
dispose();
|
||||
};
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]: (): AsyncIterator<BridgeEvent> => ({
|
||||
async next(): Promise<IteratorResult<BridgeEvent>> {
|
||||
const r = await queue.next();
|
||||
if (r.done) dispose();
|
||||
return r;
|
||||
},
|
||||
async return(): Promise<IteratorResult<BridgeEvent>> {
|
||||
queue.close();
|
||||
dispose();
|
||||
return { value: undefined as unknown as BridgeEvent, done: true };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Close all live subscribers and prevent further `publish`/`subscribe`. */
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const sub of this.subs) sub.queue.close();
|
||||
this.subs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function emptyAsyncIterable<T>(): AsyncIterable<T> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: (): AsyncIterator<T> => ({
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
return { value: undefined as unknown as T, done: true };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based bounded queue. `push` returns false (instead of blocking or
|
||||
* throwing) when full so callers can decide how to react — the EventBus uses
|
||||
* that signal to evict slow subscribers.
|
||||
*
|
||||
* The cap (`maxSize`) applies only to LIVE items pushed via `push()`. Items
|
||||
* inserted via `forcePush()` (the `Last-Event-ID` replay path on subscribe
|
||||
* and the terminal `client_evicted` frame) are tracked separately and don't
|
||||
* count toward the cap. Without this split, a reconnect with a large
|
||||
* backlog would force-push ~ringSize entries into `buf`, push `buf.length`
|
||||
* past `maxSize`, and the very next live publish would evict the
|
||||
* just-resumed subscriber — defeating the resume contract.
|
||||
*/
|
||||
class BoundedAsyncQueue<T> {
|
||||
private readonly buf: T[] = [];
|
||||
private readonly resolvers: Array<(v: IteratorResult<T>) => void> = [];
|
||||
private closed = false;
|
||||
/**
|
||||
* Number of force-pushed items still in `buf`. The cap check in
|
||||
* `push()` only applies to LIVE items; this counter tells us how
|
||||
* many slots in `buf` are replay-injected and shouldn't count.
|
||||
*
|
||||
* Position invariant: under the bus's two callers,
|
||||
* 1. subscribe-time replay (`Last-Event-ID` resume) — forcePush
|
||||
* fires BEFORE any live `push()`, so replay items are at the
|
||||
* front of `buf`;
|
||||
* 2. eviction terminal frame — forcePush fires AFTER `push()`
|
||||
* rejection, then `close()` is called immediately, so the
|
||||
* eviction frame is at the BACK of `buf`.
|
||||
*
|
||||
* `next()` decrements `forcedInBuf` whenever the counter is > 0 on
|
||||
* shift, which is correct for case (1). For case (2) it slightly
|
||||
* misaccounts (decrements on the first live shift), but that's
|
||||
* harmless: the queue is closed so no `push()` runs the cap check
|
||||
* again. The counter only matters for live cap enforcement.
|
||||
*/
|
||||
private forcedInBuf = 0;
|
||||
|
||||
constructor(private readonly maxSize: number) {}
|
||||
|
||||
/** Returns true if accepted, false if dropped due to overflow. */
|
||||
push(value: T): boolean {
|
||||
if (this.closed) return false;
|
||||
const r = this.resolvers.shift();
|
||||
if (r) {
|
||||
r({ value, done: false });
|
||||
return true;
|
||||
}
|
||||
// Cap is on the LIVE backlog only.
|
||||
if (this.buf.length - this.forcedInBuf >= this.maxSize) return false;
|
||||
this.buf.push(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bypasses the size cap. Used for replay frames and terminal eviction. */
|
||||
forcePush(value: T): void {
|
||||
if (this.closed) return;
|
||||
const r = this.resolvers.shift();
|
||||
if (r) {
|
||||
r({ value, done: false });
|
||||
return;
|
||||
}
|
||||
this.buf.push(value);
|
||||
this.forcedInBuf += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the queue closed. By default `next()` continues to drain
|
||||
* any items already in `buf` before returning `done: true` —
|
||||
* that's what the eviction path relies on (the synthetic
|
||||
* `client_evicted` frame is force-pushed THEN close is called,
|
||||
* and we want the consumer to see the terminal frame before the
|
||||
* iterator unwinds).
|
||||
*
|
||||
* Pass `{ drain: false }` to drop buffered items immediately
|
||||
* (the AbortSignal-driven unsubscribe path uses this — the
|
||||
* subscribe docstring says abort should close the iterator
|
||||
* promptly, but draining hundreds of queued events first
|
||||
* contradicts that and adds post-abort work to the SSE route).
|
||||
*/
|
||||
close(opts: { drain?: boolean } = {}): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (opts.drain === false) {
|
||||
// Truncate the buffer so subsequent `next()` calls see the
|
||||
// closed sentinel immediately.
|
||||
this.buf.length = 0;
|
||||
this.forcedInBuf = 0;
|
||||
}
|
||||
while (this.resolvers.length > 0) {
|
||||
this.resolvers.shift()!({
|
||||
value: undefined as unknown as T,
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next(): Promise<IteratorResult<T>> {
|
||||
// Length check first — `buf.shift() !== undefined` would mis-handle a
|
||||
// queue whose element type legitimately includes `undefined`. The bus
|
||||
// never pushes undefined today, but the queue is generic.
|
||||
if (this.buf.length > 0) {
|
||||
const value = this.buf.shift() as T;
|
||||
// Force-pushed entries are FIFO at the front of `buf` (forcePush
|
||||
// only happens at subscribe time, before any live push). So as long
|
||||
// as `forcedInBuf > 0` the shifted item is a replay frame.
|
||||
if (this.forcedInBuf > 0) this.forcedInBuf -= 1;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (this.closed) {
|
||||
return Promise.resolve({
|
||||
value: undefined as unknown as T,
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => this.resolvers.push(resolve));
|
||||
}
|
||||
}
|
||||
2555
packages/cli/src/serve/httpAcpBridge.test.ts
Normal file
2555
packages/cli/src/serve/httpAcpBridge.test.ts
Normal file
File diff suppressed because it is too large
Load diff
2464
packages/cli/src/serve/httpAcpBridge.ts
Normal file
2464
packages/cli/src/serve/httpAcpBridge.ts
Normal file
File diff suppressed because it is too large
Load diff
37
packages/cli/src/serve/index.ts
Normal file
37
packages/cli/src/serve/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { createServeApp, type ServeAppDeps } from './server.js';
|
||||
export {
|
||||
runQwenServe,
|
||||
type RunHandle,
|
||||
type RunQwenServeDeps,
|
||||
} from './runQwenServe.js';
|
||||
export {
|
||||
CAPABILITIES_SCHEMA_VERSION,
|
||||
STAGE1_FEATURES,
|
||||
type CapabilitiesEnvelope,
|
||||
type ServeMode,
|
||||
type ServeOptions,
|
||||
type Stage1Feature,
|
||||
} from './types.js';
|
||||
export {
|
||||
createHttpAcpBridge,
|
||||
defaultSpawnChannelFactory,
|
||||
SessionNotFoundError,
|
||||
type AcpChannel,
|
||||
type BridgeOptions,
|
||||
type BridgeSession,
|
||||
type BridgeSpawnRequest,
|
||||
type ChannelFactory,
|
||||
type HttpAcpBridge,
|
||||
} from './httpAcpBridge.js';
|
||||
export {
|
||||
EventBus,
|
||||
EVENT_SCHEMA_VERSION,
|
||||
type BridgeEvent,
|
||||
type SubscribeOptions,
|
||||
} from './eventBus.js';
|
||||
34
packages/cli/src/serve/loopbackBinds.ts
Normal file
34
packages/cli/src/serve/loopbackBinds.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The set of `--hostname` values that are treated as loopback. Both the
|
||||
* runner (boot-time auth-required check) and the request middleware (Host
|
||||
* header allowlist) consult this; keeping the set in one place prevents the
|
||||
* two from drifting apart.
|
||||
*
|
||||
* IPv6 loopback is included so users who prefer `::1`/`[::1]` don't have to
|
||||
* configure a token. We compare against the raw hostname string the operator
|
||||
* typed, not the resolved interface — both must be loopback for the bind to
|
||||
* be auth-free.
|
||||
*/
|
||||
export const LOOPBACK_BINDS: ReadonlySet<string> = new Set([
|
||||
'127.0.0.1',
|
||||
'localhost',
|
||||
'::1',
|
||||
'[::1]',
|
||||
]);
|
||||
|
||||
export function isLoopbackBind(hostname: string): boolean {
|
||||
// Lowercase the operator-supplied hostname so `--hostname Localhost`
|
||||
// / `--hostname LOCALHOST` are treated identically to `localhost`.
|
||||
// The Host-header allowlist (auth.ts) already lowercases the
|
||||
// request-side string before comparing; this aligns boot-time
|
||||
// detection with the runtime check so a valid loopback bind isn't
|
||||
// forced to require a token just because the operator typed a
|
||||
// capital. All entries in `LOOPBACK_BINDS` are already lowercase.
|
||||
return LOOPBACK_BINDS.has(hostname.toLowerCase());
|
||||
}
|
||||
377
packages/cli/src/serve/runQwenServe.ts
Normal file
377
packages/cli/src/serve/runQwenServe.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Server } from 'node:http';
|
||||
import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js';
|
||||
import { createHttpAcpBridge, type HttpAcpBridge } from './httpAcpBridge.js';
|
||||
import { isLoopbackBind } from './loopbackBinds.js';
|
||||
import { createServeApp } from './server.js';
|
||||
import type { ServeOptions } from './types.js';
|
||||
|
||||
const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN';
|
||||
const SHUTDOWN_FORCE_CLOSE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Wrap raw IPv6 literals in brackets so the printed URL is a valid RFC 3986
|
||||
* authority. `host:port` is ambiguous when host contains `:`, so the URL
|
||||
* form requires `[host]:port` for IPv6. Pass-through for IPv4 and DNS
|
||||
* names. Already-bracketed input is left alone.
|
||||
*
|
||||
* RFC 6874 also requires the `%` in an IPv6 zone identifier (e.g.
|
||||
* `fe80::1%lo0`) to be percent-encoded as `%25` so the printed URL is
|
||||
* copy-paste-valid. We do that on raw IPv6 only — already-bracketed
|
||||
* input is the operator's responsibility (don't double-encode if they
|
||||
* pre-formed the URL part themselves).
|
||||
*/
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[')) return host;
|
||||
if (host.includes(':')) {
|
||||
const encoded = host.includes('%') ? host.replace(/%/g, '%25') : host;
|
||||
return `[${encoded}]`;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
server: Server;
|
||||
url: string;
|
||||
bridge: HttpAcpBridge;
|
||||
/** Resolves when the listener has fully closed and the bridge is drained. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunQwenServeDeps {
|
||||
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
|
||||
bridge?: HttpAcpBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate options + start the listener. Resolves once the server is ready
|
||||
* to accept connections.
|
||||
*
|
||||
* Token resolution order:
|
||||
* 1. explicit `opts.token`
|
||||
* 2. `QWEN_SERVER_TOKEN` env var
|
||||
*
|
||||
* Boot refuses to start when bound beyond loopback without a token; this is a
|
||||
* hard rule, not a warning, per the threat model in the design issue.
|
||||
*/
|
||||
export async function runQwenServe(
|
||||
optsIn: Omit<ServeOptions, 'token'> & { token?: string },
|
||||
deps: RunQwenServeDeps = {},
|
||||
): Promise<RunHandle> {
|
||||
// Trim both sources. Common gotcha: `export QWEN_SERVER_TOKEN=$(cat
|
||||
// token.txt)` keeps the file's trailing `\n` in the env value, so the
|
||||
// hashed-then-compared token never matches what well-behaved clients
|
||||
// send. Every request returns the generic 401 with no breadcrumb
|
||||
// pointing at the whitespace, and operators chase ghosts. Trim once
|
||||
// at boot so the comparison is over what humans intended to set.
|
||||
const rawToken = optsIn.token ?? process.env[QWEN_SERVER_TOKEN_ENV];
|
||||
const token =
|
||||
typeof rawToken === 'string' && rawToken.trim().length > 0
|
||||
? rawToken.trim()
|
||||
: undefined;
|
||||
const opts: ServeOptions = { ...optsIn, token };
|
||||
|
||||
// BU-sh: catch the `--hostname localhost:4170` / `127.0.0.1:4170`
|
||||
// typo BEFORE the loopback / token check so the operator sees a
|
||||
// useful "did you mean --port?" message instead of "Refusing to
|
||||
// bind localhost:4170:0 without a bearer token". Unbracketed input
|
||||
// with exactly one `:` is the unambiguous host:port shape — raw
|
||||
// IPv6 literals always have two-or-more `:` (the shortest is `::`),
|
||||
// and bracketed IPv6 is handled by its own form check below.
|
||||
if (!opts.hostname.startsWith('[') && opts.hostname.split(':').length === 2) {
|
||||
const [host, port] = opts.hostname.split(':');
|
||||
throw new Error(
|
||||
`Invalid --hostname "${opts.hostname}": looks like a "host:port" ` +
|
||||
`combination. Use --port for the port, e.g. ` +
|
||||
`"--hostname ${host} --port ${port}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoopbackBind(opts.hostname) && !token) {
|
||||
throw new Error(
|
||||
`Refusing to bind ${opts.hostname}:${opts.port} without a bearer token. ` +
|
||||
`Set ${QWEN_SERVER_TOKEN_ENV} or pass --token, or rebind to loopback ` +
|
||||
`(127.0.0.1, localhost, ::1, or [::1]).`,
|
||||
);
|
||||
}
|
||||
|
||||
const bridge =
|
||||
deps.bridge ?? createHttpAcpBridge({ maxSessions: opts.maxSessions });
|
||||
let actualPort = opts.port;
|
||||
const app = createServeApp(opts, () => actualPort, { bridge });
|
||||
|
||||
// Node's `app.listen()` wants the unbracketed IPv6 literal (`::1`) but
|
||||
// operators conventionally type `[::1]` (or copy/paste from URLs that
|
||||
// need the brackets to disambiguate the port). Strip brackets at
|
||||
// bind-time, keep them for the printed URL — without this fixup
|
||||
// `qwen serve --hostname [::1]` would pass the loopback/token check
|
||||
// and then fail to start with ENOTFOUND.
|
||||
//
|
||||
// Only accept *pure* bracketed forms: `[…]` with no trailing `:port`
|
||||
// suffix. `[2001:db8::1]:8080` is operator-error (port goes through
|
||||
// `--port`, not the hostname) — fail loudly with a useful error
|
||||
// instead of silently stripping to a malformed `2001:db8::1]:8080`.
|
||||
let listenHostname = opts.hostname;
|
||||
if (opts.hostname.startsWith('[')) {
|
||||
const inner = opts.hostname.slice(1, -1);
|
||||
if (
|
||||
!opts.hostname.endsWith(']') ||
|
||||
inner.length === 0 ||
|
||||
inner.includes(']')
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid --hostname "${opts.hostname}": brackets indicate an ` +
|
||||
`IPv6 literal but the value isn't a clean [addr] form. Pass the ` +
|
||||
`address without a trailing :port (use --port for that), e.g. ` +
|
||||
`"--hostname [::1] --port 4170".`,
|
||||
);
|
||||
}
|
||||
// Empty brackets `[]` would have stripped to `''`, which Node treats
|
||||
// as "bind to all interfaces" — the operator's intent was specific,
|
||||
// not wildcard. The check above (`inner.length === 0`) rejects.
|
||||
listenHostname = inner;
|
||||
}
|
||||
|
||||
// BUF9-: validate maxConnections BEFORE binding so a typo fails the
|
||||
// promise instead of escaping as an uncaught exception inside the
|
||||
// listen callback (which fires from the `listening` event after the
|
||||
// outer promise has already resolved). Silent fail-OPEN on NaN /
|
||||
// negative would weaken the DoS/FD-exhaustion guard the cap exists
|
||||
// for.
|
||||
if (opts.maxConnections !== undefined) {
|
||||
if (Number.isNaN(opts.maxConnections)) {
|
||||
throw new TypeError(
|
||||
'Invalid maxConnections: NaN. Must be >= 0 ' +
|
||||
'(0 / Infinity = unlimited).',
|
||||
);
|
||||
}
|
||||
if (opts.maxConnections < 0) {
|
||||
throw new TypeError(
|
||||
`Invalid maxConnections: ${opts.maxConnections}. Must be >= 0 ` +
|
||||
`(0 / Infinity = unlimited).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<RunHandle>((resolve, reject) => {
|
||||
const server = app.listen(opts.port, listenHostname, () => {
|
||||
// Listener-level connection cap, set inside the listen callback
|
||||
// because Node only exposes the underlying `Server` after
|
||||
// `app.listen()` returns. Each session's `EventBus` already
|
||||
// refuses to admit more than `DEFAULT_MAX_SUBSCRIBERS` (64), but
|
||||
// an attacker can still open *connections* that never finish
|
||||
// their headers, never reach the bus, and just sit consuming
|
||||
// socket descriptors. The default of 256 leaves room for many
|
||||
// sessions × many legitimate clients while keeping the FD count
|
||||
// bounded; operators with high-concurrency deployments raise it
|
||||
// via `--max-connections` (BRQQb).
|
||||
//
|
||||
// tanzhenxin issue 1: `0` and `Infinity` are operator-visible
|
||||
// "disable the cap" sentinels — but on Node 22 setting
|
||||
// `server.maxConnections = 0` causes the listener to refuse
|
||||
// EVERY connection (verified on v22.15.0: every fetch fails
|
||||
// with `SocketError: other side closed`). Treat 0 / Infinity
|
||||
// as "leave the property unset" so the documented disable
|
||||
// path actually disables instead of silently bricking the
|
||||
// daemon. NaN / negative are rejected upstream (BUF9-) so
|
||||
// they never reach here.
|
||||
const cap = opts.maxConnections ?? 256;
|
||||
if (cap > 0 && Number.isFinite(cap)) {
|
||||
server.maxConnections = cap;
|
||||
}
|
||||
// else: leave unset (Node's default = unlimited at this layer).
|
||||
const addr = server.address();
|
||||
actualPort = typeof addr === 'object' && addr ? addr.port : opts.port;
|
||||
const url = `http://${formatHostForUrl(opts.hostname)}:${actualPort}`;
|
||||
writeStdoutLine(`qwen serve listening on ${url} (mode=${opts.mode})`);
|
||||
if (!token) {
|
||||
writeStderrLine(
|
||||
`qwen serve: bearer auth disabled (loopback default). Set ${QWEN_SERVER_TOKEN_ENV} to enable.`,
|
||||
);
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
|
||||
// Forward declaration so handle.close can detach the listener after
|
||||
// drain completes. The handler is registered just before `resolve()`.
|
||||
const onSignal = async (signal: NodeJS.Signals) => {
|
||||
if (shuttingDown) {
|
||||
// BSA0K: second signal forces exit. During drain (up to
|
||||
// ~15s for a stuck child + the 5s force-close timer) an
|
||||
// operator's reflexive `^C^C` would otherwise be dropped.
|
||||
// Match standard daemon behavior (nginx, redis, etc.):
|
||||
// first signal = graceful drain; second = hard exit.
|
||||
//
|
||||
// Bd1y6: synchronously SIGKILL every live `qwen --acp`
|
||||
// child BEFORE `process.exit(1)`. Otherwise the daemon
|
||||
// vanishes but its child processes keep running with
|
||||
// dangling stdin/stdout pipes — visible as orphan
|
||||
// `qwen` processes in the operator's `ps` output.
|
||||
writeStderrLine(
|
||||
`qwen serve: received ${signal} during drain — forcing exit`,
|
||||
);
|
||||
try {
|
||||
bridge.killAllSync();
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: force-kill error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
writeStderrLine(`qwen serve: received ${signal}, draining...`);
|
||||
try {
|
||||
await handle.close();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
writeStderrLine(`qwen serve: shutdown error: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handle: RunHandle = {
|
||||
server,
|
||||
url,
|
||||
bridge,
|
||||
close: () => {
|
||||
// Idempotent: cache the in-flight (or settled) close promise so
|
||||
// overlapping calls (e.g. test harness + signal handler firing
|
||||
// simultaneously) all observe the same drain cycle. Without this
|
||||
// each caller would arm its own force-close timer + invoke
|
||||
// bridge.shutdown / server.close redundantly.
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = new Promise<void>((res, rej) => {
|
||||
shuttingDown = true;
|
||||
// NOTE: the SIGINT/SIGTERM handlers stay attached during the
|
||||
// drain. Their `if (shuttingDown) return` guard makes a second
|
||||
// signal a no-op. Detaching them up front would leave Node's
|
||||
// default signal behavior in charge — a second SIGTERM mid-drain
|
||||
// would terminate the process and orphan agent children. We
|
||||
// detach AFTER drain completes (`finish` below).
|
||||
|
||||
// Two-phase shutdown:
|
||||
// 1. `bridge.shutdown()` — tears down agent children with
|
||||
// its own internal `KILL_HARD_DEADLINE_MS` (10s) so
|
||||
// a wedged child can't block forever. We wait
|
||||
// unconditionally; the bridge bounds itself.
|
||||
// 2. `server.close()` — drains in-flight HTTP connections
|
||||
// (long-lived SSE subscribers especially). This is
|
||||
// what `SHUTDOWN_FORCE_CLOSE_MS` actually protects:
|
||||
// a single hung SSE consumer would otherwise pin
|
||||
// the listener open forever.
|
||||
//
|
||||
// Crucially, the force timer is armed AFTER bridge.shutdown
|
||||
// resolves, not at the start of the whole sequence. An
|
||||
// earlier version raced both phases against the same 5s
|
||||
// timer; if the bridge took 5–10s to kill its children
|
||||
// (e.g. SIGTERM grace period), the timer fired first,
|
||||
// resolved this promise, and `process.exit(0)` ran while
|
||||
// the bridge was still tearing children down — orphaning
|
||||
// any that hadn't yet hit `KILL_HARD_DEADLINE_MS`.
|
||||
let settled = false;
|
||||
// BV-qW: track bridge.shutdown failures so close()
|
||||
// doesn't silently report success when the bridge
|
||||
// teardown itself failed. The contract says "resolves
|
||||
// when the listener has fully closed and the bridge is
|
||||
// drained" — propagating the failure lets `onSignal`
|
||||
// exit 1 instead of 0, and lets embedders react.
|
||||
let bridgeShutdownError: Error | undefined;
|
||||
const finish = (err?: Error | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Drain finished (or timed out) — safe to detach now.
|
||||
process.removeListener('SIGINT', onSignal);
|
||||
process.removeListener('SIGTERM', onSignal);
|
||||
// Server.close error takes precedence (operator-visible
|
||||
// listener problem); fall back to the bridge error
|
||||
// captured during shutdown if any.
|
||||
const finalErr = err ?? bridgeShutdownError;
|
||||
if (finalErr) rej(finalErr);
|
||||
else res();
|
||||
};
|
||||
|
||||
bridge
|
||||
.shutdown()
|
||||
.catch((err) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: bridge shutdown error: ${String(err)}`,
|
||||
);
|
||||
bridgeShutdownError =
|
||||
err instanceof Error ? err : new Error(String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
// Phase 2: arm the force timer NOW so it only races
|
||||
// server.close, not the bridge tear-down above.
|
||||
// BUb7h: `RunHandle.close()` contract says "fully
|
||||
// closed and bridge drained" — the previous code
|
||||
// resolved on a 100ms shortcut AFTER
|
||||
// `closeAllConnections()` without waiting for
|
||||
// `server.close`'s callback, so embedders/tests
|
||||
// could observe a "closed" handle while the server
|
||||
// was still finalizing. Now: force-close just
|
||||
// accelerates `server.close` by killing the
|
||||
// sockets, but we still wait for `server.close`'s
|
||||
// callback to fire. A secondary deadline catches
|
||||
// the pathological case where `server.close` never
|
||||
// resolves at all (kernel-stuck socket etc.) so
|
||||
// shutdown is still bounded.
|
||||
const SECONDARY_DEADLINE_MS = 2_000;
|
||||
let secondaryTimer: NodeJS.Timeout | undefined;
|
||||
const forceTimer = setTimeout(() => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${SHUTDOWN_FORCE_CLOSE_MS}ms listener-drain timeout reached; force-closing remaining connections`,
|
||||
);
|
||||
server.closeAllConnections();
|
||||
// After force-close, server.close's callback
|
||||
// SHOULD fire promptly. Give it `SECONDARY_DEADLINE_MS`
|
||||
// before we resolve anyway with a warning — much
|
||||
// longer than the previous 100ms shortcut, and
|
||||
// logged so the operator knows the contract was
|
||||
// bent.
|
||||
secondaryTimer = setTimeout(() => {
|
||||
writeStderrLine(
|
||||
`qwen serve: server.close did not fire ${SECONDARY_DEADLINE_MS}ms after force-close; resolving anyway`,
|
||||
);
|
||||
finish();
|
||||
}, SECONDARY_DEADLINE_MS);
|
||||
secondaryTimer.unref();
|
||||
}, SHUTDOWN_FORCE_CLOSE_MS);
|
||||
forceTimer.unref();
|
||||
server.close((err) => {
|
||||
clearTimeout(forceTimer);
|
||||
if (secondaryTimer) clearTimeout(secondaryTimer);
|
||||
finish(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
return closePromise;
|
||||
},
|
||||
};
|
||||
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('SIGTERM', onSignal);
|
||||
|
||||
// BX9_i: swap the boot-error listener for a runtime-error one
|
||||
// before resolving. `server.once('error', reject)` at the
|
||||
// bottom only catches errors BEFORE listening; post-listen
|
||||
// errors (EMFILE after FD exhaustion, runtime errors on the
|
||||
// listener) would be unhandled and crash the daemon. Use a
|
||||
// persistent listener that logs to stderr instead.
|
||||
server.removeAllListeners('error');
|
||||
server.on('error', (err) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: server error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
resolve(handle);
|
||||
});
|
||||
server.once('error', reject);
|
||||
});
|
||||
}
|
||||
1436
packages/cli/src/serve/server.test.ts
Normal file
1436
packages/cli/src/serve/server.test.ts
Normal file
File diff suppressed because it is too large
Load diff
891
packages/cli/src/serve/server.ts
Normal file
891
packages/cli/src/serve/server.ts
Normal file
|
|
@ -0,0 +1,891 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import express from 'express';
|
||||
import type { Application } from 'express';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
import { bearerAuth, denyBrowserOriginCors, hostAllowlist } from './auth.js';
|
||||
import { isLoopbackBind } from './loopbackBinds.js';
|
||||
import {
|
||||
createHttpAcpBridge,
|
||||
InvalidPermissionOptionError,
|
||||
SessionLimitExceededError,
|
||||
SessionNotFoundError,
|
||||
type HttpAcpBridge,
|
||||
} from './httpAcpBridge.js';
|
||||
import { SubscriberLimitExceededError, type BridgeEvent } from './eventBus.js';
|
||||
import {
|
||||
CAPABILITIES_SCHEMA_VERSION,
|
||||
STAGE1_FEATURES,
|
||||
type CapabilitiesEnvelope,
|
||||
type ServeOptions,
|
||||
} from './types.js';
|
||||
|
||||
export interface ServeAppDeps {
|
||||
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
|
||||
bridge?: HttpAcpBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Express app for `qwen serve`. Pure function — no side effects on
|
||||
* the network or process; `runQwenServe` does the listen/signal handling.
|
||||
*
|
||||
* `getPort` is invoked lazily by the host-allowlist middleware so callers
|
||||
* binding to port 0 (ephemeral) can supply the actual port after `listen()`
|
||||
* resolves. Defaults to `opts.port` for callers (e.g. tests) that pin a port
|
||||
* up front.
|
||||
*
|
||||
* Stage 1 routes shipped (matches §04 of issue #3803):
|
||||
* - `GET /health`
|
||||
* - `GET /capabilities`
|
||||
* - `POST /session`
|
||||
* - `GET /workspace/:id/sessions`
|
||||
* - `POST /session/:id/prompt`
|
||||
* - `POST /session/:id/cancel`
|
||||
* - `POST /session/:id/model`
|
||||
* - `GET /session/:id/events` (SSE)
|
||||
* - `POST /permission/:requestId`
|
||||
*/
|
||||
export function createServeApp(
|
||||
opts: ServeOptions,
|
||||
getPort: () => number = () => opts.port,
|
||||
deps: ServeAppDeps = {},
|
||||
): Application {
|
||||
const app = express();
|
||||
// Forward `maxSessions` into the default-constructed bridge so
|
||||
// direct callers of `createServeApp` (tests, embeds) get the same
|
||||
// cap they configured via `ServeOptions`. Previously the default
|
||||
// bridge silently fell back to `DEFAULT_MAX_SESSIONS` (20) and
|
||||
// only the `runQwenServe` path piped the option through.
|
||||
const bridge =
|
||||
deps.bridge ?? createHttpAcpBridge({ maxSessions: opts.maxSessions });
|
||||
|
||||
// Order matters: rejection guards (CORS / Host allowlist / bearer auth)
|
||||
// run BEFORE the JSON body parser. Otherwise an unauthenticated POST
|
||||
// gets a full 10MB `JSON.parse` before the 401 fires — a trivially
|
||||
// amplified CPU/memory cost from any wrong-token client.
|
||||
app.use(denyBrowserOriginCors);
|
||||
app.use(hostAllowlist(opts.hostname, getPort));
|
||||
|
||||
// `/health` is exempted from `bearerAuth` ONLY on loopback binds —
|
||||
// the canonical liveness-probe case (k8s/Compose probes don't
|
||||
// carry the daemon's bearer; round-tripping a 401 just to know
|
||||
// the listener is up is waste). On non-loopback binds the
|
||||
// exemption becomes a low-severity info leak (attacker can probe
|
||||
// arbitrary IP:port to confirm a `qwen serve` is listening), so
|
||||
// we register `/health` AFTER `bearerAuth` and let it 401 like
|
||||
// every other route. Operators using the loopback default get the
|
||||
// probe-friendly behavior; operators exposing the daemon publicly
|
||||
// gate `/health` behind their token alongside everything else.
|
||||
// CORS deny + Host allowlist still apply to `/health` in both
|
||||
// cases.
|
||||
// Shared handler so loopback (pre-auth) and non-loopback (post-auth)
|
||||
// routes return the same shape. `?deep=1` exposes bridge counters
|
||||
// (`sessions`, `pendingPermissions`) for observability — it is
|
||||
// INFORMATIONAL only, not a true liveness probe. Counter getters
|
||||
// are size accessors that don't perform per-session/channel pings,
|
||||
// so a wedged child (stuck on a request, leaked FD, etc.) won't
|
||||
// change the response. We retain the try/catch + 503 as a
|
||||
// defense-in-depth net for custom bridge impls whose getters MAY
|
||||
// throw — but the real bridge's getters never do, so under normal
|
||||
// operation the 503 path is unreachable. Per BQ-6F: the docs
|
||||
// (`docs/users/qwen-serve.md` + `qwen-serve-protocol.md`) clarify
|
||||
// that deep is for counters, not health verification. Default (no
|
||||
// query) stays cheap so high-frequency liveness probes don't load
|
||||
// the bridge.
|
||||
const healthHandler = (
|
||||
req: import('express').Request,
|
||||
res: import('express').Response,
|
||||
): void => {
|
||||
const deepQuery = req.query['deep'];
|
||||
const deep = deepQuery === '1' || deepQuery === 'true' || deepQuery === '';
|
||||
if (!deep) {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
sessions: bridge.sessionCount,
|
||||
pendingPermissions: bridge.pendingPermissionCount,
|
||||
});
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: /health deep probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
res.status(503).json({ status: 'degraded' });
|
||||
}
|
||||
};
|
||||
|
||||
const loopback = isLoopbackBind(opts.hostname);
|
||||
if (loopback) {
|
||||
app.get('/health', healthHandler);
|
||||
}
|
||||
|
||||
app.use(bearerAuth(opts.token));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
if (!loopback) {
|
||||
// Non-loopback: register `/health` AFTER `bearerAuth` so probes
|
||||
// must carry the token. Otherwise unauthenticated callers can
|
||||
// ping any reachable address:port to confirm a daemon exists.
|
||||
app.get('/health', healthHandler);
|
||||
}
|
||||
|
||||
app.get('/capabilities', (_req, res) => {
|
||||
const envelope: CapabilitiesEnvelope = {
|
||||
v: CAPABILITIES_SCHEMA_VERSION,
|
||||
mode: opts.mode,
|
||||
features: [...STAGE1_FEATURES],
|
||||
modelServices: [],
|
||||
};
|
||||
res.status(200).json(envelope);
|
||||
});
|
||||
|
||||
app.post('/session', async (req, res) => {
|
||||
const body = safeBody(req);
|
||||
const cwd = typeof body['cwd'] === 'string' ? (body['cwd'] as string) : '';
|
||||
if (!cwd || !path.isAbsolute(cwd)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: '`cwd` is required and must be an absolute path' });
|
||||
return;
|
||||
}
|
||||
const modelServiceId =
|
||||
typeof body['modelServiceId'] === 'string'
|
||||
? (body['modelServiceId'] as string)
|
||||
: undefined;
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({
|
||||
workspaceCwd: cwd,
|
||||
modelServiceId,
|
||||
});
|
||||
// Client may have disconnected during the 1–3s spawn window. If
|
||||
// so, the response can't be delivered. The session is otherwise
|
||||
// orphaned (in `byId` / `byWorkspace` with no client knowing the
|
||||
// id), and under churn this leaks one child per aborted request.
|
||||
//
|
||||
// Detect "can we still write the response?" via `res.writable`,
|
||||
// which stays true until the SOCKET destination side closes
|
||||
// (the right signal for our case). The legacy `req.aborted`
|
||||
// only flips while the request body is still being received,
|
||||
// so a client that completed the POST and then closed during
|
||||
// the spawn would slip past it. `req.destroyed` is too eager
|
||||
// — clients (incl. supertest) close their writable end after
|
||||
// sending the body even though they're still listening for the
|
||||
// response. `res.writable` is the documented signal for
|
||||
// "ServerResponse can still send to client".
|
||||
//
|
||||
// Combined with `!session.attached` we only reap when WE spawned
|
||||
// a fresh child for this request — if another client legitimately
|
||||
// attached, killing it would tear out their work mid-flight.
|
||||
// The disconnect-without-reap branch also needs to skip
|
||||
// `res.json` — writing to a closed socket would throw EPIPE
|
||||
// through Express's default error handler.
|
||||
if (!res.writable) {
|
||||
if (!session.attached) {
|
||||
// `requireZeroAttaches: true` closes the BQ9tV race: if
|
||||
// a second client called `spawnOrAttach` for the same
|
||||
// workspace between our `await` resolving and this reap
|
||||
// dispatching, the bridge will see `attachCount > 0` and
|
||||
// skip the kill. Without the flag, that second client's
|
||||
// session would die mid-prompt.
|
||||
bridge
|
||||
.killSession(session.sessionId, { requireZeroAttaches: true })
|
||||
.catch(() => {
|
||||
// Best-effort cleanup; channel.exited will eventually reap.
|
||||
});
|
||||
} else {
|
||||
// tanzhenxin issue 2: when an attaching client disconnects
|
||||
// before its 200 response can be written, the
|
||||
// `attachCount` bump we did inside `spawnOrAttach` is
|
||||
// fictitious — there's no live attaching client. Roll the
|
||||
// counter back and let the bridge decide whether to reap
|
||||
// (it does if attachCount returns to 0 AND no live SSE
|
||||
// subscribers). Without this, both-coalesced-callers-
|
||||
// disconnect leaves an orphan agent child no client knows
|
||||
// the id of.
|
||||
bridge.detachClient(session.sessionId).catch(() => {
|
||||
// Best-effort cleanup; channel.exited will eventually reap.
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.status(200).json(session);
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route: 'POST /session' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/session/:id/prompt', async (req, res) => {
|
||||
const sessionId = req.params['id'];
|
||||
const body = safeBody(req);
|
||||
const prompt = body['prompt'];
|
||||
if (!Array.isArray(prompt) || prompt.length === 0) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`prompt` is required and must be a non-empty array of content blocks',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!prompt.every(
|
||||
(item: unknown) =>
|
||||
// `typeof item === 'object'` is true for arrays too, so an
|
||||
// exclude-arrays check is needed to keep the contract
|
||||
// ("ACP content block, like {type: 'text', text: '...'}")
|
||||
// honest. Without `!Array.isArray(item)`, `prompt: [[]]`
|
||||
// passes validation and a confusing 500 surfaces from the
|
||||
// ACP SDK layer.
|
||||
typeof item === 'object' && item !== null && !Array.isArray(item),
|
||||
)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'each `prompt` element must be an object (content block)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Propagate HTTP-client disconnect to an ACP cancel notification so
|
||||
// the agent winds down promptly and the per-session FIFO doesn't
|
||||
// stay blocked on a dead client. Detached after the prompt settles.
|
||||
//
|
||||
// Use `res.on('close')` (NOT `req.on('close')`) — `IncomingMessage`'s
|
||||
// close event fires once the request body has been fully consumed
|
||||
// even when the client is still listening for the response, which
|
||||
// would cancel every ordinary prompt the moment its upload
|
||||
// finished. `ServerResponse`'s close event only fires when the
|
||||
// socket goes away. Guard with `!res.writableEnded` so a normal
|
||||
// response flush (which also triggers `res.close`) doesn't fire
|
||||
// the abort retroactively.
|
||||
const abort = new AbortController();
|
||||
const onResClose = () => {
|
||||
if (!res.writableEnded) abort.abort();
|
||||
};
|
||||
res.once('close', onResClose);
|
||||
try {
|
||||
// SECURITY NOTE: this `...(body as object)` passthrough is
|
||||
// intentional — the bridge / ACP SDK ignores fields it
|
||||
// doesn't recognize (ACP-spec `_meta` etc are forwarded
|
||||
// wholesale to the agent, which is the documented behavior).
|
||||
// `sessionId` and `prompt` are forced to the route's view to
|
||||
// prevent body-spoofing of the routing key. If a future
|
||||
// bridge version starts trusting an additional field by name,
|
||||
// that field becomes a client-controlled input surface — at
|
||||
// that point switch this to an explicit pick. The same
|
||||
// pattern repeats on cancel / model below; review them all
|
||||
// together when adding new bridge-trusted fields.
|
||||
const result = await bridge.sendPrompt(
|
||||
sessionId,
|
||||
{
|
||||
...(body as object),
|
||||
sessionId,
|
||||
prompt,
|
||||
} as Parameters<HttpAcpBridge['sendPrompt']>[1],
|
||||
abort.signal,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
// The HTTP client disconnecting fires the abort path above and
|
||||
// the bridge re-throws as `AbortError`. That's a normal
|
||||
// wind-down, not an error worth a 500 + stderr stack trace.
|
||||
// Drop it silently — the socket is already closed so we can't
|
||||
// send a response anyway, and active clients (e.g. an IDE
|
||||
// plugin scrubbing a stuck prompt) would otherwise spam the
|
||||
// daemon log.
|
||||
//
|
||||
// BX9_k: narrow the swallow to ONLY the case where WE armed
|
||||
// the abort. The earlier blanket `err.name === 'AbortError'`
|
||||
// could also swallow an internal bridge abort (e.g. the child
|
||||
// process aborting a prompt mid-flight) — leaving the client
|
||||
// with no response and no log trace. If `abort.signal.aborted`
|
||||
// is false, the AbortError came from somewhere we didn't
|
||||
// expect → route it through `sendBridgeError` as a real
|
||||
// failure.
|
||||
if (
|
||||
err instanceof DOMException &&
|
||||
err.name === 'AbortError' &&
|
||||
abort.signal.aborted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
sendBridgeError(res, err, {
|
||||
route: 'POST /session/:id/prompt',
|
||||
sessionId,
|
||||
});
|
||||
} finally {
|
||||
res.off('close', onResClose);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/session/:id/cancel', async (req, res) => {
|
||||
const sessionId = req.params['id'];
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
await bridge.cancelSession(sessionId, {
|
||||
...(body as object),
|
||||
sessionId,
|
||||
} as Parameters<HttpAcpBridge['cancelSession']>[1]);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, {
|
||||
route: 'POST /session/:id/cancel',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspace/:id/sessions', (req, res) => {
|
||||
// Express decodes URL-encoded path params automatically; clients pass
|
||||
// the absolute workspace cwd encoded (e.g.
|
||||
// GET /workspace/%2Fwork%2Fa/sessions).
|
||||
const workspaceCwd = req.params['id'] ?? '';
|
||||
if (!path.isAbsolute(workspaceCwd)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: '`:id` must decode to an absolute workspace path' });
|
||||
return;
|
||||
}
|
||||
const sessions = bridge.listWorkspaceSessions(workspaceCwd);
|
||||
res.status(200).json({ sessions });
|
||||
});
|
||||
|
||||
app.post('/session/:id/model', async (req, res) => {
|
||||
const sessionId = req.params['id'];
|
||||
const body = safeBody(req);
|
||||
const modelId = body['modelId'];
|
||||
if (typeof modelId !== 'string' || !modelId) {
|
||||
res.status(400).json({
|
||||
error: '`modelId` is required and must be a non-empty string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await bridge.setSessionModel(sessionId, {
|
||||
...(body as object),
|
||||
sessionId,
|
||||
modelId,
|
||||
} as Parameters<HttpAcpBridge['setSessionModel']>[1]);
|
||||
res.status(200).json(response);
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, {
|
||||
route: 'POST /session/:id/model',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/permission/:requestId', (req, res) => {
|
||||
const requestId = req.params['requestId'];
|
||||
const body = safeBody(req);
|
||||
const outcome = body['outcome'];
|
||||
if (!isValidOutcome(outcome)) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`outcome` must be `{ outcome: "cancelled" }` or `{ outcome: "selected", optionId: string }`',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let accepted: boolean;
|
||||
try {
|
||||
accepted = bridge.respondToPermission(requestId, {
|
||||
...(body as object),
|
||||
outcome,
|
||||
} as Parameters<HttpAcpBridge['respondToPermission']>[1]);
|
||||
} catch (err) {
|
||||
// BkwQI: voter's `optionId` wasn't in the option set the agent
|
||||
// originally offered (e.g. forging `ProceedAlways*` when the
|
||||
// prompt's `hideAlwaysAllow` policy suppressed it). 400, not
|
||||
// 404 — the requestId IS known, but the chosen option isn't.
|
||||
if (err instanceof InvalidPermissionOptionError) {
|
||||
res.status(400).json({
|
||||
error: err.message,
|
||||
code: 'invalid_option_id',
|
||||
requestId: err.requestId,
|
||||
optionId: err.optionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!accepted) {
|
||||
// Either the requestId never existed or another client already won
|
||||
// the race. Stage 1 doesn't distinguish — both surface as 404.
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: 'No pending permission request', requestId });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({});
|
||||
});
|
||||
|
||||
app.get('/session/:id/events', (req, res) => {
|
||||
const sessionId = req.params['id'];
|
||||
const lastEventId = parseLastEventId(req.headers['last-event-id']);
|
||||
|
||||
let iter: AsyncIterator<BridgeEvent> | undefined;
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const iterable = bridge.subscribeEvents(sessionId, {
|
||||
signal: abort.signal,
|
||||
lastEventId,
|
||||
});
|
||||
iter = iterable[Symbol.asyncIterator]();
|
||||
} catch (err) {
|
||||
// `EventBus` throws `SubscriberLimitExceededError` when the
|
||||
// per-session subscriber cap (default 64) is reached.
|
||||
//
|
||||
// Bd1zJ: surface as `429 Too Many Requests` + `Retry-After`
|
||||
// header rather than `200 + stream_error`. The previous
|
||||
// SSE-shaped response triggered `EventSource`'s
|
||||
// auto-reconnect (which honors the `retry:` directive AND
|
||||
// default-reconnects on any closed stream). The reconnect hit
|
||||
// the same cap, looped, amplifying the exact load the limit
|
||||
// exists to prevent.
|
||||
//
|
||||
// `429` is the standard "back off" signal — browsers'
|
||||
// `EventSource` treats `4xx` as terminal and does NOT
|
||||
// auto-reconnect on it, unlike `200 + close` which DOES
|
||||
// reconnect. Body shape mirrors the SSE frame's data field so
|
||||
// a raw-fetch client gets the same structured error.
|
||||
if (err instanceof SubscriberLimitExceededError) {
|
||||
writeStderrLine(
|
||||
`qwen serve: subscriber limit reached for session ${sessionId} (limit=${err.limit}); rejecting new SSE client with 429`,
|
||||
);
|
||||
res.setHeader('Retry-After', '5');
|
||||
res.status(429).json({
|
||||
error: err.message,
|
||||
code: 'subscriber_limit_exceeded',
|
||||
limit: err.limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendBridgeError(res, err, {
|
||||
route: 'GET /session/:id/events',
|
||||
sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// Disable proxy buffering (nginx); event-stream content type alone
|
||||
// doesn't always reach the client through every proxy.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
// Always present on the supported Node versions (engines.node >=22).
|
||||
res.flushHeaders();
|
||||
|
||||
// Backpressure helper: `res.write` returns false when the kernel send
|
||||
// buffer is full. Without awaiting `drain` Node accumulates the
|
||||
// payload in user-space memory unboundedly — a slow consumer on a
|
||||
// chatty session can balloon daemon RSS. Wait for `drain` (or
|
||||
// close/error) before scheduling the next write.
|
||||
//
|
||||
// Concurrency: serialize ALL writes through a per-connection chain
|
||||
// so the heartbeat (fire-and-forget interval, see below) can't
|
||||
// interleave with the main event-write loop. Without serialization,
|
||||
// the heartbeat firing while the main loop is mid-`drain` await
|
||||
// would issue a second `res.write()` that bypasses the
|
||||
// backpressure guard — and could even interleave bytes between two
|
||||
// SSE frames on the wire. The chain is single-flight: each call
|
||||
// waits for the previous write to settle before scheduling its own.
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
const doWrite = (chunk: string): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (res.writableEnded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// `res.write` can throw synchronously when the socket is
|
||||
// already destroyed (typical EPIPE shape). Wrap in try/catch
|
||||
// so that surfaces as a rejection on this promise instead of
|
||||
// escaping the executor and turning into an unhandled
|
||||
// exception. Async failures still arrive via the `'error'`
|
||||
// event handler below — Node's Writable.write callback isn't
|
||||
// documented to receive an error argument (errors come on
|
||||
// the event), so we don't rely on it.
|
||||
let ok: boolean;
|
||||
try {
|
||||
ok = res.write(chunk);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (ok) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onDrain = () => {
|
||||
res.off('close', onClose);
|
||||
res.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onClose = () => {
|
||||
res.off('drain', onDrain);
|
||||
res.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
res.off('drain', onDrain);
|
||||
res.off('close', onClose);
|
||||
reject(err);
|
||||
};
|
||||
res.once('drain', onDrain);
|
||||
res.once('close', onClose);
|
||||
res.once('error', onError);
|
||||
});
|
||||
const writeWithBackpressure = (chunk: string): Promise<void> => {
|
||||
const next = writeChain.then(() => doWrite(chunk));
|
||||
// Tail-swallow rejections on the chain itself so a single failed
|
||||
// write doesn't poison every subsequent call. The CALLER's
|
||||
// returned promise still rejects — chain-internal failures are
|
||||
// someone else's problem, not blockers for queueing.
|
||||
writeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
|
||||
// Tell EventSource to retry after 3s on disconnect. Awaiting drain on
|
||||
// the very first write is overkill but cheap — `ok` is true the
|
||||
// overwhelming majority of the time. Always swallow rejection: a
|
||||
// socket that errors before the very first write would otherwise
|
||||
// surface as an unhandled promise rejection (the `res.on('error')`
|
||||
// hook below is what we actually rely on for cleanup).
|
||||
void writeWithBackpressure('retry: 3000\n\n').catch(() => {});
|
||||
|
||||
// Heartbeat keeps NAT/proxy connections alive and lets the server
|
||||
// notice a dead client through write-back-pressure. Comment frame is
|
||||
// ignored by EventSource.
|
||||
//
|
||||
// KNOWN GAP: this only catches dead connections via write
|
||||
// back-pressure on heartbeat itself. A network partition without TCP
|
||||
// RST can leave the connection looking alive (no FIN received) for
|
||||
// however long Node's keepalive probes take to time out — usually
|
||||
// ~2 hours by default, configurable via `server.keepAliveTimeout`.
|
||||
// Stage 2 may add an explicit application-level idle timeout
|
||||
// (last-byte-written tracking + per-connection deadline).
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (!res.writableEnded) {
|
||||
// Heartbeat writes are best-effort; failure swallowed via the
|
||||
// `res.on('error')` hook below.
|
||||
void writeWithBackpressure(': heartbeat\n\n').catch(() => {});
|
||||
}
|
||||
}, 15_000);
|
||||
heartbeatTimer.unref();
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
abort.abort();
|
||||
};
|
||||
req.on('close', cleanup);
|
||||
// Swallow socket-level write errors. When the underlying TCP connection
|
||||
// dies (RST, mid-flight kill -9), the next `res.write` throws EPIPE.
|
||||
// Without an `error` listener Express forwards it to its default error
|
||||
// handler which logs noisily. The req.on('close') path above is what we
|
||||
// actually rely on to tear down the subscription; this listener just
|
||||
// suppresses the noise + ensures cleanup runs even if for some reason
|
||||
// the close event doesn't fire first.
|
||||
res.on('error', (err) => {
|
||||
// Without this log the daemon side is blind to SSE disconnects
|
||||
// (RST, mid-flight kill -9, network blip). Cleanup still runs —
|
||||
// the listener exists primarily so Node doesn't crash on EPIPE
|
||||
// — but operators get a breadcrumb when chasing flaky clients.
|
||||
writeStderrLine(
|
||||
`qwen serve: SSE socket error (session ${sessionId}): ${err.message}`,
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const next = await iter!.next();
|
||||
if (next.done) break;
|
||||
if (res.writableEnded) break;
|
||||
await writeWithBackpressure(formatSseFrame(next.value));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!res.writableEnded) {
|
||||
// Don't burn an `id:` slot — `stream_error` is a terminal frame
|
||||
// emitted on the daemon side when the bridge iterator throws, so
|
||||
// it has no place in the per-session monotonic sequence and a
|
||||
// hard-coded `id: 0` would regress the client's `Last-Event-ID`
|
||||
// tracker. `formatSseFrame` omits the `id:` line when the input
|
||||
// event has no id.
|
||||
await writeWithBackpressure(
|
||||
formatSseFrame({
|
||||
v: 1,
|
||||
type: 'stream_error',
|
||||
data: { error: errorMessage(err) },
|
||||
}),
|
||||
).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Final error handler. `express.json()` throws `SyntaxError` (with
|
||||
// `status: 400`) on malformed body — without this 4-arg middleware
|
||||
// Express renders an HTML error page, which trips SDK clients that
|
||||
// expect a JSON body on every response. Anything else bubbling out
|
||||
// is a programmer error; log it and return a JSON 500 (matches the
|
||||
// route-level `sendBridgeError` shape so clients have one error
|
||||
// contract to parse).
|
||||
app.use(
|
||||
(
|
||||
err: unknown,
|
||||
_req: import('express').Request,
|
||||
res: import('express').Response,
|
||||
_next: import('express').NextFunction,
|
||||
) => {
|
||||
if (
|
||||
err instanceof SyntaxError &&
|
||||
'status' in err &&
|
||||
(err as { status: number }).status === 400
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid JSON in request body' });
|
||||
return;
|
||||
}
|
||||
// body-parser raises a typed error with `status: 413` when a
|
||||
// request body exceeds the `express.json({ limit: '10mb' })`
|
||||
// ceiling. Without this branch it falls through to the 500 path
|
||||
// and clients see a misleading "Internal server error" instead
|
||||
// of a clear "payload too large" — which is the kind of error
|
||||
// they can actually act on (chunk the request, raise the limit).
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'status' in err &&
|
||||
(err as { status: number }).status === 413
|
||||
) {
|
||||
res.status(413).json({ error: 'Request body too large (max 10 MB)' });
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce `req.body` into a safe `Record<string, unknown>` for route
|
||||
* handlers. Replaces the 5-site copy-pasted preamble
|
||||
* `typeof req.body === 'object' && req.body !== null ? ... : {}`
|
||||
* (Bd10m).
|
||||
*
|
||||
* Also strips prototype-pollution keys (`__proto__`, `constructor`,
|
||||
* `prototype`) before returning — see BZ9uv/va/vs/wD/Bd1zz. Routes
|
||||
* downstream of this helper spread the result into objects passed to
|
||||
* the bridge / ACP SDK; without this scrub, a client could set
|
||||
* `{"__proto__": {"polluted": true}}` and pollute `Object.prototype`.
|
||||
* Uses an `Object.create(null)` target so the returned object itself
|
||||
* has no prototype either, blocking second-order spread-into-default-
|
||||
* prototype attacks.
|
||||
*/
|
||||
function safeBody(req: import('express').Request): Record<string, unknown> {
|
||||
const raw = req.body;
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return Object.create(null) as Record<string, unknown>;
|
||||
}
|
||||
const out = Object.create(null) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isValidOutcome(
|
||||
raw: unknown,
|
||||
): raw is { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } {
|
||||
if (typeof raw !== 'object' || raw === null) return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (obj['outcome'] === 'cancelled') return true;
|
||||
// `optionId` must be a non-empty string. An empty string is technically a
|
||||
// string but isn't a meaningful selection — letting it through would
|
||||
// forward malformed votes to the bridge and the agent would reject the
|
||||
// unknown option opaquely.
|
||||
return (
|
||||
obj['outcome'] === 'selected' &&
|
||||
typeof obj['optionId'] === 'string' &&
|
||||
(obj['optionId'] as string).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function parseLastEventId(raw: unknown): number | undefined {
|
||||
// Stricter than Number.parseInt: only accept pure decimal digits to avoid
|
||||
// values like "1abc" or "1.5e10z" silently parsing to 1.
|
||||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
|
||||
// BX9_I: log a breadcrumb for the operator when a non-empty
|
||||
// header is rejected. The client resumed from event 0 instead
|
||||
// of where they meant to — without this line, the loss of
|
||||
// every event buffered during their disconnect was invisible.
|
||||
// Skip the log for missing / empty headers (the common case of
|
||||
// "first connect, no resume").
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
writeStderrLine(
|
||||
`qwen serve: rejected Last-Event-ID "${raw.slice(0, 80)}" ` +
|
||||
`(not a decimal integer)`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
// Reject values that lose precision as a JS `number`. The bus's monotonic
|
||||
// ids are bounded by `Number.MAX_SAFE_INTEGER` (2^53 - 1); a client that
|
||||
// tries to resume from beyond that is either malicious or broken.
|
||||
if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
|
||||
writeStderrLine(
|
||||
`qwen serve: rejected Last-Event-ID "${raw.slice(0, 80)}" ` +
|
||||
`(exceeds Number.MAX_SAFE_INTEGER)`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function formatSseFrame(event: BridgeEvent | OmitId<BridgeEvent>): string {
|
||||
// SSE format: id (optional), event (optional), data, blank line.
|
||||
// The `id:` line is intentionally omitted when `event.id` is absent —
|
||||
// terminal/synthetic frames (e.g. daemon-side `stream_error`) must not
|
||||
// burn a slot in the per-session monotonic sequence the client uses for
|
||||
// `Last-Event-ID` reconnect tracking.
|
||||
//
|
||||
// We always emit the payload as a single `data:` line. The EventSource
|
||||
// spec also allows a frame to span multiple `data:` lines (which a
|
||||
// conformant parser joins with `\n`); we don't emit that form because
|
||||
// our payload is JSON without embedded newlines after `JSON.stringify`.
|
||||
// The SDK parser at `sdk-typescript/src/daemon/sse.ts` handles the
|
||||
// multi-line variant on the receive side — input/output asymmetry is
|
||||
// intentional.
|
||||
const dataJson = JSON.stringify(event);
|
||||
const idLine =
|
||||
'id' in event && event.id !== undefined ? `id: ${event.id}\n` : '';
|
||||
return `${idLine}event: ${event.type}\ndata: ${dataJson}\n\n`;
|
||||
}
|
||||
|
||||
type OmitId<T> = Omit<T, 'id'>;
|
||||
|
||||
/**
|
||||
* Map a thrown bridge error to an HTTP response.
|
||||
*
|
||||
* `ctx` is operator-facing: route + sessionId folded into the stderr
|
||||
* log line so a bare `ECONNRESET` / `ENOMEM` stack trace is
|
||||
* attributable to a specific session and request without having to
|
||||
* timestamp-correlate against client logs. Pass via the route handlers
|
||||
* — see how they call `sendBridgeError(res, err, { route: 'POST
|
||||
* /session/:id/prompt', sessionId })`. Optional so test/dev call
|
||||
* sites that don't care about the log can omit it.
|
||||
*/
|
||||
function sendBridgeError(
|
||||
res: import('express').Response,
|
||||
err: unknown,
|
||||
ctx?: { route?: string; sessionId?: string },
|
||||
): void {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
res.status(404).json({ error: err.message, sessionId: err.sessionId });
|
||||
return;
|
||||
}
|
||||
if (err instanceof SessionLimitExceededError) {
|
||||
// 503 Service Unavailable + `Retry-After` is the canonical
|
||||
// "we'd serve you, but we're full right now" shape. The hint
|
||||
// is intentionally conservative (5s) because a session that
|
||||
// finishes a prompt frees a slot quickly under normal load;
|
||||
// a client that backs off too aggressively wastes capacity.
|
||||
res.set('Retry-After', '5');
|
||||
res.status(503).json({
|
||||
error: err.message,
|
||||
code: 'session_limit_exceeded',
|
||||
limit: err.limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 5xx is the kind of error operators need to see in their daemon log
|
||||
// — bridge ENOMEM, agent stack trace, unexpected throw, etc. Without
|
||||
// logging here every 500 disappears once the caller consumes the
|
||||
// response body. This is a stop-gap until structured access/error
|
||||
// logging lands (tracked under §10 follow-ups). Use the stdio helper
|
||||
// (not `console.error`) to keep the no-console lint rule happy and
|
||||
// route through the same writer the rest of the daemon uses.
|
||||
const ctxParts = [
|
||||
ctx?.route,
|
||||
ctx?.sessionId ? `session=${ctx.sessionId}` : undefined,
|
||||
].filter(Boolean);
|
||||
const ctxStr = ctxParts.length > 0 ? ` (${ctxParts.join(' ')})` : '';
|
||||
writeStderrLine(
|
||||
`qwen serve: bridge error${ctxStr}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
);
|
||||
res.status(500).json(errorPayload(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary thrown value to a useful string. Plain `String(err)`
|
||||
* yields `[object Object]` for JSON-RPC-shaped errors (`{code, message,
|
||||
* data}`) which are exactly what the ACP SDK forwards from the agent. Try
|
||||
* the `message` field first, fall back to JSON-stringify, then `String`.
|
||||
*/
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (err && typeof err === 'object') {
|
||||
const maybe = (err as { message?: unknown }).message;
|
||||
if (typeof maybe === 'string' && maybe.length > 0) return maybe;
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for a 5xx response. The ACP SDK forwards
|
||||
* JSON-RPC-shaped errors like `{code: -32000, message: "Internal error",
|
||||
* data: {reason: "model quota exceeded"}}` — discarding `code`/`data`
|
||||
* collapses every distinct failure (quota / rate-limit / auth /
|
||||
* crash) to the same opaque `"Internal error"` string at the client.
|
||||
* Forward both fields so callers can triage from response body alone.
|
||||
* `error` stays as the human-readable string for backward compatibility
|
||||
* with clients that only consumed `error` in the original shape.
|
||||
*
|
||||
* BSA0G acknowledged: forwarding `data` verbatim leaks per-error
|
||||
* detail (file paths in upstream tool failures, partial API response
|
||||
* snippets, etc.) to every authenticated SSE subscriber that
|
||||
* observes 5xx responses. In Stage 1's single-user / small-team
|
||||
* trust model (every authenticated client is the same human or
|
||||
* collaborators they trust) this is acceptable — and the triage
|
||||
* value of the rich error is high. Stage 2 multi-tenant deployments
|
||||
* will need an opt-in `--redact-errors` flag (or per-deployment
|
||||
* policy hook) that strips `data` and replaces it with an
|
||||
* error-class identifier; tracked under #3803 follow-ups.
|
||||
*/
|
||||
function errorPayload(err: unknown): {
|
||||
error: string;
|
||||
code?: unknown;
|
||||
data?: unknown;
|
||||
} {
|
||||
const out: { error: string; code?: unknown; data?: unknown } = {
|
||||
error: errorMessage(err),
|
||||
};
|
||||
if (err && typeof err === 'object') {
|
||||
const obj = err as Record<string, unknown>;
|
||||
if ('code' in obj) out.code = obj['code'];
|
||||
if ('data' in obj) out.data = obj['data'];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
114
packages/cli/src/serve/types.ts
Normal file
114
packages/cli/src/serve/types.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stage 1 daemon mode shape.
|
||||
*
|
||||
* `http-bridge` (Stage 1): one `qwen --acp` child PER WORKSPACE, with
|
||||
* multiple sessions multiplexed onto that child via the agent's native
|
||||
* `connection.newSession()` (see `acp-integration/acpAgent.ts:194`).
|
||||
* Sessions on the same workspace share the child's process / OAuth /
|
||||
* file-cache / hierarchy-memory parse. The daemon pipes ACP NDJSON over
|
||||
* HTTP/SSE. Same-session multi-client requests serialize through the
|
||||
* bridge's per-session FIFO; cross-session requests on the same channel
|
||||
* can run concurrently (the ACP layer demultiplexes by sessionId).
|
||||
* `native` (Stage 2+): in-process multi-session, AsyncLocalStorage; not yet
|
||||
* implemented.
|
||||
*/
|
||||
export type ServeMode = 'http-bridge' | 'native';
|
||||
|
||||
export interface ServeOptions {
|
||||
hostname: string;
|
||||
port: number;
|
||||
/**
|
||||
* Bearer token required on every request. Optional when bound to loopback
|
||||
* (developer convenience); required when bound beyond loopback (boot fails
|
||||
* without one — see runQwenServe).
|
||||
*/
|
||||
token?: string;
|
||||
mode: ServeMode;
|
||||
/**
|
||||
* Cap on concurrent live sessions. Once `bridge.sessionCount` reaches
|
||||
* this, new `POST /session` requests that would spawn fresh sessions
|
||||
* return 503. Attaching to an existing session (same workspace under
|
||||
* `sessionScope: 'single'`) still works — so an idle daemon doesn't
|
||||
* block reconnects from existing users. Defaults to 20: comfortably
|
||||
* above single-user usage, well below the design's N≈50 cliff where
|
||||
* per-session RSS (~30–50 MB) and FD pressure start to bite. Set to
|
||||
* `0` or `Infinity` to disable.
|
||||
*/
|
||||
maxSessions?: number;
|
||||
/**
|
||||
* Listener-level TCP connection cap (`server.maxConnections`).
|
||||
* Defaults to 256 — bounds the raw socket count regardless of
|
||||
* session count, so a slow / phantom SSE client can't pin the
|
||||
* daemon's FD table even when it isn't holding a live ACP session.
|
||||
* `0` (or `Infinity`) disables the cap by leaving
|
||||
* `server.maxConnections` unset, which falls back to Node's
|
||||
* built-in unlimited default. We avoid actually setting
|
||||
* `server.maxConnections = 0` because on Node 22 that causes the
|
||||
* listener to refuse EVERY connection (tanzhenxin issue 1).
|
||||
* NaN / negative values throw at boot. Independent of
|
||||
* `maxSessions` because one session can have many SSE subscribers
|
||||
* (default cap 64) plus short-lived REST calls.
|
||||
*/
|
||||
maxConnections?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability envelope returned from `GET /capabilities`. Clients gate UI off
|
||||
* `features`, never off `mode` (per design §10 protocol-compatibility).
|
||||
*
|
||||
* `v` is the wire schema version; bumped only on breaking frame changes.
|
||||
*/
|
||||
export interface CapabilitiesEnvelope {
|
||||
v: 1;
|
||||
mode: ServeMode;
|
||||
features: string[];
|
||||
/**
|
||||
* Configured model services advertised over HTTP. **Stage 1 always
|
||||
* returns `[]`** — the agent uses its single default service and
|
||||
* doesn't enumerate it over the wire. Stage 2 will populate this
|
||||
* from the registered model adapters so SDK clients can build
|
||||
* service-pickers. Until then, SDK consumers should NOT rely on
|
||||
* this field being non-empty.
|
||||
*/
|
||||
modelServices: string[];
|
||||
}
|
||||
|
||||
export const CAPABILITIES_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Stage 1 ships only the routes wired in `server.ts`. As routes land in
|
||||
* follow-up PRs, append the corresponding feature tag here so clients can
|
||||
* progressively enable UI affordances.
|
||||
*
|
||||
* The annotation is intentionally absent: `as const` widens to
|
||||
* `readonly ['health', 'capabilities', ...]` and the derived
|
||||
* `Stage1Feature` union catches typos at compile time. Annotating as
|
||||
* `readonly string[]` would erase the literal information.
|
||||
*/
|
||||
// FIXME(stage-1.5, chiga0 finding 5):
|
||||
// `STAGE1_FEATURES` is a hard-coded constant — `extMethod` plugins
|
||||
// can't contribute to the capability set without editing the daemon.
|
||||
// Stage 1.5 should convert this to a registry that bridges and
|
||||
// plugins push into, alongside an `ext_*` event family + a
|
||||
// `POST /ext/:method` route. Tracked under #3803.
|
||||
// Reference: https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427773706
|
||||
export const STAGE1_FEATURES = [
|
||||
'health',
|
||||
'capabilities',
|
||||
'session_create',
|
||||
'session_list',
|
||||
'session_prompt',
|
||||
'session_cancel',
|
||||
'session_events',
|
||||
'session_set_model',
|
||||
'permission_vote',
|
||||
] as const;
|
||||
|
||||
/** Compile-time-checked feature identifier — element of STAGE1_FEATURES. */
|
||||
export type Stage1Feature = (typeof STAGE1_FEATURES)[number];
|
||||
600
packages/sdk-typescript/src/daemon/DaemonClient.ts
Normal file
600
packages/sdk-typescript/src/daemon/DaemonClient.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { parseSseStream } from './sse.js';
|
||||
import type {
|
||||
DaemonCapabilities,
|
||||
DaemonEvent,
|
||||
DaemonSession,
|
||||
DaemonSessionSummary,
|
||||
PermissionResponse,
|
||||
PromptContentBlock,
|
||||
PromptResult,
|
||||
SetModelResult,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* SDK-side HTTP client for the `qwen serve` daemon. Sibling to
|
||||
* `ProcessTransport`: ProcessTransport drives a stdio child running
|
||||
* `qwen --input-format stream-json`; DaemonClient hits the daemon's HTTP
|
||||
* routes (POST /session, POST /session/:id/prompt, GET /session/:id/events,
|
||||
* etc.) and yields ACP-flavored events.
|
||||
*
|
||||
* The two surfaces are NOT interchangeable — they speak different protocols
|
||||
* (stream-json vs ACP NDJSON). DaemonClient lives alongside ProcessTransport
|
||||
* so applications that want daemon-mode (cross-client attach, shared MCP
|
||||
* pool, network reachability) can opt in without disturbing the existing
|
||||
* `query()` flow that subprocess-mode users rely on.
|
||||
*/
|
||||
export interface DaemonClientOptions {
|
||||
/** Daemon base URL (e.g. `http://127.0.0.1:4170`). Trailing slash is stripped. */
|
||||
baseUrl: string;
|
||||
/** Bearer token; required for non-loopback daemon binds. */
|
||||
token?: string;
|
||||
/**
|
||||
* Override the global `fetch` for tests. Defaults to `globalThis.fetch`.
|
||||
* Note: AbortController/AbortSignal must be Node-native for the default
|
||||
* to work (jsdom's polyfill is incompatible with undici).
|
||||
*/
|
||||
fetch?: typeof globalThis.fetch;
|
||||
/**
|
||||
* Per-call request timeout in milliseconds. Applied to short-lived
|
||||
* methods (`health`, `capabilities`, `createOrAttachSession`,
|
||||
* `listWorkspaceSessions`, `setSessionModel`, `cancel`,
|
||||
* `respondToPermission`) so an unresponsive daemon doesn't block
|
||||
* callers indefinitely. **NOT** applied to `prompt()` — model + tool
|
||||
* turns can take minutes, so prompt explicitly bypasses
|
||||
* `fetchTimeoutMs`; cancellation is via the optional `signal` arg.
|
||||
* Streaming (`subscribeEvents`) is similarly excluded for the
|
||||
* long-lived SSE body, though it does apply `fetchTimeoutMs` to the
|
||||
* initial connect phase (request → headers received).
|
||||
* Defaults to 30s. Set to `0` or `Infinity` to disable.
|
||||
*/
|
||||
fetchTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Strip any trailing slashes from a base URL via plain string ops. The
|
||||
* obvious `replace(/\/+$/, '')` is technically linear here (the regex is
|
||||
* end-anchored), but CodeQL's ReDoS detector flags any `\/+$` pattern as a
|
||||
* polynomial-regex risk on attacker-controlled input. Hand-rolling the loop
|
||||
* sidesteps the rule entirely.
|
||||
*/
|
||||
function stripTrailingSlashes(url: string): string {
|
||||
let end = url.length;
|
||||
while (end > 0 && url.charCodeAt(end - 1) === 0x2f /* '/' */) end--;
|
||||
return end === url.length ? url : url.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown for any non-2xx daemon response. `status` and `body` are surfaced
|
||||
* so callers can branch on the standard daemon HTTP semantics (404 missing
|
||||
* session, 401 bad token, 400 malformed body, 500 agent failure).
|
||||
*/
|
||||
export class DaemonHttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
constructor(status: number, body: unknown, message: string) {
|
||||
super(message);
|
||||
this.name = 'DaemonHttpError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
workspaceCwd: string;
|
||||
modelServiceId?: string;
|
||||
}
|
||||
|
||||
export interface PromptRequest {
|
||||
prompt: PromptContentBlock[];
|
||||
/** Optional ACP _meta passthrough. */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/** Resume from after this event id (`Last-Event-ID` header). */
|
||||
lastEventId?: number;
|
||||
/** Aborts the subscription cleanly. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string | undefined;
|
||||
private readonly _fetch: typeof globalThis.fetch;
|
||||
private readonly fetchTimeoutMs: number;
|
||||
|
||||
constructor(opts: DaemonClientOptions) {
|
||||
this.baseUrl = stripTrailingSlashes(opts.baseUrl);
|
||||
this.token = opts.token;
|
||||
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
// Coerce non-positive / non-finite to 0 (= disabled). Without this
|
||||
// a caller passing `-1` or `NaN` would slip past the
|
||||
// `Number.isFinite` check inside `fetchWithTimeout` (NaN fails
|
||||
// isFinite, negatives pass) and either short-circuit timeout entirely
|
||||
// or fire `setTimeout(-1)` → immediate abort, killing every request
|
||||
// before it could complete. The `0` sentinel is the documented
|
||||
// disable value, so we collapse all "doesn't make sense" inputs onto
|
||||
// it instead of defending the math at every call site.
|
||||
const raw = opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
this.fetchTimeoutMs = Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a fetch call with the per-client `fetchTimeoutMs`. If the caller
|
||||
* passes their own `signal`, both signals abort the request via
|
||||
* `AbortSignal.any`, so caller cancellation and the per-call timeout
|
||||
* compose. Streaming endpoints (subscribeEvents) call `_fetch` directly
|
||||
* to skip the timeout — long-lived SSE connections must not be killed
|
||||
* by it.
|
||||
*/
|
||||
private async fetchWithTimeout<T = Response>(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
consume?: (res: Response) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// BRN1o: when `consume` is provided, the timer must remain
|
||||
// armed through the entire callback (body read + parse). The
|
||||
// previous `Response`-returning shape cleared the timer the
|
||||
// moment headers arrived, so `await res.json()` against a
|
||||
// proxy that stalled mid-body could hang indefinitely past
|
||||
// `fetchTimeoutMs`. Pass the body-reading code as a callback
|
||||
// so its execution is included in the timer scope; the
|
||||
// composed abort signal still flows through to fetch's body
|
||||
// stream, so an in-progress `res.json()` rejects cleanly when
|
||||
// the timer fires.
|
||||
if (!this.fetchTimeoutMs || !Number.isFinite(this.fetchTimeoutMs)) {
|
||||
const res = await this._fetch(url, init);
|
||||
if (consume) return consume(res);
|
||||
return res as unknown as T;
|
||||
}
|
||||
// Use AbortController + cancellable setTimeout instead of
|
||||
// `AbortSignal.timeout()` (the polyfill `abortTimeout` is the
|
||||
// same shape — fires once, never disarms). On a fast-resolving
|
||||
// request with a long `fetchTimeoutMs` (e.g. 30s default), the
|
||||
// pending timer keeps the event loop registration alive even
|
||||
// after the fetch already returned. High request volume × long
|
||||
// timeout = accumulating timers + retained closures. Clearing
|
||||
// in `finally` releases each timer the moment its fetch (and
|
||||
// body consume callback, if any) settles.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
ctrl.abort(new DOMException('The operation timed out', 'TimeoutError'));
|
||||
}, this.fetchTimeoutMs);
|
||||
if (typeof timer === 'object' && timer && 'unref' in timer) {
|
||||
(timer as { unref: () => void }).unref();
|
||||
}
|
||||
const callerSignal = init.signal ?? undefined;
|
||||
const signal = callerSignal
|
||||
? composeAbortSignals([callerSignal, ctrl.signal])
|
||||
: ctrl.signal;
|
||||
try {
|
||||
const res = await this._fetch(url, { ...init, signal });
|
||||
if (consume) return await consume(res);
|
||||
return res as unknown as T;
|
||||
} finally {
|
||||
clearTimeout(timer as Parameters<typeof clearTimeout>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Plumbing -----------------------------------------------------------
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const out: Record<string, string> = { ...extra };
|
||||
if (this.token) out['Authorization'] = `Bearer ${this.token}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
private async failOnError(
|
||||
res: Response,
|
||||
label: string,
|
||||
): Promise<DaemonHttpError> {
|
||||
// Read the body exactly once. `res.json()` consumes the stream even on
|
||||
// parse-failure, leaving a subsequent `res.text()` empty — so go via
|
||||
// text() and attempt JSON parsing ourselves; raw text is a useful
|
||||
// fallback (the daemon may surface text/plain on upstream errors).
|
||||
let body: unknown = undefined;
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* body unreadable */
|
||||
}
|
||||
const detail =
|
||||
body && typeof body === 'object' && 'error' in body
|
||||
? String((body as { error: unknown }).error)
|
||||
: `HTTP ${res.status}`;
|
||||
return new DaemonHttpError(res.status, body, `${label}: ${detail}`);
|
||||
}
|
||||
|
||||
// -- Lifecycle / discovery ---------------------------------------------
|
||||
|
||||
async health(): Promise<{ status: string }> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/health`,
|
||||
{ headers: this.headers() },
|
||||
async (res) => {
|
||||
if (!res.ok) throw await this.failOnError(res, 'GET /health');
|
||||
return (await res.json()) as { status: string };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async capabilities(): Promise<DaemonCapabilities> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/capabilities`,
|
||||
{ headers: this.headers() },
|
||||
async (res) => {
|
||||
if (!res.ok) throw await this.failOnError(res, 'GET /capabilities');
|
||||
return (await res.json()) as DaemonCapabilities;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Sessions ----------------------------------------------------------
|
||||
|
||||
async createOrAttachSession(
|
||||
req: CreateSessionRequest,
|
||||
): Promise<DaemonSession> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
cwd: req.workspaceCwd,
|
||||
...(req.modelServiceId ? { modelServiceId: req.modelServiceId } : {}),
|
||||
}),
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) throw await this.failOnError(res, 'POST /session');
|
||||
return (await res.json()) as DaemonSession;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate live sessions in the given workspace. Used by session-picker
|
||||
* UIs. Returns an empty list (not 404) when the workspace has no sessions.
|
||||
*/
|
||||
async listWorkspaceSessions(
|
||||
workspaceCwd: string,
|
||||
): Promise<DaemonSessionSummary[]> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/workspace/${encodeURIComponent(workspaceCwd)}/sessions`,
|
||||
{ headers: this.headers() },
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
throw await this.failOnError(res, 'GET /workspace/:id/sessions');
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
sessions: DaemonSessionSummary[];
|
||||
};
|
||||
return body.sessions;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active model for a session. Backed by ACP's currently-unstable
|
||||
* `unstable_setSessionModel`; the daemon also publishes a `model_switched`
|
||||
* event so cross-client UIs can update.
|
||||
*/
|
||||
async setSessionModel(
|
||||
sessionId: string,
|
||||
modelId: string,
|
||||
): Promise<SetModelResult> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/model`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ modelId }),
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
throw await this.failOnError(res, 'POST /session/:id/model');
|
||||
}
|
||||
return (await res.json()) as SetModelResult;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a prompt to the agent. Long-lived: a model + tool turn can
|
||||
* take minutes, so this method bypasses `fetchTimeoutMs` (which
|
||||
* would force a default 30s deadline that's too short for normal
|
||||
* use). Cancellation is via the optional `signal` — when it fires,
|
||||
* the daemon receives the underlying TCP close and forwards an
|
||||
* ACP `cancel` notification to the agent, resolving the prompt
|
||||
* with `stopReason: 'cancelled'`. `cancel(sessionId)` is the
|
||||
* out-of-band alternative.
|
||||
*/
|
||||
async prompt(
|
||||
sessionId: string,
|
||||
req: PromptRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PromptResult> {
|
||||
const res = await this._fetch(
|
||||
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/prompt');
|
||||
return (await res.json()) as PromptResult;
|
||||
}
|
||||
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
||||
body: '{}',
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw await this.failOnError(res, 'POST /session/:id/cancel');
|
||||
}
|
||||
// Drain so undici doesn't keep the socket pinned waiting for
|
||||
// the consumer (matches the respondToPermission rationale).
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
/* body already consumed or no body */
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Events stream -----------------------------------------------------
|
||||
|
||||
async *subscribeEvents(
|
||||
sessionId: string,
|
||||
opts: SubscribeOptions = {},
|
||||
): AsyncGenerator<DaemonEvent> {
|
||||
const headers = this.headers({ Accept: 'text/event-stream' });
|
||||
if (opts.lastEventId !== undefined) {
|
||||
headers['Last-Event-ID'] = String(opts.lastEventId);
|
||||
}
|
||||
// Apply `fetchTimeoutMs` to the CONNECT phase only (request → headers
|
||||
// received). The SSE body itself must NOT be timed out — it's
|
||||
// long-lived by design — so once `_fetch` returns the timer is
|
||||
// cleared. Without this, an unresponsive daemon (TCP open but no
|
||||
// headers) blocks `subscribeEvents` indefinitely instead of
|
||||
// failing with the same 30s default the rest of the SDK uses.
|
||||
const connectCtrl = new AbortController();
|
||||
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (this.fetchTimeoutMs && Number.isFinite(this.fetchTimeoutMs)) {
|
||||
connectTimer = setTimeout(
|
||||
() =>
|
||||
connectCtrl.abort(
|
||||
new DOMException('Initial connect timed out', 'TimeoutError'),
|
||||
),
|
||||
this.fetchTimeoutMs,
|
||||
);
|
||||
if (
|
||||
typeof connectTimer === 'object' &&
|
||||
connectTimer &&
|
||||
'unref' in connectTimer
|
||||
) {
|
||||
(connectTimer as { unref: () => void }).unref();
|
||||
}
|
||||
}
|
||||
const fetchSignal = opts.signal
|
||||
? composeAbortSignals([opts.signal, connectCtrl.signal])
|
||||
: connectCtrl.signal;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this._fetch(
|
||||
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`,
|
||||
{ headers, signal: fetchSignal },
|
||||
);
|
||||
} finally {
|
||||
if (connectTimer !== undefined) clearTimeout(connectTimer);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw await this.failOnError(res, 'GET /session/:id/events');
|
||||
}
|
||||
// A 200 with the wrong content type usually means a misconfigured
|
||||
// proxy or middleware swallowed our SSE response and replaced it
|
||||
// with JSON/HTML. Without this check `parseSseStream` would
|
||||
// silently produce zero frames — a confusing "no events" symptom
|
||||
// that's easy to misdiagnose. Fail fast with the actual mime type.
|
||||
//
|
||||
// Cancel the body before throwing so undici doesn't keep the
|
||||
// underlying socket pinned waiting for the consumer. Same
|
||||
// reasoning as `respondToPermission` — long-running clients
|
||||
// hitting this path repeatedly would otherwise exhaust the
|
||||
// connection pool.
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (!ct.toLowerCase().includes('text/event-stream')) {
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
/* body already consumed or no body */
|
||||
}
|
||||
throw new DaemonHttpError(
|
||||
res.status,
|
||||
ct,
|
||||
`GET /session/:id/events: expected content-type text/event-stream, got "${ct}"`,
|
||||
);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error('SSE response has no body');
|
||||
}
|
||||
// Forward the abort signal so post-200 aborts stop the iteration.
|
||||
// Without this, callers who `controller.abort()` after the response
|
||||
// arrives keep receiving frames until the upstream closes.
|
||||
yield* parseSseStream(res.body, opts.signal);
|
||||
}
|
||||
|
||||
// -- Permissions -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cast a permission vote. Returns true when the daemon accepted the vote,
|
||||
* false on 404 (request unknown or already resolved by another client —
|
||||
* the typical "lost the race" outcome under multi-client fan-out).
|
||||
*/
|
||||
async respondToPermission(
|
||||
requestId: string,
|
||||
response: PermissionResponse,
|
||||
): Promise<boolean> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/permission/${encodeURIComponent(requestId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(response),
|
||||
},
|
||||
async (res) => {
|
||||
if (res.status === 200) {
|
||||
// Drain the body so undici doesn't keep the underlying socket
|
||||
// pinned waiting for the consumer. On long-running clients with
|
||||
// frequent permission votes this would exhaust the connection
|
||||
// pool. Use `res.body?.cancel()` rather than `await res.json()`
|
||||
// because the daemon returns `{}` (no useful payload here) and
|
||||
// cancel is cheaper than a parse round-trip.
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
/* body already consumed or no body */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (res.status === 404) {
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
/* body already consumed or no body */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw await this.failOnError(res, 'POST /permission/:requestId');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `AbortSignal.timeout` is in every Node version this package supports
|
||||
* (`engines.node >=22.0.0` ships it natively). The feature-detect below
|
||||
* is defensive against non-Node runtimes — browsers / edge workers /
|
||||
* stripped-down V8 hosts that may consume the SDK and ship an
|
||||
* incomplete `AbortSignal` shape.
|
||||
*/
|
||||
// Exported solely for direct unit testing — production callers go
|
||||
// through `fetchWithTimeout` above. The polyfill branch only fires on
|
||||
// runtimes where `AbortSignal.timeout` isn't natively available
|
||||
// (non-Node hosts), which can't easily be exercised from the public
|
||||
// API surface in unit tests.
|
||||
export function abortTimeout(ms: number): AbortSignal {
|
||||
const tFn = (
|
||||
AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }
|
||||
).timeout;
|
||||
if (typeof tFn === 'function') return tFn.call(AbortSignal, ms);
|
||||
const ctrl = new AbortController();
|
||||
// `.unref()` so a fast-resolving fetch doesn't keep the event loop
|
||||
// alive waiting for this timer to fire (the call is `await`-ed so
|
||||
// a long-lived event loop is the caller's problem, not ours).
|
||||
// Also clear the timer when the controller aborts via another path
|
||||
// (the composed callerSignal aborts first) so we don't accumulate
|
||||
// pending timers across many fast calls in the polyfill path.
|
||||
// Native `AbortSignal.timeout()` aborts with a DOMException whose
|
||||
// `name === 'TimeoutError'` (per WHATWG). Constructor signature is
|
||||
// `new DOMException(message, name)` — calling `new DOMException(
|
||||
// 'TimeoutError')` would set the *message* to "TimeoutError" and
|
||||
// leave `name` at its default ("Error"), so callers doing
|
||||
// `if (err.name === 'TimeoutError')` would see the polyfill
|
||||
// differently from the native runtime.
|
||||
const handle = setTimeout(
|
||||
() =>
|
||||
ctrl.abort(new DOMException('The operation timed out', 'TimeoutError')),
|
||||
ms,
|
||||
);
|
||||
if (typeof handle === 'object' && handle && 'unref' in handle) {
|
||||
(handle as { unref: () => void }).unref();
|
||||
}
|
||||
ctrl.signal.addEventListener(
|
||||
'abort',
|
||||
() => clearTimeout(handle as Parameters<typeof clearTimeout>[0]),
|
||||
{ once: true },
|
||||
);
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AbortSignal.any` is available natively in every Node version this
|
||||
* package supports (`engines.node >=22.0.0` ships it). The polyfill
|
||||
* branch below is defensive against non-Node runtimes (browsers /
|
||||
* edge workers / stripped-down V8 hosts) that may consume the SDK
|
||||
* and lack `AbortSignal.any` — without it those callers would throw
|
||||
* `TypeError: AbortSignal.any is not a function` on every
|
||||
* non-streaming method.
|
||||
*
|
||||
* The polyfill creates a fresh controller and forwards the first abort
|
||||
* from any input signal, including any that are already aborted at call
|
||||
* time. It does NOT support every native edge-case (cleanup of remaining
|
||||
* listeners after the first fire is best-effort), but for `fetch`-style
|
||||
* single-shot use the difference is invisible.
|
||||
*/
|
||||
// Exported solely for direct unit testing — see note on `abortTimeout`.
|
||||
export function composeAbortSignals(signals: AbortSignal[]): AbortSignal {
|
||||
const anyFn = (
|
||||
AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }
|
||||
).any;
|
||||
if (typeof anyFn === 'function') return anyFn.call(AbortSignal, signals);
|
||||
const ctrl = new AbortController();
|
||||
// Track per-input listener so we can detach them all on the FIRST
|
||||
// abort (whichever input fires). Without this, callers who reuse a
|
||||
// long-lived AbortSignal (e.g. a session-scope cancel signal that
|
||||
// never fires for the lifetime of the SDK client) accumulate one
|
||||
// listener per SDK call — slow leak that retains the closure +
|
||||
// controller of every prior call.
|
||||
const cleanups: Array<() => void> = [];
|
||||
const detachAll = () => {
|
||||
while (cleanups.length > 0) {
|
||||
const fn = cleanups.pop();
|
||||
try {
|
||||
fn?.();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const s of signals) {
|
||||
if (s.aborted) {
|
||||
ctrl.abort(s.reason);
|
||||
detachAll();
|
||||
return ctrl.signal;
|
||||
}
|
||||
const onAbort = () => {
|
||||
ctrl.abort(s.reason);
|
||||
detachAll();
|
||||
};
|
||||
s.addEventListener('abort', onAbort, { once: true });
|
||||
cleanups.push(() => s.removeEventListener('abort', onAbort));
|
||||
}
|
||||
// Also detach if our composed controller aborts via some other path
|
||||
// (e.g. its consumer aborted independently — defense-in-depth).
|
||||
ctrl.signal.addEventListener('abort', detachAll, { once: true });
|
||||
return ctrl.signal;
|
||||
}
|
||||
30
packages/sdk-typescript/src/daemon/index.ts
Normal file
30
packages/sdk-typescript/src/daemon/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
type CreateSessionRequest,
|
||||
type DaemonClientOptions,
|
||||
type PromptRequest,
|
||||
type SubscribeOptions,
|
||||
} from './DaemonClient.js';
|
||||
export { parseSseStream, SseFramingError } from './sse.js';
|
||||
export type {
|
||||
DaemonCapabilities,
|
||||
DaemonEvent,
|
||||
DaemonMode,
|
||||
DaemonSession,
|
||||
DaemonSessionSummary,
|
||||
PermissionOutcome,
|
||||
PermissionOutcomeCancelled,
|
||||
PermissionOutcomeSelected,
|
||||
PermissionResponse,
|
||||
PromptContentBlock,
|
||||
PromptResult,
|
||||
PromptTextContent,
|
||||
SetModelResult,
|
||||
} from './types.js';
|
||||
294
packages/sdk-typescript/src/daemon/sse.ts
Normal file
294
packages/sdk-typescript/src/daemon/sse.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { DaemonEvent } from './types.js';
|
||||
|
||||
/**
|
||||
* Bd10T: typed error raised by `parseSseStream` on framing-level
|
||||
* violations (today: buffer-overflow from a non-SSE upstream that
|
||||
* never emits the `\n\n` separator). Lets SDK consumers distinguish
|
||||
* "the upstream isn't an SSE stream" from generic network failures
|
||||
* via `err instanceof SseFramingError` instead of fragile string
|
||||
* matching on `err.message`.
|
||||
*/
|
||||
export class SseFramingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SseFramingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE-encoded event-stream `Response.body` into a stream of
|
||||
* `DaemonEvent`s.
|
||||
*
|
||||
* Field handling follows the EventSource spec subset the daemon emits
|
||||
* (`packages/cli/src/serve/server.ts` `formatSseFrame`):
|
||||
* - Frames are separated by a blank line. Both `\n\n` and `\r\n\r\n`
|
||||
* are accepted; CRLF can show up when an intermediary (corporate
|
||||
* proxy, some Node http servers) normalizes line endings.
|
||||
* - Comment lines (`: ...`) and the `retry:` directive are ignored.
|
||||
* - The `data` field is parsed as JSON and yielded as the event payload;
|
||||
* `id` and `event` fields are encoded redundantly inside the JSON
|
||||
* data payload by the daemon, so we don't need to surface them
|
||||
* separately.
|
||||
* - Malformed frames (non-JSON `data`, missing `data`) are skipped
|
||||
* silently so a single bad frame can't poison the iterator.
|
||||
*
|
||||
* The reader is released in `finally` so `for await … break` paths and
|
||||
* AbortSignal cancellation both clean up cleanly.
|
||||
*/
|
||||
/**
|
||||
* Hard cap on accumulated unread UTF-16 code units (`buf.length`)
|
||||
* before we abort the stream as malformed. SSE frames are typically a
|
||||
* few hundred bytes; even a heavily-batched provider rarely crosses
|
||||
* 64 KiB. A buffer that grows past 16 Mi code units is a strong
|
||||
* signal that the upstream is NOT SSE — e.g. a misconfigured proxy
|
||||
* returned a non-streaming body, or the server never emits the
|
||||
* `\n\n` separator. Without a cap, `buf` grows until the consumer
|
||||
* OOMs.
|
||||
*
|
||||
* The cap is **code units, not bytes** (BRker). JS strings are
|
||||
* stored as UTF-16 (sometimes Latin-1 under the hood, depending on
|
||||
* engine string representation), so `buf.length` is NOT a reliable
|
||||
* byte-count proxy — a mostly-ASCII payload uses ~1 byte per code
|
||||
* unit on V8's Latin-1 path, while supplementary code points (a
|
||||
* single user-perceived character like an emoji) cost 2 code units
|
||||
* per source byte after decode. We cap on what we can cheaply
|
||||
* measure (`buf.length`); the *intent* is "stop runaway non-SSE
|
||||
* bodies", not exact memory accounting. Operators that need a
|
||||
* tighter byte-precise bound should put a reverse proxy in front of
|
||||
* the daemon with a request-size limit. 16 Mi code units is generous
|
||||
* enough that legitimate SSE traffic won't hit it under any sane
|
||||
* encoding mix.
|
||||
*/
|
||||
const MAX_BUF_CHARS = 16 * 1024 * 1024;
|
||||
|
||||
export async function* parseSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<DaemonEvent> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
// Wire abort to `reader.cancel()` so an idle/stalled upstream
|
||||
// doesn't trap the generator inside `await reader.read()`. Polling
|
||||
// `signal.aborted` between reads (the previous behavior) is fine
|
||||
// when frames are flowing, but if the stream sits silent and
|
||||
// somebody calls `controller.abort()`, the generator stays parked
|
||||
// on the pending `read()` until the upstream eventually closes —
|
||||
// contradicting this function's "AbortSignal cancellation cleans
|
||||
// up cleanly" contract. `reader.cancel()` is a no-op if already
|
||||
// cancelled, so racing the listener with the finally cleanup is
|
||||
// safe.
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
reader.cancel().catch(() => {
|
||||
/* already cancelled or detached */
|
||||
});
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// Pre-read fast-path check: if abort already fired, return
|
||||
// without entering `read()`. The listener-driven cancel above
|
||||
// covers the parked-read case; this covers the
|
||||
// already-aborted-when-loop-iterates case.
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
// BlqF_: wrap `reader.read()` so an abort-driven body-stream
|
||||
// error doesn't bubble. `reader.cancel()` (fired by the abort
|
||||
// listener above) settles the reader cleanly on most paths,
|
||||
// but undici-on-abort can also reject the in-flight `read()`
|
||||
// with an AbortError / "BodyStreamBuffer was aborted". The
|
||||
// public contract says "abort cancels cleanly" — so if we
|
||||
// catch a rejection AFTER the signal already aborted, treat
|
||||
// it as clean completion. Re-throw for any other failure so
|
||||
// consumers still see real upstream errors (network drop,
|
||||
// unexpected close, etc.) on streams they didn't abort.
|
||||
let value: Uint8Array | undefined;
|
||||
let done: boolean;
|
||||
try {
|
||||
({ value, done } = await reader.read());
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
throw err;
|
||||
}
|
||||
if (done) {
|
||||
// Flush any bytes the decoder is still holding for an incomplete
|
||||
// multi-byte UTF-8 sequence at the tail. Without this, the last
|
||||
// character of the last frame can be silently dropped.
|
||||
buf += decoder.decode();
|
||||
if (buf.length > 0) {
|
||||
// Use the same `consumeFrames` walker as the main loop
|
||||
// so a multi-byte split that completed multiple frame
|
||||
// separators in the trailing decode flush still yields
|
||||
// every frame instead of being merged into one parse.
|
||||
// The previous `splitFrames(buf)` returned `[buf]` (a
|
||||
// single-frame fallback) which silently dropped events.
|
||||
const consumed = consumeFrames(buf);
|
||||
for (const raw of consumed.frames) {
|
||||
const frame = parseFrame(raw);
|
||||
if (frame) yield frame;
|
||||
}
|
||||
// Anything left over after the last separator is a
|
||||
// legitimate trailing fragment (no `\n\n` ever arrived);
|
||||
// try to parse it once as a final attempt.
|
||||
if (consumed.tail.length > 0) {
|
||||
const frame = parseFrame(consumed.tail);
|
||||
if (frame) yield frame;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
// Unbounded buffer is a memory-pressure vector — see MAX_BUF_CHARS.
|
||||
if (buf.length > MAX_BUF_CHARS) {
|
||||
throw new SseFramingError(
|
||||
`parseSseStream: unread buffer exceeded ${MAX_BUF_CHARS} ` +
|
||||
`UTF-16 code units without a frame separator — upstream likely not SSE`,
|
||||
);
|
||||
}
|
||||
const consumed = consumeFrames(buf);
|
||||
if (consumed.frames.length > 0) {
|
||||
for (const raw of consumed.frames) {
|
||||
const frame = parseFrame(raw);
|
||||
if (frame) yield frame;
|
||||
}
|
||||
}
|
||||
buf = consumed.tail;
|
||||
}
|
||||
} finally {
|
||||
if (signal && onAbort) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
// `reader.cancel()` does both the release-lock work AND signals the
|
||||
// upstream that we don't want any more data — closing the underlying
|
||||
// HTTP body stream when the consumer breaks out early. Using only
|
||||
// `releaseLock()` would orphan the connection until idle timeout.
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already cancelled or detached */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `buf` and pull off every complete frame (either `\n\n` or
|
||||
* `\r\n\r\n` separator). Returns the frames + the unconsumed tail.
|
||||
*/
|
||||
function consumeFrames(buf: string): { frames: string[]; tail: string } {
|
||||
const frames: string[] = [];
|
||||
let cursor = 0;
|
||||
// BX9_a + BeFHR + BeFId: scan for `\n\n` first; on hit, look for
|
||||
// an earlier `\r\n\r\n` within the window `[cursor, lf)` ONLY by
|
||||
// slicing before scanning (Node's `String.indexOf` has no upper-
|
||||
// bound argument). On the LF-not-found path, fall back to a full
|
||||
// CRLF scan over the remainder. Net cost in the common LF-only
|
||||
// case is one full scan + one bounded scan over the matched
|
||||
// frame's bytes (small).
|
||||
while (cursor < buf.length) {
|
||||
const lf = buf.indexOf('\n\n', cursor);
|
||||
if (lf === -1) {
|
||||
// No LF separator left — try the CRLF fallback.
|
||||
const crlf = buf.indexOf('\r\n\r\n', cursor);
|
||||
if (crlf === -1) break;
|
||||
frames.push(buf.slice(cursor, crlf));
|
||||
cursor = crlf + 4;
|
||||
continue;
|
||||
}
|
||||
// An LF exists. Look for a CRLF that appears earlier
|
||||
// (mixed-encoding edge case) by searching ONLY the
|
||||
// pre-LF window so we don't pay for a full-remainder scan
|
||||
// every iteration.
|
||||
const window = buf.slice(cursor, lf);
|
||||
const crlfInWindow = window.indexOf('\r\n\r\n');
|
||||
if (crlfInWindow !== -1) {
|
||||
const crlf = cursor + crlfInWindow;
|
||||
frames.push(buf.slice(cursor, crlf));
|
||||
cursor = crlf + 4;
|
||||
} else {
|
||||
frames.push(buf.slice(cursor, lf));
|
||||
cursor = lf + 2;
|
||||
}
|
||||
}
|
||||
return { frames, tail: buf.slice(cursor) };
|
||||
}
|
||||
|
||||
function parseFrame(raw: string): DaemonEvent | undefined {
|
||||
if (!raw) return undefined;
|
||||
// Per the EventSource spec, comment lines (`:` prefix) and `retry:`
|
||||
// are line-level fields, not frame-level. A frame may legitimately
|
||||
// contain a leading comment / retry line AND `data:` lines (e.g. an
|
||||
// intermediary that prepends `: keep-alive` to every frame). The
|
||||
// line-level loop below only collects `data:` lines, so a
|
||||
// pure-comment frame still returns undefined via the
|
||||
// `dataLines.length === 0` guard — without us dropping real events
|
||||
// whose first line happens to be a comment (BRgq-).
|
||||
// Split on either CRLF or LF (same forgiving stance as frame boundaries).
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const rest = line.slice(5);
|
||||
// Strip ONE leading space if present (per spec); preserve subsequent
|
||||
// whitespace verbatim.
|
||||
dataLines.push(rest.startsWith(' ') ? rest.slice(1) : rest);
|
||||
}
|
||||
if (dataLines.length === 0) return undefined;
|
||||
const dataText = dataLines.join('\n');
|
||||
try {
|
||||
const parsed = JSON.parse(dataText);
|
||||
// `JSON.parse('null')` / `JSON.parse('42')` / `JSON.parse('[1,2]')`
|
||||
// etc. parse cleanly but aren't `DaemonEvent`-shaped. Casting
|
||||
// them through would hand consumers a value that violates the
|
||||
// generator's `AsyncGenerator<DaemonEvent>` contract (e.g.
|
||||
// `null` where `ev.type` is supposed to be readable, or an
|
||||
// array where `ev.v` would be undefined). The daemon itself
|
||||
// never emits these — `formatSseFrame` always serializes a
|
||||
// populated object with `v === 1` and `type: string` — so the
|
||||
// guard is defense-in-depth against misbehaving proxies /
|
||||
// alternate daemon implementations. Per BREsR: also reject
|
||||
// arrays and require minimal shape (`v === 1`, `type` is a
|
||||
// string) before yielding so the generator's static type is a
|
||||
// genuine runtime guarantee.
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
||||
if (Array.isArray(parsed)) return undefined;
|
||||
if (
|
||||
(parsed as { v?: unknown }).v !== 1 ||
|
||||
typeof (parsed as { type?: unknown }).type !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// BSP1-: when `id` is present it must be a finite safe integer.
|
||||
// `DaemonEvent.id` is `number | undefined`; a string `id` from a
|
||||
// misbehaving proxy would survive the v+type guard above and
|
||||
// break Last-Event-ID resume logic on the consumer side (which
|
||||
// does numeric comparisons). Reject the frame entirely so the
|
||||
// consumer's id-monotonicity invariant holds.
|
||||
//
|
||||
// BX8Y1: also require `id >= 1`. The daemon's `Last-Event-ID`
|
||||
// parser only accepts decimal digits (positive integers ≥ 0)
|
||||
// and the EventBus emits monotonic ids starting at 1. A client
|
||||
// that persisted `id = -1` from a malformed frame would later
|
||||
// send `Last-Event-ID: -1`, which the daemon silently ignores
|
||||
// → replay diverges. Fail loud at parse time instead.
|
||||
const rawId = (parsed as { id?: unknown }).id;
|
||||
if (rawId !== undefined) {
|
||||
if (!Number.isSafeInteger(rawId)) return undefined;
|
||||
if ((rawId as number) < 1) return undefined;
|
||||
}
|
||||
return parsed as DaemonEvent;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
103
packages/sdk-typescript/src/daemon/types.ts
Normal file
103
packages/sdk-typescript/src/daemon/types.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire types for the `qwen serve` daemon HTTP API.
|
||||
*
|
||||
* These mirror the shapes emitted by `packages/cli/src/serve` but are
|
||||
* defined SDK-side to avoid an SDK→CLI dependency. The shapes are stable
|
||||
* once the capabilities envelope's `v` advances; bumping `v` is what
|
||||
* signals breaking wire changes (per design §04).
|
||||
*/
|
||||
|
||||
export type DaemonMode = 'http-bridge' | 'native';
|
||||
|
||||
/** Capabilities envelope returned from `GET /capabilities`. */
|
||||
export interface DaemonCapabilities {
|
||||
v: 1;
|
||||
mode: DaemonMode;
|
||||
/**
|
||||
* Feature tags the client should gate UI off (e.g. `permission_vote`,
|
||||
* `session_events`). Never gate UI off `mode` — see §10.
|
||||
*/
|
||||
features: string[];
|
||||
modelServices: string[];
|
||||
}
|
||||
|
||||
/** Returned from `POST /session`. */
|
||||
export interface DaemonSession {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
/** True when an existing session was reused under sessionScope:single. */
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
/** Sparse session record returned by `GET /workspace/:id/sessions`. */
|
||||
export interface DaemonSessionSummary {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
}
|
||||
|
||||
/** Returned from `POST /session/:id/model`. ACP currently allows an opaque body. */
|
||||
export interface SetModelResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A frame in the SSE event stream. */
|
||||
export interface DaemonEvent {
|
||||
/**
|
||||
* Monotonic per-session id; pass back as `Last-Event-ID` to resume.
|
||||
*
|
||||
* Optional because terminal/synthetic frames (notably `stream_error`)
|
||||
* are emitted without an `id` line so they don't pollute the
|
||||
* Last-Event-ID sequence the client uses for resume tracking. Consumers
|
||||
* persisting the last-seen id should ignore frames where `id === undefined`.
|
||||
*/
|
||||
id?: number;
|
||||
/** Schema version; clients should ignore frames whose `v` they don't understand. */
|
||||
v: 1;
|
||||
/** Frame discriminator: `session_update`, `permission_request`, etc. */
|
||||
type: string;
|
||||
/** Frame payload — opaque JSON. */
|
||||
data: unknown;
|
||||
originatorClientId?: string;
|
||||
}
|
||||
|
||||
export interface PromptTextContent {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of content blocks the daemon's prompt route accepts. The full ACP
|
||||
* `ContentBlock` union is wider; SDK clients can pass any of those shapes
|
||||
* through — the route forwards the array verbatim.
|
||||
*/
|
||||
export type PromptContentBlock = PromptTextContent | Record<string, unknown>;
|
||||
|
||||
/** Returned from `POST /session/:id/prompt`. */
|
||||
export interface PromptResult {
|
||||
stopReason: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PermissionOutcomeCancelled {
|
||||
outcome: 'cancelled';
|
||||
}
|
||||
|
||||
export interface PermissionOutcomeSelected {
|
||||
outcome: 'selected';
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export type PermissionOutcome =
|
||||
| PermissionOutcomeCancelled
|
||||
| PermissionOutcomeSelected;
|
||||
|
||||
export interface PermissionResponse {
|
||||
outcome: PermissionOutcome;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -3,6 +3,40 @@ export { AbortError, isAbortError } from './types/errors.js';
|
|||
export { Query } from './query/Query.js';
|
||||
export { SdkLogger } from './utils/logger.js';
|
||||
|
||||
// Daemon HTTP client (talks to `qwen serve`; see GitHub issue #3803)
|
||||
export {
|
||||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
parseSseStream,
|
||||
SseFramingError,
|
||||
type CreateSessionRequest,
|
||||
type DaemonCapabilities,
|
||||
type DaemonClientOptions,
|
||||
type DaemonEvent,
|
||||
type DaemonMode,
|
||||
type DaemonSession,
|
||||
type DaemonSessionSummary,
|
||||
type PermissionOutcome,
|
||||
type PermissionOutcomeCancelled,
|
||||
type PermissionOutcomeSelected,
|
||||
type PermissionResponse,
|
||||
type PromptContentBlock,
|
||||
// BRSCv: drop the historical `Daemon`-prefixed aliases for
|
||||
// consistency with the rest of the daemon-type exports
|
||||
// (CreateSessionRequest / DaemonSession / PromptResult / etc. are
|
||||
// all exported un-prefixed). The prefix on these two was a
|
||||
// transitional artifact from when the daemon types lived alongside
|
||||
// older non-daemon types of the same name; they don't anymore.
|
||||
// The SDK is Stage-1-experimental with no shipping consumers, so
|
||||
// breaking the alias is cheaper than carrying inconsistent naming
|
||||
// forward into Stage 2.
|
||||
type PromptRequest,
|
||||
type PromptResult,
|
||||
type PromptTextContent,
|
||||
type SetModelResult,
|
||||
type SubscribeOptions,
|
||||
} from './daemon/index.js';
|
||||
|
||||
// SDK MCP Server exports
|
||||
export { tool } from './mcp/tool.js';
|
||||
export { createSdkMcpServer } from './mcp/createSdkMcpServer.js';
|
||||
|
|
|
|||
638
packages/sdk-typescript/test/unit/DaemonClient.test.ts
Normal file
638
packages/sdk-typescript/test/unit/DaemonClient.test.ts
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
abortTimeout,
|
||||
composeAbortSignals,
|
||||
} from '../../src/daemon/DaemonClient.js';
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function sseResponse(frames: string): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(frames));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
reply: (req: CapturedRequest) => Response | Promise<Response>,
|
||||
): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } {
|
||||
const calls: CapturedRequest[] = [];
|
||||
const fetchImpl = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
const method = init?.method ?? 'GET';
|
||||
const headers: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
const h = new Headers(init.headers);
|
||||
h.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
||||
}
|
||||
const body = typeof init?.body === 'string' ? init.body : null;
|
||||
const captured: CapturedRequest = { url, method, headers, body };
|
||||
calls.push(captured);
|
||||
return reply(captured);
|
||||
},
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe('DaemonClient', () => {
|
||||
describe('health', () => {
|
||||
it('GETs /health and returns the body', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { status: 'ok' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const res = await client.health();
|
||||
expect(res).toEqual({ status: 'ok' });
|
||||
expect(calls[0]?.url).toBe('http://daemon/health');
|
||||
expect(calls[0]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('throws DaemonHttpError on non-2xx', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(503, { error: 'down' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(client.health()).rejects.toBeInstanceOf(DaemonHttpError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities', () => {
|
||||
it('GETs /capabilities and returns the v1 envelope', async () => {
|
||||
const envelope = {
|
||||
v: 1 as const,
|
||||
mode: 'http-bridge' as const,
|
||||
features: ['health', 'capabilities'],
|
||||
modelServices: [],
|
||||
};
|
||||
const { fetch } = recordingFetch(() => jsonResponse(200, envelope));
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const caps = await client.capabilities();
|
||||
expect(caps).toEqual(envelope);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer auth', () => {
|
||||
it('attaches Authorization: Bearer when token is set', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { status: 'ok' }),
|
||||
);
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
token: 'secret',
|
||||
fetch,
|
||||
});
|
||||
await client.health();
|
||||
expect(calls[0]?.headers['authorization']).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('omits Authorization when no token', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { status: 'ok' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.health();
|
||||
expect(calls[0]?.headers['authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOrAttachSession', () => {
|
||||
it('POSTs cwd in the body', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
sessionId: 's-1',
|
||||
workspaceCwd: '/work/a',
|
||||
attached: false,
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: '/work/a',
|
||||
});
|
||||
expect(session.sessionId).toBe('s-1');
|
||||
expect(calls[0]?.method).toBe('POST');
|
||||
expect(calls[0]?.url).toBe('http://daemon/session');
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ cwd: '/work/a' });
|
||||
});
|
||||
|
||||
it('forwards modelServiceId when supplied', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
sessionId: 's-1',
|
||||
workspaceCwd: '/work/a',
|
||||
attached: false,
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.createOrAttachSession({
|
||||
workspaceCwd: '/work/a',
|
||||
modelServiceId: 'qwen-prod',
|
||||
});
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({
|
||||
cwd: '/work/a',
|
||||
modelServiceId: 'qwen-prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on 400', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(400, { error: 'bad cwd' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.createOrAttachSession({ workspaceCwd: 'relative' }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt', () => {
|
||||
it('POSTs the prompt body and returns the agent response', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { stopReason: 'end_turn' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const res = await client.prompt('s-1', {
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
expect(res.stopReason).toBe('end_turn');
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/s-1/prompt');
|
||||
expect(calls[0]?.method).toBe('POST');
|
||||
const body = JSON.parse(calls[0]!.body!);
|
||||
expect(body.prompt).toEqual([{ type: 'text', text: 'hi' }]);
|
||||
});
|
||||
|
||||
it('url-encodes the sessionId', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { stopReason: 'end_turn' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.prompt('with/slash', {
|
||||
prompt: [{ type: 'text', text: 'x' }],
|
||||
});
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/with%2Fslash/prompt');
|
||||
});
|
||||
|
||||
it('forwards a caller AbortSignal through to fetch (A-UsQ)', async () => {
|
||||
// The bridge already supports per-prompt cancellation via the
|
||||
// signal arg on `sendPrompt`; the SDK had the parameter wired
|
||||
// but no test, so a regression that dropped it on the floor
|
||||
// would silently leave callers unable to cancel.
|
||||
const fetch = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_res, rej) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
rej(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 30);
|
||||
await expect(
|
||||
client.prompt(
|
||||
's-1',
|
||||
{ prompt: [{ type: 'text', text: 'hi' }] },
|
||||
ctrl.signal,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('POSTs /cancel and tolerates 204', async () => {
|
||||
const { fetch, calls } = recordingFetch(
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.cancel('s-1');
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/s-1/cancel');
|
||||
expect(calls[0]?.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('throws on 404', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(404, { error: 'unknown', sessionId: 's-1' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(client.cancel('s-1')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('respondToPermission', () => {
|
||||
it('returns true on 200', async () => {
|
||||
const { fetch, calls } = recordingFetch(() => jsonResponse(200, {}));
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const accepted = await client.respondToPermission('req-1', {
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
});
|
||||
expect(accepted).toBe(true);
|
||||
expect(calls[0]?.url).toBe('http://daemon/permission/req-1');
|
||||
});
|
||||
|
||||
it('returns false on 404 (lost the race)', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(404, { error: 'unknown', requestId: 'req-1' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const accepted = await client.respondToPermission('req-1', {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
});
|
||||
expect(accepted).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on 400 (malformed outcome)', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(400, { error: 'bad outcome' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.respondToPermission('req-1', {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeEvents', () => {
|
||||
it('GETs /events and yields parsed frames', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
sseResponse(
|
||||
'id: 1\nevent: session_update\ndata: {"id":1,"v":1,"type":"session_update","data":"a"}\n\n' +
|
||||
'id: 2\nevent: session_update\ndata: {"id":2,"v":1,"type":"session_update","data":"b"}\n\n',
|
||||
),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const events = [];
|
||||
for await (const e of client.subscribeEvents('s-1')) events.push(e);
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2]);
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/s-1/events');
|
||||
expect(calls[0]?.headers['accept']).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('forwards Last-Event-ID', async () => {
|
||||
const { fetch, calls } = recordingFetch(() => sseResponse(''));
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
// Drain immediately — empty stream.
|
||||
for await (const _ of client.subscribeEvents('s-1', {
|
||||
lastEventId: 42,
|
||||
})) {
|
||||
/* unreachable */
|
||||
}
|
||||
expect(calls[0]?.headers['last-event-id']).toBe('42');
|
||||
});
|
||||
|
||||
it('throws DaemonHttpError when the daemon returns a non-2xx for events', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(404, { error: 'unknown', sessionId: 'missing' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const iter = client.subscribeEvents('missing');
|
||||
await expect(iter.next()).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWorkspaceSessions', () => {
|
||||
it('GETs /workspace/:id/sessions and returns the array', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
sessions: [
|
||||
{ sessionId: 's-1', workspaceCwd: '/work/a' },
|
||||
{ sessionId: 's-2', workspaceCwd: '/work/a' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const sessions = await client.listWorkspaceSessions('/work/a');
|
||||
expect(sessions).toHaveLength(2);
|
||||
// The cwd must be URL-encoded so the slashes don't collide with the
|
||||
// route segments.
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://daemon/workspace/%2Fwork%2Fa/sessions',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-2xx (e.g. 400 from a relative path)', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(400, { error: 'must be absolute' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.listWorkspaceSessions('relative'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionModel', () => {
|
||||
it('POSTs the modelId in the body and returns the agent response', async () => {
|
||||
const { fetch, calls } = recordingFetch(() => jsonResponse(200, {}));
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const result = await client.setSessionModel('s-1', 'qwen3-coder');
|
||||
expect(result).toEqual({});
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/s-1/model');
|
||||
expect(calls[0]?.method).toBe('POST');
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ modelId: 'qwen3-coder' });
|
||||
});
|
||||
|
||||
it('throws on 404 (unknown session)', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(404, { error: 'unknown', sessionId: 's-1' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.setSessionModel('s-1', 'qwen3-coder'),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error coercion', () => {
|
||||
it('falls back to text body when the response is not JSON', async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response('plaintext error from upstream', {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const err = await client.health().then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(DaemonHttpError);
|
||||
expect((err as DaemonHttpError).status).toBe(502);
|
||||
expect((err as DaemonHttpError).body).toBe(
|
||||
'plaintext error from upstream',
|
||||
);
|
||||
});
|
||||
|
||||
it('respondToPermission throws on 5xx', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(503, { error: 'agent crashed' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.respondToPermission('req-1', {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeEvents edge cases', () => {
|
||||
it('throws when the response body is null', async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const iter = client.subscribeEvents('s-1');
|
||||
await expect(iter.next()).rejects.toThrow(/SSE response has no body/);
|
||||
});
|
||||
|
||||
it('throws DaemonHttpError when content-type is not text/event-stream', async () => {
|
||||
// E.g. a misconfigured proxy returns 200 + JSON instead of SSE.
|
||||
// Without the content-type guard the parser would silently produce
|
||||
// zero events.
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const iter = client.subscribeEvents('s-1');
|
||||
await expect(iter.next()).rejects.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies fetchTimeoutMs to the connect phase only — never-resolving fetch aborts (A-UsS)', async () => {
|
||||
// The CONNECT phase (request → headers received) must respect
|
||||
// `fetchTimeoutMs`; the SSE body itself must NOT be timed out.
|
||||
// Verify the timer fires when headers never arrive.
|
||||
const fetch = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_res, rej) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
rej(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
fetchTimeoutMs: 50,
|
||||
});
|
||||
const before = Date.now();
|
||||
const iter = client.subscribeEvents('s-1');
|
||||
await expect(iter.next()).rejects.toThrow();
|
||||
const elapsed = Date.now() - before;
|
||||
// Generous bound — just confirms the timer fired.
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('clears the connect-timeout when headers arrive promptly (A-UsS)', async () => {
|
||||
// A fast-resolving fetch must NOT leave the timer pending,
|
||||
// otherwise vitest would see a dangling handle that keeps the
|
||||
// event loop alive past the test (flake on slow CI).
|
||||
const { fetch } = recordingFetch(() => sseResponse(''));
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
fetchTimeoutMs: 60_000, // long; if we don't clear it, the test would hang
|
||||
});
|
||||
const iter = client.subscribeEvents('s-1');
|
||||
const first = await iter.next();
|
||||
expect(first.done).toBe(true);
|
||||
// We reach this line in < a second; the 60s timer was cleared.
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL encoding of session-scoped endpoints', () => {
|
||||
it('cancel encodes a slash-bearing sessionId', async () => {
|
||||
const { fetch, calls } = recordingFetch(
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.cancel('weird/id');
|
||||
expect(calls[0]?.url).toBe('http://daemon/session/weird%2Fid/cancel');
|
||||
});
|
||||
|
||||
it('respondToPermission encodes a slash-bearing requestId', async () => {
|
||||
const { fetch, calls } = recordingFetch(() => jsonResponse(200, {}));
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await client.respondToPermission('weird/req', {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
});
|
||||
expect(calls[0]?.url).toBe('http://daemon/permission/weird%2Freq');
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseUrl normalization', () => {
|
||||
it('strips trailing slashes', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { status: 'ok' }),
|
||||
);
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon/////',
|
||||
fetch,
|
||||
});
|
||||
await client.health();
|
||||
expect(calls[0]?.url).toBe('http://daemon/health');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithTimeout', () => {
|
||||
it('aborts the underlying fetch when the configured timeout fires', async () => {
|
||||
// Fetch that *never* resolves on its own — only abort can end it.
|
||||
// This is what the polyfill paths (`abortTimeout` /
|
||||
// `composeAbortSignals`) need to actually exercise; the rest of
|
||||
// the suite uses synchronous-resolving fakes that never trigger
|
||||
// the timeout machinery.
|
||||
const fetch = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_res, rej) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
rej(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
fetchTimeoutMs: 50,
|
||||
});
|
||||
const before = Date.now();
|
||||
await expect(client.health()).rejects.toThrow();
|
||||
const elapsed = Date.now() - before;
|
||||
// Generous upper bound — we just want to know the timer fired
|
||||
// (not that the test runner waited the full default 5s).
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('aborts when the response BODY stalls after headers (BRN1o)', async () => {
|
||||
// Pre-fix bug: `fetchWithTimeout` cleared the timer the moment
|
||||
// `fetch` resolved (i.e. headers received). If the body then
|
||||
// stalled (proxy half-buffered, daemon hung mid-write), the
|
||||
// subsequent `await res.json()` had no deadline and could hang
|
||||
// indefinitely. Now the body-read happens INSIDE the timer
|
||||
// scope (via the `consume` callback), so this test exercises
|
||||
// the timer firing during body consumption.
|
||||
const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// Build a Response whose body never delivers data and never
|
||||
// closes on its own — the only way `res.json()` ever
|
||||
// returns is if the timer aborts via the composed signal.
|
||||
// Wire the abort to `controller.error(...)` (NOT
|
||||
// `body.cancel()` — that throws on a locked stream once
|
||||
// `res.json()` has started reading) so the in-flight read
|
||||
// rejects naturally.
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
try {
|
||||
controller.error(
|
||||
new DOMException('The operation timed out', 'TimeoutError'),
|
||||
);
|
||||
} catch {
|
||||
/* stream already errored / closed */
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
fetchTimeoutMs: 80,
|
||||
});
|
||||
const before = Date.now();
|
||||
await expect(client.health()).rejects.toThrow();
|
||||
const elapsed = Date.now() - before;
|
||||
// Pre-fix: this would hang for the test's outer timeout (5s+).
|
||||
// Post-fix: the timer fires ~80ms in, body read rejects.
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('composeAbortSignals forwards the first abort, with or without native AbortSignal.any', async () => {
|
||||
// Direct-unit test on the helper — `subscribeEvents` bypasses
|
||||
// `fetchWithTimeout` entirely (it calls `_fetch` directly with
|
||||
// the caller's signal), so testing through subscribeEvents
|
||||
// never exercises the polyfill. Calling `composeAbortSignals`
|
||||
// here covers it on all Node versions: native (`>=20.3`) and
|
||||
// polyfill (`18.0`–`20.2`) take the same input shape.
|
||||
const a = new AbortController();
|
||||
const b = new AbortController();
|
||||
const composed = composeAbortSignals([a.signal, b.signal]);
|
||||
expect(composed.aborted).toBe(false);
|
||||
a.abort(new DOMException('first', 'AbortError'));
|
||||
// The composed signal should follow whichever input fires first.
|
||||
// Allow a microtask for native AbortSignal.any propagation.
|
||||
await Promise.resolve();
|
||||
expect(composed.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('composeAbortSignals fires immediately if any input is already aborted', () => {
|
||||
const a = new AbortController();
|
||||
a.abort();
|
||||
const b = new AbortController();
|
||||
const composed = composeAbortSignals([a.signal, b.signal]);
|
||||
expect(composed.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('abortTimeout fires after the configured delay', async () => {
|
||||
const t0 = Date.now();
|
||||
const sig = abortTimeout(40);
|
||||
await new Promise<void>((resolve) =>
|
||||
sig.addEventListener('abort', () => resolve(), { once: true }),
|
||||
);
|
||||
const elapsed = Date.now() - t0;
|
||||
// Generous tolerance — just checking the timer fires.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(30);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
packages/sdk-typescript/test/unit/daemon-sse.test.ts
Normal file
319
packages/sdk-typescript/test/unit/daemon-sse.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSseStream } from '../../src/daemon/sse.js';
|
||||
import type { DaemonEvent } from '../../src/daemon/types.js';
|
||||
|
||||
function bodyFromString(s: string): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(s));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bodyFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(
|
||||
iter: AsyncIterable<DaemonEvent>,
|
||||
max = 100,
|
||||
): Promise<DaemonEvent[]> {
|
||||
const out: DaemonEvent[] = [];
|
||||
for await (const e of iter) {
|
||||
out.push(e);
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('parseSseStream', () => {
|
||||
it('parses a single frame', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: session_update\ndata: {"id":1,"v":1,"type":"session_update","data":"hello"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({
|
||||
id: 1,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multiple frames', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: session_update\ndata: {"id":1,"v":1,"type":"session_update","data":"a"}\n\n' +
|
||||
'id: 2\nevent: session_update\ndata: {"id":2,"v":1,"type":"session_update","data":"b"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('skips comment lines and retry directives', async () => {
|
||||
const stream = bodyFromString(
|
||||
'retry: 3000\n\n' +
|
||||
': heartbeat\n\n' +
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":1}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.id).toBe(1);
|
||||
});
|
||||
|
||||
it('still parses a frame whose FIRST line is a comment / retry (BRgq-)', async () => {
|
||||
// Per SSE spec, comment + retry are line-level, not frame-level.
|
||||
// An intermediary that prepends `: keep-alive` or `retry: …` to
|
||||
// every frame must NOT cause the embedded event to be dropped.
|
||||
const stream = bodyFromString(
|
||||
': intermediary keep-alive\nid: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":"ok"}\n\n' +
|
||||
'retry: 5000\nid: 2\nevent: x\ndata: {"id":2,"v":1,"type":"x","data":"ok"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('handles frames split across read chunks', async () => {
|
||||
const stream = bodyFromChunks([
|
||||
'id: 1\nevent: x\nda',
|
||||
'ta: {"id":1,"v":1,"type"',
|
||||
':"x","data":42}\n\n',
|
||||
]);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.data).toBe(42);
|
||||
});
|
||||
|
||||
it('skips frames whose data is not valid JSON', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\ndata: {bogus json\n\n' +
|
||||
'id: 2\nevent: x\ndata: {"id":2,"v":1,"type":"x","data":"ok"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.id).toBe(2);
|
||||
});
|
||||
|
||||
it('skips frames whose `id` is present but not a safe integer (BSP1-)', async () => {
|
||||
// `DaemonEvent.id` is `number | undefined`. A string / float /
|
||||
// unsafe-bigint id from a misbehaving proxy would break the
|
||||
// consumer's Last-Event-ID resume math (which does numeric
|
||||
// comparisons against the in-memory monotonic counter).
|
||||
const stream = bodyFromString(
|
||||
'data: {"id":"1","v":1,"type":"x","data":"ok"}\n\n' + // string id
|
||||
'data: {"id":1.5,"v":1,"type":"x","data":"ok"}\n\n' + // float id
|
||||
'data: {"id":9007199254740993,"v":1,"type":"x","data":"ok"}\n\n' + // > MAX_SAFE_INTEGER
|
||||
'data: {"id":-1,"v":1,"type":"x","data":"ok"}\n\n' + // negative — BX8Y1 rejects (id < 1)
|
||||
'data: {"id":0,"v":1,"type":"x","data":"ok"}\n\n' + // zero — BX8Y1 rejects (id < 1)
|
||||
'data: {"v":1,"type":"x","data":"ok"}\n\n' + // no id — passes
|
||||
'data: {"id":42,"v":1,"type":"x","data":"ok"}\n\n', // ok
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
// BX8Y1: id must be a safe integer ≥ 1 (the daemon's
|
||||
// Last-Event-ID parser only accepts non-negative decimals and
|
||||
// EventBus emits monotonic ids starting at 1; negative / zero
|
||||
// would diverge from the daemon's resume math).
|
||||
expect(events.map((e) => e.id)).toEqual([undefined, 42]);
|
||||
});
|
||||
|
||||
it('skips non-DaemonEvent JSON (null/primitive/array/shape-mismatch) — BQ9ze+BREsR guards', async () => {
|
||||
// `JSON.parse('null')` / `JSON.parse('[...]')` / objects missing
|
||||
// `v === 1` / `type: string` parse cleanly but aren't
|
||||
// `DaemonEvent`-shaped. The generator's static type is
|
||||
// `AsyncGenerator<DaemonEvent>` — yielding non-event values
|
||||
// would violate the runtime contract. The daemon never emits
|
||||
// any of these; defense-in-depth against misbehaving proxies.
|
||||
const stream = bodyFromString(
|
||||
'id: 1\ndata: null\n\n' +
|
||||
'id: 2\ndata: 42\n\n' +
|
||||
'id: 3\ndata: "string"\n\n' +
|
||||
'id: 4\ndata: [1,2,3]\n\n' +
|
||||
'id: 5\ndata: {"v":1}\n\n' + // missing `type`
|
||||
'id: 6\ndata: {"v":2,"type":"x","data":"ok"}\n\n' + // wrong `v`
|
||||
'id: 7\ndata: {"v":1,"type":42,"data":"ok"}\n\n' + // type not string
|
||||
'id: 8\nevent: x\ndata: {"id":8,"v":1,"type":"x","data":"ok"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
// Only the well-formed frame should yield.
|
||||
expect(events.map((e) => e.id)).toEqual([8]);
|
||||
});
|
||||
|
||||
it('flushes a trailing frame with no terminating blank line on stream close', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":1}',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('yields nothing for an empty stream', async () => {
|
||||
const stream = bodyFromString('');
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses CRLF-delimited frames', async () => {
|
||||
// Some proxies / Node http servers normalize line endings to CRLF.
|
||||
const stream = bodyFromString(
|
||||
'id: 1\r\nevent: x\r\ndata: {"id":1,"v":1,"type":"x","data":1}\r\n\r\n' +
|
||||
'id: 2\r\nevent: x\r\ndata: {"id":2,"v":1,"type":"x","data":2}\r\n\r\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('parses a mix of LF and CRLF frame separators', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":1}\n\n' +
|
||||
'id: 2\r\nevent: x\r\ndata: {"id":2,"v":1,"type":"x","data":2}\r\n\r\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('accepts data: lines without a trailing space (per SSE spec)', async () => {
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: x\ndata:{"id":1,"v":1,"type":"x","data":"no-space"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.data).toBe('no-space');
|
||||
});
|
||||
|
||||
it('accumulates multiple data: lines per spec (joined by \\n)', async () => {
|
||||
// Per SSE field parsing, a frame with two `data:` lines yields a value
|
||||
// with a `\n` between them. JSON-encoded objects with embedded newlines
|
||||
// round-trip fine when re-parsed.
|
||||
const stream = bodyFromString(
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x",\ndata: "data":"split"}\n\n',
|
||||
);
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.data).toBe('split');
|
||||
});
|
||||
|
||||
it('cancels the underlying reader on early consumer break', async () => {
|
||||
let cancelled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":1}\n\n',
|
||||
),
|
||||
);
|
||||
// Hold the stream open — we expect the consumer to cancel before
|
||||
// we send another frame.
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
for await (const _e of parseSseStream(body)) {
|
||||
// First event arrives; break out immediately.
|
||||
break;
|
||||
}
|
||||
// The for-await break invokes the iterator's `return()`, which runs
|
||||
// the parser's finally block and calls `reader.cancel()` — that
|
||||
// propagates to the underlying ReadableStream's `cancel()`.
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('abort during read() returns cleanly instead of rethrowing (BlqF_)', async () => {
|
||||
// Some fetch impls (undici on abort) settle the in-flight
|
||||
// `reader.read()` with a rejection AFTER `reader.cancel()`
|
||||
// fires. `parseSseStream`'s public contract is "abort cancels
|
||||
// cleanly" — that rejection must NOT bubble to the consumer's
|
||||
// `for await`.
|
||||
const controller = new AbortController();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(streamController) {
|
||||
// Enqueue one valid frame then go idle. Abort fires later;
|
||||
// on abort, error the controller (mimicking undici's
|
||||
// body-stream-aborted-mid-read behavior).
|
||||
streamController.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":1}\n\n',
|
||||
),
|
||||
);
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
streamController.error(
|
||||
new DOMException('BodyStreamBuffer was aborted', 'AbortError'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const events: number[] = [];
|
||||
const iter = parseSseStream(body, controller.signal);
|
||||
const firstFrame = await iter.next();
|
||||
if (!firstFrame.done) events.push(firstFrame.value.id ?? -1);
|
||||
controller.abort();
|
||||
// The for-await loop's next `read()` will reject due to the
|
||||
// streamController.error above. Pre-fix this rejection bubbled
|
||||
// to the consumer; the BlqF_ guard treats abort-while-aborted
|
||||
// as clean completion and `for await` exits cleanly.
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const ev of iter) {
|
||||
events.push(ev.id ?? -1);
|
||||
}
|
||||
})(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(events).toEqual([1]);
|
||||
});
|
||||
|
||||
it('non-abort stream errors still bubble (BlqF_ guard scope)', async () => {
|
||||
// The clean-shutdown path is ONLY for signal-driven aborts.
|
||||
// Real upstream errors (network drop, malformed close) must
|
||||
// still reach the consumer so they can distinguish "user
|
||||
// cancelled" from "the daemon hung up on us".
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(streamController) {
|
||||
streamController.error(new Error('upstream network drop'));
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ev of parseSseStream(body)) {
|
||||
/* drain */
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow(/upstream network drop/);
|
||||
});
|
||||
|
||||
it('flushes the TextDecoder on stream close so the last UTF-8 char is preserved', async () => {
|
||||
// "中" is 3 bytes in UTF-8 (0xE4 0xB8 0xAD). Split the byte stream
|
||||
// mid-character to simulate a chunk boundary that lands inside the
|
||||
// multi-byte sequence; without `decoder.decode()` flush at end-of-
|
||||
// stream the trailing byte would be dropped and the JSON parse would
|
||||
// fail.
|
||||
const fullFrame =
|
||||
'id: 1\nevent: x\ndata: {"id":1,"v":1,"type":"x","data":"中"}';
|
||||
const bytes = new TextEncoder().encode(fullFrame);
|
||||
const splitAt = bytes.length - 1; // chop off the last byte
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes.slice(0, splitAt));
|
||||
controller.enqueue(bytes.slice(splitAt));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const events = await collect(parseSseStream(stream));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.data as string).toBe('中');
|
||||
});
|
||||
});
|
||||
|
|
@ -2044,6 +2044,33 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
mime@2.6.0
|
||||
(https://github.com/broofa/mime)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2010 Benjamin Thomas, Robert Kieffer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
serve-static@1.16.2
|
||||
(No repository found)
|
||||
|
|
@ -2592,7 +2619,7 @@ SOFTWARE.
|
|||
|
||||
|
||||
============================================================
|
||||
scheduler@0.26.0
|
||||
scheduler@0.27.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue