From cf3a0e6bc1c5965483b9270674bf4a5a174e9dde Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 01:16:47 +0800 Subject: [PATCH 1/8] feat(cli): Add Phase 2a workspace foundation Co-authored-by: Qwen-Coder --- ...daemon-multi-workspace-phase2a-sessions.md | 116 +++++++++++++ packages/cli/src/commands/serve.test.ts | 21 ++- packages/cli/src/commands/serve.ts | 1 + packages/cli/src/serve/fast-path.test.ts | 12 ++ packages/cli/src/serve/fast-path.ts | 3 + packages/cli/src/serve/run-qwen-serve.ts | 17 +- packages/cli/src/serve/server.test.ts | 125 +++++++++++++- packages/cli/src/serve/server.ts | 99 +++++++++-- .../cli/src/serve/workspace-inputs.test.ts | 76 +++++++++ packages/cli/src/serve/workspace-inputs.ts | 98 +++++++++++ .../cli/src/serve/workspace-registry.test.ts | 158 +++++++++++++++++- packages/cli/src/serve/workspace-registry.ts | 100 ++++++++++- 12 files changed, 783 insertions(+), 43 deletions(-) create mode 100644 .qwen/design/daemon-multi-workspace-phase2a-sessions.md create mode 100644 packages/cli/src/serve/workspace-inputs.test.ts create mode 100644 packages/cli/src/serve/workspace-inputs.ts diff --git a/.qwen/design/daemon-multi-workspace-phase2a-sessions.md b/.qwen/design/daemon-multi-workspace-phase2a-sessions.md new file mode 100644 index 00000000000..fcdc97cfdb8 --- /dev/null +++ b/.qwen/design/daemon-multi-workspace-phase2a-sessions.md @@ -0,0 +1,116 @@ +# Phase 2a Multi-Workspace Sessions Foundation + +## Summary + +This document records the Phase 2a foundation contract for issue #6378 after +the Phase 1 `WorkspaceRegistry` PR. The current implementation batch combines +the Phase 1 repeated `--workspace` follow-up, the Phase 2a prep guardrails, and +the first internal registry/runtime contract needed by later multi-workspace +session work. + +Phase 2a remains sessions-only. It does not add plural routes, a +`WorkspaceDaemonClient`, workspace-qualified ACP/WebSocket, file, memory, MCP, +settings, voice, channel-worker migration, env overlays, total-session +admission, capabilities `workspaces[]`, `multi_workspace_sessions`, route +dispatch, or non-primary runtime construction in this foundation batch. + +## Foundation Contract + +- `--workspace` is repeatable at the CLI parser layer so yargs preserves array + input instead of collapsing it. +- The serve fast path falls back to the full parser when repeated workspace + values are present. +- A single-item workspace array is treated as the primary workspace and keeps + the existing single-workspace behavior. +- Multiple explicit workspaces remain gated and fail before runtime boot. +- Duplicate canonical workspace inputs fail explicitly. +- Nested workspace inputs fail explicitly. +- Distinct non-nested multiple workspace inputs fail with the generic + "multi-workspace serve is not enabled" boot error. +- The first explicit workspace is the future primary workspace once the gate is + removed; this foundation batch does not expose that list publicly. + +The internal `WorkspaceRuntime` contract now carries stable metadata for later +Phase 2a work: + +- `workspaceId`: stable hash of the canonical workspace cwd. +- `workspaceCwd`: canonical workspace cwd. +- `primary`: true for the primary runtime. +- `trusted`: boot-time trust metadata; direct `createServeApp` fallback remains + false unless production passes an explicit trusted value. +- `env`: metadata only. This foundation batch records parent-process mode and + empty overlay keys; it does not compute runtime-local env overlays. + +The internal `WorkspaceRegistry` supports exact cwd lookup, exact id lookup, +`resolveWorkspaceCwd(undefined)` primary fallback, and live session owner +resolution. Live owner resolution scans runtime bridge summaries only; it does +not scan persisted storage, create children, or route any request yet. Duplicate +live owners fail closed as an ambiguous result. + +`createServeApp` may accept an injected registry for tests and future assembly, +but route modules still receive the primary runtime only. Existing legacy +`app.locals.boundWorkspace` and `app.locals.fsFactory` remain primary-only +compatibility locals. + +## Phase 2a Route Classification + +The first ungated Phase 2a milestone must classify all `/session/:id/*` routes +before enabling multiple explicit workspaces. + +Phase 2a-dispatched routes: + +- `POST /session` +- `GET /session/:id/events` +- `POST /session/:id/prompt` +- `POST /session/:id/cancel` +- `POST /session/:id/permission/:requestId` +- `POST /session/:id/heartbeat` +- `POST /session/:id/detach` +- `GET /session/:id/pending-prompts` +- `DELETE /session/:id/pending-prompts/:promptId` +- `DELETE /session/:id` +- `GET /session/:id/status` + +Later or primary-only routes: + +- non-primary `POST /session/:id/load` +- non-primary `POST /session/:id/resume` +- `GET /session/:id/export` +- `POST /sessions/delete` +- `POST /sessions/archive` +- `POST /sessions/unarchive` +- `PATCH /session/:id/organization` +- session-group mutations +- branch, fork, cd, rewind, shell, model, and language session mutations +- non-session `POST /permission/:requestId` +- `/acp` + +Additional live read routes may be owner-routed in a later Phase 2a slice only +after tests prove they depend solely on the owning live bridge. + +## Later Phase 2a Requirements + +- Keep scan misses as `404 session_not_found`; never fall back to primary. +- Fail closed if more than one runtime reports the same live session id. +- Keep non-primary session listing live-only unless persisted entries are + explicitly marked non-resumable. +- Add runtime-local env overlays before non-primary child spawn. +- Add `maxTotalSessions` at the bridge fresh-creation seam so REST and primary + `/acp` cannot bypass it, while attach still bypasses admission. +- Publish `workspaces[]`, total limits, and `multi_workspace_sessions` only in + the final ungate PR. +- Update SDK capability types when the additive capabilities schema ships, but + do not add a workspace client in Phase 2a. + +## Audit Decisions + +- The foundation PR must not create non-primary runtimes or relax any REST + route. +- Existing `app.locals.boundWorkspace` and `app.locals.fsFactory` remain + primary-only compatibility locals. +- The REST `routeFileSystemFactory` remains distinct from bridge filesystem + factories; it must not be used to represent non-primary bridge boundaries. +- IDE secondary filesystem roots must not be promoted into explicit workspace + runtimes. +- Single-workspace parent-env behavior remains compatible until true + multi-workspace mode is ungated. diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index e94aef79455..40df3026261 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -94,10 +94,10 @@ describe('serve command args', () => { expect(parsed['channel']).toEqual(['telegram', 'feishu']); }); - it('parses a single --workspace value as a string', () => { + it('parses a single --workspace value as a single-element array', () => { const parsed = buildParser().parseSync('--workspace /tmp/primary'); - expect(parsed['workspace']).toBe('/tmp/primary'); + expect(parsed['workspace']).toEqual(['/tmp/primary']); }); it('parses repeatable --workspace values as an array', () => { @@ -107,6 +107,23 @@ describe('serve command args', () => { expect(parsed['workspace']).toEqual(['/tmp/primary', '/tmp/secondary']); }); + + it('preserves repeatable --workspace values in command mode', () => { + let captured: unknown; + yargs([]) + .exitProcess(false) + .fail(false) + .locale('en') + .command({ + ...serveCommand, + handler: (argv) => { + captured = argv.workspace; + }, + }) + .parseSync('serve --workspace /tmp/primary --workspace /tmp/secondary'); + + expect(captured).toEqual(['/tmp/primary', '/tmp/secondary']); + }); }); describe('serve rate limit env parsing', () => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d76168522dc..b1e201fdb53 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -174,6 +174,7 @@ export const serveCommand: CommandModule = { }) .option('workspace', { type: 'string', + array: true, description: 'Absolute workspace path this daemon binds to. ' + 'POST /session requests with a mismatched cwd return 400 workspace_mismatch. ' + diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 918609a5e31..ec28391666c 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -546,6 +546,18 @@ describe('serve fast path argument parsing', () => { }); }); + it('falls back to the full parser for repeatable --workspace values', () => { + expect( + parseServeFastPathArgs([ + 'serve', + '--workspace', + '/tmp/primary', + '--workspace', + '/tmp/secondary', + ]), + ).toEqual({ kind: 'fallback' }); + }); + it('parses Windows bundled entrypoint argv before serve', () => { const parsed = parseServeFastPathArgs([ 'C:\\repo\\dist\\cli.js', diff --git a/packages/cli/src/serve/fast-path.ts b/packages/cli/src/serve/fast-path.ts index cf34304732f..32bbe6b252f 100644 --- a/packages/cli/src/serve/fast-path.ts +++ b/packages/cli/src/serve/fast-path.ts @@ -349,6 +349,9 @@ export function parseServeFastPathArgs( const read = readOptionValue(argv, i, inlineValue); if (!read) return { kind: 'fallback' }; i = read.nextIndex; + if (stringTarget === 'workspace' && options.workspace !== undefined) { + return { kind: 'fallback' }; + } setServeOption(options, stringTarget, read.value); continue; } diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 2fa0111d7d3..b58dd968430 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -27,6 +27,7 @@ import { preResolveServeFastPathHomeEnvOverrides, type ServeFastPathSettings, } from './fast-path-settings.js'; +import { resolveSingleWorkspaceInput } from './workspace-inputs.js'; import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes'; import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; import type { @@ -221,21 +222,6 @@ function envFlagDisabled(raw: string | undefined): boolean { return normalized === '0' || normalized === 'false'; } -function resolveSingleWorkspaceInput(workspace: unknown): string { - if (Array.isArray(workspace)) { - if (workspace.length === 0) return process.cwd(); - if (workspace.length > 1) { - throw new Error( - 'Multiple --workspace values are not supported yet. ' + - 'Multi-workspace serve is not enabled; pass one --workspace.', - ); - } - return String(workspace[0]); - } - if (workspace === undefined) return process.cwd(); - return String(workspace); -} - function hasChromeExtensionOrigin(origins: readonly string[] | undefined) { return ( origins?.some((origin) => @@ -2546,6 +2532,7 @@ export async function runQwenServe( pathLocks: sharedPathLocks, ...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}), }), + primaryWorkspaceTrusted: trustedWorkspace, daemonLog, getChannelWorkerSnapshot, getPerfSnapshot: () => ({ diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8fcd4fe96f2..c58e50c5a5b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -112,7 +112,11 @@ import type { DaemonLogger } from './daemon-logger.js'; import { FsError, type WorkspaceFileSystemFactory } from './fs/index.js'; import { getRateLimiter } from './rate-limit.js'; import type { DaemonWorkspaceService } from './workspace-service/types.js'; -import type { WorkspaceRegistry } from './workspace-registry.js'; +import { + createWorkspaceRegistry, + type WorkspaceRegistry, + type WorkspaceRuntime, +} from './workspace-registry.js'; import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; import { resetTrustedFoldersForTesting, @@ -14035,6 +14039,15 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { expect(locals.workspaceRegistry!.primary.workspaceCwd).toBe( locals.boundWorkspace, ); + expect(locals.workspaceRegistry!.primary.workspaceId).toMatch( + /^[a-f0-9]{16}$/, + ); + expect(locals.workspaceRegistry!.primary.primary).toBe(true); + expect(locals.workspaceRegistry!.primary.trusted).toBe(false); + expect(locals.workspaceRegistry!.primary.env).toEqual({ + mode: 'parent-process', + overlayKeys: [], + }); expect(locals.workspaceRegistry!.list()).toEqual([ locals.workspaceRegistry!.primary, ]); @@ -14093,6 +14106,116 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { ); }); + it('threads production-style primary trust into the default runtime metadata', async () => { + const { createServeApp } = await import('./server.js'); + const app = createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/bound', + } as Parameters[0], + () => 0, + { primaryWorkspaceTrusted: true } as Parameters[2], + ); + const locals = app.locals as { workspaceRegistry?: WorkspaceRegistry }; + + expect(locals.workspaceRegistry!.primary.trusted).toBe(true); + }); + + it('uses an injected workspace registry as the primary runtime source', async () => { + const { createServeApp } = await import('./server.js'); + const bridge = fakeBridge(); + const fsFactory = { forRequest: vi.fn(() => ({ marker: 'registry-fs' })) }; + const runtime = { + workspaceId: 'ws-registry', + workspaceCwd: '/work/registry-primary', + primary: true, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge, + workspaceService: {} as DaemonWorkspaceService, + routeFileSystemFactory: + fsFactory as unknown as WorkspaceFileSystemFactory, + clientMcpSenderRegistry: {}, + } as WorkspaceRuntime; + const registry = createWorkspaceRegistry([runtime]); + + const app = createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { workspaceRegistry: registry } as Parameters[2], + ); + const locals = app.locals as { + boundWorkspace?: string; + fsFactory?: unknown; + workspaceRegistry?: WorkspaceRegistry; + }; + + expect(locals.workspaceRegistry).toBe(registry); + expect(locals.boundWorkspace).toBe('/work/registry-primary'); + expect(locals.fsFactory).toBe(fsFactory); + + const res = await request(app) + .get('/capabilities') + .set('Host', '127.0.0.1:0') + .expect(200); + expect(res.body.workspaceCwd).toBe('/work/registry-primary'); + }); + + it('rejects conflicting runtime deps when a workspace registry is injected', async () => { + const { createServeApp } = await import('./server.js'); + const runtime = { + workspaceId: 'ws-registry', + workspaceCwd: '/work/registry-primary', + primary: true, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: fakeBridge(), + workspaceService: {} as DaemonWorkspaceService, + routeFileSystemFactory: { + forRequest: vi.fn(() => ({ marker: 'registry-fs' })), + } as unknown as WorkspaceFileSystemFactory, + clientMcpSenderRegistry: {}, + } as WorkspaceRuntime; + const registry = createWorkspaceRegistry([runtime]); + + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + bridge: fakeBridge(), + } as Parameters[2], + ), + ).toThrow(/workspaceRegistry conflicts with deps\.bridge/); + + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + fsFactory: { + forRequest: vi.fn(() => ({ marker: 'other-fs' })), + } as unknown as WorkspaceFileSystemFactory, + } as Parameters[2], + ), + ).toThrow(/workspaceRegistry conflicts with deps\.fsFactory/); + }); + it('passes custom ignore files through resolveBridgeFsFactory', async () => { const { resolveBridgeFsFactory } = await import('./server.js'); const tmp = await fsp.mkdtemp( diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index b90413644d1..30390a9b054 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -6,6 +6,8 @@ import express from 'express'; import type { Application } from 'express'; +import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; +import { hashDaemonWorkspace } from '@qwen-code/qwen-code-core'; import type { DaemonLogger } from './daemon-logger.js'; import type { DaemonMetricsBucket, @@ -25,7 +27,6 @@ import type { DeviceFlowProvider, DeviceFlowRegistry, } from './auth/device-flow.js'; -import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; import { createDaemonStatusProvider } from './daemon-status-provider.js'; import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; @@ -262,6 +263,8 @@ export interface ServeAppDeps { * builds its own registry and wires it into the bridge it creates. */ clientMcpSenderRegistry?: ClientMcpSenderRegistry; + workspaceRegistry?: WorkspaceRegistry; + primaryWorkspaceTrusted?: boolean; voiceTranscriber?: WorkspaceVoiceRouteDeps['transcribe']; } @@ -311,15 +314,65 @@ export function createServeApp( // AND passed into the bridge must be the SAME canonical form. // `deps.boundWorkspace` is the pre-canonicalized fast-path from // `runQwenServe`; when omitted we canonicalize ourselves. + const injectedWorkspaceRegistry = deps.workspaceRegistry; const boundWorkspace = + injectedWorkspaceRegistry?.primary.workspaceCwd ?? deps.boundWorkspace ?? canonicalizeWorkspace(opts.workspace ?? process.cwd()); + if ( + injectedWorkspaceRegistry && + deps.boundWorkspace !== undefined && + deps.boundWorkspace !== injectedWorkspaceRegistry.primary.workspaceCwd + ) { + throw new Error( + 'createServeApp: workspaceRegistry conflicts with deps.boundWorkspace.', + ); + } + if (injectedWorkspaceRegistry && deps.bridge) { + if (deps.bridge !== injectedWorkspaceRegistry.primary.bridge) { + throw new Error( + 'createServeApp: workspaceRegistry conflicts with deps.bridge.', + ); + } + } + if (injectedWorkspaceRegistry && deps.workspace) { + if (deps.workspace !== injectedWorkspaceRegistry.primary.workspaceService) { + throw new Error( + 'createServeApp: workspaceRegistry conflicts with deps.workspace.', + ); + } + } + if (injectedWorkspaceRegistry && deps.fsFactory) { + if ( + deps.fsFactory !== + injectedWorkspaceRegistry.primary.routeFileSystemFactory + ) { + throw new Error( + 'createServeApp: workspaceRegistry conflicts with deps.fsFactory.', + ); + } + } + if (injectedWorkspaceRegistry && deps.clientMcpSenderRegistry) { + if ( + deps.clientMcpSenderRegistry !== + injectedWorkspaceRegistry.primary.clientMcpSenderRegistry + ) { + throw new Error( + 'createServeApp: workspaceRegistry conflicts with deps.clientMcpSenderRegistry.', + ); + } + } // Construct `fsFactory` BEFORE the bridge so the bridge can wire it // through `BridgeFileSystem` for ACP-side writeTextFile/readTextFile. // Default trust is `false` (test-safe). Embeds without `deps.fsFactory` // or `deps.bridge` will see agent writes rejected with // `untrusted_workspace` — warn once so the asymmetry is visible. - if (!deps.fsFactory && !deps.bridge && !warnedDefaultTrust) { + if ( + !injectedWorkspaceRegistry && + !deps.fsFactory && + !deps.bridge && + !warnedDefaultTrust + ) { warnedDefaultTrust = true; process.stderr.write( 'qwen serve: createServeApp default fsFactory uses trusted=false ' + @@ -327,11 +380,13 @@ export function createServeApp( 'Inject deps.fsFactory (with explicit trust) or deps.bridge to override.\n', ); } - const fsFactory = resolveBridgeFsFactory({ - boundWorkspaces: [boundWorkspace], - injected: deps.fsFactory, - trusted: false, - }); + const fsFactory = + injectedWorkspaceRegistry?.primary.routeFileSystemFactory ?? + resolveBridgeFsFactory({ + boundWorkspaces: [boundWorkspace], + injected: deps.fsFactory, + trusted: false, + }); const tokenConfigured = typeof opts.token === 'string' && opts.token.length > 0; const sessionShellCommandEnabled = @@ -354,6 +409,7 @@ export function createServeApp( if ( opts.clientMcpOverWs === true && deps.bridge && + !injectedWorkspaceRegistry && !deps.clientMcpSenderRegistry ) { throw new Error( @@ -363,17 +419,21 @@ export function createServeApp( ); } const clientMcpSenderRegistry = - deps.clientMcpSenderRegistry ?? new ClientMcpSenderRegistry(); + injectedWorkspaceRegistry?.primary.clientMcpSenderRegistry ?? + deps.clientMcpSenderRegistry ?? + new ClientMcpSenderRegistry(); const { languageCodes, currentServeFeatures, invalidateServeFeaturesCache } = createServeFeatures({ opts, boundWorkspace, persistSettingAvailable: deps.persistSetting !== undefined, - reloadAvailable: deps.workspace !== undefined, + reloadAvailable: + deps.workspace !== undefined || injectedWorkspaceRegistry !== undefined, sessionShellCommandEnabled, }); const statusProvider = deps.statusProvider ?? createDaemonStatusProvider(); const bridge = + injectedWorkspaceRegistry?.primary.bridge ?? deps.bridge ?? createAcpSessionBridge({ maxSessions: opts.maxSessions, @@ -424,6 +484,7 @@ export function createServeApp( ) => sendPermissionVoteErrorResponse(res, err, ctx, daemonLog); const workspace: DaemonWorkspaceService = + injectedWorkspaceRegistry?.primary.workspaceService ?? deps.workspace ?? createDaemonWorkspaceService({ boundWorkspace, @@ -460,13 +521,19 @@ export function createServeApp( bridge.publishWorkspaceEvent(event); }, }); - const workspaceRegistry = createSingleWorkspaceRegistry({ - workspaceCwd: boundWorkspace, - bridge, - workspaceService: workspace, - routeFileSystemFactory: fsFactory, - clientMcpSenderRegistry, - }); + const workspaceRegistry = + injectedWorkspaceRegistry ?? + createSingleWorkspaceRegistry({ + workspaceId: hashDaemonWorkspace(boundWorkspace), + workspaceCwd: boundWorkspace, + primary: true, + trusted: deps.primaryWorkspaceTrusted ?? false, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge, + workspaceService: workspace, + routeFileSystemFactory: fsFactory, + clientMcpSenderRegistry, + }); (app.locals as { workspaceRegistry?: WorkspaceRegistry }).workspaceRegistry = workspaceRegistry; const primaryRuntime = workspaceRegistry.primary; diff --git a/packages/cli/src/serve/workspace-inputs.test.ts b/packages/cli/src/serve/workspace-inputs.test.ts new file mode 100644 index 00000000000..97b58643ea8 --- /dev/null +++ b/packages/cli/src/serve/workspace-inputs.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + DuplicateWorkspaceInputError, + MultipleWorkspaceInputError, + NestedWorkspaceInputError, + resolveSingleWorkspaceInput, +} from './workspace-inputs.js'; + +let scratch: string | undefined; + +function makeScratch(): string { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'qws-workspaces-')); + return scratch; +} + +afterEach(() => { + if (scratch) { + fs.rmSync(scratch, { recursive: true, force: true }); + scratch = undefined; + } +}); + +describe('resolveSingleWorkspaceInput', () => { + it('preserves single-workspace inputs', () => { + expect(resolveSingleWorkspaceInput('/repo/primary')).toBe('/repo/primary'); + expect(resolveSingleWorkspaceInput(['/repo/primary'])).toBe( + '/repo/primary', + ); + }); + + it('falls back to process.cwd() when no workspace is supplied', () => { + expect(resolveSingleWorkspaceInput(undefined)).toBe(process.cwd()); + expect(resolveSingleWorkspaceInput([])).toBe(process.cwd()); + }); + + it('rejects duplicate canonical explicit workspaces', () => { + const root = makeScratch(); + const workspace = fs.realpathSync(path.join(root)); + + expect(() => resolveSingleWorkspaceInput([workspace, workspace])).toThrow( + DuplicateWorkspaceInputError, + ); + }); + + it('rejects nested explicit workspaces', () => { + const root = makeScratch(); + const parent = path.join(root, 'parent'); + const child = path.join(parent, 'child'); + fs.mkdirSync(child, { recursive: true }); + + expect(() => resolveSingleWorkspaceInput([parent, child])).toThrow( + NestedWorkspaceInputError, + ); + }); + + it('rejects distinct non-nested explicit workspaces while Phase 2a is gated', () => { + const root = makeScratch(); + const primary = path.join(root, 'primary'); + const secondary = path.join(root, 'secondary'); + fs.mkdirSync(primary); + fs.mkdirSync(secondary); + + expect(() => resolveSingleWorkspaceInput([primary, secondary])).toThrow( + MultipleWorkspaceInputError, + ); + }); +}); diff --git a/packages/cli/src/serve/workspace-inputs.ts b/packages/cli/src/serve/workspace-inputs.ts new file mode 100644 index 00000000000..14d78673578 --- /dev/null +++ b/packages/cli/src/serve/workspace-inputs.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; + +export class DuplicateWorkspaceInputError extends Error { + constructor(workspace: string) { + super( + `Duplicate --workspace value resolves to ${JSON.stringify(workspace)}. ` + + 'Multi-workspace serve is not enabled; pass one --workspace.', + ); + this.name = 'DuplicateWorkspaceInputError'; + } +} + +export class NestedWorkspaceInputError extends Error { + constructor(parent: string, child: string) { + super( + `Nested --workspace values are not supported yet: ` + + `${JSON.stringify(child)} is inside ${JSON.stringify(parent)}. ` + + 'Multi-workspace serve is not enabled; pass one --workspace.', + ); + this.name = 'NestedWorkspaceInputError'; + } +} + +export class MultipleWorkspaceInputError extends Error { + constructor() { + super( + 'Multiple --workspace values are not supported yet. ' + + 'Multi-workspace serve is not enabled; pass one --workspace.', + ); + this.name = 'MultipleWorkspaceInputError'; + } +} + +function normalizeWorkspaceInputs(workspace: unknown): string[] { + if (Array.isArray(workspace)) { + if (workspace.length === 0) return [process.cwd()]; + return workspace.map((value) => String(value)); + } + if (workspace === undefined) return [process.cwd()]; + return [String(workspace)]; +} + +function isNestedWorkspace(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return ( + relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) + ); +} + +function rejectUnsupportedMultiWorkspaceInputs( + workspaces: readonly string[], +): void { + if (workspaces.length <= 1) return; + + let canonicalWorkspaces: string[]; + try { + canonicalWorkspaces = workspaces.map((workspace) => + canonicalizeWorkspace(workspace), + ); + } catch { + throw new MultipleWorkspaceInputError(); + } + const seen = new Set(); + for (const workspace of canonicalWorkspaces) { + if (seen.has(workspace)) { + throw new DuplicateWorkspaceInputError(workspace); + } + seen.add(workspace); + } + + for (let i = 0; i < canonicalWorkspaces.length; i++) { + for (let j = i + 1; j < canonicalWorkspaces.length; j++) { + const first = canonicalWorkspaces[i]!; + const second = canonicalWorkspaces[j]!; + if (isNestedWorkspace(first, second)) { + throw new NestedWorkspaceInputError(first, second); + } + if (isNestedWorkspace(second, first)) { + throw new NestedWorkspaceInputError(second, first); + } + } + } + + throw new MultipleWorkspaceInputError(); +} + +export function resolveSingleWorkspaceInput(workspace: unknown): string { + const workspaces = normalizeWorkspaceInputs(workspace); + rejectUnsupportedMultiWorkspaceInputs(workspaces); + return workspaces[0]!; +} diff --git a/packages/cli/src/serve/workspace-registry.test.ts b/packages/cli/src/serve/workspace-registry.test.ts index 0909cc6d914..f27ce405ef6 100644 --- a/packages/cli/src/serve/workspace-registry.test.ts +++ b/packages/cli/src/serve/workspace-registry.test.ts @@ -5,14 +5,43 @@ */ import { describe, expect, it } from 'vitest'; +import { SessionNotFoundError } from './acp-session-bridge.js'; import { + createWorkspaceRegistry, createSingleWorkspaceRegistry, type WorkspaceRuntime, } from './workspace-registry.js'; +function bridgeWithSummary( + getSessionSummary: (sessionId: string) => unknown, +): WorkspaceRuntime['bridge'] { + return { getSessionSummary } as unknown as WorkspaceRuntime['bridge']; +} + +function makeRuntime( + workspaceCwd: string, + overrides: Partial = {}, +): WorkspaceRuntime { + const bridge = bridgeWithSummary(() => { + throw new SessionNotFoundError('missing'); + }); + return { + workspaceId: `id:${workspaceCwd}`, + workspaceCwd, + primary: false, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge, + workspaceService: {}, + routeFileSystemFactory: {}, + clientMcpSenderRegistry: {}, + ...overrides, + } as WorkspaceRuntime; +} + describe('createSingleWorkspaceRegistry', () => { it('exposes the supplied runtime as the primary and only runtime', () => { - const runtime = { workspaceCwd: '/work/primary' } as WorkspaceRuntime; + const runtime = makeRuntime('/work/primary', { primary: true }); const registry = createSingleWorkspaceRegistry(runtime); @@ -22,7 +51,7 @@ describe('createSingleWorkspaceRegistry', () => { }); it('looks up only the exact canonical workspace string', () => { - const runtime = { workspaceCwd: '/work/primary' } as WorkspaceRuntime; + const runtime = makeRuntime('/work/primary', { primary: true }); const registry = createSingleWorkspaceRegistry(runtime); @@ -32,4 +61,129 @@ describe('createSingleWorkspaceRegistry', () => { expect(registry.getByWorkspaceCwd('/work/primary/')).toBeUndefined(); expect(registry.getByWorkspaceCwd('/other')).toBeUndefined(); }); + + it('looks up by workspace id and resolves omitted workspace to primary', () => { + const runtime = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + }); + + const registry = createSingleWorkspaceRegistry(runtime); + + expect(registry.getByWorkspaceId('ws-primary')).toBe(runtime); + expect(registry.getByWorkspaceId('missing')).toBeUndefined(); + expect(registry.resolveWorkspaceCwd(undefined)).toBe(runtime); + expect(registry.resolveWorkspaceCwd('/work/primary')).toBe(runtime); + expect(registry.resolveWorkspaceCwd('/work/primary/')).toBeUndefined(); + }); +}); + +describe('createWorkspaceRegistry', () => { + it('keeps runtime order frozen and uses the marked primary runtime', () => { + const primary = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + }); + const secondary = makeRuntime('/work/secondary', { + workspaceId: 'ws-secondary', + }); + + const registry = createWorkspaceRegistry([primary, secondary]); + + expect(registry.primary).toBe(primary); + expect(registry.list()).toEqual([primary, secondary]); + expect(() => (registry.list() as WorkspaceRuntime[]).push(primary)).toThrow( + TypeError, + ); + expect(registry.getByWorkspaceCwd('/work/secondary')).toBe(secondary); + expect(registry.getByWorkspaceId('ws-secondary')).toBe(secondary); + expect(registry.resolveWorkspaceCwd('/work/missing')).toBeUndefined(); + }); + + it('rejects invalid registry construction', () => { + const primary = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + }); + const otherPrimary = makeRuntime('/work/other', { + workspaceId: 'ws-other', + primary: true, + }); + + expect(() => createWorkspaceRegistry([])).toThrow( + /at least one workspace runtime/, + ); + expect(() => + createWorkspaceRegistry([makeRuntime('/work/no-primary')]), + ).toThrow(/exactly one primary workspace runtime/); + expect(() => createWorkspaceRegistry([primary, otherPrimary])).toThrow( + /exactly one primary workspace runtime/, + ); + expect(() => + createWorkspaceRegistry([ + primary, + makeRuntime('/work/primary', { workspaceId: 'ws-duplicate-cwd' }), + ]), + ).toThrow(/Duplicate workspace runtime cwd/); + expect(() => + createWorkspaceRegistry([ + primary, + makeRuntime('/work/secondary', { workspaceId: 'ws-primary' }), + ]), + ).toThrow(/Duplicate workspace runtime id/); + }); + + it('resolves live session owners without falling back to primary', () => { + const primary = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + bridge: bridgeWithSummary(() => { + throw new SessionNotFoundError('sess-secondary'); + }), + }); + const secondary = makeRuntime('/work/secondary', { + workspaceId: 'ws-secondary', + bridge: bridgeWithSummary((sessionId: string) => { + if (sessionId !== 'sess-secondary') { + throw new SessionNotFoundError(sessionId); + } + return { sessionId, workspaceCwd: '/work/secondary' }; + }), + }); + + const registry = createWorkspaceRegistry([primary, secondary]); + + expect(registry.resolveLiveSessionOwner('sess-secondary')).toEqual({ + kind: 'found', + runtime: secondary, + }); + expect(registry.resolveLiveSessionOwner('missing')).toEqual({ + kind: 'not_found', + }); + }); + + it('fails closed when live session owner resolution is ambiguous', () => { + const first = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + bridge: bridgeWithSummary((sessionId: string) => ({ + sessionId, + workspaceCwd: '/work/primary', + })), + }); + const second = makeRuntime('/work/secondary', { + workspaceId: 'ws-secondary', + bridge: bridgeWithSummary((sessionId: string) => ({ + sessionId, + workspaceCwd: '/work/secondary', + })), + }); + + const registry = createWorkspaceRegistry([first, second]); + + expect(registry.resolveLiveSessionOwner('sess-ambiguous')).toEqual({ + kind: 'ambiguous', + runtimes: [first, second], + }); + }); }); diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index ef20743a7e4..a8da291c688 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -4,33 +4,119 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { + SessionNotFoundError, + type AcpSessionBridge, +} from './acp-session-bridge.js'; import type { ClientMcpSenderRegistry } from './acp-http/client-mcp-sender-registry.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; import type { DaemonWorkspaceService } from './workspace-service/types.js'; +export interface WorkspaceRuntimeEnvMetadata { + readonly mode: 'parent-process'; + readonly overlayKeys: readonly string[]; +} + export interface WorkspaceRuntime { + readonly workspaceId: string; readonly workspaceCwd: string; + readonly primary: boolean; + readonly trusted: boolean; + readonly env: WorkspaceRuntimeEnvMetadata; readonly bridge: AcpSessionBridge; readonly workspaceService: DaemonWorkspaceService; readonly routeFileSystemFactory: WorkspaceFileSystemFactory; readonly clientMcpSenderRegistry: ClientMcpSenderRegistry; } +export type WorkspaceSessionOwnerResolution = + | { readonly kind: 'found'; readonly runtime: WorkspaceRuntime } + | { readonly kind: 'not_found' } + | { + readonly kind: 'ambiguous'; + readonly runtimes: readonly WorkspaceRuntime[]; + }; + export interface WorkspaceRegistry { readonly primary: WorkspaceRuntime; list(): readonly WorkspaceRuntime[]; getByWorkspaceCwd(workspaceCwd: string): WorkspaceRuntime | undefined; + getByWorkspaceId(workspaceId: string): WorkspaceRuntime | undefined; + resolveWorkspaceCwd( + workspaceCwd: string | undefined, + ): WorkspaceRuntime | undefined; + resolveLiveSessionOwner(sessionId: string): WorkspaceSessionOwnerResolution; } -export function createSingleWorkspaceRegistry( - runtime: WorkspaceRuntime, +export function createWorkspaceRegistry( + inputRuntimes: readonly WorkspaceRuntime[], ): WorkspaceRegistry { - const runtimes = Object.freeze([runtime]); + if (inputRuntimes.length === 0) { + throw new Error( + 'WorkspaceRegistry requires at least one workspace runtime.', + ); + } + + const primaryRuntimes = inputRuntimes.filter((runtime) => runtime.primary); + if (primaryRuntimes.length !== 1) { + throw new Error( + 'WorkspaceRegistry requires exactly one primary workspace runtime.', + ); + } + + const byCwd = new Map(); + const byId = new Map(); + for (const runtime of inputRuntimes) { + if (byCwd.has(runtime.workspaceCwd)) { + throw new Error( + `Duplicate workspace runtime cwd ${JSON.stringify( + runtime.workspaceCwd, + )}.`, + ); + } + byCwd.set(runtime.workspaceCwd, runtime); + + if (byId.has(runtime.workspaceId)) { + throw new Error( + `Duplicate workspace runtime id ${JSON.stringify( + runtime.workspaceId, + )}.`, + ); + } + byId.set(runtime.workspaceId, runtime); + } + + const runtimes = Object.freeze([...inputRuntimes]); + const primary = primaryRuntimes[0]!; return { - primary: runtime, + primary, list: () => runtimes, - getByWorkspaceCwd: (workspaceCwd) => - workspaceCwd === runtime.workspaceCwd ? runtime : undefined, + getByWorkspaceCwd: (workspaceCwd) => byCwd.get(workspaceCwd), + getByWorkspaceId: (workspaceId) => byId.get(workspaceId), + resolveWorkspaceCwd: (workspaceCwd) => + workspaceCwd === undefined ? primary : byCwd.get(workspaceCwd), + resolveLiveSessionOwner: (sessionId) => { + const matches: WorkspaceRuntime[] = []; + for (const runtime of runtimes) { + try { + runtime.bridge.getSessionSummary(sessionId); + matches.push(runtime); + } catch (err) { + if (err instanceof SessionNotFoundError) continue; + throw err; + } + } + if (matches.length === 0) return { kind: 'not_found' }; + if (matches.length === 1) { + return { kind: 'found', runtime: matches[0]! }; + } + return { kind: 'ambiguous', runtimes: matches }; + }, }; } + +export function createSingleWorkspaceRegistry( + runtime: WorkspaceRuntime, +): WorkspaceRegistry { + return createWorkspaceRegistry([runtime]); +} From 9bda837285527b32d81d47ebcaec3039842d8e3d Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 05:06:55 +0800 Subject: [PATCH 2/8] test(cli): Clarify registry reload capability Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 1 + packages/cli/src/serve/server.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c58e50c5a5b..a593a3b6477 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14164,6 +14164,7 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { .set('Host', '127.0.0.1:0') .expect(200); expect(res.body.workspaceCwd).toBe('/work/registry-primary'); + expect(res.body.features).toContain('workspace_reload'); }); it('rejects conflicting runtime deps when a workspace registry is injected', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 30390a9b054..c67c4a00188 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -427,6 +427,8 @@ export function createServeApp( opts, boundWorkspace, persistSettingAvailable: deps.persistSetting !== undefined, + // Registry injection supplies the primary workspace service through the + // runtime, so it has the same reload surface as legacy deps.workspace. reloadAvailable: deps.workspace !== undefined || injectedWorkspaceRegistry !== undefined, sessionShellCommandEnabled, From 1eb2b32433330f704ad9870a83525f13a76f3a95 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 05:39:48 +0800 Subject: [PATCH 3/8] fix(cli): Reject valueless repeated workspace args Co-authored-by: Qwen-Coder --- packages/cli/src/commands/serve.test.ts | 12 +++++ packages/cli/src/commands/serve.ts | 1 + packages/cli/src/serve/server.test.ts | 45 +++++++++++++++++++ .../cli/src/serve/workspace-inputs.test.ts | 38 +++++++++++++++- packages/cli/src/serve/workspace-inputs.ts | 22 +++++---- .../cli/src/serve/workspace-registry.test.ts | 15 +++++++ 6 files changed, 122 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index 40df3026261..502f7674d3b 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -108,6 +108,18 @@ describe('serve command args', () => { expect(parsed['workspace']).toEqual(['/tmp/primary', '/tmp/secondary']); }); + it('rejects valueless --workspace forms', () => { + for (const input of [ + '--workspace', + '--workspace=', + '--workspace /tmp/primary --workspace', + ]) { + expect(() => buildParser().parseSync(input)).toThrow( + /Not enough arguments following: workspace/, + ); + } + }); + it('preserves repeatable --workspace values in command mode', () => { let captured: unknown; yargs([]) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index b1e201fdb53..1f2e4921eef 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -175,6 +175,7 @@ export const serveCommand: CommandModule = { .option('workspace', { type: 'string', array: true, + requiresArg: true, description: 'Absolute workspace path this daemon binds to. ' + 'POST /session requests with a mismatched cwd return 400 workspace_mismatch. ' + diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a593a3b6477..7a622f3c8de 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14184,6 +14184,21 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { } as WorkspaceRuntime; const registry = createWorkspaceRegistry([runtime]); + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + boundWorkspace: '/work/other', + } as Parameters[2], + ), + ).toThrow(/workspaceRegistry conflicts with deps\.boundWorkspace/); + expect(() => createServeApp( { @@ -14199,6 +14214,21 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { ), ).toThrow(/workspaceRegistry conflicts with deps\.bridge/); + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + workspace: {} as DaemonWorkspaceService, + } as Parameters[2], + ), + ).toThrow(/workspaceRegistry conflicts with deps\.workspace/); + expect(() => createServeApp( { @@ -14215,6 +14245,21 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { } as Parameters[2], ), ).toThrow(/workspaceRegistry conflicts with deps\.fsFactory/); + + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + clientMcpSenderRegistry: {}, + } as Parameters[2], + ), + ).toThrow(/workspaceRegistry conflicts with deps\.clientMcpSenderRegistry/); }); it('passes custom ignore files through resolveBridgeFsFactory', async () => { diff --git a/packages/cli/src/serve/workspace-inputs.test.ts b/packages/cli/src/serve/workspace-inputs.test.ts index 97b58643ea8..f07242fc9ad 100644 --- a/packages/cli/src/serve/workspace-inputs.test.ts +++ b/packages/cli/src/serve/workspace-inputs.test.ts @@ -7,9 +7,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DuplicateWorkspaceInputError, + MissingWorkspaceInputError, MultipleWorkspaceInputError, NestedWorkspaceInputError, resolveSingleWorkspaceInput, @@ -39,7 +40,12 @@ describe('resolveSingleWorkspaceInput', () => { it('falls back to process.cwd() when no workspace is supplied', () => { expect(resolveSingleWorkspaceInput(undefined)).toBe(process.cwd()); - expect(resolveSingleWorkspaceInput([])).toBe(process.cwd()); + }); + + it('rejects an explicit empty workspace array', () => { + expect(() => resolveSingleWorkspaceInput([])).toThrow( + MissingWorkspaceInputError, + ); }); it('rejects duplicate canonical explicit workspaces', () => { @@ -73,4 +79,32 @@ describe('resolveSingleWorkspaceInput', () => { MultipleWorkspaceInputError, ); }); + + it('propagates canonicalization failures for explicit multi-workspace inputs', async () => { + const canonicalizationError = Object.assign( + new Error('permission denied'), + { code: 'EACCES' }, + ); + vi.resetModules(); + vi.doMock('@qwen-code/acp-bridge/workspacePaths', () => ({ + canonicalizeWorkspace: (workspace: string) => { + if (workspace === '/inaccessible') { + throw canonicalizationError; + } + return workspace; + }, + })); + try { + const { resolveSingleWorkspaceInput } = await import( + './workspace-inputs.js' + ); + + expect(() => + resolveSingleWorkspaceInput(['/inaccessible', '/other']), + ).toThrow(canonicalizationError); + } finally { + vi.doUnmock('@qwen-code/acp-bridge/workspacePaths'); + vi.resetModules(); + } + }); }); diff --git a/packages/cli/src/serve/workspace-inputs.ts b/packages/cli/src/serve/workspace-inputs.ts index 14d78673578..2f77c1796ab 100644 --- a/packages/cli/src/serve/workspace-inputs.ts +++ b/packages/cli/src/serve/workspace-inputs.ts @@ -38,9 +38,18 @@ export class MultipleWorkspaceInputError extends Error { } } +export class MissingWorkspaceInputError extends Error { + constructor() { + super('--workspace requires a value.'); + this.name = 'MissingWorkspaceInputError'; + } +} + function normalizeWorkspaceInputs(workspace: unknown): string[] { if (Array.isArray(workspace)) { - if (workspace.length === 0) return [process.cwd()]; + if (workspace.length === 0) { + throw new MissingWorkspaceInputError(); + } return workspace.map((value) => String(value)); } if (workspace === undefined) return [process.cwd()]; @@ -59,14 +68,9 @@ function rejectUnsupportedMultiWorkspaceInputs( ): void { if (workspaces.length <= 1) return; - let canonicalWorkspaces: string[]; - try { - canonicalWorkspaces = workspaces.map((workspace) => - canonicalizeWorkspace(workspace), - ); - } catch { - throw new MultipleWorkspaceInputError(); - } + const canonicalWorkspaces = workspaces.map((workspace) => + canonicalizeWorkspace(workspace), + ); const seen = new Set(); for (const workspace of canonicalWorkspaces) { if (seen.has(workspace)) { diff --git a/packages/cli/src/serve/workspace-registry.test.ts b/packages/cli/src/serve/workspace-registry.test.ts index f27ce405ef6..b45bc6bf788 100644 --- a/packages/cli/src/serve/workspace-registry.test.ts +++ b/packages/cli/src/serve/workspace-registry.test.ts @@ -186,4 +186,19 @@ describe('createWorkspaceRegistry', () => { runtimes: [first, second], }); }); + + it('propagates unexpected live session lookup errors', () => { + const lookupError = new Error('bridge unavailable'); + const primary = makeRuntime('/work/primary', { + workspaceId: 'ws-primary', + primary: true, + bridge: bridgeWithSummary(() => { + throw lookupError; + }), + }); + + const registry = createWorkspaceRegistry([primary]); + + expect(() => registry.resolveLiveSessionOwner('sess')).toThrow(lookupError); + }); }); From 814d0c7d580b5ab10321b457378cd590e28423f1 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 06:12:49 +0800 Subject: [PATCH 4/8] fix(cli): Fallback for empty workspace fast path Co-authored-by: Qwen-Coder --- packages/cli/src/serve/fast-path.test.ts | 9 +++++++++ packages/cli/src/serve/fast-path.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index ec28391666c..59f6ea6b52f 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -558,6 +558,15 @@ describe('serve fast path argument parsing', () => { ).toEqual({ kind: 'fallback' }); }); + it('falls back to the full parser for empty --workspace values', () => { + expect(parseServeFastPathArgs(['serve', '--workspace='])).toEqual({ + kind: 'fallback', + }); + expect(parseServeFastPathArgs(['serve', '--workspace', ''])).toEqual({ + kind: 'fallback', + }); + }); + it('parses Windows bundled entrypoint argv before serve', () => { const parsed = parseServeFastPathArgs([ 'C:\\repo\\dist\\cli.js', diff --git a/packages/cli/src/serve/fast-path.ts b/packages/cli/src/serve/fast-path.ts index 32bbe6b252f..63a5c992ad5 100644 --- a/packages/cli/src/serve/fast-path.ts +++ b/packages/cli/src/serve/fast-path.ts @@ -349,7 +349,10 @@ export function parseServeFastPathArgs( const read = readOptionValue(argv, i, inlineValue); if (!read) return { kind: 'fallback' }; i = read.nextIndex; - if (stringTarget === 'workspace' && options.workspace !== undefined) { + if ( + stringTarget === 'workspace' && + (options.workspace !== undefined || read.value === '') + ) { return { kind: 'fallback' }; } setServeOption(options, stringTarget, read.value); From 7d82381635871eaa9c068514462cfb9a9404e2a2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 06:44:22 +0800 Subject: [PATCH 5/8] test(cli): Address workspace foundation suggestions Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 20 +++++++++--- packages/cli/src/serve/server.ts | 31 ++++++++++++++++--- .../cli/src/serve/workspace-inputs.test.ts | 5 ++- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7a622f3c8de..f68dcf17d5a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14197,7 +14197,9 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { boundWorkspace: '/work/other', } as Parameters[2], ), - ).toThrow(/workspaceRegistry conflicts with deps\.boundWorkspace/); + ).toThrow( + /workspaceRegistry conflicts with deps\.boundWorkspace: .*registry primary cwd="\/work\/registry-primary".*workspaceId="ws-registry".*deps\.boundWorkspace="\/work\/other"/, + ); expect(() => createServeApp( @@ -14212,7 +14214,9 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { bridge: fakeBridge(), } as Parameters[2], ), - ).toThrow(/workspaceRegistry conflicts with deps\.bridge/); + ).toThrow( + /workspaceRegistry conflicts with deps\.bridge: .*workspaceId="ws-registry".*deps\.bridge is a different object/, + ); expect(() => createServeApp( @@ -14227,7 +14231,9 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { workspace: {} as DaemonWorkspaceService, } as Parameters[2], ), - ).toThrow(/workspaceRegistry conflicts with deps\.workspace/); + ).toThrow( + /workspaceRegistry conflicts with deps\.workspace: .*workspaceId="ws-registry".*deps\.workspace is a different object/, + ); expect(() => createServeApp( @@ -14244,7 +14250,9 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { } as unknown as WorkspaceFileSystemFactory, } as Parameters[2], ), - ).toThrow(/workspaceRegistry conflicts with deps\.fsFactory/); + ).toThrow( + /workspaceRegistry conflicts with deps\.fsFactory: .*workspaceId="ws-registry".*deps\.fsFactory is a different object/, + ); expect(() => createServeApp( @@ -14259,7 +14267,9 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { clientMcpSenderRegistry: {}, } as Parameters[2], ), - ).toThrow(/workspaceRegistry conflicts with deps\.clientMcpSenderRegistry/); + ).toThrow( + /workspaceRegistry conflicts with deps\.clientMcpSenderRegistry: .*workspaceId="ws-registry".*deps\.clientMcpSenderRegistry is a different object/, + ); }); it('passes custom ignore files through resolveBridgeFsFactory', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index c67c4a00188..8627af14487 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -150,6 +150,15 @@ export { getActiveSseCount } from './routes/sse-events.js'; */ let warnedDefaultTrust = false; +function describeRegistryPrimaryForConflict( + registry: WorkspaceRegistry, +): string { + return ( + `registry primary cwd=${JSON.stringify(registry.primary.workspaceCwd)}, ` + + `workspaceId=${JSON.stringify(registry.primary.workspaceId)}` + ); +} + export interface ServeAppDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ bridge?: AcpSessionBridge; @@ -325,20 +334,27 @@ export function createServeApp( deps.boundWorkspace !== injectedWorkspaceRegistry.primary.workspaceCwd ) { throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.boundWorkspace.', + 'createServeApp: workspaceRegistry conflicts with ' + + `deps.boundWorkspace: ${describeRegistryPrimaryForConflict( + injectedWorkspaceRegistry, + )}; deps.boundWorkspace=${JSON.stringify(deps.boundWorkspace)}.`, ); } if (injectedWorkspaceRegistry && deps.bridge) { if (deps.bridge !== injectedWorkspaceRegistry.primary.bridge) { throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.bridge.', + 'createServeApp: workspaceRegistry conflicts with deps.bridge: ' + + `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + + 'deps.bridge is a different object.', ); } } if (injectedWorkspaceRegistry && deps.workspace) { if (deps.workspace !== injectedWorkspaceRegistry.primary.workspaceService) { throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.workspace.', + 'createServeApp: workspaceRegistry conflicts with deps.workspace: ' + + `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + + 'deps.workspace is a different object.', ); } } @@ -348,7 +364,9 @@ export function createServeApp( injectedWorkspaceRegistry.primary.routeFileSystemFactory ) { throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.fsFactory.', + 'createServeApp: workspaceRegistry conflicts with deps.fsFactory: ' + + `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + + 'deps.fsFactory is a different object.', ); } } @@ -358,7 +376,10 @@ export function createServeApp( injectedWorkspaceRegistry.primary.clientMcpSenderRegistry ) { throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.clientMcpSenderRegistry.', + 'createServeApp: workspaceRegistry conflicts with ' + + 'deps.clientMcpSenderRegistry: ' + + `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + + 'deps.clientMcpSenderRegistry is a different object.', ); } } diff --git a/packages/cli/src/serve/workspace-inputs.test.ts b/packages/cli/src/serve/workspace-inputs.test.ts index f07242fc9ad..a5d8804dd08 100644 --- a/packages/cli/src/serve/workspace-inputs.test.ts +++ b/packages/cli/src/serve/workspace-inputs.test.ts @@ -57,7 +57,7 @@ describe('resolveSingleWorkspaceInput', () => { ); }); - it('rejects nested explicit workspaces', () => { + it('rejects nested explicit workspaces in either order', () => { const root = makeScratch(); const parent = path.join(root, 'parent'); const child = path.join(parent, 'child'); @@ -66,6 +66,9 @@ describe('resolveSingleWorkspaceInput', () => { expect(() => resolveSingleWorkspaceInput([parent, child])).toThrow( NestedWorkspaceInputError, ); + expect(() => resolveSingleWorkspaceInput([child, parent])).toThrow( + NestedWorkspaceInputError, + ); }); it('rejects distinct non-nested explicit workspaces while Phase 2a is gated', () => { From bb8c51bd2b2c4e49b139b2632bd480fb04ede4ed Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 07:45:20 +0800 Subject: [PATCH 6/8] test(cli): Cover registry injection happy paths Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 95 +++++++++++++++++++-------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index f68dcf17d5a..fc4e81e6c3f 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14020,6 +14020,24 @@ describe('runQwenServe SIGINT handler', () => { }); describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { + function makeInjectedWorkspaceRuntime(): WorkspaceRuntime { + const bridge = fakeBridge(); + const fsFactory = { + forRequest: vi.fn(() => ({ marker: 'registry-fs' })), + } as unknown as WorkspaceFileSystemFactory; + return { + workspaceId: 'ws-registry', + workspaceCwd: '/work/registry-primary', + primary: true, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge, + workspaceService: {} as DaemonWorkspaceService, + routeFileSystemFactory: fsFactory, + clientMcpSenderRegistry: {}, + } as WorkspaceRuntime; + } + it('parks a single-workspace registry on app.locals for the canonical primary workspace', async () => { const { createServeApp } = await import('./server.js'); const app = createServeApp( @@ -14124,20 +14142,7 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { it('uses an injected workspace registry as the primary runtime source', async () => { const { createServeApp } = await import('./server.js'); - const bridge = fakeBridge(); - const fsFactory = { forRequest: vi.fn(() => ({ marker: 'registry-fs' })) }; - const runtime = { - workspaceId: 'ws-registry', - workspaceCwd: '/work/registry-primary', - primary: true, - trusted: true, - env: { mode: 'parent-process', overlayKeys: [] }, - bridge, - workspaceService: {} as DaemonWorkspaceService, - routeFileSystemFactory: - fsFactory as unknown as WorkspaceFileSystemFactory, - clientMcpSenderRegistry: {}, - } as WorkspaceRuntime; + const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); const app = createServeApp( @@ -14157,7 +14162,7 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { expect(locals.workspaceRegistry).toBe(registry); expect(locals.boundWorkspace).toBe('/work/registry-primary'); - expect(locals.fsFactory).toBe(fsFactory); + expect(locals.fsFactory).toBe(runtime.routeFileSystemFactory); const res = await request(app) .get('/capabilities') @@ -14167,21 +14172,55 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { expect(res.body.features).toContain('workspace_reload'); }); + it('accepts matching runtime deps when a workspace registry is injected', async () => { + const { createServeApp } = await import('./server.js'); + const runtime = makeInjectedWorkspaceRuntime(); + const registry = createWorkspaceRegistry([runtime]); + + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + bridge: runtime.bridge, + workspace: runtime.workspaceService, + fsFactory: runtime.routeFileSystemFactory, + clientMcpSenderRegistry: runtime.clientMcpSenderRegistry, + } as Parameters[2], + ), + ).not.toThrow(); + }); + + it('uses the injected registry sender when client-MCP over WS is enabled', async () => { + const { createServeApp } = await import('./server.js'); + const runtime = makeInjectedWorkspaceRuntime(); + const registry = createWorkspaceRegistry([runtime]); + + expect(() => + createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/ignored', + clientMcpOverWs: true, + } as Parameters[0], + () => 0, + { + workspaceRegistry: registry, + bridge: runtime.bridge, + } as Parameters[2], + ), + ).not.toThrow(); + }); + it('rejects conflicting runtime deps when a workspace registry is injected', async () => { const { createServeApp } = await import('./server.js'); - const runtime = { - workspaceId: 'ws-registry', - workspaceCwd: '/work/registry-primary', - primary: true, - trusted: true, - env: { mode: 'parent-process', overlayKeys: [] }, - bridge: fakeBridge(), - workspaceService: {} as DaemonWorkspaceService, - routeFileSystemFactory: { - forRequest: vi.fn(() => ({ marker: 'registry-fs' })), - } as unknown as WorkspaceFileSystemFactory, - clientMcpSenderRegistry: {}, - } as WorkspaceRuntime; + const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); expect(() => From 8b7d8fdd01dc3e2e05b8913753c0ef3fc13ed1f1 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 07:48:51 +0800 Subject: [PATCH 7/8] fix(cli): Tighten workspace foundation guardrails Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.ts | 94 +++++++++---------- .../cli/src/serve/workspace-inputs.test.ts | 14 ++- packages/cli/src/serve/workspace-inputs.ts | 18 ++-- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 8627af14487..ba29c44c639 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -328,58 +328,52 @@ export function createServeApp( injectedWorkspaceRegistry?.primary.workspaceCwd ?? deps.boundWorkspace ?? canonicalizeWorkspace(opts.workspace ?? process.cwd()); - if ( - injectedWorkspaceRegistry && - deps.boundWorkspace !== undefined && - deps.boundWorkspace !== injectedWorkspaceRegistry.primary.workspaceCwd - ) { - throw new Error( - 'createServeApp: workspaceRegistry conflicts with ' + - `deps.boundWorkspace: ${describeRegistryPrimaryForConflict( - injectedWorkspaceRegistry, - )}; deps.boundWorkspace=${JSON.stringify(deps.boundWorkspace)}.`, - ); - } - if (injectedWorkspaceRegistry && deps.bridge) { - if (deps.bridge !== injectedWorkspaceRegistry.primary.bridge) { - throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.bridge: ' + - `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + - 'deps.bridge is a different object.', - ); - } - } - if (injectedWorkspaceRegistry && deps.workspace) { - if (deps.workspace !== injectedWorkspaceRegistry.primary.workspaceService) { - throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.workspace: ' + - `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + - 'deps.workspace is a different object.', - ); - } - } - if (injectedWorkspaceRegistry && deps.fsFactory) { - if ( - deps.fsFactory !== - injectedWorkspaceRegistry.primary.routeFileSystemFactory - ) { - throw new Error( - 'createServeApp: workspaceRegistry conflicts with deps.fsFactory: ' + - `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + - 'deps.fsFactory is a different object.', - ); - } - } - if (injectedWorkspaceRegistry && deps.clientMcpSenderRegistry) { - if ( - deps.clientMcpSenderRegistry !== - injectedWorkspaceRegistry.primary.clientMcpSenderRegistry - ) { + if (injectedWorkspaceRegistry) { + const primary = injectedWorkspaceRegistry.primary; + const registryConflictCandidates = [ + { + depName: 'deps.boundWorkspace', + depValue: deps.boundWorkspace, + registryValue: primary.workspaceCwd, + detail: `deps.boundWorkspace=${JSON.stringify(deps.boundWorkspace)}`, + }, + { + depName: 'deps.bridge', + depValue: deps.bridge, + registryValue: primary.bridge, + detail: 'deps.bridge is a different object', + }, + { + depName: 'deps.workspace', + depValue: deps.workspace, + registryValue: primary.workspaceService, + detail: 'deps.workspace is a different object', + }, + { + depName: 'deps.fsFactory', + depValue: deps.fsFactory, + registryValue: primary.routeFileSystemFactory, + detail: 'deps.fsFactory is a different object', + }, + { + depName: 'deps.clientMcpSenderRegistry', + depValue: deps.clientMcpSenderRegistry, + registryValue: primary.clientMcpSenderRegistry, + detail: 'deps.clientMcpSenderRegistry is a different object', + }, + ]; + for (const candidate of registryConflictCandidates) { + if ( + candidate.depValue === undefined || + candidate.depValue === candidate.registryValue + ) { + continue; + } throw new Error( 'createServeApp: workspaceRegistry conflicts with ' + - 'deps.clientMcpSenderRegistry: ' + - `${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ` + - 'deps.clientMcpSenderRegistry is a different object.', + `${candidate.depName}: ${describeRegistryPrimaryForConflict( + injectedWorkspaceRegistry, + )}; ${candidate.detail}.`, ); } } diff --git a/packages/cli/src/serve/workspace-inputs.test.ts b/packages/cli/src/serve/workspace-inputs.test.ts index a5d8804dd08..cd30c062425 100644 --- a/packages/cli/src/serve/workspace-inputs.test.ts +++ b/packages/cli/src/serve/workspace-inputs.test.ts @@ -61,7 +61,9 @@ describe('resolveSingleWorkspaceInput', () => { const root = makeScratch(); const parent = path.join(root, 'parent'); const child = path.join(parent, 'child'); + const dotPrefixedChild = path.join(parent, '..foo'); fs.mkdirSync(child, { recursive: true }); + fs.mkdirSync(dotPrefixedChild); expect(() => resolveSingleWorkspaceInput([parent, child])).toThrow( NestedWorkspaceInputError, @@ -69,6 +71,9 @@ describe('resolveSingleWorkspaceInput', () => { expect(() => resolveSingleWorkspaceInput([child, parent])).toThrow( NestedWorkspaceInputError, ); + expect(() => + resolveSingleWorkspaceInput([parent, dotPrefixedChild]), + ).toThrow(NestedWorkspaceInputError); }); it('rejects distinct non-nested explicit workspaces while Phase 2a is gated', () => { @@ -83,7 +88,7 @@ describe('resolveSingleWorkspaceInput', () => { ); }); - it('propagates canonicalization failures for explicit multi-workspace inputs', async () => { + it('keeps canonicalization failures on the gated multi-workspace error path', async () => { const canonicalizationError = Object.assign( new Error('permission denied'), { code: 'EACCES' }, @@ -98,13 +103,12 @@ describe('resolveSingleWorkspaceInput', () => { }, })); try { - const { resolveSingleWorkspaceInput } = await import( - './workspace-inputs.js' - ); + const { MultipleWorkspaceInputError, resolveSingleWorkspaceInput } = + await import('./workspace-inputs.js'); expect(() => resolveSingleWorkspaceInput(['/inaccessible', '/other']), - ).toThrow(canonicalizationError); + ).toThrow(MultipleWorkspaceInputError); } finally { vi.doUnmock('@qwen-code/acp-bridge/workspacePaths'); vi.resetModules(); diff --git a/packages/cli/src/serve/workspace-inputs.ts b/packages/cli/src/serve/workspace-inputs.ts index 2f77c1796ab..632537fd995 100644 --- a/packages/cli/src/serve/workspace-inputs.ts +++ b/packages/cli/src/serve/workspace-inputs.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as path from 'node:path'; import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; +import { isWithinRoot } from '../config/path-comparison.js'; export class DuplicateWorkspaceInputError extends Error { constructor(workspace: string) { @@ -57,10 +57,7 @@ function normalizeWorkspaceInputs(workspace: unknown): string[] { } function isNestedWorkspace(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return ( - relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) - ); + return parent !== child && isWithinRoot(child, parent); } function rejectUnsupportedMultiWorkspaceInputs( @@ -68,9 +65,14 @@ function rejectUnsupportedMultiWorkspaceInputs( ): void { if (workspaces.length <= 1) return; - const canonicalWorkspaces = workspaces.map((workspace) => - canonicalizeWorkspace(workspace), - ); + let canonicalWorkspaces: string[]; + try { + canonicalWorkspaces = workspaces.map((workspace) => + canonicalizeWorkspace(workspace), + ); + } catch { + throw new MultipleWorkspaceInputError(); + } const seen = new Set(); for (const workspace of canonicalWorkspaces) { if (seen.has(workspace)) { From 1fd84a9a0b59cdbdcd02d5ecab1f1528bca53a4e Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 7 Jul 2026 08:20:58 +0800 Subject: [PATCH 8/8] test(cli): Cover injected client MCP registry path Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 51 +++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index fc4e81e6c3f..187a8c05a3b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -117,6 +117,10 @@ import { type WorkspaceRegistry, type WorkspaceRuntime, } from './workspace-registry.js'; +import { + ClientMcpSenderRegistry, + createClientMcpServerProvider, +} from './acp-http/client-mcp-sender-registry.js'; import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; import { resetTrustedFoldersForTesting, @@ -14020,11 +14024,12 @@ describe('runQwenServe SIGINT handler', () => { }); describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { - function makeInjectedWorkspaceRuntime(): WorkspaceRuntime { + function makeInjectedWorkspaceRuntime() { const bridge = fakeBridge(); const fsFactory = { forRequest: vi.fn(() => ({ marker: 'registry-fs' })), } as unknown as WorkspaceFileSystemFactory; + const clientMcpSenderRegistry = new ClientMcpSenderRegistry(); return { workspaceId: 'ws-registry', workspaceCwd: '/work/registry-primary', @@ -14034,8 +14039,8 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { bridge, workspaceService: {} as DaemonWorkspaceService, routeFileSystemFactory: fsFactory, - clientMcpSenderRegistry: {}, - } as WorkspaceRuntime; + clientMcpSenderRegistry, + } satisfies WorkspaceRuntime; } it('parks a single-workspace registry on app.locals for the canonical primary workspace', async () => { @@ -14201,7 +14206,7 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); - expect(() => + expect(() => { createServeApp( { port: 0, @@ -14214,8 +14219,42 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { workspaceRegistry: registry, bridge: runtime.bridge, } as Parameters[2], - ), - ).not.toThrow(); + ); + }).not.toThrow(); + + const provider = createClientMcpServerProvider( + runtime.clientMcpSenderRegistry, + runtime.bridge, + 'connA', + ); + const sendSdkMcpMessage: Parameters< + typeof provider.registerClientMcpServer + >[1] = vi.fn(async (_serverName, message) => message); + + await expect( + provider.registerClientMcpServer('chrome-tools', sendSdkMcpMessage), + ).resolves.toEqual({ toolCount: 3 }); + + expect(runtime.clientMcpSenderRegistry.serverNames()).toEqual([ + 'chrome-tools', + ]); + expect(runtime.bridge.addRuntimeMcpServerCalls).toHaveLength(1); + expect(runtime.bridge.addRuntimeMcpServerCalls[0]).toMatchObject({ + name: 'chrome-tools', + originatorClientId: 'connA', + }); + + const payload = { jsonrpc: '2.0', id: 1, method: 'ping' } as const; + const sender = runtime.clientMcpSenderRegistry.lookup('chrome-tools'); + expect(sender).toBeDefined(); + await expect(sender!(payload)).resolves.toEqual(payload); + expect(sendSdkMcpMessage).toHaveBeenCalledWith('chrome-tools', payload); + + await provider.unregisterClientMcpServer('chrome-tools'); + expect(runtime.clientMcpSenderRegistry.serverNames()).toEqual([]); + expect(runtime.bridge.removeRuntimeMcpServerCalls).toEqual([ + { name: 'chrome-tools', originatorClientId: 'connA' }, + ]); }); it('rejects conflicting runtime deps when a workspace registry is injected', async () => {