diff --git a/README.md b/README.md index fa88ea34a8..a0a95c88b1 100644 --- a/README.md +++ b/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: diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index ed6597257d..240b767e3e 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -20,6 +20,7 @@ export default { 'channel-plugins': 'Channel Plugin Guide', tools: 'Tools', + 'qwen-serve-protocol': 'qwen serve HTTP protocol', examples: { display: 'hidden', diff --git a/docs/developers/examples/daemon-client-quickstart.md b/docs/developers/examples/daemon-client-quickstart.md new file mode 100644 index 0000000000..a72069aca4 --- /dev/null +++ b/docs/developers/examples/daemon-client-quickstart.md @@ -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/` diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md new file mode 100644 index 0000000000..4b73183e16 --- /dev/null +++ b/docs/developers/qwen-serve-protocol.md @@ -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 +``` + +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 \"\"", "sessionId": "" } +``` + +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": "", + "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": "", "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":"","sessionId":"","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: ` to replay events with `id > n` from the per-session ring (default depth 4000) +- **Gap detection (client-side):** if `` 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": "" }` — 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":""}} +``` + +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`) | diff --git a/docs/users/_meta.ts b/docs/users/_meta.ts index a822e82012..06587e1e73 100644 --- a/docs/users/_meta.ts +++ b/docs/users/_meta.ts @@ -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 diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md new file mode 100644 index 0000000000..66c1b8acc2 --- /dev/null +++ b/docs/users/qwen-serve.md @@ -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":"","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="" +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 ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | +| `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | +| `--token ` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). | +| `--max-sessions ` | `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 ` | `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 ` 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`). 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). diff --git a/eslint.config.js b/eslint.config.js index f2eb93ecfc..ea31e0f1ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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/**', diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts new file mode 100644 index 0000000000..295cdb1c7b --- /dev/null +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -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((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); + }); +}); diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts new file mode 100644 index 0000000000..08369bc230 --- /dev/null +++ b/integration-tests/cli/qwen-serve-streaming.test.ts @@ -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((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 { + const headers: Record = { + 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); +}); diff --git a/package-lock.json b/package-lock.json index 3dfebda8e4..52d681671d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 71a7591a59..cff9b6ea7e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" }, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000000..6240f97090 --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -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(() => {})` as a bug (BRQQZ). + */ +function blockForever(): Promise { + return new Promise(() => {}); +} + +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 = { + 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, + 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//cmdline` + // (Linux default; only suppressed under `hidepid=2`). Steer + // operators toward the env-var path which uses + // `/proc//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(); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f829665b1c..cc691650f5 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { // 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 { 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 diff --git a/packages/cli/src/serve/auth.ts b/packages/cli/src/serve/auth.ts new file mode 100644 index 0000000000..1f280086e3 --- /dev/null +++ b/packages/cli/src/serve/auth.ts @@ -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 = new Set(); + const allowedFor = (port: number): Set => { + 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` (SP then HTAB) doesn't 401. + // Pure-HTAB-as-separator (`Bearer\t`) 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(); + }; +} diff --git a/packages/cli/src/serve/eventBus.test.ts b/packages/cli/src/serve/eventBus.test.ts new file mode 100644 index 0000000000..063ceed97f --- /dev/null +++ b/packages/cli/src/serve/eventBus.test.ts @@ -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, + count: number, +): Promise { + 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(); + }); +}); diff --git a/packages/cli/src/serve/eventBus.ts b/packages/cli/src/serve/eventBus.ts new file mode 100644 index 0000000000..f85dc35b3d --- /dev/null +++ b/packages/cli/src/serve/eventBus.ts @@ -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; + 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(); + 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 | 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 { + if (this.closed) { + return emptyAsyncIterable(); + } + // 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( + 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 => ({ + async next(): Promise> { + const r = await queue.next(); + if (r.done) dispose(); + return r; + }, + async return(): Promise> { + 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(): AsyncIterable { + return { + [Symbol.asyncIterator]: (): AsyncIterator => ({ + async next(): Promise> { + 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 { + private readonly buf: T[] = []; + private readonly resolvers: Array<(v: IteratorResult) => 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> { + // 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)); + } +} diff --git a/packages/cli/src/serve/httpAcpBridge.test.ts b/packages/cli/src/serve/httpAcpBridge.test.ts new file mode 100644 index 0000000000..51ba8b0fe3 --- /dev/null +++ b/packages/cli/src/serve/httpAcpBridge.test.ts @@ -0,0 +1,2555 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + AgentSideConnection, + PROTOCOL_VERSION, + ndJsonStream, +} from '@agentclientprotocol/sdk'; +import type { + Agent, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; +import { + createHttpAcpBridge, + InvalidPermissionOptionError, + SessionNotFoundError, + type AcpChannel, + type ChannelFactory, +} from './httpAcpBridge.js'; +import type { BridgeEvent } from './eventBus.js'; + +// Workspace fixtures must round-trip through `path.resolve` so the +// expected values match what the bridge canonicalizes internally on +// every platform — a literal `/work/a` resolves to `D:\work\a` on +// Windows and the assertion drifts. Same for the FakeAgent's +// `sess:` synthetic id, since the cwd it sees is the post-resolve +// value the bridge passes through `connection.newSession`. +const WS_A = path.resolve(path.sep, 'work', 'a'); +const WS_B = path.resolve(path.sep, 'work', 'b'); +const SESS_A = `sess:${WS_A}`; + +interface FakeAgentOpts { + /** What the fake agent returns from `newSession`. */ + sessionIdPrefix?: string; + /** Inject a per-call delay before responding to `initialize`. */ + initializeDelayMs?: number; + /** Force `initialize` to throw. */ + initializeThrows?: Error; + /** + * Custom prompt handler. Default returns `end_turn` synchronously. Useful + * for test cases that want to observe prompt ordering. + */ + promptImpl?: ( + p: PromptRequest, + self: FakeAgent, + ) => Promise | PromptResponse; +} + +class FakeAgent implements Agent { + newSessionCalls: NewSessionRequest[] = []; + promptCalls: PromptRequest[] = []; + cancelCalls: CancelNotification[] = []; + constructor(private readonly opts: FakeAgentOpts = {}) {} + + async initialize(_p: InitializeRequest): Promise { + if (this.opts.initializeThrows) throw this.opts.initializeThrows; + if (this.opts.initializeDelayMs) { + await new Promise((r) => setTimeout(r, this.opts.initializeDelayMs)); + } + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'fake-agent', version: '0' }, + authMethods: [], + agentCapabilities: {}, + }; + } + + async newSession(p: NewSessionRequest): Promise { + this.newSessionCalls.push(p); + const prefix = this.opts.sessionIdPrefix ?? 'sess'; + // Stage 1.5 multi-session: one FakeAgent can host multiple + // sessions (same as the real ACP agent), so each newSession call + // returns a fresh id. Suffix by call-count so tests that issue + // multiple newSession on the same channel get distinct ids. + const count = this.newSessionCalls.length; + const suffix = count === 1 ? '' : `#${count}`; + return { sessionId: `${prefix}:${p.cwd}${suffix}` }; + } + + async loadSession(_p: LoadSessionRequest): Promise { + throw new Error('not implemented in test fake'); + } + async authenticate(_p: AuthenticateRequest): Promise { + throw new Error('not implemented in test fake'); + } + async prompt(p: PromptRequest): Promise { + this.promptCalls.push(p); + if (this.opts.promptImpl) { + return this.opts.promptImpl(p, this); + } + return { stopReason: 'end_turn' }; + } + async cancel(p: CancelNotification): Promise { + this.cancelCalls.push(p); + } + async setSessionMode( + _p: SetSessionModeRequest, + ): Promise { + throw new Error('not implemented in test fake'); + } + async setSessionConfigOption( + _p: SetSessionConfigOptionRequest, + ): Promise { + throw new Error('not implemented in test fake'); + } +} + +interface ChannelHandle { + channel: AcpChannel; + agent: FakeAgent; + killed: boolean; + /** + * Resolve `channel.exited` without going through `kill()`. Optionally + * supply exit info so the bridge's `session_died` event carries the + * same `exitCode` / `signalCode` it would in a real crash (BX9_P). + */ + crash: (info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => void; +} + +/** + * Create a paired in-memory NDJSON channel: bridge sees `clientChannel`, + * fake agent sees `agentStream`. Each `TransformStream` carries one + * direction. + */ +function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + let resolveExited: + | ((info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => void) + | undefined; + const exited = new Promise< + { exitCode: number | null; signalCode: NodeJS.Signals | null } | undefined + >((res) => { + resolveExited = res; + }); + const handle: ChannelHandle = { + channel: undefined as unknown as AcpChannel, + agent: new FakeAgent(opts), + killed: false, + /** Test hook: simulate an unexpected child crash. */ + crash: (info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => resolveExited!(info), + }; + // Spin up the fake agent on the agent side. + new AgentSideConnection(() => handle.agent, agentStream); + handle.channel = { + stream: clientStream, + exited, + kill: async () => { + handle.killed = true; + try { + await ab.writable.close(); + } catch { + /* ignore */ + } + try { + await ba.writable.close(); + } catch { + /* ignore */ + } + resolveExited!(); + }, + killSync: () => { + // Test fake: just mark killed; the async streams will close + // naturally on test cleanup. Mirrors the real spawn factory's + // SIGKILL semantics (fire-and-forget). + handle.killed = true; + resolveExited!(); + }, + }; + return handle; +} + +describe('createHttpAcpBridge', () => { + it('spawns a session and returns the agent-assigned id', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(session.sessionId).toBe(SESS_A); + expect(session.workspaceCwd).toBe(WS_A); + expect(session.attached).toBe(false); + expect(bridge.sessionCount).toBe(1); + expect(handles).toHaveLength(1); + expect(handles[0]?.agent.newSessionCalls[0]?.cwd).toBe(WS_A); + + await bridge.shutdown(); + expect(handles[0]?.killed).toBe(true); + }); + + it('reuses the existing session under sessionScope:single', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + expect(first.sessionId).toBe(second.sessionId); + expect(first.attached).toBe(false); + expect(second.attached).toBe(true); + expect(handles).toHaveLength(1); // only one child spawned + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + }); + + it('does NOT reuse across workspaces', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_B }); + + expect(a.sessionId).not.toBe(b.sessionId); + expect(a.attached).toBe(false); + expect(b.attached).toBe(false); + expect(handles).toHaveLength(2); + expect(bridge.sessionCount).toBe(2); + + await bridge.shutdown(); + }); + + it('creates fresh session per call under sessionScope:thread (Stage 1.5 multi-session: shares channel)', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ sessionIdPrefix: `s${handles.length}` }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ + sessionScope: 'thread', + channelFactory: factory, + }); + + const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Distinct sessions, both freshly created (neither is an attach). + expect(first.sessionId).not.toBe(second.sessionId); + expect(first.attached).toBe(false); + expect(second.attached).toBe(false); + // Stage 1.5 multi-session: the two thread-scope calls SHARE the + // workspace's `qwen --acp` child. Only one `channelFactory` call. + // Each `newSession()` call to the agent produces a distinct id. + expect(handles).toHaveLength(1); + expect(bridge.sessionCount).toBe(2); + + await bridge.shutdown(); + }); + + it('rejects relative workspace paths', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + await expect( + bridge.spawnOrAttach({ workspaceCwd: 'relative/path' }), + ).rejects.toThrow(/absolute path/); + }); + + it('canonicalizes the workspace key (single-scope reuses normalized paths)', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const aNoisy = await bridge.spawnOrAttach({ workspaceCwd: '/work/./a' }); + + expect(a.sessionId).toBe(aNoisy.sessionId); + expect(aNoisy.attached).toBe(true); + expect(handles).toHaveLength(1); + + await bridge.shutdown(); + }); + + it('kills the spawned channel and rejects when initialize fails', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + initializeThrows: new Error('handshake refused'), + }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + // ACP SDK rewrites unhandled exceptions to a JSON-RPC Internal error + // object (code -32603); the original message text is intentionally not + // forwarded. Assert on rejection + resource cleanup. + const err = await bridge.spawnOrAttach({ workspaceCwd: WS_A }).then( + () => null, + (e: unknown) => e, + ); + expect(err).not.toBeNull(); + expect(handles[0]?.killed).toBe(true); + expect(bridge.sessionCount).toBe(0); + }); + + it('times out a stuck initialize', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ initializeDelayMs: 5_000 }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + initializeTimeoutMs: 50, + }); + + await expect(bridge.spawnOrAttach({ workspaceCwd: WS_A })).rejects.toThrow( + /initialize timed out/, + ); + expect(handles[0]?.killed).toBe(true); + expect(bridge.sessionCount).toBe(0); + }); + + it('shutdown kills every live channel', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.spawnOrAttach({ workspaceCwd: WS_B }); + expect(bridge.sessionCount).toBe(2); + + await bridge.shutdown(); + expect(handles.every((h) => h.killed)).toBe(true); + expect(bridge.sessionCount).toBe(0); + }); + + it('killAllSync force-kills channels even after shutdown cleared byWorkspaceChannel (BkUyD)', async () => { + // tanzhenxin BkUyD regression: shutdown clears + // `byWorkspaceChannel` BEFORE awaiting per-child SIGTERM grace. + // If the operator double-Ctrl+C's during that window, + // killAllSync MUST still see the in-flight-being-killed + // channels. Pre-fix: killAllSync iterated `byWorkspaceChannel` + // and silently no-op'd; children orphaned. Fix: separate + // `liveChannels` set, only emptied on channel.exited. + const killSyncInvoked: number[] = []; + let nextChannelTag = 0; + const factory: ChannelFactory = async () => { + const tag = nextChannelTag++; + const h = makeChannel({ sessionIdPrefix: `s${tag}` }); + const realKillSync = h.channel.killSync; + // Spy on killSync calls so we can assert the force-kill path + // actually fired for every live channel. + h.channel = { + ...h.channel, + kill: () => + // Never resolve — simulates a stuck SIGTERM grace window. + new Promise(() => {}), + killSync: () => { + killSyncInvoked.push(tag); + realKillSync(); + }, + }; + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.spawnOrAttach({ workspaceCwd: WS_B }); + + // Kick off shutdown — its `channel.kill()` will hang on the + // never-resolving Promise above, so `byWorkspaceChannel` clears + // but the awaits never finish. This is the mid-drain state. + const shutdownPromise = bridge.shutdown(); + // Yield twice so shutdown's sync prefix runs (clear maps, + // publish session_died, start awaits). + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + // Operator double-Ctrl+C arrives now. + bridge.killAllSync(); + + // Both channels' killSync was invoked. Pre-fix this would have + // been an empty array. + expect(killSyncInvoked).toHaveLength(2); + + // Cleanup: the never-resolving kill keeps shutdownPromise + // pending forever. Don't await it (would hang the test). The + // test runner GCs it when this `it` returns. + void shutdownPromise; + }); + + describe('sendPrompt', () => { + it('forwards a prompt and returns the agent response', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + promptImpl: () => ({ stopReason: 'max_tokens' }), + }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const result = await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hi' }], + }); + expect(result).toEqual({ stopReason: 'max_tokens' }); + expect(handles[0]?.agent.promptCalls).toHaveLength(1); + + await bridge.shutdown(); + }); + + it('overrides a stale sessionId in the body with the routing id', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt(session.sessionId, { + // Body claims a different sessionId — bridge must not honor it. + sessionId: 'spoofed', + prompt: [{ type: 'text', text: 'hi' }], + }); + expect(handles[0]?.agent.promptCalls[0]?.sessionId).toBe( + session.sessionId, + ); + + await bridge.shutdown(); + }); + + it('FIFO-serializes concurrent prompts on the same session', async () => { + const order: string[] = []; + let resolveFirst: (() => void) | undefined; + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + promptImpl: async (p) => { + const tag = + (p.prompt[0] as { text?: string } | undefined)?.text ?? '?'; + order.push(`start:${tag}`); + if (tag === 'first') { + await new Promise((res) => { + resolveFirst = res; + }); + } + order.push(`end:${tag}`); + return { stopReason: 'end_turn' }; + }, + }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const p1 = bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'first' }], + }); + const p2 = bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'second' }], + }); + + // Give the event loop a chance to run the agent's start handler. + await new Promise((r) => setTimeout(r, 10)); + // The second prompt MUST NOT have started before the first ended. + expect(order).toEqual(['start:first']); + + resolveFirst!(); + await Promise.all([p1, p2]); + expect(order).toEqual([ + 'start:first', + 'end:first', + 'start:second', + 'end:second', + ]); + + await bridge.shutdown(); + }); + + it('a failed prompt does not poison the queue for subsequent prompts', async () => { + let promptCount = 0; + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + promptImpl: async () => { + promptCount += 1; + if (promptCount === 1) { + throw new Error('first prompt boom'); + } + return { stopReason: 'end_turn' }; + }, + }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const failed = await bridge + .sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'a' }], + }) + .then( + () => null, + (e: unknown) => e, + ); + expect(failed).not.toBeNull(); + + const ok = await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'b' }], + }); + expect(ok).toEqual({ stopReason: 'end_turn' }); + + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown session ids', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + await expect( + bridge.sendPrompt('unknown', { + sessionId: 'unknown', + prompt: [{ type: 'text', text: 'x' }], + }), + ).rejects.toBeInstanceOf(SessionNotFoundError); + }); + }); + + describe('cancelSession', () => { + it('forwards a cancel notification with the routing id', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.cancelSession(session.sessionId); + // Cancel is a notification — let it propagate before observing. + await new Promise((r) => setTimeout(r, 10)); + expect(handles[0]?.agent.cancelCalls).toHaveLength(1); + expect(handles[0]?.agent.cancelCalls[0]?.sessionId).toBe( + session.sessionId, + ); + + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown session ids', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + await expect(bridge.cancelSession('unknown')).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + }); + + describe('permission flow', () => { + /** Spin up a bridge with a hand-driven channel; returns the bridge, + * session, and a function the test uses to call `requestPermission` + * from the agent side. */ + async function setupForPermission() { + let capturedConn: AgentSideConnection | undefined; + const handles: Array<{ killed: boolean }> = []; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + // The agent side gets an AgentSideConnection; that exposes a + // ClientSideConnection-equivalent on its `agent` callback. We need + // to drive `requestPermission` from the agent direction — for that + // the agent calls back through its `connection` instance. + const conn = new AgentSideConnection(() => fakeAgent, agentStream); + // Save the connection — agent code uses `conn.requestPermission(...)` + // which sends the JSON-RPC request to the bridge's BridgeClient. + capturedConn = conn; + const handle = { killed: false }; + handles.push(handle); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => { + handle.killed = true; + }, + killSync: () => { + handle.killed = true; + }, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + return { bridge, session, conn: capturedConn!, handles }; + } + + it('publishes a permission_request event with a generated requestId and awaits a vote', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + // Fire requestPermission from the agent side. + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'rm -rf /' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + + // Read the permission_request event off the bus. + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + const evt = next.value!; + expect(evt.type).toBe('permission_request'); + const payload = evt.data as { + requestId: string; + sessionId: string; + options: Array<{ optionId: string }>; + }; + expect(typeof payload.requestId).toBe('string'); + expect(payload.requestId.length).toBeGreaterThan(0); + expect(payload.sessionId).toBe(session.sessionId); + expect(payload.options.map((o) => o.optionId)).toEqual(['allow', 'deny']); + expect(bridge.pendingPermissionCount).toBe(1); + + // Vote. + const accepted = bridge.respondToPermission(payload.requestId, { + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + expect(accepted).toBe(true); + + // The agent's promise resolves. + const response = (await respPromise) as { + outcome: { outcome: string; optionId?: string }; + }; + expect(response.outcome.outcome).toBe('selected'); + expect(response.outcome.optionId).toBe('allow'); + expect(bridge.pendingPermissionCount).toBe(0); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('rejects votes whose optionId was not in the agent-offered set (BkwQI)', async () => { + // BkwQI: bridge.respondToPermission validates the voter's + // `optionId` against the original `options` the agent sent. + // A client with the bearer can't forge a hidden outcome (e.g. + // `ProceedAlways*` when the prompt's `hideAlwaysAllow` policy + // suppressed it). Throws `InvalidPermissionOptionError`. + const { bridge, session, conn } = await setupForPermission(); + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'rm -rf /' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + const payload = next.value!.data as { requestId: string }; + + // Forged optionId — NOT in the agent-offered set. + expect(() => + bridge.respondToPermission(payload.requestId, { + outcome: { outcome: 'selected', optionId: 'ProceedAlwaysProject' }, + }), + ).toThrow(InvalidPermissionOptionError); + + // The pending permission is still alive — a valid vote can + // still resolve it. (Throw didn't consume the pending entry.) + expect(bridge.pendingPermissionCount).toBe(1); + bridge.respondToPermission(payload.requestId, { + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + const response = (await respPromise) as { + outcome: { outcome: string; optionId?: string }; + }; + expect(response.outcome.optionId).toBe('allow'); + + // Cancelled outcomes don't need an optionId, and aren't checked. + // (Already covered by `cancelSession resolves outstanding + // permissions as cancelled` below — call out the contract here.) + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('first-responder wins: a second vote returns false', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + const evt = (await it.next()).value!; + const requestId = (evt.data as { requestId: string }).requestId; + + const first = bridge.respondToPermission(requestId, { + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + const second = bridge.respondToPermission(requestId, { + outcome: { outcome: 'cancelled' }, + }); + expect(first).toBe(true); + expect(second).toBe(false); + + await respPromise; // resolved by the first vote + subAbort.abort(); + await bridge.shutdown(); + }); + + it('publishes a permission_resolved event when a vote lands', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + void ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + const reqEvt = (await it.next()).value!; + const requestId = (reqEvt.data as { requestId: string }).requestId; + bridge.respondToPermission(requestId, { + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + + const resolvedEvt = (await it.next()).value!; + expect(resolvedEvt.type).toBe('permission_resolved'); + expect(resolvedEvt.data).toMatchObject({ + requestId, + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('respondToPermission returns false for unknown requestId', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => makeChannel().channel, + }); + const accepted = bridge.respondToPermission('does-not-exist', { + outcome: { outcome: 'cancelled' }, + }); + expect(accepted).toBe(false); + await bridge.shutdown(); + }); + + it('cancelSession resolves outstanding permissions as cancelled', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + // Drain the permission_request event off the bus before cancelling + // (resolving via cancel publishes a permission_resolved event; + // ensure the consumer's queue isn't already full of unread frames). + const it = iter[Symbol.asyncIterator](); + await it.next(); + expect(bridge.pendingPermissionCount).toBe(1); + + await bridge.cancelSession(session.sessionId); + + const response = (await respPromise) as { + outcome: { outcome: string }; + }; + expect(response.outcome.outcome).toBe('cancelled'); + expect(bridge.pendingPermissionCount).toBe(0); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('shutdown resolves outstanding permissions as cancelled', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + await it.next(); + expect(bridge.pendingPermissionCount).toBe(1); + + await bridge.shutdown(); + + const response = (await respPromise) as { + outcome: { outcome: string }; + }; + expect(response.outcome.outcome).toBe('cancelled'); + expect(bridge.pendingPermissionCount).toBe(0); + + subAbort.abort(); + }); + + it('sendPrompt abort resolves pending permissions as cancelled (A-UsU)', async () => { + // Regression test for the bug fix where `sendPrompt`'s + // `onAbort` handler was missing the `cancelPendingForSession` + // call. Without it, an HTTP client disconnecting mid-permission + // would leave the agent stuck waiting on a vote that no SSE + // subscriber would ever cast. + // + // FakeAgent's `prompt()` here issues a permission request and + // then awaits a never-resolving promise, so the agent IS the + // thing pending on the permission. When the test aborts the + // sendPrompt, `cancelPendingForSession` resolves the + // permission, which in turn lets the agent's prompt() throw + // (it sees the cancelled outcome). Both sides settle. + let conn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent({ + promptImpl: async (p): Promise => { + // Issue the permission request from inside prompt() so + // it's correlated with the in-flight prompt the bridge + // is awaiting. + await ( + conn as unknown as { + requestPermission(q: unknown): Promise; + } + ).requestPermission({ + sessionId: p.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + ], + }); + return { stopReason: 'cancelled' }; + }, + }); + conn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Kick off sendPrompt — agent will issue a permission request + // that no SSE subscriber will vote on. + const promptAbort = new AbortController(); + const promptResult = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'x' }], + }, + promptAbort.signal, + ) + .catch(() => undefined); + + // Wait until the permission has been registered. + for (let i = 0; i < 50 && bridge.pendingPermissionCount === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(bridge.pendingPermissionCount).toBe(1); + + // Abort the prompt — the bug being regressed: the abort + // handler must call `cancelPendingForSession` so the pending + // permission resolves as cancelled (otherwise the agent's + // `requestPermission` blocks forever). + promptAbort.abort(); + + // Wait for the permission to resolve as cancelled. With the + // bug present this would hang until the test timeout. + for (let i = 0; i < 50 && bridge.pendingPermissionCount > 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(bridge.pendingPermissionCount).toBe(0); + + await bridge.shutdown(); + await promptResult; + }); + }); + + describe('modelServiceId honored at session create', () => { + /** Build a channel that records `unstable_setSessionModel` calls. */ + function setup(opts: { setModelImpl?: () => Promise } = {}) { + const setModelCalls: Array<{ sessionId: string; modelId: string }> = []; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async (req: { sessionId: string; modelId: string }) => { + setModelCalls.push({ + sessionId: req.sessionId, + modelId: req.modelId, + }); + if (opts.setModelImpl) await opts.setModelImpl(); + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + return { bridge, setModelCalls }; + } + + it('applies modelServiceId via unstable_setSessionModel after newSession', async () => { + const { bridge, setModelCalls } = setup(); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'qwen3-coder', + }); + expect(session.attached).toBe(false); + expect(setModelCalls).toHaveLength(1); + expect(setModelCalls[0]?.sessionId).toBe(session.sessionId); + expect(setModelCalls[0]?.modelId).toBe('qwen3-coder'); + await bridge.shutdown(); + }); + + it('does NOT call setSessionModel when modelServiceId is omitted', async () => { + const { bridge, setModelCalls } = setup(); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(setModelCalls).toHaveLength(0); + await bridge.shutdown(); + }); + + it('keeps the session alive on model-switch failure and publishes model_switch_failed', async () => { + // Contract (per #3889 review A05Ym): when the agent rejects the + // requested model at create-session time, the session is still + // operational on the agent's default model. The caller gets a + // sessionId they can retry the model switch against (via + // POST /session/:id/model) and observe via the SSE stream. + // Tearing the session down would force the caller into a 500 + // with no way to recover. + const { bridge } = setup({ + setModelImpl: async () => { + throw new Error('unknown model'); + }, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'definitely-not-a-real-model', + }); + expect(session.attached).toBe(false); + expect(bridge.sessionCount).toBe(1); + // The model_switch_failed event must be on the bus for any + // subscriber that subscribes with `lastEventId: 0` (replay). + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + lastEventId: 0, + }); + const it = iter[Symbol.asyncIterator](); + const first = await it.next(); + expect(first.value?.type).toBe('model_switch_failed'); + expect(first.value?.data).toMatchObject({ + sessionId: session.sessionId, + requestedModelId: 'definitely-not-a-real-model', + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('attaches to the existing session on retry after a model-switch failure', async () => { + // Per the same A05Ym contract: a follow-up `spawnOrAttach` for + // the same workspace finds the existing session (rather than + // re-spawning a fresh one), and a retry of the model switch + // through `POST /session/:id/model` is the documented recovery + // path. We exercise just the attach side here. + const { bridge } = setup({ + setModelImpl: async () => { + throw new Error('first attempt rejected'); + }, + }); + + const first = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'try-1', + }); + expect(first.attached).toBe(false); + expect(bridge.sessionCount).toBe(1); + + // Second attach (no modelServiceId so we don't re-trigger the + // failing setModel) reuses the same session. + const second = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + }); + expect(second.attached).toBe(true); + expect(second.sessionId).toBe(first.sessionId); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + }); + }); + + describe('channel exit cleanup (child-crash recovery)', () => { + it('removes the SessionEntry when the channel terminates unexpectedly', async () => { + const handles: ChannelHandle[] = []; + let n = 0; + const factory: ChannelFactory = async () => { + // Distinct sessionIdPrefix per spawn so the post-crash retry gets + // a different sessionId than the dead session — verifies the + // bridge spawned a NEW child rather than reusing. + const h = makeChannel({ sessionIdPrefix: `gen${n++}` }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(bridge.sessionCount).toBe(1); + + // Subscribe so we can observe the session_died event. + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Simulate a child crash (channel.exited resolves but we never called + // kill() — entry is still in byId/byWorkspace at the moment of crash). + handles[0]?.crash(); + + // Drain the bus — first frame is `session_died`. + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + expect(next.value?.type).toBe('session_died'); + + // After the crash handler runs, the entry should be gone. + // (await one microtask in case the handler is still resolving.) + await Promise.resolve(); + expect(bridge.sessionCount).toBe(0); + + // A subsequent spawnOrAttach for the same workspace must NOT reuse + // the dead session; it spawns fresh (attached: false) with a new id. + const fresh = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(fresh.attached).toBe(false); + expect(fresh.sessionId).not.toBe(session.sessionId); + expect(handles).toHaveLength(2); + + abort.abort(); + await bridge.shutdown(); + }); + + it('exit fired on planned shutdown does NOT trigger the unexpected-cleanup path', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // No subscribers; planned shutdown removes the entry first, THEN + // calls channel.kill() which resolves channel.exited. The cleanup + // .then() handler runs but sees byId.get(sessionId) === undefined + // (already removed), so it no-ops and doesn't double-publish. + await bridge.shutdown(); + + // Re-subscribing throws SessionNotFoundError (not a stale state). + expect(() => bridge.subscribeEvents(session.sessionId)).toThrow(); + expect(bridge.sessionCount).toBe(0); + }); + }); + + describe('model-change FIFO + failure recovery', () => { + it('publishes model_switch_failed and surfaces the error when the agent rejects', async () => { + let attempts = 0; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => { + attempts += 1; + if (attempts > 1) throw new Error('agent denied'); + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'first', + }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Second attach with a NEW model — agent rejects. Per #3889 + // review A-UsJ the attach path now SWALLOWS the model-switch + // failure (matches the create-session path's existing + // behavior): the session is fully operational on its current + // model, and returning an error without the sessionId would + // deny the caller any way to recover. The visible signal is + // the `model_switch_failed` SSE event (asserted below). + const attached = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'rejected', + }); + expect(attached.attached).toBe(true); + expect(attached.sessionId).toBe(session.sessionId); + + // Crucially: the session is still alive (we didn't tear it down + // because it's a SHARED session). Other clients keep working. + expect(bridge.sessionCount).toBe(1); + + // And cross-client observability: a model_switch_failed event + // surfaced on the bus so attached clients learn the agent denied + // the model change. (We subscribed AFTER the first spawn, so the + // initial `model_switched` from spawn-time isn't in this iter + // unless we'd passed lastEventId=0; the failed switch is the only + // event we expect to observe live.) + const it = iter[Symbol.asyncIterator](); + const failed = await it.next(); + expect(failed.value?.type).toBe('model_switch_failed'); + expect( + (failed.value?.data as { requestedModelId?: string })?.requestedModelId, + ).toBe('rejected'); + + abort.abort(); + await bridge.shutdown(); + }); + + it('serializes concurrent model-change calls (FIFO)', async () => { + const callOrder: string[] = []; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async (req: { modelId: string }) => { + callOrder.push(`enter:${req.modelId}`); + // Simulate an agent that takes time to apply. + await new Promise((r) => setTimeout(r, 30)); + callOrder.push(`exit:${req.modelId}`); + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + // First call spawns the session AND applies model "A". + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'A', + }); + + // Two concurrent attaches with different models. Without the FIFO + // they'd interleave (enter:B, enter:C, exit:B, exit:C). + await Promise.all([ + bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'B', + }), + bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'C', + }), + ]); + + // Strict sequencing: each `setSessionModel` exits before the next + // one enters. + const noEnter = callOrder.findIndex( + (s, i) => + s.startsWith('enter:') && + i > 0 && + callOrder[i - 1]!.startsWith('enter:'), + ); + expect(noEnter).toBe(-1); + await bridge.shutdown(); + }); + }); + + describe('attach honors modelServiceId on existing session', () => { + /** Channel + agent factory that records every set-model call. */ + function setupRecording() { + const setModelCalls: Array<{ sessionId: string; modelId: string }> = []; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async (req: { sessionId: string; modelId: string }) => { + setModelCalls.push({ + sessionId: req.sessionId, + modelId: req.modelId, + }); + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + return { factory, setModelCalls }; + } + + it('applies modelServiceId on attach via unstable_setSessionModel', async () => { + const { factory, setModelCalls } = setupRecording(); + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + // First call spawns; second call attaches with a DIFFERENT model. + const first = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'model-A', + }); + const second = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'model-B', + }); + + expect(second.attached).toBe(true); + expect(second.sessionId).toBe(first.sessionId); + // Two set-model calls: one at create time, one at attach time. + expect(setModelCalls.map((c) => c.modelId)).toEqual([ + 'model-A', + 'model-B', + ]); + + await bridge.shutdown(); + }); + + it('attach without modelServiceId does NOT issue setSessionModel', async () => { + const { factory, setModelCalls } = setupRecording(); + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'model-A', + }); + // Plain attach — no model preference passed. + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + expect(setModelCalls).toEqual([ + { sessionId: expect.any(String), modelId: 'model-A' }, + ]); + + await bridge.shutdown(); + }); + }); + + describe('sendPrompt fail-fast on transport close', () => { + it('rejects in-flight prompt when channel.exited fires', async () => { + // Build a channel whose `prompt()` never resolves naturally; + // exposing the `crash()` hook lets us trigger channel.exited. + let resolveExited: (() => void) | undefined; + const exited = new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >((r) => { + resolveExited = () => r(undefined); + }); + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + // Fake agent's prompt() never replies — we want the bridge's + // race-against-exited to be the only resolution path. + const stuckAgent: Agent = { + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'stuck', version: '0' }, + authMethods: [], + agentCapabilities: {}, + }; + }, + async newSession(p) { + return { sessionId: `stuck:${p.cwd}` }; + }, + async loadSession() { + throw new Error('not impl'); + }, + async authenticate() { + throw new Error('not impl'); + }, + async prompt() { + return new Promise(() => {}); // hang forever + }, + async cancel() {}, + async setSessionMode() { + throw new Error('not impl'); + }, + async setSessionConfigOption() { + throw new Error('not impl'); + }, + }; + new AgentSideConnection(() => stuckAgent, agentStream); + return { + stream: clientStream, + exited, + kill: async () => resolveExited!(), + killSync: () => resolveExited!(), + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const promptResult = bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hi' }], + }); + + // Trigger transport close mid-flight. + setTimeout(() => resolveExited!(), 50); + + await expect(promptResult).rejects.toThrow(/channel closed/i); + await bridge.shutdown(); + }); + }); + + describe('opts validation', () => { + it('rejects an invalid sessionScope', () => { + expect(() => + createHttpAcpBridge({ + sessionScope: 'bogus' as unknown as 'single', + }), + ).toThrow(/Invalid sessionScope/); + }); + + it('rejects a non-positive initializeTimeoutMs', () => { + expect(() => createHttpAcpBridge({ initializeTimeoutMs: 0 })).toThrow( + /initializeTimeoutMs/, + ); + expect(() => createHttpAcpBridge({ initializeTimeoutMs: -1 })).toThrow( + /initializeTimeoutMs/, + ); + }); + + it('rejects NaN maxSessions (BRApy: silent fail-OPEN guard)', () => { + // A typo / parse error in CLI / config that yields NaN must + // NOT silently disable the daemon's resource cap. We fail + // boot loud instead of serving unbounded. + expect(() => createHttpAcpBridge({ maxSessions: NaN })).toThrow( + /maxSessions: NaN/, + ); + expect(() => createHttpAcpBridge({ maxSessions: -5 })).toThrow( + /maxSessions: -5/, + ); + // Explicit zero or Infinity remain valid "unlimited" sentinels. + expect(() => createHttpAcpBridge({ maxSessions: 0 })).not.toThrow(); + expect(() => + createHttpAcpBridge({ maxSessions: Infinity }), + ).not.toThrow(); + }); + }); + + describe('concurrent spawn coalescing (single scope)', () => { + it('two parallel calls for the same workspace spawn ONE channel', async () => { + let spawnCount = 0; + const factory: ChannelFactory = async () => { + spawnCount += 1; + // Tiny delay so the second call's check arrives before the first + // resolves — this is the race window without coalescing. + await new Promise((r) => setTimeout(r, 10)); + return makeChannel().channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + const [a, b] = await Promise.all([ + bridge.spawnOrAttach({ workspaceCwd: WS_A }), + bridge.spawnOrAttach({ workspaceCwd: WS_A }), + ]); + + expect(spawnCount).toBe(1); + expect(a.sessionId).toBe(b.sessionId); + // Exactly one of the two callers reports `attached: false` (the spawn + // owner); the other reports `attached: true`. + expect([a.attached, b.attached].sort()).toEqual([false, true]); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + }); + + it('clears the in-flight slot on rejection so the next call can retry', async () => { + let attempt = 0; + const factory: ChannelFactory = async () => { + attempt += 1; + if (attempt === 1) { + // First spawn fails the initialize handshake. + const h = makeChannel({ + initializeThrows: new Error('boom'), + }); + return h.channel; + } + return makeChannel().channel; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + + await expect( + bridge.spawnOrAttach({ workspaceCwd: WS_A }), + ).rejects.toBeTruthy(); + + // The retry must NOT see the rejected promise still parked in + // inFlightSpawns — that would poison every future call. + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(session.sessionId).toBe(SESS_A); + expect(session.attached).toBe(false); + expect(attempt).toBe(2); + + await bridge.shutdown(); + }); + }); + + describe('BridgeClient file proxy (Stage 1: same-host trust)', () => { + /** Spawn an agent that drives readTextFile/writeTextFile from the agent + * side, exercising the BridgeClient proxy. */ + async function setupForFs() { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + return { bridge, session, conn: capturedConn! }; + } + + it('writeTextFile writes to local fs', async () => { + const { bridge, conn } = await setupForFs(); + const tmp = path.join( + os.tmpdir(), + `qwen-bridge-write-${randomBytes(8).toString('hex')}.txt`, + ); + try { + await ( + conn as unknown as { + writeTextFile(p: { + path: string; + content: string; + sessionId: string; + }): Promise; + } + ).writeTextFile({ + sessionId: 'unused', + path: tmp, + content: 'hello bridge', + }); + const content = await fsp.readFile(tmp, 'utf8'); + expect(content).toBe('hello bridge'); + } finally { + await fsp.rm(tmp, { force: true }); + await bridge.shutdown(); + } + }); + + it('writeTextFile leaves no .tmp turd in the target directory (BSA0D)', async () => { + // Verify the atomic write-then-rename pattern doesn't leak the + // intermediate temp file. After a successful write, only the + // target should exist in the directory. + const { bridge, conn } = await setupForFs(); + const dir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-bridge-atomic-'), + ); + const tmp = path.join(dir, 'target.txt'); + try { + await ( + conn as unknown as { + writeTextFile(p: { + path: string; + content: string; + sessionId: string; + }): Promise; + } + ).writeTextFile({ + sessionId: 'unused', + path: tmp, + content: 'atomic', + }); + const entries = await fsp.readdir(dir); + // Only the target should remain — no `target.txt...tmp`. + expect(entries).toEqual(['target.txt']); + expect(await fsp.readFile(tmp, 'utf8')).toBe('atomic'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + await bridge.shutdown(); + } + }); + + it('readTextFile rejects files past the size cap (BSA0E)', async () => { + // Cap is 100 MiB; create a 1 KiB sentinel and monkey-patch the + // path's stat-reported size to exceed the cap by re-pointing + // readTextFile at /dev/zero (which fs.stat reports as size 0 + // on Linux), so we can't easily simulate a 100MB file in unit + // tests. Instead, confirm the cap path is reachable via + // direct invocation by stubbing fs.stat through a sparse file. + // + // Sparse file: `truncate -s 200M` creates a 200 MiB hole that + // costs zero blocks. fs.stat reports size=200MiB; fs.readFile + // would balloon RSS but we throw before that. + const { bridge, conn } = await setupForFs(); + const sparse = path.join( + os.tmpdir(), + `qwen-bridge-sparse-${randomBytes(8).toString('hex')}.bin`, + ); + const fh = await fsp.open(sparse, 'w'); + try { + await fh.truncate(200 * 1024 * 1024); // 200 MiB hole + await fh.close(); + // Error message is wrapped by the JSON-RPC layer; assert via + // the structured envelope's data.details rather than the + // outer "Internal error" string. + await expect( + ( + conn as unknown as { + readTextFile(p: { + path: string; + sessionId: string; + }): Promise; + } + ).readTextFile({ sessionId: 'unused', path: sparse }), + ).rejects.toMatchObject({ + data: { + details: expect.stringMatching(/exceeds the.*byte daemon cap/), + }, + }); + } finally { + await fsp.rm(sparse, { force: true }); + await bridge.shutdown(); + } + }); + + it('readTextFile rejects non-regular files even when size=0 (BX8YO)', async () => { + // Char devices / FIFOs / procfs entries report size=0 but + // produce unbounded data on read. Use a FIFO as the portable + // probe (chrdev / procfs not always available). + // + // Hard-skip on Windows: the platform doesn't have FIFOs at the + // OS level. Git-Bash and similar shells ship a `mkfifo` binary + // that succeeds-with-degeneration (creates a regular file or + // silently does nothing), which then makes the test assert + // against the wrong error shape and look like a regression. + // The bridge's `!stats.isFile()` check itself is platform- + // agnostic; Linux + macOS coverage is sufficient. + if (process.platform === 'win32') return; + const { bridge, conn } = await setupForFs(); + const fifoPath = path.join( + os.tmpdir(), + `qwen-bridge-fifo-${randomBytes(8).toString('hex')}`, + ); + const { execFileSync } = await import('node:child_process'); + try { + execFileSync('mkfifo', [fifoPath]); + } catch { + // Skip if mkfifo not on PATH for some reason. + await bridge.shutdown(); + return; + } + try { + await expect( + ( + conn as unknown as { + readTextFile(p: { + path: string; + sessionId: string; + }): Promise; + } + ).readTextFile({ sessionId: 'unused', path: fifoPath }), + ).rejects.toMatchObject({ + data: { details: expect.stringMatching(/not a regular file/) }, + }); + } finally { + await fsp.rm(fifoPath, { force: true }); + await bridge.shutdown(); + } + }); + + it('writeTextFile preserves symlinks (BX8Yw)', async () => { + // Pre-fix: rename replaced the symlink with a regular file, + // leaving the original target unchanged. Verify the target's + // content is what was written and the symlink is preserved. + const { bridge, conn } = await setupForFs(); + const dir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-bridge-symlink-'), + ); + const target = path.join(dir, 'target.txt'); + const link = path.join(dir, 'link.txt'); + await fsp.writeFile(target, 'original target', 'utf8'); + await fsp.symlink(target, link); + try { + await ( + conn as unknown as { + writeTextFile(p: { + path: string; + content: string; + sessionId: string; + }): Promise; + } + ).writeTextFile({ + sessionId: 'unused', + path: link, + content: 'updated through symlink', + }); + // Target got the new content. + expect(await fsp.readFile(target, 'utf8')).toBe( + 'updated through symlink', + ); + // Link is still a symlink, not a regular file. + const linkStat = await fsp.lstat(link); + expect(linkStat.isSymbolicLink()).toBe(true); + // Reading through the link still goes to the target. + expect(await fsp.readFile(link, 'utf8')).toBe( + 'updated through symlink', + ); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + await bridge.shutdown(); + } + }); + + it('writeTextFile preserves dangling symlinks (BfFvO)', async () => { + // Symlink whose target doesn't exist yet — `fs.realpath` throws + // ENOENT. Pre-fix: the catch silently fell back to writing to + // params.path (the symlink), and rename replaced the symlink + // with a regular file (the original BX8Yw bug, masked for + // dangling targets). Fix uses `fs.readlink` to disambiguate. + if (process.platform === 'win32') return; // symlinks need admin on Windows + const { bridge, conn } = await setupForFs(); + const dir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-bridge-dangling-'), + ); + const target = path.join(dir, 'target.txt'); // not created yet + const link = path.join(dir, 'link.txt'); + await fsp.symlink(target, link); + try { + await ( + conn as unknown as { + writeTextFile(p: { + path: string; + content: string; + sessionId: string; + }): Promise; + } + ).writeTextFile({ + sessionId: 'unused', + path: link, + content: 'created through dangling symlink', + }); + // Target now exists with the content. + expect(await fsp.readFile(target, 'utf8')).toBe( + 'created through dangling symlink', + ); + // Link is STILL a symlink (not replaced by a regular file). + const linkStat = await fsp.lstat(link); + expect(linkStat.isSymbolicLink()).toBe(true); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + await bridge.shutdown(); + } + }); + + it('readTextFile returns full content by default', async () => { + const { bridge, conn } = await setupForFs(); + const tmp = path.join( + os.tmpdir(), + `qwen-bridge-read-${randomBytes(8).toString('hex')}.txt`, + ); + await fsp.writeFile( + tmp, + 'line one\nline two\nline three\nline four', + 'utf8', + ); + try { + const result = (await ( + conn as unknown as { + readTextFile(p: { + path: string; + sessionId: string; + }): Promise<{ content: string }>; + } + ).readTextFile({ sessionId: 'unused', path: tmp })) as { + content: string; + }; + expect(result.content).toContain('line one'); + expect(result.content).toContain('line four'); + } finally { + await fsp.rm(tmp, { force: true }); + await bridge.shutdown(); + } + }); + + it('readTextFile slices via line/limit (ACP 1-based line)', async () => { + const { bridge, conn } = await setupForFs(); + const tmp = path.join( + os.tmpdir(), + `qwen-bridge-slice-${randomBytes(8).toString('hex')}.txt`, + ); + await fsp.writeFile(tmp, 'a\nb\nc\nd\ne', 'utf8'); + try { + // line:1, limit:2 means "first two lines" per ACP spec (1-based). + const first = (await ( + conn as unknown as { + readTextFile(p: { + path: string; + sessionId: string; + line?: number; + limit?: number; + }): Promise<{ content: string }>; + } + ).readTextFile({ + sessionId: 'unused', + path: tmp, + line: 1, + limit: 2, + })) as { content: string }; + expect(first.content).toBe('a\nb'); + + // line:3, limit:2 → lines 3 and 4. + const middle = (await ( + conn as unknown as { + readTextFile(p: { + path: string; + sessionId: string; + line?: number; + limit?: number; + }): Promise<{ content: string }>; + } + ).readTextFile({ + sessionId: 'unused', + path: tmp, + line: 3, + limit: 2, + })) as { content: string }; + expect(middle.content).toBe('c\nd'); + } finally { + await fsp.rm(tmp, { force: true }); + await bridge.shutdown(); + } + }); + }); + + describe('listWorkspaceSessions', () => { + it('returns sessions matching the canonical workspace cwd', async () => { + let n = 0; + const factory: ChannelFactory = async () => { + // Distinct sessionIdPrefix per spawn so two thread-scope sessions + // in the same workspace get distinct ids (the FakeAgent encodes the + // cwd into the id otherwise → collision). + const h = makeChannel({ sessionIdPrefix: `s${n++}` }); + return h.channel; + }; + const bridge = createHttpAcpBridge({ + sessionScope: 'thread', + channelFactory: factory, + }); + + const a1 = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const a2 = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.spawnOrAttach({ workspaceCwd: WS_B }); + + const aList = bridge.listWorkspaceSessions(WS_A); + expect(aList).toHaveLength(2); + expect(aList.map((s) => s.sessionId).sort()).toEqual( + [a1.sessionId, a2.sessionId].sort(), + ); + const bList = bridge.listWorkspaceSessions(WS_B); + expect(bList).toHaveLength(1); + const idleList = bridge.listWorkspaceSessions('/work/c'); + expect(idleList).toEqual([]); + + await bridge.shutdown(); + }); + + it('canonicalizes the lookup path', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const list = bridge.listWorkspaceSessions('/work/./a'); + expect(list).toHaveLength(1); + expect(list[0]?.workspaceCwd).toBe(WS_A); + + await bridge.shutdown(); + }); + + it('returns empty for relative paths instead of throwing', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + expect(bridge.listWorkspaceSessions('relative/path')).toEqual([]); + }); + }); + + describe('setSessionModel', () => { + /** Set up a channel where the agent records setSessionModel calls. */ + async function setup() { + const setModelCalls: Array<{ sessionId: string; modelId: string }> = []; + const factory: ChannelFactory = async () => { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + // Augment the agent with the unstable model setter via a proxy so we + // don't need to extend the FakeAgent class with optional methods. + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async (req: { sessionId: string; modelId: string }) => { + setModelCalls.push({ + sessionId: req.sessionId, + modelId: req.modelId, + }); + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + return { bridge, session, setModelCalls }; + } + + it('forwards modelId to the agent and overrides body sessionId', async () => { + const { bridge, session, setModelCalls } = await setup(); + const response = await bridge.setSessionModel(session.sessionId, { + sessionId: 'spoofed', + modelId: 'qwen3-coder', + }); + expect(response).toEqual({}); + expect(setModelCalls[0]?.sessionId).toBe(session.sessionId); + expect(setModelCalls[0]?.modelId).toBe('qwen3-coder'); + await bridge.shutdown(); + }); + + it('publishes a model_switched event on success', async () => { + const { bridge, session } = await setup(); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + await bridge.setSessionModel(session.sessionId, { + sessionId: session.sessionId, + modelId: 'qwen3-coder', + }); + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.value?.type).toBe('model_switched'); + expect(next.value?.data).toEqual({ + sessionId: session.sessionId, + modelId: 'qwen3-coder', + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown session ids', async () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + await expect( + bridge.setSessionModel('unknown', { + sessionId: 'unknown', + modelId: 'qwen3-coder', + }), + ).rejects.toBeInstanceOf(SessionNotFoundError); + }); + }); + + describe('subscribeEvents', () => { + it('throws SessionNotFoundError for unknown session ids', () => { + const bridge = createHttpAcpBridge({ + channelFactory: async () => { + throw new Error('factory should not be called'); + }, + }); + expect(() => bridge.subscribeEvents('unknown')).toThrow( + SessionNotFoundError, + ); + }); + + it('publishes session_update events to subscribers when the agent sends them', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + // Build a channel pair where we capture the agent-side connection + // so we can drive sessionUpdate notifications from the test. + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Send a sessionUpdate from the agent side (fire-and-forget). + void capturedConn!.sessionUpdate({ + sessionId: session.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi' }, + }, + }); + + const collected: Array<{ id?: number; type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ id: e.id, type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('session_update'); + expect(collected[0]?.id).toBe(1); + + abort.abort(); + await bridge.shutdown(); + }); + + it('shutdown closes live event subscriptions', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + const drain = (async () => { + const events: unknown[] = []; + for await (const e of iter) { + events.push(e); + } + return events; + })(); + + // Give the subscriber a tick to register. + await new Promise((r) => setTimeout(r, 10)); + await bridge.shutdown(); + + // Subscriber must unwind to completion. Per #3889 review A05Ys + // the bus now publishes a terminal `session_died` event before + // closing on shutdown, so SSE subscribers can distinguish + // daemon shutdown from a transient network error. + const events = (await drain) as Array<{ type: string }>; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('session_died'); + }); + }); + + describe('maxSessions cap (chiga0 Rec 3)', () => { + it('refuses NEW spawns past the cap with SessionLimitExceededError', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + maxSessions: 2, + // `thread` so each call is a fresh session, not an attach. + sessionScope: 'thread', + }); + + // First two spawns succeed. + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.spawnOrAttach({ workspaceCwd: WS_B }); + expect(bridge.sessionCount).toBe(2); + + // Third hits the cap. + await expect( + bridge.spawnOrAttach({ workspaceCwd: WS_A }), + ).rejects.toMatchObject({ + name: 'SessionLimitExceededError', + limit: 2, + }); + // Cap rejection must NOT register a new session. + expect(bridge.sessionCount).toBe(2); + + await bridge.shutdown(); + }); + + it('attach to an existing session under single scope is NOT counted toward the cap', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + maxSessions: 1, + sessionScope: 'single', + }); + + // First call spawns. + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + expect(bridge.sessionCount).toBe(1); + + // Second call to the SAME workspace attaches — cap doesn't apply. + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(b.attached).toBe(true); + expect(b.sessionId).toBe(a.sessionId); + expect(bridge.sessionCount).toBe(1); + + // But a NEW workspace (would need a fresh spawn) is rejected. + await expect( + bridge.spawnOrAttach({ workspaceCwd: WS_B }), + ).rejects.toMatchObject({ + name: 'SessionLimitExceededError', + }); + + await bridge.shutdown(); + }); + + it('killSession({requireZeroAttaches:true}) skips reap when another client attached (BQ9tV)', async () => { + // Race: client A spawned (attached:false), then disconnected. + // Before A's disconnect-reaper runs, client B POSTs /session + // for the same workspace and gets attached:true. Without the + // race guard, A's reaper would tear down B's session. + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'single', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + // Simulate client B's attach in the race window. + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(b.attached).toBe(true); + // Client A's disconnect-reaper fires now. + await bridge.killSession(a.sessionId, { requireZeroAttaches: true }); + // Session must SURVIVE — client B is still using it. + const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(c.attached).toBe(true); + expect(c.sessionId).toBe(a.sessionId); + expect(bridge.sessionCount).toBe(1); + await bridge.shutdown(); + }); + + it('in-flight coalescing race: B attaches via inFlight before A reaps (BRSCi)', async () => { + // The harder coalescing path: A and B BOTH await the same + // doSpawn. When the spawn resolves, B's continuation must bump + // attachCount BEFORE A's route-handler-equivalent calls + // killSession. Slow-spawn factory → kick off both calls in + // parallel → confirm B's session survives A's reap. + let resolveSpawn: (() => void) | undefined; + const slowFactory: ChannelFactory = async () => { + await new Promise((r) => { + resolveSpawn = r; + }); + return makeChannel().channel; + }; + const bridge = createHttpAcpBridge({ + channelFactory: slowFactory, + sessionScope: 'single', + }); + const aPromise = bridge.spawnOrAttach({ workspaceCwd: WS_A }); + // Wait a tick so A's spawnOrAttach reaches `await doSpawn`. + await new Promise((r) => setTimeout(r, 5)); + // Now B comes in and finds A's promise in inFlightSpawns. + const bPromise = bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await new Promise((r) => setTimeout(r, 5)); + // Release the spawn — both A and B's awaits now resolve. + resolveSpawn!(); + const [a, b] = await Promise.all([aPromise, bPromise]); + expect(a.attached).toBe(false); + expect(b.attached).toBe(true); + expect(b.sessionId).toBe(a.sessionId); + // Client A's disconnect-reaper fires AFTER B has bumped + // attachCount (which the in-flight branch now does pre-await). + await bridge.killSession(a.sessionId, { requireZeroAttaches: true }); + // Session must survive — B was the late attacher. + expect(bridge.sessionCount).toBe(1); + await bridge.shutdown(); + }); + + it('detachClient does NOT reap when spawn owner is still alive (BkwQP)', async () => { + // BkwQP refinement: the BX (tanzhenxin issue 2) detach-reap path + // was eager and killed live sessions. Scenario: A spawns + // (attached: false, hasn't opened SSE yet); B attaches + // (attachCount: 1); B disconnects → detachClient. detachClient + // must NOT kill A's still-valid session. Reap is only safe + // when the spawn owner ALSO indicated they want it (via the + // killSession-bail tombstone). + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'single', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(b.attached).toBe(true); + expect(bridge.sessionCount).toBe(1); + // B disconnects — but A is alive. detachClient must NOT reap. + await bridge.detachClient(b.sessionId); + // Session survives — A would have 404'd on every subsequent + // request otherwise. + expect(bridge.sessionCount).toBe(1); + await bridge.shutdown(); + }); + + it('detachClient completes deferred reap when spawn owner ALSO disconnected (BkwQP+tanzhenxin issue 2)', async () => { + // Scenario: A spawns + disconnects (spawn-owner reap bails + // because B already bumped attachCount); B attaches + + // disconnects (detachClient decrements). With the tombstone + // set during the spawn-owner bail, B's detach now completes + // the deferred reap. + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'single', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(b.attached).toBe(true); + expect(bridge.sessionCount).toBe(1); + // A's disconnect-reaper fires: requireZeroAttaches:true bails + // (attachCount===1 from B) but sets `spawnOwnerWantedKill`. + await bridge.killSession(a.sessionId, { requireZeroAttaches: true }); + expect(bridge.sessionCount).toBe(1); // bailed, no reap + // B disconnects: detachClient decrements attachCount→0 AND + // sees the tombstone → completes the deferred reap. + await bridge.detachClient(b.sessionId); + expect(bridge.sessionCount).toBe(0); + await bridge.shutdown(); + }); + + it('detachClient does NOT reap when an SSE subscriber is live (tanzhenxin issue 2)', async () => { + // Counterpart: when client C is actively subscribed, detach + // from a transient B must NOT reap C's session. + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'single', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(b.attached).toBe(true); + // C opens an SSE subscription (counts as "live consumer"). + const sub = bridge.subscribeEvents(a.sessionId); + const sublooper = (async () => { + for await (const _ev of sub) { + /* drain */ + } + })(); + // Yield so the iterator's start-up runs and the subscriber + // registers on the EventBus. + await new Promise((r) => setImmediate(r)); + // B disconnects → detach. Session must survive. + await bridge.detachClient(b.sessionId); + expect(bridge.sessionCount).toBe(1); + await bridge.shutdown(); + await sublooper.catch(() => {}); + }); + + it('killSession({requireZeroAttaches:true}) DOES reap when no other client attached (BQ9tV)', async () => { + // Counterpart to the above: when the spawn-owner truly was + // alone, the reaper must still reap. This pins the guard's + // negative path so a future change can't accidentally make + // it always-skip. + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'single', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(a.attached).toBe(false); + expect(bridge.sessionCount).toBe(1); + // No second attach. Reaper fires. + await bridge.killSession(a.sessionId, { requireZeroAttaches: true }); + expect(bridge.sessionCount).toBe(0); + await bridge.shutdown(); + }); + + it('maxSessions: 0 disables the cap', async () => { + // Distinct sessionIdPrefix per spawn so each call gets a unique + // sessionId (otherwise they'd collide in `byId` and only the + // last would remain — making `sessionCount` stay at 1). + let n = 0; + const factory: ChannelFactory = async () => + makeChannel({ sessionIdPrefix: `s${n++}` }).channel; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + maxSessions: 0, + sessionScope: 'thread', + }); + // 5 spawns is far past the would-be default of 20 isn't, but + // it's enough to confirm the cap is disabled (with default of + // 20 a thread-scope flood could go 5 deep without hitting it + // anyway, so we use a smaller test value with 0/disabled + // explicit so a regression that re-enabled some default cap + // would still surface). + for (let i = 0; i < 5; i++) { + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + } + expect(bridge.sessionCount).toBe(5); + await bridge.shutdown(); + }); + + it('Stage 1.5 multi-session: N sessions on same workspace share ONE channel', async () => { + // The headline of the Stage 1.5 refactor — multiple thread-scope + // sessions on one workspace pay for one `qwen --acp` child, not + // N children. LaZzyMan + tanzhenxin pushed for this; the agent + // already supports it via `acpAgent.ts:194 sessions: + // Map`. + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel({ sessionIdPrefix: `s${factoryCalls}` }).channel; + }; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + maxSessions: 0, + sessionScope: 'thread', + }); + // Spin up 5 sessions on the same workspace. + const sessions = await Promise.all( + Array.from({ length: 5 }, () => + bridge.spawnOrAttach({ workspaceCwd: WS_A }), + ), + ); + // 5 distinct sessions... + expect(new Set(sessions.map((s) => s.sessionId)).size).toBe(5); + expect(bridge.sessionCount).toBe(5); + // ...but only ONE channelFactory call (= one child process). + expect(factoryCalls).toBe(1); + await bridge.shutdown(); + }); + + it('Stage 1.5: killSession on one of N sessions does NOT kill the shared channel', async () => { + // Counterpart guarantee: tearing down one session must not take + // its siblings with it. The channel stays alive while + // `channelInfo.sessionIds.size > 0`. + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ sessionIdPrefix: `s${handles.length}` }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'thread', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(handles).toHaveLength(1); + // Kill one — the other two stay. + await bridge.killSession(b.sessionId); + expect(bridge.sessionCount).toBe(2); + expect(handles[0]?.killed).toBe(false); + // Kill the second — last one alive. + await bridge.killSession(a.sessionId); + expect(bridge.sessionCount).toBe(1); + expect(handles[0]?.killed).toBe(false); + // Kill the last — NOW the channel is killed. + await bridge.killSession(c.sessionId); + expect(bridge.sessionCount).toBe(0); + expect(handles[0]?.killed).toBe(true); + await bridge.shutdown(); + }); + + it('Stage 1.5: channel.exited tears down ALL multiplexed sessions', async () => { + // When the shared child dies (crash, kill, network gone), all + // sessions on it die together — they're truly co-fated. Each + // session's bus gets its own `session_died` event so each SSE + // subscriber learns the bad news on their own stream. + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ sessionIdPrefix: `s${handles.length}` }); + handles.push(h); + return h.channel; + }; + const bridge = createHttpAcpBridge({ + channelFactory: factory, + sessionScope: 'thread', + }); + const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(bridge.sessionCount).toBe(3); + + // Subscribe so we can observe each session_died. + const eventsByA: BridgeEvent[] = []; + const eventsByB: BridgeEvent[] = []; + const eventsByC: BridgeEvent[] = []; + const drainA = (async () => { + for await (const ev of bridge.subscribeEvents(a.sessionId)) + eventsByA.push(ev); + })(); + const drainB = (async () => { + for await (const ev of bridge.subscribeEvents(b.sessionId)) + eventsByB.push(ev); + })(); + const drainC = (async () => { + for await (const ev of bridge.subscribeEvents(c.sessionId)) + eventsByC.push(ev); + })(); + // Let the subscriptions register before crashing. + await new Promise((r) => setImmediate(r)); + + // Simulate channel-level crash (child exited). + handles[0]?.crash(); + await Promise.all([drainA, drainB, drainC]); + + expect(eventsByA[eventsByA.length - 1]?.type).toBe('session_died'); + expect(eventsByB[eventsByB.length - 1]?.type).toBe('session_died'); + expect(eventsByC[eventsByC.length - 1]?.type).toBe('session_died'); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + }); + }); +}); diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts new file mode 100644 index 0000000000..10b0a77373 --- /dev/null +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -0,0 +1,2464 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promises as fs, realpathSync } from 'node:fs'; +import * as path from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, +} from '@agentclientprotocol/sdk'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { + EventBus, + type BridgeEvent, + type SubscribeOptions, +} from './eventBus.js'; +import type { + CancelNotification, + Client, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SetSessionModelRequest, + SetSessionModelResponse, + Stream, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; + +/** + * Stage 1 HTTP→ACP bridge. + * + * Per design §08 (Roadmap, Stage 1) and the issue body's Caveat: + * - One `qwen --acp` child PER WORKSPACE; multiple sessions on the same + * workspace multiplex onto that child via `connection.newSession()` + * (the agent's native `sessions: Map` — see + * `acp-integration/acpAgent.ts:194`). Sessions share the child's + * process / OAuth state / `FileReadCache` / hierarchy-memory parse. + * - HTTP request bodies are forwarded as ACP NDJSON over the child's stdin. + * - Child stdout NDJSON notifications publish onto each session's + * `EventBus`; HTTP SSE subscribers (`GET /session/:id/events`) drain + * it. Cross-client fan-out + `Last-Event-ID` reconnect supported. + * - Multi-client requests against the same session serialize through this + * bridge (FIFO; honors ACP's "one active prompt per session" invariant). + * Different sessions on the same channel can prompt concurrently — + * the ACP layer demultiplexes by sessionId. + * - Cross-workspace channel sharing is intentionally NOT done. Different + * workspaces have different `loadSettings(cwd)` state; one child would + * step on the previous workspace's settings. One channel per workspace + * is the safe scope. + * + * Stage 2 replaces the spawn step with an in-process call into core's + * ACP-equivalent API. The `HttpAcpBridge` interface stays the same so HTTP + * route handlers don't need to change. + */ + +export interface BridgeSpawnRequest { + /** Absolute path to the workspace root the child inherits as cwd. */ + workspaceCwd: string; + /** Optional explicit model service id; falls back to settings default. */ + modelServiceId?: string; +} + +export interface BridgeSession { + sessionId: string; + workspaceCwd: string; + /** True if this attach reused an existing session under `sessionScope: 'single'`. */ + attached: boolean; +} + +/** Sparse summary used by `GET /workspace/:id/sessions`. */ +export interface BridgeSessionSummary { + sessionId: string; + workspaceCwd: string; +} + +export interface HttpAcpBridge { + /** + * Create a new session, or — under `sessionScope: 'single'` — attach to an + * existing session for the same workspace. + */ + spawnOrAttach(req: BridgeSpawnRequest): Promise; + + /** + * Forward a prompt to the agent. Concurrent prompts against the same + * session FIFO-serialize through a per-session queue (ACP guarantees + * "one active prompt per session"). Throws `SessionNotFoundError` when + * the id is unknown. + * + * Optional `signal` — abort cancels the in-flight prompt by sending an + * ACP `cancel` notification to the agent (which causes the agent to + * resolve its `prompt()` with `stopReason: 'cancelled'`). Used by the + * SSE route to propagate `req.on('close')` so a disconnected HTTP + * client unblocks the per-session FIFO instead of poisoning it. + */ + sendPrompt( + sessionId: string, + req: PromptRequest, + signal?: AbortSignal, + ): Promise; + + /** + * Cancel the in-flight prompt on the session. ACP-side this is a + * notification, not a request — the agent acknowledges by resolving the + * active `prompt()` with a `cancelled` stop reason. Throws + * `SessionNotFoundError` when the id is unknown. + */ + cancelSession(sessionId: string, req?: CancelNotification): Promise; + + /** + * Subscribe to the session's event stream. Returns an AsyncIterable that + * yields published events; supports `Last-Event-ID` reconnect through + * `opts.lastEventId`. Throws `SessionNotFoundError` when the id is + * unknown. + */ + subscribeEvents( + sessionId: string, + opts?: SubscribeOptions, + ): AsyncIterable; + + /** + * Cast a vote on a pending `permission_request` (first-responder wins). + * Returns true when the vote was accepted, false when the requestId is + * unknown — either never existed or already resolved by another client. + */ + respondToPermission( + requestId: string, + response: RequestPermissionResponse, + ): boolean; + + /** + * List all live sessions whose canonical workspace path matches the + * supplied cwd. Empty array (not throw) when no sessions exist — + * a session-picker UI shouldn't 404 just because the workspace is idle. + */ + listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[]; + + /** + * Switch the active model service for a session. Forwards through ACP's + * (currently unstable) `unstable_setSessionModel` and broadcasts a + * `model_switched` event so cross-client UIs reflect the change. + * Throws `SessionNotFoundError` for unknown ids. + */ + setSessionModel( + sessionId: string, + req: SetSessionModelRequest, + ): Promise; + + /** + * Kill the agent process for the session and remove it from the maps. + * Used by the HTTP route layer to reap orphans created when a client + * disconnects mid-spawn (the server-side child kept being created + * even though no caller will ever know the sessionId). Idempotent — + * unknown / already-dead sessions are no-ops. + */ + /** + * Tear down a session — kill the child, drop from maps, publish + * `session_died`. Idempotent on already-dead sessions. + * + * `requireZeroAttaches: true` makes the call a no-op when at + * least one other client has called `spawnOrAttach` for this + * entry and got `attached: true`. Used by the disconnect-reaper + * in `server.ts` so a fast reattach by client B doesn't lose its + * session to client A's "I disconnected mid-spawn" cleanup. + */ + killSession( + sessionId: string, + opts?: { requireZeroAttaches?: boolean }, + ): Promise; + + /** + * Roll back a prior attach: decrement `attachCount` and, if the + * session now has neither attaching clients (`attachCount === 0`) + * nor live SSE subscribers, reap it. + * + * Called from the server's `POST /session` route handler when the + * attaching client disconnected before the response could be + * written (`!res.writable && session.attached === true`). Without + * this, the BQ9tV `attachCount`-based race guard would persist + * monotonically: once any attach bumped the counter, the + * spawn-owner's disconnect-reaper would never run again — even if + * the attacher themselves disconnected (tanzhenxin issue 2). This + * is the symmetric "I bumped, but my socket died so the bump is + * fictitious" cleanup. + */ + detachClient(sessionId: string): Promise; + + /** Test/inspection hook: number of live sessions. */ + readonly sessionCount: number; + + /** Test/inspection hook: number of permission requests awaiting a vote. */ + readonly pendingPermissionCount: number; + + /** + * Bd1y6: synchronous force-kill of every live channel. Called by + * the runQwenServe SIGINT/SIGTERM handler when the operator + * double-taps — the second signal can't afford the async + * `shutdown()` Promise that the first signal is still in the + * middle of. Without this, `process.exit(1)` would leave agent + * children running after the daemon vanishes. + */ + killAllSync(): void; + + /** Close all live child processes; called on daemon shutdown. */ + shutdown(): Promise; +} + +/** + * Routes catch this to map to HTTP 404. Distinct from generic Error so the + * route layer doesn't have to brittle-match on message text. + */ +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`No session with id "${sessionId}"`); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} + +/** + * Thrown by `spawnOrAttach` when a fresh-spawn would push `sessionCount` + * past `BridgeOptions.maxSessions`. The HTTP route maps this to 503 + * with a `Retry-After` hint. Attaches (same workspace under `single` + * scope) never trip this — only NEW children. Distinct error type so + * routes can branch without text-matching. + */ +export class SessionLimitExceededError extends Error { + readonly limit: number; + constructor(limit: number) { + super(`Session limit reached (${limit})`); + this.name = 'SessionLimitExceededError'; + this.limit = limit; + } +} + +/** + * One ACP NDJSON channel to a single agent. Tests inject a fake by replacing + * the channel factory; production uses `defaultSpawnChannelFactory`. + */ +export interface AcpChannel { + stream: Stream; + /** Best-effort terminate; resolves when teardown is complete. */ + kill(): Promise; + /** + * Bd1y6: synchronous force-kill for the second-signal force-exit + * path. Fires SIGKILL on the underlying child (or equivalent + * in-process tear-down) and returns immediately — no Promise. The + * daemon's signal handler can call this before `process.exit(1)` + * so that double-Ctrl+C doesn't leave the agent child running + * after the daemon vanishes. + */ + killSync(): void; + /** + * Resolves when the channel has terminated for any reason — planned + * (`kill()` called) OR unexpected (child process crashed, stream closed). + * The bridge subscribes to this so a SessionEntry whose underlying + * channel dies between requests is removed from `byWorkspace`/`byId` + * instead of lingering as a stuck session. + * + * Resolves to `{ exitCode, signalCode }` when the spawn factory can + * capture them (the standard `child.on('exit', code, signal)` path), + * or `undefined` when termination didn't go through the OS exit path + * (programmatic kill via the in-process channel, channel-factory + * error path, etc.). The bridge threads this through the + * `session_died` event so an operator triaging a crash doesn't need + * to grep stderr for the pid (BX9_P). + */ + exited: Promise; +} + +export interface AcpChannelExitInfo { + exitCode: number | null; + signalCode: NodeJS.Signals | null; +} + +export type ChannelFactory = (workspaceCwd: string) => Promise; + +// FIXME(stage-1.5, chiga0 finding 1 + 4): +// Stage 1.5 should split this file's responsibilities into: +// - `AcpChannel` interface (sendPrompt/cancel/setModel/sessionUpdate) +// with `SpawnedAcpChannel` (Stage 1) + `InProcessAcpChannel` +// (Stage 2) implementations — lifted to `@qwen-code/acp-bridge` +// so `channels/base/AcpBridge.ts` can consume the same primitive +// (today both reimplement the child lifecycle independently). +// - `Transport` interface (`SseTransport` (Stage 1) + +// `WebSocketTransport` / `InProcessTransport` seams visible) so +// adding wire formats doesn't require rewriting the bridge. +// Plus a `fileSystem?: FileSystemService` option to BridgeOptions +// (finding 4) so the BridgeClient writeTextFile/readTextFile stop +// reimplementing core's filesystem semantics — closes the Stage 1 +// known-divergence on BOM / non-UTF-8 / line-ending handling. Cost: +// one constructor dep; benefit: Stage 1 clients see correct fs +// semantics today instead of a wire-level break at Stage 2. Tracked +// under #3803. Reference: +// https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427773706 +export interface BridgeOptions { + /** + * §03 decision §1. `single` shares one session per workspace across HTTP + * clients (live-collaboration default); `thread` gives each `spawnOrAttach` + * call its own session for strict isolation. + * + * FIXME(stage-1.5, chiga0 must-have 1): + * Today this is a daemon-wide setting — clients can't override per + * request. A VSCode extension that wants a private session per + * window can't ask for it against a daemon configured for `single`. + * Stage 1.5 should accept `sessionScope` on the `POST /session` + * body, treating the daemon-wide value as a hint not a hard rule. + * Reference: + * https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427875644 + */ + sessionScope?: 'single' | 'thread'; + /** Channel factory; defaults to spawning `qwen --acp` as a child process. */ + channelFactory?: ChannelFactory; + /** How long to wait for the child's `initialize` reply before giving up. */ + initializeTimeoutMs?: number; + /** + * Cap on concurrent live sessions. `spawnOrAttach` calls that would + * cross this throw `SessionLimitExceededError`; attaches to an + * existing session (same workspace under `single` scope) are not + * counted. `0` / `Infinity` disable the cap. Defaults to 20 — see + * `ServeOptions.maxSessions` for the rationale. + */ + maxSessions?: number; + /** + * Bd1yh: per-`requestPermission` wall clock. After this many ms with + * no client vote, the agent's permission promise resolves as + * cancelled — the per-session FIFO can drain instead of poisoning + * forever on a missing SSE subscriber. Defaults to 5 minutes. + * `0` / `Infinity` / non-finite disable the timeout (matches + * legacy behavior, NOT recommended). + */ + permissionResponseTimeoutMs?: number; + /** + * Bd1z5: per-session cap on pending permissions in flight. New + * `requestPermission` calls past this cap resolve as cancelled with + * a stderr warning. Defaults to 64. `0` / `Infinity` disable the + * cap. + */ + maxPendingPermissionsPerSession?: number; +} + +/** + * One `qwen --acp` child + the ACP connection on top of it, shared by + * all SessionEntries whose workspace maps to this channel. Stage 1.5 + * multi-session work (per LaZzyMan / tanzhenxin reviews) leverages + * the agent's native `sessions: Map` (see + * `acp-integration/acpAgent.ts:194`) so multiple `newSession()` calls + * on one channel get separate session ids while sharing the child's + * process / OAuth / file-cache / hierarchy-memory parse. + * + * Lifetime: created on first `spawnOrAttach` for a workspace, kept + * alive while `sessionIds.size > 0`, and killed by `killSession` when + * the last entry leaves OR by `channel.exited` when the child dies. + * Cross-workspace channel sharing is intentionally NOT done in this + * bridge — `acpAgent.ts:601 (this.settings = loadSettings(cwd))` + * replaces the cached settings on each newSession call, so different + * workspaces in one child would step on each other's settings. One + * channel per workspace is the safe scope for Stage 1.5. + */ +interface ChannelInfo { + channel: AcpChannel; + connection: ClientSideConnection; + /** Shared BridgeClient — its methods route ACP params by sessionId. */ + client: BridgeClient; + workspaceCwd: string; + /** + * Live session ids multiplexed on this channel. Updated when + * `doSpawn` registers a new session and when `killSession` / + * `channel.exited` removes one. When the set drops to empty AND no + * session is mid-attach, the channel is killed and removed from + * `byWorkspaceChannel`. + */ + sessionIds: Set; +} + +interface SessionEntry { + sessionId: string; + workspaceCwd: string; + channel: AcpChannel; + connection: ClientSideConnection; + /** Per-session event bus drives `GET /session/:id/events`. */ + events: EventBus; + /** + * Tail of the per-session prompt queue. Each new prompt chains off the + * resolved (or rejected) state of this promise so prompts run one at a + * time in arrival order. Always resolves — failures are swallowed at the + * tail so a prior failure doesn't block subsequent prompts; the original + * caller still observes the rejection on its own returned promise. + */ + promptQueue: Promise; + /** + * Per-session model-change FIFO. Prevents two concurrent + * `applyModelServiceId` calls (e.g. simultaneous attach-with-different- + * model requests) from racing into `unstable_setSessionModel` and + * leaving the agent in non-deterministic state. Always resolves — + * failures swallowed at the tail like `promptQueue`. + */ + modelChangeQueue: Promise; + /** + * Cached "transport closed" promise. The first `sendPrompt` on a + * session lazy-builds this from `channel.exited.then(throw)`; every + * subsequent prompt's race uses the SAME promise so the listener + * count on `channel.exited` stays at one regardless of how many + * prompts run on the session over its lifetime. + */ + transportClosedReject?: Promise; + /** + * Permission requestIds belonging to this session, kept so cancelSession + * + shutdown can resolve them as `cancelled` per ACP requirement + * (cancelled prompt MUST resolve outstanding requestPermission with + * outcome.cancelled). + */ + pendingPermissionIds: Set; + /** + * Count of times `spawnOrAttach` has returned `attached: true` for + * this entry — i.e. a second-or-subsequent client claimed this + * session under `sessionScope: 'single'`. Used by the disconnect- + * reaper in `server.ts`: if the spawn-owner client disconnected + * during the spawn handshake but another client has already + * attached, the reaper must NOT tear the session down (option 1 + * from PR #3889 review BQ9tV — "track an attached-after-spawn + * counter and skip kill if any other client attached"). The + * increment + the killSession-skip-check both happen in the + * synchronous portion of their respective async functions, so the + * counter is observed atomically across the awaiting boundary. + */ + attachCount: number; + /** + * BkwQP: tombstone for the spawn-owner-disconnect path. When the + * spawn owner's HTTP response can't be written and they call + * `killSession({ requireZeroAttaches: true })` but the bail + * triggers (because some other client already bumped + * `attachCount`), set this flag — it remembers the spawn owner + * wanted the session reaped. A later `detachClient()` that brings + * `attachCount` back to 0 then completes the deferred reap. Stays + * `false` for sessions the spawn owner never tried to kill, so + * `detachClient` of a transient attach doesn't reap a still-valid + * session. + */ + spawnOwnerWantedKill: boolean; +} + +interface PendingPermission { + requestId: string; + sessionId: string; + resolve: (resp: RequestPermissionResponse) => void; + /** + * BkwQI: the option IDs the agent originally offered to clients in + * the `permission_request` event. `respondToPermission` validates + * the voter's `optionId` against this set so an authenticated + * client can't smuggle in a hidden outcome (e.g. + * `ProceedAlwaysProject` when the prompt's + * `hideAlwaysAllow` / forced-ask policy intentionally omitted it). + * Stored as a Set for O(1) membership check. + */ + allowedOptionIds: ReadonlySet; +} + +/** + * BkwQI: thrown by `bridge.respondToPermission` when the voter's + * `optionId` isn't in the set of options the agent originally + * offered. Server route catches this and returns 400 (distinct from + * 404 unknown-requestId). + */ +export class InvalidPermissionOptionError extends Error { + readonly requestId: string; + readonly optionId: string; + constructor(requestId: string, optionId: string) { + super( + `Permission ${requestId}: optionId "${optionId}" is not in the ` + + `set of options the agent offered.`, + ); + this.name = 'InvalidPermissionOptionError'; + this.requestId = requestId; + this.optionId = optionId; + } +} + +/** + * Bridge `Client` implementation — the daemon's response surface for things + * the agent asks the client (file reads/writes, permission prompts). + * + * Stage 1 behavior: + * - `requestPermission` publishes a `permission_request` event onto the + * session bus and awaits the first HTTP `POST /permission/:requestId` + * vote (first-responder wins). When the session is cancelled or the + * daemon shuts down, the pending promise resolves with + * `{ outcome: { outcome: 'cancelled' } }` per ACP spec. + * - `sessionUpdate` notifications publish onto the session's EventBus; SSE + * subscribers (`GET /session/:id/events`) drain it. + * - File reads/writes proxy to local fs (daemon and agent share the host). + * + * Stage 1 trust model: the spawned `qwen --acp` child runs as the same user + * as the daemon, so the file-proxy methods do NOT enforce a workspace-cwd + * sandbox. The agent could already read or write the same files via its + * built-in tools (e.g. shell). Restricting the bridge here would be + * theatre. Stage 4+ remote-sandbox deployments swap this `Client` for a + * sandbox-aware variant — see issue #3803 §11. + */ +class BridgeClient implements Client { + constructor( + /** + * Look up the `SessionEntry` for an ACP call. Stage 1.5 multi- + * session on one channel means `BridgeClient` is shared across + * many sessions, so we can't bind the entry in a closure — we + * dispatch by the `sessionId` ACP includes in every per-session + * notification / request. `undefined` sessionId is the fallback + * for ACP calls that don't carry one (none expected on the + * client surface as of this writing) and resolves to whatever + * the channel's most-recent entry is — kept defensive to avoid + * silent drops if ACP grows a no-sessionId call. + */ + private readonly resolveEntry: ( + sessionId?: string, + ) => SessionEntry | undefined, + private readonly registerPending: (pending: PendingPermission) => void, + /** + * Roll back a `registerPending` call when the subsequent publish + * fails (closed bus). Resolves the pending promise as cancelled + * and removes it from the daemon-wide maps so a late + * `respondToPermission` for this id returns 404 cleanly. + */ + private readonly rollbackPending: (requestId: string) => void, + /** + * Bd1yh: wall-clock ms before `requestPermission` resolves as + * cancelled if no client vote arrives. 0 = disabled. Prevents + * the per-session FIFO `promptQueue` from poisoning forever + * when no SSE subscriber is connected. + */ + private readonly permissionTimeoutMs: number, + /** + * Bd1z5: per-session cap on in-flight permissions. New requests + * past this cap resolve as cancelled with a stderr warning. + * Infinity = disabled. + */ + private readonly maxPendingPerSession: number, + ) {} + + // FIXME(stage-1.5, chiga0 finding 3): + // The first-responder permission flow here is a third permission + // model in the codebase (alongside ACP `requestPermission` direct + // and stream-json `ControlDispatcher`). Stage 1.5 should lift + // "permission request lifecycle" into a `PermissionMediator` + // interface with strategy-pluggable policies (`first-responder` | + // `designated` | `consensus` | `local-only`) so all four + // agent-exposing surfaces share one lifecycle. This is also the + // closure point for the prior chiga0 audit Risk 2 (first-responder + // lacks an authorization model). Reference: + // https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427773706 + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const entry = this.resolveEntry(params.sessionId); + if (!entry) return { outcome: { outcome: 'cancelled' } }; + + // Bd1z5: per-session cap. Reject before registering so we never + // grow `pendingPermissionIds` past the limit. + if (entry.pendingPermissionIds.size >= this.maxPendingPerSession) { + writeStderrLine( + `qwen serve: session ${entry.sessionId} exceeded ` + + `maxPendingPermissionsPerSession (${this.maxPendingPerSession}) — ` + + `resolving new permission as cancelled.`, + ); + return { outcome: { outcome: 'cancelled' } }; + } + + const requestId = randomUUID(); + return await new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + const settleOnce = (response: RequestPermissionResponse) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(response); + }; + + // BkwQI: snapshot the option-id set the agent is offering for + // this prompt. `respondToPermission` checks the voter's + // `optionId` against this set so a malicious client can't + // forge an option (e.g. `ProceedAlways*`) the agent + // intentionally hid. + const allowedOptionIds = new Set( + params.options.map((o: { optionId?: unknown }) => + String(o.optionId ?? ''), + ), + ); + allowedOptionIds.delete(''); + this.registerPending({ + requestId, + sessionId: entry.sessionId, + resolve: settleOnce, + allowedOptionIds, + }); + // `publish()` returns `undefined` on a closed bus — the + // shutdown path closes per-session buses BEFORE awaiting + // `channel.kill()`, leaving a small window where the agent + // can still issue `requestPermission`. If we registered the + // pending entry above but the publish fails, no SSE + // subscriber will ever see the request → no client can vote + // → the pending promise never resolves → agent's + // `requestPermission` hangs forever (a real bug, not a + // theoretical one — the daemon's shutdown.kill() loop awaits + // each child, and a child stuck waiting on permission would + // pin shutdown until the kill timer expires). + // + // Resolve as `cancelled` immediately if the bus rejected + // the publish. Mirrors the orphan-permission handling in + // `registerPending` itself for the entry-already-gone case. + const published = entry.events.publish({ + type: 'permission_request', + data: { + requestId, + sessionId: entry.sessionId, + toolCall: params.toolCall, + options: params.options, + }, + }); + if (!published) { + // Roll back the pending registration and resolve cancelled. + this.rollbackPending(requestId); + return; + } + + // Bd1yh: arm the deadline AFTER publish so we don't fire-and- + // cancel a no-subscriber request before the bus even saw it. + // When the deadline fires, roll back the pending (so a late + // vote returns 404) and resolve as cancelled (unwinding the + // agent's awaiting promise so the per-session FIFO can drain). + if (this.permissionTimeoutMs > 0) { + timer = setTimeout(() => { + if (settled) return; + writeStderrLine( + `qwen serve: session ${entry.sessionId} permission ` + + `${requestId} timed out after ${this.permissionTimeoutMs}ms ` + + `(no client voted) — resolving as cancelled.`, + ); + this.rollbackPending(requestId); + }, this.permissionTimeoutMs); + if (typeof timer === 'object' && timer && 'unref' in timer) { + (timer as { unref: () => void }).unref(); + } + } + }); + } + + async sessionUpdate(params: SessionNotification): Promise { + const entry = this.resolveEntry(params.sessionId); + if (!entry) return; + entry.events.publish({ type: 'session_update', data: params }); + } + + async writeTextFile( + params: WriteTextFileRequest, + ): Promise { + // Stage 1 known divergence: this raw `fs.writeFile` reimplements file + // I/O instead of delegating to core's filesystem service. The + // user-visible scenarios where they differ: + // - BOM handling: this drops/re-encodes whatever the agent passed; + // core would preserve. + // - Non-UTF-8 source files: round-tripping through utf8 mangles + // content. + // - Original line endings: core preserves CRLF on Windows files; + // this writes whatever the agent buffered. + // Wiring core's FileSystemService through the bridge requires + // exposing it as a constructor dep; the cost-benefit is low for + // Stage 1 (most agent-side tools call core directly, NOT through + // these ACP fs methods) and Stage 2 in-process eliminates the + // bridge fs proxy entirely. Tracked as a Stage 2 prerequisite. + // + // BSA0D: write-then-rename so a SIGKILL / OOM mid-write doesn't + // leave the target truncated. POSIX `rename` is atomic within the + // same filesystem; on Windows it's atomic when the target doesn't + // exist (we tolerate the race-on-overwrite case as a Stage 2 + // gap). The tmp file lives in the same directory so the rename + // can't cross filesystem boundaries (which would degrade to a + // copy + race re-emerges). + // + // BX8Yw: rename would replace a symlink at the target path with a + // regular file, leaving the original symlink target unchanged + // while the write appears successful. Resolve symlinks via + // `realpath` first so the atomic write lands at the actual file. + // + // BfFvO: dangling-symlink case — `realpath` throws ENOENT when + // the symlink's target doesn't exist. A blanket catch then + // silently falls back to `params.path` (the symlink itself), and + // `rename(tmp, params.path)` would replace the symlink with a + // regular file — exactly the bug BX8Yw was supposed to fix. + // Distinguish "path doesn't exist at all" (truly new file → + // write through) from "dangling symlink" (symlink exists, target + // doesn't → write through to the symlink's intended target so + // the symlink stays a symlink and points at a fresh file). + let realTarget = params.path; + try { + realTarget = await fs.realpath(params.path); + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: unknown }).code + : undefined; + if (code !== 'ENOENT') throw err; + // realpath ENOENT can mean (a) path doesn't exist at all, or + // (b) the path is a symlink whose target doesn't exist. Use + // `readlink` to disambiguate. If it succeeds we've got a + // dangling symlink → resolve its target manually so the + // subsequent rename creates the target instead of replacing + // the symlink. + try { + const linkTarget = await fs.readlink(params.path); + realTarget = path.resolve(path.dirname(params.path), linkTarget); + } catch { + // readlink also failed → truly non-existent path → write + // through to the original (it'll be created). + } + } + // BX8Yp + BX9_h: temp filename must include random bytes — + // PID+ms alone collides under `sessionScope: 'thread'` (two + // concurrent sessions writing the same path in the same ms) AND + // can collide between concurrent prompts in one session. Add a + // UUID and create exclusively (`flag: 'wx'`) so any residual + // collision fails before content is overwritten. + const tmp = `${realTarget}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + // BkwQW: preserve the existing target's mode bits (and owner/group + // where possible) so editing a `0600` secret doesn't downgrade + // it to `0644` via the process umask, and an executable file + // doesn't lose its `+x` bit. Snapshot before write — if the + // target doesn't exist yet, fall through to umask defaults + // (which is correct for a new file). + let preserveMode: { mode: number; uid: number; gid: number } | undefined; + try { + const targetStat = await fs.stat(realTarget); + preserveMode = { + mode: targetStat.mode & 0o7777, + uid: targetStat.uid, + gid: targetStat.gid, + }; + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: unknown }).code + : undefined; + if (code !== 'ENOENT') throw err; + // New file — accept umask defaults. + } + try { + // Blehd: pass `mode` to `writeFile` so the temp file is + // CREATED with the preserved mode (atomically, via the + // syscall's open(O_CREAT, mode)). The previous "create with + // umask defaults → chmod after" had a window where a `0600` + // secret-edit existed at `0644` on disk before chmod ran, + // briefly readable by anyone with directory access. Passing + // `mode` shrinks that window to "doesn't exist". On Windows + // the mode bits are mostly ignored by the OS; that's fine + // since the platform has no equivalent threat model here. + await fs.writeFile(tmp, params.content, { + encoding: 'utf8', + flag: 'wx', + mode: preserveMode?.mode ?? 0o600, + }); + if (preserveMode) { + // `writeFile`'s `mode` option is `mode & ~umask` on POSIX, + // so a tight umask (e.g. operator's shell `umask 077` for + // 0o600 default) could still drop bits we wanted preserved. + // Belt-and-suspenders chmod brings the file to EXACTLY the + // target's preserved mode regardless of umask interference. + await fs.chmod(tmp, preserveMode.mode).catch(() => { + /* chmod failed (Windows / fs without permission bits) */ + }); + // chown is owner-restricted on POSIX; non-root daemons hit + // EPERM here. Silent ignore — preserving mode is the + // first-order goal, ownership is a stretch goal. + await fs.chown(tmp, preserveMode.uid, preserveMode.gid).catch(() => { + /* expected EPERM for non-root operators */ + }); + } + await fs.rename(tmp, realTarget); + } catch (err) { + // Best-effort cleanup if the write succeeded but rename failed + // (e.g. permission change between calls). Swallow cleanup + // errors — the original failure is the meaningful one. + await fs.unlink(tmp).catch(() => {}); + throw err; + } + return {}; + } + + async readTextFile( + params: ReadTextFileRequest, + ): Promise { + // Reject obviously-degenerate `limit` up front. Without this, + // `sliceLineRange` hits the `end < start` path and returns an + // unexpectedly-larger slice (or empty depending on internals). + // ACP doesn't define semantics for limit ≤ 0, so treat as "no + // bytes wanted". + if (typeof params.limit === 'number' && params.limit <= 0) { + return { content: '' }; + } + // BSA0E: cap the file size we'll buffer into RSS at 100 MiB so a + // request like `{ line: 1, limit: 10 }` against a 500 MB log + // doesn't cost the daemon 500 MB of memory just to return 10 + // lines. Stage 2's in-process refactor will replace this proxy + // with a streaming readline implementation that stops at the + // requested range; until then the cap is the cheapest defense. + // + // BX8YO: also reject non-regular files. Character devices, named + // pipes (FIFOs), procfs / sysfs entries, sockets etc. can report + // `stats.size === 0` while producing unbounded data on read, so + // a size-only cap doesn't protect against `/dev/zero` / + // `/dev/urandom` / `/proc/kcore`-style inputs. ACP's contract + // for `readTextFile` is "regular file"; everything else is an + // operator-supplied path mistake or an adversarial-prompt + // attempt and should fail loud. + const READ_FILE_SIZE_CAP = 100 * 1024 * 1024; + const stats = await fs.stat(params.path); + if (!stats.isFile()) { + throw new Error( + `readTextFile: ${params.path} is not a regular file ` + + `(reported as ${describeStatKind(stats)}). ` + + `Pipe / device / proc-like inputs can produce unbounded data ` + + `and aren't supported by the bridge fs proxy.`, + ); + } + if (stats.size > READ_FILE_SIZE_CAP) { + throw new Error( + `readTextFile: ${params.path} is ${stats.size} bytes, ` + + `exceeds the ${READ_FILE_SIZE_CAP}-byte daemon cap. ` + + `Tail/grep externally and feed the relevant slice instead.`, + ); + } + const content = await fs.readFile(params.path, 'utf8'); + if (typeof params.line === 'number' || typeof params.limit === 'number') { + // ACP `ReadTextFileRequest.line` is 1-based per spec — clients passing + // `{ line: 1, limit: 2 }` mean "the first two lines", not "skip the + // first then take two". Convert to a 0-based slice index, clamping + // values < 1 to 0 to be tolerant of unusual inputs. + const startLine = params.line ?? 1; + const start = startLine > 0 ? startLine - 1 : 0; + const end = params.limit != null ? start + params.limit : undefined; + // Avoid `content.split('\n')` — allocating a per-line String[] for + // a 100 MB file roughly doubles the memory footprint just to + // extract a few lines. Manual scan walks `indexOf('\n', …)` only + // until the end-of-range boundary is found, then slices a single + // range of the original string. Stage 2 in-process replaces this + // proxy entirely (the bridge stops reading user fs). + return { content: sliceLineRange(content, start, end) }; + } + return { content }; + } +} + +const DEFAULT_INIT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_SESSIONS = 20; +// Bd1yh: per-permission-request wall clock. Without this, an agent +// calling `requestPermission` while no SSE subscriber is connected +// would hang the per-session FIFO promptQueue forever (the prompt +// can't complete, every subsequent prompt is blocked behind it). +// 5 minutes is generous for "human reads UI, decides, clicks +// approve" while still bounded enough to recover from a wedged +// state. Configurable via `BridgeOptions.permissionResponseTimeoutMs`. +const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; +// Bd1z5: per-session cap on pending permissions in flight. A chatty +// agent making rapid `requestPermission` calls would otherwise grow +// `pendingPermissions` unboundedly — each entry is a UUID + closure +// + bus event. 64 mirrors `DEFAULT_MAX_SUBSCRIBERS` (one pending +// per subscriber feels like a reasonable headroom). Excess requests +// resolve as cancelled and emit a stderr warning so operators see +// the limit being hit. Configurable via +// `BridgeOptions.maxPendingPermissionsPerSession`. +const DEFAULT_MAX_PENDING_PER_SESSION = 64; + +export function createHttpAcpBridge(opts: BridgeOptions = {}): HttpAcpBridge { + const sessionScope = opts.sessionScope ?? 'single'; + // `undefined` → default 20 (intentionally tight per #3803 N≈50 cliff). + // `0` → explicitly unlimited (operator opt-out). + // `Infinity` → unlimited (programmatic opt-out — accepted as a + // long-standing alias since the cap check is `>= max`). + // `NaN` / negative → throw. A typo / parse error in CLI/config + // silently disabling the daemon's only resource + // guard is fail-OPEN behavior; gpt-5.5 flagged + // this as critical (BRApy) — we'd rather fail + // boot than serve unbounded. + let maxSessions: number; + if (opts.maxSessions === undefined) { + maxSessions = DEFAULT_MAX_SESSIONS; + } else if (Number.isNaN(opts.maxSessions)) { + throw new TypeError( + `Invalid maxSessions: NaN. Must be a number >= 0 ` + + `(0 / Infinity = unlimited).`, + ); + } else if (opts.maxSessions < 0) { + throw new TypeError( + `Invalid maxSessions: ${opts.maxSessions}. Must be >= 0 ` + + `(0 / Infinity = unlimited).`, + ); + } else if (opts.maxSessions === 0 || opts.maxSessions === Infinity) { + maxSessions = Infinity; + } else { + maxSessions = opts.maxSessions; + } + if (sessionScope !== 'single' && sessionScope !== 'thread') { + throw new TypeError( + `Invalid sessionScope: ${JSON.stringify(sessionScope)}. ` + + `Expected 'single' or 'thread'.`, + ); + } + const channelFactory = opts.channelFactory ?? defaultSpawnChannelFactory; + const initTimeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INIT_TIMEOUT_MS; + if (initTimeoutMs <= 0) { + throw new TypeError( + `Invalid initializeTimeoutMs: ${initTimeoutMs}. Must be > 0.`, + ); + } + // Bd1yh + Bd1z5: per-permission deadline + per-session pending cap. + // 0 / Infinity / non-finite (NaN, -1) all disable — same sentinel + // convention as maxSessions / maxConnections. + const permissionTimeoutRaw = + opts.permissionResponseTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + const permissionTimeoutMs = + permissionTimeoutRaw > 0 && Number.isFinite(permissionTimeoutRaw) + ? permissionTimeoutRaw + : 0; // 0 = disabled + const maxPendingRaw = + opts.maxPendingPermissionsPerSession ?? DEFAULT_MAX_PENDING_PER_SESSION; + const maxPendingPerSession = + maxPendingRaw > 0 && Number.isFinite(maxPendingRaw) + ? maxPendingRaw + : Infinity; + + // Single-scope reuse keyed by canonical workspace path. Tracks the + // SessionEntry that a same-workspace attach should re-use. With + // Stage 1.5 multi-session per channel, this points at the FIRST + // session created for the workspace under `single` scope; under + // `thread` scope additional sessions on the same workspace don't + // overwrite this entry. + const byWorkspace = new Map(); + // Stage 1.5 multi-session: one channel per workspace, N sessions + // multiplex on it via `connection.newSession({cwd, mcpServers})`. + // `byWorkspaceChannel.get(workspaceKey)` returns the shared channel + // for spawn-vs-reuse decisions in `doSpawn`. Channel is kept alive + // while `sessionIds.size > 0`; the last `killSession` (or the + // `channel.exited` cleanup) drops the entry from this map. + const byWorkspaceChannel = new Map(); + // tanzhenxin BkUyD: source of truth for "channels with potentially- + // alive child processes" — independent of `byWorkspaceChannel`, + // which `shutdown()` clears BEFORE awaiting per-child SIGTERM- + // grace kills. `killAllSync()` (the double-Ctrl+C force-exit + // path) iterates THIS set so a mid-shutdown second signal still + // sees the children that haven't yet finished their SIGTERM grace. + // Only removed when `channel.exited` fires (the OS-level "really + // dead" signal). The earlier design iterated `byWorkspaceChannel` + // and silently no-op'd during the shutdown await window. + const liveChannels = new Set(); + // Coalesces concurrent channel-spawn requests for the same workspace + // (regardless of sessionScope). Without this, two parallel callers + // would both `channelFactory(workspaceKey)` and one of the + // spawned children would never make it into `byWorkspaceChannel`, + // becoming a permanent orphan. Cleared in the `finally` of the + // creator regardless of outcome. + const inFlightChannelSpawns = new Map>(); + const byId = new Map(); + // Daemon-wide pending permission table; requestIds are UUIDs so collisions + // across sessions are infeasible in practice. + const pendingPermissions = new Map(); + // Set by `shutdown()` so any in-flight `spawnOrAttach` that was + // dispatched on an existing connection AFTER the shutdown snapshot + // taken in `shutdown()` fails fast instead of creating a child the + // shutdown path has no more visibility into. Without this, the + // server.listen → bridge.shutdown ordering in `runQwenServe` leaves + // a window between (a) shutdown snapshotting `byId` for kills and + // (b) `server.close` rejecting new connections, during which a + // late-arriving `POST /session` slips a fresh child past cleanup. + let shuttingDown = false; + // Coalesces concurrent `spawnOrAttach` calls for the same workspace under + // single-scope. Without this, two parallel callers would both pass the + // `byWorkspace.get` check, both spawn, and one entry would be orphaned + // (in `byId` but not in `byWorkspace`) — violating the + // "at most one session per workspace" invariant. + const inFlightSpawns = new Map>(); + + const registerPending = (p: PendingPermission) => { + const entry = byId.get(p.sessionId); + if (!entry) { + // The session was torn down (channel.exited, killSession, shutdown) + // between when the agent decided to ask for permission and when the + // request reached this function. There's no SessionEntry to chain + // the requestId onto and no SSE bus to publish `permission_request` + // — nobody can vote, so the permission would hang the agent's + // `requestPermission` forever. Resolve immediately as cancelled to + // unwind the agent side; matches the shutdown / killSession path. + p.resolve({ outcome: { outcome: 'cancelled' } }); + return; + } + pendingPermissions.set(p.requestId, p); + entry.pendingPermissionIds.add(p.requestId); + }; + + /** Resolve a single pending request and clean up its bookkeeping. */ + const resolvePending = ( + requestId: string, + response: RequestPermissionResponse, + ): boolean => { + const pending = pendingPermissions.get(requestId); + if (!pending) return false; + pendingPermissions.delete(requestId); + const entry = byId.get(pending.sessionId); + if (entry) { + entry.pendingPermissionIds.delete(requestId); + // Fan-out a follow-up event so other clients update their UI when the + // race is decided. Best-effort — failure to publish (e.g. bus closed + // mid-shutdown) doesn't block resolution. + try { + entry.events.publish({ + type: 'permission_resolved', + data: { requestId, outcome: response.outcome }, + }); + } catch { + /* bus closed during shutdown */ + } + } + pending.resolve(response); + return true; + }; + + /** + * Get-or-create the shared `qwen --acp` channel for a workspace. + * Stage 1.5 multi-session: one channel hosts N sessions via + * `connection.newSession()`. Concurrent callers coalesce through + * `inFlightChannelSpawns` so we never spawn two children for one + * workspace. The returned `ChannelInfo` is shared — caller adds + * their session id to `sessionIds` and uses `info.connection.newSession()`. + * + * Wires up the one-and-only `channel.exited` cleanup on first + * creation so the late-arriving event tears down ALL sessions on + * the channel (vs. the previous 1-session-per-channel design where + * each entry registered its own listener). + */ + async function getOrCreateChannel( + workspaceKey: string, + ): Promise { + const existing = byWorkspaceChannel.get(workspaceKey); + if (existing) return existing; + const inFlight = inFlightChannelSpawns.get(workspaceKey); + if (inFlight) return await inFlight; + + const promise = (async () => { + const channel = await channelFactory(workspaceKey); + const client = new BridgeClient( + // BfFut: ACP today carries a sessionId on every per-session + // notification / request, so the no-sessionId branch is + // technically unreachable. But the channel is multi-session + // (Stage 1.5 multiplex), so if ACP ever grows a no-sessionId + // call we'd silently drop it on a multi-session channel + // instead of throwing. Surface that ambiguity loudly. + (sessionId) => { + if (sessionId) return byId.get(sessionId); + const info = byWorkspaceChannel.get(workspaceKey); + if (info && info.sessionIds.size > 1) { + throw new Error( + 'BridgeClient: ACP call without sessionId on a ' + + 'multi-session channel cannot be routed — workspace=' + + workspaceKey, + ); + } + return undefined; + }, + registerPending, + (rid) => + // Roll back a register-then-publish-failed pending so the agent + // doesn't hang waiting on a vote nobody can see. + resolvePending(rid, { outcome: { outcome: 'cancelled' } }), + permissionTimeoutMs, + maxPendingPerSession, + ); + const connection = new ClientSideConnection(() => client, channel.stream); + + try { + await withTimeout( + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + clientInfo: { name: 'qwen-serve-bridge', version: '0' }, + }), + initTimeoutMs, + 'initialize', + ); + } catch (err) { + await channel.kill().catch(() => {}); + throw err; + } + + // Late-shutdown re-check: if shutdown flipped during `initialize`, + // tear this channel down rather than leak past `process.exit(0)`. + if (shuttingDown) { + await channel.kill().catch(() => {}); + throw new Error('HttpAcpBridge is shutting down'); + } + + const info: ChannelInfo = { + channel, + connection, + client, + workspaceCwd: workspaceKey, + sessionIds: new Set(), + }; + byWorkspaceChannel.set(workspaceKey, info); + liveChannels.add(info); + + // One-time channel.exited cleanup. The child dying takes ALL + // multiplexed sessions with it — iterate `sessionIds` (snapshot + // first to be safe against concurrent killSession during + // iteration), publish `session_died` on each session's bus, + // remove from byId / byWorkspace / pending tables. + void channel.exited.then((exitInfo) => { + // tanzhenxin BkUyD: drop from `liveChannels` ONLY when the + // OS process is actually gone. Async kill paths + // (`killSession` reap, `shutdown()` await) remove from + // `byWorkspaceChannel` early but the child's SIGTERM grace + // can still be in-flight; the force-kill path needs the + // entry until `channel.exited` fires here. + liveChannels.delete(info); + const stillOurs = byWorkspaceChannel.get(workspaceKey) === info; + if (stillOurs) byWorkspaceChannel.delete(workspaceKey); + const sessions = Array.from(info.sessionIds); + info.sessionIds.clear(); + for (const sid of sessions) { + const sessEntry = byId.get(sid); + if (!sessEntry) continue; + cancelPendingForSession(sid); + try { + sessEntry.events.publish({ + type: 'session_died', + data: { + sessionId: sid, + reason: 'channel_closed', + // BX9_P: thread exitCode/signalCode through. + exitCode: exitInfo?.exitCode ?? null, + signalCode: exitInfo?.signalCode ?? null, + }, + }); + } catch { + /* bus already closed */ + } + byId.delete(sid); + if (byWorkspace.get(sessEntry.workspaceCwd) === sessEntry) { + byWorkspace.delete(sessEntry.workspaceCwd); + } + sessEntry.events.close(); + } + }); + + return info; + })(); + + inFlightChannelSpawns.set(workspaceKey, promise); + try { + return await promise; + } finally { + inFlightChannelSpawns.delete(workspaceKey); + } + } + + async function doSpawn( + workspaceKey: string, + modelServiceId?: string, + ): Promise { + // Stage 1.5 multi-session: get-or-create the channel for this + // workspace, then call `connection.newSession()` on it. Sessions + // share the child's process / OAuth / file-cache / hierarchy- + // memory parse via the agent's `sessions: Map` + // (see `acp-integration/acpAgent.ts:194`). + // newSession on an established channel can fail (auth, config, + // etc.) without the channel dying. We DON'T kill the channel on + // newSession failure when OTHER sessions are still using it — + // they'd lose their work for a problem orthogonal to them. + // + // BkwQA: when the failed newSession was the channel's ONLY + // attempt (sessionIds.size === 0), the empty channel must NOT + // linger — it would sit in `byWorkspaceChannel` invisible to + // `sessionCount` / `maxSessions` (both backed by `byId`), and + // repeated failing creates would still find this channel via + // `getOrCreateChannel`, never spawning a fresh one. Tear down + // the empty channel so the next attempt gets a clean spawn. + const channelInfo = await getOrCreateChannel(workspaceKey); + let newSessionResp: { sessionId: string }; + try { + newSessionResp = await withTimeout( + channelInfo.connection.newSession({ + cwd: workspaceKey, + mcpServers: [], + }), + initTimeoutMs, + 'newSession', + ); + } catch (err) { + // Only reap when this newSession was the channel's first/only + // attempt — a populated channel keeps running for its other + // live sessions. + if (channelInfo.sessionIds.size === 0) { + if (byWorkspaceChannel.get(workspaceKey) === channelInfo) { + byWorkspaceChannel.delete(workspaceKey); + } + await channelInfo.channel.kill().catch(() => { + /* best-effort — channel.exited handler still runs */ + }); + } + throw err; + } + + // Late-shutdown re-check (BUy4U): shutdown() may have flipped + // while we were in `connection.newSession` (~1s on cold start). + if (shuttingDown) { + // Don't kill the channel — see comment above. Just throw. + throw new Error('HttpAcpBridge is shutting down'); + } + + const entry: SessionEntry = { + sessionId: newSessionResp.sessionId, + workspaceCwd: workspaceKey, + channel: channelInfo.channel, + connection: channelInfo.connection, + events: new EventBus(), + promptQueue: Promise.resolve(), + modelChangeQueue: Promise.resolve(), + pendingPermissionIds: new Set(), + attachCount: 0, + spawnOwnerWantedKill: false, + }; + channelInfo.sessionIds.add(entry.sessionId); + byId.set(entry.sessionId, entry); + // `byWorkspace` is the single-scope attach lookup — only the + // FIRST session for a workspace wins this slot. Subsequent + // thread-scope sessions don't overwrite it. + if (!byWorkspace.has(workspaceKey)) { + byWorkspace.set(workspaceKey, entry); + } + + // ACP `newSession` doesn't take a model id; honor the caller's + // `modelServiceId` via `unstable_setSessionModel`. See + // `applyModelServiceId` for rationale (race against + // transportClosedReject, publish model_switched on success, + // model_switch_failed on failure, don't tear down the session). + if (modelServiceId) { + await applyModelServiceId(entry, modelServiceId, initTimeoutMs).catch( + () => { + // Already published `model_switch_failed`; session stays + // operational on the agent's default model. + }, + ); + } + + // Bd1zc: re-check that the entry is still live before returning. + // The model-switch call yields and races against + // `channel.exited` — if the child crashed during the model + // switch, the exited handler already removed the entry from + // byId. Without this check, the caller would get HTTP 200 with + // a sessionId that already 404s on every subsequent request. + if (!byId.has(entry.sessionId)) { + throw new Error( + `Session ${entry.sessionId} died during model-switch ` + + `initialization`, + ); + } + + return { + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + attached: false, + }; + } + + /** + * Send `unstable_setSessionModel` and broadcast a `model_switched` + * event. Used at create-session time (via doSpawn) AND on attach when + * the caller passes a modelServiceId — the existing session may be + * running a different model. + * + * Serialized through `entry.modelChangeQueue` so two concurrent + * attach-with-different-model requests can't race into the agent. + * On failure, publishes a `model_switch_failed` event for cross-client + * observability and re-throws so the HTTP caller sees the error + * (session keeps running its previous model — that's the safer + * default than tearing down a shared session because one client + * asked for an unknown model). + */ + async function applyModelServiceId( + entry: SessionEntry, + modelId: string, + timeoutMs: number, + ): Promise { + const conn = entry.connection as unknown as { + unstable_setSessionModel(p: { + sessionId: string; + modelId: string; + }): Promise; + }; + // Race against `transportClosedReject` so a child crash during + // model switch fails the call immediately instead of waiting the + // full `timeoutMs`. Matches what `sendPrompt` and `setSessionModel` + // already do — without this, a callback-attach with a broken model + // wedges the HTTP handler for 10s. + const transportClosed = getTransportClosedReject(entry); + const work = entry.modelChangeQueue.then(async () => { + try { + await Promise.race([ + withTimeout( + conn.unstable_setSessionModel({ + sessionId: entry.sessionId, + modelId, + }), + timeoutMs, + 'setSessionModel', + ), + transportClosed, + ]); + entry.events.publish({ + type: 'model_switched', + data: { sessionId: entry.sessionId, modelId }, + }); + } catch (err) { + // Surface the failure to ALL attached clients, not just the + // caller — a shared session swallowing a denied model change + // silently would surprise the others. + entry.events.publish({ + type: 'model_switch_failed', + data: { + sessionId: entry.sessionId, + requestedModelId: modelId, + error: err instanceof Error ? err.message : String(err), + }, + }); + throw err; + } + }); + // Tail swallows failures so subsequent model changes still run; the + // original caller still observes the rejection on `work`. + entry.modelChangeQueue = work.then( + () => undefined, + () => undefined, + ); + return work; + } + + /** + * Resolve every pending request belonging to one session as cancelled. + * + * **Scope contract (per ACP spec / live-collab default):** + * Permissions are issued by the agent inline DURING an active + * prompt — `requestPermission` returns a Promise the agent awaits + * before continuing. Per the bridge's per-session FIFO + ACP's + * "one active prompt per session" guarantee, ALL outstanding + * permissions at any moment belong to the **currently active + * prompt**. So "cancel all pending permissions for this session" + * is equivalent to "cancel the active prompt's permissions" — and + * that's exactly what ACP requires when a prompt is cancelled + * ("cancelling a prompt MUST resolve outstanding requestPermission + * calls with outcome.cancelled"). + * + * **Multi-client live-collab caveat:** under `sessionScope: 'single'` + * Client B may have been about to vote on A's pending permission + * via SSE — when A disconnects mid-prompt, B's vote (if it arrives + * after the abort) gets `404`. This is the right behavior: A's + * prompt is being cancelled, so the permission belongs to a turn + * that no longer matters. From B's side they see + * `permission_resolved` with `outcome: cancelled` on the SSE + * stream, then the prompt's `cancelled` stop reason. Voting on a + * cancelled-prompt's permission was never going to drive the + * agent forward anyway. + */ + const cancelPendingForSession = (sessionId: string) => { + const entry = byId.get(sessionId); + if (!entry) return; + // Snapshot ids — resolvePending mutates the underlying set. + const ids = Array.from(entry.pendingPermissionIds); + for (const id of ids) { + resolvePending(id, { outcome: { outcome: 'cancelled' } }); + } + }; + + /** + * Lazy-init the per-session `transportClosedReject` promise that + * `sendPrompt` / `setSessionModel` / `applyModelServiceId` race their + * ACP calls against. ONE listener is attached to `channel.exited` + * over the session's lifetime (the first caller "wins" and creates + * the promise; subsequent callers reuse it) — a per-call attach + * would grow Node's listener list linearly with prompt count on + * chatty sessions. The rejection message names the FIRST caller, + * which can be misleading if a later method observes the failure; + * the cost-benefit favors the single-listener invariant. + */ + const getTransportClosedReject = (entry: SessionEntry): Promise => { + if (!entry.transportClosedReject) { + entry.transportClosedReject = entry.channel.exited.then(() => { + throw new Error( + `agent channel closed mid-request (session ${entry.sessionId})`, + ); + }); + } + return entry.transportClosedReject; + }; + + return { + get sessionCount() { + return byId.size; + }, + + get pendingPermissionCount() { + return pendingPermissions.size; + }, + + async spawnOrAttach(req) { + if (shuttingDown) { + // `runQwenServe.close()` calls `bridge.shutdown()` BEFORE + // `server.close()`. During that window, established HTTP + // connections can still hit `POST /session`. Refuse here so + // late-arrivers don't spawn children the shutdown path won't + // see — they'd otherwise leak past `process.exit(0)`. + throw new Error('HttpAcpBridge is shutting down'); + } + if (!path.isAbsolute(req.workspaceCwd)) { + throw new Error( + `workspaceCwd must be an absolute path; got "${req.workspaceCwd}"`, + ); + } + const workspaceKey = canonicalizeWorkspace(req.workspaceCwd); + + if (sessionScope === 'single') { + const existing = byWorkspace.get(workspaceKey); + if (existing) { + // BRSCi: bump attach counter BEFORE any await so the + // spawn-owner's disconnect reaper (server.ts: + // `requireZeroAttaches: true`) sees this attach even when + // we yield on the model-switch below. Increment is + // synchronous → atomic against the killSession + // sync-prefix check. + // + // BVryk + BWGSL: counter is NOT strictly monotonic any + // more — `detachClient()` decrements it to roll back an + // attach whose HTTP response couldn't be written + // (tanzhenxin issue 2). The race-guard invariant we still + // hold is "attachCount reflects the number of attaching + // clients whose response was written or is about to be + // written"; decrementing is the symmetric cleanup for + // attaches that turned out to be fictitious. The + // ordering guarantee that matters for the killSession + // race is "bump runs before any await inside this + // microtask," which is what we get here. + existing.attachCount++; + // If the caller passed a modelServiceId on attach, the session + // may currently be running a DIFFERENT model. Honor the request + // by issuing setSessionModel — same call we'd use on + // /session/:id/model. Surfaces a `model_switched` event so + // every attached client sees the change. If the new model is + // rejected, propagate as a spawn-style error rather than + // silently returning an attach-with-stale-model. + if (req.modelServiceId) { + // Swallow: matches the create-session catch in `doSpawn` + // below — a model-switch rejection on an already-running + // session must NOT 500 the attach (the session is fully + // operational on its current model; tearing it down or + // returning an error without the sessionId would deny + // the caller any way to recover). The + // `model_switch_failed` SSE event is the visible signal. + await applyModelServiceId( + existing, + req.modelServiceId, + initTimeoutMs, + ).catch(() => {}); + } + return { + sessionId: existing.sessionId, + workspaceCwd: existing.workspaceCwd, + attached: true, + }; + } + // Coalesce: if another caller is already mid-spawn for this same + // workspace, await their result. The reporter's call appears as an + // attach (the spawn was someone else's, not theirs). If the + // reporter asked for a different modelServiceId than the spawn + // chose, apply it now. + const inFlight = inFlightSpawns.get(workspaceKey); + if (inFlight) { + const session = await inFlight; + // BRSCi: bump attach counter SYNCHRONOUSLY in the same + // microtask the in-flight spawn resolves to us, BEFORE + // any further await. The spawn-owner's route handler + // microtask (which calls `killSession({requireZeroAttaches})`) + // runs after our spawnOrAttach() resolves; the ordering + // guarantee is "every attach-bump runs before the + // matching killSession sync prefix" only if the bump is + // the first sync step after `await inFlight`. Doing the + // model-switch await first re-opens the race deepseek-v4-pro + // flagged in BRSCi. + const attachedEntry = byId.get(session.sessionId); + if (attachedEntry) attachedEntry.attachCount++; + // BX9_U: even with the BRSCi bump-before-await ordering, + // there are still adversarial paths where the entry could + // be torn down between `await inFlight` resolving and our + // continuation running (e.g. channel.exited firing during + // a crash spawn, or a direct bridge.killSession call from + // outside the route handler). In those cases byId.get() + // returned undefined; we'd otherwise return + // `{ attached: true, sessionId: }` and every + // subsequent prompt/cancel call would 404. Fail loud + // instead so the caller can retry into a fresh spawn. + if (!attachedEntry) { + throw new SessionNotFoundError(session.sessionId); + } + if (req.modelServiceId) { + // Same swallow as above — we picked up an in-flight + // spawn, the session is real, model-switch failure + // shouldn't deny us the sessionId. + await applyModelServiceId( + attachedEntry, + req.modelServiceId, + initTimeoutMs, + ).catch(() => {}); + } + return { ...session, attached: true }; + } + } + + // Cap check: count both registered sessions and in-flight spawns + // (a fresh-spawn races that's about to register hasn't hit + // `byId` yet but should still count toward the limit). Attaches + // returned above bypass this — only NEW children are gated. + if (byId.size + inFlightSpawns.size >= maxSessions) { + throw new SessionLimitExceededError(maxSessions); + } + + const promise = doSpawn(workspaceKey, req.modelServiceId); + // Track in-flight spawns regardless of scope. Under `single` + // this also serves the coalescing path above (a parallel + // `spawnOrAttach` finds the entry and waits for the same + // promise). Under `thread` we don't need coalescing — every + // call gets its own session — but `shutdown()` snapshots + // `inFlightSpawns.values()` to know which spawns to await + // for graceful tear-down. Without this, a `thread`-scope + // shutdown returns before in-progress spawns finish their + // child cleanup, surfacing stderr noise after the daemon + // claimed graceful shutdown. Use a unique key per spawn so + // simultaneous thread-scope spawns don't collide on the + // workspace key. + const tracker = + sessionScope === 'single' + ? workspaceKey + : `${workspaceKey}#${randomUUID()}`; + inFlightSpawns.set(tracker, promise); + try { + return await promise; + } finally { + // Always clear the in-flight slot whether the spawn resolved + // or rejected — leaving a rejected promise behind would + // poison every future coalescing-path call for this + // workspace (single-scope) or grow unbounded (thread-scope). + inFlightSpawns.delete(tracker); + } + }, + + async sendPrompt(sessionId, req, signal) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + // Pre-aborted: skip the queue entirely. Without this the prompt + // chains onto promptQueue, waits its turn, and the FIFO worker + // checks `signal.aborted` only AFTER reaching the head — wasted + // queue churn on every retry-after-abort, plus a confusing trace + // where the prompt appears to "run" before erroring. + if (signal?.aborted) { + throw new DOMException('Prompt aborted', 'AbortError'); + } + // Force the body's sessionId to match the routing id — a client that + // sent a stale id in the body would otherwise be dispatched to the + // wrong agent process. + const normalized: PromptRequest = { ...req, sessionId }; + const result = entry.promptQueue.then(() => { + // If the caller aborted while we were queued behind earlier + // prompts, don't even start this one. + if (signal?.aborted) { + throw new DOMException('Prompt aborted', 'AbortError'); + } + const promptPromise = entry.connection.prompt(normalized); + + // Race against channel termination: if the underlying transport + // dies (child crashed, stream torn down) WHILE the prompt is in + // flight, the SDK's pending-request promise can hang because the + // wire never delivers a response. Make the prompt fail-fast in + // that case so the per-session FIFO doesn't poison the next + // queued prompt with an unbounded await. See + // `getTransportClosedReject` for the single-listener invariant. + // + // FIXME(stage-2): no absolute prompt deadline. A buggy agent + // that ignores `cancel()` while keeping the channel alive can + // hold this race open indefinitely — the abort path fires + // `cancel()` and resolves pending permissions, but the + // `promptPromise` itself only settles when the agent + // cooperates. Stage 2 should add a configurable per-prompt + // wall clock (e.g. `--prompt-deadline 30m`) into this race so + // a wedged agent can't slow-leak prompt promises. Tracked + // under #3803 follow-ups. + const racedPromise = Promise.race([ + promptPromise, + getTransportClosedReject(entry), + ]); + + if (!signal) return racedPromise; + // Wire the abort: when the signal fires (e.g. SSE route's + // req.on('close')), tell the agent to wind down. ACP cancel is a + // notification — the active prompt resolves with + // stopReason: 'cancelled', then the next queued prompt can run. + // + // Also resolve any pending permission requests as `cancelled`. + // ACP spec requires `cancel` to settle outstanding + // `requestPermission` calls — `cancelSession()` already does + // this; the abort path here was missing the call. Without it, + // a client disconnecting while the agent is inside + // `requestPermission` leaves the permission promise unresolved + // forever (the agent is stuck waiting on a vote that no SSE + // subscriber will ever cast). + const onAbort = () => { + cancelPendingForSession(sessionId); + entry.connection.cancel({ sessionId }).catch(() => { + // Cancel is fire-and-forget; the agent may already be dead. + }); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + // The aborted state can flip synchronously between the early-exit + // check at the top of `sendPrompt` and addEventListener — re-check + // after registration so a microsecond-window abort still fires + // `cancel()` instead of letting the prompt run uncancellable. + if (signal.aborted) onAbort(); + // Detach the listener once the prompt resolves so the + // AbortController can be GC'd. The `.finally()` returns a + // promise chained on `racedPromise`; if `racedPromise` + // rejects, that returned promise rejects too — and we + // never await it, so under Node's default + // unhandled-rejection behavior the daemon could terminate + // even though the route's own catch handles the original + // rejection. Attach `.catch(() => {})` to the + // listener-cleanup chain only — the caller's reference to + // `racedPromise` (via `return racedPromise` below) still + // surfaces failures normally. + racedPromise + .finally(() => signal.removeEventListener('abort', onAbort)) + .catch(() => {}); + } + return racedPromise; + }); + // Tail swallows failures so subsequent prompts still run. The caller + // still sees rejections on its own `result` reference. + entry.promptQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + + async cancelSession(sessionId, req) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + // ACP spec: cancelling a prompt MUST resolve outstanding + // requestPermission calls with outcome.cancelled. Do this *before* + // forwarding the notification so the agent's wind-down sees the + // resolutions. + cancelPendingForSession(sessionId); + // Cancel intentionally bypasses the prompt queue: it's a notification + // that the agent uses to wind down the *currently active* prompt, not + // something to wait behind queued work. + // + // CONTRACT (multi-prompt clients): cancel affects ONLY the active + // prompt. Any prompts the client previously POSTed and that are + // still queued behind the active one will continue to execute + // after the active prompt resolves with `stopReason: 'cancelled'`. + // This matches ACP's "cancel is a wind-down notification for the + // current turn" semantics — multi-prompt queueing is a daemon + // convenience, not in spec, so we don't extend cancel's reach + // there. Clients that want a hard stop should stop posting new + // prompts and call `cancelSession` after their last prompt + // resolves, or kill the session via the channel-exit path. + const notif: CancelNotification = req + ? { ...req, sessionId } + : { sessionId }; + await entry.connection.cancel(notif); + }, + + subscribeEvents(sessionId, subOpts) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return entry.events.subscribe(subOpts); + }, + + respondToPermission(requestId, response) { + // BkwQI: validate the voter's optionId against the original + // options the agent advertised. The route already enforces + // "non-empty string" structurally; this layer enforces + // semantic membership in the agent-published set so a + // malicious client can't forge hidden outcomes (e.g. + // `ProceedAlways*` when the prompt's `hideAlwaysAllow` + // policy intentionally suppressed them). + if (response.outcome.outcome === 'selected') { + const pending = pendingPermissions.get(requestId); + if ( + pending && + !pending.allowedOptionIds.has(response.outcome.optionId) + ) { + throw new InvalidPermissionOptionError( + requestId, + response.outcome.optionId, + ); + } + } + return resolvePending(requestId, response); + }, + + listWorkspaceSessions(workspaceCwd) { + if (!path.isAbsolute(workspaceCwd)) return []; + const key = canonicalizeWorkspace(workspaceCwd); + const out: BridgeSessionSummary[] = []; + for (const entry of byId.values()) { + if (entry.workspaceCwd === key) { + out.push({ + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + }); + } + } + return out; + }, + + async setSessionModel(sessionId, req) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const normalized: SetSessionModelRequest = { ...req, sessionId }; + // The ACP SDK marks setSessionModel as unstable (not in spec yet); the + // method on AgentSideConnection is `unstable_setSessionModel`. Cast + // through the shape we know rather than couple to the prefix in case + // it's renamed when the spec stabilizes. + const conn = entry.connection as unknown as { + unstable_setSessionModel( + p: SetSessionModelRequest, + ): Promise; + }; + // Serialize through `entry.modelChangeQueue` so a `POST /session/:id/model` + // can't race with `applyModelServiceId` (e.g. an attach-with-different- + // modelServiceId) and leave the agent connection in an indeterminate + // model. `applyModelServiceId` already chains on this queue; without + // mirroring that here, two concurrent model changes interleave and the + // last `model_switched` event published may not match the actual model + // the agent is on. + // + // Race the agent call against `transportClosedReject` and a + // `withTimeout` so a wedged child can't block the HTTP handler + // forever. Matches `sendPrompt` (transport race) and + // `applyModelServiceId` (timeout) — the absence of either was an + // attack surface for "POST /session/:id/model never returns". + // See `getTransportClosedReject` for the single-listener invariant. + // + // FIXME(stage-2): we reuse `initTimeoutMs` (default 10s) as the + // model-switch deadline because the two values happen to share + // a sensible order of magnitude today. They're conceptually + // distinct (cold-start handshake vs in-flight model swap) and + // a Stage 2 split into `modelSwitchTimeoutMs` would let + // operators tune them independently — also a good time to + // remove the no-abort behavior of `withTimeout` (it rejects + // the promise but leaves the underlying ACP call running, so a + // late-arriving `model_switched` can race a previously-fired + // `model_switch_failed`). Both depend on ACP exposing a cancel + // signal for `unstable_setSessionModel`. + const transportClosed = getTransportClosedReject(entry); + const work = entry.modelChangeQueue.then(() => + Promise.race([ + withTimeout( + conn.unstable_setSessionModel(normalized), + initTimeoutMs, + 'setSessionModel', + ), + transportClosed, + ]), + ); + // Tail-swallow on the queue so a model-change failure doesn't poison + // every subsequent change (matches `applyModelServiceId`'s pattern). + entry.modelChangeQueue = work.then( + () => undefined, + () => undefined, + ); + let response: SetSessionModelResponse; + try { + response = await work; + } catch (err) { + // Mirror `applyModelServiceId`'s observability contract: surface + // failed model changes on the SSE bus so subscribers can update + // their UI / retry. Without this the only signal is the HTTP + // 5xx, which doesn't reach passive viewers. + try { + entry.events.publish({ + type: 'model_switch_failed', + data: { + sessionId: entry.sessionId, + requestedModelId: req.modelId, + error: err instanceof Error ? err.message : String(err), + }, + }); + } catch { + /* bus closed */ + } + throw err; + } + try { + entry.events.publish({ + type: 'model_switched', + data: { sessionId: entry.sessionId, modelId: req.modelId }, + }); + } catch { + /* bus closed */ + } + return response; + }, + + async killSession(sessionId, opts) { + const entry = byId.get(sessionId); + if (!entry) return; + // BQ9tV race guard: skip the reap if any other client already + // attached to this entry. The disconnect-reaper in server.ts + // sets `requireZeroAttaches: true` because it only wants to + // reap when the spawn-owner that disconnected truly was the + // sole client. Counter increment + this check both run + // synchronously, so no microtask boundary lets a race slip + // through. + // BkwQP: when bailing because of an attach, set the tombstone + // so a later `detachClient` (that brings attachCount back to + // 0) can complete the deferred reap. Without this, both + // spawn-owner-and-attach disconnecting leaves the session + // orphaned forever (spawn owner's reap bails here, attach's + // detach does nothing structural). + if (opts?.requireZeroAttaches && entry.attachCount > 0) { + entry.spawnOwnerWantedKill = true; + return; + } + // Remove from the maps eagerly so concurrent `spawnOrAttach` + // can't reattach to a session we're tearing down. + if (byWorkspace.get(entry.workspaceCwd) === entry) { + byWorkspace.delete(entry.workspaceCwd); + } + byId.delete(sessionId); + // Stage 1.5 multi-session: detach from the channel. The channel + // dies only when its LAST session leaves — other sessions on + // the same channel keep running. + const channelInfo = byWorkspaceChannel.get(entry.workspaceCwd); + if (channelInfo && channelInfo.channel === entry.channel) { + channelInfo.sessionIds.delete(sessionId); + } + // Resolve any still-pending permission as cancelled (matches the + // shutdown path) so callers awaiting requestPermission unwind. + for (const id of Array.from(entry.pendingPermissionIds)) { + resolvePending(id, { outcome: { outcome: 'cancelled' } }); + } + // Publish `session_died` BEFORE closing the bus. After the eager + // `byId.delete` above, the channel.exited handler's + // `byId.get(...)` returns undefined so the automatic publish + // at crash time wouldn't fire. SSE subscribers need this + // terminal frame to know the session is gone. + try { + entry.events.publish({ + type: 'session_died', + data: { sessionId, reason: 'killed' }, + }); + } catch { + /* bus already closed */ + } + entry.events.close(); + // Only kill the channel when no other sessions remain. ACP + // doesn't expose a per-session "close" call on the agent side, + // so the agent's `sessions: Map` grows by one + // until the channel dies — bounded by `maxSessions` (default + // 20) so memory is capped. FIXME(stage-1.5): if ACP grows a + // `closeSession` notification, send it here so the agent can + // drop the entry from its map immediately rather than at + // channel exit. + if (channelInfo && channelInfo.sessionIds.size === 0) { + byWorkspaceChannel.delete(entry.workspaceCwd); + await channelInfo.channel.kill().catch(() => { + // Best-effort kill — channel may already be dead. + }); + } + }, + + async detachClient(sessionId) { + // tanzhenxin issue 2: the BQ9tV `attachCount` race guard is + // monotonic — once any attach bumps it, the spawn-owner's + // disconnect-reaper becomes a permanent no-op even if the + // attaching client itself disconnected. This is the symmetric + // rollback the server's `!res.writable && session.attached` + // path calls into. + // + // BkwQP: detachClient ONLY decrements; it does NOT reap on + // its own. Reaping is the spawn-owner's responsibility, and + // the spawn owner's `killSession({ requireZeroAttaches: true })` + // sets `spawnOwnerWantedKill` if they had to bail because we + // already had `attachCount > 0`. Only when that tombstone is + // set do we complete the deferred reap from here. Without + // this restraint, a transient attach disconnecting would + // reap a still-valid session whose spawn owner is alive but + // hasn't opened SSE yet. + const entry = byId.get(sessionId); + if (!entry) return; + if (entry.attachCount > 0) entry.attachCount--; + if ( + entry.spawnOwnerWantedKill && + entry.attachCount === 0 && + entry.events.subscriberCount === 0 + ) { + // Defer-completed reap. Re-use killSession's logic; pass + // `requireZeroAttaches: false` (default) because we've + // already validated all the conditions ourselves. + await this.killSession(sessionId).catch(() => { + /* best-effort; channel.exited will eventually reap anyway */ + }); + } + }, + + killAllSync() { + // Bd1y6: synchronous best-effort SIGKILL on every live channel. + // Set `shuttingDown` so any racing async path fails fast. + // tanzhenxin BkUyD fix: iterate `liveChannels` (the OS-level + // source of truth) NOT `byWorkspaceChannel`. The latter is + // cleared by `shutdown()` BEFORE awaiting per-child SIGTERM + // grace; if the operator double-Ctrl+C's during that window, + // iterating `byWorkspaceChannel` would find nothing and + // `process.exit(1)` would orphan children still inside their + // SIGTERM grace. `liveChannels` only loses an entry when + // `channel.exited` fires (OS exit), so the force-kill path + // catches every still-alive child regardless of where the + // graceful drain is. + shuttingDown = true; + const channels = Array.from(liveChannels); + byWorkspaceChannel.clear(); + byWorkspace.clear(); + byId.clear(); + for (const info of channels) { + try { + info.channel.killSync(); + } catch { + /* best-effort — already-dead child / pid race */ + } + } + }, + + async shutdown() { + // Set BEFORE the snapshot so any racing `spawnOrAttach` triggered + // by an in-flight HTTP connection after `runQwenServe.close()` + // entered the bridge.shutdown() phase fails fast instead of + // spawning a child this teardown won't see. + shuttingDown = true; + const entries = Array.from(byId.values()); + // Snapshot channels too — Stage 1.5 multi-session means N + // sessions may share one channel; we tear down channels + // (which transitively takes all their sessions), not entries + // one-by-one. + const channelInfos = Array.from(byWorkspaceChannel.values()); + // Resolve every still-pending permission as cancelled before clearing + // the maps so callers awaiting `requestPermission` unwind cleanly. + for (const e of entries) { + const ids = Array.from(e.pendingPermissionIds); + for (const id of ids) { + resolvePending(id, { outcome: { outcome: 'cancelled' } }); + } + } + byWorkspace.clear(); + byId.clear(); + byWorkspaceChannel.clear(); + pendingPermissions.clear(); + // Publish a terminal `session_died` BEFORE closing each bus so SSE + // subscribers can distinguish "daemon shut down" from a transient + // network error and don't sit indefinitely retrying. The + // channel.exited handler also publishes this on a child crash, + // but at shutdown time the entry has already been removed from + // `byId` (above), so the handler's `byId.get(...)` is undefined + // and the automatic publish wouldn't fire. + for (const e of entries) { + try { + e.events.publish({ + type: 'session_died', + data: { sessionId: e.sessionId, reason: 'daemon_shutdown' }, + }); + } catch { + /* bus already closed */ + } + e.events.close(); + } + // Wait for in-flight channel spawns + session spawns. The + // snapshots above only see what's already registered; a doSpawn + // past `newSession()` but pre-`byId.set` is missed, as is a + // `getOrCreateChannel` past `channelFactory()` but pre- + // `byWorkspaceChannel.set`. The late-shutdown re-checks at + // doSpawn/getOrCreateChannel catch both — but without these + // awaits, `bridge.shutdown()` would resolve before they + // finish, and the orphan stderr error from a half-built + // child would fire AFTER the daemon claimed graceful + // shutdown (log-confusing). + const inFlightSessionAwaits = Array.from(inFlightSpawns.values()).map( + (p): Promise => + p.then( + () => undefined, + () => undefined, + ), + ); + const inFlightChannelAwaits = Array.from( + inFlightChannelSpawns.values(), + ).map( + (p): Promise => + p.then( + () => undefined, + () => undefined, + ), + ); + await Promise.all([ + // Kill each unique channel once. With multi-session per + // channel, the same channel object can be referenced by + // multiple entries; `channelInfos` is the deduplicated set. + ...channelInfos.map((ci) => ci.channel.kill().catch(() => {})), + ...inFlightSessionAwaits, + ...inFlightChannelAwaits, + ]); + }, + }; +} + +/** + * Human-readable label for a `fs.Stats` object's kind, used in the + * `readTextFile` "not a regular file" rejection message (BX8YO). + * Sockets, pipes, char-devices etc. all report `size: 0` but stream + * unbounded data; the operator wants to know which one they hit so + * the path-mistake is obvious. + */ +function describeStatKind(stats: import('node:fs').Stats): string { + if (stats.isDirectory()) return 'directory'; + if (stats.isSymbolicLink()) return 'symlink'; + if (stats.isCharacterDevice()) return 'character device'; + if (stats.isBlockDevice()) return 'block device'; + if (stats.isFIFO()) return 'named pipe (FIFO)'; + if (stats.isSocket()) return 'socket'; + return 'non-regular file'; +} + +/** + * Extract the line range `[startLine, endLine)` (0-based) from a string + * without allocating a per-line array. Equivalent to + * `content.split('\n').slice(startLine, endLine).join('\n')` but + * O(file size) string scan rather than O(file size) string + O(line + * count) array. Matters for the partial-read path of `readTextFile` + * where the limit is small and the file is large. + */ +function sliceLineRange( + content: string, + startLine: number, + endLine: number | undefined, +): string { + // Find the byte offset where line `startLine` begins. + let offset = 0; + for (let i = 0; i < startLine; i++) { + const nl = content.indexOf('\n', offset); + if (nl === -1) return ''; + offset = nl + 1; + } + if (endLine === undefined) return content.slice(offset); + // Walk `endLine - startLine` newlines forward to find the end byte. + let end = offset; + const want = endLine - startLine; + for (let i = 0; i < want; i++) { + const nl = content.indexOf('\n', end); + if (nl === -1) return content.slice(offset); + end = nl + 1; + } + // Trim the trailing `\n` so the slice mirrors `lines.slice(...).join('\n')`. + return content.slice(offset, end > offset ? end - 1 : end); +} + +/** + * Canonicalize a workspace path so two callers referring to the same + * directory get the same `byWorkspace` key. `path.resolve` alone collapses + * `..` and `.` segments and absolutizes, but on case-insensitive filesystems + * (macOS APFS, Windows NTFS) `/Work/A` and `/work/a` are the same directory + * yet `resolve` returns them verbatim — two `byWorkspace` entries form for + * one physical workspace and `sessionScope: 'single'` silently degrades to + * "one per spelling". + * + * `realpathSync.native` (when the path exists) walks symlinks and returns + * the on-disk casing; this matches what `config.ts` / `settings.ts` / + * `sandbox.ts` use for their own workspace resolution. When the path + * doesn't exist (test fixtures, ahead-of-mkdir flows) we fall back to + * the resolved-but-uncanonicalized form rather than throwing — the + * downstream `spawn({cwd})` will fail with a useful ENOENT if the + * workspace truly doesn't exist. + * + * NOTE: This is a **cross-module contract** (BX9_q) — `config.ts`, + * `settings.ts`, `sandbox.ts`, and this file all need to canonicalize + * the same way for `sessionScope: 'single'` re-attach to work + * correctly across paths. The contract: use `realpathSync.native` on + * the resolved absolute path; fall back to `path.resolve` only when + * the path doesn't exist yet. If a future change breaks this + * alignment (e.g. one module starts lowercasing on Windows but this + * one doesn't), `byWorkspace.get()` lookup misses for the same + * physical directory → duplicate sessions silently spawn, and + * `sessionScope: 'single'` degrades to "one per spelling" with no + * error. There's no test that pins the alignment; the integration + * suite would catch a divergence only if it tested the specific + * casing / symlink path the affected module changed. + * + * Stage 2 in-process (#3803 §10) collapses the bridge into core, + * removing the bridge-side path resolution entirely. Stage 1.5 + * `@qwen-code/acp-bridge` lift (chiga0 finding 1) is the natural + * place to extract a shared `canonicalizeWorkspace` primitive that + * all four modules consume — the lowest-common-denominator + * extraction is fine THERE because the package boundary forces the + * call sites to converge. Until then, *any* change to how those + * modules resolve workspace paths needs a matching change here. + */ +function canonicalizeWorkspace(p: string): string { + const resolved = path.resolve(p); + try { + // FIXME(stage-2): switch to `fs.promises.realpath` once the + // bridge call sites become async-friendly. This sync syscall + // runs on the hot `spawnOrAttach` path and blocks the event + // loop for one filesystem stat per call. Single-user loopback + // (Stage 1's design target) doesn't notice; high-concurrency + // deployments will. Stage 2 in-process refactor removes the + // entire bridge-side path resolution anyway, but if Stage 2 + // ever lands without that change, switch to the async version. + return realpathSync.native(resolved); + } catch { + return resolved; + } +} + +/** + * Race `p` against a timeout. The timeout REJECTS the returned + * promise but does NOT abort the underlying operation — `p` keeps + * running to completion (or its own failure) and its eventual + * resolution is silently dropped. + * + * Stage 1 limitation: for `unstable_setSessionModel` the agent may + * complete the model switch AFTER we surfaced the timeout to the + * HTTP caller, leading to drift between caller's perceived model + * and agent's actual model. Subscribers also see contradictory + * SSE events (`model_switch_failed` from the timeout, then a late + * `model_switched` if the agent succeeds). Acceptable for Stage 1 + * because: + * 1. ACP's `unstable_setSessionModel` doesn't accept a cancel + * signal yet (the SDK's `prompt` does, hence `sendPrompt`'s + * explicit `cancel` notification on abort). + * 2. Model switches complete in milliseconds in practice; a + * timeout firing means the agent is genuinely wedged, not + * just slow, and would have been DOA anyway. + * Stage 2 will add abort plumbing once ACP exposes a cancel hook + * for `unstable_setSessionModel`. Tracked in the model-change + * concurrency notes in `applyModelServiceId`. BSA0C suggested a + * `modelSwitchTimedOut` flag + `model_switch_late_success` + * synthetic frame for full observability of the divergent state; + * recorded as a Stage 2 follow-up so the timeout/late-success + * handshake is implemented once across both ACP-side cancel and + * the bridge-side state flag (rather than just papering over the + * symptom). + */ +async function withTimeout( + p: Promise, + ms: number, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeoutP = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`HttpAcpBridge ${label} timed out after ${ms}ms`)), + ms, + ); + }); + try { + return await Promise.race([p, timeoutP]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Default channel factory: spawn the current Node executable running this + * CLI's entry script in `--acp` mode. `process.argv[1]` resolves to the qwen + * entry script when launched via the `qwen` bin shim. + * + * Note on `cwd`: CodeQL flags the `workspaceCwd` flow into `spawn({cwd})` + * as an "uncontrolled data used in path expression" finding. That's the + * Stage 1 trust model speaking — the caller (a token-authenticated HTTP + * client) is treated as an extension of the operator. The agent already + * runs as the same UID with shell-tool access, so restricting the spawn + * cwd to a sandbox here would be theatre. Stage 4+ remote-sandbox swaps + * this factory for a sandbox-aware variant; see issue #3803 §11. + */ +export const defaultSpawnChannelFactory: ChannelFactory = async ( + workspaceCwd, +) => { + // Resolution order: + // 1. `QWEN_CLI_ENTRY` env override — escape hatch for non-standard + // launch paths (bundled binaries, npx wrappers, `node -e`, + // `tsx ./src/...`, custom shims, container images that + // relocate the entry script). Anyone hitting "process.argv[1] + // is empty" or "process.argv[1] points at the wrong file" can + // set this without code changes. + // 2. `process.argv[1]` — works when launched via the `qwen` bin + // shim, which is the common path. + // Fail loudly with an actionable error if neither resolves. + const cliEntry = process.env['QWEN_CLI_ENTRY'] || process.argv[1]; + if (!cliEntry) { + throw new Error( + 'Cannot determine CLI entry path for spawning the ACP child: ' + + 'process.argv[1] is empty and QWEN_CLI_ENTRY is unset. ' + + 'Set QWEN_CLI_ENTRY to the absolute path of the qwen entry ' + + 'script (e.g. `export QWEN_CLI_ENTRY=$(which qwen)`) to override.', + ); + } + // Each session takes ~3 file descriptors (stdin/stdout/stderr) for the + // child plus a few sockets. Operators running many concurrent sessions + // should bump `ulimit -n` accordingly. Stage 1 doesn't pre-flight FD + // headroom — Stage 2 in-process drops the per-session FD cost entirely. + // Child stderr is piped (NOT `inherit`ed) so we can prefix each + // line with `[serve pid=… cwd=…]` before forwarding to the + // daemon's stderr — see the prefix-and-forward loop below the + // `spawn(...)` call. Sessions are still interleaved on the + // daemon's stderr stream but each line carries its own session + // identifier, so operators can `grep pid=12345` to pull one + // session's trace cleanly. Stage 4+ remote sandboxes will isolate + // stderr at the transport level. + // + // Note: spawning `process.execPath` only works when the entry script can + // be loaded by raw Node. In dev (e.g. `npm run dev` via `tsx`) the entry + // is a `.ts` file Node can't run; users should `npm run build` before + // `qwen serve` or set `process.execPath` to a tsx-aware shim. Stage 1 + // accepts this — the daemon is meant for built deployments. + // Pass through the daemon's full environment to the child, scrubbing + // ONLY daemon-internal secrets (see SCRUBBED_CHILD_ENV_KEYS at module + // scope). An earlier version used an allowlist, but that broke the + // common deployment shape: users export `OPENAI_API_KEY` / + // `ANTHROPIC_API_KEY` / `QWEN_*` / `DASHSCOPE_API_KEY` / a custom + // `modelProviders[].envKey` to authenticate the agent's LLM calls, + // and core's model config resolves those from `process.env`. An + // exhaustive allowlist can't enumerate user-defined provider keys, + // so the agent ends up unable to authenticate. + // + // Threat-model rationale: the agent already runs as the same UID + // with shell-tool access — anything in `~/.bashrc`, `~/.npmrc`, + // `~/.aws/credentials`, etc. is reachable by prompt injection + // regardless of what we put in `env`. The env passthrough is not + // the security boundary; the user-as-trust-root is. The only thing + // we MUST scrub is `QWEN_SERVER_TOKEN` (daemon-only auth that + // would let a prompt-injected shell turn the agent into an + // authenticated client of its own daemon — escalation the agent + // doesn't otherwise have). + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const key of SCRUBBED_CHILD_ENV_KEYS) { + delete childEnv[key]; + } + // CodeQL `js/path-injection` flags the `cwd: workspaceCwd` flow. + // Stage 1 trust model accepts this — see the function-level comment + // above for the design rationale. Defense-in-depth: the cwd is + // canonicalized via `path.resolve()` upstream in `spawnOrAttach`, + // and `spawn`'s `cwd` only changes the child's working directory, + // it doesn't pass through any shell. + // + // NOTE: GitHub Code Scanning does NOT honor inline `// lgtm` / + // `// codeql` annotations (LGTM.com retired in 2021). Suppressing + // this alert requires either (a) UI dismissal as "won't fix" with + // the rationale above, or (b) a repo-level + // `.github/codeql/codeql-config.yml` query exclusion. Both are + // out of scope for a code-only PR; flagging here for the human + // reviewer. + const child = spawn(process.execPath, [cliEntry, '--acp'], { + cwd: workspaceCwd, + // Pipe stderr (was: 'inherit') so we can prefix each line with + // the spawn's pid + workspace, making per-session crash output + // attributable. Bare 'inherit' sends every child's stderr to + // the daemon's stderr verbatim and unprefixed — under any + // multi-session load the operator's log becomes a salad of + // unattributed traces. + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, + }); + + // Forward child stderr to the daemon's stderr line-by-line, with a + // `[serve pid=… cwd=…]` prefix on each line so operators can + // correlate stack traces back to the spawning request. Best-effort: + // a child that prints partial lines without a trailing newline is + // flushed when the stream emits `end`. + if (child.stderr) { + let buf = ''; + const prefix = `[serve pid=${child.pid} cwd=${workspaceCwd}] `; + // BRAp3 cap: a buggy child that writes a huge stderr line, or + // never emits `\n`, would otherwise grow `buf` per spawn + // unboundedly. 64 KiB is generous for the longest legitimate + // stack trace line we'd expect from a Node child; anything + // past that gets force-flushed with a `[truncated]` marker so + // the operator still sees a prefix-attributed log line and + // memory stays bounded. We DON'T drop content — we flush + // chunks at the cap. (Picking 64 KiB matches our SSE per-frame + // write budget; anything above this already implies the child + // is misbehaving.) + const STDERR_LINE_CAP_CHARS = 64 * 1024; + const flush = (line: string) => { + if (line.length > 0) process.stderr.write(prefix + line + '\n'); + }; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + buf += chunk; + let nl = buf.indexOf('\n'); + while (nl !== -1) { + flush(buf.slice(0, nl)); + buf = buf.slice(nl + 1); + nl = buf.indexOf('\n'); + } + // Force-flush the unterminated tail if it's grown past the cap + // — keeps memory bounded against a `\n`-less stderr storm. + while (buf.length > STDERR_LINE_CAP_CHARS) { + flush(buf.slice(0, STDERR_LINE_CAP_CHARS) + ' [truncated]'); + buf = buf.slice(STDERR_LINE_CAP_CHARS); + } + }); + child.stderr.on('end', () => { + if (buf.length > 0) flush(buf); + }); + child.stderr.on('error', () => { + // Don't crash the daemon if the pipe breaks; the child is + // already gone or about to be. + }); + } + + // Build the `exited` promise BEFORE checking stdin/stdout so the listener + // is in place before any error event can fire. We treat both `exit` and + // `error` as termination — without an `error` listener Node would treat + // an async spawn failure (ENOMEM, EACCES, …) as an unhandled error and + // crash the whole daemon. + const exited = new Promise((resolve) => { + let resolved = false; + const finish = (info?: AcpChannelExitInfo) => { + if (resolved) return; + resolved = true; + resolve(info); + }; + child.once('exit', (code, signal) => + finish({ exitCode: code, signalCode: signal }), + ); + child.once('error', () => finish(undefined)); + }); + + if (!child.stdin || !child.stdout) { + child.kill('SIGKILL'); + throw new Error( + 'Spawned ACP child has no stdin/stdout — cannot establish NDJSON channel.', + ); + } + + const writable = Writable.toWeb(child.stdin) as WritableStream; + const readable = Readable.toWeb(child.stdout) as ReadableStream; + const stream = ndJsonStream(writable, readable); + + return { + stream, + kill: () => killChild(child), + killSync: () => { + // Bd1y6: synchronous SIGKILL for the double-signal force-exit + // path. Skip if child already exited (kill on a dead process + // raises an OS-level error that's noise here). + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + /* already dead / pid recycled — ignore */ + } + } + }, + exited, + }; +}; + +const KILL_HARD_DEADLINE_MS = 10_000; + +/** + * Environment variables stripped from the spawned `qwen --acp` child's + * environment. Everything else is passed through — see the + * threat-model rationale at the call site in `defaultSpawnChannelFactory`. + * + * Currently just `QWEN_SERVER_TOKEN`: the daemon's own bearer token, + * which the agent doesn't need (it speaks to the daemon over stdio, + * not HTTP). Leaving it in the child's env would let prompt injection + * turn the agent into an authenticated client of its own daemon — an + * escalation the agent doesn't otherwise have. + * + * **WARNING**: this denylist is correct *only because the agent + * already has unrestricted shell-tool access* — anything in the env + * is reachable via `~/.bashrc`/`~/.aws/credentials`/etc. anyway. + * Any future mode that **removes** shell-tool access (e.g. a + * sandbox-locked agent variant) MUST switch this back to an + * allowlist OR significantly expand the denylist to cover common + * provider/CI/cloud secret prefixes (`OPENAI_*`, `ANTHROPIC_*`, + * `AWS_*`, `GITHUB_TOKEN`, `CI_*`, `*_API_KEY`, `*_SECRET`, …). + * See issue #3803 §11 for the Stage 4+ remote-sandbox plan. + * + * Defined at module scope so the Set is allocated once at load. + */ +const SCRUBBED_CHILD_ENV_KEYS: ReadonlySet = new Set([ + 'QWEN_SERVER_TOKEN', +]); + +function killChild(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + let resolved = false; + const finish = () => { + if (resolved) return; + resolved = true; + child.removeListener('exit', finish); + resolve(); + }; + child.once('exit', finish); + try { + child.kill('SIGTERM'); + } catch { + finish(); + return; + } + setTimeout(() => { + if (!resolved && child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + /* swallow */ + } + } + }, 5_000).unref(); + // Even SIGKILL doesn't return if the child is in uninterruptible + // sleep (D-state, e.g. NFS read blocked on a dead server). Without + // this hard deadline, `bridge.shutdown()`'s `Promise.all` waits + // forever on that one wedged child and SHUTDOWN_FORCE_CLOSE_MS in + // `runQwenServe` only covers `server.close()`, not the bridge. + // After the deadline give up: the child is probably stuck in a + // kernel call we can't cancel, and `process.exit(0)` will reap it + // when the daemon returns to its caller. + setTimeout(() => { + if (!resolved) finish(); + }, KILL_HARD_DEADLINE_MS).unref(); + }); +} diff --git a/packages/cli/src/serve/index.ts b/packages/cli/src/serve/index.ts new file mode 100644 index 0000000000..c36c7fd0a4 --- /dev/null +++ b/packages/cli/src/serve/index.ts @@ -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'; diff --git a/packages/cli/src/serve/loopbackBinds.ts b/packages/cli/src/serve/loopbackBinds.ts new file mode 100644 index 0000000000..3ec2c84791 --- /dev/null +++ b/packages/cli/src/serve/loopbackBinds.ts @@ -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 = 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()); +} diff --git a/packages/cli/src/serve/runQwenServe.ts b/packages/cli/src/serve/runQwenServe.ts new file mode 100644 index 0000000000..13bdb7841f --- /dev/null +++ b/packages/cli/src/serve/runQwenServe.ts @@ -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; +} + +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 & { token?: string }, + deps: RunQwenServeDeps = {}, +): Promise { + // 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((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 | 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((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); + }); +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts new file mode 100644 index 0000000000..c9ec984b3d --- /dev/null +++ b/packages/cli/src/serve/server.test.ts @@ -0,0 +1,1436 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import { createServeApp } from './server.js'; +import { runQwenServe, type RunHandle } from './runQwenServe.js'; +import type { + CancelNotification, + PromptRequest, + PromptResponse, + RequestPermissionResponse, + SetSessionModelRequest, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import { + InvalidPermissionOptionError, + SessionLimitExceededError, + SessionNotFoundError, + type BridgeSession, + type BridgeSessionSummary, + type BridgeSpawnRequest, + type HttpAcpBridge, +} from './httpAcpBridge.js'; +import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; +import { + CAPABILITIES_SCHEMA_VERSION, + STAGE1_FEATURES, + type ServeOptions, +} from './types.js'; + +const baseOpts: ServeOptions = { + hostname: '127.0.0.1', + port: 4170, + mode: 'http-bridge', +}; + +interface FakeBridgeOpts { + spawnImpl?: (req: BridgeSpawnRequest) => Promise; + promptImpl?: ( + sessionId: string, + req: PromptRequest, + signal?: AbortSignal, + ) => Promise; + cancelImpl?: (sessionId: string, req?: CancelNotification) => Promise; + subscribeImpl?: ( + sessionId: string, + opts?: SubscribeOptions, + ) => AsyncIterable; + respondImpl?: ( + requestId: string, + response: RequestPermissionResponse, + ) => boolean; + listImpl?: (workspaceCwd: string) => BridgeSessionSummary[]; + setModelImpl?: ( + sessionId: string, + req: SetSessionModelRequest, + ) => Promise; +} + +interface FakeBridge extends HttpAcpBridge { + calls: BridgeSpawnRequest[]; + promptCalls: Array<{ + sessionId: string; + req: PromptRequest; + signal?: AbortSignal; + }>; + cancelCalls: Array<{ sessionId: string; req?: CancelNotification }>; + killCalls: Array<{ + sessionId: string; + opts?: { requireZeroAttaches?: boolean }; + }>; + detachCalls: string[]; + permissionVotes: Array<{ + requestId: string; + response: RequestPermissionResponse; + }>; + listCalls: string[]; + setModelCalls: Array<{ sessionId: string; req: SetSessionModelRequest }>; + shutdownCalls: number; +} + +function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { + const calls: BridgeSpawnRequest[] = []; + const promptCalls: FakeBridge['promptCalls'] = []; + const cancelCalls: FakeBridge['cancelCalls'] = []; + const killCalls: Array<{ + sessionId: string; + opts?: { requireZeroAttaches?: boolean }; + }> = []; + const detachCalls: string[] = []; + const permissionVotes: FakeBridge['permissionVotes'] = []; + const listCalls: string[] = []; + const setModelCalls: FakeBridge['setModelCalls'] = []; + let shutdownCalls = 0; + const spawnImpl = + opts.spawnImpl ?? + (async (req) => ({ + sessionId: `fake-${calls.length}`, + workspaceCwd: req.workspaceCwd, + attached: false, + })); + const promptImpl = + opts.promptImpl ?? (async () => ({ stopReason: 'end_turn' })); + const cancelImpl = opts.cancelImpl ?? (async () => {}); + const respondImpl = opts.respondImpl ?? (() => true); + const listImpl = opts.listImpl ?? (() => []); + const setModelImpl = opts.setModelImpl ?? (async () => ({})); + return { + calls, + promptCalls, + cancelCalls, + killCalls, + detachCalls, + permissionVotes, + listCalls, + setModelCalls, + get shutdownCalls() { + return shutdownCalls; + }, + get sessionCount() { + return calls.length; + }, + get pendingPermissionCount() { + return 0; + }, + async spawnOrAttach(req) { + const result = await spawnImpl(req); + calls.push(req); + return result; + }, + async sendPrompt(sessionId, req, signal) { + promptCalls.push({ sessionId, req, signal }); + return promptImpl(sessionId, req, signal); + }, + async cancelSession(sessionId, req) { + cancelCalls.push({ sessionId, req }); + return cancelImpl(sessionId, req); + }, + subscribeEvents(sessionId, subOpts) { + if (opts.subscribeImpl) return opts.subscribeImpl(sessionId, subOpts); + // Default: empty stream + return (async function* () { + // empty + })(); + }, + respondToPermission(requestId, response) { + const accepted = respondImpl(requestId, response); + permissionVotes.push({ requestId, response }); + return accepted; + }, + listWorkspaceSessions(workspaceCwd) { + listCalls.push(workspaceCwd); + return listImpl(workspaceCwd); + }, + async setSessionModel(sessionId, req) { + setModelCalls.push({ sessionId, req }); + return setModelImpl(sessionId, req); + }, + async killSession(sessionId, opts) { + killCalls.push({ sessionId, opts }); + }, + async detachClient(sessionId) { + detachCalls.push(sessionId); + }, + async shutdown() { + shutdownCalls += 1; + }, + killAllSync() { + shutdownCalls += 1; + }, + }; +} + +describe('createServeApp', () => { + describe('GET /health', () => { + it('returns 200 ok', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + }); + + describe('GET /capabilities', () => { + it('returns the v1 envelope', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body.v).toBe(CAPABILITIES_SCHEMA_VERSION); + expect(res.body.mode).toBe('http-bridge'); + expect(res.body.features).toEqual([...STAGE1_FEATURES]); + expect(res.body.modelServices).toEqual([]); + }); + }); + + describe('host allowlist (loopback bind)', () => { + it('rejects requests with an unrelated Host header', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', 'evil.example.com'); + expect(res.status).toBe(403); + }); + + it('accepts host.docker.internal so containers can reach the host daemon', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', `host.docker.internal:${baseOpts.port}`); + expect(res.status).toBe(200); + }); + }); + + describe('middleware order — auth runs before body parser', () => { + it('rejects unauthorized POST without parsing the (possibly huge) body', async () => { + // If auth ran AFTER body-parsing, an unauthenticated client could + // force the daemon to JSON.parse a 10MB payload before the 401. + // This test verifies the 401 fires regardless of body content + // (no 413 / no parse error / no validation error). + const bridge = fakeBridge(); + const tokenedOpts: ServeOptions = { + ...baseOpts, + token: 'real-secret', + }; + const app = createServeApp(tokenedOpts, undefined, { bridge }); + const fakeBigBody = JSON.stringify({ filler: 'x'.repeat(100_000) }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('content-type', 'application/json') + .send(fakeBigBody); + expect(res.status).toBe(401); + // Bridge must NOT have been touched — auth short-circuited. + expect(bridge.calls).toHaveLength(0); + }); + }); + + describe('CORS / browser origin denial', () => { + it('returns a deterministic 403 JSON when an Origin header is present', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'https://evil.example.com'); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Request denied by CORS policy' }); + }); + + it('accepts requests with no Origin header (CLI/SDK clients)', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + }); + + it('also rejects POSTs with an Origin header', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'https://evil.example.com') + .send({ cwd: '/work/a' }); + expect(res.status).toBe(403); + // Bridge must NOT have been touched. + expect(bridge.calls).toHaveLength(0); + }); + }); + + describe('POST /session', () => { + it('400 when cwd is missing', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(res.status).toBe(400); + expect(bridge.calls).toHaveLength(0); + }); + + it('400 when cwd is relative', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: 'relative/path' }); + expect(res.status).toBe(400); + expect(bridge.calls).toHaveLength(0); + }); + + it('200 with the BridgeSession shape on success', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: '/work/a', modelServiceId: 'qwen-prod' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionId: 'fake-0', + workspaceCwd: '/work/a', + attached: false, + }); + expect(bridge.calls).toEqual([ + { workspaceCwd: '/work/a', modelServiceId: 'qwen-prod' }, + ]); + }); + + it('500 when bridge throws', async () => { + const bridge = fakeBridge({ + spawnImpl: async () => { + throw new Error('boom'); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: '/work/a' }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'boom' }); + }); + + it('strips prototype-pollution keys from body (BZ9uv/va/vs/wD)', async () => { + // `safeBody()` strips `__proto__` / `constructor` / `prototype` + // and copies into an `Object.create(null)` target before any + // route spreads it into the bridge call. Even if a client + // sends those keys, neither the bridge request nor + // `Object.prototype` ends up touched. + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + // Build the body as a raw string so the server-side + // `express.json` parser is the only path that could land the + // dangerous key on the request object. + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('content-type', 'application/json') + .send( + '{"cwd":"/work/a","__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}}}', + ); + expect(res.status).toBe(200); + expect(bridge.calls[0]?.workspaceCwd).toBe('/work/a'); + // No prototype pollution: Object.prototype.polluted is + // undefined. (This is the core security property — if the + // dangerous key landed via spread, this check would fail.) + expect(({} as Record)['polluted']).toBeUndefined(); + }); + }); + + describe('POST /session/:id/prompt', () => { + it('200 with PromptResponse on success; route :id wins over body sessionId', async () => { + const bridge = fakeBridge({ + promptImpl: async () => ({ stopReason: 'end_turn' }), + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + sessionId: 'spoofed-session-B', + prompt: [{ type: 'text', text: 'hi' }], + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ stopReason: 'end_turn' }); + expect(bridge.promptCalls).toHaveLength(1); + expect(bridge.promptCalls[0]?.sessionId).toBe('session-A'); + expect(bridge.promptCalls[0]?.req.sessionId).toBe('session-A'); + }); + + it('400 when prompt body is missing', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(res.status).toBe(400); + expect(bridge.promptCalls).toHaveLength(0); + }); + + it('404 when bridge reports unknown session', async () => { + const bridge = fakeBridge({ + promptImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ prompt: [{ type: 'text', text: 'hi' }] }); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + + it('500 on generic bridge errors', async () => { + const bridge = fakeBridge({ + promptImpl: async () => { + throw new Error('agent crashed'); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ prompt: [{ type: 'text', text: 'hi' }] }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'agent crashed' }); + }); + + it('passes an AbortSignal into bridge.sendPrompt', async () => { + let signalDefined = false; + let abortedAtCall = false; + const bridge = fakeBridge({ + promptImpl: async (_sid, _req, signal) => { + signalDefined = signal !== undefined; + abortedAtCall = signal?.aborted ?? false; + return { stopReason: 'end_turn' }; + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ prompt: [{ type: 'text', text: 'hi' }] }); + expect(res.status).toBe(200); + // The route always supplies a signal — the AbortController it wires + // to req.on('close'). The bridge must receive it so a future client + // disconnect can be routed into an ACP cancel. (Capture happens at + // call time; supertest's later connection close would flip the + // signal's `aborted` flag if asserted post-hoc.) + expect(signalDefined).toBe(true); + expect(abortedAtCall).toBe(false); + }); + + it('aborting the signal mid-prompt asks the bridge to wind down', async () => { + // Bridge waits forever unless aborted, then resolves with a + // cancelled stop reason. Verifies the route's + // req.on('close') → abort.abort() flow propagates. + let promptStarted: (() => void) | undefined; + const promptStartedPromise = new Promise((r) => { + promptStarted = r; + }); + const bridge = fakeBridge({ + promptImpl: async (_sid, _req, signal) => + new Promise((resolve) => { + promptStarted!(); + const onAbort = () => resolve({ stopReason: 'cancelled' }); + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + }), + }); + const localHandle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + try { + const port = (localHandle.server.address() as { port: number }).port; + // Use Node's `http` directly — vitest's jsdom env replaces + // AbortController with a polyfill that undici's fetch rejects. + const http = await import('node:http'); + const reqBody = JSON.stringify({ + prompt: [{ type: 'text', text: 'hi' }], + }); + const httpReq = http.request({ + host: '127.0.0.1', + port, + method: 'POST', + path: '/session/sess/prompt', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(reqBody), + }, + }); + // Swallow ECONNRESET / socket-hangup that the destroy below emits. + httpReq.on('error', () => {}); + httpReq.write(reqBody); + httpReq.end(); + // Wait for the bridge to receive the prompt before destroying. + await promptStartedPromise; + httpReq.destroy(); + // Give the daemon a moment to register the close → propagate. + await new Promise((r) => setTimeout(r, 100)); + expect(bridge.promptCalls).toHaveLength(1); + expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); + } finally { + await localHandle.close(); + } + }); + }); + + describe('GET /workspace/:id/sessions', () => { + it('returns the list returned by the bridge', async () => { + const bridge = fakeBridge({ + listImpl: () => [ + { sessionId: 's-1', workspaceCwd: '/work/a' }, + { sessionId: 's-2', workspaceCwd: '/work/a' }, + ], + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get(`/workspace/${encodeURIComponent('/work/a')}/sessions`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(2); + expect(bridge.listCalls).toEqual(['/work/a']); + }); + + it('returns an empty array when no sessions exist for the workspace', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get(`/workspace/${encodeURIComponent('/work/idle')}/sessions`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ sessions: [] }); + }); + + it('400 when :id does not decode to an absolute path', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get(`/workspace/${encodeURIComponent('relative/path')}/sessions`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(400); + expect(bridge.listCalls).toHaveLength(0); + }); + }); + + describe('POST /session/:id/model', () => { + it('200 with the agent response on success', async () => { + const bridge = fakeBridge({ + setModelImpl: async () => ({ _meta: { applied: true } }), + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/model') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ modelId: 'qwen3-coder', sessionId: 'spoofed-B' }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ _meta: { applied: true } }); + expect(bridge.setModelCalls).toHaveLength(1); + expect(bridge.setModelCalls[0]?.sessionId).toBe('session-A'); + expect(bridge.setModelCalls[0]?.req.sessionId).toBe('session-A'); + expect(bridge.setModelCalls[0]?.req.modelId).toBe('qwen3-coder'); + }); + + it('400 when modelId is missing', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/model') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(res.status).toBe(400); + expect(bridge.setModelCalls).toHaveLength(0); + }); + + it('400 when modelId is not a non-empty string', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/model') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ modelId: '' }); + expect(res.status).toBe(400); + expect(bridge.setModelCalls).toHaveLength(0); + }); + + it('404 when bridge reports unknown session', async () => { + const bridge = fakeBridge({ + setModelImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/model') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ modelId: 'qwen3-coder' }); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + }); + + describe('POST /permission/:requestId', () => { + it('200 when bridge accepts the vote', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected', optionId: 'allow' } }); + expect(res.status).toBe(200); + expect(bridge.permissionVotes).toEqual([ + { + requestId: 'req-1', + response: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }, + ]); + }); + + it('200 with cancelled outcome', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'cancelled' } }); + expect(res.status).toBe(200); + expect(bridge.permissionVotes[0]?.response.outcome.outcome).toBe( + 'cancelled', + ); + }); + + it('404 when bridge reports the requestId is unknown or already resolved', async () => { + const bridge = fakeBridge({ respondImpl: () => false }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/missing') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'cancelled' } }); + expect(res.status).toBe(404); + expect(res.body.requestId).toBe('missing'); + }); + + it('400 on a malformed outcome', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected' } }); // missing optionId + expect(res.status).toBe(400); + expect(bridge.permissionVotes).toHaveLength(0); + }); + + it('400 when outcome is missing entirely', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(res.status).toBe(400); + expect(bridge.permissionVotes).toHaveLength(0); + }); + + it('400 when selected outcome has an empty-string optionId', async () => { + // An empty string passes `typeof === 'string'` but isn't a meaningful + // selection — would push a malformed vote to the agent which would + // reject with an opaque "unknown option" error. + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected', optionId: '' } }); + expect(res.status).toBe(400); + expect(bridge.permissionVotes).toHaveLength(0); + }); + + it('400 with invalid_option_id when bridge throws InvalidPermissionOptionError (Blehl)', async () => { + // The bridge's optionId-validation path (BkwQI) surfaces + // forged outcomes (e.g. `ProceedAlways*` when the prompt's + // `hideAlwaysAllow` policy hid them). Route maps that + // distinct error to 400 with code `invalid_option_id` + // (vs 404 for "unknown requestId"). + const bridge = fakeBridge({ + respondImpl: () => { + throw new InvalidPermissionOptionError( + 'req-1', + 'ProceedAlwaysProject', + ); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + outcome: { outcome: 'selected', optionId: 'ProceedAlwaysProject' }, + }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_option_id', + requestId: 'req-1', + optionId: 'ProceedAlwaysProject', + }); + }); + }); + + describe('POST /session/:id/cancel', () => { + it('204 on success and forwards routing id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/cancel') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionId: 'spoofed-B' }); + expect(res.status).toBe(204); + expect(res.body).toEqual({}); + expect(bridge.cancelCalls).toHaveLength(1); + expect(bridge.cancelCalls[0]?.sessionId).toBe('session-A'); + expect(bridge.cancelCalls[0]?.req?.sessionId).toBe('session-A'); + }); + + it('204 with empty body', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/cancel') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(204); + expect(bridge.cancelCalls).toHaveLength(1); + }); + + it('404 on unknown session', async () => { + const bridge = fakeBridge({ + cancelImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/cancel') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + }); + + describe('bearer auth', () => { + it('is open by default (loopback developer convenience)', async () => { + const app = createServeApp(baseOpts); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + }); + + // Switched probe endpoint from `/health` to `/capabilities` for + // these auth-rejection tests because per #3889 review A8dZT + // `/health` is now intentionally registered BEFORE the bearer + // middleware so liveness probes work without credentials. + // `/capabilities` is the cheapest endpoint that still goes through + // the auth chain. + it('rejects missing Authorization header when token is set', async () => { + const app = createServeApp({ ...baseOpts, token: 'secret' }); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(401); + }); + + it('rejects wrong scheme', async () => { + const app = createServeApp({ ...baseOpts, token: 'secret' }); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Basic c2VjcmV0'); + expect(res.status).toBe(401); + }); + + it('rejects wrong token', async () => { + const app = createServeApp({ ...baseOpts, token: 'secret' }); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer wrong'); + expect(res.status).toBe(401); + }); + + it('accepts the right token', async () => { + const app = createServeApp({ ...baseOpts, token: 'secret' }); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(200); + }); + + it('exempts /health from bearer auth so liveness probes work without credentials', async () => { + // Per #3889 review A8dZT — the registration order in + // `createServeApp` puts `/health` BEFORE `bearerAuth`, so a + // probe with no credentials still gets 200 even when the daemon + // was started with a token. CORS deny + Host allowlist still + // apply to `/health` (registered before /health), so this is + // not a way to bypass DNS rebinding or browser-origin + // protection. + const app = createServeApp({ ...baseOpts, token: 'secret' }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + }); + + describe('payload-too-large handling (A-UsP)', () => { + it('returns 413 JSON when the request body exceeds the 10 MB limit', async () => { + // body-parser raises `{status: 413, type: 'entity.too.large'}` + // when the body exceeds the configured limit. The Express + // error middleware special-cases this to a structured 413 + // response instead of falling through to a misleading 500. + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + // 11 MB of `x` characters > 10 MB body-parser limit + const oversize = 'x'.repeat(11 * 1024 * 1024); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Content-Type', 'application/json') + .send(JSON.stringify({ cwd: '/work', pad: oversize })); + expect(res.status).toBe(413); + expect(res.body).toEqual({ error: 'Request body too large (max 10 MB)' }); + // Body parser short-circuits before the route handler runs. + expect(bridge.calls).toHaveLength(0); + }); + }); + + describe('GET /health?deep=1 (chiga0 Risk 3)', () => { + it('default /health stays cheap (no bridge touch)', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + it('deep=1 includes bridge state', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get('/health?deep=1') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: 'ok', + sessions: 0, + pendingPermissions: 0, + }); + }); + + it('deep=1 returns 503 when bridge state access throws', async () => { + // Simulate a wedged bridge by replacing the getter to throw. + const bridge = fakeBridge(); + Object.defineProperty(bridge, 'sessionCount', { + get() { + throw new Error('bridge wedged'); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get('/health?deep=1') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(503); + expect(res.body).toEqual({ status: 'degraded' }); + }); + }); + + describe('session limit (chiga0 Rec 3 — --max-sessions)', () => { + it('503 + Retry-After + structured error when bridge throws SessionLimitExceededError', async () => { + const bridge = fakeBridge({ + spawnImpl: async () => { + throw new SessionLimitExceededError(20); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: '/work/a' }); + expect(res.status).toBe(503); + expect(res.headers['retry-after']).toBe('5'); + expect(res.body).toMatchObject({ + code: 'session_limit_exceeded', + limit: 20, + }); + }); + }); +}); + +describe('runQwenServe', () => { + let handle: RunHandle | undefined; + + afterEach(async () => { + if (handle) { + await handle.close(); + handle = undefined; + } + delete process.env['QWEN_SERVER_TOKEN']; + }); + + it('refuses to bind 0.0.0.0 without a token', async () => { + await expect( + runQwenServe({ + hostname: '0.0.0.0', + port: 0, + mode: 'http-bridge', + }), + ).rejects.toThrow(/Refusing to bind/); + }); + + it('accepts QWEN_SERVER_TOKEN from the env when binding non-loopback', async () => { + process.env['QWEN_SERVER_TOKEN'] = 'env-secret'; + handle = await runQwenServe({ + hostname: '0.0.0.0', + port: 0, + mode: 'http-bridge', + }); + expect(handle.url).toMatch(/^http:\/\/0\.0\.0\.0:\d+$/); + }); + + it('starts on a loopback ephemeral port without a token', async () => { + handle = await runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + }); + const port = (handle.server.address() as { port: number }).port; + expect(port).toBeGreaterThan(0); + + const res = await fetch(`http://127.0.0.1:${port}/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + + it('--max-connections 0 still accepts connections (tanzhenxin issue 1)', async () => { + // Pre-fix bug: docs say "Set to 0 to disable" and code did + // `server.maxConnections = opts.maxConnections ?? 256`, but on + // Node 22 `server.maxConnections = 0` causes the listener to + // refuse EVERY connection. An operator following the documented + // disable path got a daemon that booted cleanly but silently + // bricked every request. Fix treats 0 / Infinity / non-finite as + // "leave the property unset" so Node's default (no cap) actually + // applies. + handle = await runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + maxConnections: 0, + }); + const port = (handle.server.address() as { port: number }).port; + const res = await fetch(`http://127.0.0.1:${port}/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + // And `server.maxConnections` should be the Node default + // (undefined / unset), NOT 0. + expect(handle.server.maxConnections).not.toBe(0); + }); + + it('--max-connections Infinity treated as unlimited (tanzhenxin issue 1)', async () => { + handle = await runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + maxConnections: Infinity, + }); + const port = (handle.server.address() as { port: number }).port; + const res = await fetch(`http://127.0.0.1:${port}/health`); + expect(res.status).toBe(200); + expect(handle.server.maxConnections).not.toBe(0); + expect(handle.server.maxConnections).not.toBe(Infinity); + }); + + it('--max-connections 100 sets the cap as supplied', async () => { + handle = await runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + maxConnections: 100, + }); + expect(handle.server.maxConnections).toBe(100); + }); + + it('--max-connections NaN/negative throws at boot (BUF9-)', async () => { + // Silent fail-OPEN on a CLI typo would weaken the DoS guard. + // Boot-loud is the right behavior for an unparseable cap. + await expect( + runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + maxConnections: NaN, + }), + ).rejects.toThrow(/maxConnections: NaN/); + await expect( + runQwenServe({ + hostname: '127.0.0.1', + port: 0, + mode: 'http-bridge', + maxConnections: -5, + }), + ).rejects.toThrow(/maxConnections: -5/); + }); + + it('case-insensitive loopback: --hostname Localhost / LOCALHOST does NOT require a token (BQ92B)', async () => { + // The previous Set lookup was case-sensitive, so `Localhost` was + // treated as non-loopback and refused to boot without a token. + // Fix lowercases the operator-supplied hostname before lookup. + handle = await runQwenServe({ + hostname: 'Localhost', + port: 0, + mode: 'http-bridge', + }); + expect(handle.url).toMatch(/^http:\/\/Localhost:\d+$/); + }); + + it('strips brackets from `[::1]` before passing to app.listen()', async () => { + // Node's app.listen wants the unbracketed IPv6 literal — `[::1]` + // would fail with ENOTFOUND. The fixup is in runQwenServe's + // bind-time normalization. + handle = await runQwenServe({ + hostname: '[::1]', + port: 0, + mode: 'http-bridge', + }); + const addr = handle.server.address(); + expect(typeof addr).toBe('object'); + if (typeof addr === 'object' && addr) { + // Successfully bound — the string the OS reports is `::1` (no + // brackets). + expect( + addr.address === '::1' || addr.address === '::ffff:127.0.0.1', + ).toBe(true); + } + }); + + it('rejects `[host]:port` syntax in --hostname with a useful error', async () => { + // Operators typing `--hostname [2001:db8::1]:8080` are conflating the + // URL form with the bind args. The previous bracket-strip would have + // mangled to `2001:db8::1]:8080` and let Node ENOTFOUND. Catch it + // upstream with a clear error pointing at the right separation. + await expect( + runQwenServe({ + hostname: '[2001:db8::1]:8080', + port: 0, + mode: 'http-bridge', + token: 'irrelevant', + }), + ).rejects.toThrow(/Invalid --hostname/); + }); + + it('rejects unbracketed host:port typo with a useful error (BU-sh)', async () => { + // Without the upfront check, `localhost:4170` would flow into + // `formatHostForUrl` (treated as IPv6 because of the `:`) and + // produce a misleading `[localhost:4170]:port` URL, then fail + // at `app.listen()` with ENOTFOUND. Catch upstream. + await expect( + runQwenServe({ + hostname: 'localhost:4170', + port: 0, + mode: 'http-bridge', + }), + ).rejects.toThrow( + /Invalid --hostname "localhost:4170".*looks like a "host:port" combination/, + ); + await expect( + runQwenServe({ + hostname: '127.0.0.1:4170', + port: 0, + mode: 'http-bridge', + }), + ).rejects.toThrow(/Invalid --hostname "127\.0\.0\.1:4170"/); + // But raw IPv6 (multiple colons) still works. + handle = await runQwenServe({ + hostname: '::1', + port: 0, + mode: 'http-bridge', + }); + expect(handle.url).toMatch(/^http:\/\/\[::1\]:\d+$/); + }); + + it('rejects empty-bracket `[]` --hostname (would bind to all interfaces)', async () => { + // Node's `listen('')` is interpreted as "all interfaces". An operator + // typing `[]` clearly meant something specific, not wildcard — fail + // loudly instead of silently exposing the daemon on every interface. + await expect( + runQwenServe({ + hostname: '[]', + port: 0, + mode: 'http-bridge', + token: 'irrelevant', + }), + ).rejects.toThrow(/Invalid --hostname/); + }); + + it('drains the bridge before closing the listener', async () => { + const bridge = fakeBridge(); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + expect(bridge.shutdownCalls).toBe(0); + await handle.close(); + handle = undefined; + expect(bridge.shutdownCalls).toBe(1); + }); + + it('handle.close() is idempotent — concurrent + repeat calls share one drain cycle', async () => { + const bridge = fakeBridge(); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + // Three overlapping callers — without the cached promise each would + // arm its own force-close timer and call bridge.shutdown again. + const a = handle.close(); + const b = handle.close(); + const c = handle.close(); + await Promise.all([a, b, c]); + // Subsequent call after settle should also resolve immediately and + // not re-trigger shutdown. + await handle.close(); + handle = undefined; + expect(bridge.shutdownCalls).toBe(1); + }); + + it('force-closes connections after the shutdown timeout', async () => { + const bridge = fakeBridge(); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + + // Open a long-lived SSE-like connection; without force-close the + // listener's `server.close` would hang on this socket forever. + const sseFetch = fetch(`http://127.0.0.1:${port}/session/dangle/events`); + + // close() is expected to resolve in well under the 5s force-close + // window — but well above 0ms because the timer arms after bridge + // shutdown. Just assert it resolves at all and observe roughly when. + const start = Date.now(); + await handle.close(); + handle = undefined; + const elapsed = Date.now() - start; + + // The fakeBridge's subscribe stream is empty so the SSE response ends + // promptly; this assertion mainly proves the close didn't hang on the + // live connection. Even if the connection had stayed open, the 5s + // force-close timer would unblock us. + expect(elapsed).toBeLessThan(5_500); + // Drain the fetch promise so vitest doesn't complain about open handles. + try { + const res = await sseFetch; + await res.body?.cancel(); + } catch { + /* socket may be torn down by force-close */ + } + }); + + it('detaches its SIGINT/SIGTERM listeners after close completes', async () => { + const bridge = fakeBridge(); + const sigintBefore = process.listenerCount('SIGINT'); + const sigtermBefore = process.listenerCount('SIGTERM'); + + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + + // runQwenServe attaches one of each. + expect(process.listenerCount('SIGINT')).toBe(sigintBefore + 1); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBefore + 1); + + await handle.close(); + handle = undefined; + + // After drain completes, the listener that runQwenServe added is gone. + // (Detaching during drain would leave a second-signal-during-shutdown + // hitting Node's default termination behavior; this design detaches at + // the end of `finish` so the `if (shuttingDown) return` guard is the + // sole no-op path during the drain window.) + expect(process.listenerCount('SIGINT')).toBe(sigintBefore); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBefore); + }); +}); + +describe('GET /session/:id/events (SSE)', () => { + let handle: RunHandle | undefined; + + afterEach(async () => { + if (handle) { + await handle.close(); + handle = undefined; + } + }); + + async function readSseFrames( + body: ReadableStream, + minFrames: number, + ): Promise> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + const frames: Array<{ id?: string; event?: string; data?: string }> = []; + while (frames.length < minFrames) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const raw = buf.slice(0, idx); + buf = buf.slice(idx + 2); + if (!raw || raw.startsWith(':') || raw.startsWith('retry:')) continue; + const frame: { id?: string; event?: string; data?: string } = {}; + for (const line of raw.split('\n')) { + if (line.startsWith('id: ')) frame.id = line.slice(4); + else if (line.startsWith('event: ')) frame.event = line.slice(7); + else if (line.startsWith('data: ')) frame.data = line.slice(6); + } + frames.push(frame); + } + } + await reader.cancel(); + return frames; + } + + it('streams events from the bridge as SSE frames', async () => { + const bridge = fakeBridge({ + async *subscribeImpl(_sessionId, _opts) { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { foo: 'bar' }, + }; + yield { id: 2, v: 1, type: 'session_update', data: { foo: 'baz' } }; + // No more events; the stream stays open until the caller aborts. + await new Promise(() => {}); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + + const frames = await readSseFrames(res.body!, 2); + + expect(frames).toHaveLength(2); + expect(frames[0]?.id).toBe('1'); + expect(frames[0]?.event).toBe('session_update'); + expect(JSON.parse(frames[0]!.data!)).toEqual({ + id: 1, + v: 1, + type: 'session_update', + data: { foo: 'bar' }, + }); + expect(frames[1]?.id).toBe('2'); + }); + + it('forwards Last-Event-ID to the bridge', async () => { + const seen: number[] = []; + const bridge = fakeBridge({ + async *subscribeImpl(_sessionId, opts) { + seen.push(opts?.lastEventId ?? -1); + yield { id: 42, v: 1, type: 'session_update', data: 'replay' }; + await new Promise(() => {}); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`, { + headers: { 'Last-Event-ID': '17' }, + }); + const frames = await readSseFrames(res.body!, 1); + + expect(seen).toEqual([17]); + expect(frames[0]?.id).toBe('42'); + }); + + it('returns 404 when the bridge reports unknown session', async () => { + const bridge = fakeBridge({ + subscribeImpl: (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${port}/session/missing/events`); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.sessionId).toBe('missing'); + }); + + it('aborts the bridge subscription when the client disconnects', async () => { + const aborted = { value: false }; + const bridge = fakeBridge({ + async *subscribeImpl(_sessionId, opts) { + opts?.signal?.addEventListener( + 'abort', + () => { + aborted.value = true; + }, + { once: true }, + ); + yield { id: 1, v: 1, type: 'session_update', data: 'first' }; + await new Promise((resolve) => { + opts?.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`); + const frames = await readSseFrames(res.body!, 1); + expect(frames).toHaveLength(1); + // readSseFrames calls reader.cancel() once the requested frame count is + // reached, which severs the underlying connection — the daemon's + // `req.on('close')` handler then aborts the bridge subscription. + + // Wait briefly for the close handler to propagate to the bridge. + await new Promise((r) => setTimeout(r, 100)); + expect(aborted.value).toBe(true); + }); + + it('emits a stream_error frame when the bridge iterator throws mid-stream', async () => { + const bridge = fakeBridge({ + async *subscribeImpl(_sessionId, _opts) { + yield { id: 1, v: 1, type: 'session_update', data: 'first' }; + throw new Error('agent died'); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`); + const frames = await readSseFrames(res.body!, 2); + expect(frames).toHaveLength(2); + expect(frames[0]?.event).toBe('session_update'); + expect(frames[0]?.id).toBe('1'); + expect(frames[1]?.event).toBe('stream_error'); + // The terminal `stream_error` frame deliberately has no `id:` line so + // it doesn't pollute the per-session monotonic sequence used for + // Last-Event-ID resume. + expect(frames[1]?.id).toBeUndefined(); + expect(JSON.parse(frames[1]!.data!).data).toEqual({ error: 'agent died' }); + }); + + it('forwards numeric Last-Event-ID even when supplied as a string', async () => { + let seen: number | undefined; + const bridge = fakeBridge({ + subscribeImpl: (_sessionId, opts) => { + seen = opts?.lastEventId; + // Empty stream — close immediately so the test doesn't hang. + return (async function* () { + /* no events */ + })(); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`, { + headers: { 'Last-Event-ID': '17' }, + }); + // Drain the empty response so the connection closes. + await res.body?.cancel(); + expect(seen).toBe(17); + }); + + it('drops malformed Last-Event-ID values (non-numeric, negative)', async () => { + const seen: Array = []; + const bridge = fakeBridge({ + subscribeImpl: (_sessionId, opts) => { + seen.push(opts?.lastEventId); + return (async function* () { + /* no events */ + })(); + }, + }); + handle = await runQwenServe( + { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, + { bridge }, + ); + const port = (handle.server.address() as { port: number }).port; + for (const value of ['abc', '-1', '1.5e10z']) { + const res = await fetch( + `http://127.0.0.1:${port}/session/sess-A/events`, + { headers: { 'Last-Event-ID': value } }, + ); + await res.body?.cancel(); + } + // None of these should pass through as a parsed lastEventId. + expect(seen).toEqual([undefined, undefined, undefined]); + }); +}); + +describe('runQwenServe SIGINT handler', () => { + it('does not register signal handlers until the listener is up', () => { + // Sanity: we register `once` so we don't leak across test runs. + // No assertion beyond "module loads without throwing"; full lifecycle + // is covered indirectly by the loopback boot test above. + expect(typeof runQwenServe).toBe('function'); + void vi.fn(); // silence unused-import lint if vitest tree-shakes + }); +}); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts new file mode 100644 index 0000000000..456b2ffe8a --- /dev/null +++ b/packages/cli/src/serve/server.ts @@ -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[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[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[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[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 | 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 = Promise.resolve(); + const doWrite = (chunk: string): Promise => + 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 => { + 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` 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 { + const raw = req.body; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return Object.create(null) as Record; + } + const out = Object.create(null) as Record; + for (const [key, value] of Object.entries(raw as Record)) { + 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; + 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): 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 = Omit; + +/** + * 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; + if ('code' in obj) out.code = obj['code']; + if ('data' in obj) out.data = obj['data']; + } + return out; +} diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts new file mode 100644 index 0000000000..11a4b8e9c7 --- /dev/null +++ b/packages/cli/src/serve/types.ts @@ -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]; diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts new file mode 100644 index 0000000000..3983c63af9 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -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 | 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( + url: string, + init: RequestInit = {}, + consume?: (res: Response) => Promise, + ): Promise { + // 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[0]); + } + } + + // -- Plumbing ----------------------------------------------------------- + + private headers(extra: Record = {}): Record { + const out: Record = { ...extra }; + if (this.token) out['Authorization'] = `Bearer ${this.token}`; + return out; + } + + private async failOnError( + res: Response, + label: string, + ): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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[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; +} diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts new file mode 100644 index 0000000000..c463fed928 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -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'; diff --git a/packages/sdk-typescript/src/daemon/sse.ts b/packages/sdk-typescript/src/daemon/sse.ts new file mode 100644 index 0000000000..70320403e7 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/sse.ts @@ -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, + signal?: AbortSignal, +): AsyncGenerator { + 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` 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; + } +} diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts new file mode 100644 index 0000000000..29d49c0da8 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -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; + +/** 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; +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 7d841fe5aa..64d3214f10 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -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'; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts new file mode 100644 index 0000000000..27c9aadb14 --- /dev/null +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -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({ + 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; + body: string | null; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { 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 = {}; + 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((_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((_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((_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((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); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/daemon-sse.test.ts b/packages/sdk-typescript/test/unit/daemon-sse.test.ts new file mode 100644 index 0000000000..a4e44c19ba --- /dev/null +++ b/packages/sdk-typescript/test/unit/daemon-sse.test.ts @@ -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 { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(s)); + controller.close(); + }, + }); +} + +function bodyFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); +} + +async function collect( + iter: AsyncIterable, + max = 100, +): Promise { + 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` — 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({ + 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({ + 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({ + 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({ + 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('中'); + }); +}); diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 392aa54499..0006bd5427 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -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