diff --git a/docs/design/daemon-acp-http/README.md b/docs/design/daemon-acp-http/README.md new file mode 100644 index 00000000000..84f4a05ea8d --- /dev/null +++ b/docs/design/daemon-acp-http/README.md @@ -0,0 +1,568 @@ +# Daemon ACP-over-HTTP → Official ACP Streamable HTTP Transport + +> Targets `daemon_mode_b_main`. Branch: `feat/daemon-acp-http-streamable`. +> Author: arnoo.gao. Date: 2026-05-24. Status: **Design v1 → implementation**. +> Design-first per repo workflow: this doc lands before/with the implementation PR so the wire contract is reviewable. + +--- + +## 0. TL;DR + +The daemon (`qwen serve`) today speaks a **bespoke REST + SSE** dialect to web/SDK +clients, while speaking **real ACP JSON-RPC over stdio** to the spawned `qwen --acp` +child. This proposal adds a **second northbound transport** that implements the +**official ACP Streamable HTTP transport** (RFD #721) at a single `/acp` endpoint, +so any ACP-native client (Zed, Goose, future SDKs) can drive the daemon directly +over the standard protocol — no qwen-specific REST knowledge required. + +**Decision: dual-transport, additive.** The new `/acp` endpoint is mounted +alongside the existing REST surface, reusing the same `HttpAcpBridge` + +`EventBus` underneath. The REST API is *not* removed. Rationale in §6. + +**Decision: extension namespace = `_qwen/…`** (single-underscore prefix, the +ACP-spec-reserved form for custom methods) for daemon features that have no +standard ACP method (model switch, workspace introspection, heartbeat, +multi-client permission policy, SSE backpressure tuning). Rationale in §5. + +A complete, locally-runnable reference implementation ships in this PR +(`packages/cli/src/serve/acpHttp/`) plus a verification harness +(`scripts/acp-http-smoke.mjs`). + +--- + +## 1. Background — what "ACP over HTTP" means today + +Three tiers (verified at commit `0c0430939`): + +``` +┌──────────────┐ bespoke REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐ +│ web / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │ +│ client │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ child (Agent)│ +│ (ACP client) │ (text/event-stream) │ (daemon) │ ndJsonStream │ │ +└──────────────┘ └────────────┘ └──────────────┘ + northbound: NOT ACP wire bridge southbound: real ACP +``` + +### 1.1 Northbound (client ↔ daemon) — bespoke, today + +- Express 5 app in `packages/cli/src/serve/server.ts` (~30 routes). +- Discrete REST verbs, **not** JSON-RPC: + - `POST /session` (create), `POST /session/:id/prompt`, `POST /session/:id/cancel`, + `POST /session/:id/load|resume`, `POST /session/:id/model`, + `POST /session/:id/permission/:requestId`, `POST /session/:id/heartbeat`, + `DELETE /session/:id`, plus `/workspace/*`, `/capabilities`, `/health`. +- Server→client streaming: `GET /session/:id/events` → `text/event-stream`. + - Frames: `id: \nevent: \ndata: \n\n` (`server.ts:formatSseFrame`, ~2626). + - Per-session **monotonic `id`** + `Last-Event-ID` resume backed by a + ring-buffer `EventBus` (`acp-bridge/src/eventBus.ts`). + - Event `type`s: `session_update`, `client_evicted`, `slow_client_warning`, + `state_resync_required`, `stream_error`, … +- Auth: `Authorization: Bearer ` (`serve/auth.ts`), CORS deny + host allowlist. +- Backpressure: per-connection serialized write chain + 15 s heartbeat comments. + +### 1.2 Southbound (daemon ↔ child) — already ACP + +- `acp-bridge/src/spawnChannel.ts` spawns `qwen --acp`, wraps stdin/stdout with + `ndJsonStream` from `@agentclientprotocol/sdk` (`^0.14.1`). +- `acp-bridge/src/bridge.ts:729` `new ClientSideConnection(() => client, channel.stream)` + — the daemon is the ACP **client**, the child is the ACP **agent**. +- Extension methods already in use on this leg: `unstable_setSessionModel`, + `unstable_resumeSession`, `unstable_listSessions` (`acp-integration/acpAgent.ts`). + +### 1.3 Why migrate the northbound + +- Every client (webui, TS SDK, Java SDK, Python SDK, VSCode companion) re-implements + the bespoke REST mapping. An ACP-standard endpoint lets ACP-native editors attach + with zero qwen-specific glue. +- Aligns the daemon's remote surface with the protocol it already speaks internally. + +--- + +## 2. Target: ACP Streamable HTTP (RFD #721) + +Merged **Draft** RFD (`agentclientprotocol/agent-client-protocol#721`, merged 2026-04-22). +Not yet normative; not yet in any SDK. We implement against the RFD wire design. + +### 2.1 Endpoint & verbs (single `/acp`) + +| Verb | Behavior | +|------|----------| +| `POST /acp` | Send JSON-RPC. `initialize` → **`200`** + JSON body (capabilities) and sets `Acp-Connection-Id`. All other requests/notifications → **`202 Accepted`**, empty body; the *response* (if any) is delivered on the matching long-lived SSE stream. | +| `GET /acp` | Open a long-lived **SSE** stream. (`Upgrade: websocket` → WebSocket; **deferred**, see §7.) | +| `DELETE /acp` | Terminate the connection → `202`. | + +### 2.2 Two-tier long-lived streams + +- **Connection-scoped stream**: `GET /acp` with header `Acp-Connection-Id`, no session + header. Carries connection-level responses (`session/new`, `session/load`, + `authenticate`) and connection-level notifications. +- **Session-scoped stream**: `GET /acp` with `Acp-Connection-Id` **and** `Acp-Session-Id`. + Carries `session/update` notifications, **agent→client requests** + (`session/request_permission`, `fs/read_text_file`, …), and responses to + session POSTs (`session/prompt`, `session/cancel`). + +### 2.3 Identity (3 layers) + +- `Acp-Connection-Id` (HTTP header) — transport binding, minted at `initialize`. +- `Acp-Session-Id` (HTTP header) — required on session-scoped GET + session POSTs. +- `sessionId` (JSON-RPC param) — inside method params (must match the header). + +### 2.4 Divergences from MCP StreamableHTTP + +ACP uses **long-lived** streams (not per-request SSE), **two** ID headers (connection +vs session), `202`-for-non-initialize, HTTP/2-required, WebSocket-required-client. We +borrow the single-endpoint + POST/GET-SSE + session-header skeleton but adapt to the +long-lived dual-ID model. We do **not** reuse `@modelcontextprotocol/sdk`'s +`StreamableHTTPServerTransport` (its per-request stream model and single +`Mcp-Session-Id` don't fit). + +### 2.5 Standard methods (confirmed from current schema) + +- Client→Agent requests: `initialize`, `authenticate`, `session/new`, `session/load`, + `session/prompt`, `session/resume`, `session/close`, `session/list`, + `session/set_mode`, `session/set_config_option`, `logout`. +- Client→Agent notification: `session/cancel`. +- Agent→Client requests: `fs/read_text_file`, `fs/write_text_file`, + `session/request_permission`, `terminal/create|output|wait_for_exit|kill|release`. +- Agent→Client notification: `session/update`. + +--- + +## 3. Architecture of the new transport + +The daemon must present an **ACP Agent surface over HTTP** northbound, while it +remains an ACP **client** to the child southbound. The `/acp` layer is therefore a +**JSON-RPC router** that terminates the HTTP transport and bridges into the existing +`HttpAcpBridge`. + +``` + POST /acp (JSON-RPC requests/responses/notifs) +client ──────────────────────────────────────────────► ┌───────────────────────────┐ +(editor) │ AcpHttpTransport │ + ◄── GET /acp (connection-scoped SSE) ────────── │ - connection registry │ + ◄── GET /acp (session-scoped SSE) ───────────── │ - JSON-RPC id correlation│ + │ - method dispatch │ + └────────────┬──────────────┘ + │ reuses + ┌────────────▼──────────────┐ + │ HttpAcpBridge + EventBus │ (unchanged) + └────────────┬──────────────┘ + │ ACP stdio (unchanged) + qwen --acp child +``` + +### 3.1 New module layout (`packages/cli/src/serve/acpHttp/`) + +| File | Responsibility | +|------|----------------| +| `index.ts` | `mountAcpHttp(app, bridge, opts)` — registers `/acp` routes on the existing Express app. | +| `connectionRegistry.ts` | `Acp-Connection-Id` → `AcpConnection` (connection SSE writer, `Map`, pending agent→client requests by JSON-RPC id, monotonic id allocator). TTL + DELETE cleanup. | +| `jsonRpc.ts` | JSON-RPC 2.0 parse/validate/serialize helpers; error codes (`-32600` etc.); `_qwen/` namespace guard. | +| `dispatch.ts` | Maps inbound JSON-RPC methods → `HttpAcpBridge` calls. Maps `BridgeEvent`s → outbound JSON-RPC frames. The translation table (§4). | +| `sseStream.ts` | Long-lived SSE writer (reuses the backpressure/heartbeat pattern from `server.ts`). Distinct from REST `/events` (different framing: full JSON-RPC objects, not qwen event envelopes). | + +No change to `bridge.ts` / `eventBus.ts` (additive consumer only). + +### 3.2 Connection & session lifecycle + +1. `POST /acp {initialize}` → mint `connectionId`, create `AcpConnection`, reply `200` + with `{protocolVersion, agentCapabilities, _meta:{qwen:{…}}}` + `Acp-Connection-Id` header. +2. Client opens `GET /acp` (connection-scoped) carrying `Acp-Connection-Id`. +3. `POST /acp {session/new}` → `202`; daemon calls `bridge.createSession(...)`; pushes + the JSON-RPC response (with `sessionId`) down the **connection** stream. +4. Client opens `GET /acp` (session-scoped) with `Acp-Connection-Id`+`Acp-Session-Id`; + daemon `bridge.subscribeEvents(sessionId)` and pipes translated frames. +5. `POST /acp {session/prompt}` → `202`; `bridge.sendPrompt(...)`; `session/update` + notifications stream live on the session stream; the final prompt **response** + (`{id, result:{stopReason}}`) is pushed on the session stream when it settles. +6. Agent→client request (e.g. `session/request_permission`) is emitted as a JSON-RPC + **request** on the session stream with a daemon-allocated id; the client answers via + `POST /acp {id, result}`; `dispatch` resolves it through the bridge's permission API. +7. `DELETE /acp` (or connection-stream close + TTL) tears down sessions/subscriptions. + +--- + +## 4. Translation table (bridge ⇄ ACP/HTTP) + +### 4.1 Inbound (client POST → bridge) + +| ACP method | Bridge call | Response routed to | +|------------|-------------|--------------------| +| `initialize` | (none; capabilities from `capabilities.ts`) | inline `200` | +| `authenticate` | existing auth provider (`serve/auth/*`) | connection stream | +| `session/new` | `bridge.createSession` | connection stream | +| `session/load` / `session/resume` | `bridge.restoreSession('load'|'resume')` | connection stream | +| `session/prompt` | `bridge.sendPrompt` | session stream (deferred until settle) | +| `session/cancel` (notif) | `bridge.cancel` | — | +| `session/list` | `bridge.listSessions` (`unstable_listSessions`) | connection stream | +| `session/set_mode` | approval-mode route logic | session stream | +| JSON-RPC **response** (to agent→client req) | resolve pending (`§4.3`) | — | +| `_qwen/session/set_model` | `bridge.setSessionModel` (`unstable_setSessionModel`) | session stream | +| `_qwen/workspace/list` etc. | workspace introspection routes | connection stream | +| `_qwen/session/heartbeat` | `bridge.heartbeat` | connection stream | + +### 4.2 Outbound (BridgeEvent → JSON-RPC on session stream) + +| BridgeEvent.type | Emitted as | +|------------------|-----------| +| `session_update` | `{method:"session/update", params:}` notification | +| permission request | `{id:, method:"session/request_permission", params}` request | +| `client_evicted` / `slow_client_warning` / `state_resync_required` | `{method:"_qwen/notify", params:{kind,…}}` notification | +| `stream_error` | JSON-RPC error response on the active prompt id (or `_qwen/notify`) | +| prompt settle | `{id:, result:{stopReason}}` | + +### 4.3 Pending agent→client requests + +`AcpConnection` keeps `Map`. +When the client POSTs a JSON-RPC response object, `dispatch` matches `id`, then calls the +bridge resolution path (e.g. permission `POST /session/:id/permission/:requestId` +internal equivalent). + +> **v1 status:** only the `session/request_permission` agent→client round-trip is +> implemented. `fs/*` and `terminal/*` agent→client forwarding is **deferred** (§7) — the +> daemon does not yet advertise `fs`/`terminal` client-capability negotiation on `/acp`, +> so ACP clients should not assume filesystem/terminal semantics over this transport in +> v1. The intended end state (forward `fs/*` to the client; fall back to the daemon's +> workspace FS when the client lacks the `fs` capability) is the follow-up described in §7. + +--- + +## 5. Extension strategy (requirement #2) + +ACP reserves any method starting with `_` for custom extensions and provides `_meta` +on every type. The codebase's southbound leg already uses `unstable_*` method names. + +**Northbound choice:** vendor-namespaced **`_qwen//`** method names +(spec-compliant `_` prefix). Capabilities advertised under +`agentCapabilities._meta.qwen` at `initialize` so clients feature-detect before use. + +| Need | No standard ACP method? | Extension | +|------|------------------------|-----------| +| Model switch | yes | `_qwen/session/set_model` | +| Workspace MCP/skills/providers/env introspection | yes | `_qwen/workspace/list`, `_qwen/workspace/` | +| Heartbeat / last-seen | yes | `_qwen/session/heartbeat` | +| Multi-client permission policy (consensus/designated) | partial | `session/request_permission` + `_meta.qwen.policy` | +| SSE backpressure tuning (`maxQueued`) | yes | `Acp-Qwen-Max-Queued` header on session GET | +| Resume cursor (ring `Last-Event-ID`) | RFD Phase 4 | `Last-Event-ID` header + `_meta.qwen.eventId` on frames | + +Standard methods are **never** renamed; extensions are strictly additive and ignorable. + +--- + +## 6. Dual-transport vs. replace (requirement #4) + +**Decision: dual-transport (additive).** + +- The official transport is a **Draft** RFD, not normative, and absent from every SDK — + hard-replacing would couple us to an unratified design and break webui + 3 SDKs + + VSCode companion at once. +- The REST surface carries features with no clean ACP mapping yet (workspace + introspection, multi-client permission mediation, ring-buffer resume, capability + registry). Those degrade to `_qwen/*` extensions on `/acp` but the REST surface stays + authoritative until the RFD ratifies. +- Both transports share **one** `HttpAcpBridge` + `EventBus` instance, so there is no + state duplication — `/acp` and `/session/*` can even drive the same live session + concurrently (multi-client is already supported by the bridge). +- Toggle (v1, shipped): on by default; **`QWEN_SERVE_ACP_HTTP=0`** disables the mount. A + `--no-acp-http` CLI flag and an `acp_http` tag in `/capabilities` for client feature- + detection are **deferred** to a follow-up (not in v1) — until then clients detect the + transport by probing `POST /acp {initialize}`. + +Migration path: once the RFD ratifies and SDKs ship, REST routes can be reframed as a +thin compat shim over `/acp` (separate, later PR). + +--- + +## 7. Scope of the implementation PR + +**In scope (runnable + verified locally):** +- `POST /acp` dispatch for `initialize`, `session/new`, `session/prompt`, + `session/cancel`, `session/load`, JSON-RPC response handling. +- Connection-scoped + session-scoped `GET /acp` SSE streams with JSON-RPC framing. +- `session/update` streaming + final prompt response correlation. +- `session/request_permission` agent→client round-trip. +- `_qwen/session/set_model` extension as the worked example of #2. +- Bearer-auth + host allowlist reuse (same middleware as REST). +- Unit tests (`acpHttp/*.test.ts`) + a black-box smoke script driving a real daemon. + +**Deferred (documented, not built now):** +- WebSocket upgrade path (RFD-required client cap; SSE suffices for local verify). +- HTTP/2 multiplexing (we run HTTP/1.1; POST and long-lived GET use separate sockets, + which works for CLI/Node clients and ≤6-connection browsers). Documented divergence. +- Full `fs/*` + `terminal/*` agent→client forwarding (permission path proves the + mechanism; rest is mechanical follow-up). +- SSE resumability hardening parity with the ring buffer (Phase 4 in RFD). + +--- + +## 8. Local verification plan + +1. `npm run build` (or workspace build of `cli` + `acp-bridge`). +2. Start daemon: `qwen serve --listen 127.0.0.1:0 --token ` (or env token). +3. Run `node scripts/acp-http-smoke.mjs`: + - `POST /acp {initialize}` → assert `200` + `Acp-Connection-Id`. + - Open connection SSE; `POST {session/new}` → assert response on stream. + - Open session SSE; `POST {session/prompt:"say hi"}` → assert ≥1 `session/update` + then a final `{result:{stopReason}}`. + - Trigger a tool needing permission → assert `session/request_permission` request, + POST a grant response → assert prompt completes. + - `POST {_qwen/session/set_model}` → assert model switch + `session/update`. +4. Vitest: `acpHttp/*.test.ts` green. + +--- + +## 9. Risks + +| Risk | Mitigation | +|------|-----------| +| RFD changes before ratification | Behind capability tag + `_qwen` namespace; isolated module; easy to revise. | +| HTTP/1.1 vs required HTTP/2 | Localhost/CLI clients unaffected; documented; h2 is a transport swap later. | +| Two transports on one bridge race | Bridge already supports multi-client; reuse its locking. | +| `fs/*` forwarding vs daemon-local FS | Capability-gated: forward when client declares `fs`, else local. | + +--- + +## 10. Implementation & verification log (v1) + +Implemented in `packages/cli/src/serve/acpHttp/` (`jsonRpc.ts`, `sseStream.ts`, +`connectionRegistry.ts`, `dispatch.ts`, `index.ts`), mounted from `server.ts` +via `mountAcpHttp(app, bridge, { boundWorkspace })`. + +### Automated (`packages/cli/src/serve/acpHttp/*.test.ts`) + +`transport.test.ts` boots a real Express server + the real `mountAcpHttp` over +a controllable fake bridge and drives it with `fetch` + manual SSE parsing. +15 tests green, covering: `initialize` 200 + `Acp-Connection-Id`; unknown-conn +400; `session/new` reply on the connection stream; prompt → `session/update` +stream + final result correlation; `session/request_permission` agent→client→ +agent round-trip; `_qwen/session/set_model`; method-not-found; `DELETE` teardown. + +### Live daemon (real model) + +Booted `qwen serve --port 8767 --token … --workspace …` (bundle entry so the +spawned `qwen --acp` child is self-contained) and ran `scripts/acp-http-smoke.mjs`: + +``` +✓ initialize: connectionId=… protocolVersion=1 +✓ session/new: sessionId=… +→ prompt: "Reply with the single word: pong" +pong +✓ prompt complete: 10 session/update frames, stopReason=end_turn +✓ DELETE /acp — connection closed +ALL CHECKS PASSED ✅ +``` + +Error-path was also confirmed live: when the child failed to start, the bridge +timeout surfaced to the client as a JSON-RPC error frame on the connection +stream (`{"id":2,"error":{"code":-32603,…}}`), proving id-correlation + the +202/SSE split under failure. + +### Review fold-in — bridge-issued clientId (found in live verify) + +First live run failed `session/prompt` with *"client id … is not registered for +session"*. Root cause: `spawnOrAttach`/`loadSession` **ignore** a caller-supplied +clientId the bridge has never issued and stamp a fresh one (returned in +`BridgeSession.clientId`); the dispatcher was echoing the connection's own +(unregistered) id on `sendPrompt`. Fix: persist the bridge-stamped id on the +`SessionBinding` and echo it on every per-session call (`sessionCtx`). Re-verified +green above. + +--- + +## 11. Review round 2 — fold-ins + +Two independent reviews (correctness/concurrency + protocol-conformance/security) plus a self-read. +All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live smoke run +(21 `session/update` frames → `stopReason=end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| R1 | **P0** | Session-stream **reconnect was permanently dead**: `SessionBinding.abort` was created once and reused; on stream close it was aborted forever, so a reconnect's `subscribeEvents(signal)` got an already-aborted signal and received zero events. | `attachSessionStream` now installs a **fresh** `AbortController` per stream (and closes any prior stream); `index.ts` pumps on that fresh signal. | +| R2 | **P0** | `await dispatcher.handle()` ran **after** `res.end(202)`; a throwing bridge call (notably the un-try/caught `isResponse` path) would reject and surface as an unhandled rejection → possible daemon crash. | Wrapped the `isResponse` path in try/catch; `.catch()` on the awaited `handle(...)` and on `pumpSessionEvents(...)`. | +| R3 | **P1** | **No connection→session ownership**: any authenticated connection could open the session SSE for, or prompt, *any* sessionId in the workspace (read-eavesdrop; prompt was only blocked incidentally by the unregistered-clientId error). | `AcpConnection.ownedSessions` populated by `session/new`/`load`/`resume`; session stream returns `403` and per-session POSTs return `INVALID_PARAMS` for unowned ids (`requireOwned`). | +| R4 | **P1** | `mountAcpHttp` handle was discarded → TTL sweep timer + live SSE streams leaked on shutdown. | Handle parked on `app.locals`; `runQwenServe` close hook calls `dispose()` before `bridge.shutdown()` (mirrors the device-flow registry). | +| R5 | **P1** | **Pending permission leak**: closing a session/connection with a permission outstanding left the bridge blocked awaiting a vote. | `closeSessionStream`/`destroy` cancel matching pending requests via an injected `onAbandonPending` → `cancelAbandonedPermission`. | +| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Capped at 256 frames (drop-oldest), matching the EventBus `maxQueued`. | +| R7 | **P2** | `initialize` ignored the client's requested `protocolVersion`. | Negotiates `min(requested, 1)`. | +| R8 | **P2** | No `Acp-Session-Id` ↔ `params.sessionId` cross-check (RFD §2.3). | POST asserts they agree; mismatch → `INVALID_PARAMS`. | +| R9 | **P2** | `session/cancel` request-form (with id) never answered; duplicate top-level `_meta.qwen`. | Reply when an id is present; single `agentCapabilities._meta.qwen`. | + +### Accepted / documented (not fixed in v1) + +- **Prompt-result vs trailing `session/update` ordering** (P2): `handlePrompt` awaits `sendPrompt` then + writes the result frame, while updates stream concurrently. In practice the bridge publishes all + `session/update`s to the bus before `sendPrompt` resolves and both share one ordered SSE write + chain, so the result lands last (confirmed: 21 updates then result). A strict barrier is a possible + later hardening if a client reducer proves sensitive. +- **Browser `EventSource` can't set `Authorization`** — `/acp` GET streams require the bearer header, + so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected. +- The daemon's real trust boundary remains the **bearer token + single-workspace bind** (same as the + REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary. + +--- + +## 12. Review round 3 — PR bot fold-ins (#4472) + +Two automated PR reviewers plus the summary bot. +All fixes verified by the suite (now **22 tests**) + a fresh live run (16 `session/update` → `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| B1 | **P0** | `handlePrompt`'s `AbortController` was never aborted — a disconnecting/cancelling client left the agent running (burned model quota, blocked the session FIFO). Flagged by both bots + 5 sub-agents. | `promptAbort` parked on `SessionBinding`; aborted by `session/cancel` and by session/connection teardown (`closeSessionStream`/`destroy`). | +| B2 | **P0** | `sessionCtx` missing `fromLoopback` → every ACP permission vote treated as remote; `local-only` policy would reject loopback clients. | Capture loopback at `initialize` (kernel `remoteAddress`, not forgeable headers) → `AcpConnection.fromLoopback` → threaded through `sessionCtx`. | +| B3 | **P0** | SSE write failures silently swallowed → zombie streams (heartbeats fire, zero events delivered, no logs). | First write failure logs + closes the stream. | +| B4 | **P0** | Idle sweep destroyed connections with no log + no connection cap (initialize-flood). | Sweep logs each reap; `pumpSessionEvents` calls `touch()` (long quiet prompts aren't reaped); `maxConnections` cap (64) → `503`. | +| B5 | **P1** | `sessionCtx` silently fell back to the connection's unregistered clientId when the binding lacked one (untested, always-fired in `FakeBridge`). | Throw on missing stamped clientId (invariant violation); `FakeBridge` now stamps one. | +| B6 | **P1** | `session/new|load|resume` accepted `cwd` unvalidated (REST validates string/length/absolute — amplification DoS). | Shared `parseOptionalWorkspaceCwd` (string, ≤4096, absolute). | +| B7 | **P1** | `session/prompt` forwarded an unvalidated `prompt` to the bridge. | `validatePrompt` (non-empty array of objects), mirroring REST. | +| B8 | **P1** | Raw bridge error messages echoed to the client. | `toRpcError` maps known bridge errors to coded, client-safe shapes; unknown → generic `Internal error` (full detail still to stderr). | +| B9 | **P1** | `nextId` used sequential negatives — a client legally using negative ids could collide in `pending`. | Daemon-originated ids are now strings (`_qwen_perm_N`), disjoint from any client id. | +| B10 | **P2** | `resolveClientResponse` param type excluded `JsonRpcError`; conn-scoped SSE stream had no `onClose`; `DELETE` with no header was a silent 202; `SseStream.close` ran `onClose` outside try/catch; `session/load`·`resume`·`close` untested. | Widened param to `JsonRpcResponse`; conn stream logs on close; `DELETE` missing header → `400`; `onClose` wrapped in try/catch; added load/resume/close + DELETE-400 tests. | + +**Out of scope (base-branch `daemon_mode_b_main`, not this diff)** — the second reviewer flagged +typecheck errors in `acpAgent.ts` (`entryCount`/`entrySummary`/`sessionClose`) and other pre-existing +items it explicitly attributed to the base branch (introduced by #4353). Tracked separately; not +touched here. + +**Still deferred** (documented): per-connection secret for `DELETE`/connection ownership (token remains +the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11). + +--- + +## 13. Review round 4 — PR fold-ins (rebased onto #4469) + +Branch rebased onto `daemon_mode_b_main` (#4353 + #4469) — **clean, no conflicts**. Two PR +reviewers (GPT-5 + qwen3.7-max). Suite now **25 tests**; live re-verified (125 `session/update` +→ `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| C1 | **P0** | Round-3 "SSE write-failure handling" was documented but NOT implemented — `SseStream` still left it to discarding callers (zombie streams). | `writeRaw` now owns it: first write rejection logs once + `close()`s; `doWrite` also listens for `'error'` (rejects promptly instead of hanging to `'close'`); `onClose` wrapped in try/catch. | +| C2 | **P1** | `fromLoopback` captured only at `initialize` + helper narrower than REST → `local-only` votes from a later POST misjudged. | Per-request loopback threaded through `handle`→`sessionCtx`/`resolveClientResponse`; `isLoopbackReq` widened to `127.0.0.0/8` + `::ffff:127.*` + `::1` (matches REST). | +| C3 | **P1** | Error routing inferred stream from `params.sessionId` → conn-scoped method failures (`session/load`/`resume`/`close`/`heartbeat`) misrouted to a non-existent session stream (silent loss). | `CONN_ROUTED_METHODS` set; errors route the same way as the success path. | +| C4 | **P1** | `bridge.detachClient` never called on teardown → stale bridge-stamped client ids linger in `knownClientIds()`/voter sets. | Registry takes a `DetachSessionFn`; `closeSessionStream`/`destroy` detach each owned session (best-effort). | +| C5 | **P1** | `session/close` skipped local cleanup if `bridge.closeSession` threw. | `closeSessionStream` moved into a `finally`. | +| C6 | **P2** | Windows `cwd` (`C:\…`) rejected by `startsWith('/')`. | `path.isAbsolute` (platform-aware), matching REST. | +| C7 | **P2** | `protocolVersion` could negotiate `0`/negative. | Clamp `Math.max(1, Math.min(requested, 1))`; tests for 0/neg/huge/invalid. | +| C8 | **P2** | `session/load`/`resume` accepted empty `sessionId`. | Reject empty with `INVALID_PARAMS`. | +| C9 | **P2** | Notification-form `session/prompt` errors vanished silently. | Log on the no-id path. | +| C10 | **P2** | Session SSE flushed buffered frames before headers/`retry:`. | `open()` before `attachSessionStream`. | +| C11 | **P2** | Duplicate local `logStderr`. | Shared `writeStderrLine` from `utils/stdioHelpers`. | +| C12 | **P2** | Docs advertised `--no-acp-http` flag, `acp_http` capability tag, and `fs/*` forwarding not in v1. | Doc aligned to shipped surface (env-var toggle only; `fs/*`+`terminal/*` + flag + tag marked deferred). | + +Still deferred (unchanged): WebSocket + HTTP/2; per-connection secret for `DELETE`/ownership +(token + single-workspace remains the boundary); strict prompt-result ordering barrier; the +`as never` bridge-boundary casts (targeted, noted for an adapter-types follow-up). + +--- + +## 14. Review round 5 — PR fold-ins + +One more reviewer pass (qwen3.7-max). Suite **26 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| D1 | **P0** | `resolveClientResponse` deleted the pending entry BEFORE calling `respondToSessionPermission`. A malformed vote (`result: {}`) makes the bridge mediator throw — and with the pending entry already gone, teardown's `abandonPendingForSession` can't cancel it, so the agent's prompt hangs on a vote that never resolves (a token-holder could stall a session with one bad POST). | Wrap the vote in try/catch; on any failure fall back to `cancelAbandonedPermission` so the mediator is always released. New test covers the malformed-vote path. | +| D2 | **P1** | Session-stream `onClose` aborted only the event pump, not `binding.promptAbort` — a client disconnect (tab close / network drop) left the in-flight prompt running (quota + FIFO) until idle TTL. | `onClose` now also aborts the session's `promptAbort`. | +| D3 | **P1** | When `pumpSessionEvents` rejected, the `.catch` only logged — the SSE stream stayed open heartbeating but delivering nothing (zombie, no reconnect signal). | `.catch` now also `closeSessionStream(sessionId)`. | + +--- + +## 15. Review round 6 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **28 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| E1 | **P0** | `handlePrompt` overwrote `binding.promptAbort` without aborting the prior controller — two concurrent `session/prompt`s for one session orphaned the first (runs to completion in the bridge FIFO, unabortable by `session/cancel`). | Abort the prior `promptAbort` before installing the new one. Test added. | +| E2 | **P0** | The `subscribeEvents`-throws path sent a `stream_error` notify then `return`ed (resolved) — the caller's `.catch` never fired, leaving a zombie SSE stream (heartbeats, no events, no reconnect signal). | Re-throw after the notify so the caller's `.catch` closes the stream. Test asserts prompt closure. | +| E3 | **P1** | SSE heartbeat didn't mark the connection active — a long prompt with no intermediate events for >30 min got idle-reaped (streams + prompts killed). | `SseStream` takes an `onHeartbeat` hook; both GET handlers pass `() => conn.touch()`. | +| E4 | **P2** | `pumpSessionEvents` `.catch` closed by sessionId — a reconnect between the throw and the microtask could kill the NEW stream. | Identity-guard: only close if `binding.stream` is still this stream. | +| E6 | **P2** | `sendSession` auto-created a binding — a late pump/reply frame after `closeSessionStream` resurrected a ghost binding that buffered up to 256 frames forever. | `sendSession` is now lookup-only: drops frames when the session has no live binding. | +| E5 | accepted | `session/load`/`resume` don't reject when another live connection owns the session ("hijack"). | **Accepted, not changed:** the daemon's trust boundary is the bearer token + single-workspace bind, and multi-client attach is intentional (the bridge is multi-client by design; REST has the same property). A token-holder gains no capability they lack via REST. Tracked with the other token-boundary items (DELETE ownership, §13). | + +--- + +## 16. Review round 7 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **30 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| F1 | **P0** | Concurrent `session/close` TOCTOU: `ownedSessions.delete` ran only in `finally` (after the await), so two concurrent closes both passed `requireOwned` → misleading error to the 2nd + redundant bridge close. | Delete the ownership gate SYNCHRONOUSLY before the await; bridge close runs once. Test added. | +| F2 | **P1** | Pump lifecycle: a CLEAN iterator end (subprocess ended, `done`) resolved → the `.catch` never fired → zombie stream; and a MID-STREAM iterator error sent no `stream_error`. | `pumpSessionEvents` wraps the whole loop (sync + mid-stream errors send `stream_error` then re-throw); the consumer `.then(onDone, onErr)` closes the stream on BOTH paths (identity-guarded). Tests added. | +| F3 | **P2** | 503 connection-cap rejection had no stderr log. | `writeStderrLine` with the cap value. | +| F4 | **P2** | `_qwen/notify stream_error` spread let `event.data.kind` shadow the discriminator. | Spread first, then `kind: 'stream_error'`. | +| F5 | **P2** | `MAX_WORKSPACE_PATH_LENGTH` redeclared (`= 4096`) vs the canonical `fs/paths.js`. | Import from `../fs/paths.js` (no divergence). | +| F6 | **P2** | `isObjectParams` duplicated `jsonRpc.isObject`. | Import `isObject`. | +| F7 | **P2** | Raw `process.stderr.write` in `index.ts`/`sseStream.ts` vs `writeStderrLine` elsewhere. | Unified on `writeStderrLine` across the module. | + +--- + +## 17. REST 等价对齐 + 扩展方案审计落地(round 8) + +目标:让 `/acp` 成为 REST+SSE 的**等价替代**。本批基于审计结论重构扩展方案,并补齐**所有 bridge 已暴露**的能力;bridge 尚未拥有的能力(文件 I/O、设备流、agents/memory CRUD)按架构正确性要求**先由 acp-bridge 补齐**(见 §17.3)。 + +### 17.1 扩展方案审计 → 落地(替换 §5 的旧方案) + +依据**仓库实装 SDK `@agentclientprotocol/sdk@0.14.1`**(非仅官网)核对: +- `session/set_config_option` 是**一等(非 `unstable_`)方法**,请求 `{sessionId, configId, value}`,`category` 含 `model`/`mode`/`thought_level`;而 `set_model` 仍走 `unstable_setSessionModel`。 +- 规范保留 `_` 前缀给扩展,示例为域风格 `_zed.dev/…`;厂商数据放 `_meta` 按域名分键。 + +落地: +- **命名空间 `_qwen/` → 反向域名 `_qwen/`**;`_meta` 统一 `_meta:{ "qwen": … }`(含 `initialize` 能力广告与 `session/request_permission` 的 requestId)。 +- **模型 + 审批模式 → 标准 `session/set_config_option`**(`configId:"model"|"mode"`),路由到现有 `bridge.setSessionModel`/`setSessionApprovalMode`;`session/new` 结果**广告 `configOptions`**(取自子进程会话状态 `getSessionContextStatus().state.configOptions`,已是 ACP 形状)。**删除**厂商 `_qwen/session/set_model`。 +- REST(http+sse) **无需同步修改**:两 transport 共用同一 bridge,状态天然一致。 + +### 17.2 本批新增的 `/acp` 方法(bridge 已支持,1:1 对齐 REST) + +| REST | `/acp` | bridge | +|---|---|---| +| `POST /session/:id/model` / `approval-mode` | **标准** `session/set_config_option`(model/mode) | setSessionModel / setSessionApprovalMode | +| `GET /session/:id/context` | `_qwen/session/context` | getSessionContextStatus | +| `GET /session/:id/supported-commands` | `_qwen/session/supported_commands` | getSessionSupportedCommandsStatus | +| `PATCH /session/:id/metadata` | `_qwen/session/update_metadata` | updateSessionMetadata | +| `GET /workspace/{mcp,skills,providers,env,preflight}` | `_qwen/workspace/{…}` | getWorkspace*Status | +| `POST /workspace/init` | `_qwen/workspace/init` | initWorkspace | +| `POST /workspace/tools/:name/enable` | `_qwen/workspace/set_tool_enabled` | setWorkspaceToolEnabled | +| `POST /workspace/mcp/:server/restart` | `_qwen/workspace/restart_mcp_server` | restartMcpServer | + +(既有:session/new·load·resume·close·list·prompt·cancel、heartbeat、permission、events 已对齐。) + +### 17.3 仍缺口 → 要求 acp-bridge 先补齐(架构正确性) + +REST 的 **文件 I/O**(`/file /glob /list /stat /file/write /file/edit`)、**设备流登录**(`/workspace/auth/*`)、**agents CRUD**(`/workspace/agents`)、**memory CRUD**(`/workspace/memory`)目前**不在 `HttpAcpBridge` 上**——REST 路由直接调 route 级服务(`WorkspaceFileSystemFactory`、`DeviceFlowRegistry`、`SubagentManager`、`writeWorkspaceContextFile`),绕过了 bridge。 + +**决策(采纳评审/owner 意见)**:不让 `/acp` transport 再去直连这些 route 级服务(那会复制 REST 的架构漂移、并使 transport 耦合翻倍)。**正确做法是先在 `@qwen-code/acp-bridge` 的 `HttpAcpBridge` 上补齐这些能力**(如 `readWorkspaceFile`/`writeWorkspaceFile`/`globWorkspace`、`startDeviceFlow`/`pollDeviceFlow`、`listAgents`/`upsertAgent`/`deleteAgent`、`readMemory`/`writeMemory`),让 REST 与 `/acp` 都经由 bridge。届时 `/acp` 再加 `_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*`(文件读因无标准 ACP client→agent 方法,属合法厂商扩展)。 + +**完整等价 = 本批(bridge 已有能力)+ acp-bridge 补齐缺口后的后续批**。 + +--- + +## 18. Review round 9 — PR fold-ins + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| G1 | **P1 (regression)** | Session-stream reconnect aborted the in-flight prompt: `attachSessionStream` closed the OLD stream before installing the new one, and the old stream's `onClose` unconditionally aborted `promptAbort` — so a reconnecting client (network glitch/roaming) lost its running prompt. | Install the new stream BEFORE closing the old; identity-guard `onClose`'s prompt-abort (only abort if THIS is still the session's live stream). Test added (prompt survives reconnect). | +| G2 | **P2** | `session/cancel` passed `undefined` as the `CancelNotification` body, dropping client-supplied cancel fields (reason/context) that REST forwards. | Forward `{ ...params, sessionId }` (mirrors REST). | + +Rebased onto latest `daemon_mode_b_main` (#4473/#4483/#4484/#4500), no conflicts. Suite **33 tests**, live re-verified. + +--- + +## 19. 路线图 / 后续 PR(防遗忘) + +本 PR(#4472)= ACP Streamable HTTP transport + **全部 bridge-backed 能力对齐** + 官方扩展方案。已转 **ready**。达到「`/acp` 完全等价 REST+SSE」尚需: + +1. **Follow-up PR 1 — acp-bridge 能力补齐(前置 / bridge-first)**:`HttpAcpBridge` 新增 文件 I/O、设备流、agents CRUD、memory CRUD 方法;REST 路由改走 bridge(消除直连 route 级服务的漂移)。 +2. **Follow-up PR 2 — `/acp` 剩余对齐(依赖 PR 1)**:`_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*` → 完全等价 REST。 + +跟踪:#3803(open decisions)、#4175(Mode B roadmap)均已 comment。 +Deferred 硬化项见 PR 描述「已知 deferred」。 + +--- + +## 20. Extension-namespace rename + SDK-transport analysis (round 11) + +- **Namespace `_qwen.ai/` → `_qwen/`**: ACP's only hard rule is the leading `_`; the `_zed.dev/` domain segment is convention-by-example, not a MUST. Since `qwen` is distinctive, we use the shorter bare form. `_meta` key likewise `"qwen"`. (Survey of real agents: Zed/gemini-cli mostly use `_meta`-on-standard-methods + ACP's own `unstable_*`; bare custom `_` methods are rare — our `_qwen/*` are genuinely-new workspace/session ops with no standard equivalent, so a `_` method is the right tool.) +- **Why hand-rolled transport (not SDK-based)**: the TS SDK ships only `ndJsonStream` (stdio); RFD #721 HTTP is SDK Phase-3 (not implemented). The SDK `Connection` is single-duplex-stream; our transport is multi-stream (POSTs + connection-SSE + per-session-SSE) and needs outbound demux by sessionId — which our dispatcher already knows at routing time. A full SDK rewrite fights that model and wouldn't remove the bulk (bridge translation, SSE lifecycle, ownership, EventBus→JSON-RPC). **Pragmatic improvement (candidate follow-up): adopt the SDK's Zod schema validators + types for param validation while keeping the hand-rolled transport.** SDK clients using `extMethod('_qwen/…')` interoperate with our handlers (identical wire shape). diff --git a/packages/cli/src/serve/acpHttp/connectionRegistry.ts b/packages/cli/src/serve/acpHttp/connectionRegistry.ts new file mode 100644 index 00000000000..78146363f83 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/connectionRegistry.ts @@ -0,0 +1,414 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { logSafe } from './jsonRpc.js'; +import type { SseStream } from './sseStream.js'; + +/** + * Per-stream cap on frames buffered before the client attaches its SSE + * stream. Mirrors the EventBus's `maxQueued` backpressure cap so a client + * that drives requests without ever opening a stream can't grow daemon + * memory without bound. Oldest frames are dropped past the cap. + */ +const MAX_BUFFERED_FRAMES = 256; + +/** Default cap on concurrent live connections (mirrors a bounded resource). */ +const DEFAULT_MAX_CONNECTIONS = 64; + +/** + * Invoked when a session/connection tears down while an agent→client + * request (e.g. a permission prompt) is still outstanding, so the bridge + * isn't left blocked awaiting a vote that will never arrive. + */ +export type AbandonPendingFn = ( + req: PendingClientRequest, + clientId: string | undefined, +) => boolean; + +/** + * Best-effort bridge detach for a session's bridge-stamped clientId on + * teardown. Without it, `session/new`/`load`/`resume`-registered client ids + * stay visible in `knownClientIds()`/`votersForSession()` after the ACP + * connection is gone — skewing permission mediation + origin validation. + * ACP clients can't clean this up themselves (the id isn't on the wire). + */ +export type DetachSessionFn = ( + sessionId: string, + clientId: string | undefined, +) => void; + +/** + * Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is + * minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many + * sessions — each with its own session-scoped SSE stream. + */ +export interface SessionBinding { + sessionId: string; + /** + * The clientId the bridge STAMPED for this session at create/attach. + * The bridge ignores caller-supplied ids it has never issued and mints + * a fresh one (returned on `spawnOrAttach`/`loadSession`), so every + * later per-session call (`sendPrompt`, permission votes, …) must echo + * THIS id, not the connection's own — otherwise the bridge rejects it + * with "client id is not registered for session". + */ + clientId?: string; + /** Session-scoped SSE stream (the client's `GET /acp` with both headers). */ + stream?: SseStream; + /** Frames emitted before the session stream attached, flushed on attach. */ + buffer: unknown[]; + /** + * Aborts the bridge event subscription tied to the CURRENT session + * stream. Replaced with a fresh controller on every re-attach — a + * controller, once aborted (on stream close), can never resume, so + * reusing it across reconnects would leave the new stream permanently + * event-starved. + */ + abort: AbortController; + /** + * Aborts the in-flight `session/prompt` for this session. Set by + * `handlePrompt` while a prompt runs; aborted on `session/cancel` and on + * session/connection teardown so a disconnecting client doesn't leave + * the agent burning model quota on a result nobody will read. + */ + promptAbort?: AbortController; +} + +/** An agent→client request awaiting the client's JSON-RPC response. */ +export interface PendingClientRequest { + sessionId: string; + /** Maps the JSON-RPC id we issued back to the bridge's permission id. */ + bridgeRequestId: string; + kind: 'permission'; +} + +export class AcpConnection { + readonly connectionId: string; + /** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */ + connStream?: SseStream; + /** Frames emitted before the connection stream attached, flushed on attach. */ + private readonly connBuffer: unknown[] = []; + readonly sessions = new Map(); + /** + * Sessions this connection created (`session/new`) or explicitly + * attached to (`session/load`/`resume`). Per-session operations + * (subscribe, prompt, cancel, …) are gated on membership here so one + * connection can't drive or eavesdrop on a session it never claimed. + */ + readonly ownedSessions = new Set(); + /** + * Sessions with an in-flight `session/close` (between the synchronous + * ownership-revoke and the bridge close + local teardown). `session/load` + * / `resume` reject for an id in this set so a close racing a re-load + * can't have its `finally` teardown destroy the freshly-loaded session. + */ + readonly closingSessions = new Set(); + /** Agent→client requests awaiting a client response, keyed by JSON-RPC id. */ + readonly pending = new Map(); + /** Daemon-issued client id reused across this connection's bridge calls. */ + readonly clientId: string; + /** + * True when the `initialize` POST arrived from a kernel-stamped loopback + * peer. Threaded into per-session bridge contexts so the `local-only` + * permission policy can gate votes by transport — mirrors the REST + * surface's `detectFromLoopback(req)`. NOT derived from forgeable + * headers (`X-Forwarded-For` etc). + */ + readonly fromLoopback: boolean; + /** + * Set by `destroy()`. An in-flight `session/new`/`load`/`resume` whose + * bridge call resolves AFTER teardown checks this to kill/detach the + * late-registered session, so a `DELETE` (or idle sweep) racing a spawn + * doesn't orphan a child process / phantom clientId. + */ + destroyed = false; + /** + * Grace-period reap timer armed when the connection-scoped SSE stream + * closes; cleared on reconnect (`attachConnStream`) or teardown. Avoids a + * dead connection locking its `ownedSessions` (and counting against + * `maxConnections`) for the full 30-min idle TTL. + */ + connGraceTimer?: ReturnType; + lastActiveMs: number = Date.now(); + private idCounter = 0; + + constructor( + connectionId: string | undefined, + fromLoopback: boolean, + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + ) { + this.connectionId = connectionId ?? randomUUID(); + this.clientId = randomUUID(); + this.fromLoopback = fromLoopback; + } + + /** + * Allocate a fresh JSON-RPC id for an agent→client request. STRING-typed + * (`_qwen_perm_N`) so it can never collide with a client-originated id — + * JSON-RPC 2.0 permits clients to use any number (incl. negatives) or + * string, so a numeric namespace wasn't actually safe. + */ + nextId(): string { + this.idCounter += 1; + return `_qwen_perm_${this.idCounter}`; + } + + touch(): void { + this.lastActiveMs = Date.now(); + } + + ownSession(sessionId: string): void { + this.ownedSessions.add(sessionId); + } + + ownsSession(sessionId: string): boolean { + return this.ownedSessions.has(sessionId); + } + + getOrCreateSession(sessionId: string): SessionBinding { + let binding = this.sessions.get(sessionId); + if (!binding) { + binding = { sessionId, abort: new AbortController(), buffer: [] }; + this.sessions.set(sessionId, binding); + } + return binding; + } + + /** Send a frame on the connection-scoped stream (buffer until it attaches). */ + sendConn(frame: unknown): void { + if (this.connStream && !this.connStream.isClosed) { + void this.connStream.send(frame); + } else { + pushCapped(this.connBuffer, frame, `conn ${this.connectionId}`); + } + } + + /** True if any session currently has a live (open) SSE stream. */ + hasLiveSessionStream(): boolean { + for (const b of this.sessions.values()) { + if (b.stream && !b.stream.isClosed) return true; + } + return false; + } + + /** Cancel a pending grace-period reap (e.g. on conn-stream reconnect). */ + clearGraceTimer(): void { + if (this.connGraceTimer) { + clearTimeout(this.connGraceTimer); + this.connGraceTimer = undefined; + } + } + + /** Attach the connection-scoped stream and flush any buffered frames. */ + attachConnStream(stream: SseStream): void { + // A reconnect cancels any pending grace-period reap. + this.clearGraceTimer(); + // Close any prior connection stream so its heartbeat interval + socket + // don't leak when a client reconnects the connection-scoped GET. + if (this.connStream && this.connStream !== stream) this.connStream.close(); + this.connStream = stream; + for (const frame of this.connBuffer.splice(0)) void stream.send(frame); + } + + /** + * Send a frame on a session-scoped stream (buffer until it attaches). + * LOOKUP-ONLY: drops the frame when the session has no binding — a binding + * always exists for a live session (created at `session/new`/`load`/ + * `resume`), so a missing one means the session was torn down. Auto- + * creating here would resurrect a ghost binding (no stream, no owner) that + * buffers up to 256 late pump/reply frames forever. + */ + sendSession(sessionId: string, frame: unknown): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + if (binding.stream && !binding.stream.isClosed) { + void binding.stream.send(frame); + } else { + pushCapped(binding.buffer, frame, `session ${sessionId}`); + } + } + + /** + * Attach a session-scoped stream: close any prior stream, abort the prior + * subscription, install the caller's FRESH AbortController (the old one is + * aborted and can never resume — reusing it would leave the new stream + * event-starved), flush buffered frames, and return the binding. + */ + attachSessionStream( + sessionId: string, + stream: SseStream, + abort: AbortController, + ): SessionBinding { + const binding = this.getOrCreateSession(sessionId); + const prevStream = binding.stream; + binding.abort.abort(); + binding.abort = abort; + // Install the NEW stream BEFORE closing the old one. The old stream's + // `onClose` is identity-guarded on `binding.stream` (see the session-GET + // handler in `index.ts` — `if (conn.sessions.get(sessionId)?.stream === + // stream) ...promptAbort?.abort()`), so installing first means a + // reconnect's close can't abort the in-flight prompt (the client is + // reconnecting, not leaving — the prompt must survive). CONTRACT: that + // identity guard and this ordering must stay in lockstep. + binding.stream = stream; + if (prevStream && prevStream !== stream) prevStream.close(); + for (const frame of binding.buffer.splice(0)) void stream.send(frame); + return binding; + } + + closeSessionStream(sessionId: string): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + binding.abort.abort(); + binding.promptAbort?.abort(); + binding.stream?.close(); + this.abandonPendingForSession(sessionId, binding.clientId); + this.onDetachSession?.(sessionId, binding.clientId); + this.sessions.delete(sessionId); + this.ownedSessions.delete(sessionId); + } + + destroy(): void { + this.destroyed = true; + this.clearGraceTimer(); + for (const binding of this.sessions.values()) { + binding.abort.abort(); + binding.promptAbort?.abort(); + binding.stream?.close(); + this.abandonPendingForSession(binding.sessionId, binding.clientId); + // Release the bridge-stamped clientId so it doesn't linger in the + // bridge's voter/known-client sets after this connection is gone. + this.onDetachSession?.(binding.sessionId, binding.clientId); + } + this.sessions.clear(); + this.ownedSessions.clear(); + this.pending.clear(); + this.connStream?.close(); + } + + /** + * Cancel + drop any pending agent→client requests for a closing session. + * This is the LAST-RESORT recovery path: `resolveClientResponse` retains a + * pending entry on double-failure (vote AND cancel both threw) precisely so + * this teardown sweep can retry the cancel. We always drop the entry here + * (the connection is going away — there is no further retry after teardown), + * but if the cancel itself still fails (triple-failure) the bridge mediator + * may be stuck awaiting a vote that will never arrive, so log it for the + * operator rather than failing silently. + */ + private abandonPendingForSession( + sessionId: string, + clientId: string | undefined, + ): void { + for (const [id, req] of this.pending) { + if (req.sessionId !== sessionId) continue; + this.pending.delete(id); + const cancelled = this.onAbandonPending?.(req, clientId) ?? true; + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp MEDIATOR STUCK: abandonPendingForSession(${logSafe(sessionId)}) cancel failed for ${logSafe(req.bridgeRequestId)}`, + ); + } + } + } +} + +function pushCapped(buf: unknown[], frame: unknown, label = 'stream'): void { + if (buf.length >= MAX_BUFFERED_FRAMES) { + buf.shift(); + writeStderrLine( + `qwen serve: /acp pre-attach buffer full (${label}), dropped oldest frame`, + ); + } + buf.push(frame); +} + +/** + * Registry of live ACP connections with an idle-TTL sweep. The sweep is + * defensive: a well-behaved client `DELETE /acp`s, but a crashed client + * that never closes its streams would otherwise leak connection state. + */ +export class ConnectionRegistry { + private readonly byId = new Map(); + private readonly sweepTimer: ReturnType; + + constructor( + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + private readonly maxConnections = DEFAULT_MAX_CONNECTIONS, + private readonly idleTtlMs = 30 * 60_000, + ) { + this.sweepTimer = setInterval(() => this.sweep(), 60_000); + this.sweepTimer.unref(); + } + + /** + * Mint a connection, or return `undefined` when the live-connection cap + * is reached (the caller answers `503`). Bounds an `initialize` flood from + * growing the registry without limit through the full TTL window. + */ + create(fromLoopback: boolean): AcpConnection | undefined { + if (this.maxConnections > 0 && this.byId.size >= this.maxConnections) { + return undefined; + } + const conn = new AcpConnection( + undefined, + fromLoopback, + this.onAbandonPending, + this.onDetachSession, + ); + this.byId.set(conn.connectionId, conn); + return conn; + } + + get(connectionId: string | undefined): AcpConnection | undefined { + if (!connectionId) return undefined; + const conn = this.byId.get(connectionId); + conn?.touch(); + return conn; + } + + delete(connectionId: string): boolean { + const conn = this.byId.get(connectionId); + if (!conn) return false; + conn.destroy(); + return this.byId.delete(connectionId); + } + + get size(): number { + return this.byId.size; + } + + /** The configured concurrent-connection cap (for operator-facing logs). */ + get connectionCap(): number { + return this.maxConnections; + } + + dispose(): void { + clearInterval(this.sweepTimer); + for (const id of [...this.byId.keys()]) this.delete(id); + } + + private sweep(): void { + const cutoff = Date.now() - this.idleTtlMs; + for (const [id, conn] of this.byId) { + if (conn.lastActiveMs >= cutoff) continue; + // Observability: a reaped connection silently dropping its SSE + // streams is otherwise invisible to operators chasing "my client + // froze". Note that `touch()` fires on inbound HTTP AND on event + // delivery (pumpSessionEvents), so a long quiet prompt isn't reaped. + writeStderrLine( + `qwen serve: /acp reaping idle connection ${id} ` + + `(idle > ${Math.round(this.idleTtlMs / 60_000)}m, ` + + `${conn.sessions.size} session(s))`, + ); + this.delete(id); + } + } +} diff --git a/packages/cli/src/serve/acpHttp/dispatch.ts b/packages/cli/src/serve/acpHttp/dispatch.ts new file mode 100644 index 00000000000..6c6df8838a4 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/dispatch.ts @@ -0,0 +1,1179 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { APPROVAL_MODES, type ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; +import type { AcpConnection } from './connectionRegistry.js'; +import { + QWEN_META_KEY, + QWEN_METHOD_NS, + RPC, + error, + isNotification, + isObject, + isRequest, + isResponse, + logSafe, + notification, + request, + success, + type JsonRpcId, + type JsonRpcInbound, + type JsonRpcRequest, + type JsonRpcResponse, +} from './jsonRpc.js'; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Method names whose responses ride the CONNECTION-scoped stream (the + * session stream may not exist yet / ownership not granted on failure). + * Error frames must route the same way as their success path. + */ +const CONN_ROUTED_METHODS = new Set([ + 'authenticate', + 'session/new', + 'session/load', + 'session/resume', + 'session/list', + 'session/close', + `${QWEN_METHOD_NS}session/heartbeat`, + `${QWEN_METHOD_NS}session/context`, + `${QWEN_METHOD_NS}session/supported_commands`, + `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}workspace/mcp`, + `${QWEN_METHOD_NS}workspace/skills`, + `${QWEN_METHOD_NS}workspace/providers`, + `${QWEN_METHOD_NS}workspace/env`, + `${QWEN_METHOD_NS}workspace/preflight`, + `${QWEN_METHOD_NS}workspace/init`, + `${QWEN_METHOD_NS}workspace/set_tool_enabled`, + `${QWEN_METHOD_NS}workspace/restart_mcp_server`, +]); + +// SYNC: server.ts MAX_TOOL_NAME_LENGTH / MAX_SERVER_NAME_LENGTH (both 256). +// Keep in lockstep with the REST surface — a divergence means ACP clients get +// INVALID_PARAMS for names REST accepts (or vice versa). (Not extracted to a +// shared module to avoid churning the 2987-line server.ts near merge; a +// follow-up may lift all three to a `serve/limits.ts`.) +const MAX_NAME_LENGTH = 256; + +class AcpParamError extends Error {} + +/** + * Validate an optional `cwd` param the same way the REST `POST /session` + * route does: when present it must be a string, ≤ PATH_MAX, and absolute. + * Closes the body-amplification DoS the REST code documents. Returns the + * bound workspace when omitted. + */ +function parseOptionalWorkspaceCwd( + params: Record, + boundWorkspace: string, +): string { + if (!('cwd' in params) || params['cwd'] === undefined) return boundWorkspace; + const cwd = params['cwd']; + if (typeof cwd !== 'string') { + throw new AcpParamError( + '`cwd` must be a string absolute path when provided', + ); + } + if (cwd.length > MAX_WORKSPACE_PATH_LENGTH) { + throw new AcpParamError( + `\`cwd\` exceeds the ${MAX_WORKSPACE_PATH_LENGTH}-character limit`, + ); + } + // `path.isAbsolute` (platform-aware) — same as the REST route. A bare + // `startsWith('/')` would reject valid Windows `C:\…`/UNC paths a client + // gets back from `/capabilities.workspaceCwd`. + if (!path.isAbsolute(cwd)) { + throw new AcpParamError('`cwd` must be an absolute path when provided'); + } + return cwd; +} + +/** Validate a `session/prompt` body before it reaches the bridge/agent. */ +function validatePrompt(params: Record): void { + const prompt = params['prompt']; + if (!Array.isArray(prompt) || prompt.length === 0) { + throw new AcpParamError( + '`prompt` is required and must be a non-empty array of content blocks', + ); + } + if ( + !prompt.every( + (b) => typeof b === 'object' && b !== null && !Array.isArray(b), + ) + ) { + throw new AcpParamError('each `prompt` element must be an object'); + } +} + +/** + * Map a thrown error to a JSON-RPC error code + a client-safe message. + * Param-validation errors are echoed (they describe the client's own bad + * input); bridge/internal errors are coded by class name with their + * message preserved (the daemon's trust boundary is the bearer token, so + * the operator-facing message is not a cross-tenant leak), and anything + * unrecognized collapses to a generic INTERNAL_ERROR string. + */ +function toRpcError(err: unknown): { code: number; message: string } { + if (err instanceof AcpParamError) { + return { code: RPC.INVALID_PARAMS, message: err.message }; + } + const name = err instanceof Error ? err.name : ''; + switch (name) { + case 'SessionNotFoundError': + case 'InvalidSessionScopeError': + case 'WorkspaceMismatchError': + case 'InvalidClientIdError': + return { code: RPC.INVALID_PARAMS, message: errMsg(err) }; + case 'SessionLimitExceededError': + return { code: RPC.INTERNAL_ERROR, message: errMsg(err) }; + default: + return { code: RPC.INTERNAL_ERROR, message: 'Internal error' }; + } +} + +/** + * The ACP protocol version this transport speaks (ACP stable = 1). + */ +export const ACP_PROTOCOL_VERSION = 1; + +/** + * Routes JSON-RPC messages between the HTTP transport and the + * `HttpAcpBridge`. Inbound client messages map to bridge calls; the + * bridge's `BridgeEvent`s map back to JSON-RPC frames on the matching + * session stream (see the design doc §4 translation table). + */ +export class AcpDispatcher { + constructor( + private readonly bridge: HttpAcpBridge, + private readonly boundWorkspace: string, + ) {} + + /** + * Build the bridge context for a per-session call. Echoes the clientId the + * bridge STAMPED at create/attach (the connection's own id is unregistered + * and would be rejected) and threads `fromLoopback` so the `local-only` + * permission policy can gate votes by transport — symmetric with the REST + * surface's `detectFromLoopback(req)`. + * + * Throws when no stamped clientId is present: the only callers reach here + * AFTER `requireOwned`, so the binding must exist and carry the bridge's + * id. A missing id means an invariant broke (a `session/new`/`load` that + * didn't record it) — fail loud rather than silently send an unregistered + * id whose rejection surfaces asynchronously, far from the cause. + */ + private sessionCtx( + conn: AcpConnection, + sessionId: string, + fromLoopback: boolean, + ): { clientId: string; fromLoopback: boolean } { + const clientId = conn.sessions.get(sessionId)?.clientId; + if (!clientId) { + throw new Error( + `no bridge-stamped clientId for session ${sessionId} (ownership invariant violated)`, + ); + } + return { clientId, fromLoopback }; + } + + /** + * The session's ACP-shaped config options (model/mode/…), read from the + * child's own session state. Returned in `session/new` and as the result + * of `session/set_config_option`. Best-effort — `undefined` on error. + */ + private async configOptionsFor( + sessionId: string, + ): Promise { + try { + const ctx = (await this.bridge.getSessionContextStatus(sessionId)) as { + state?: { configOptions?: unknown }; + }; + const co = ctx?.state?.configOptions; + return Array.isArray(co) ? co : undefined; + } catch (err) { + writeStderrLine( + `qwen serve: /acp configOptionsFor(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, + ); + return undefined; + } + } + + /** + * Cancel a permission request the client abandoned (closed its stream / + * connection before voting), so the bridge isn't left blocked. Invoked + * by the connection-registry teardown path. + */ + cancelAbandonedPermission( + req: { sessionId: string; bridgeRequestId: string }, + clientId: string | undefined, + ): boolean { + try { + this.bridge.respondToSessionPermission( + req.sessionId, + req.bridgeRequestId, + { outcome: { outcome: 'cancelled' } } as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + clientId !== undefined ? { clientId } : undefined, + ); + return true; + } catch (err) { + // "Session already gone" is the common, expected path (treat as done). + // Any OTHER failure means the mediator may still be stuck — log it AND + // report failure so a caller can keep the pending entry for a later + // teardown retry rather than dropping it. + const msg = errMsg(err); + if (/not found|unknown session/i.test(msg)) return true; + writeStderrLine( + `qwen serve: /acp cancelAbandonedPermission(${logSafe(req.sessionId)}) failed: ${logSafe(msg)}`, + ); + return false; + } + } + + /** + * Build the `initialize` result advertising standard + `_qwen` caps. + * Negotiates the protocol version: we only implement stable V1, so we + * clamp to `[1, ACP_PROTOCOL_VERSION]` — a client asking for 0/negative + * (ACP marks V0 a pre-release fallback) or a future version gets `1` + * rather than an echoed version we don't actually implement. + */ + buildInitializeResult( + connectionId: string, + requestedVersion?: unknown, + ): Record { + const requested = + typeof requestedVersion === 'number' && Number.isFinite(requestedVersion) + ? requestedVersion + : ACP_PROTOCOL_VERSION; + const negotiated = Math.max(1, Math.min(requested, ACP_PROTOCOL_VERSION)); + return { + protocolVersion: negotiated, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + // Model + mode are exposed via the STANDARD `session/set_config_option` + // (categories `model`/`mode`); advertise that here. + configOptions: true, + // Vendor extensions are advertised under `_meta` keyed by domain + // (ACP convention, e.g. `_meta: { "zed.dev": … }`). Clients + // feature-detect before calling `_qwen/…` methods. + _meta: { + [QWEN_META_KEY]: { + connectionId, + workspaceCwd: this.boundWorkspace, + methods: [ + `${QWEN_METHOD_NS}session/heartbeat`, + `${QWEN_METHOD_NS}session/context`, + `${QWEN_METHOD_NS}session/supported_commands`, + `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}workspace/mcp`, + `${QWEN_METHOD_NS}workspace/skills`, + `${QWEN_METHOD_NS}workspace/providers`, + `${QWEN_METHOD_NS}workspace/env`, + `${QWEN_METHOD_NS}workspace/preflight`, + `${QWEN_METHOD_NS}workspace/init`, + `${QWEN_METHOD_NS}workspace/set_tool_enabled`, + `${QWEN_METHOD_NS}workspace/restart_mcp_server`, + ], + }, + }, + }, + }; + } + + /** + * Gate a per-session operation on connection ownership. Sends a JSON-RPC + * error and returns false when this connection never created/attached + * the session (prevents driving or eavesdropping on another + * connection's session). `session/new|load|resume` are the + * ownership-GRANTING ops and skip this. + */ + private requireOwned( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + ): boolean { + if (conn.ownsSession(sessionId)) return true; + if (id === undefined) { + // Notification (no id) for an unowned session: no wire response to + // send, so log it — otherwise "my cancel did nothing" is undebuggable. + writeStderrLine( + `qwen serve: /acp notification for unowned session ${logSafe(sessionId)} (dropped)`, + ); + return false; + } + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Session ${sessionId} is not owned by this connection`, + ), + ); + return false; + } + + /** + * Handle one inbound POST message. Returns nothing — every reply is + * delivered asynchronously on a long-lived SSE stream per the RFD + * (`POST` itself answers `202`). `initialize` is handled by the caller + * (it mints the connection) and never reaches here. + */ + async handle( + conn: AcpConnection, + msg: JsonRpcInbound, + sessionHeader?: string, + reqLoopback?: boolean, + ): Promise { + // Loopback is evaluated PER REQUEST (the permission-vote POST may arrive + // from a different peer than `initialize`), falling back to the + // connection's initialize-time value when the caller didn't supply it. + const loopback = reqLoopback ?? conn.fromLoopback; + + // A client's JSON-RPC RESPONSE (to an agent→client request) — wrapped + // so a throwing bridge call can't reject this promise after index.ts + // already sent `202` (which would surface as an unhandled rejection). + if (isResponse(msg)) { + try { + this.resolveClientResponse(conn, msg, loopback); + } catch (err) { + writeStderrLine( + `qwen serve: /acp response handling error: ${logSafe(errMsg(err))}`, + ); + } + return; + } + if (!isRequest(msg) && !isNotification(msg)) return; + + const method = msg.method; + const params = (isObject(msg.params) ? msg.params : {}) as Record< + string, + unknown + >; + const id = isRequest(msg) ? msg.id : undefined; + + // RFD §2.3: when both are present the `Acp-Session-Id` header and the + // `sessionId` param MUST agree — reject divergence rather than let a + // POST act on a session other than the one the header names. + if ( + sessionHeader && + typeof params['sessionId'] === 'string' && + params['sessionId'] !== sessionHeader + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'Acp-Session-Id header does not match params.sessionId', + ), + ); + } + return; + } + + try { + switch (method) { + case 'authenticate': + // HTTP transport authenticates via the daemon's bearer token + // middleware; the ACP-level method is a success no-op. + this.replyConn(conn, id, {}); + return; + + case 'session/new': { + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + // Forward sessionScope like REST (bridge supports single|thread). + const rawScope = params['sessionScope']; + if ( + rawScope !== undefined && + rawScope !== 'single' && + rawScope !== 'thread' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`sessionScope` must be "single" or "thread"', + ), + ); + } + return; + } + const session = await this.bridge.spawnOrAttach({ + workspaceCwd: cwd, + clientId: conn.clientId, + ...(rawScope !== undefined + ? { sessionScope: rawScope as 'single' | 'thread' } + : {}), + }); + // Teardown raced the spawn: the connection was destroyed while the + // bridge call was in flight, so nothing will tear this session down. + // Kill the orphan (no other client could have attached yet). + if (conn.destroyed) { + void this.bridge + .killSession(session.sessionId, { requireZeroAttaches: true }) + .catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(session.sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); + return; + } + // Record the clientId the bridge actually stamped — later + // per-session calls MUST echo it (see SessionBinding.clientId). + conn.getOrCreateSession(session.sessionId).clientId = + session.clientId; + conn.ownSession(session.sessionId); + // Advertise the session's config options (model/mode/…) so a + // standard client can drive `session/set_config_option`. Sourced + // from the child's own session state (already ACP-shaped). + const configOptions = await this.configOptionsFor(session.sessionId); + if (conn.destroyed) { + void this.bridge + .killSession(session.sessionId, { requireZeroAttaches: true }) + .catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(session.sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); + return; + } + this.replyConn(conn, id, { + sessionId: session.sessionId, + ...(configOptions ? { configOptions } : {}), + }); + return; + } + + case 'session/load': + case 'session/resume': { + const sessionId = String(params['sessionId'] ?? ''); + if (!sessionId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`sessionId` is required'), + ); + } + return; + } + // Reject if a session/close for this id is in flight — otherwise the + // close's `finally` teardown would destroy the session we're about + // to load (TOCTOU). Client should retry after the close settles. + if (conn.closingSessions.has(sessionId)) { + if (id !== undefined) { + // The client's params are valid — the rejection is a server-side + // timing race against an in-flight close, so use INTERNAL_ERROR + // (-32603), not INVALID_PARAMS, to signal a transient/retryable + // condition rather than a permanent parameter fault. + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} is being closed; retry`, + ), + ); + } + return; + } + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + const restored = + method === 'session/load' + ? await this.bridge.loadSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }) + : await this.bridge.resumeSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }); + // Teardown raced the restore — EITHER the whole connection was + // destroyed (`conn.destroyed`) OR a `session/close` for this id + // started DURING the await (`closingSessions`); in the latter the + // close's `finally` teardown would destroy the binding we're about + // to create. Both need the same cleanup; only the client reply + // differs. Cleanup depends on what restore did: + // - attached:true → detachClient rolls back just our attach. + // - attached:false → restore SPAWNED a fresh session from disk; + // detachClient only decrements attachCount and does NOT reap + // (reaping is the spawn-owner's job) — so kill it. + const closeRaced = conn.closingSessions.has(sessionId); + if (conn.destroyed || closeRaced) { + const cleanup = restored.attached + ? this.bridge.detachClient(sessionId, restored.clientId) + : this.bridge.killSession(sessionId, { + requireZeroAttaches: true, + }); + void cleanup.catch((err) => + writeStderrLine( + `qwen serve: /acp orphan ${restored.attached ? 'detach' : 'kill'}(${logSafe(sessionId)}) teardown-race: ${logSafe(errMsg(err))}`, + ), + ); + // Connection-still-alive close race → tell the client to retry. + // Same rationale as the pre-await guard: a transient server-side + // race, so INTERNAL_ERROR (-32603), not INVALID_PARAMS. + if (closeRaced && !conn.destroyed && id !== undefined) { + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} was closed during load; retry`, + ), + ); + } + return; + } + conn.getOrCreateSession(sessionId).clientId = restored.clientId; + conn.ownSession(sessionId); + this.replyConn(conn, id, restored.state ?? {}); + return; + } + + case 'session/list': { + const sessions = this.bridge.listWorkspaceSessions( + this.boundWorkspace, + ); + this.replyConn(conn, id, { sessions }); + return; + } + + case 'session/close': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Close the ownership gate SYNCHRONOUSLY (before the await) so two + // concurrent `session/close`s don't both pass `requireOwned` — + // the second would otherwise send a misleading error and trigger a + // redundant bridge close. + conn.ownedSessions.delete(sessionId); + // Mark closing so a concurrent session/load|resume of the SAME id + // can't grant fresh ownership + create a new binding that this + // close's `finally` teardown would then destroy (TOCTOU). + conn.closingSessions.add(sessionId); + try { + await this.bridge.closeSession( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + } finally { + // Local teardown must run even if the bridge close throws — + // otherwise the SSE stream, abort controller, buffered frames and + // pending permissions leak until idle TTL. + try { + conn.closeSessionStream(sessionId); + } catch (teardownErr) { + writeStderrLine( + `qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(teardownErr instanceof Error ? teardownErr.message : String(teardownErr))}`, + ); + } + conn.closingSessions.delete(sessionId); + } + this.replyConn(conn, id, {}); + return; + } + + case 'session/cancel': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Abort our local in-flight prompt controller too — cancelSession + // tells the agent to wind down, but the HTTP-side `sendPrompt` + // await must also be released so the session FIFO unblocks. + conn.sessions.get(sessionId)?.promptAbort?.abort(); + await this.bridge.cancelSession( + sessionId, + // Forward client-supplied cancel fields (reason/context) while + // force-stamping sessionId — mirrors the REST surface. + { ...params, sessionId } as Parameters< + HttpAcpBridge['cancelSession'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + // `session/cancel` is normally a notification (no id), but answer + // the request-form so a client that sent an id isn't left hanging. + if (id !== undefined) this.replySession(conn, sessionId, id, {}); + return; + } + + case 'session/prompt': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + validatePrompt(params); + await this.handlePrompt(conn, sessionId, id, params, loopback); + return; + } + + // STANDARD method (SDK 0.14.1, non-`unstable_`): model + mode live + // here under categories `model`/`mode`, routed to the existing bridge + // setters. Replaces the old vendor `_qwen/session/set_model`. + case 'session/set_config_option': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const configId = String(params['configId'] ?? ''); + const rawValue = params['value']; + const ctx = this.sessionCtx(conn, sessionId, loopback); + // Validate value at the boundary like REST (empty/null is rejected + // rather than forwarded as "" to the bridge). + if (typeof rawValue !== 'string' || rawValue.length === 0) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + '`value` must be a non-empty string', + ), + ); + } + return; + } + const value = rawValue; + if (configId === 'model') { + await this.bridge.setSessionModel( + sessionId, + { modelId: value } as unknown as Parameters< + HttpAcpBridge['setSessionModel'] + >[1], + ctx, + ); + } else if (configId === 'mode') { + // Validate against the closed approval-mode set, like REST. + if (!APPROVAL_MODES.includes(value as ApprovalMode)) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + `invalid mode "${value}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + ), + ); + } + return; + } + await this.bridge.setSessionApprovalMode( + sessionId, + value as ApprovalMode, + // Forward the optional persist flag like REST. + { persist: params['persist'] === true }, + ctx, + ); + } else { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, RPC.INVALID_PARAMS, `Unknown configId: ${configId}`), + ); + } + return; + } + // Response returns the updated config option set (per ACP). + const configOptions = await this.configOptionsFor(sessionId); + this.replySession(conn, sessionId, id, { configOptions }); + return; + } + + case `${QWEN_METHOD_NS}session/heartbeat`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = this.bridge.recordHeartbeat( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/context`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionContextStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/supported_commands`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionSupportedCommandsStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/update_metadata`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const metadata = isObject(params['metadata']) + ? (params['metadata'] as Record) + : {}; + const result = this.bridge.updateSessionMetadata( + sessionId, + metadata as unknown as Parameters< + HttpAcpBridge['updateSessionMetadata'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp`: + this.replyConn(conn, id, await this.bridge.getWorkspaceMcpStatus()); + return; + case `${QWEN_METHOD_NS}workspace/skills`: + this.replyConn( + conn, + id, + await this.bridge.getWorkspaceSkillsStatus(), + ); + return; + case `${QWEN_METHOD_NS}workspace/providers`: + this.replyConn( + conn, + id, + await this.bridge.getWorkspaceProvidersStatus(), + ); + return; + case `${QWEN_METHOD_NS}workspace/env`: + this.replyConn(conn, id, await this.bridge.getWorkspaceEnvStatus()); + return; + case `${QWEN_METHOD_NS}workspace/preflight`: + this.replyConn( + conn, + id, + await this.bridge.getWorkspacePreflightStatus(), + ); + return; + + case `${QWEN_METHOD_NS}workspace/init`: { + const rawForce = params['force']; + if (rawForce !== undefined && typeof rawForce !== 'boolean') { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`force` must be a boolean when provided', + ), + ); + } + return; + } + const force = rawForce === true; + const result = await this.bridge.initWorkspace( + { force }, + conn.clientId, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/set_tool_enabled`: { + const toolName = String(params['toolName'] ?? ''); + if (!toolName || toolName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`toolName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const result = await this.bridge.setWorkspaceToolEnabled( + toolName, + params['enabled'] === true, + conn.clientId, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/restart_mcp_server`: { + const serverName = String(params['serverName'] ?? ''); + if (!serverName || serverName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`serverName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const rawIdx = params['entryIndex']; + if ( + rawIdx !== undefined && + (typeof rawIdx !== 'number' || + !Number.isInteger(rawIdx) || + rawIdx < 0) + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`entryIndex` must be a non-negative integer', + ), + ); + } + return; + } + const result = await this.bridge.restartMcpServer( + serverName, + conn.clientId, + rawIdx !== undefined ? { entryIndex: rawIdx } : undefined, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + default: + if (id !== undefined) { + conn.sendConn( + error(id, RPC.METHOD_NOT_FOUND, `Unknown method: ${method}`), + ); + } + return; + } + } catch (err) { + // Full detail to stderr for the operator; a coded, client-safe shape + // on the wire (raw bridge messages may carry internal paths/details). + writeStderrLine( + `qwen serve: /acp dispatch error (${logSafe(method)}): ${logSafe(errMsg(err))}`, + ); + if (id !== undefined) { + const { code, message } = toRpcError(err); + const frame = error(id, code, message); + // Route the error the SAME way as the method's success path. Inferring + // from `params.sessionId` would misroute conn-scoped method failures + // (session/load|resume|close|…) to a session stream that doesn't exist + // yet — the client waiting on the connection stream never sees them. + const sessionId = + typeof params['sessionId'] === 'string' + ? (params['sessionId'] as string) + : undefined; + if (sessionId && !CONN_ROUTED_METHODS.has(method)) { + this.replySession(conn, sessionId, id, undefined, frame); + } else { + conn.sendConn(frame); + } + } + } + } + + /** + * Bind a session-scoped SSE stream to the bridge's event stream, + * translating each `BridgeEvent` into a JSON-RPC frame (design §4.2). + */ + async pumpSessionEvents( + conn: AcpConnection, + sessionId: string, + signal: AbortSignal, + ): Promise { + try { + const iterable = this.bridge.subscribeEvents(sessionId, { signal }); + for await (const event of iterable) { + if (signal.aborted) break; + // Count event delivery as connection activity so a long, quiet prompt + // (no inbound HTTP) isn't reaped by the idle-TTL sweep. + conn.touch(); + this.translateEvent(conn, sessionId, event); + } + } catch (err) { + // Symmetric for the SYNC `subscribeEvents` throw and a MID-STREAM + // iterator error: surface a `stream_error` to the client, then re-throw + // so the caller's `.catch()` closes the stream. Returning would leave a + // zombie SSE stream (heartbeats, no events, no reconnect signal). + if (!signal.aborted) { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: 'stream_error', + error: errMsg(err), + }), + ); + } + throw err; + } + // Normal completion (iterator returned `done` — e.g. the subprocess ended + // cleanly). The caller's `.then` closes the stream so it isn't left as a + // zombie heartbeating with nothing more to deliver. + } + + private translateEvent( + conn: AcpConnection, + sessionId: string, + event: BridgeEvent, + ): void { + switch (event.type) { + case 'session_update': { + // `event.data` is the ACP `SessionNotification` (params shape). + conn.sendSession(sessionId, notification('session/update', event.data)); + return; + } + case 'permission_request': { + const data = event.data as { + requestId: string; + sessionId: string; + toolCall: unknown; + options: unknown; + }; + // A permission request MUST reach a LIVE session stream. Going + // through `sendSession` would (a) silently drop the frame if the + // session was torn down (lookup-only), or (b) buffer it pre-attach + // where `pushCapped` could evict it under event throughput — either + // way the `pending` entry is orphaned and the agent's prompt blocks + // on a vote forever. So deliver DIRECTLY to a live stream, and if + // there is none, cancel (deny-safe) rather than register+stall. + const binding = conn.sessions.get(sessionId); + if (!binding?.stream || binding.stream.isClosed) { + const cancelled = this.cancelAbandonedPermission( + { sessionId, bridgeRequestId: data.requestId }, + // Pass the bridge-stamped clientId when the binding still exists + // (stream closed but session live) — only `undefined` when the + // session is fully gone. + binding?.clientId, + ); + // Unlike resolveClientResponse (where the pending entry exists and + // teardown can retry), this path returns BEFORE `conn.pending.set` — + // so `abandonPendingForSession` will NOT find it. A failed cancel + // here means the mediator is stuck permanently, not just until + // teardown. Log clearly so the operator knows there is no automatic + // recovery; manual intervention (restart the agent session) is needed. + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp permission cancel FAILED for ${logSafe(sessionId)} (mediator stuck; no automatic recovery)`, + ); + } + return; + } + const id = conn.nextId(); + conn.pending.set(id, { + sessionId, + bridgeRequestId: data.requestId, + kind: 'permission', + }); + void binding.stream.send( + request(id, 'session/request_permission', { + sessionId: data.sessionId, + toolCall: data.toolCall, + options: data.options, + _meta: { [QWEN_META_KEY]: { requestId: data.requestId } }, + }), + ); + return; + } + case 'stream_error': { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + // Spread first so a stray `kind` in event.data can't shadow the + // discriminator the client's error handler keys on. + ...(event.data as object), + kind: 'stream_error', + }), + ); + return; + } + default: { + // client_evicted / slow_client_warning / state_resync_required / + // model_switched / approval_mode_changed / … → opaque qwen notify. + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: event.type, + data: event.data, + }), + ); + } + } + } + + /** + * Resolve a client's JSON-RPC response to an agent→client request. + * `fromLoopback` is the CURRENT request's loopback bit (the vote POST may + * arrive from a different peer than `initialize`). + */ + private resolveClientResponse( + conn: AcpConnection, + msg: JsonRpcResponse, + fromLoopback: boolean, + ): void { + // Our outbound request ids are strings (`_qwen_perm_N`); a client echoes + // the same id verbatim. Anything else can't match a pending entry. + const id = msg.id; + if (typeof id !== 'string') return; + const pending = conn.pending.get(id); + if (!pending) return; + // NOTE: do NOT delete the pending entry yet. Keep it until either the + // bridge vote OR the cancel fallback runs — if both somehow fail, the + // entry survives so a later session/connection teardown + // (`abandonPendingForSession`) can still release the mediator. + + // A client error response is a cancellation; otherwise pass the result + // through. The cast defers shape validation to the bridge, so a + // MALFORMED result (e.g. `{}` with no `outcome`) makes the mediator + // throw — caught below, where we fall back to an explicit cancel so the + // mediator is always released. The pending entry is dropped only after a + // successful vote/cancel (see the NOTE above), so a double-failure leaves + // it for teardown to retry. + const vote = + 'error' in msg + ? { outcome: { outcome: 'cancelled' } } + : (msg as { result: unknown }).result; + try { + this.bridge.respondToSessionPermission( + pending.sessionId, + pending.bridgeRequestId, + vote as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + this.sessionCtx(conn, pending.sessionId, fromLoopback), + ); + conn.pending.delete(id); // vote landed — safe to drop + } catch (err) { + writeStderrLine( + `qwen serve: /acp permission vote failed (${logSafe(pending.sessionId)}): ${logSafe(errMsg(err))}`, + ); + // Cancel BEFORE deleting, and ONLY drop the entry if the cancel + // landed. If it also failed, keep the entry so teardown's + // `abandonPendingForSession` can retry — otherwise the mediator is + // permanently stuck with no recovery path. + const cancelled = this.cancelAbandonedPermission( + pending, + conn.sessions.get(pending.sessionId)?.clientId, + ); + if (cancelled) conn.pending.delete(id); + } + } + + private async handlePrompt( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + params: Record, + fromLoopback: boolean, + ): Promise { + // Park the controller on the binding so `session/cancel` and + // session/connection teardown can abort an in-flight prompt — otherwise + // a disconnecting client leaves the agent running, burning model quota + // and holding the session's prompt FIFO. + const binding = conn.getOrCreateSession(sessionId); + // Abort any prior in-flight prompt for this session before replacing the + // controller — two concurrent `session/prompt`s would otherwise orphan + // the first (it runs to completion in the bridge FIFO, burning quota, + // and `session/cancel` could only reach the latest controller). + binding.promptAbort?.abort(); + const abort = new AbortController(); + binding.promptAbort = abort; + try { + const result = await this.bridge.sendPrompt( + sessionId, + // SECURITY NOTE: `params.sessionId` already equals the routing + // `sessionId` (both from the same params), so there's no routing + // divergence today. If the bridge ever trusts an additional + // `sendPrompt` field by name (e.g. a priority/temperature override), + // force-stamp it here like the REST surface does (`{ ...body, + // sessionId, prompt }`) so it can't become client-controlled. + params as unknown as Parameters[1], + abort.signal, + this.sessionCtx(conn, sessionId, fromLoopback), + ); + if (id !== undefined) this.replySession(conn, sessionId, id, result); + } catch (err) { + const { code, message } = toRpcError(err); + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, code, message), + ); + } else { + // Notification-form prompt (no id): no response frame to send, so a + // failure would vanish silently — log it for the operator. + writeStderrLine( + `qwen serve: /acp prompt error (${logSafe(sessionId)}, notification): ${logSafe(errMsg(err))}`, + ); + } + } finally { + if (binding.promptAbort === abort) binding.promptAbort = undefined; + } + } + + private replyConn( + conn: AcpConnection, + id: JsonRpcId | undefined, + result: unknown, + ): void { + if (id === undefined) return; + conn.sendConn(success(id, result)); + } + + private replySession( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + result: unknown, + errorFrame?: ReturnType, + ): void { + if (id === undefined) return; + const frame = errorFrame ?? success(id, result); + // If the session was torn down mid-flight (e.g. a concurrent + // `session/close`), the binding + session stream are gone and + // `sendSession` is lookup-only — it would SILENTLY DROP this frame, + // violating the JSON-RPC one-response-per-request contract. Fall back to + // the connection-scoped stream so an id'd request always gets its reply. + if (conn.sessions.has(sessionId)) { + conn.sendSession(sessionId, frame); + } else { + // Fallback fired — log it so an operator can correlate "reply arrived on + // the connection stream, not the session stream" with a mid-flight + // session teardown. + writeStderrLine( + `qwen serve: /acp replySession(${logSafe(sessionId)}) binding gone mid-flight, ` + + `reply routed to connection stream ${conn.connectionId.slice(0, 8)}`, + ); + conn.sendConn(frame); + } + } +} + +// Re-export so tests can reference the request type without the jsonRpc path. +export type { JsonRpcRequest }; diff --git a/packages/cli/src/serve/acpHttp/index.ts b/packages/cli/src/serve/acpHttp/index.ts new file mode 100644 index 00000000000..9b2794e80ff --- /dev/null +++ b/packages/cli/src/serve/acpHttp/index.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, Response } from 'express'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { AcpDispatcher } from './dispatch.js'; +import { ConnectionRegistry } from './connectionRegistry.js'; +import { SseStream } from './sseStream.js'; +import { RPC, error as rpcError, isRequest, parseInbound } from './jsonRpc.js'; + +export const ACP_CONNECTION_HEADER = 'acp-connection-id'; +export const ACP_SESSION_HEADER = 'acp-session-id'; + +/** + * Grace window after the connection-scoped SSE stream closes before the + * connection is reaped (if not reconnected and no session stream is live). + * Long enough to ride out a transient blip / reconnect, short enough to free + * `ownedSessions` + a `maxConnections` slot well before the 30-min idle TTL. + */ +const CONN_GRACE_MS = 10_000; + +export interface MountAcpHttpOptions { + boundWorkspace: string; + /** Defaults to `process.env.QWEN_SERVE_ACP_HTTP !== '0'`. */ + enabled?: boolean; + /** Mount path; defaults to `/acp`. */ + path?: string; + /** Concurrent-connection cap; `0` disables. Defaults to the registry default. */ + maxConnections?: number; +} + +export interface AcpHttpHandle { + dispose(): void; + registry: ConnectionRegistry; +} + +/** + * Mount the official ACP Streamable HTTP transport (RFD #721) on an + * existing Express app, backed by the shared `HttpAcpBridge`. Additive: + * the REST surface (`/session/*`) is untouched (design doc §6). + * + * Wire shape (single `/acp` endpoint): + * - POST {initialize} → 200 + capabilities JSON + `Acp-Connection-Id` + * - POST {other} → 202; reply delivered on a long-lived SSE stream + * - GET (conn header) → connection-scoped SSE stream + * - GET (conn+session)→ session-scoped SSE stream + * - DELETE → 202; tears the connection down + */ +export function mountAcpHttp( + app: Application, + bridge: HttpAcpBridge, + opts: MountAcpHttpOptions, +): AcpHttpHandle | undefined { + const enabled = opts.enabled ?? process.env['QWEN_SERVE_ACP_HTTP'] !== '0'; + if (!enabled) return undefined; + + const path = opts.path ?? '/acp'; + const dispatcher = new AcpDispatcher(bridge, opts.boundWorkspace); + // When a session/connection tears down with a permission still pending, + // cancel it on the bridge so the agent's prompt isn't left blocked. + const registry = new ConnectionRegistry( + (req, clientId) => dispatcher.cancelAbandonedPermission(req, clientId), + // Best-effort bridge detach so a torn-down connection's bridge-stamped + // client ids don't linger in the bridge's voter/known-client sets. + (sessionId, clientId) => { + void bridge.detachClient(sessionId, clientId).catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp detachClient(${sessionId}) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }, + opts.maxConnections, + ); + + // ── POST /acp ────────────────────────────────────────────────────── + app.post(path, async (req: Request, res: Response) => { + const parsed = parseInbound(req.body); + if (!parsed.ok) { + writeStderrLine( + `qwen serve: /acp malformed request from ${req.socket?.remoteAddress}: ${parsed.error.error.message}`, + ); + res.status(400).json(parsed.error); + return; + } + const message = parsed.message; + + // `initialize` mints a connection and replies inline (200 + JSON). + if (isRequest(message) && message.method === 'initialize') { + const conn = registry.create(isLoopbackReq(req)); + if (!conn) { + // Connection cap reached — shed load rather than grow unbounded. + writeStderrLine( + `qwen serve: /acp connection cap reached (max=${registry.connectionCap}), rejecting initialize`, + ); + res.setHeader('Retry-After', '5'); + res + .status(503) + .json( + rpcError( + message.id, + RPC.INTERNAL_ERROR, + 'Too many ACP connections; retry later', + ), + ); + return; + } + const requestedVersion = + message.params && + typeof message.params === 'object' && + !Array.isArray(message.params) + ? (message.params as Record)['protocolVersion'] + : undefined; + res.setHeader('Acp-Connection-Id', conn.connectionId); + res.status(200).json({ + // success envelope: clients correlate by the request id. + jsonrpc: '2.0', + id: message.id, + result: dispatcher.buildInitializeResult( + conn.connectionId, + requestedVersion, + ), + }); + writeStderrLine( + `qwen serve: /acp connection established ${conn.connectionId.slice(0, 8)} ` + + `(loopback=${conn.fromLoopback}, active=${registry.size})`, + ); + return; + } + + const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); + if (!conn) { + res + .status(400) + .json( + rpcError( + isRequest(message) ? message.id : null, + RPC.INVALID_REQUEST, + 'Missing or unknown Acp-Connection-Id', + ), + ); + return; + } + + // Per RFD: non-initialize POST acks 202; the reply rides an SSE stream. + res.status(202).end(); + // Response already sent — `handle` delivers everything else over SSE, so + // swallow+log any late rejection rather than let it escape as an + // unhandled rejection (which could take the daemon down). + await dispatcher + .handle( + conn, + message, + headerOf(req, ACP_SESSION_HEADER), + isLoopbackReq(req), + ) + .catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp handle error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }); + + // ── GET /acp (SSE) ───────────────────────────────────────────────── + app.get(path, (req: Request, res: Response) => { + const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); + if (!conn) { + res.status(400).json({ error: 'Missing or unknown Acp-Connection-Id' }); + return; + } + const sessionId = headerOf(req, ACP_SESSION_HEADER); + + if (!sessionId) { + // Connection-scoped stream. onClose logs the disconnect so a + // half-dead connection (conn stream gone, replies silently buffering) + // leaves an operator breadcrumb. + const connId = conn.connectionId; + const stream = new SseStream( + res, + () => { + writeStderrLine( + `qwen serve: /acp connection stream closed (${connId.slice(0, 8)})`, + ); + // Grace-period reap: a dead connection otherwise locks its + // ownedSessions + counts against maxConnections for the full 30-min + // idle TTL. After the grace window, reap UNLESS a reconnect + // re-attached the conn stream (clears the timer) OR a session + // stream is still live (client is active — only the conn stream + // blipped, don't kill its sessions/prompts). + conn.clearGraceTimer(); + conn.connGraceTimer = setTimeout(() => { + if ( + registry.get(connId) === conn && + conn.connStream === stream && + !conn.hasLiveSessionStream() + ) { + writeStderrLine( + `qwen serve: /acp reaping connection ${connId.slice(0, 8)} (conn stream gone, no live session stream)`, + ); + registry.delete(connId); + } + }, CONN_GRACE_MS); + conn.connGraceTimer.unref?.(); + }, + () => conn.touch(), + ); + stream.open(); + conn.attachConnStream(stream); + return; + } + + // Session-scoped stream — only for a session THIS connection owns + // (created via session/new or attached via session/load|resume). Stops + // one connection eavesdropping on another's session event stream. + if (!conn.ownsSession(sessionId)) { + res.status(403).json({ error: 'Session not owned by this connection' }); + return; + } + + // Fresh controller per stream so a reconnect gets a live (non-aborted) + // signal; `attachSessionStream` installs it and tears down any prior + // stream/subscription. onClose aborts THIS stream's controller — a + // stale stream closing can't cancel a newer subscription. + const ac = new AbortController(); + const stream = new SseStream( + res, + () => { + // Stream closed (tab close / network drop / crash): stop the event + // pump AND abort any in-flight prompt for this session — otherwise + // the agent keeps running (quota, FIFO) until idle TTL. + ac.abort(); + // BUT only abort the prompt when THIS is still the session's live + // stream. A reconnect already installed a newer stream — the prompt + // must survive the old stream's close. CONTRACT: this identity guard + // pairs with `attachSessionStream`'s install-before-close ordering + // (connectionRegistry.ts) — keep both in lockstep. + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.sessions.get(sessionId)?.promptAbort?.abort(); + } + }, + () => conn.touch(), + ); + // Open (write SSE headers + `retry:`) BEFORE attaching, so the protocol + // handshake precedes any buffered frames the attach flushes. + stream.open(); + conn.attachSessionStream(sessionId, stream, ac); + // Identity-guarded close: only tear down if THIS stream is still the + // session's current one (a reconnect between settle and this microtask + // would otherwise kill the fresh stream). + const closeIfCurrent = () => { + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.closeSessionStream(sessionId); + } + }; + void dispatcher.pumpSessionEvents(conn, sessionId, ac.signal).then( + // NORMAL completion (iterator returned `done` — subprocess ended): close + // so the stream isn't a zombie heartbeating with nothing left to deliver. + closeIfCurrent, + (err: unknown) => { + writeStderrLine( + `qwen serve: /acp event pump error (${sessionId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + closeIfCurrent(); + }, + ); + }); + + // ── DELETE /acp ──────────────────────────────────────────────────── + app.delete(path, (req: Request, res: Response) => { + const connectionId = headerOf(req, ACP_CONNECTION_HEADER); + if (!connectionId) { + res.status(400).json({ error: 'Missing Acp-Connection-Id' }); + return; + } + // NOTE: like every other route, DELETE is gated only by the bearer + // token — the daemon's trust boundary is "holds the token for this + // single-workspace daemon", so any token-holder may tear down any + // connection (same posture as the REST `DELETE /session/:id`). A + // per-connection secret would add intra-token isolation; deferred with + // the rest of the multi-tenant hardening (design §7). + const existed = registry.delete(connectionId); + if (existed) { + writeStderrLine( + `qwen serve: /acp connection deleted ${connectionId.slice(0, 8)} (remaining=${registry.size})`, + ); + } + res.status(202).end(); + }); + + return { dispose: () => registry.dispose(), registry }; +} + +function headerOf(req: Request, name: string): string | undefined { + const v = req.headers[name]; + return Array.isArray(v) ? v[0] : v; +} + +/** + * True when the request's KERNEL-stamped peer address is loopback. Mirrors + * the REST surface's `detectFromLoopback` (NOT derived from forgeable + * headers like `X-Forwarded-For`). Replicated here rather than imported + * from `server.ts` to avoid a server↔acpHttp import cycle. + */ +function isLoopbackReq(req: Request): boolean { + const addr = req.socket?.remoteAddress; + if (typeof addr !== 'string') return false; + // Match the REST surface's `detectFromLoopback`: the full 127.0.0.0/8 + // range + the IPv4-mapped block, not just three exact literals (a + // container peer on 127.0.0.2 is legal loopback). + return ( + addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.') + ); +} diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.test.ts b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts new file mode 100644 index 00000000000..43281887ad0 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + isNotification, + isRequest, + isResponse, + parseInbound, + QWEN_METHOD_NS, + RPC, +} from './jsonRpc.js'; + +describe('jsonRpc helpers', () => { + it('classifies a request', () => { + const m = { jsonrpc: '2.0', id: 1, method: 'initialize' }; + expect(isRequest(m)).toBe(true); + expect(isNotification(m)).toBe(false); + expect(isResponse(m)).toBe(false); + }); + + it('classifies a notification (no id)', () => { + const m = { jsonrpc: '2.0', method: 'session/cancel' }; + expect(isNotification(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies a response (result, no method)', () => { + const m = { jsonrpc: '2.0', id: -1, result: { ok: true } }; + expect(isResponse(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies an error response', () => { + const m = { jsonrpc: '2.0', id: 2, error: { code: -1, message: 'x' } }; + expect(isResponse(m)).toBe(true); + }); + + it('rejects a response with BOTH result and error (XOR); parseInbound → 400-shape', () => { + const m = { + jsonrpc: '2.0', + id: 3, + result: {}, + error: { code: -1, message: 'x' }, + }; + expect(isResponse(m)).toBe(false); + const r = parseInbound(m); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects JSON-RPC batch arrays', () => { + const r = parseInbound([{ jsonrpc: '2.0', id: 1, method: 'x' }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects malformed envelopes', () => { + expect(parseInbound({ foo: 'bar' }).ok).toBe(false); + expect(parseInbound(null).ok).toBe(false); + }); + + it('accepts a well-formed request', () => { + const r = parseInbound({ jsonrpc: '2.0', id: 1, method: 'session/new' }); + expect(r.ok).toBe(true); + }); + + it('exposes the qwen extension namespace', () => { + expect(QWEN_METHOD_NS).toBe('_qwen/'); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.ts b/packages/cli/src/serve/acpHttp/jsonRpc.ts new file mode 100644 index 00000000000..71339e69c6f --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal JSON-RPC 2.0 helpers for the ACP-over-HTTP transport + * (`packages/cli/src/serve/acpHttp/`). The official ACP Streamable HTTP + * transport (RFD #721) frames every message as a JSON-RPC 2.0 object; + * this module owns the wire types + parse/validate/serialize so the + * dispatcher stays focused on bridge routing. + * + * We hand-roll framing (rather than reuse `@agentclientprotocol/sdk`'s + * `ndJsonStream`) because the RFD splits a single logical connection + * across multiple long-lived SSE streams (connection-scoped + one per + * session), so outbound frames must be demultiplexed to the right + * stream — something a single duplex `Connection` can't express. + */ + +/** + * Vendor extension namespace. ACP reserves any `_`-prefixed method for + * extensions (the ONLY hard rule); the spec's `_zed.dev/…` example shows a + * domain-style segment by convention, but `qwen` is distinctive enough that + * we use the shorter bare form `_qwen/…`. Vendor data on standard messages + * goes under `_meta` keyed by the same name (`_meta: { "qwen": … }`). + */ +export const QWEN_METHOD_NS = '_qwen/'; +/** Key for vendor `_meta` blocks (capabilities + per-message data). */ +export const QWEN_META_KEY = 'qwen'; + +export type JsonRpcId = number | string; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcErrorObject { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId | null; + error: JsonRpcErrorObject; +} + +export type JsonRpcOutbound = JsonRpcRequest | JsonRpcNotification; +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; +export type JsonRpcInbound = + | JsonRpcRequest + | JsonRpcNotification + | JsonRpcResponse; + +/** Standard JSON-RPC 2.0 error codes. */ +export const RPC = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +} as const; + +export function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function isRequest(m: unknown): m is JsonRpcRequest { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + 'id' in m && + m['id'] !== null && + (typeof m['id'] === 'number' || typeof m['id'] === 'string') + ); +} + +export function isNotification(m: unknown): m is JsonRpcNotification { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + !('id' in m) + ); +} + +export function isResponse(m: unknown): m is JsonRpcResponse { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + !('method' in m) && + 'id' in m && + // JSON-RPC 2.0 §5: EXACTLY one of result/error (XOR). Accepting both + // would let a buggy client's approval (result + error) be misread as a + // cancellation by the `'error' in msg` check downstream. A dual-field + // message therefore fails isRequest/isNotification/isResponse → + // `parseInbound` rejects it → the POST handler returns 400 (logged by the + // malformed-request path in index.ts), so the client is told its vote was + // not accepted (not a silent drop); it can retry with a valid response, + // and teardown still releases the pending entry if it doesn't. + 'result' in m !== 'error' in m + ); +} + +/** + * Strip C0 control chars + DEL from values interpolated into operator-facing + * stderr logs, so a client-controlled `sessionId`/`method`/error string can't + * forge or split log lines (log injection). Shared by the transport modules. + */ +export function logSafe(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, ' '); +} + +export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} + +export function error( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown, +): JsonRpcError { + return { + jsonrpc: '2.0', + id, + error: { code, message, ...(data !== undefined ? { data } : {}) }, + }; +} + +export function notification( + method: string, + params: unknown, +): JsonRpcNotification { + return { jsonrpc: '2.0', method, params }; +} + +export function request( + id: JsonRpcId, + method: string, + params: unknown, +): JsonRpcRequest { + return { jsonrpc: '2.0', id, method, params }; +} + +/** + * Parse a request body into a JSON-RPC message. Returns `{ ok: false }` + * with a ready-to-send error on malformed JSON or a non-conforming + * envelope (batch arrays are rejected per RFD §"batch → 501", surfaced + * here as INVALID_REQUEST since we never reach the 501 path). + */ +export function parseInbound( + raw: unknown, +): { ok: true; message: JsonRpcInbound } | { ok: false; error: JsonRpcError } { + if (Array.isArray(raw)) { + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'JSON-RPC batch not supported'), + }; + } + if (isRequest(raw) || isNotification(raw) || isResponse(raw)) { + return { ok: true, message: raw }; + } + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'Malformed JSON-RPC message'), + }; +} diff --git a/packages/cli/src/serve/acpHttp/sseStream.test.ts b/packages/cli/src/serve/acpHttp/sseStream.test.ts new file mode 100644 index 00000000000..500fe270728 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Response } from 'express'; +import { SseStream } from './sseStream.js'; + +/** + * Minimal Express `Response` mock: an EventEmitter with the `write`/`end`/ + * header surface `SseStream` touches. `writeBehavior` lets a test force + * `res.write` to return false (backpressure) or throw (socket error). + */ +function mockRes(writeBehavior?: () => boolean) { + const ee = new EventEmitter() as unknown as Response & { + chunks: string[]; + ended: boolean; + }; + const m = ee as unknown as { + chunks: string[]; + ended: boolean; + writableEnded: boolean; + status: () => unknown; + setHeader: () => void; + flushHeaders: () => void; + write: (c: string) => boolean; + end: () => void; + req: EventEmitter; + }; + m.chunks = []; + m.ended = false; + m.writableEnded = false; + m.status = () => ee; + m.setHeader = () => {}; + m.flushHeaders = () => {}; + m.req = new EventEmitter(); + m.write = (chunk: string) => { + m.chunks.push(chunk); + return writeBehavior ? writeBehavior() : true; + }; + m.end = () => { + m.ended = true; + m.writableEnded = true; + }; + return ee as unknown as Response & { chunks: string[]; ended: boolean }; +} + +describe('SseStream', () => { + afterEach(() => vi.useRealTimers()); + + it('open() writes the retry hint; send() writes a data: frame', async () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + await s.send({ jsonrpc: '2.0', id: 1, result: { ok: true } }); + const joined = (res as unknown as { chunks: string[] }).chunks.join(''); + expect(joined).toContain('retry: 3000'); + expect(joined).toContain( + 'data: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n', + ); + }); + + it('close() ends the response once and is idempotent', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + s.close(); + expect((res as unknown as { ended: boolean }).ended).toBe(true); + expect(s.isClosed).toBe(true); + s.close(); // no throw on double close + }); + + it('close() swallows a throwing onClose callback', () => { + const res = mockRes(); + const s = new SseStream(res, () => { + throw new Error('onClose boom'); + }); + s.open(); + expect(() => s.close()).not.toThrow(); + expect(s.isClosed).toBe(true); + }); + + it('a write failure closes the stream and fires onClose', async () => { + let closed = false; + const res = mockRes(() => { + throw new Error('EPIPE'); + }); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); // retry write throws → chain catch closes + await new Promise((r) => setTimeout(r, 10)); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('heartbeat fires onHeartbeat on the interval', () => { + vi.useFakeTimers(); + let beats = 0; + const res = mockRes(); + const s = new SseStream(res, undefined, () => { + beats++; + }); + s.open(); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(1); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(2); + s.close(); + }); + + it('a req "close" event auto-closes the stream and fires onClose', () => { + let closed = false; + const res = mockRes(); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); + (res as unknown as { req: EventEmitter }).req.emit('close'); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('a res "error" event auto-closes the stream', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + (res as unknown as EventEmitter).emit('error', new Error('ECONNRESET')); + expect(s.isClosed).toBe(true); + }); + + it('doWrite resolves after drain when write() returns false (backpressure)', async () => { + let backpressured = true; + const res = mockRes(() => !backpressured); // false first → drain needed + const s = new SseStream(res); + s.open(); + const p = s.send({ id: 2 }); + let settled = false; + void p.then(() => (settled = true)); + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); // still awaiting drain + backpressured = false; + (res as unknown as EventEmitter).emit('drain'); + await p; + expect(settled).toBe(true); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/sseStream.ts b/packages/cli/src/serve/acpHttp/sseStream.ts new file mode 100644 index 00000000000..dfac5745c68 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Response } from 'express'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +/** + * A long-lived Server-Sent-Events writer for the ACP-over-HTTP transport. + * + * Unlike the REST `/session/:id/events` stream (qwen event envelopes), the + * ACP transport carries raw JSON-RPC 2.0 objects as the SSE `data:` payload + * — one object per frame. The RFD keeps these streams open for the life of + * the connection/session, so the writer must: + * - serialize writes through a single chain (heartbeat can't interleave), + * - respect backpressure (`res.write` → false ⇒ await `drain`), + * - emit periodic comment heartbeats to keep NAT/proxies alive. + * + * This mirrors the battle-tested pattern in `server.ts`'s SSE handler but + * trimmed to what the ACP transport needs (no ring-buffer `id:` sequencing — + * resumability is RFD Phase 4, deferred per the design doc §7). + */ +export class SseStream { + private writeChain: Promise = Promise.resolve(); + private heartbeat: ReturnType | undefined; + private closed = false; + private cleanupFn: (() => void) | undefined; + + constructor( + private readonly res: Response, + private readonly onClose?: () => void, + /** + * Fired on each heartbeat tick while the stream is open. Used to mark the + * connection active so a long-running prompt that emits no intermediate + * frames for >30 min isn't reaped by the idle-TTL sweep. + */ + private readonly onHeartbeat?: () => void, + ) {} + + /** Write SSE headers + retry hint and start the heartbeat. */ + open(): void { + this.res.status(200); + this.res.setHeader('Content-Type', 'text/event-stream'); + this.res.setHeader('Cache-Control', 'no-cache, no-transform'); + this.res.setHeader('Connection', 'keep-alive'); + this.res.setHeader('X-Accel-Buffering', 'no'); + this.res.flushHeaders(); + void this.writeRaw('retry: 3000\n\n'); + + this.heartbeat = setInterval(() => { + if (this.closed) return; + this.onHeartbeat?.(); + void this.writeRaw(': hb\n\n'); + }, 15_000); + this.heartbeat.unref(); + + this.cleanupFn = () => this.close(); + this.res.req.on('close', this.cleanupFn); + this.res.on('error', this.cleanupFn); + } + + /** Serialize a JSON-RPC message as one SSE frame. */ + send(message: unknown): Promise { + return this.writeRaw(`data: ${JSON.stringify(message)}\n\n`); + } + + get isClosed(): boolean { + return this.closed; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.cleanupFn) { + this.res.req.off('close', this.cleanupFn); + this.res.off('error', this.cleanupFn); + this.cleanupFn = undefined; + } + try { + if (!this.res.writableEnded) this.res.end(); + } catch { + // socket already gone — nothing to flush + } + // Guard `onClose`: `close()` can run inside a socket `'error'`/`'close'` + // event handler, and a throwing callback there would escape into Node's + // emitter stack (potential crash). Swallow + log instead. + try { + this.onClose?.(); + } catch (err) { + writeStderrLine( + `qwen serve: /acp SSE onClose threw: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + private writeRaw(chunk: string): Promise { + const next = this.writeChain.then(() => this.doWrite(chunk)); + // The stream OWNS write-failure handling: callers fire-and-forget + // (`void stream.send(...)`), so a broken socket would otherwise leave a + // zombie stream (heartbeats firing, no events delivered, no log). On the + // first failure, log once and close so the subscription tears down. + this.writeChain = next.catch((err: unknown) => { + if (!this.closed) { + writeStderrLine( + `qwen serve: /acp SSE write failed, closing stream: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + this.close(); + } + return undefined; + }); + return next; + } + + private doWrite(chunk: string): Promise { + return new Promise((resolve, reject) => { + if (this.closed || this.res.writableEnded) { + resolve(); + return; + } + let ok: boolean; + try { + ok = this.res.write(chunk); + } catch (err) { + reject(err as Error); + return; + } + if (ok) { + resolve(); + return; + } + // Await drain, but also bail on close/error so a socket failure during + // the drain window rejects promptly instead of hanging until 'close'. + const onDrain = () => { + this.res.off('close', onCloseEv); + this.res.off('error', onErrorEv); + resolve(); + }; + const onCloseEv = () => { + this.res.off('drain', onDrain); + this.res.off('error', onErrorEv); + resolve(); + }; + const onErrorEv = (err: Error) => { + this.res.off('drain', onDrain); + this.res.off('close', onCloseEv); + reject(err); + }; + this.res.once('drain', onDrain); + this.res.once('close', onCloseEv); + this.res.once('error', onErrorEv); + }); + } +} diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts new file mode 100644 index 00000000000..5a6e2e4b276 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -0,0 +1,1441 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { mountAcpHttp } from './index.js'; + +/** + * End-to-end transport test: boots a real Express server with the ACP + * Streamable-HTTP transport mounted over a *fake* bridge, then drives it + * with a real HTTP client (global fetch + manual SSE parsing). This is + * the automated form of the design doc's local verification plan — it + * exercises the actual wire protocol (200/202 conventions, both SSE + * streams, JSON-RPC framing) without needing a model. + */ + +interface PushIterable { + iterable: AsyncIterable; + push: (e: Omit) => void; + end: () => void; +} + +function pushQueue(signal?: AbortSignal): PushIterable { + const buf: BridgeEvent[] = []; + let resolveNext: (() => void) | undefined; + let done = false; + let nextId = 1; + const wake = () => { + resolveNext?.(); + resolveNext = undefined; + }; + signal?.addEventListener('abort', () => { + done = true; + wake(); + }); + const iterable: AsyncIterable = { + async *[Symbol.asyncIterator]() { + while (true) { + while (buf.length) yield buf.shift()!; + if (done) return; + await new Promise((r) => (resolveNext = r)); + } + }, + }; + return { + iterable, + push: (e) => { + buf.push({ v: 1, id: nextId++, ...e } as BridgeEvent); + wake(); + }, + end: () => { + done = true; + wake(); + }, + }; +} + +// A controllable fake bridge: tests register what `sendPrompt` should do. +class FakeBridge { + queues = new Map(); + promptBehavior: + | (( + sessionId: string, + q: PushIterable, + signal?: AbortSignal, + ) => Promise) + | undefined; + lastSetModel: unknown; + lastSpawnScope: string | undefined; + closeShouldThrow = false; + killed: string[] = []; + cancelled: string[] = []; + /** When set, spawnOrAttach/loadSession await it (to simulate a slow bridge). */ + gate: Promise | undefined; + /** `attached` value loadSession returns (false = spawned-from-disk). */ + loadAttached = true; + + closedSessions: string[] = []; + + async spawnOrAttach(req: { sessionScope?: string }) { + this.lastSpawnScope = req?.sessionScope; + if (this.gate) await this.gate; + return { + sessionId: 'sess-1', + workspaceCwd: '/ws', + attached: false, + clientId: 'client-1', + }; + } + async killSession(sessionId: string) { + this.killed.push(sessionId); + } + + loadShouldThrow = false; + + async loadSession(req: { sessionId: string }) { + if (this.loadShouldThrow) throw new Error('load failed'); + if (this.gate) await this.gate; + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: this.loadAttached, + clientId: 'client-load', + state: { replayed: true }, + }; + } + + async resumeSession(req: { sessionId: string }) { + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: true, + clientId: 'client-resume', + state: { resumed: true }, + }; + } + + subscribeThrows = false; + + subscribeEvents(sessionId: string, opts?: { signal?: AbortSignal }) { + if (this.subscribeThrows) throw new Error('subscribe failed'); + const q = pushQueue(opts?.signal); + this.queues.set(sessionId, q); + return q.iterable; + } + + async sendPrompt(sessionId: string, _req: unknown, signal?: AbortSignal) { + const q = this.queues.get(sessionId); + if (this.promptBehavior && q) + return this.promptBehavior(sessionId, q, signal); + return { stopReason: 'end_turn' }; + } + + respondToSessionPermission() { + return true; + } + + async setSessionModel(_s: string, req: unknown) { + this.lastSetModel = req; + return { modelServiceId: 'qwen-max' }; + } + + lastApprovalMode: string | undefined; + async setSessionApprovalMode(_s: string, mode: string) { + this.lastApprovalMode = mode; + return { sessionId: 'sess-1', mode, previous: 'default', persisted: false }; + } + + // Session config options live in the child's session context state. + async getSessionContextStatus(sessionId: string) { + return { + v: 1, + sessionId, + workspaceCwd: '/ws', + state: { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'qwen-max', + options: [], + }, + ], + }, + }; + } + async getSessionSupportedCommandsStatus(sessionId: string) { + return { v: 1, sessionId, availableCommands: [], availableSkills: [] }; + } + async getWorkspaceMcpStatus() { + return { ok: true, v: 1, workspaceCwd: '/ws' }; + } + async getWorkspaceSkillsStatus() { + return { ok: true }; + } + async getWorkspaceProvidersStatus() { + return { ok: true }; + } + async getWorkspaceEnvStatus() { + return { ok: true }; + } + async getWorkspacePreflightStatus() { + return { ok: true }; + } + updateSessionMetadata(_s: string, metadata: unknown) { + return metadata; + } + async setWorkspaceToolEnabled(toolName: string, enabled: boolean) { + return { toolName, enabled }; + } + async initWorkspace() { + return { path: '/ws/QWEN.md', action: 'created' as const }; + } + async restartMcpServer() { + return { ok: true }; + } + + recordHeartbeat() { + return { sessionId: 'sess-1', lastSeenAt: Date.now() }; + } + + listWorkspaceSessions() { + return []; + } + + detached: Array<{ sessionId: string; clientId?: string }> = []; + + async cancelSession(sessionId: string) { + this.cancelled.push(sessionId); + } + closeGate: Promise | undefined; + async closeSession(sessionId: string) { + this.closedSessions.push(sessionId); + if (this.closeGate) await this.closeGate; + if (this.closeShouldThrow) throw new Error('bridge close failed'); + } + async detachClient(sessionId: string, clientId?: string) { + this.detached.push({ sessionId, clientId }); + } +} + +// ── SSE client helper ──────────────────────────────────────────────── +async function* readSse( + res: Response, + signal: AbortSignal, +): AsyncGenerator { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + signal.addEventListener('abort', () => void reader.cancel().catch(() => {})); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const dataLine = frame.split('\n').find((l) => l.startsWith('data: ')); + if (dataLine) yield JSON.parse(dataLine.slice('data: '.length)); + } + } +} + +/** Read the next N data frames from an SSE response, then abort. */ +async function takeFrames( + res: Response, + n: number, + timeoutMs = 2000, +): Promise { + const out: unknown[] = []; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + for await (const f of readSse(res, ac.signal)) { + out.push(f); + if (out.length >= n) break; + } + } finally { + clearTimeout(timer); + ac.abort(); + } + return out; +} + +describe('ACP Streamable HTTP transport (over the wire)', () => { + let server: Server; + let base: string; + let bridge: FakeBridge; + + beforeEach(async () => { + bridge = new FakeBridge(); + const app = express(); + app.use(express.json()); + mountAcpHttp(app, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + enabled: true, + }); + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address() as AddressInfo; + base = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(async () => { + // Force-close any long-lived SSE sockets a test left open so + // `server.close()` doesn't hang on them. + server.closeAllConnections?.(); + await new Promise((r) => server.close(() => r())); + }); + + async function initialize(): Promise { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }); + expect(res.status).toBe(200); + const connId = res.headers.get('acp-connection-id'); + expect(connId).toBeTruthy(); + const body = (await res.json()) as { result: { protocolVersion: number } }; + expect(body.result.protocolVersion).toBe(1); + return connId!; + } + + function post(connId: string, msg: unknown) { + return fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + }, + body: JSON.stringify(msg), + }); + } + + function openStream(connId: string, sessionId?: string) { + const headers: Record = { + accept: 'text/event-stream', + 'acp-connection-id': connId, + }; + if (sessionId) headers['acp-session-id'] = sessionId; + return fetch(`${base}/acp`, { headers }); + } + + // Establish ownership of the fake bridge's session ('sess-1') so the + // ownership-gated session stream + per-session POSTs are allowed. + async function newSession(connId: string, id = 99): Promise { + await post(connId, { + jsonrpc: '2.0', + id, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // let handle() register ownership + } + + it('initialize → 200 + Acp-Connection-Id; unknown conn → 400', async () => { + await initialize(); + const bad = await post('nope', { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + }); + expect(bad.status).toBe(400); + }); + + it('session/new reply rides the connection-scoped stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + // Give the SSE handshake a tick before POSTing. + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: '/ws' }, + }); + expect(ack.status).toBe(202); + const [frame] = (await got) as Array<{ + id: number; + result: { sessionId: string }; + }>; + expect(frame.id).toBe(2); + expect(frame.result.sessionId).toBe('sess-1'); + }); + + it('prompt streams session/update then the final result', async () => { + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { sessionUpdate: 'agent_message_chunk' }, + }, + }); + await new Promise((r) => setTimeout(r, 20)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 2); + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 5, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + expect(ack.status).toBe(202); + const frames = (await got) as Array>; + expect(frames[0]['method']).toBe('session/update'); + expect( + (frames[1] as { id: number; result: { stopReason: string } }).id, + ).toBe(5); + expect( + (frames[1] as { result: { stopReason: string } }).result.stopReason, + ).toBe('end_turn'); + }); + + it('permission request round-trips agent→client→agent', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-1', + sessionId: 'sess-1', + toolCall: { name: 'shell' }, + options: [{ optionId: 'allow', name: 'Allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 30)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 7, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'rm' }] }, + }); + const [reqFrame] = (await got) as Array<{ + id: number; + method: string; + params: { _meta: Record }; + }>; + expect(reqFrame.method).toBe('session/request_permission'); + expect(reqFrame.params._meta['qwen'].requestId).toBe('perm-1'); + // Client answers with a JSON-RPC response echoing the issued id. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(resolvedWith).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + }); + + it('standard session/set_config_option (model) routes to the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 9, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: 'qwen-max' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { configOptions: unknown }; + }>; + expect(frame.id).toBe(9); + expect(bridge.lastSetModel).toMatchObject({ modelId: 'qwen-max' }); + }); + + it('session/set_config_option (mode) routes to setSessionApprovalMode', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 10, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'yolo' }, + }); + await got; + expect(bridge.lastApprovalMode).toBe('yolo'); + }); + + it('_qwen/workspace/mcp introspection reaches the bridge', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 12, + method: '_qwen/workspace/mcp', + }); + const [frame] = (await got) as Array<{ + id: number; + result: { ok: boolean }; + }>; + expect(frame.id).toBe(12); + expect(frame.result.ok).toBe(true); + }); + + it('unknown method → JSON-RPC method-not-found on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { jsonrpc: '2.0', id: 11, method: 'bogus/method' }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32601); + }); + + it('session stream for an unowned session → 403', async () => { + const connId = await initialize(); + // No session/new → connection does not own 'sess-1'. + const res = await openStream(connId, 'sess-1'); + expect(res.status).toBe(403); + }); + + it('prompt for an unowned session → INVALID_PARAMS on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 13, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('Acp-Session-Id header that disagrees with params.sessionId → INVALID_PARAMS', async () => { + // Cross-check fires before ownership, so no session/new needed (and + // skipping it keeps a buffered session/new reply off the conn stream). + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + 'acp-session-id': 'sess-1', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 14, + method: 'session/prompt', + params: { sessionId: 'OTHER', prompt: [{ type: 'text', text: 'x' }] }, + }), + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/load owns the session + replies state on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 20, + method: 'session/load', + params: { sessionId: 'loaded-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { replayed: boolean }; + }>; + expect(frame.id).toBe(20); + expect(frame.result.replayed).toBe(true); + // Ownership was granted, so the session stream is now allowed. + const sess = await openStream(connId, 'loaded-1'); + expect(sess.status).toBe(200); + await sess.body?.cancel(); // release the long-lived SSE socket + }); + + it('session/resume owns the session + replies state', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 21, + method: 'session/resume', + params: { sessionId: 'resumed-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { resumed: boolean }; + }>; + expect(frame.id).toBe(21); + expect(frame.result.resumed).toBe(true); + }); + + it('session/close reaches the bridge + replies on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + // 2 frames: the session/new reply (establishes ownership), then close. + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 22, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ id: number }>; + expect(frames.map((f) => f.id)).toContain(22); + expect(bridge.closedSessions).toContain('sess-1'); + }); + + it('initialize clamps protocolVersion to [1, 1]', async () => { + for (const [requested, expected] of [ + [0, 1], + [-3, 1], + [99, 1], + ['bad', 1], + ] as Array<[unknown, number]>) { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requested }, + }), + }); + const body = (await res.json()) as { + result: { protocolVersion: number }; + }; + expect(body.result.protocolVersion).toBe(expected); + } + }); + + it('session/load failure routes the error to the connection stream', async () => { + bridge.loadShouldThrow = true; + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 30, + method: 'session/load', + params: { sessionId: 'x' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.id).toBe(30); + expect(frame.error.code).toBe(-32603); + }); + + it('connection teardown detaches the session client from the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await new Promise((r) => setTimeout(r, 20)); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + }); + + it('malformed permission response still releases the bridge (cancel fallback)', async () => { + const votes: Array<{ outcome?: { outcome?: string } }> = []; + // Emulate the real bridge: throw on a vote with no `outcome`. + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + const r = resp as { outcome?: { outcome?: string } }; + if (!r?.outcome?.outcome) throw new Error('invalid permission response'); + votes.push(r); + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-x', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a malformed result (no outcome) → bridge throws → + // fallback must still cancel so the mediator is released. + await post(connId, { jsonrpc: '2.0', id: reqFrame.id, result: {} }); + await new Promise((r) => setTimeout(r, 50)); + expect(votes).toContainEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('a second concurrent prompt aborts the first', async () => { + let firstSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + if (!firstSignal) { + firstSignal = signal; + await new Promise((r) => + signal?.addEventListener('abort', () => r(), { once: true }), + ); + return { stopReason: 'cancelled' }; + } + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const drain = takeFrames(sessStream, 2); // both prompt results + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'a' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'b' }] }, + }); + await drain; + expect(firstSignal?.aborted).toBe(true); + }); + + it('subscribeEvents throwing closes the session stream promptly (no zombie)', async () => { + bridge.subscribeThrows = true; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + // The guarantee is that the server CLOSES the stream (not a zombie that + // heartbeats forever). A safety abort at 3s distinguishes "server closed" + // (loop ends fast) from "zombie" (only our timeout ends it). + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + // Server-initiated close arrives well under the 3s safety timeout. + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('concurrent session/close calls the bridge exactly once (no TOCTOU double-close)', async () => { + const connId = await initialize(); + await newSession(connId); + await Promise.all([ + post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + post(connId, { + jsonrpc: '2.0', + id: 71, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + ]); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions.filter((s) => s === 'sess-1')).toHaveLength(1); + }); + + it('clean iterator end closes the session stream (no zombie)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 50)); + // Subprocess ends cleanly → bridge event iterator returns done. + bridge.queues.get('sess-1')?.end(); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('session-stream reconnect does NOT abort the in-flight prompt', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, q, signal) => { + promptSignal = signal; + q.push({ + type: 'session_update', + data: { sessionId: 'sess-1', update: {} }, + }); + await new Promise((r) => setTimeout(r, 200)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const s1 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + // Reconnect: install the NEW stream and let it attach FIRST, then drop the + // old one. This deterministically exercises the invariant under test — + // the old (now-stale) stream's close must NOT abort the prompt because a + // newer stream is already the session's current one (install-before-close + // + identity-guarded onClose). (Attaching s2 before dropping s1 avoids a + // test-only race between s1.close and s2.attach under full-suite load.) + const s2 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await s1.body?.cancel(); + await new Promise((r) => setTimeout(r, 40)); + // The prompt must survive the reconnect. + expect(promptSignal?.aborted).toBe(false); + await s2.body?.cancel(); + }); + + it('prompt response is delivered even if the session closes mid-flight', async () => { + // Prompt resolves only after we close the session — exercises the + // binding-gone fallback (reply must ride the connection stream). + let release: () => void = () => {}; + bridge.promptBehavior = async (_s, _q) => { + await new Promise((r) => (release = r)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const sessStream = await openStream(connId, 'sess-1'); + // conn stream carries: buffered session/new reply (id 99), the close + // ack (id 91), AND the fallback prompt reply (id 90). + const connFrames = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 90, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + // Close the session while the prompt is still in flight, then let it resolve. + await post(connId, { + jsonrpc: '2.0', + id: 91, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + release(); + const frames = (await connFrames) as Array<{ id: number }>; + // The prompt's id-90 response must appear (on the conn stream, since the + // session binding is gone) — not silently dropped. + expect(frames.map((f) => f.id)).toContain(90); + await sessStream.body?.cancel(); + }); + + it('session/set_config_option rejects empty value (INVALID_PARAMS)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 41, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: '' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/set_config_option rejects an invalid mode value', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 42, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'bogus-mode' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + expect(bridge.lastApprovalMode).toBeUndefined(); + }); + + it('session/new forwards sessionScope; rejects invalid scope', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + // invalid scope → error on conn stream + await post(connId, { + jsonrpc: '2.0', + id: 43, + method: 'session/new', + params: { sessionScope: 'bogus' }, + }); + const [bad] = (await got) as Array<{ error: { code: number } }>; + expect(bad.error.code).toBe(-32602); + // valid scope → forwarded to bridge + const c2 = await initialize(); + await post(c2, { + jsonrpc: '2.0', + id: 44, + method: 'session/new', + params: { sessionScope: 'thread' }, + }); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.lastSpawnScope).toBe('thread'); + }); + + it('session/prompt with empty prompt → INVALID_PARAMS', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 45, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [] }, + }); + const [frame] = (await got) as Array<{ error: { code: number } }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/close runs local cleanup even if the bridge close throws', async () => { + bridge.closeShouldThrow = true; + const connId = await initialize(); + await newSession(connId); // creates + owns sess-1 + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 46, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions).toContain('sess-1'); // bridge was called (then threw) + // Local teardown ran in `finally` despite the throw → session unowned now. + const after = await openStream(connId, 'sess-1'); + expect(after.status).toBe(403); + }); + + it('connection cap → 503 on initialize', async () => { + const app2 = express(); + app2.use(express.json()); + mountAcpHttp(app2, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + enabled: true, + maxConnections: 1, + }); + const srv = app2.listen(0, '127.0.0.1'); + await new Promise((r) => srv.once('listening', r)); + const port = (srv.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/acp`; + const init = (n: number) => + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: n, method: 'initialize' }), + }); + const r1 = await init(1); + expect(r1.status).toBe(200); + const r2 = await init(2); + expect(r2.status).toBe(503); + expect(r2.headers.get('retry-after')).toBe('5'); + srv.closeAllConnections?.(); + await new Promise((r) => srv.close(() => r())); + }); + + it('session/cancel aborts the in-flight prompt and calls the bridge', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + promptSignal = signal; + await new Promise((r) => setTimeout(r, 300)); + return { stopReason: 'cancelled' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 51, + method: 'session/cancel', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 40)); + expect(promptSignal?.aborted).toBe(true); + expect(bridge.cancelled).toContain('sess-1'); + await sess.body?.cancel(); + }); + + it('session/new rejects bad cwd (non-string + relative) → INVALID_PARAMS', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/new', + params: { cwd: 123 }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/new', + params: { cwd: 'rel/path' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + }>; + for (const f of frames) expect(f.error?.code).toBe(-32602); + }); + + it('session/new orphan: DELETE before spawn resolves → bridge.killSession', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // spawnOrAttach now awaiting the gate + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); // spawn resolves AFTER destroy + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + }); + + it('session/load orphan (attached:false) → killSession, not detach', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + bridge.loadAttached = false; // restore SPAWNED from disk → must be killed + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(false); + }); + + it('_qwen/* introspection methods reach the bridge (conn-routed)', async () => { + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + // 4 frames: buffered session/new reply (id 99) + the 3 below. + const got = takeFrames(connStream, 4); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 200, + method: '_qwen/session/context', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 201, + method: '_qwen/session/heartbeat', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 202, + method: '_qwen/workspace/skills', + }); + const ids = ((await got) as Array<{ id?: number }>).map((f) => f.id); + expect(ids).toEqual(expect.arrayContaining([200, 201, 202])); + }); + + it('_qwen/workspace/set_tool_enabled + restart_mcp_server validate name', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 210, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: '', enabled: true }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 211, + method: '_qwen/workspace/restart_mcp_server', + params: { serverName: '' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: 'shell', enabled: false }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + result?: unknown; + }>; + const byId = Object.fromEntries(frames.map((f) => [f.id, f])); + expect(byId[210].error?.code).toBe(-32602); + expect(byId[211].error?.code).toBe(-32602); + expect(byId[212].result).toBeDefined(); + }); + + it('translateEvent: stream_error + client_evicted → _qwen/notify with kind', async () => { + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 2); + await new Promise((r) => setTimeout(r, 50)); + const q = bridge.queues.get('sess-1'); + q?.push({ type: 'stream_error', data: { error: 'boom' } }); + q?.push({ type: 'client_evicted', data: { reason: 'slow' } }); + const frames = (await got) as Array<{ + method: string; + params: { kind: string }; + }>; + expect(frames.every((f) => f.method === '_qwen/notify')).toBe(true); + const kinds = frames.map((f) => f.params.kind); + expect(kinds).toEqual( + expect.arrayContaining(['stream_error', 'client_evicted']), + ); + // (takeFrames already locked + aborted `sess`; afterEach force-closes.) + }); + + it('session/load while a session/close is in-flight → rejected (TOCTOU guard)', async () => { + let releaseClose: () => void = () => {}; + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // close is now in flight (awaiting closeGate) → sess-1 is "closing". + void post(connId, { + jsonrpc: '2.0', + id: 300, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 301, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 301); + // Transient server-side race → INTERNAL_ERROR (-32603), not INVALID_PARAMS. + expect(loadReply?.error?.code).toBe(-32603); // "being closed; retry" + expect(loadReply?.error?.message).toContain('being closed'); + releaseClose(); + }); + + it('session/load while close races DURING loadSession → post-await reject + rollback', async () => { + // Distinct from the pre-await guard above: here the pre-await + // `closingSessions` check passes, then a `session/close` for the same id + // starts WHILE `loadSession` is awaiting. The post-await re-check + // (dispatch.ts) must detect `closeRaced`, roll back the just-restored + // attach (detachClient, since loadAttached=true), and reply INTERNAL_ERROR. + let releaseLoad: () => void = () => {}; + let releaseClose: () => void = () => {}; + const connId = await initialize(); + await newSession(connId); // own sess-1 so session/close passes requireOwned + // Arm the gates only AFTER ownership is established — otherwise newSession's + // own spawnOrAttach would block on bridge.gate and never grant ownership. + bridge.gate = new Promise((r) => (releaseLoad = r)); + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // buffered session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // Load goes in-flight (awaits bridge.gate); pre-await closingSessions empty. + void post(connId, { + jsonrpc: '2.0', + id: 340, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + // Close starts DURING the load → marks sess-1 closing (awaits closeGate). + void post(connId, { + jsonrpc: '2.0', + id: 341, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + releaseLoad(); // loadSession resolves → post-await sees closeRaced + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 340); + expect(loadReply?.error?.code).toBe(-32603); + expect(loadReply?.error?.message).toContain('closed during load'); + // attached:true → rollback is a detach, NOT a kill. + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + expect(bridge.killed).not.toContain('sess-1'); + releaseClose(); + }); + + it('double-failure permission vote → pending retained + retried on teardown', async () => { + // Core R14 invariant: when BOTH the vote and the immediate cancel throw a + // non-"not found" error, resolveClientResponse must RETAIN the pending + // entry so connection teardown's abandonPendingForSession can retry the + // cancel (otherwise the bridge mediator is stuck forever). Retention is + // observable as a SECOND cancel attempt during teardown. + const calls: unknown[] = []; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + calls.push(resp); + throw new Error('mediator unavailable'); // vote AND every cancel fail + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-d', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 100)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 350, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Vote → respondToSessionPermission throws → immediate cancel ALSO throws. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await new Promise((r) => setTimeout(r, 40)); + // Teardown retries the cancel — whether triggered by the session stream + // closing or the explicit DELETE below. Either way it only happens if the + // entry was RETAINED after the immediate cancel failed. + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await new Promise((r) => setTimeout(r, 40)); + const cancels = calls.filter((c) => + JSON.stringify(c).includes('cancelled'), + ); + // 1 vote + ≥2 cancels (immediate fail + teardown retry). If the entry were + // dropped unconditionally after the failed immediate cancel, there would be + // exactly ONE cancel — so ≥2 is the retention invariant. + expect(cancels.length).toBeGreaterThanOrEqual(2); + expect(calls.length).toBeGreaterThanOrEqual(3); + }); + + it('client error response to a permission request → cancellation', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-e', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 310, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a JSON-RPC ERROR (not result) → treated as cancel. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + error: { code: -32000, message: 'user declined' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(resolvedWith).toEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('DELETE without a connection id → 400', async () => { + const res = await fetch(`${base}/acp`, { method: 'DELETE' }); + expect(res.status).toBe(400); + }); + + it('DELETE tears the connection down (subsequent POST 400)', async () => { + const connId = await initialize(); + const del = await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + expect(del.status).toBe(202); + const after = await post(connId, { + jsonrpc: '2.0', + id: 12, + method: 'session/new', + }); + expect(after.status).toBe(400); + }); +}); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 99f3cbeb4a9..8a3d7912d0b 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -40,6 +40,7 @@ import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isServeDebugMode } from './debugMode.js'; import { isLoopbackBind } from './loopbackBinds.js'; +import { mountAcpHttp } from './acpHttp/index.js'; import { canonicalizeWorkspace, CancelSentinelCollisionError, @@ -2555,6 +2556,15 @@ export function createServeApp( })(); }); + // Official ACP Streamable HTTP transport (RFD #721) mounted at `/acp` + // alongside the REST surface, sharing this same `bridge` instance. + // Additive + toggleable (`QWEN_SERVE_ACP_HTTP=0` opts out). See + // `docs/design/daemon-acp-http/README.md` §6 for the dual-transport + // decision. Mounted AFTER the REST routes (distinct path, no overlap) + // and BEFORE the final error handler so malformed `/acp` bodies still + // route through the JSON error contract below. + mountAcpHttp(app, bridge, { boundWorkspace }); + // 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 diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index da7cbf60401..567727bf7bb 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -578,6 +578,322 @@ describe('useTextBuffer', () => { act(() => result.current.insert(shortText, { paste: true })); expect(getBufferState(result).text).toBe(shortText); }); + + it('should prepend @ to multiple quoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/file2.txt' '/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple unquoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/path/to/file1.txt' || + p === '/path/to/file2.txt' || + p === '/path/to/file3.txt', + }), + ); + const filePaths = + '/path/to/file1.txt /path/to/file2.txt /path/to/file3.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple file paths separated by newlines', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + '/path/to/file1.txt\n/path/to/file2.txt\n/path/to/file3.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple quoted file paths separated by newlines', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt'\n'/path/to/file2.txt'\n'/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should handle mixed quoted and unquoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt' /path/to/file2.txt '/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should preserve original content when not all tokens are valid paths', () => { + // When any token is not a valid path, preserve the original paste + // to prevent silent data loss (wenshao #4544 review). + const { result: result2 } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (path: string) => + path.includes('file1') || path.includes('file2'), + }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/invalid.txt' '/path/to/file2.txt'"; + act(() => result2.current.insert(filePaths, { paste: true })); + // Content preserved unchanged because not all tokens are valid paths + expect(getBufferState(result2).text).toBe(filePaths); + }); + + it('should transform when all tokens are valid paths', () => { + // When every token is a valid path, transform all of them + const { result: result3 } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (path: string) => + path.includes('file1') || + path.includes('file2') || + path.includes('file3'), + }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/file2.txt' '/path/to/file3.txt'"; + act(() => result3.current.insert(filePaths, { paste: true })); + expect(getBufferState(result3).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should handle quoted paths with spaces via greedy matching', () => { + // Critical 3: Test greedy multi-token matching and escapePath integration + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/path/to/my file.txt' || p === '/path/to/another file.txt', + }), + ); + act(() => + result.current.insert( + "'/path/to/my file.txt' '/path/to/another file.txt'", + { paste: true }, + ), + ); + expect(getBufferState(result).text).toBe( + '@/path/to/my\\ file.txt @/path/to/another\\ file.txt ', + ); + }); + + it('should handle unquoted paths with spaces via greedy matching', () => { + // Critical 3: Test unquoted paths with spaces + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/path/to/my file.txt', + }), + ); + act(() => result.current.insert('/path/to/my file.txt', { paste: true })); + expect(getBufferState(result).text).toBe('@/path/to/my\\ file.txt '); + }); + + it('should handle CRLF-separated paths', () => { + // Suggestion 6: Test CRLF normalization + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + act(() => + result.current.insert('/a.txt\r\n/b.txt\r\n/c.txt', { paste: true }), + ); + expect(getBufferState(result).text).toBe('@/a.txt @/b.txt @/c.txt '); + }); + + it('should preserve newline paste content when no valid paths found', () => { + // Suggestion 6: Test null return from tryExtractFilePaths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + }), + ); + const text = 'line one\nline two'; + act(() => result.current.insert(text, { paste: true })); + expect(getBufferState(result).text).toBe(text); + }); + + it('should preserve newline paste content in shell mode', () => { + // Suggestion 6: Test shellModeActive + newline paste + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => true, + shellModeActive: true, + }), + ); + const text = '/a.txt\n/b.txt\n/c.txt'; + act(() => result.current.insert(text, { paste: true })); + expect(getBufferState(result).text).toBe(text); + }); + + it('should escape commas in paths for parseAllAtCommands compatibility', () => { + // Suggestion 7: Test comma escaping + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/path/to/report,v2.txt', + }), + ); + act(() => + result.current.insert("'/path/to/report,v2.txt'", { paste: true }), + ); + // Comma should be escaped so parseAllAtCommands doesn't truncate + expect(getBufferState(result).text).toBe('@/path/to/report\\,v2.txt '); + }); + + it('should escape shell metacharacters like parentheses in paths', () => { + // Suggestion 4 (wenshao #4544): Test shell metacharacters in paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/Downloads/report(v2).txt' || + p === '/data[2024].csv' || + p === '/report;v2.txt', + }), + ); + // Test parentheses + act(() => + result.current.insert("'/Downloads/report(v2).txt'", { paste: true }), + ); + expect(getBufferState(result).text).toBe( + '@/Downloads/report\\(v2\\).txt ', + ); + + // Reset buffer and test brackets + act(() => result.current.setText('')); + act(() => result.current.insert("'/data[2024].csv'", { paste: true })); + expect(getBufferState(result).text).toBe('@/data\\[2024\\].csv '); + + // Reset buffer and test semicolon + act(() => result.current.setText('')); + act(() => result.current.insert("'/report;v2.txt'", { paste: true })); + expect(getBufferState(result).text).toBe('@/report\\;v2.txt '); + }); + + it('should handle relative paths like ./src/index.ts', () => { + // Suggestion 1 (wenshao #4544): looksLikePath should support relative paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === './src/index.ts' || + p === '../lib/utils.ts' || + p === '~/notes.md', + }), + ); + const filePaths = './src/index.ts ../lib/utils.ts ~/notes.md'; + act(() => result.current.insert(filePaths, { paste: true })); + // Paths with ~ are escaped by escapePath + expect(getBufferState(result).text).toBe( + '@./src/index.ts @../lib/utils.ts @\\~/notes.md ', + ); + }); + + it('should handle unquoted paths with spaces via longest-match-first greedy matching', () => { + // Suggestion 2 (wenshao #4544): longest-match-first greedy matching + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/tmp/a b.txt' || p === '/tmp/a' || p === 'b.txt', + }), + ); + // Without longest-match-first, this would match "/tmp/a" + "b.txt" (invalid) + // With longest-match-first, this matches "/tmp/a b.txt" + act(() => result.current.insert('/tmp/a b.txt', { paste: true })); + expect(getBufferState(result).text).toBe('@/tmp/a\\ b.txt '); + }); + + it('should handle unquoted invalid paths without crashing', () => { + // Suggestion 4 (wenshao #4544): cover the !found branch + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/valid/file.txt', + }), + ); + const filePaths = '/valid/file.txt /nonexistent/path'; + act(() => result.current.insert(filePaths, { paste: true })); + // Content preserved unchanged because not all tokens are valid paths + expect(getBufferState(result).text).toBe(filePaths); + }); + + it('should handle Windows drive-letter paths', () => { + // Suggestion 6 (wenshao #4544): test drive-letter branch of looksLikePath + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === 'C:\\Users\\file.txt' || p === 'D:\\data\\report.csv', + }), + ); + act(() => + result.current.insert('C:\\Users\\file.txt D:\\data\\report.csv', { + paste: true, + }), + ); + expect(getBufferState(result).text).toBe( + '@C:\\Users\\file.txt @D:\\data\\report.csv ', + ); + }); + + it('should handle quoted Windows paths with spaces', () => { + // Suggestion 3 (wenshao #4544): test quoted Windows paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === 'C:\\Users\\my file.txt' || p === 'D:\\data\\report.csv', + }), + ); + act(() => + result.current.insert( + "'C:\\Users\\my file.txt' 'D:\\data\\report.csv'", + { + paste: true, + }, + ), + ); + // escapePath escapes spaces, so "my file" becomes "my\ file" + expect(getBufferState(result).text).toBe( + '@C:\\Users\\my\\ file.txt @D:\\data\\report.csv ', + ); + }); + + it('should prepend @ to a bare filename when isValidPath returns true', () => { + // Suggestion 3 (wenshao #4544): test bare filename for single-token segments + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: (p) => p === 'README.md' }), + ); + act(() => result.current.insert('README.md', { paste: true })); + expect(getBufferState(result).text).toBe('@README.md '); + }); }); describe('Shell Mode Behavior', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 35be9f30c1c..48e827b8f15 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -12,6 +12,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { createDebugLogger, unescapePath, + escapePath, getExternalEditorCommand, type EditorType, } from '@qwen-code/qwen-code-core'; @@ -1899,6 +1900,166 @@ export function textBufferReducer( // --- End of reducer logic --- +// --- Path extraction helpers (pure functions, outside useTextBuffer) --- + +/** + * Check if a string looks like a path prefix (starts with /, ./, ../, ~/, ., .., or drive letter). + * Strips surrounding quotes first to handle quoted paths. + * Used to pre-filter tokens before expensive fs calls. + */ +function looksLikePath(str: string): boolean { + // Strip surrounding quotes first to handle quoted paths + const unquoted = str.replace(/^'(.*)'$/, '$1'); + // Also handle tokens that are the start of a quoted path split by whitespace + const inner = unquoted.startsWith("'") ? unquoted.slice(1) : unquoted; + return ( + inner.startsWith('/') || + inner.startsWith('./') || + inner.startsWith('../') || + inner.startsWith('~/') || + inner.startsWith('.') || + /^[A-Za-z]:/.test(inner) + ); +} + +/** + * Extract file paths from content and prepend @ prefix. + * Handles quoted paths, unquoted paths, whitespace-separated, and newline-separated. + * Supports file paths with spaces using greedy matching. + * IMPORTANT: Escapes shell-special characters (spaces, commas, parentheses, + * brackets, semicolons, etc.) with backslash so that the downstream + * `parseAllAtCommands` parser correctly includes the entire path. + * + * Only transforms when ALL non-whitespace tokens are valid paths. If any + * non-path, non-separator token exists, returns null to preserve original + * content (prevents silent data loss). + */ +function tryExtractFilePaths( + content: string, + isValidPath: (p: string) => boolean, +): string[] | null { + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split(/\n/).filter((s) => s.trim().length > 0); + + const validPaths: string[] = []; + const hadNonPathToken = { value: false }; + + for (const line of lines) { + // Short-circuit: once any token is flagged as non-path, the result will be null + if (hadNonPathToken.value) break; + + // Use a regex that only matches quoted content starting with path-like chars + // to avoid false matches on English contractions (e.g., "don't"). + const quotedPathRegex = /'((?:[~/.]|[A-Za-z]:)[^']*)'/g; + let lastIndex = 0; + let match; + let hasQuotedPaths = false; + + while ((match = quotedPathRegex.exec(line)) !== null) { + const gap = line.slice(lastIndex, match.index).trim(); + if (gap) { + const gapPaths = extractPathsFromSegment( + gap, + isValidPath, + hadNonPathToken, + ); + validPaths.push(...gapPaths); + } + const unescaped = unescapePath(match[1]); + if (isValidPath(unescaped)) { + validPaths.push(`@${escapePath(unescaped)}`); + } else { + // Quoted path found but not a valid path — mark as non-path + hadNonPathToken.value = true; + } + lastIndex = quotedPathRegex.lastIndex; + hasQuotedPaths = true; + } + + if (hasQuotedPaths) { + const trailing = line.slice(lastIndex).trim(); + if (trailing) { + const trailingPaths = extractPathsFromSegment( + trailing, + isValidPath, + hadNonPathToken, + ); + validPaths.push(...trailingPaths); + } + } else { + const linePaths = extractPathsFromSegment( + line.trim(), + isValidPath, + hadNonPathToken, + ); + validPaths.push(...linePaths); + } + } + + // Only return paths if we extracted at least one AND the content looks like + // a pure list of paths (all non-whitespace tokens are valid paths). + // This prevents silent data loss when pasting prose mixed with paths. + if (validPaths.length > 0 && !hadNonPathToken.value) { + return validPaths; + } + + return null; +} + +/** + * Extract file paths from a whitespace-separated segment. + * Tries longest possible path first (greedy) so paths with spaces are matched + * before shorter prefixes. + * Pre-filters tokens that don't look like paths to avoid O(n²) fs calls. + * Sets `hadNonPathToken` to true if any token was skipped (not a valid path). + */ +function extractPathsFromSegment( + segment: string, + isValidPath: (p: string) => boolean, + hadNonPathToken: { value: boolean }, +): string[] { + const tokens = segment.split(/\s+/).filter(Boolean); + const paths: string[] = []; + let i = 0; + while (i < tokens.length) { + // Short-circuit: once any token is flagged as non-path, the result will be null + if (hadNonPathToken.value) break; + + // Pre-filter: skip tokens that can't possibly be paths. + // For single-token segments, let isValidPath decide (preserves + // old behavior for bare filenames like README.md). + if (tokens.length > 1 && !looksLikePath(tokens[i])) { + hadNonPathToken.value = true; + i++; + continue; + } + let found = false; + // Try longest-match-first so paths with spaces are tried before shorter + // prefixes (e.g., "/tmp/a b.txt" before "/tmp/a"). + for (let j = tokens.length; j >= i + 1; j--) { + const candidate = tokens.slice(i, j).join(' '); + let unquoted = candidate; + const quoteMatch = unquoted.match(/^'(.*)'$/); + if (quoteMatch) { + unquoted = quoteMatch[1]; + } + const unescaped = unescapePath(unquoted); + if (isValidPath(unescaped)) { + paths.push(`@${escapePath(unescaped)}`); + i = j; + found = true; + break; + } + } + if (!found) { + // Token looked like a path but isn't valid — mark as non-path + hadNonPathToken.value = true; + i++; + } + } + return paths; +} + export function useTextBuffer({ initialText = '', initialCursorOffset = 0, @@ -1993,6 +2154,18 @@ export function useTextBuffer({ const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { + // Handle pastes that contain newlines (e.g., file paths separated by newlines). + // We need to process these before the newline check below, which would + // otherwise cause an early return and skip the @-path detection. + if (paste && /[\n\r]/.test(ch) && !shellModeActive) { + const validPaths = tryExtractFilePaths(ch, isValidPath); + if (validPaths) { + ch = `${validPaths.join(' ')} `; + } + dispatch({ type: 'insert', payload: ch }); + return; + } + if (/[\n\r]/.test(ch)) { dispatch({ type: 'insert', payload: ch }); return; @@ -2000,19 +2173,13 @@ export function useTextBuffer({ const minLengthToInferAsDragDrop = 3; if ( + paste && ch.length >= minLengthToInferAsDragDrop && - !shellModeActive && - paste + !shellModeActive ) { - let potentialPath = ch.trim(); - const quoteMatch = potentialPath.match(/^'(.*)'$/); - if (quoteMatch) { - potentialPath = quoteMatch[1]; - } - - potentialPath = potentialPath.trim(); - if (isValidPath(unescapePath(potentialPath))) { - ch = `@${potentialPath} `; + const validPaths = tryExtractFilePaths(ch.trim(), isValidPath); + if (validPaths) { + ch = `${validPaths.join(' ')} `; } } diff --git a/packages/core/src/telemetry/session-tracing.test.ts b/packages/core/src/telemetry/session-tracing.test.ts index 75cb6b97750..c0119982010 100644 --- a/packages/core/src/telemetry/session-tracing.test.ts +++ b/packages/core/src/telemetry/session-tracing.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SpanStatusCode } from '@opentelemetry/api'; +import { SpanStatusCode, type Context } from '@opentelemetry/api'; const mockState = vi.hoisted(() => ({ sdkInitialized: true, @@ -141,6 +141,7 @@ import { runTTLSweepForTesting, truncateSpanError, } from './session-tracing.js'; +import { setSessionContext } from './session-context.js'; function createMockConfig( overrides: Partial<{ @@ -280,6 +281,52 @@ describe('session-tracing', () => { }); }); + describe('interaction span — trace context (#4486)', () => { + it('attaches to the session root context returned by getSessionContext', () => { + const fakeRoot = { __sessionRoot: true } as unknown as Context; + setSessionContext(fakeRoot, 'test-session'); + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toBe(fakeRoot); + }); + + it('anchors at session root even when an unrelated OTel span is active', () => { + const fakeRoot = { __sessionRoot: true } as unknown as Context; + setSessionContext(fakeRoot, 'test-session'); + mockState.activeOtelSpan = { name: 'unrelated-wrapper-span' }; + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toBe(fakeRoot); + }); + + it('falls back to otelContext.active() when no session context is set', () => { + // Intentionally NOT calling setSessionContext — exercises the fallback. + const fakeActive = { kind: 'fake-active-span' }; + mockState.activeOtelSpan = fakeActive; + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toMatchObject({ __activeSpan: fakeActive }); + }); + }); + describe('LLM request spans', () => { it('creates and ends an LLM request span', () => { const span = startLLMRequestSpan('test-model', 'prompt-llm'); diff --git a/packages/core/src/telemetry/session-tracing.ts b/packages/core/src/telemetry/session-tracing.ts index 97089df54e1..118192bea91 100644 --- a/packages/core/src/telemetry/session-tracing.ts +++ b/packages/core/src/telemetry/session-tracing.ts @@ -26,7 +26,7 @@ import { } from './constants.js'; import { clearDetailedSpanState } from './detailed-span-attributes.js'; import { isTelemetrySdkInitialized } from './sdk.js'; -import { getSessionContext } from './session-context.js'; +import { getSessionContext, setSessionContext } from './session-context.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('SESSION_TRACING'); @@ -291,10 +291,14 @@ export function startInteractionSpan( 'interaction.sequence': interactionSequence, }; - const span = getTracer().startSpan(SPAN_INTERACTION, { - kind: SpanKind.INTERNAL, - attributes, - }); + // Pin to session root directly — resolveParentContext() would prefer + // any active OTel span, but interaction is a turn boundary (#4486). + const sessionCtx = getSessionContext() ?? otelContext.active(); + const span = getTracer().startSpan( + SPAN_INTERACTION, + { kind: SpanKind.INTERNAL, attributes }, + sessionCtx, + ); const spanId = getSpanId(span); const spanContextObj: SpanContext = { @@ -957,6 +961,8 @@ export function clearSessionTracingForTesting(): void { interactionSequence = 0; lastInteractionCtx = undefined; clearDetailedSpanState(); + // Reach into session-context module to prevent cross-test leakage (#4486). + setSessionContext(undefined); } /** diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 11824de98de..5a823442981 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -186,8 +186,8 @@ describe('escapePath', () => { }); it('should handle paths with only special characters', () => { - expect(escapePath(' ()[]{};&|*?$`\'"#!~<>')).toBe( - '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>', + expect(escapePath(' ()[]{};&|*?$`\'"#!~<>,')).toBe( + '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>\\,', ); }); }); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index e11fa0b3ffc..1fbbea042ff 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -44,7 +44,7 @@ export function _resetValidatePathCacheForTest(): void { * Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes, * asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters. */ -export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/; // Single shared list of path-argument keys used across file tools. // file_path (Edit, ReadFile, WriteFile), path (Glob, Grep, Ls, RipGrep), diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 0006bd5427d..9608e55ff54 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -198,11 +198,237 @@ This file contains third-party software notices and license terms. ============================================================ -@qwen-code/webui@undefined -(No repository found) +@modelcontextprotocol/sdk@1.25.1 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +MIT License + +Copyright (c) 2024 Anthropic, PBC + +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. + + +============================================================ +@hono/node-server@1.19.7 +(https://github.com/honojs/node-server.git) License text not found. +============================================================ +ajv@8.17.1 +(No repository found) + +The MIT License (MIT) + +Copyright (c) 2015-2021 Evgeny Poberezkin + +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. + + + +============================================================ +fast-deep-equal@3.1.3 +(git+https://github.com/epoberezkin/fast-deep-equal.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +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. + + +============================================================ +fast-uri@3.0.6 +(git+https://github.com/fastify/fast-uri.git) + +Copyright (c) 2021 The Fastify Team +Copyright (c) 2011-2021, Gary Court until https://github.com/garycourt/uri-js/commit/a1acf730b4bba3f1097c9f52e7d9d3aba8cdcaae +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * The names of any contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + * * * + +The complete list of contributors can be found at: +- https://github.com/garycourt/uri-js/graphs/contributors + +============================================================ +json-schema-traverse@1.0.0 +(git+https://github.com/epoberezkin/json-schema-traverse.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +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. + + +============================================================ +require-from-string@2.0.2 +(No repository found) + +The MIT License (MIT) + +Copyright (c) Vsevolod Strukchinsky (github.com/floatdrop) + +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. + + +============================================================ +ajv-formats@3.0.1 +(git+https://github.com/ajv-validator/ajv-formats.git) + +MIT License + +Copyright (c) 2020 Evgeny Poberezkin + +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. + + +============================================================ +content-type@1.0.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +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. + + ============================================================ cors@2.8.5 (No repository found) @@ -287,36 +513,176 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -dotenv@17.1.0 -(git://github.com/motdotla/dotenv.git) +cross-spawn@7.0.6 +(git@github.com:moxystudio/node-cross-spawn.git) + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +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. + + +============================================================ +path-key@3.1.1 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +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. + + +============================================================ +shebang-command@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +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. + + +============================================================ +shebang-regex@3.0.0 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +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. + + +============================================================ +which@2.0.2 +(git://github.com/isaacs/node-which.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +isexe@2.0.0 +(git+https://github.com/isaacs/isexe.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +eventsource@3.0.7 +(git://git@github.com/EventSource/eventsource.git) + +The MIT License + +Copyright (c) EventSource GitHub organisation + +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. + + +============================================================ +eventsource-parser@3.0.3 +(git+ssh://git@github.com/rexxars/eventsource-parser.git) -Copyright (c) 2015, Scott Motte -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Copyright (c) 2025 Espen Hovlandsdal -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +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: -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +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. ============================================================ -express@4.21.2 +express@5.2.1 (No repository found) (The MIT License) @@ -346,7 +712,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -accepts@1.3.8 +accepts@2.0.0 (No repository found) (The MIT License) @@ -375,7 +741,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -mime-types@3.0.1 +mime-types@3.0.2 (No repository found) (The MIT License) @@ -433,7 +799,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -negotiator@0.6.3 +negotiator@1.0.0 (No repository found) (The MIT License) @@ -463,34 +829,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -array-flatten@1.1.1 -(git://github.com/blakeembrey/array-flatten.git) - -The MIT License (MIT) - -Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) - -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. - - -============================================================ -body-parser@1.20.3 +body-parser@2.2.2 (No repository found) (The MIT License) @@ -547,34 +886,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -content-type@1.0.5 -(No repository found) - -(The MIT License) - -Copyright (c) 2015 Douglas Christopher Wilson - -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. - - ============================================================ debug@4.4.3 (git://github.com/debug-js/debug.git) @@ -629,42 +940,14 @@ SOFTWARE. ============================================================ -depd@2.0.0 -(No repository found) - -(The MIT License) - -Copyright (c) 2014-2018 Douglas Christopher Wilson - -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. - - -============================================================ -destroy@1.2.0 +http-errors@2.0.1 (No repository found) The MIT License (MIT) Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2015-2022 Douglas Christopher Wilson doug@somethingdoug.com +Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -686,32 +969,31 @@ THE SOFTWARE. ============================================================ -http-errors@2.0.0 +depd@2.0.0 (No repository found) +(The MIT License) -The MIT License (MIT) - -Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com +Copyright (c) 2014-2018 Douglas Christopher Wilson -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: +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 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. +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. ============================================================ @@ -812,8 +1094,8 @@ SOFTWARE. ============================================================ -iconv-lite@0.6.3 -(git://github.com/ashtuchkin/iconv-lite.git) +iconv-lite@0.7.2 +(https://github.com/pillarjs/iconv-lite.git) Copyright (c) 2011 Alexander Shtuchkin @@ -923,7 +1205,7 @@ THE SOFTWARE. ============================================================ -qs@6.13.0 +qs@6.15.2 (https://github.com/ljharb/qs.git) BSD 3-Clause License @@ -1443,7 +1725,7 @@ SOFTWARE. ============================================================ -raw-body@3.0.0 +raw-body@3.0.2 (No repository found) The MIT License (MIT) @@ -1499,7 +1781,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -type-is@1.6.18 +type-is@2.1.0 (No repository found) (The MIT License) @@ -1528,12 +1810,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -media-typer@0.3.0 +media-typer@1.1.0 (No repository found) (The MIT License) -Copyright (c) 2014 Douglas Christopher Wilson +Copyright (c) 2014-2017 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1556,7 +1838,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -content-disposition@0.5.4 +content-disposition@1.1.0 (No repository found) (The MIT License) @@ -1583,33 +1865,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -safe-buffer@5.2.1 -(git://github.com/feross/safe-buffer.git) - -The MIT License (MIT) - -Copyright (c) Feross Aboukhadijeh - -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. - - ============================================================ cookie@0.7.2 (No repository found) @@ -1641,10 +1896,32 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -cookie-signature@1.0.6 +cookie-signature@1.2.2 (https://github.com/visionmedia/node-cookie-signature.git) -License text not found. +(The MIT License) + +Copyright (c) 2012–2024 LearnBoost and other contributors; + +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. + ============================================================ encodeurl@2.0.0 @@ -1733,7 +2010,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -finalhandler@1.3.1 +finalhandler@2.1.1 (No repository found) (The MIT License) @@ -1791,7 +2068,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -fresh@0.5.2 +fresh@2.0.0 (No repository found) (The MIT License) @@ -1820,13 +2097,71 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -merge-descriptors@1.0.3 +merge-descriptors@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Jonathan Ong +Copyright (c) Douglas Christopher Wilson +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +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. + + +============================================================ +once@1.4.0 +(git://github.com/isaacs/once) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +wrappy@1.0.2 +(https://github.com/npm/wrappy) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +proxy-addr@2.0.7 (No repository found) (The MIT License) -Copyright (c) 2013 Jonathan Ong -Copyright (c) 2015 Douglas Christopher Wilson +Copyright (c) 2014-2016 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1849,13 +2184,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -methods@1.1.2 +forwarded@0.2.0 (No repository found) (The MIT License) -Copyright (c) 2013-2014 TJ Holowaychuk -Copyright (c) 2015-2016 Douglas Christopher Wilson +Copyright (c) 2014-2017 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1877,14 +2211,11 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ============================================================ -path-to-regexp@0.1.12 -(https://github.com/pillarjs/path-to-regexp.git) - -The MIT License (MIT) +ipaddr.js@1.9.1 +(No repository found) -Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) +Copyright (C) 2011-2017 whitequark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1906,12 +2237,13 @@ THE SOFTWARE. ============================================================ -proxy-addr@2.0.7 +range-parser@1.2.1 (No repository found) (The MIT License) -Copyright (c) 2014-2016 Douglas Christopher Wilson +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015-2016 Douglas Christopher Wilson +Copyright (c) 2014 Forbes Lindesay + +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. + +============================================================ +path-to-regexp@8.2.0 +(https://github.com/pillarjs/path-to-regexp.git) + +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1987,13 +2346,13 @@ THE SOFTWARE. ============================================================ -range-parser@1.2.1 +send@1.2.1 (No repository found) (The MIT License) -Copyright (c) 2012-2014 TJ Holowaychuk -Copyright (c) 2015-2016 Douglas Christopher Wilson -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: +All JSON Schema documentation and descriptions are copyright (c): -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +2009 [draft-0] IETF Trust , Kris Zyp , +and SitePen (USA) . -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. +2009 [draft-1] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-2] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-3] IETF Trust , Kris Zyp , +Gary Court , and SitePen (USA) . + +2013 [draft-4] IETF Trust ), Francis Galiegue +, Kris Zyp , Gary Court +, and SitePen (USA) . + +2018 [draft-7] IETF Trust , Austin Wright , +Henry Andrews , Geraint Luff , and +Cloudflare, Inc. . + +2019 [draft-2019-09] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +2020 [draft-2020-12] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ============================================================ -utils-merge@1.0.1 -(git://github.com/jaredhanson/utils-merge.git) +pkce-challenge@5.0.0 +(git+https://github.com/crouchcd/pkce-challenge.git) -The MIT License (MIT) +MIT License -Copyright (c) 2013-2017 Jared Hanson +Copyright (c) 2019 -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: +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. +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. + + +============================================================ +zod@3.25.76 +(git+https://github.com/colinhacks/zod.git) + +MIT License + +Copyright (c) 2025 Colin McDonnell + +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. + + +============================================================ +zod-to-json-schema@3.25.0 +(https://github.com/StefanTerdell/zod-to-json-schema) + +ISC License + +Copyright (c) 2020, Stefan Terdell + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ============================================================ markdown-it@14.1.0 @@ -2564,6 +3031,35 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +dotenv@17.1.0 +(git://github.com/motdotla/dotenv.git) + +Copyright (c) 2015, Scott Motte +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ============================================================ react@19.2.4 (https://github.com/facebook/react.git) @@ -2666,30 +3162,3 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -============================================================ -zod@3.25.76 -(git+https://github.com/colinhacks/zod.git) - -MIT License - -Copyright (c) 2025 Colin McDonnell - -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. - - diff --git a/packages/vscode-ide-companion/scripts/generate-notices.js b/packages/vscode-ide-companion/scripts/generate-notices.js index 06f735ca81b..09b43f578a2 100644 --- a/packages/vscode-ide-companion/scripts/generate-notices.js +++ b/packages/vscode-ide-companion/scripts/generate-notices.js @@ -14,27 +14,26 @@ const projectRoot = path.resolve( const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion'); const noticeFilePath = path.join(packagePath, 'NOTICES.txt'); -async function getDependencyLicense(depName, depVersion) { - let depPackageJsonPath; +/** + * Read license information for a dependency from its on-disk location. + * + * @param {string} depName - Package name + * @param {string} depVersion - Resolved version string + * @param {string} resolvedKey - Lockfile key indicating where the package is installed + * @returns {Promise<{name: string, version: string, repository: string, license: string}>} + */ +async function getDependencyLicense(depName, depVersion, resolvedKey) { let licenseContent = 'License text not found.'; let repositoryUrl = 'No repository found'; - try { - depPackageJsonPath = path.join( - projectRoot, - 'node_modules', - depName, - 'package.json', - ); - if (!(await fs.stat(depPackageJsonPath).catch(() => false))) { - depPackageJsonPath = path.join( - packagePath, - 'node_modules', - depName, - 'package.json', - ); - } + // Derive the on-disk path directly from the lockfile key + const depPackageJsonPath = path.join( + projectRoot, + resolvedKey, + 'package.json', + ); + try { const depPackageJsonContent = await fs.readFile( depPackageJsonPath, 'utf-8', @@ -76,7 +75,7 @@ async function getDependencyLicense(depName, depVersion) { } } catch (e) { console.warn( - `Warning: Could not find package.json for ${depName}: ${e.message}`, + `Warning: Could not find package.json for ${depName} at ${depPackageJsonPath}: ${e.message}`, ); } @@ -88,24 +87,90 @@ async function getDependencyLicense(depName, depVersion) { }; } -function collectDependencies(packageName, packageLock, dependenciesMap) { +/** + * Resolve a package in the lockfile by walking up the node_modules chain, + * mirroring Node.js module resolution algorithm. + * + * @param {string} packageName - Package to find + * @param {object} packages - packageLock.packages map + * @param {string} resolveFrom - Lockfile key to start resolution from + * @returns {{info: object, key: string} | null} + */ +function resolveInLockfile(packageName, packages, resolveFrom) { + // Walk up from resolveFrom, trying each node_modules level + let current = resolveFrom; + while (current) { + const candidate = `${current}/node_modules/${packageName}`; + if (packages[candidate]) { + return { info: packages[candidate], key: candidate }; + } + // Move up: strip the last /node_modules/... segment + const lastNm = current.lastIndexOf('/node_modules/'); + if (lastNm === -1) break; + current = current.slice(0, lastNm); + } + // Finally try root hoisted level + const hoistedKey = `node_modules/${packageName}`; + if (packages[hoistedKey]) { + return { info: packages[hoistedKey], key: hoistedKey }; + } + return null; +} + +/** + * Recursively collect third-party dependencies by walking the lockfile. + * Mirrors Node.js module resolution: walks up the node_modules chain from + * the current package's location. + * + * @param {string} packageName - Package to resolve + * @param {object} packageLock - Parsed package-lock.json + * @param {Map} dependenciesMap - Accumulated results + * @param {string} resolveFrom - Lockfile key prefix to resolve from (e.g. "packages/vscode-ide-companion") + */ +function collectDependencies( + packageName, + packageLock, + dependenciesMap, + resolveFrom, +) { if (dependenciesMap.has(packageName)) { return; } - const packageInfo = packageLock.packages[`node_modules/${packageName}`]; - if (!packageInfo) { + const resolved = resolveInLockfile( + packageName, + packageLock.packages, + resolveFrom, + ); + if (!resolved) { console.warn( `Warning: Could not find package info for ${packageName} in package-lock.json.`, ); return; } - dependenciesMap.set(packageName, packageInfo.version); + const { info: packageInfo, key: resolvedKey } = resolved; + + // Workspace-linked packages: follow resolved pointer to collect their third-party deps + if (packageInfo.link) { + const realInfo = packageLock.packages[packageInfo.resolved]; + if (realInfo?.dependencies) { + for (const depName of Object.keys(realInfo.dependencies)) { + collectDependencies(depName, packageLock, dependenciesMap, resolveFrom); + } + } + return; + } + + dependenciesMap.set(packageName, { + version: packageInfo.version, + resolvedKey, + }); if (packageInfo.dependencies) { for (const depName of Object.keys(packageInfo.dependencies)) { - collectDependencies(depName, packageLock, dependenciesMap); + // Resolve transitive deps from THIS package's location + collectDependencies(depName, packageLock, dependenciesMap, resolvedKey); } } } @@ -125,15 +190,22 @@ async function main() { const allDependencies = new Map(); const directDependencies = Object.keys(packageJson.dependencies); + const workspacePrefix = path.relative(projectRoot, packagePath); for (const depName of directDependencies) { - collectDependencies(depName, packageLockJson, allDependencies); + collectDependencies( + depName, + packageLockJson, + allDependencies, + workspacePrefix, + ); } const dependencyEntries = Array.from(allDependencies.entries()); - const licensePromises = dependencyEntries.map(([depName, depVersion]) => - getDependencyLicense(depName, depVersion), + const licensePromises = dependencyEntries.map( + ([depName, { version, resolvedKey }]) => + getDependencyLicense(depName, version, resolvedKey), ); const dependencyLicenses = await Promise.all(licensePromises); @@ -151,6 +223,7 @@ async function main() { await fs.writeFile(noticeFilePath, noticeText); console.log(`NOTICES.txt generated at ${noticeFilePath}`); + console.log(`Total dependencies: ${dependencyEntries.length}`); } catch (error) { console.error('Error generating NOTICES.txt:', error); process.exit(1); diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.ts b/packages/vscode-ide-companion/src/utils/imageSupport.ts index f06d8532427..d217f16091b 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.ts @@ -28,7 +28,7 @@ export const MAX_TOTAL_IMAGE_SIZE = 20 * 1024 * 1024; // ---------- Path escaping ---------- -export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/; export function escapePath(filePath: string): string { let result = ''; diff --git a/scripts/acp-http-smoke.mjs b/scripts/acp-http-smoke.mjs new file mode 100644 index 00000000000..4813dcaf702 --- /dev/null +++ b/scripts/acp-http-smoke.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Black-box smoke client for the daemon's official ACP Streamable HTTP + * transport (RFD #721), mounted at `/acp` by `qwen serve`. + * + * Usage: + * 1. Start a daemon, e.g.: + * qwen serve --listen 127.0.0.1:8765 --token devtoken + * (or any port; copy the printed address) + * 2. Run this client against it: + * ACP_BASE_URL=http://127.0.0.1:8765 ACP_TOKEN=devtoken \ + * PROMPT="say hello in one word" node scripts/acp-http-smoke.mjs + * + * It drives: initialize → open connection stream → session/new → + * open session stream → session/prompt, printing every session/update + * notification and the final result. Exits non-zero on protocol failure. + * + * No SDK dependency — pure fetch + manual SSE parsing, so it doubles as + * the minimal reference for what an ACP HTTP client must do. + */ + +const BASE = process.env['ACP_BASE_URL'] ?? 'http://127.0.0.1:8765'; +const TOKEN = process.env['ACP_TOKEN']; +const PROMPT = process.env['PROMPT'] ?? 'Say hello in exactly one word.'; +const CWD = process.env['ACP_CWD']; + +const authHeaders = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}; + +function log(...a) { + console.log(...a); +} +function die(msg) { + console.error(`✗ ${msg}`); + process.exit(1); +} + +async function* sseFrames(res, signal) { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + signal.addEventListener('abort', () => reader.cancel().catch(() => {})); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, i); + buf = buf.slice(i + 2); + const line = frame.split('\n').find((l) => l.startsWith('data: ')); + if (line) yield JSON.parse(line.slice(6)); + } + } +} + +async function main() { + log(`→ daemon: ${BASE}/acp`); + + // 1. initialize + const initRes = await fetch(`${BASE}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeaders }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }); + if (initRes.status !== 200) + die(`initialize expected 200, got ${initRes.status}`); + const connId = initRes.headers.get('acp-connection-id'); + if (!connId) die('missing Acp-Connection-Id header'); + const initBody = await initRes.json(); + log( + `✓ initialize: connectionId=${connId} protocolVersion=${initBody.result?.protocolVersion}`, + ); + + const connHeaders = { ...authHeaders, 'acp-connection-id': connId }; + + // 2. connection-scoped stream (carries session/new reply) + const connAbort = new AbortController(); + const connStream = await fetch(`${BASE}/acp`, { + headers: { accept: 'text/event-stream', ...connHeaders }, + signal: connAbort.signal, + }); + if (!connStream.ok) die(`connection stream failed: ${connStream.status}`); + + const newReply = (async () => { + for await (const f of sseFrames(connStream, connAbort.signal)) { + if (f.id === 2) return f; + } + })(); + + await new Promise((r) => setTimeout(r, 100)); + + // 3. session/new + const ack = await fetch(`${BASE}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...connHeaders }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: CWD ? { cwd: CWD } : {}, + }), + }); + if (ack.status !== 202) die(`session/new expected 202, got ${ack.status}`); + const reply = await Promise.race([ + newReply, + new Promise((_, rej) => + setTimeout(() => rej(new Error('timeout')), 10_000), + ), + ]).catch((e) => die(`session/new reply: ${e.message}`)); + const sessionId = reply.result?.sessionId; + if (!sessionId) die('session/new returned no sessionId'); + log(`✓ session/new: sessionId=${sessionId}`); + + // 4. session-scoped stream + const sessAbort = new AbortController(); + const sessHeaders = { ...connHeaders, 'acp-session-id': sessionId }; + const sessStream = await fetch(`${BASE}/acp`, { + headers: { accept: 'text/event-stream', ...sessHeaders }, + signal: sessAbort.signal, + }); + if (!sessStream.ok) die(`session stream failed: ${sessStream.status}`); + + const promptDone = (async () => { + let updates = 0; + for await (const f of sseFrames(sessStream, sessAbort.signal)) { + if (f.method === 'session/update') { + updates++; + const u = f.params?.update; + if (u?.sessionUpdate === 'agent_message_chunk' && u.content?.text) { + process.stdout.write(u.content.text); + } + } else if (f.method === 'session/request_permission') { + // Auto-allow the first offered option so the smoke run is non-interactive. + const optionId = f.params?.options?.[0]?.optionId; + log(`\n ⚙ permission requested → auto-selecting "${optionId}"`); + await fetch(`${BASE}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...connHeaders }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: f.id, + result: { outcome: { outcome: 'selected', optionId } }, + }), + }); + } else if (f.id === 3 && (f.result || f.error)) { + return { updates, result: f.result, error: f.error }; + } + } + return { updates }; + })(); + + await new Promise((r) => setTimeout(r, 100)); + + // 5. session/prompt + log(`→ prompt: ${JSON.stringify(PROMPT)}`); + const pAck = await fetch(`${BASE}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...connHeaders }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: PROMPT }] }, + }), + }); + if (pAck.status !== 202) + die(`session/prompt expected 202, got ${pAck.status}`); + + const done = await Promise.race([ + promptDone, + new Promise((_, rej) => + setTimeout(() => rej(new Error('timeout')), 60_000), + ), + ]).catch((e) => die(`prompt: ${e.message}`)); + + log(''); + if (done.error) die(`prompt error: ${JSON.stringify(done.error)}`); + log( + `✓ prompt complete: ${done.updates} session/update frames, stopReason=${done.result?.stopReason}`, + ); + + // 6. teardown + connAbort.abort(); + sessAbort.abort(); + await fetch(`${BASE}/acp`, { method: 'DELETE', headers: connHeaders }).catch( + () => {}, + ); + log('✓ DELETE /acp — connection closed'); + log('\nALL CHECKS PASSED ✅'); + process.exit(0); +} + +main().catch((e) => die(e.stack ?? String(e)));