diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 295cdb1c7..c5dfb0c79 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -60,6 +60,14 @@ beforeAll(async () => { TOKEN, '--hostname', '127.0.0.1', + // Per #3803 §02 (1 daemon = 1 workspace), pin the bound + // workspace so test assertions that POST `workspaceCwd: + // REPO_ROOT` succeed regardless of where the test runner + // happens to be cwd'd. Without this the daemon would inherit + // the test runner's cwd, which is brittle across CI / local + // / IDE-launcher environments. + '--workspace', + REPO_ROOT, ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); @@ -221,20 +229,77 @@ describe('qwen serve — POST /session validation + concurrent coalescing', () = // Tearing the session down on model-switch failure would force // the caller into a 500 with no way to recover. The // `model_switch_failed` SSE event is the visible failure signal. - const cwd = '/tmp'; + // + // Use REPO_ROOT (the daemon's bound workspace) — under #3803 §02 + // any other cwd would return 400 workspace_mismatch before the + // session is even spawned. + const cwd = REPO_ROOT; const session = await client.createOrAttachSession({ workspaceCwd: cwd, modelServiceId: 'definitely-not-a-real-model', }); expect(session.sessionId).toBeTypeOf('string'); - expect(session.attached).toBe(false); + // `attached` may be true or false depending on whether earlier + // tests in this file already created a REPO_ROOT session. The + // shape of the response is what matters here (sessionId present, + // listWorkspaceSessions sees it). + expect(typeof session.attached).toBe('boolean'); const sessions = await client.listWorkspaceSessions(cwd); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.sessionId).toBe(session.sessionId); + expect(sessions.some((s) => s.sessionId === session.sessionId)).toBe(true); // No teardown — Stage 1 has no DELETE /session route, and the - // session persists in `byId` until daemon shutdown. The other - // tests in this file use unique workspace cwds so the surviving - // session here doesn't interfere. + // session persists in `byId` until daemon shutdown. + }); + + it('rejects cross-workspace cwd with 400 workspace_mismatch (#3803 §02)', async () => { + // The daemon is bound to REPO_ROOT (via `--workspace` in beforeAll). + // A POST /session with `cwd: '/tmp'` (or any other absolute path + // that doesn't canonicalize to REPO_ROOT) must reject with 400 + // `workspace_mismatch`, carrying both paths in the body so an + // orchestrator-aware client can spawn / route to the right + // daemon. + const res = await fetch(`${base}/session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ cwd: '/tmp' }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { + code?: string; + boundWorkspace?: string; + requestedWorkspace?: string; + }; + expect(body.code).toBe('workspace_mismatch'); + expect(body.boundWorkspace).toBe(REPO_ROOT); + expect(body.requestedWorkspace).toBe('/tmp'); + }); + + it('omits cwd → falls back to bound workspace (#3803 §02)', async () => { + // The route accepts an empty body and falls back to the daemon's + // bound workspace. Asserting this end-to-end through a real + // daemon process verifies the runQwenServe → createServeApp → + // bridge plumbing for the fallback path. + const res = await fetch(`${base}/session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(200); + const session = (await res.json()) as { + sessionId?: string; + workspaceCwd?: string; + }; + expect(session.workspaceCwd).toBe(REPO_ROOT); + }); + + it('GET /capabilities surfaces workspaceCwd (#3803 §02)', async () => { + const caps = await client.capabilities(); + expect(caps.workspaceCwd).toBe(REPO_ROOT); }); }); diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 29d49c0da..0d7166b20 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -25,6 +25,17 @@ export interface DaemonCapabilities { */ features: string[]; modelServices: string[]; + /** + * Absolute canonical workspace path this daemon is bound to + * (per #3803 §02: 1 daemon = 1 workspace). Clients use this to + * (a) detect mismatch before posting `/session` (vs. waiting for + * a 400 `workspace_mismatch` response), and (b) omit `cwd` on + * `POST /session` — the route falls back to this path when the + * body has no `cwd` field. Multi-workspace deployments expose + * multiple daemons on different ports, each advertising its own + * `workspaceCwd`. + */ + 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 27c9aadb1..bdc223159 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -96,11 +96,15 @@ describe('DaemonClient', () => { mode: 'http-bridge' as const, features: ['health', 'capabilities'], modelServices: [], + workspaceCwd: '/work/bound', }; const { fetch } = recordingFetch(() => jsonResponse(200, envelope)); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); const caps = await client.capabilities(); expect(caps).toEqual(envelope); + // #3803 §02: clients use `workspaceCwd` to pre-flight check + + // omit `cwd` from `POST /session` (route falls back). + expect(caps.workspaceCwd).toBe('/work/bound'); }); });