diff --git a/docs/developers/examples/daemon-client-quickstart.md b/docs/developers/examples/daemon-client-quickstart.md index 35a7dd036..16d1dd864 100644 --- a/docs/developers/examples/daemon-client-quickstart.md +++ b/docs/developers/examples/daemon-client-quickstart.md @@ -36,11 +36,15 @@ const caps = await client.capabilities(); console.log('Daemon features:', caps.features); console.log('Daemon workspace:', caps.workspaceCwd); // canonical bound path -// 2. Spawn-or-attach a session. The `workspaceCwd` MUST match the -// daemon's bound workspace (`caps.workspaceCwd`) — pass it -// explicitly, OR omit it and the route falls back to the bound -// path. A mismatched `workspaceCwd` returns -// `400 workspace_mismatch` (see "Workspace mismatch" below). +// 2. Spawn-or-attach a session. Two equally-valid shapes: +// (a) pass `workspaceCwd: caps.workspaceCwd` to be explicit, or +// (b) omit `workspaceCwd` entirely — the SDK then sends no `cwd` +// field and the daemon route falls back to its bound +// workspace. The (b) shape is concise but assumes you trust +// `caps.workspaceCwd` to be whatever you intended. +// A non-empty `workspaceCwd` that doesn't canonicalize to the +// daemon's bound path yields `400 workspace_mismatch` (see +// "Workspace mismatch" below). const session = await client.createOrAttachSession({ workspaceCwd: caps.workspaceCwd, }); diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 583c2ac88..74224ab1c 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -115,7 +115,7 @@ Stable contract: when `v` increments the frame layout has changed in a backwards > **`modelServices` is always `[]` in Stage 1.** The agent uses its single default model service and doesn't enumerate it over the wire. Stage 2 will populate this from registered model adapters so SDK clients can build service-pickers; until then, do NOT rely on this field being non-empty. -> **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. +> **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it. ### `POST /session` diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index f398f0c04..1c7ef1532 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -115,7 +115,7 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 | `--max-sessions ` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). | | `--workspace ` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. | | `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | -| `--http-bridge` | `true` | Stage 1 mode: per-session `qwen --acp` child process. Stage 2 native in-process becomes available later. | +| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | > **Sizing the load knobs.** `--max-sessions` is the **new-child** cap. > Three other layers also limit load — when sizing for a high-concurrency diff --git a/packages/cli/src/serve/runQwenServe.ts b/packages/cli/src/serve/runQwenServe.ts index f2092dd75..fc3e7ed54 100644 --- a/packages/cli/src/serve/runQwenServe.ts +++ b/packages/cli/src/serve/runQwenServe.ts @@ -139,6 +139,20 @@ export async function runQwenServe( `Invalid --workspace "${rawWorkspace}": directory does not exist.`, ); } + // EACCES / EPERM: the path exists but the current user can't + // stat it (typical for SIP-protected paths on macOS, root-owned + // dirs the daemon's user can't traverse, etc.). The raw Node + // SystemError has the path AND the syscall but no operator- + // facing breadcrumb that this came from `--workspace`. Wrap + // both codes so the boot failure points at the flag the + // operator actually set. + if (code === 'EACCES' || code === 'EPERM') { + throw new Error( + `Invalid --workspace "${rawWorkspace}": permission denied ` + + `(${String(code)}). The path exists but cannot be stat'd ` + + `by the current user.`, + ); + } } throw err; } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7ae04ad24..1351a9f7b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, it, expect, afterEach, vi } from 'vitest'; import request from 'supertest'; @@ -217,7 +218,13 @@ describe('createServeApp', () => { .get('/capabilities') .set('Host', `127.0.0.1:${baseOpts.port}`); expect(res.status).toBe(200); - expect(res.body.workspaceCwd).toBe(process.cwd()); + // `createServeApp` runs `canonicalizeWorkspace` on + // `process.cwd()`, which collapses symlinks via + // `realpathSync.native`. On macOS the default tmpdir is + // `/var/folders/...` whose canonical form is + // `/private/var/folders/...`; a raw `process.cwd()` assertion + // would diverge there. Use the same realpath the route does. + expect(res.body.workspaceCwd).toBe(realpathSync.native(process.cwd())); }); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 784176618..64bf5b28c 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -184,7 +184,7 @@ export function createServeApp( if (!path.isAbsolute(cwd)) { res .status(400) - .json({ error: '`cwd` is required and must be an absolute path' }); + .json({ error: '`cwd` must be an absolute path when provided' }); return; } const modelServiceId = @@ -840,6 +840,19 @@ function sendBridgeError( // the wrong daemon for their workspace). Body includes both // paths so orchestrator-aware clients can route to the right // daemon / spawn a new one. + // + // Operator log line: unlike SessionNotFoundError (per-session + // 404 with rich URL context), workspace_mismatch indicates an + // orchestration / deployment drift (operator booted with the + // wrong workspace, or client is routing to the wrong daemon). + // Without a breadcrumb the daemon's log looks healthy while + // every client request silently 400s. Limited to authenticated + // requests by the upstream bearer-token gate, so probing-DoS + // log noise stays bounded. + writeStderrLine( + `qwen serve: workspace_mismatch (POST /session): ` + + `daemon bound to "${err.bound}", rejected "${err.requested}"`, + ); res.status(400).json({ error: err.message, code: 'workspace_mismatch', diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 3983c63af..d07051cee 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -88,7 +88,16 @@ export class DaemonHttpError extends Error { } export interface CreateSessionRequest { - workspaceCwd: string; + /** + * Workspace path the daemon must be bound to (per #3803 §02). When + * omitted, the SDK sends no `cwd` field and the daemon route falls + * back to its boot-time `boundWorkspace`. Pass `caps.workspaceCwd` + * to be explicit, or omit it for the daemon-knows-best path. A + * non-empty `workspaceCwd` that doesn't canonicalize to the + * daemon's bound path yields a `400 workspace_mismatch` + * `DaemonHttpError`. + */ + workspaceCwd?: string; modelServiceId?: string; } @@ -250,13 +259,18 @@ export class DaemonClient { async createOrAttachSession( req: CreateSessionRequest, ): Promise { + // Per #3803 §02: omitting `cwd` lets the daemon fall back to its + // bound workspace. Send `cwd` ONLY when the caller passed + // `workspaceCwd` — sending `cwd: undefined` would JSON.stringify + // it out anyway, but conditional spread keeps the on-wire body + // shape obvious to anyone tracing the request. return await this.fetchWithTimeout( `${this.baseUrl}/session`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - cwd: req.workspaceCwd, + ...(req.workspaceCwd ? { cwd: req.workspaceCwd } : {}), ...(req.modelServiceId ? { modelServiceId: req.modelServiceId } : {}), }), }, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 0d7166b20..865aa0b5b 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -34,8 +34,15 @@ export interface DaemonCapabilities { * body has no `cwd` field. Multi-workspace deployments expose * multiple daemons on different ports, each advertising its own * `workspaceCwd`. + * + * Optional at the type level because the field is an additive + * extension to v=1 envelopes (added by #3803 §02). Daemons + * predating §02 still announce `v: 1` but omit this field; the + * protocol's "bump v only on incompatible frame changes" stance + * (see `qwen-serve-protocol.md`) makes additive optionality the + * correct shape. All post-§02 daemons populate it. */ - workspaceCwd: string; + workspaceCwd?: string; } /** Returned from `POST /session`. */ diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index bdc223159..06f746213 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -151,6 +151,25 @@ describe('DaemonClient', () => { expect(JSON.parse(calls[0]!.body!)).toEqual({ cwd: '/work/a' }); }); + it('omits cwd when workspaceCwd is not provided (#3803 §02)', async () => { + // Per #3803 §02 the daemon route falls back to its bound + // workspace when `cwd` is absent. The SDK must therefore NOT + // send `cwd: undefined` (or any other placeholder) when the + // caller omits `workspaceCwd` — otherwise the wire body + // carries an explicit empty field that wouldn't ever match + // the fallback semantics on a future stricter daemon. + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/bound', + attached: false, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await client.createOrAttachSession({}); + expect(JSON.parse(calls[0]!.body!)).toEqual({}); + }); + it('forwards modelServiceId when supplied', async () => { const { fetch, calls } = recordingFetch(() => jsonResponse(200, {