diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index b66b1ac8ce..281cd02ce5 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -187,6 +187,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'non_blocking_prompt', 'session_language', 'session_rewind', 'workspace_hooks', 'session_hooks', 'workspace_extensions', 'session_branch', 'rate_limit', 'workspace_reload', + 'multi_workspace_sessions', 'workspace_qualified_rest_core', 'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp'] ``` @@ -208,6 +209,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. +`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. File write routes and all other plural core routes require a trusted workspace and return `403 { code: "untrusted_workspace" }` when the selected runtime is untrusted. This plural trust gate is intentionally stricter than some legacy primary-workspace read routes, which keep their existing compatibility behavior and are not drop-in replacements. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool toggle, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. + `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. `session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list. @@ -227,6 +230,10 @@ The write tag means the route contract exists; it does not mean the current deployment is open for anonymous mutation. Write/edit are strict mutation routes and require a configured bearer token even on loopback. +When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, and `/workspaces/:workspace/file/edit`. + +The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope. + `daemon_status` advertises `GET /daemon/status`, the consolidated read-only operator diagnostic snapshot documented below. @@ -1333,7 +1340,7 @@ Use `/load` when the client has no history rendered (cold reconnect, picker → ### `GET /workspace/:id/sessions` and `GET /workspaces/:workspace/sessions` -List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` is a plural alias with the same response shape. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted non-primary workspaces return `403 { code: "untrusted_workspace" }`. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor. +List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` has the same response shape but follows the plural core trust gate. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted workspaces on plural routes return `403 { code: "untrusted_workspace" }`; legacy primary routes keep their existing compatibility behavior. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor. ```bash curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions @@ -1341,6 +1348,8 @@ curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions curl http://127.0.0.1:4170/workspaces//sessions ``` +When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations and group CRUD are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}` and `/workspaces/:workspace/session-groups`. Workspace-less batch routes remain primary-workspace-only for compatibility. + Query parameters: | Field | Required | Notes | diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 074dc0b6e9..0f3e6a86e0 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -311,6 +311,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_extensions', 'session_branch', 'workspace_reload', + 'workspace_qualified_rest_core', 'voice_transcribe', ]); }); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 3720317448..7593d99773 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -269,6 +269,14 @@ export const SERVE_CAPABILITY_REGISTRY = { // Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised // only when one daemon hosts more than one registered workspace runtime. multi_workspace_sessions: { since: 'v1' }, + // Workspace-qualified core REST routes under `/workspaces/:workspace/...`. + // Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory, + // workspace agent CRUD, and persisted session organization surfaces. + // Workspace-qualified settings also require the existing + // `workspace_settings` tag because that surface depends on settings + // persistence. ACP/WebSocket, auth, voice, and extensions stay on their + // existing primary-workspace routes in this phase. + workspace_qualified_rest_core: { since: 'v1' }, // Phase 2 "reverse tool channel" (issue #5626). A connected WS client (e.g. // the Chrome extension) can host an MCP server that the daemon's agent // calls by carrying `mcp_message` JSON-RPC frames over the daemon WS, diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 9a40115793..5d06f150e9 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -348,6 +348,7 @@ function makeDaemonLog(): DaemonLogger { } function makeHarness(opts?: { + primaryTrusted?: boolean; secondaryTrusted?: boolean; secondaryChannelLive?: boolean; daemonLog?: DaemonLogger; @@ -370,7 +371,7 @@ function makeHarness(opts?: { workspaceId: 'primary-id', workspaceCwd: PRIMARY_CWD, primary: true, - trusted: true, + trusted: opts?.primaryTrusted ?? true, bridge: primaryBridge, }), makeRuntime({ @@ -488,6 +489,8 @@ describe('multi-workspace session dispatch', () => { expect(unknownRes.status).toBe(400); expect(unknownRes.body.code).toBe('workspace_mismatch'); expect(unknownRes.body.workspaceCount).toBe(2); + expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD); + expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD); expect(unknown.primaryBridge.spawnCalls).toEqual([]); expect(unknown.secondaryBridge.spawnCalls).toEqual([]); @@ -500,6 +503,9 @@ describe('multi-workspace session dispatch', () => { expect(untrustedRes.status).toBe(403); expect(untrustedRes.body.code).toBe('untrusted_workspace'); + expect(untrustedRes.body.error).toBe('Workspace is not trusted.'); + expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD); + expect(untrustedRes.body.workspaceId).toBe('secondary-id'); expect(untrusted.secondaryBridge.spawnCalls).toEqual([]); expect(daemonLog.warn).toHaveBeenCalledWith( 'session routing failed', @@ -520,7 +526,10 @@ describe('multi-workspace session dispatch', () => { expect(res.status).toBe(403); expect(res.body.code).toBe('untrusted_workspace'); + expect(res.body.error).toBe('Workspace is not trusted.'); + expect(res.body.sessionId).toBe('secondary-session'); expect(res.body.workspaceCwd).toBe(SECONDARY_CWD); + expect(res.body.workspaceId).toBe('secondary-id'); expect(secondaryBridge.promptCalls).toEqual([]); }); @@ -720,6 +729,8 @@ describe('multi-workspace session dispatch', () => { expect(unknownRes.status).toBe(400); expect(unknownRes.body.code).toBe('workspace_mismatch'); expect(unknownRes.body.workspaceCount).toBe(2); + expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD); + expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD); expect(unknown.primaryBridge.restoreCalls).toEqual([]); expect(unknown.secondaryBridge.restoreCalls).toEqual([]); @@ -732,6 +743,9 @@ describe('multi-workspace session dispatch', () => { expect(untrustedRes.status).toBe(403); expect(untrustedRes.body.code).toBe('untrusted_workspace'); + expect(untrustedRes.body.error).toBe('Workspace is not trusted.'); + expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD); + expect(untrustedRes.body.workspaceId).toBe('secondary-id'); expect(untrusted.primaryBridge.restoreCalls).toEqual([]); expect(untrusted.secondaryBridge.restoreCalls).toEqual([]); expect(daemonLog.warn).toHaveBeenCalledWith( @@ -847,6 +861,8 @@ describe('multi-workspace session dispatch', () => { expect(unknown.status).toBe(400); expect(unknown.body.code).toBe('workspace_mismatch'); expect(unknown.body.workspaceCount).toBe(2); + expect(unknown.body.boundWorkspace).toBe(PRIMARY_CWD); + expect(unknown.body.requestedWorkspace).toBe(UNKNOWN_CWD); }); it('lists active persisted non-primary sessions by encoded workspace cwd', async () => { @@ -989,7 +1005,9 @@ describe('multi-workspace session dispatch', () => { expect(res.status).toBe(403); expect(res.body.code).toBe('untrusted_workspace'); - expect(res.body.workspaceCwd).toBe(SECONDARY_CWD); + expect(res.body.error).toBe('Workspace is not trusted.'); + expect(res.body).not.toHaveProperty('workspaceCwd'); + expect(res.body).not.toHaveProperty('workspaceId'); expect(secondaryBridge.listCalls).toEqual([]); expect(daemonLog.warn).toHaveBeenCalledWith( 'session routing failed', @@ -1001,6 +1019,147 @@ describe('multi-workspace session dispatch', () => { ); }); + it('rejects untrusted primary workspace on plural session routes', async () => { + const daemonLog = makeDaemonLog(); + const { app } = makeHarness({ + primaryTrusted: false, + daemonLog, + }); + + const res = await request(app) + .get('/workspaces/primary-id/session-groups') + .set('Host', host()); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + expect(res.body.error).toBe('Workspace is not trusted.'); + expect(res.body).not.toHaveProperty('workspaceCwd'); + expect(res.body).not.toHaveProperty('workspaceId'); + expect(daemonLog.warn).toHaveBeenCalledWith( + 'session routing failed', + expect.objectContaining({ + route: 'GET /workspaces/:workspace/session-groups', + resolutionKind: 'untrusted_workspace', + workspaceCwd: PRIMARY_CWD, + }), + ); + }); + + it('routes plural batch archive, unarchive, and delete to the selected workspace', async () => { + await withRuntimeDir(async () => { + const archiveId = '550e8400-e29b-41d4-a716-446655440120'; + const deleteId = '550e8400-e29b-41d4-a716-446655440121'; + await writeStoredSession({ + sessionId: archiveId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:10:00.000Z', + prompt: 'secondary archive target', + mtime: new Date('2026-07-08T00:10:00.000Z'), + }); + await writeStoredSession({ + sessionId: deleteId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:11:00.000Z', + prompt: 'secondary delete target', + mtime: new Date('2026-07-08T00:11:00.000Z'), + }); + const { app, primaryBridge, secondaryBridge } = makeHarness({ + secondarySummaries: [], + }); + + const archived = await request(app) + .post('/workspaces/secondary-id/sessions/archive') + .set('Host', host()) + .send({ sessionIds: [archiveId] }) + .expect(200); + expect(archived.body).toMatchObject({ + archived: [archiveId], + alreadyArchived: [], + notFound: [], + errors: [], + }); + + const unarchived = await request(app) + .post('/workspaces/secondary-id/sessions/unarchive') + .set('Host', host()) + .send({ sessionIds: [archiveId] }) + .expect(200); + expect(unarchived.body).toMatchObject({ + unarchived: [archiveId], + alreadyActive: [], + notFound: [], + errors: [], + }); + + const deleted = await request(app) + .post('/workspaces/secondary-id/sessions/delete') + .set('Host', host()) + .send({ sessionIds: [deleteId] }) + .expect(200); + expect(deleted.body).toMatchObject({ + removed: [deleteId], + notFound: [], + errors: [], + }); + expect(primaryBridge.closeCalls).toEqual([]); + expect(secondaryBridge.closeCalls).toEqual([archiveId, deleteId]); + }); + }); + + it('routes plural session group CRUD to the selected workspace', async () => { + await withRuntimeDir(async () => { + const { app } = makeHarness(); + + const created = await request(app) + .post('/workspaces/secondary-id/session-groups') + .set('Host', host()) + .send({ name: 'Secondary Group', color: 'blue' }) + .expect(201); + expect(created.body.group).toMatchObject({ + name: 'Secondary Group', + color: 'blue', + }); + const groupId = created.body.group.id as string; + + const secondaryList = await request(app) + .get('/workspaces/secondary-id/session-groups') + .set('Host', host()) + .expect(200); + expect( + (secondaryList.body.groups as Array<{ id: string }>).map( + (group) => group.id, + ), + ).toContain(groupId); + + const primaryList = await request(app) + .get('/workspaces/primary-id/session-groups') + .set('Host', host()) + .expect(200); + expect( + (primaryList.body.groups as Array<{ id: string }>).map( + (group) => group.id, + ), + ).not.toContain(groupId); + + const updated = await request(app) + .patch(`/workspaces/secondary-id/session-groups/${groupId}`) + .set('Host', host()) + .send({ name: 'Secondary Renamed', order: 10 }) + .expect(200); + expect(updated.body.group).toMatchObject({ + id: groupId, + name: 'Secondary Renamed', + order: 10, + }); + + const deleted = await request(app) + .delete(`/workspaces/secondary-id/session-groups/${groupId}`) + .set('Host', host()) + .expect(200); + expect(deleted.body).toEqual({ deleted: true }); + }); + }); + it('pages live non-primary workspace sessions with a stable cursor', async () => { const { app } = makeHarness({ secondarySummaries: [ diff --git a/packages/cli/src/serve/routes/session-runtime.ts b/packages/cli/src/serve/routes/session-runtime.ts index 27d973b31f..158e458195 100644 --- a/packages/cli/src/serve/routes/session-runtime.ts +++ b/packages/cli/src/serve/routes/session-runtime.ts @@ -10,6 +10,7 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { sendUntrustedWorkspaceResponse } from '../workspace-route-runtime.js'; export function requireSessionRuntime(opts: { sessionId: string; @@ -43,12 +44,10 @@ export function requireSessionRuntime(opts: { workspaceCwd: runtime.workspaceCwd, ...details, }); - res.status(403).json({ - error: `Workspace "${runtime.workspaceCwd}" is not trusted.`, - code: 'untrusted_workspace', + sendUntrustedWorkspaceResponse(res, { sessionId, - workspaceId: runtime.workspaceId, workspaceCwd: runtime.workspaceCwd, + workspaceId: runtime.workspaceId, }); return undefined; } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 2a29fc2b0e..4650c73e83 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -63,6 +63,10 @@ import { } from '../server/session-export.js'; import { createSessionOrganizationService } from '../session-organization-helpers.js'; import { requireSessionRuntime } from './session-runtime.js'; +import { + resolveWorkspaceRuntimeFromParam, + sendUntrustedWorkspaceResponse, +} from '../workspace-route-runtime.js'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -223,10 +227,9 @@ export function registerSessionRoutes( workspaceId: runtime.workspaceId, workspaceCwd: runtime.workspaceCwd, }); - res.status(403).json({ - error: `Workspace "${runtime.workspaceCwd}" is not trusted.`, - code: 'untrusted_workspace', + sendUntrustedWorkspaceResponse(res, { workspaceCwd: runtime.workspaceCwd, + workspaceId: runtime.workspaceId, }); return undefined; } @@ -285,6 +288,29 @@ export function registerSessionRoutes( ); }; + const requireTrustedRuntimeForWorkspaceRoute = ( + req: Request, + res: Response, + route: string, + ): WorkspaceRuntime | null => { + const runtime = resolveWorkspaceRuntimeFromParam( + workspaceRegistry, + req, + res, + 'workspace', + ); + if (runtime === null) return null; + if (!runtime.trusted) { + logSessionRoutingFailure(route, 'untrusted_workspace', { + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + }); + sendUntrustedWorkspaceResponse(res); + return null; + } + return runtime; + }; + const sendAmbiguousSessionOwner = ( res: Response, route: string, @@ -414,9 +440,7 @@ export function registerSessionRoutes( workspaceId: runtime.workspaceId, workspaceCwd: runtime.workspaceCwd, }); - res.status(403).json({ - error: `Workspace "${runtime.workspaceCwd}" is not trusted.`, - code: 'untrusted_workspace', + sendUntrustedWorkspaceResponse(res, { workspaceCwd: runtime.workspaceCwd, workspaceId: runtime.workspaceId, }); @@ -562,12 +586,7 @@ export function registerSessionRoutes( } const key = canonicalizeWorkspace(workspaceCwd); if (key !== boundWorkspace) { - res.status(400).json({ - error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`, - code: 'workspace_mismatch', - boundWorkspace, - requestedWorkspace: key, - }); + sendWorkspaceMismatch(res, key); return null; } return key; @@ -1532,6 +1551,98 @@ export function registerSessionRoutes( } }); + app.post( + '/workspaces/:workspace/sessions/delete', + mutate(), + async (req, res) => { + const route = 'POST /workspaces/:workspace/sessions/delete'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; + try { + const service = new SessionService(runtime.workspaceCwd); + const result = await deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, + }); + res.status(200).json(result); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }, + ); + + app.post( + '/workspaces/:workspace/sessions/archive', + mutate(), + async (req, res) => { + const route = 'POST /workspaces/:workspace/sessions/archive'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; + const service = new SessionService(runtime.workspaceCwd, { + onWarning: logSessionArchiveWarning, + }); + try { + const result = await archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }); + res.status(200).json({ + archived: result.archived, + alreadyArchived: result.alreadyArchived, + notFound: result.notFound, + errors: serializeSessionErrors(result.errors), + }); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }, + ); + + app.post( + '/workspaces/:workspace/sessions/unarchive', + mutate(), + async (req, res) => { + const route = 'POST /workspaces/:workspace/sessions/unarchive'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; + const service = new SessionService(runtime.workspaceCwd, { + onWarning: logSessionArchiveWarning, + }); + try { + const result = await unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }); + res.status(200).json({ + unarchived: result.unarchived, + alreadyActive: result.alreadyActive, + notFound: result.notFound, + errors: serializeSessionErrors(result.errors), + }); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }, + ); + app.patch( '/session/:id/metadata', mutate({ strict: true }), @@ -1730,6 +1841,95 @@ export function registerSessionRoutes( }, ); + app.get('/workspaces/:workspace/session-groups', async (req, res) => { + const route = 'GET /workspaces/:workspace/session-groups'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + try { + res + .status(200) + .json( + await createSessionOrganizationService( + runtime.workspaceCwd, + ).listGroups(), + ); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.post( + '/workspaces/:workspace/session-groups', + mutate(), + async (req, res) => { + const route = 'POST /workspaces/:workspace/session-groups'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const body = safeBody(req); + try { + const group = await createSessionOrganizationService( + runtime.workspaceCwd, + ).createGroup({ + name: body['name'] as string, + color: body['color'] as SessionGroupColor, + }); + res.status(201).json({ group }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { route }); + } + }, + ); + + app.patch( + '/workspaces/:workspace/session-groups/:groupId', + mutate(), + async (req, res) => { + const route = 'PATCH /workspaces/:workspace/session-groups/:groupId'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const body = safeBody(req); + try { + const group = await createSessionOrganizationService( + runtime.workspaceCwd, + ).updateGroup(req.params['groupId'] ?? '', { + ...(Object.prototype.hasOwnProperty.call(body, 'name') + ? { name: body['name'] as string } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'color') + ? { color: body['color'] as SessionGroupColor } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'order') + ? { order: body['order'] as number } + : {}), + }); + res.status(200).json({ group }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { route }); + } + }, + ); + + app.delete( + '/workspaces/:workspace/session-groups/:groupId', + mutate(), + async (req, res) => { + const route = 'DELETE /workspaces/:workspace/session-groups/:groupId'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + try { + const deleted = await createSessionOrganizationService( + runtime.workspaceCwd, + ).deleteGroup(req.params['groupId'] ?? ''); + res.status(200).json({ deleted }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { route }); + } + }, + ); + const listWorkspaceSessionsHandler = (paramName: string): RequestHandler => async (req, res) => { @@ -1740,19 +1940,26 @@ export function registerSessionRoutes( // Express decodes URL-encoded path params automatically; clients pass // the absolute workspace cwd encoded (e.g. // GET /workspace/%2Fwork%2Fa/sessions). - const runtime = resolveRuntimeFromWorkspaceParam(req, res, paramName); + const runtime = + paramName === 'workspace' + ? resolveWorkspaceRuntimeFromParam( + workspaceRegistry, + req, + res, + 'workspace', + ) + : resolveRuntimeFromWorkspaceParam(req, res, paramName); if (runtime === null) return; - if (!runtime.primary && !runtime.trusted) { + if ( + paramName === 'workspace' + ? !runtime.trusted + : !runtime.primary && !runtime.trusted + ) { logSessionRoutingFailure(route, 'untrusted_workspace', { workspaceId: runtime.workspaceId, workspaceCwd: runtime.workspaceCwd, }); - res.status(403).json({ - error: `Workspace "${runtime.workspaceCwd}" is not trusted.`, - code: 'untrusted_workspace', - workspaceCwd: runtime.workspaceCwd, - workspaceId: runtime.workspaceId, - }); + sendUntrustedWorkspaceResponse(res); return; } const key = runtime.workspaceCwd; diff --git a/packages/cli/src/serve/routes/workspace-file-read.test.ts b/packages/cli/src/serve/routes/workspace-file-read.test.ts index b086990c9f..1b0506f142 100644 --- a/packages/cli/src/serve/routes/workspace-file-read.test.ts +++ b/packages/cli/src/serve/routes/workspace-file-read.test.ts @@ -12,10 +12,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import request from 'supertest'; import { Ignore } from '@qwen-code/qwen-code-core'; import { createServeApp } from '../server.js'; +import { workspaceRelative } from './workspace-file-read.js'; import { canonicalizeWorkspace, createWorkspaceFileSystemFactory, } from '../fs/index.js'; +import type { Request } from 'express'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import type { ServeOptions } from '../types.js'; @@ -67,6 +69,35 @@ function rawHash(data: string | Buffer): `sha256:${string}` { return `sha256:${createHash('sha256').update(data).digest('hex')}`; } +describe('workspaceRelative', () => { + let scratch: string; + + beforeEach(async () => { + scratch = await fsp.mkdtemp( + path.join( + os.tmpdir(), + `qwen-route-rel-${randomBytes(4).toString('hex')}-`, + ), + ); + }); + + afterEach(async () => { + await fsp.rm(scratch, { recursive: true, force: true }); + }); + + it('rejects paths that remain outside the workspace after canonicalization', async () => { + const workspace = path.join(scratch, 'ws'); + await fsp.mkdir(workspace); + const req = { + app: { locals: { boundWorkspace: workspace } }, + } as unknown as Request; + + expect(() => + workspaceRelative(req, path.join(scratch, 'outside.txt')), + ).toThrow(expect.objectContaining({ kind: 'path_outside_workspace' })); + }); +}); + describe('GET /file', () => { let h: Harness; beforeEach(async () => { diff --git a/packages/cli/src/serve/routes/workspace-file-read.ts b/packages/cli/src/serve/routes/workspace-file-read.ts index aa988dd52c..51a4c13e33 100644 --- a/packages/cli/src/serve/routes/workspace-file-read.ts +++ b/packages/cli/src/serve/routes/workspace-file-read.ts @@ -8,11 +8,18 @@ import * as path from 'node:path'; import type { Application, Request, Response } from 'express'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { + FsError, MAX_READ_BYTES, + canonicalizeWorkspace, isFsError, - type FsError, type WorkspaceFileSystemFactory, } from '../fs/index.js'; +import { + getWorkspaceRouteContext, + resolveWorkspaceRuntimeFromParam, + setWorkspaceRouteContext, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; /** * Hard cap on entries returned from `GET /list`. The boundary probes @@ -168,10 +175,18 @@ function requireStringQuery( * by a custom embed without injecting `deps.fsFactory`, which is a * deployment misconfiguration the route can't recover from. */ +function routeName(req: Request, legacyRoute: string): string { + const context = getWorkspaceRouteContext(req); + if (!context) return legacyRoute; + return `${context.routePrefix}${legacyRoute.slice('GET '.length)}`; +} + function getFsFactory( req: Request, res: Response, ): WorkspaceFileSystemFactory | null { + const context = getWorkspaceRouteContext(req); + if (context) return context.runtime.routeFileSystemFactory; const factory = (req.app.locals as { fsFactory?: WorkspaceFileSystemFactory }) .fsFactory; if (!factory) { @@ -202,7 +217,7 @@ async function handleGetFile( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'GET /file'; + const ROUTE = routeName(req, 'GET /file'); const factory = getFsFactory(req, res); if (!factory) return; const clientId = deps.parseClientId(req, res); @@ -272,7 +287,7 @@ async function handleGetFileBytes( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'GET /file/bytes'; + const ROUTE = routeName(req, 'GET /file/bytes'); const factory = getFsFactory(req, res); if (!factory) return; const clientId = deps.parseClientId(req, res); @@ -334,7 +349,7 @@ async function handleGetStat( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'GET /stat'; + const ROUTE = routeName(req, 'GET /stat'); const factory = getFsFactory(req, res); if (!factory) return; const clientId = deps.parseClientId(req, res); @@ -366,7 +381,7 @@ async function handleGetList( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'GET /list'; + const ROUTE = routeName(req, 'GET /list'); const factory = getFsFactory(req, res); if (!factory) return; const clientId = deps.parseClientId(req, res); @@ -408,7 +423,7 @@ async function handleGetGlob( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'GET /glob'; + const ROUTE = routeName(req, 'GET /glob'); const factory = getFsFactory(req, res); if (!factory) return; const clientId = deps.parseClientId(req, res); @@ -502,16 +517,39 @@ async function handleGetGlob( * `/file`, `/stat`, `/list`, and `/glob` response paths. */ export function workspaceRelative(req: Request, resolved: string): string { - const boundWorkspace = (req.app.locals as { boundWorkspace?: string }) - .boundWorkspace; + const boundWorkspace = + getWorkspaceRouteContext(req)?.runtime.workspaceCwd ?? + (req.app.locals as { boundWorkspace?: string }).boundWorkspace; if (!boundWorkspace) { throw new Error('bound workspace is not configured'); } - const rel = path.relative(boundWorkspace, resolved); + let rel = path.relative(boundWorkspace, resolved); + if (isOutsideWorkspaceRelative(rel)) { + try { + rel = path.relative(canonicalizeWorkspace(boundWorkspace), resolved); + } catch { + throw new FsError( + 'path_outside_workspace', + 'path resolved outside workspace', + ); + } + } + if (isOutsideWorkspaceRelative(rel)) { + throw new FsError( + 'path_outside_workspace', + 'path resolved outside workspace', + ); + } if (rel === '') return '.'; return path.sep === '/' ? rel : rel.split(path.sep).join('/'); } +function isOutsideWorkspaceRelative(rel: string): boolean { + return ( + rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) + ); +} + export function registerWorkspaceFileReadRoutes( app: Application, deps: RegisterDeps, @@ -522,3 +560,42 @@ export function registerWorkspaceFileReadRoutes( app.get('/list', (req, res) => handleGetList(req, res, deps)); app.get('/glob', (req, res) => handleGetGlob(req, res, deps)); } + +export function registerWorkspaceQualifiedFileReadRoutes( + app: Application, + deps: RegisterDeps & { workspaceRegistry: WorkspaceRegistry }, +): void { + const resolve = (req: Request, res: Response): boolean => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return false; + setWorkspaceRouteContext(req, { + runtime, + routePrefix: 'GET /workspaces/:workspace', + }); + return true; + }; + app.get('/workspaces/:workspace/file', (req, res) => { + if (!resolve(req, res)) return; + void handleGetFile(req, res, deps); + }); + app.get('/workspaces/:workspace/file/bytes', (req, res) => { + if (!resolve(req, res)) return; + void handleGetFileBytes(req, res, deps); + }); + app.get('/workspaces/:workspace/stat', (req, res) => { + if (!resolve(req, res)) return; + void handleGetStat(req, res, deps); + }); + app.get('/workspaces/:workspace/list', (req, res) => { + if (!resolve(req, res)) return; + void handleGetList(req, res, deps); + }); + app.get('/workspaces/:workspace/glob', (req, res) => { + if (!resolve(req, res)) return; + void handleGetGlob(req, res, deps); + }); +} diff --git a/packages/cli/src/serve/routes/workspace-file-write.ts b/packages/cli/src/serve/routes/workspace-file-write.ts index 4fead58d0b..aba02c17bf 100644 --- a/packages/cli/src/serve/routes/workspace-file-write.ts +++ b/packages/cli/src/serve/routes/workspace-file-write.ts @@ -17,6 +17,13 @@ import { sendFsError, workspaceRelative, } from './workspace-file-read.js'; +import { + getWorkspaceRouteContext, + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, + setWorkspaceRouteContext, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; interface RegisterDeps { bridge: AcpSessionBridge; @@ -29,6 +36,8 @@ function getFsFactory( req: Request, res: Response, ): WorkspaceFileSystemFactory | null { + const context = getWorkspaceRouteContext(req); + if (context) return context.runtime.routeFileSystemFactory; const factory = (req.app.locals as { fsFactory?: WorkspaceFileSystemFactory }) .fsFactory; if (!factory) { @@ -43,6 +52,16 @@ function getFsFactory( return factory; } +function routeName(req: Request, legacyRoute: string): string { + const context = getWorkspaceRouteContext(req); + if (!context) return legacyRoute; + return `${context.routePrefix}${legacyRoute.slice('POST '.length)}`; +} + +function getBridge(req: Request, deps: RegisterDeps): AcpSessionBridge { + return getWorkspaceRouteContext(req)?.runtime.bridge ?? deps.bridge; +} + function sendParseError(res: Response, _route: string, error: string): null { applyReadHeaders(res); res.status(400).json({ @@ -144,9 +163,11 @@ function resolveOriginatorClientId( clientId: string | undefined, deps: RegisterDeps, res: Response, + req?: Request, ): string | undefined | null { if (clientId === undefined) return undefined; - if (!deps.bridge.knownClientIds().has(clientId)) { + const bridge = req ? getBridge(req, deps) : deps.bridge; + if (!bridge.knownClientIds().has(clientId)) { applyReadHeaders(res); res.status(400).json({ error: `Client id "${clientId}" is not registered for this workspace`, @@ -163,7 +184,7 @@ async function handlePostFileWrite( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'POST /file/write'; + const ROUTE = routeName(req, 'POST /file/write'); const factory = getFsFactory(req, res); if (!factory) return; const body = deps.safeBody(req); @@ -193,7 +214,12 @@ async function handlePostFileWrite( if (lineEnding === null) return; const clientId = deps.parseClientId(req, res); if (clientId === null) return; - const originatorClientId = resolveOriginatorClientId(clientId, deps, res); + const originatorClientId = resolveOriginatorClientId( + clientId, + deps, + res, + req, + ); if (originatorClientId === null) return; const fs = factory.forRequest({ originatorClientId, @@ -231,7 +257,7 @@ async function handlePostFileEdit( res: Response, deps: RegisterDeps, ): Promise { - const ROUTE = 'POST /file/edit'; + const ROUTE = routeName(req, 'POST /file/edit'); const factory = getFsFactory(req, res); if (!factory) return; const body = deps.safeBody(req); @@ -251,7 +277,12 @@ async function handlePostFileEdit( if (expectedHash === null) return; const clientId = deps.parseClientId(req, res); if (clientId === null) return; - const originatorClientId = resolveOriginatorClientId(clientId, deps, res); + const originatorClientId = resolveOriginatorClientId( + clientId, + deps, + res, + req, + ); if (originatorClientId === null) return; const fs = factory.forRequest({ originatorClientId, @@ -290,3 +321,39 @@ export function registerWorkspaceFileWriteRoutes( handlePostFileEdit(req, res, deps), ); } + +export function registerWorkspaceQualifiedFileWriteRoutes( + app: Application, + deps: RegisterDeps & { workspaceRegistry: WorkspaceRegistry }, +): void { + const resolve = (req: Request, res: Response): boolean => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return false; + if (!requireTrustedWorkspaceRuntime(runtime, res)) return false; + setWorkspaceRouteContext(req, { + runtime, + routePrefix: 'POST /workspaces/:workspace', + }); + return true; + }; + app.post( + '/workspaces/:workspace/file/write', + deps.mutate({ strict: true }), + (req, res) => { + if (!resolve(req, res)) return; + void handlePostFileWrite(req, res, deps); + }, + ); + app.post( + '/workspaces/:workspace/file/edit', + deps.mutate({ strict: true }), + (req, res) => { + if (!resolve(req, res)) return; + void handlePostFileEdit(req, res, deps); + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-lifecycle.ts b/packages/cli/src/serve/routes/workspace-lifecycle.ts index 22ec7f3082..d9af85404e 100644 --- a/packages/cli/src/serve/routes/workspace-lifecycle.ts +++ b/packages/cli/src/serve/routes/workspace-lifecycle.ts @@ -6,8 +6,16 @@ import type { Application, Request, RequestHandler, Response } from 'express'; import type { SendBridgeError } from '../server/error-response.js'; -import { createBuildWorkspaceCtx } from '../server/request-helpers.js'; +import { + createBuildWorkspaceCtx, + parseAndValidateWorkspaceClientId, +} from '../server/request-helpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; interface RegisterWorkspaceLifecycleRoutesDeps { boundWorkspace: string; @@ -73,3 +81,85 @@ export function registerWorkspaceLifecycleRoutes( } }); } + +export function registerWorkspaceQualifiedLifecycleRoutes( + app: Application, + deps: Pick< + RegisterWorkspaceLifecycleRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' | 'invalidateServeFeaturesCache' + > & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + app.post( + '/workspaces/:workspace/init', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const body = deps.safeBody(req); + const force = body['force']; + if (force !== undefined && typeof force !== 'boolean') { + res.status(400).json({ + error: '`force` must be a boolean when provided', + code: 'invalid_force_flag', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const route = 'POST /workspaces/:workspace/init'; + try { + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)( + route, + clientId, + ); + const result = await runtime.workspaceService.initWorkspace(ctx, { + force: force === true, + }); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); + + app.post( + '/workspaces/:workspace/reload', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const route = 'POST /workspaces/:workspace/reload'; + try { + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)( + route, + clientId, + ); + const result = await runtime.workspaceService.reload(ctx); + deps.invalidateServeFeaturesCache(); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-mcp-control.ts b/packages/cli/src/serve/routes/workspace-mcp-control.ts index 17baef95bf..5fb32df3d9 100644 --- a/packages/cli/src/serve/routes/workspace-mcp-control.ts +++ b/packages/cli/src/serve/routes/workspace-mcp-control.ts @@ -10,9 +10,18 @@ import type { SendBridgeError } from '../server/error-response.js'; import { createBuildWorkspaceCtx, MAX_SERVER_NAME_LENGTH, + parseAndValidateWorkspaceClientId, validateMcpRuntimeServerName, } from '../server/request-helpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; interface RegisterWorkspaceMcpControlRoutesDeps { boundWorkspace: string; @@ -219,3 +228,238 @@ export function registerWorkspaceMcpControlRoutes( }, ); } + +function resolveTrustedMcpRuntime( + registry: WorkspaceRegistry, + req: Request, + res: Response, +): WorkspaceRuntime | null { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime) return null; + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; +} + +export function registerWorkspaceQualifiedMcpControlRoutes( + app: Application, + deps: Pick< + RegisterWorkspaceMcpControlRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' + > & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + app.post( + '/workspaces/:workspace/mcp/:server/restart', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedMcpRuntime( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const rawEntryIndex = req.query['entryIndex']; + let entryIndex: number | undefined; + if (rawEntryIndex !== undefined && rawEntryIndex !== '*') { + const candidate = + typeof rawEntryIndex === 'string' ? rawEntryIndex : undefined; + const parsed = + candidate !== undefined ? Number.parseInt(candidate, 10) : NaN; + if ( + !Number.isInteger(parsed) || + parsed < 0 || + String(parsed) !== candidate + ) { + res.status(400).json({ + error: + '`entryIndex` query parameter must be a non-negative integer or "*"', + code: 'invalid_entry_index', + }); + return; + } + entryIndex = parsed; + } + const route = 'POST /workspaces/:workspace/mcp/:server/restart'; + try { + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)( + route, + clientId, + ); + const result = await runtime.workspaceService.restartMcpServer( + ctx, + serverName, + entryIndex !== undefined ? { entryIndex } : undefined, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); + + for (const [routeAction, bridgeAction] of [ + ['enable', 'enable'], + ['disable', 'disable'], + ['authenticate', 'authenticate'], + ['clear-auth', 'clear-auth'], + ] as const) { + app.post( + `/workspaces/:workspace/mcp/:server/${routeAction}`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedMcpRuntime( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const route = `POST /workspaces/:workspace/mcp/:server/${routeAction}`; + try { + const result = await runtime.bridge.manageMcpServer( + serverName, + bridgeAction, + clientId, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); + } + + app.post( + '/workspaces/:workspace/mcp/servers', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedMcpRuntime( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const body = deps.safeBody(req); + const name = body['name']; + if (!validateMcpRuntimeServerName(name, res)) return; + const config = body['config']; + if ( + typeof config !== 'object' || + config === null || + Array.isArray(config) + ) { + res.status(400).json({ + error: '`config` must be a non-null object', + code: 'missing_required_field', + field: 'config', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + if (!clientId) { + res.status(400).json({ + error: + '`X-Qwen-Client-Id` header is required for runtime MCP mutation', + code: 'missing_client_id', + }); + return; + } + const route = 'POST /workspaces/:workspace/mcp/servers'; + try { + const result = await runtime.bridge.addRuntimeMcpServer( + name, + config as Record, + clientId, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); + + app.delete( + '/workspaces/:workspace/mcp/servers/:name', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedMcpRuntime( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const name = req.params['name'] ?? ''; + if (!validateMcpRuntimeServerName(name, res)) return; + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + if (!clientId) { + res.status(400).json({ + error: + '`X-Qwen-Client-Id` header is required for runtime MCP mutation', + code: 'missing_client_id', + }); + return; + } + const route = 'DELETE /workspaces/:workspace/mcp/servers/:name'; + try { + const result = await runtime.bridge.removeRuntimeMcpServer( + name, + clientId, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-permissions.ts b/packages/cli/src/serve/routes/workspace-permissions.ts index b3f6e86d7e..02a6249971 100644 --- a/packages/cli/src/serve/routes/workspace-permissions.ts +++ b/packages/cli/src/serve/routes/workspace-permissions.ts @@ -18,6 +18,12 @@ import { loadSettings } from '../../config/settings.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/types.js'; import { WorkspacePermissionRulesSessionRequiredError } from '../workspace-service/types.js'; +import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; export interface WorkspacePermissionsRouteDeps { boundWorkspace: string; @@ -145,3 +151,124 @@ export function registerWorkspacePermissionsRoutes( }, ); } + +export function registerWorkspaceQualifiedPermissionsRoutes( + app: Application, + deps: Pick & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + app.get('/workspaces/:workspace/permissions', (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + try { + res + .status(200) + .json(buildPermissionSettings(loadSettings(runtime.workspaceCwd))); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspaces/:workspace/permissions error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to load permission rules', + code: 'internal_error', + }); + } + }); + + app.post( + '/workspaces/:workspace/permissions', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const body = deps.safeBody(req); + const scope = body['scope']; + const ruleType = body['ruleType']; + + if (scope !== 'workspace') { + res.status(400).json({ + error: + 'workspace-qualified permissions routes only support "workspace" scope', + code: 'global_scope_not_supported_for_workspace_route', + }); + return; + } + const permissionScope: PermissionSettingsScope = 'workspace'; + + if (!isPermissionRuleType(ruleType)) { + res.status(400).json({ + error: 'ruleType must be "allow", "ask", or "deny"', + code: 'invalid_rule_type', + }); + return; + } + + let rules: string[]; + try { + const settings = loadSettings(runtime.workspaceCwd); + const existingRules = readPermissionRuleSet( + settings.workspace.settings, + )[ruleType]; + rules = normalizePermissionRules(body['rules'], { existingRules }); + } catch (err) { + if (err instanceof PermissionRulesValidationError) { + res.status(400).json({ + error: err.message, + code: err.code, + }); + return; + } + throw err; + } + + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + + try { + const liveResponse = + await runtime.workspaceService.setWorkspacePermissionRules( + { + route: 'POST /workspaces/:workspace/permissions', + workspaceCwd: runtime.workspaceCwd, + ...(clientId ? { originatorClientId: clientId } : {}), + }, + { scope: permissionScope, ruleType, rules }, + ); + res.status(200).json(liveResponse); + } catch (err) { + if (err instanceof WorkspacePermissionRulesSessionRequiredError) { + res.status(409).json({ + error: + 'A live ACP session is required to update active permission rules.', + code: 'permission_session_required', + }); + return; + } + writeStderrLine( + `qwen serve: POST /workspaces/:workspace/permissions ACP error (ruleType=${ruleType}, workspace=${runtime.workspaceCwd}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to update permission rules', + code: 'permission_update_failed', + }); + } + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index fb20f11625..fc526688a9 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -18,6 +18,12 @@ import { validateSettingValue, } from '../../utils/settingsUtils.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; const TUI_ONLY_SETTINGS = new Set([ 'general.vimMode', @@ -299,3 +305,151 @@ export function registerWorkspaceSettingsRoutes( }, ); } + +export function registerWorkspaceQualifiedSettingsRoutes( + app: Application, + deps: Pick< + WorkspaceSettingsRouteDeps, + 'mutate' | 'safeBody' | 'persistSetting' + > & { + workspaceRegistry: WorkspaceRegistry; + invalidateServeFeaturesCache: () => void; + }, +): void { + const allowedKeys = getAllowedKeys(); + + app.get('/workspaces/:workspace/settings', (req: Request, res: Response) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + // Legacy /workspace/settings remains primary-only and pre-trust for + // compatibility; plural workspace-qualified settings intentionally follow + // the Phase 3 core-route trust gate. + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + try { + const response = buildSettingsResponse(runtime.workspaceCwd, allowedKeys); + res.status(200).json(response); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspaces/:workspace/settings error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to load settings', + code: 'internal_error', + }); + } + }); + + app.post( + '/workspaces/:workspace/settings', + deps.mutate({ strict: true }), + async (req: Request, res: Response) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const body = deps.safeBody(req); + const scope = body['scope']; + const key = body['key']; + const value = body['value']; + + if (typeof scope !== 'string' || !VALID_WRITE_SCOPES.has(scope)) { + res.status(400).json({ + error: `scope must be one of: ${[...VALID_WRITE_SCOPES].join(', ')}`, + code: 'invalid_scope', + }); + return; + } + if (typeof key !== 'string' || !key) { + res.status(400).json({ + error: 'key is required and must be a string', + code: 'invalid_key', + }); + return; + } + if (!allowedKeys.has(key)) { + res.status(400).json({ + error: `Setting "${key}" is not modifiable via this API`, + code: 'disallowed_key', + }); + return; + } + if (value === undefined || value === null) { + res.status(400).json({ + error: 'value is required', + code: 'missing_value', + }); + return; + } + const def = getSettingDefinition(key); + if (!def) { + res.status(400).json({ + error: `Unknown setting: ${key}`, + code: 'unknown_key', + }); + return; + } + const validationError = validateSettingValue(def, value); + if (validationError) { + res.status(400).json({ + error: validationError, + code: 'invalid_value', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + + const settingScope = SCOPE_MAP[scope]; + if (!settingScope) { + res.status(400).json({ + error: `scope must be one of: ${[...VALID_WRITE_SCOPES].join(', ')}`, + code: 'invalid_scope', + }); + return; + } + try { + await deps.persistSetting( + runtime.workspaceCwd, + settingScope, + key, + value, + ); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspaces/:workspace/settings persist error (key=${key}, scope=${scope}, workspace=${runtime.workspaceCwd}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to persist setting', + code: 'persist_error', + }); + return; + } + + deps.invalidateServeFeaturesCache(); + runtime.bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + ...(clientId ? { originatorClientId: clientId } : {}), + }); + res.status(200).json({ + key, + scope, + value, + requiresRestart: def.requiresRestart, + }); + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-status.ts b/packages/cli/src/serve/routes/workspace-status.ts index c2e0839a1f..6bec14b6d1 100644 --- a/packages/cli/src/serve/routes/workspace-status.ts +++ b/packages/cli/src/serve/routes/workspace-status.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Application, RequestHandler } from 'express'; +import type { Application, Request, RequestHandler, Response } from 'express'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; import type { SendBridgeError } from '../server/error-response.js'; import { @@ -12,6 +12,14 @@ import { MAX_SERVER_NAME_LENGTH, } from '../server/request-helpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; const MAX_ACP_PREHEAT_TIMEOUT_MS = 60_000; @@ -156,6 +164,134 @@ export function registerWorkspaceStatusRoutes( }); } +function resolveTrustedRuntime( + registry: WorkspaceRegistry, + req: Request, + res: Response, +): WorkspaceRuntime | null { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime) return null; + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; +} + +export function registerWorkspaceQualifiedStatusRoutes( + app: Application, + deps: Pick & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + const { workspaceRegistry, sendBridgeError } = deps; + + app.get('/workspaces/:workspace/mcp', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const route = 'GET /workspaces/:workspace/mcp'; + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route); + try { + res + .status(200) + .json(await runtime.workspaceService.getWorkspaceMcpStatus(ctx)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.get('/workspaces/:workspace/mcp/:server/tools', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + const route = 'GET /workspaces/:workspace/mcp/:server/tools'; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpToolsStatus(serverName)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.get('/workspaces/:workspace/mcp/:server/resources', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + const route = 'GET /workspaces/:workspace/mcp/:server/resources'; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpResourcesStatus(serverName)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.get('/workspaces/:workspace/skills', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const route = 'GET /workspaces/:workspace/skills'; + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route); + try { + res + .status(200) + .json(await runtime.workspaceService.getWorkspaceSkillsStatus(ctx)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.get('/workspaces/:workspace/tools', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const route = 'GET /workspaces/:workspace/tools'; + try { + res.status(200).json(await runtime.bridge.getWorkspaceToolsStatus()); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + + app.get('/workspaces/:workspace/providers', async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const route = 'GET /workspaces/:workspace/providers'; + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route); + try { + res + .status(200) + .json(await runtime.workspaceService.getWorkspaceProvidersStatus(ctx)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); +} + export function registerWorkspaceDiagnosticStatusRoutes( app: Application, deps: RegisterWorkspaceStatusRoutesDeps, @@ -199,3 +335,29 @@ export function registerWorkspaceDiagnosticStatusRoutes( } }); } + +export function registerWorkspaceQualifiedDiagnosticStatusRoutes( + app: Application, + deps: Pick & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + const { workspaceRegistry, sendBridgeError } = deps; + for (const [pathSuffix, methodName] of [ + ['env', 'getWorkspaceEnvStatus'], + ['preflight', 'getWorkspacePreflightStatus'], + ['hooks', 'getWorkspaceHooksStatus'], + ] as const) { + app.get(`/workspaces/:workspace/${pathSuffix}`, async (req, res) => { + const runtime = resolveTrustedRuntime(workspaceRegistry, req, res); + if (!runtime) return; + const route = `GET /workspaces/:workspace/${pathSuffix}`; + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route); + try { + res.status(200).json(await runtime.workspaceService[methodName](ctx)); + } catch (err) { + sendBridgeError(res, err, { route }); + } + }); + } +} diff --git a/packages/cli/src/serve/routes/workspace-tools.ts b/packages/cli/src/serve/routes/workspace-tools.ts index d5451331aa..823519a205 100644 --- a/packages/cli/src/serve/routes/workspace-tools.ts +++ b/packages/cli/src/serve/routes/workspace-tools.ts @@ -9,8 +9,14 @@ import type { SendBridgeError } from '../server/error-response.js'; import { createBuildWorkspaceCtx, MAX_TOOL_NAME_LENGTH, + parseAndValidateWorkspaceClientId, } from '../server/request-helpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; interface RegisterWorkspaceToolsRoutesDeps { boundWorkspace: string; @@ -95,3 +101,79 @@ export function registerWorkspaceToolsRoutes( }, ); } + +export function registerWorkspaceQualifiedToolsRoutes( + app: Application, + deps: Pick< + RegisterWorkspaceToolsRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' + > & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + app.post( + '/workspaces/:workspace/tools/:name/enable', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const rawToolName = req.params['name']; + if (!rawToolName || typeof rawToolName !== 'string') { + res.status(400).json({ + error: 'Tool name path parameter is required', + code: 'invalid_tool_name', + }); + return; + } + const toolName = rawToolName.trim(); + if (toolName.length === 0) { + res.status(400).json({ + error: 'Tool name path parameter is required', + code: 'invalid_tool_name', + }); + return; + } + if (toolName.length > MAX_TOOL_NAME_LENGTH) { + res.status(400).json({ + error: `Tool name exceeds ${MAX_TOOL_NAME_LENGTH}-character limit`, + code: 'invalid_tool_name', + }); + return; + } + const body = deps.safeBody(req); + const enabled = body['enabled']; + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: '`enabled` is required and must be a boolean', + code: 'invalid_enabled_flag', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const route = 'POST /workspaces/:workspace/tools/:name/enable'; + try { + const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)( + route, + clientId, + ); + const result = await runtime.workspaceService.setWorkspaceToolEnabled( + ctx, + toolName, + enabled, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-trust.ts b/packages/cli/src/serve/routes/workspace-trust.ts index 15dfe09349..25e6e00be5 100644 --- a/packages/cli/src/serve/routes/workspace-trust.ts +++ b/packages/cli/src/serve/routes/workspace-trust.ts @@ -9,6 +9,9 @@ import { FatalConfigError } from '@qwen-code/qwen-code-core'; import type { DaemonWorkspaceService } from '../workspace-service/types.js'; import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { resolveWorkspaceRuntimeFromParam } from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; +import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; export interface WorkspaceTrustRouteDeps { boundWorkspace: string; @@ -119,3 +122,97 @@ export function registerWorkspaceTrustRoutes( }, ); } + +export function registerWorkspaceQualifiedTrustRoutes( + app: Application, + deps: Pick & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + const { workspaceRegistry, mutate, safeBody } = deps; + + app.get('/workspaces/:workspace/trust', async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const route = 'GET /workspaces/:workspace/trust'; + try { + const status = await runtime.workspaceService.getWorkspaceTrustStatus({ + route, + workspaceCwd: runtime.workspaceCwd, + }); + res.status(200).json(status); + } catch (err) { + sendTrustError(res, route, err); + } + }); + + app.post( + '/workspaces/:workspace/trust/request', + mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + workspaceRegistry, + req, + res, + ); + if (!runtime) return; + const body = safeBody(req); + const desiredState = body['desiredState']; + if (desiredState !== 'trusted' && desiredState !== 'untrusted') { + res.status(400).json({ + error: 'desiredState must be "trusted" or "untrusted"', + code: 'invalid_desired_state', + }); + return; + } + + const reason = body['reason']; + if ( + reason !== undefined && + (typeof reason !== 'string' || reason.length > MAX_TRUST_REASON_LENGTH) + ) { + res.status(400).json({ + error: `reason must be a string up to ${MAX_TRUST_REASON_LENGTH} characters`, + code: 'invalid_reason', + }); + return; + } + + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const route = 'POST /workspaces/:workspace/trust/request'; + const ctx = { + route, + workspaceCwd: runtime.workspaceCwd, + ...(clientId !== undefined ? { originatorClientId: clientId } : {}), + }; + try { + const status = + await runtime.workspaceService.getWorkspaceTrustStatus(ctx); + if (!status.folderTrustEnabled) { + res.status(409).json({ + error: 'Folder trust is disabled for this workspace', + code: 'folder_trust_disabled', + }); + return; + } + const result = + await runtime.workspaceService.requestWorkspaceTrustChange(ctx, { + desiredState, + ...(reason !== undefined ? { reason } : {}), + }); + res.status(202).json(result); + } catch (err) { + sendTrustError(res, route, err); + } + }, + ); +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index cbb9beeb18..6192a0c64d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -306,6 +306,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_hooks', 'workspace_extensions', 'session_branch', + 'workspace_qualified_rest_core', // Baseline (always advertised) — presence means the `/voice/stream` // endpoint exists; the WS errors if no voice model is configured. 'voice_transcribe', @@ -350,6 +351,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'session_hooks' && f !== 'workspace_extensions' && f !== 'session_branch' && + f !== 'workspace_qualified_rest_core' && f !== 'voice_transcribe', ), 'workspace_settings', @@ -382,6 +384,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_reload', 'channel_reload', 'multi_workspace_sessions', + 'workspace_qualified_rest_core', 'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp', @@ -6422,6 +6425,7 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.code).toBe('workspace_mismatch'); + expect(res.body.boundWorkspace).toBe(process.cwd()); expect(res.body.requestedWorkspace).toBe(missingCwd); expect(bridge.loadCalls).toHaveLength(0); expect(bridge.resumeCalls).toHaveLength(0); @@ -8273,6 +8277,7 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.code).toBe('workspace_mismatch'); expect(res.body.boundWorkspace).toBe(WS_BOUND); + expect(res.body.requestedWorkspace).toBe(WS_DIFFERENT); expect(bridge.listCalls).toHaveLength(0); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 40000e846c..687d3aa0d3 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -52,21 +52,36 @@ import { mountWebShellAssets, mountWebShellSpaFallback, } from './web-shell-static.js'; -import { mountWorkspaceMemoryRoutes } from './workspace-memory.js'; +import { + mountWorkspaceMemoryRoutes, + mountWorkspaceQualifiedMemoryRoutes, +} from './workspace-memory.js'; import { mountWorkspaceMemoryRememberRoutes, WorkspaceRememberTaskLane, } from './workspace-remember.js'; -import { mountWorkspaceAgentsRoutes } from './workspace-agents.js'; +import { + mountWorkspaceAgentsRoutes, + mountWorkspaceQualifiedAgentsRoutes, +} from './workspace-agents.js'; import { registerDaemonStatusRoutes } from './routes/daemon-status.js'; import { createHealthDemoRoutes } from './routes/health-demo.js'; import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js'; import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; -import { registerWorkspaceFileReadRoutes } from './routes/workspace-file-read.js'; -import { registerWorkspaceFileWriteRoutes } from './routes/workspace-file-write.js'; +import { + registerWorkspaceFileReadRoutes, + registerWorkspaceQualifiedFileReadRoutes, +} from './routes/workspace-file-read.js'; +import { + registerWorkspaceFileWriteRoutes, + registerWorkspaceQualifiedFileWriteRoutes, +} from './routes/workspace-file-write.js'; import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js'; -import { registerWorkspaceTrustRoutes } from './routes/workspace-trust.js'; +import { + registerWorkspaceQualifiedTrustRoutes, + registerWorkspaceTrustRoutes, +} from './routes/workspace-trust.js'; import { registerPermissionRoutes } from './routes/permission.js'; import { registerSessionRoutes } from './routes/session.js'; import { registerScheduledTasksRoutes } from './routes/scheduled-tasks.js'; @@ -77,6 +92,8 @@ import { } from './scheduled-task-keepalive.js'; import { registerWorkspaceDiagnosticStatusRoutes, + registerWorkspaceQualifiedDiagnosticStatusRoutes, + registerWorkspaceQualifiedStatusRoutes, registerWorkspaceStatusRoutes, } from './routes/workspace-status.js'; import { @@ -84,8 +101,14 @@ import { type DaemonWorkspaceService, } from './workspace-service/index.js'; import { registerCapabilitiesRoutes } from './routes/capabilities.js'; -import { registerWorkspacePermissionsRoutes } from './routes/workspace-permissions.js'; -import { registerWorkspaceSettingsRoutes } from './routes/workspace-settings.js'; +import { + registerWorkspacePermissionsRoutes, + registerWorkspaceQualifiedPermissionsRoutes, +} from './routes/workspace-permissions.js'; +import { + registerWorkspaceQualifiedSettingsRoutes, + registerWorkspaceSettingsRoutes, +} from './routes/workspace-settings.js'; import { getActiveSseCount, registerSseEventsRoutes, @@ -129,10 +152,23 @@ import { type WorkspaceRegistry, type WorkspaceRuntimeEnvMetadata, } from './workspace-registry.js'; -import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js'; -import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js'; +import { + isPortableAbsolutePath, + resolveRegisteredWorkspaceRuntimeByPathSelector, +} from './workspace-route-runtime.js'; +import { + registerWorkspaceLifecycleRoutes, + registerWorkspaceQualifiedLifecycleRoutes, +} from './routes/workspace-lifecycle.js'; +import { + registerWorkspaceMcpControlRoutes, + registerWorkspaceQualifiedMcpControlRoutes, +} from './routes/workspace-mcp-control.js'; import { registerWorkspaceChannelControlRoutes } from './routes/workspace-channel-control.js'; -import { registerWorkspaceToolsRoutes } from './routes/workspace-tools.js'; +import { + registerWorkspaceQualifiedToolsRoutes, + registerWorkspaceToolsRoutes, +} from './routes/workspace-tools.js'; export { createDefaultFsAuditEmit, @@ -768,10 +804,27 @@ export function createServeApp( }); app.use( - daemonTelemetryMiddleware( - () => primaryBoundWorkspace, - deps.recordDaemonRequest, - ), + daemonTelemetryMiddleware((req) => { + const match = req.path.match(/^\/workspaces\/([^/]+)/); + const rawSelector = match?.[1]; + if (rawSelector) { + try { + const selector = decodeURIComponent(rawSelector); + const byId = workspaceRegistry.getByWorkspaceId(selector); + if (byId) return byId.workspaceCwd; + if (isPortableAbsolutePath(selector)) { + const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector( + workspaceRegistry, + selector, + ); + if (runtime) return runtime.workspaceCwd; + } + } catch { + return primaryBoundWorkspace; + } + } + return primaryBoundWorkspace; + }, deps.recordDaemonRequest), ); const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace); @@ -828,6 +881,10 @@ export function createServeApp( mutate, sendBridgeError, }); + registerWorkspaceQualifiedStatusRoutes(app, { + workspaceRegistry, + sendBridgeError, + }); // Workspace memory + agents CRUD routes. mountWorkspaceMemoryRoutes(app, { @@ -837,6 +894,12 @@ export function createServeApp( parseClientId: parseClientIdHeader, safeBody, }); + mountWorkspaceQualifiedMemoryRoutes(app, { + workspaceRegistry, + mutate, + parseClientId: parseClientIdHeader, + safeBody, + }); mountWorkspaceMemoryRememberRoutes(app, { bridge: primaryBridge, lane: workspaceRememberLane, @@ -851,6 +914,12 @@ export function createServeApp( parseClientId: parseClientIdHeader, safeBody, }); + mountWorkspaceQualifiedAgentsRoutes(app, { + workspaceRegistry, + mutate, + parseClientId: parseClientIdHeader, + safeBody, + }); registerWorkspaceDiagnosticStatusRoutes(app, { boundWorkspace: primaryBoundWorkspace, @@ -859,6 +928,10 @@ export function createServeApp( mutate, sendBridgeError, }); + registerWorkspaceQualifiedDiagnosticStatusRoutes(app, { + workspaceRegistry, + sendBridgeError, + }); registerWorkspaceExtensionRoutes(app, { boundWorkspace: primaryBoundWorkspace, @@ -876,12 +949,23 @@ export function createServeApp( registerWorkspaceFileReadRoutes(app, { parseClientId: parseClientIdHeader, }); + registerWorkspaceQualifiedFileReadRoutes(app, { + parseClientId: parseClientIdHeader, + workspaceRegistry, + }); registerWorkspaceFileWriteRoutes(app, { bridge: primaryBridge, mutate, parseClientId: parseClientIdHeader, safeBody, }); + registerWorkspaceQualifiedFileWriteRoutes(app, { + bridge: primaryBridge, + mutate, + parseClientId: parseClientIdHeader, + safeBody, + workspaceRegistry, + }); registerWorkspaceSetupGithubRoutes(app, { boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, @@ -897,6 +981,11 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedTrustRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + }); const broadcastSettingsChanged = ( key: string, @@ -925,6 +1014,15 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedSettingsRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + persistSetting: async (...args) => { + await persistSetting(...args); + }, + invalidateServeFeaturesCache, + }); } registerWorkspacePermissionsRoutes(app, { boundWorkspace: primaryBoundWorkspace, @@ -934,6 +1032,11 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedPermissionsRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + }); registerWorkspaceVoiceRoutes(app, { boundWorkspace: primaryBoundWorkspace, mutate, @@ -1000,6 +1103,12 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedMcpControlRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); if (deps.getChannelWorkerSnapshot && deps.reloadChannelWorker) { const getChannelWorkerSnapshot = deps.getChannelWorkerSnapshot; const reloadChannelWorker = deps.reloadChannelWorker; @@ -1022,6 +1131,13 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedLifecycleRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + invalidateServeFeaturesCache, + }); registerWorkspaceToolsRoutes(app, { boundWorkspace: primaryBoundWorkspace, workspace: primaryWorkspace, @@ -1031,6 +1147,12 @@ export function createServeApp( parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); + registerWorkspaceQualifiedToolsRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); // Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page). // Reads/writes the per-project cron file only; firing stays with the diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index dd48aaaef5..345bc57402 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -118,6 +118,37 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { ); }); + it('normalizes plural workspace agent routes to stable route labels', () => { + const mw = daemonTelemetryMiddleware(() => '/ws'); + for (const [method, path, route] of [ + ['GET', '/workspaces/ws-secondary/agents', 'GET /workspace/agents'], + [ + 'GET', + '/workspaces/ws-secondary/agents/reviewer', + 'GET /workspace/agents/:agentType', + ], + ['POST', '/workspaces/ws-secondary/agents', 'POST /workspace/agents'], + [ + 'POST', + '/workspaces/ws-secondary/agents/reviewer', + 'POST /workspace/agents/:agentType', + ], + [ + 'DELETE', + '/workspaces/ws-secondary/agents/reviewer', + 'DELETE /workspace/agents/:agentType', + ], + ] as const) { + const res = mockRes(200); + mw(mockReq(method, path), res, vi.fn() as unknown as NextFunction); + res.emit('finish'); + expect(coreMocks.withDaemonRequestSpan).toHaveBeenLastCalledWith( + expect.objectContaining({ method, route }), + expect.any(Function), + ); + } + }); + it('excludes the dashboard status poll (GET /daemon/status) from recordRequest', () => { const recordRequest = vi.fn(); const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index d400474a2b..7ea042b6b8 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -117,6 +117,109 @@ export function resolveDaemonTelemetryRoute( if (req.method === 'GET' && /^\/workspaces\/[^/]+\/sessions$/.test(path)) { return { route: 'GET /workspace/:id/sessions' }; } + const pluralWorkspacePrefix = /^\/workspaces\/[^/]+/; + if (pluralWorkspacePrefix.test(path)) { + const suffix = path.replace(pluralWorkspacePrefix, '/workspace'); + if (req.method === 'GET') { + if ( + suffix === '/workspace/mcp' || + suffix === '/workspace/skills' || + suffix === '/workspace/tools' || + suffix === '/workspace/providers' || + suffix === '/workspace/env' || + suffix === '/workspace/preflight' || + suffix === '/workspace/hooks' || + suffix === '/workspace/settings' || + suffix === '/workspace/permissions' || + suffix === '/workspace/trust' || + suffix === '/workspace/memory' || + suffix === '/workspace/agents' + ) { + return { route: `GET ${suffix}` }; + } + if (/^\/workspace\/agents\/[^/]+$/.test(suffix)) { + return { route: 'GET /workspace/agents/:agentType' }; + } + if (suffix === '/workspace/file') return { route: 'GET /file' }; + if (suffix === '/workspace/file/bytes') { + return { route: 'GET /file/bytes' }; + } + if (suffix === '/workspace/stat') return { route: 'GET /stat' }; + if (suffix === '/workspace/list') return { route: 'GET /list' }; + if (suffix === '/workspace/glob') return { route: 'GET /glob' }; + if (/^\/workspace\/mcp\/[^/]+\/tools$/.test(suffix)) { + return { route: 'GET /workspace/mcp/:server/tools' }; + } + if (/^\/workspace\/mcp\/[^/]+\/resources$/.test(suffix)) { + return { route: 'GET /workspace/mcp/:server/resources' }; + } + } + if (req.method === 'POST') { + if ( + suffix === '/workspace/settings' || + suffix === '/workspace/permissions' || + suffix === '/workspace/trust/request' || + suffix === '/workspace/init' || + suffix === '/workspace/reload' || + suffix === '/workspace/file/write' || + suffix === '/workspace/file/edit' || + suffix === '/workspace/mcp/servers' || + suffix === '/workspace/memory' || + suffix === '/workspace/agents' || + suffix === '/workspace/sessions/delete' || + suffix === '/workspace/sessions/archive' || + suffix === '/workspace/sessions/unarchive' || + suffix === '/workspace/session-groups' + ) { + return { route: `POST ${suffix}` }; + } + if (/^\/workspace\/tools\/[^/]+\/enable$/.test(suffix)) { + return { route: 'POST /workspace/tools/:name/enable' }; + } + if (/^\/workspace\/mcp\/[^/]+\/restart$/.test(suffix)) { + return { route: 'POST /workspace/mcp/:server/restart' }; + } + if (/^\/workspace\/agents\/[^/]+$/.test(suffix)) { + return { route: 'POST /workspace/agents/:agentType' }; + } + if ( + /^\/workspace\/mcp\/[^/]+\/(enable|disable|authenticate|clear-auth)$/.test( + suffix, + ) + ) { + return { + route: `POST /workspace/mcp/:server/${suffix.split('/').at(-1)}`, + }; + } + } + if ( + req.method === 'DELETE' && + /^\/workspace\/mcp\/servers\/[^/]+$/.test(suffix) + ) { + return { route: 'DELETE /workspace/mcp/servers/:name' }; + } + if ( + req.method === 'DELETE' && + /^\/workspace\/agents\/[^/]+$/.test(suffix) + ) { + return { route: 'DELETE /workspace/agents/:agentType' }; + } + if (suffix === '/workspace/session-groups' && req.method === 'GET') { + return { route: 'GET /workspace/session-groups' }; + } + if ( + /^\/workspace\/session-groups\/[^/]+$/.test(suffix) && + req.method === 'PATCH' + ) { + return { route: 'PATCH /workspace/session-groups/:groupId' }; + } + if ( + /^\/workspace\/session-groups\/[^/]+$/.test(suffix) && + req.method === 'DELETE' + ) { + return { route: 'DELETE /workspace/session-groups/:groupId' }; + } + } if (req.method === 'POST' && path === '/workspace/init') { return { route: 'POST /workspace/init' }; } diff --git a/packages/cli/src/serve/workspace-agents.ts b/packages/cli/src/serve/workspace-agents.ts index c76c5158be..537c9f520a 100644 --- a/packages/cli/src/serve/workspace-agents.ts +++ b/packages/cli/src/serve/workspace-agents.ts @@ -23,6 +23,14 @@ import { type AcpSessionBridge, } from './acp-session-bridge.js'; import { safeLogValue } from './server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from './workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; /** * Pattern for the route-layer `:agentType` URL parameter. Matches the @@ -101,6 +109,13 @@ export interface WorkspaceAgentsRouteDeps { safeBody: (req: Request) => Record; } +export interface WorkspaceQualifiedAgentsRouteDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + parseClientId: (req: Request, res: Response) => string | undefined | null; + safeBody: (req: Request) => Record; +} + export function mountWorkspaceAgentsRoutes( app: Application, deps: WorkspaceAgentsRouteDeps, @@ -680,6 +695,318 @@ export function mountWorkspaceAgentsRoutes( ); } +export function mountWorkspaceQualifiedAgentsRoutes( + app: Application, + deps: WorkspaceQualifiedAgentsRouteDeps, +): void { + app.get('/workspaces/:workspace/agents', async (req, res) => { + const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res); + if (!runtime) return; + const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res); + if (scopedLevel === null) return; + const manager = createDaemonSubagentManager(runtime.workspaceCwd); + try { + const agents = await manager.listSubagents({ force: true }); + const status: ServeWorkspaceAgentsStatus = { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: runtime.workspaceCwd, + agents: agents + .filter((agent) => agent.level === 'project') + .map(toSummary), + }; + res.status(200).json(status); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspaces/:workspace/agents failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to list workspace agents', + code: 'agent_list_failed', + }); + } + }); + + app.post( + '/workspaces/:workspace/agents', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res); + if (!runtime) return; + const routeDeps = workspaceAgentDepsForRuntime(runtime, deps); + const body = deps.safeBody(req); + const clientIdResult = resolveOriginatorClientId(routeDeps, req, res); + if (clientIdResult === null) return; + const originatorClientId = clientIdResult; + + const level = parseWorkspaceOnlyAgentBodyScope(body, res); + if (level === null) return; + const manager = createDaemonSubagentManager(runtime.workspaceCwd); + const config = parseAgentConfig(body, level, res); + if (!config) return; + + const collision = await manager.loadSubagent(config.name, level); + if (collision) { + res.status(409).json({ + error: `Subagent "${config.name}" already exists at ${level} level`, + code: 'agent_already_exists', + name: config.name, + level, + }); + return; + } + + try { + await manager.createSubagent(config, { level }); + } catch (err) { + if (sendCreateAgentError(res, err, config.name)) return; + writeStderrLine( + `qwen serve: POST /workspaces/:workspace/agents failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to create workspace agent', + code: 'agent_create_failed', + }); + return; + } + + const created = await manager.loadSubagent(config.name, level); + if (!created) { + writeStderrLine( + `qwen serve: agent_create_reload_failed (name=${safeLogValue(config.name)} ` + + `level=${level}) — file likely persisted on disk; check ` + + '`GET /workspaces/:workspace/agents` for a phantom entry', + ); + res.status(500).json({ + error: 'Agent creation succeeded but reload failed', + code: 'agent_create_reload_failed', + name: config.name, + level, + }); + return; + } + runtime.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'created', name: config.name, level: 'project' }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + res.status(201).json({ ok: true, agent: toDetail(created) }); + }, + ); + + app.get('/workspaces/:workspace/agents/:agentType', async (req, res) => { + const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res); + if (!runtime) return; + const agentType = validateAgentType(req, res); + if (agentType === null) return; + const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res); + if (scopedLevel === null) return; + const manager = createDaemonSubagentManager(runtime.workspaceCwd); + try { + const config = await manager.loadSubagent(agentType, scopedLevel); + if (!config) { + res.status(404).json({ + error: `Subagent "${agentType}" not found`, + code: 'agent_not_found', + name: agentType, + }); + return; + } + res.status(200).json(toDetail(config)); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to read workspace agent', + code: 'agent_read_failed', + }); + } + }); + + app.post( + '/workspaces/:workspace/agents/:agentType', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res); + if (!runtime) return; + const agentType = validateAgentType(req, res); + if (agentType === null) return; + const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res); + if (scopedLevel === null) return; + const routeDeps = workspaceAgentDepsForRuntime(runtime, deps); + const clientIdResult = resolveOriginatorClientId(routeDeps, req, res); + if (clientIdResult === null) return; + const originatorClientId = clientIdResult; + + const body = deps.safeBody(req); + const updates = parseAgentUpdates(body, res); + if (!updates) return; + const manager = createDaemonSubagentManager(runtime.workspaceCwd); + const existing = await manager.loadSubagent(agentType, scopedLevel); + if (!existing) { + res.status(404).json({ + error: `Subagent "${agentType}" not found`, + code: 'agent_not_found', + name: agentType, + }); + return; + } + if (assertMutableLevel(existing, agentType, res)) return; + + if (Object.keys(updates).length === 0) { + res.status(400).json({ + error: + '`POST /workspaces/:workspace/agents/:agentType` requires at least one updatable field in the body', + code: 'invalid_config', + name: agentType, + }); + return; + } + if (isNoOpUpdate(existing, updates)) { + res.status(200).json({ + ok: true, + agent: toDetail(existing), + changed: false, + }); + return; + } + + try { + await manager.updateSubagent(agentType, updates, existing.level); + } catch (err) { + if (sendUpdateAgentError(res, err, agentType)) return; + writeStderrLine( + `qwen serve: POST /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to update workspace agent', + code: 'agent_update_failed', + }); + return; + } + + const updated = await manager.loadSubagent(agentType, existing.level); + if (!updated) { + writeStderrLine( + `qwen serve: agent_update_reload_failed (name=${safeLogValue(agentType)} ` + + `level=${existing.level}) — disk write completed; check ` + + `\`GET /workspaces/:workspace/agents/${safeLogValue(agentType)}\` for the new state`, + ); + res.status(500).json({ + error: 'Agent update succeeded but reload failed', + code: 'agent_update_reload_failed', + name: agentType, + level: existing.level, + }); + return; + } + runtime.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'updated', name: existing.name, level: 'project' }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + res + .status(200) + .json({ ok: true, agent: toDetail(updated), changed: true }); + }, + ); + + app.delete( + '/workspaces/:workspace/agents/:agentType', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res); + if (!runtime) return; + const agentType = validateAgentType(req, res); + if (agentType === null) return; + const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res); + if (scopedLevel === null) return; + const routeDeps = workspaceAgentDepsForRuntime(runtime, deps); + const clientIdResult = resolveOriginatorClientId(routeDeps, req, res); + if (clientIdResult === null) return; + const originatorClientId = clientIdResult; + const manager = createDaemonSubagentManager(runtime.workspaceCwd); + + const existing = await manager.loadSubagent(agentType, scopedLevel); + if (existing && assertMutableLevel(existing, agentType, res)) return; + + try { + await manager.deleteSubagent(agentType, scopedLevel); + } catch (err) { + if (err instanceof SubagentError) { + if (err.code === SubagentErrorCode.NOT_FOUND) { + res.status(404).json({ + error: err.message, + code: 'agent_not_found', + name: err.subagentName ?? agentType, + }); + return; + } + if (err.code === SubagentErrorCode.INVALID_CONFIG) { + res.status(403).json({ + error: err.message, + code: 'agent_readonly', + name: err.subagentName ?? agentType, + }); + return; + } + } + writeStderrLine( + `qwen serve: DELETE /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to delete workspace agent', + code: 'agent_delete_failed', + }); + return; + } + + if (existing?.filePath) { + try { + await fs.access(existing.filePath); + writeStderrLine( + `qwen serve: DELETE /workspaces/:workspace/agents/${safeLogValue(agentType)} partial — ` + + `remaining=${existing.level}:${existing.filePath}`, + ); + res.status(500).json({ + error: + `Failed to delete subagent "${agentType}" — ` + + `${existing.level} level still has its file on disk`, + code: 'agent_delete_partial', + name: agentType, + removedLevels: [], + remainingLevels: [existing.level], + }); + return; + } catch { + // Access failure means the project-level file is gone. + } + } + + runtime.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { + change: 'deleted', + name: existing?.name ?? agentType, + level: 'project', + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + res.status(204).end(); + }, + ); +} + /** * Pull `:agentType` off the request and reject malformed values at * the route boundary. Returns the validated string, or `null` AFTER @@ -794,6 +1121,167 @@ function resolveOriginatorClientId( return clientId; } +function resolveTrustedWorkspaceAgentRuntime( + deps: WorkspaceQualifiedAgentsRouteDeps, + req: Request, + res: Response, +): WorkspaceRuntime | undefined { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return undefined; + if (!requireTrustedWorkspaceRuntime(runtime, res)) return undefined; + return runtime; +} + +function workspaceAgentDepsForRuntime( + runtime: WorkspaceRuntime, + deps: WorkspaceQualifiedAgentsRouteDeps, +): WorkspaceAgentsRouteDeps { + return { + bridge: runtime.bridge, + boundWorkspace: runtime.workspaceCwd, + mutate: deps.mutate, + parseClientId: deps.parseClientId, + safeBody: deps.safeBody, + }; +} + +function parseWorkspaceOnlyAgentBodyScope( + body: Record, + res: Response, +): SubagentLevel | null { + const scope = body['scope']; + if (scope === undefined || scope === 'workspace' || scope === 'project') { + return 'project'; + } + return rejectWorkspaceQualifiedAgentScope(scope, res); +} + +function parseWorkspaceOnlyAgentScopeQuery( + req: Request, + res: Response, +): SubagentLevel | null { + const raw = req.query['scope']; + if (raw === undefined || raw === 'workspace' || raw === 'project') { + return 'project'; + } + if (typeof raw !== 'string') { + res.status(400).json({ + error: '`scope` query must be a single workspace/project value', + code: 'invalid_scope', + }); + return null; + } + return rejectWorkspaceQualifiedAgentScope(raw, res); +} + +function rejectWorkspaceQualifiedAgentScope( + scope: unknown, + res: Response, +): null { + if (scope === 'global' || scope === 'user') { + res.status(400).json({ + error: + 'Workspace-qualified agents routes only support workspace/project scope', + code: 'global_scope_not_supported_for_workspace_route', + }); + return null; + } + res.status(400).json({ + error: '`scope` must be "workspace" or "project"', + code: 'invalid_scope', + }); + return null; +} + +function sendCreateAgentError( + res: Response, + err: unknown, + name: string, +): boolean { + if (!(err instanceof SubagentError)) return false; + if (err.code === SubagentErrorCode.ALREADY_EXISTS) { + res.status(409).json({ + error: err.message, + code: 'agent_already_exists', + name: err.subagentName ?? name, + }); + return true; + } + if ( + err.code === SubagentErrorCode.VALIDATION_ERROR || + err.code === SubagentErrorCode.INVALID_CONFIG || + err.code === SubagentErrorCode.INVALID_NAME || + err.code === SubagentErrorCode.TOOL_NOT_FOUND + ) { + res.status(422).json({ + error: err.message, + code: 'invalid_config', + name: err.subagentName ?? name, + }); + return true; + } + if (err.code === SubagentErrorCode.FILE_ERROR) { + const debug = isServeDebugMode(); + res.status(500).json({ + error: debug ? err.message : 'Failed to write workspace agent file', + code: 'file_error', + name: err.subagentName ?? name, + }); + return true; + } + return false; +} + +function sendUpdateAgentError( + res: Response, + err: unknown, + agentType: string, +): boolean { + if (!(err instanceof SubagentError)) return false; + if (err.code === SubagentErrorCode.NOT_FOUND) { + res.status(404).json({ + error: err.message, + code: 'agent_not_found', + name: err.subagentName ?? agentType, + }); + return true; + } + if (err.code === SubagentErrorCode.INVALID_CONFIG) { + res.status(403).json({ + error: err.message, + code: 'agent_readonly', + name: err.subagentName ?? agentType, + }); + return true; + } + if ( + err.code === SubagentErrorCode.VALIDATION_ERROR || + err.code === SubagentErrorCode.INVALID_NAME || + err.code === SubagentErrorCode.TOOL_NOT_FOUND + ) { + res.status(422).json({ + error: err.message, + code: 'invalid_config', + name: err.subagentName ?? agentType, + }); + return true; + } + if (err.code === SubagentErrorCode.FILE_ERROR) { + const debug = isServeDebugMode(); + res.status(500).json({ + error: debug ? err.message : 'Failed to write workspace agent file', + code: 'file_error', + name: err.subagentName ?? agentType, + }); + return true; + } + return false; +} + function parseAgentConfig( body: Record, level: SubagentLevel, diff --git a/packages/cli/src/serve/workspace-memory.ts b/packages/cli/src/serve/workspace-memory.ts index f1590b08b2..a80f5c123a 100644 --- a/packages/cli/src/serve/workspace-memory.ts +++ b/packages/cli/src/serve/workspace-memory.ts @@ -24,6 +24,11 @@ import { type ServeWorkspaceMemoryFile, type ServeWorkspaceMemoryStatus, } from '@qwen-code/acp-bridge/status'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from './workspace-route-runtime.js'; +import type { WorkspaceRegistry } from './workspace-registry.js'; /** * Issue #4175 PR 16: workspace memory CRUD routes. @@ -79,6 +84,78 @@ export interface WorkspaceMemoryRouteDeps { const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024; +function sendWorkspaceMemoryWriteError( + res: Response, + err: unknown, + options: { + route: string; + scope: ServeContextFileScope; + mode: 'append' | 'replace'; + }, +): void { + const { route, scope, mode } = options; + if (err instanceof WorkspaceMemoryWriteTimeoutError) { + writeStderrLine( + `qwen serve: ${route} timeout — file lock at ` + + `${err.filePath} did not acquire within ${err.timeoutMs}ms ` + + `(stalled FS / OneDrive / NFS)`, + ); + const debug = isServeDebugMode(); + res.status(500).json({ + error: debug + ? err.message + : 'Workspace memory write timed out waiting for the per-file lock. Retry or restart the daemon.', + code: 'memory_write_timeout', + scope, + mode, + timeoutMs: err.timeoutMs, + ...(debug ? { filePath: err.filePath } : {}), + }); + return; + } + if (err instanceof WorkspaceMemoryFileTooLargeError) { + writeStderrLine( + `qwen serve: ${route} refused — existing file ` + + `${err.filePath} is ${err.bytes} bytes (cap ${err.limit})`, + ); + const debug = isServeDebugMode(); + res.status(413).json({ + error: debug + ? err.message + : 'Existing memory file exceeds the safe-append cap. Trim the file or POST with mode=replace.', + code: 'memory_file_too_large', + scope, + mode, + ...(debug ? { filePath: err.filePath } : {}), + bytes: err.bytes, + limit: err.limit, + }); + return; + } + writeStderrLine( + `qwen serve: ${route} failed (scope=${scope} mode=${mode}): ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + const osCode = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: unknown }).code + : undefined; + const debug = isServeDebugMode(); + res.status(500).json({ + error: 'Failed to write workspace memory', + code: 'file_error', + scope, + mode, + ...(typeof osCode === 'string' ? { osCode } : {}), + ...(debug + ? { + errorMessage: err instanceof Error ? err.message : String(err), + } + : {}), + }); +} + /** Mount the two memory routes on the supplied Express app. */ export function mountWorkspaceMemoryRoutes( app: Application, @@ -216,89 +293,138 @@ export function mountWorkspaceMemoryRoutes( } res.status(200).json(responseBody); } catch (err) { - // 413 + structured fields for the "memory file is past the - // safe-append cap" case so callers can tell pathological - // file size apart from generic file errors. The helper - // refuses to pull >16 MB into memory on append; clients - // either trim the file or switch to mode=replace. - if (err instanceof WorkspaceMemoryWriteTimeoutError) { - writeStderrLine( - `qwen serve: POST /workspace/memory timeout — file lock at ` + - `${err.filePath} did not acquire within ${err.timeoutMs}ms ` + - `(stalled FS / OneDrive / NFS)`, - ); - const debug = isServeDebugMode(); - res.status(500).json({ - error: debug - ? err.message - : 'Workspace memory write timed out waiting for the per-file lock. Retry or restart the daemon.', - code: 'memory_write_timeout', - scope, - mode, - timeoutMs: err.timeoutMs, - ...(debug ? { filePath: err.filePath } : {}), - }); - return; - } - if (err instanceof WorkspaceMemoryFileTooLargeError) { - writeStderrLine( - `qwen serve: POST /workspace/memory refused — existing file ` + - `${err.filePath} is ${err.bytes} bytes (cap ${err.limit})`, - ); - // Path disclosure: both `error` (which embeds the absolute - // file path in the constructor message — see - // `WorkspaceMemoryFileTooLargeError`) and `filePath` are - // gated behind QWEN_SERVE_DEBUG so production responses - // don't include `/Users//.qwen/...` in the body. - // Operators triaging an issue locally enable the debug - // toggle to get the full text; in default mode SDK - // callers branch on `code` + `bytes` / `limit` instead - // (the structured discriminator survives without the - // disclosure). - const debug = isServeDebugMode(); - res.status(413).json({ - error: debug - ? err.message - : 'Existing memory file exceeds the safe-append cap. Trim the file or POST with mode=replace.', - code: 'memory_file_too_large', - scope, - mode, - ...(debug ? { filePath: err.filePath } : {}), - bytes: err.bytes, - limit: err.limit, - }); - return; - } - writeStderrLine( - `qwen serve: POST /workspace/memory failed (scope=${scope} mode=${mode}): ${ - err instanceof Error ? (err.stack ?? err.message) : String(err) - }`, - ); - // Surface enough context for callers to debug without leaking - // absolute paths in the response body. `osCode` (`EACCES` / - // `EROFS` / `EDQUOT` / `ENOSPC` / ...) stays unconditional so - // SDK clients can branch on the failure class. The full - // `errorMessage` (which often embeds the file path on Node's - // ENOENT/EACCES messages) is gated behind `QWEN_SERVE_DEBUG`. - // Without the debug toggle, callers see only the generic - // `error` + `code` + `osCode` envelope; the daemon's stderr - // log has the full message for the operator. - const osCode = - err && typeof err === 'object' && 'code' in err - ? (err as { code?: unknown }).code - : undefined; - const debug = isServeDebugMode(); - res.status(500).json({ - error: 'Failed to write workspace memory', - code: 'file_error', + sendWorkspaceMemoryWriteError(res, err, { + route: 'POST /workspace/memory', scope, mode, - ...(typeof osCode === 'string' ? { osCode } : {}), - ...(debug - ? { - errorMessage: err instanceof Error ? err.message : String(err), - } - : {}), + }); + } + }, + ); +} + +export function mountWorkspaceQualifiedMemoryRoutes( + app: Application, + deps: Omit & { + workspaceRegistry: WorkspaceRegistry; + }, +): void { + app.get('/workspaces/:workspace/memory', async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + try { + const collectStatus = deps.collectStatus ?? collectWorkspaceMemoryStatus; + res.status(200).json(await collectStatus(runtime.workspaceCwd)); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspaces/:workspace/memory failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to discover workspace memory', + code: 'memory_discovery_failed', + }); + } + }); + + app.post( + '/workspaces/:workspace/memory', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const body = deps.safeBody(req); + if (body['scope'] !== 'workspace') { + res.status(400).json({ + error: + 'workspace-qualified memory routes only support "workspace" scope', + code: 'global_scope_not_supported_for_workspace_route', + }); + return; + } + const modeRaw = body['mode']; + if ( + modeRaw !== undefined && + modeRaw !== 'append' && + modeRaw !== 'replace' + ) { + res.status(400).json({ + error: '`mode` must be "append", "replace", or omitted', + code: 'invalid_mode', + }); + return; + } + const mode: 'append' | 'replace' = + modeRaw === 'replace' ? 'replace' : 'append'; + const content = body['content']; + if (typeof content !== 'string') { + res.status(400).json({ + error: '`content` must be a string', + code: 'invalid_content', + }); + return; + } + if (Buffer.byteLength(content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) { + res.status(400).json({ + error: `\`content\` exceeds the ${MAX_MEMORY_CONTENT_BYTES}-byte limit`, + code: 'content_too_large', + }); + return; + } + const clientId = deps.parseClientId(req, res); + if (clientId === null) return; + let originatorClientId: string | undefined; + if (clientId !== undefined) { + if (!runtime.bridge.knownClientIds().has(clientId)) { + res.status(400).json({ + error: `Client id "${clientId}" is not registered for this workspace`, + code: 'invalid_client_id', + clientId, + }); + return; + } + originatorClientId = clientId; + } + try { + const result = await writeWorkspaceContextFile({ + scope: 'workspace', + mode, + content, + projectRoot: runtime.workspaceCwd, + }); + if (result.changed) { + runtime.bridge.publishWorkspaceEvent({ + type: 'memory_changed', + data: { + scope: 'workspace', + filePath: result.filePath, + mode, + bytesWritten: result.bytesWritten, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + res.status(200).json({ + ok: true, + filePath: result.filePath, + bytesWritten: result.bytesWritten, + mode, + changed: result.changed, + }); + } catch (err) { + sendWorkspaceMemoryWriteError(res, err, { + route: 'POST /workspaces/:workspace/memory', + scope: 'workspace', + mode, }); } }, diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts new file mode 100644 index 0000000000..02c6f0dbce --- /dev/null +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -0,0 +1,1079 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promises as fsp, realpathSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { hashDaemonWorkspace } from '@qwen-code/qwen-code-core'; +import { createServeApp } from './server.js'; +import { ClientMcpSenderRegistry } from './acp-http/client-mcp-sender-registry.js'; +import { + canonicalizeWorkspace, + createWorkspaceFileSystemFactory, +} from './fs/index.js'; +import type { ServeOptions } from './types.js'; +import { + createWorkspaceRegistry, + type WorkspaceRuntime, +} from './workspace-registry.js'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import type { DaemonWorkspaceService } from './workspace-service/types.js'; + +const baseOpts: ServeOptions = { + hostname: '127.0.0.1', + port: 4197, + mode: 'http-bridge', +}; + +function host(): string { + return `127.0.0.1:${baseOpts.port}`; +} + +function makeBridge(): AcpSessionBridge { + return { + permissionPolicy: 'first-responder', + knownClientIds: () => new Set(['client-1']), + publishWorkspaceEvent: vi.fn(), + manageMcpServer: vi.fn(async (serverName, action, clientId) => ({ + serverName, + action, + clientId, + ok: true, + })), + addRuntimeMcpServer: vi.fn(async (name, _config, clientId) => ({ + name, + replaced: false, + originatorClientId: clientId, + })), + removeRuntimeMcpServer: vi.fn(async (name, clientId) => ({ + name, + removed: true, + originatorClientId: clientId, + })), + getWorkspaceToolsStatus: vi.fn(async () => ({ v: 1, tools: [] })), + getWorkspaceMcpToolsStatus: vi.fn(async () => ({ v: 1, tools: [] })), + getWorkspaceMcpResourcesStatus: vi.fn(async () => ({ + v: 1, + resources: [], + })), + getDaemonStatusSnapshot: vi.fn(() => ({ + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + compactedReplayMaxBytes: 4 * 1024 * 1024, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 0, + pendingPermissionCount: 0, + channelLive: false, + permissionPolicy: 'first-responder', + sessions: [], + })), + listWorkspaceSessions: vi.fn(() => []), + getSessionSummary: vi.fn(() => { + throw new Error('not found'); + }), + sessionCount: 0, + activePromptCount: 0, + pendingPromptTotal: 0, + lastActivityAt: null, + } as unknown as AcpSessionBridge; +} + +function makeWorkspaceService(label: string): DaemonWorkspaceService { + return { + getWorkspaceTrustStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + trusted: true, + folderTrustEnabled: true, + })), + requestWorkspaceTrustChange: vi.fn(async (ctx, request) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + requestedState: request.desiredState, + accepted: true, + })), + setWorkspacePermissionRules: vi.fn(async (ctx, request) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + user: { + path: '/user/settings.json', + rules: { allow: [], ask: [], deny: [] }, + }, + workspace: { + path: `${ctx.workspaceCwd}/.qwen/settings.json`, + rules: { + allow: request.ruleType === 'allow' ? request.rules : [], + ask: request.ruleType === 'ask' ? request.rules : [], + deny: request.ruleType === 'deny' ? request.rules : [], + }, + }, + })), + setWorkspaceToolEnabled: vi.fn(async (_ctx, toolName, enabled) => ({ + toolName, + enabled, + })), + initWorkspace: vi.fn(async (ctx) => ({ + path: `${ctx.workspaceCwd}/QWEN.md`, + action: 'created' as const, + })), + restartMcpServer: vi.fn(async (_ctx, serverName) => ({ + serverName, + restarted: true, + })), + reload: vi.fn(async (ctx) => ({ + workspaceCwd: ctx.workspaceCwd, + env: { reloaded: false }, + settings: { reloaded: true }, + })), + getWorkspaceMcpStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + servers: [{ name: label }], + })), + getWorkspaceSkillsStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + skills: [], + })), + getWorkspaceProvidersStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + providers: [], + })), + getWorkspaceEnvStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + env: {}, + })), + getWorkspacePreflightStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + checks: [], + })), + getWorkspaceHooksStatus: vi.fn(async (ctx) => ({ + v: 1, + workspaceCwd: ctx.workspaceCwd, + hooks: [], + })), + } as unknown as DaemonWorkspaceService; +} + +async function makeHarness(opts?: { + secondaryTrusted?: boolean; + secondaryDirName?: string; + token?: string; + persistSetting?: boolean; +}) { + const scratch = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-qualified-rest-'), + ); + const primaryCwd = canonicalizeWorkspace(path.join(scratch, 'primary')); + const secondaryCwd = canonicalizeWorkspace( + path.join(scratch, opts?.secondaryDirName ?? 'secondary'), + ); + await fsp.mkdir(primaryCwd, { recursive: true }); + await fsp.mkdir(secondaryCwd, { recursive: true }); + await fsp.writeFile(path.join(primaryCwd, 'target.txt'), 'primary'); + await fsp.writeFile(path.join(secondaryCwd, 'target.txt'), 'secondary'); + + const primaryFsFactory = createWorkspaceFileSystemFactory({ + boundWorkspaces: [primaryCwd], + trusted: true, + emit: () => {}, + }); + const secondaryFsFactory = createWorkspaceFileSystemFactory({ + boundWorkspaces: [secondaryCwd], + trusted: true, + emit: () => {}, + }); + const untrustedFsFactory = createWorkspaceFileSystemFactory({ + boundWorkspaces: [secondaryCwd], + trusted: false, + emit: () => {}, + }); + + const primary: WorkspaceRuntime = { + workspaceId: 'same-as-path', + workspaceCwd: primaryCwd, + primary: true, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: makeBridge(), + workspaceService: makeWorkspaceService('primary'), + routeFileSystemFactory: primaryFsFactory, + clientMcpSenderRegistry: new ClientMcpSenderRegistry(), + }; + const secondary: WorkspaceRuntime = { + workspaceId: hashDaemonWorkspace(secondaryCwd), + workspaceCwd: secondaryCwd, + primary: false, + trusted: opts?.secondaryTrusted ?? true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: makeBridge(), + workspaceService: makeWorkspaceService('secondary'), + routeFileSystemFactory: + opts?.secondaryTrusted === false + ? untrustedFsFactory + : secondaryFsFactory, + clientMcpSenderRegistry: new ClientMcpSenderRegistry(), + }; + + const persistSetting = vi.fn(async () => {}); + const app = createServeApp( + { ...baseOpts, workspace: primaryCwd, token: opts?.token }, + undefined, + { + workspaceRegistry: createWorkspaceRegistry([primary, secondary]), + ...(opts?.persistSetting === false ? {} : { persistSetting }), + }, + ); + + return { + app, + scratch, + primaryCwd, + secondaryCwd, + secondaryId: secondary.workspaceId, + persistSetting, + }; +} + +async function makeWindowsSelectorHarness() { + const scratch = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-qualified-rest-win-'), + ); + const primaryCwd = canonicalizeWorkspace(path.join(scratch, 'primary')); + await fsp.mkdir(primaryCwd, { recursive: true }); + const windowsCwd = 'C:\\repo'; + const primaryFsFactory = createWorkspaceFileSystemFactory({ + boundWorkspaces: [primaryCwd], + trusted: true, + emit: () => {}, + }); + const windowsFsFactory = createWorkspaceFileSystemFactory({ + boundWorkspaces: [windowsCwd], + trusted: true, + emit: () => {}, + }); + const primary: WorkspaceRuntime = { + workspaceId: 'primary-id', + workspaceCwd: primaryCwd, + primary: true, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: makeBridge(), + workspaceService: makeWorkspaceService('primary'), + routeFileSystemFactory: primaryFsFactory, + clientMcpSenderRegistry: new ClientMcpSenderRegistry(), + }; + const windowsRuntime: WorkspaceRuntime = { + workspaceId: 'windows-id', + workspaceCwd: windowsCwd, + primary: false, + trusted: true, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: makeBridge(), + workspaceService: makeWorkspaceService('windows'), + routeFileSystemFactory: windowsFsFactory, + clientMcpSenderRegistry: new ClientMcpSenderRegistry(), + }; + const persistSetting = vi.fn(async () => {}); + const app = createServeApp( + { ...baseOpts, workspace: primaryCwd }, + undefined, + { + workspaceRegistry: createWorkspaceRegistry([primary, windowsRuntime]), + persistSetting, + }, + ); + return { app, scratch, windowsCwd }; +} + +describe('workspace-qualified core REST', () => { + it('routes file reads to the workspace selected by id', async () => { + const h = await makeHarness(); + try { + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + kind: 'file', + path: 'target.txt', + content: 'secondary', + }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('advertises the workspace-qualified core REST capability', async () => { + const h = await makeHarness(); + try { + const res = await request(h.app).get('/capabilities').set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.features).toContain('workspace_qualified_rest_core'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('advertises core workspace-qualified REST without settings persistence', async () => { + const h = await makeHarness({ persistSetting: false }); + try { + const res = await request(h.app).get('/capabilities').set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.features).toContain('workspace_qualified_rest_core'); + expect(res.body.features).not.toContain('workspace_settings'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes file reads to the workspace selected by encoded cwd', async () => { + const h = await makeHarness(); + try { + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryCwd)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.content).toBe('secondary'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes cwd selectors through local canonical symlink equivalence', async () => { + const h = await makeHarness(); + try { + const link = path.join(path.dirname(h.secondaryCwd), 'secondary-link'); + try { + await fsp.symlink(h.secondaryCwd, link, 'dir'); + } catch (err) { + if ( + err && + typeof err === 'object' && + 'code' in err && + ((err as { code?: unknown }).code === 'EPERM' || + (err as { code?: unknown }).code === 'EACCES') + ) { + return; + } + throw err; + } + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(link)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.content).toBe('secondary'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('preserves literal percent-encoded text in cwd selectors', async () => { + const h = await makeHarness({ secondaryDirName: 'secondary%2Fencoded' }); + try { + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryCwd)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.content).toBe('secondary'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('prefers workspace id over an absolute cwd-shaped selector', async () => { + const h = await makeHarness(); + try { + const res = await request(h.app) + .get('/workspaces/same-as-path/file') + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.content).toBe('primary'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects unknown workspace selectors with workspace_mismatch', async () => { + const h = await makeHarness(); + try { + const res = await request(h.app) + .get( + `/workspaces/${encodeURIComponent(path.join(h.scratch, 'nope'))}/file`, + ) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(400); + expect(res.body.code).toBe('workspace_mismatch'); + expect(res.body.boundWorkspace).toBeUndefined(); + expect(res.body.requestedWorkspace).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain(h.primaryCwd); + expect(JSON.stringify(res.body)).not.toContain( + path.join(h.scratch, 'nope'), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not canonicalize unregistered absolute cwd selectors', async () => { + const h = await makeHarness(); + const realpathSpy = vi.spyOn(realpathSync, 'native'); + try { + const uncSelector = '\\\\attacker\\share'; + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(uncSelector)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(res.status).toBe(400); + expect(res.body.code).toBe('workspace_mismatch'); + expect(realpathSpy).not.toHaveBeenCalled(); + } finally { + realpathSpy.mockRestore(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('uses portable cwd selector resolution for workspace session routes', async () => { + const h = await makeWindowsSelectorHarness(); + try { + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.windowsCwd)}/sessions`) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.sessions).toEqual([]); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('allows untrusted workspace file reads but rejects other core reads', async () => { + const h = await makeHarness({ secondaryTrusted: false }); + try { + const file = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/file`) + .query({ path: 'target.txt' }) + .set('Host', host()); + expect(file.status).toBe(200); + expect(file.body.content).toBe('secondary'); + + const mcp = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/mcp`) + .set('Host', host()); + expect(mcp.status).toBe(403); + expect(mcp.body.code).toBe('untrusted_workspace'); + expect(mcp.body.error).toBe('Workspace is not trusted.'); + expect(mcp.body).not.toHaveProperty('workspaceCwd'); + expect(mcp.body).not.toHaveProperty('workspaceId'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified status reads to the selected workspace', async () => { + const h = await makeHarness(); + try { + for (const route of [ + 'mcp', + 'skills', + 'providers', + 'env', + 'preflight', + 'hooks', + ]) { + const res = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/${route}`) + .set('Host', host()); + expect(res.status).toBe(200); + expect(res.body.workspaceCwd).toBe(h.secondaryCwd); + } + + const tools = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/tools`) + .set('Host', host()); + expect(tools.status).toBe(200); + expect(tools.body).toEqual({ v: 1, tools: [] }); + + const mcpTools = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/tools`) + .set('Host', host()); + expect(mcpTools.status).toBe(200); + expect(mcpTools.body).toEqual({ v: 1, tools: [] }); + + const mcpResources = await request(h.app) + .get( + `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/resources`, + ) + .set('Host', host()); + expect(mcpResources.status).toBe(200); + expect(mcpResources.body).toEqual({ v: 1, resources: [] }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified settings and rejects user scope or untrusted access', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const get = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/settings`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(get.status).toBe(200); + + const post = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/settings`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + scope: 'workspace', + key: 'general.cleanupPeriodDays', + value: 30, + }); + expect(post.status).toBe(200); + expect(h.persistSetting).toHaveBeenCalledWith( + h.secondaryCwd, + expect.any(String), + 'general.cleanupPeriodDays', + 30, + ); + + const badScope = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/settings`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + scope: 'user', + key: 'general.cleanupPeriodDays', + value: 30, + }); + expect(badScope.status).toBe(400); + expect(badScope.body.code).toBe('invalid_scope'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .get( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/settings`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + expect(res.body.error).toBe('Workspace is not trusted.'); + expect(res.body).not.toHaveProperty('workspaceCwd'); + expect(res.body).not.toHaveProperty('workspaceId'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified permissions and rejects non-workspace scope', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const get = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/permissions`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(get.status).toBe(200); + + const post = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/permissions`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace', ruleType: 'allow', rules: ['Shell(ls)'] }); + expect(post.status).toBe(200); + expect(post.body.workspace.rules.allow).toEqual(['Shell(ls)']); + + const badScope = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/permissions`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'user', ruleType: 'allow', rules: ['Shell(ls)'] }); + expect(badScope.status).toBe(400); + expect(badScope.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .get( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/permissions`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('allows workspace-qualified trust status and requests while untrusted', async () => { + const h = await makeHarness({ secondaryTrusted: false, token: 'secret' }); + try { + const get = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/trust`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(get.status).toBe(200); + expect(get.body.workspaceCwd).toBe(h.secondaryCwd); + + const post = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/trust/request`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ desiredState: 'trusted', reason: 'tests' }); + expect(post.status).toBe(202); + expect(post.body.requestedState).toBe('trusted'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified file writes and trust-gates untrusted writes', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const res = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/write`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ path: 'created.txt', content: 'created', mode: 'create' }); + expect(res.status).toBe(201); + expect(res.body.path).toBe('created.txt'); + await expect( + fsp.readFile(path.join(h.secondaryCwd, 'created.txt'), 'utf8'), + ).resolves.toBe('created'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .post( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/file/write`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ path: 'blocked.txt', content: 'blocked', mode: 'create' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified lifecycle mutations and trust-gates them', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const init = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/init`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ force: true }); + expect(init.status).toBe(200); + expect(init.body.path).toBe(`${h.secondaryCwd}/QWEN.md`); + + const reload = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/reload`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(reload.status).toBe(200); + expect(reload.body.workspaceCwd).toBe(h.secondaryCwd); + + const badForce = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/init`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ force: 'yes' }); + expect(badForce.status).toBe(400); + expect(badForce.body.code).toBe('invalid_force_flag'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .post(`/workspaces/${encodeURIComponent(untrusted.secondaryId)}/reload`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified MCP control through the selected runtime', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const restart = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(restart.status).toBe(200); + expect(restart.body).toMatchObject({ + serverName: 'docs', + restarted: true, + }); + + const enable = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .set('Host', host()) + .send({}); + expect(enable.status).toBe(200); + expect(enable.body).toMatchObject({ + serverName: 'docs', + action: 'enable', + clientId: 'client-1', + }); + + const add = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/servers`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .set('Host', host()) + .send({ name: 'runtime', config: { command: 'node' } }); + expect(add.status).toBe(200); + expect(add.body.name).toBe('runtime'); + + const remove = await request(h.app) + .delete( + `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/servers/runtime`, + ) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .set('Host', host()); + expect(remove.status).toBe(200); + expect(remove.body.removed).toBe(true); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .post( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified tool toggles and validates mutation bodies', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const enabled = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/tools/Bash/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ enabled: false }); + expect(enabled.status).toBe(200); + expect(enabled.body).toEqual({ toolName: 'Bash', enabled: false }); + + const badBody = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/tools/Bash/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ enabled: 'no' }); + expect(badBody.status).toBe(400); + expect(badBody.body.code).toBe('invalid_enabled_flag'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .post( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/tools/Bash/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ enabled: false }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + + it('routes project agents to the selected workspace', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const create = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/agents`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + name: 'ws-agent', + description: 'secondary agent', + systemPrompt: 'Operate in the secondary workspace.', + scope: 'workspace', + }); + expect(create.status).toBe(201); + expect(create.body.agent).toMatchObject({ + name: 'ws-agent', + level: 'project', + }); + + const selected = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/agents`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(selected.status).toBe(200); + expect( + (selected.body.agents as Array<{ name: string }>).map((a) => a.name), + ).toContain('ws-agent'); + + const detail = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/agents/ws-agent`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(detail.status).toBe(200); + expect(detail.body).toMatchObject({ + name: 'ws-agent', + description: 'secondary agent', + level: 'project', + }); + + const update = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/agents/ws-agent`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ description: 'updated secondary agent' }); + expect(update.status).toBe(200); + expect(update.body).toMatchObject({ + changed: true, + agent: { + name: 'ws-agent', + description: 'updated secondary agent', + level: 'project', + }, + }); + + const primary = await request(h.app) + .get('/workspaces/same-as-path/agents') + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(primary.status).toBe(200); + expect( + (primary.body.agents as Array<{ name: string }>).map((a) => a.name), + ).not.toContain('ws-agent'); + + const deleted = await request(h.app) + .delete( + `/workspaces/${encodeURIComponent(h.secondaryId)}/agents/ws-agent`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(deleted.status).toBe(204); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects global and user scope on workspace-qualified agents routes', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const create = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/agents`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + name: 'bad-agent', + description: 'bad agent', + systemPrompt: 'Do not create.', + scope: 'global', + }); + expect(create.status).toBe(400); + expect(create.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + + const list = await request(h.app) + .get( + `/workspaces/${encodeURIComponent(h.secondaryId)}/agents?scope=global`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(list.status).toBe(400); + expect(list.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + + const detail = await request(h.app) + .get( + `/workspaces/${encodeURIComponent(h.secondaryId)}/agents/ws-agent?scope=user`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(detail.status).toBe(400); + expect(detail.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + + const update = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/agents/ws-agent?scope=user`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ description: 'bad update' }); + expect(update.status).toBe(400); + expect(update.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes workspace-qualified memory reads and writes to the selected workspace', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const write = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/memory`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + scope: 'workspace', + mode: 'replace', + content: '# Secondary memory\n', + }); + expect(write.status).toBe(200); + expect(write.body.filePath).toBe(path.join(h.secondaryCwd, 'QWEN.md')); + expect(write.body.changed).toBe(true); + + const read = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/memory`) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(read.status).toBe(200); + expect(read.body.workspaceCwd).toBe(h.secondaryCwd); + expect(read.body.files).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: path.join(h.secondaryCwd, 'QWEN.md'), + scope: 'workspace', + }), + ]), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects global and user scope on workspace-qualified memory routes', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + for (const scope of ['global', 'user']) { + const res = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/memory`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope, mode: 'replace', content: '# ignored\n' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe( + 'global_scope_not_supported_for_workspace_route', + ); + } + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('returns typed memory write errors on workspace-qualified routes', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + await fsp.writeFile( + path.join(h.secondaryCwd, 'QWEN.md'), + 'x'.repeat(17 * 1024 * 1024), + 'utf8', + ); + + const res = await request(h.app) + .post(`/workspaces/${encodeURIComponent(h.secondaryId)}/memory`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace', mode: 'append', content: '- entry' }); + + expect(res.status).toBe(413); + expect(res.body.code).toBe('memory_file_too_large'); + expect(res.body.scope).toBe('workspace'); + expect(res.body.mode).toBe('append'); + expect(res.body.bytes).toBe(17 * 1024 * 1024); + expect(res.body.limit).toBe(16 * 1024 * 1024); + expect(res.body.filePath).toBeUndefined(); + expect(res.body.error).not.toContain(h.secondaryCwd); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/serve/workspace-route-runtime.ts b/packages/cli/src/serve/workspace-route-runtime.ts new file mode 100644 index 0000000000..93cf93cffc --- /dev/null +++ b/packages/cli/src/serve/workspace-route-runtime.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import type { Request, Response } from 'express'; +import { canonicalizeWorkspace } from './acp-session-bridge.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; + +export interface WorkspaceRouteContext { + readonly runtime: WorkspaceRuntime; + readonly routePrefix: string; +} + +export function isPortableAbsolutePath(value: string): boolean { + return ( + path.isAbsolute(value) || + /^[A-Za-z]:[\\/]/.test(value) || + /^\\\\[^\\]+\\[^\\]+/.test(value) + ); +} + +function isUncPath(value: string): boolean { + return /^\\\\[^\\]+\\[^\\]+/.test(value); +} + +function normalizePortableAbsolutePath(value: string): string { + if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)) { + return path.win32.normalize(value).toLowerCase(); + } + return path.resolve(value); +} + +export function resolveRegisteredWorkspaceRuntimeByPathSelector( + registry: WorkspaceRegistry, + selector: string, +): WorkspaceRuntime | undefined { + const exact = registry.getByWorkspaceCwd(selector); + if (exact) return exact; + + if (path.isAbsolute(selector) && !isUncPath(selector)) { + try { + const canonicalSelector = canonicalizeWorkspace(selector); + const canonicalMatch = registry.getByWorkspaceCwd(canonicalSelector); + if (canonicalMatch) return canonicalMatch; + for (const runtime of registry.list()) { + if (canonicalizeWorkspace(runtime.workspaceCwd) === canonicalSelector) { + return runtime; + } + } + } catch { + // Fall through to lexical matching; unresolved selectors still return + // workspace_mismatch without probing UNC/network paths. + } + } + + const normalizedSelector = normalizePortableAbsolutePath(selector); + return registry + .list() + .find( + (runtime) => + normalizePortableAbsolutePath(runtime.workspaceCwd) === + normalizedSelector, + ); +} + +export function resolveWorkspaceRuntimeFromParam( + registry: WorkspaceRegistry, + req: Request, + res: Response, + paramName = 'workspace', +): WorkspaceRuntime | null { + const selector = req.params[paramName] ?? ''; + const byId = registry.getByWorkspaceId(selector); + if (byId) return byId; + + if (!isPortableAbsolutePath(selector)) { + res.status(400).json({ + error: `\`:${paramName}\` must decode to a workspace id or absolute path`, + code: 'workspace_mismatch', + }); + return null; + } + + const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector( + registry, + selector, + ); + if (!runtime) { + sendWorkspaceMismatch(res, registry); + return null; + } + return runtime; +} + +export function requireTrustedWorkspaceRuntime( + runtime: WorkspaceRuntime, + res: Response, +): boolean { + if (runtime.trusted) return true; + sendUntrustedWorkspaceResponse(res); + return false; +} + +export function sendUntrustedWorkspaceResponse( + res: Response, + extra?: { sessionId?: string; workspaceCwd?: string; workspaceId?: string }, +): void { + res.status(403).json({ + error: 'Workspace is not trusted.', + code: 'untrusted_workspace', + ...extra, + }); +} + +export function getWorkspaceRouteContext( + req: Request, +): WorkspaceRouteContext | undefined { + return (req as { workspaceRouteContext?: WorkspaceRouteContext }) + .workspaceRouteContext; +} + +export function setWorkspaceRouteContext( + req: Request, + context: WorkspaceRouteContext, +): void { + ( + req as { workspaceRouteContext?: WorkspaceRouteContext } + ).workspaceRouteContext = context; +} + +export function sendWorkspaceMismatch( + res: Response, + registry: WorkspaceRegistry, +): void { + const runtimes = registry.list(); + res.status(400).json({ + error: + 'Workspace mismatch: the requested workspace is not registered with this daemon.', + code: 'workspace_mismatch', + workspaceCount: runtimes.length, + }); +} diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index c10cbf5494..520dea11c4 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -49,7 +49,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 138KB to 139KB for workspace ACP status/preheat APIs. // Bumped from 139KB to 141KB after merging main (ACP status/preheat) into // the history_truncated/transcript-status branch. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 141 * 1024; +// Bumped from 141KB to 150KB for WorkspaceDaemonClient's workspace-qualified +// core REST helpers, including Phase 3 file/status/settings/agents/session APIs. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 150 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index b63e650d34..b869dad3a7 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -671,6 +671,72 @@ export class DaemonClient { ); } + /** @internal */ + async workspaceJsonRequest( + workspaceSelector: string, + path: string, + label: string, + opts: { + method?: string; + body?: unknown; + clientId?: string; + timeoutMs?: number; + } = {}, + ): Promise { + return await this.jsonRequest( + `/workspaces/${workspaceSelector}${path}`, + label, + opts, + ); + } + + /** @internal */ + async workspaceNoContentRequest( + workspaceSelector: string, + path: string, + label: string, + opts: { + method?: string; + clientId?: string; + timeoutMs?: number; + okNotFoundCode?: string; + } = {}, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspaces/${workspaceSelector}${path}`, + { + ...(opts.method ? { method: opts.method } : {}), + headers: this.headers({}, opts.clientId), + }, + async (res) => { + if (res.status === 204) { + try { + await res.body?.cancel(); + } catch { + /* body already consumed or no body */ + } + return; + } + if (res.status === 404 && opts.okNotFoundCode) { + const err = await this.failOnError(res, label); + const body = err.body as { code?: unknown } | undefined; + if (body?.code === opts.okNotFoundCode) return; + throw err; + } + throw await this.failOnError(res, label); + }, + opts.timeoutMs, + ); + } + + workspaceById(workspaceId: string): WorkspaceDaemonClient { + return new WorkspaceDaemonClient(this, urlEncode(workspaceId)); + } + + workspaceByCwd(workspaceCwd: string): WorkspaceDaemonClient { + return new WorkspaceDaemonClient(this, urlEncode(workspaceCwd)); + } + // -- Lifecycle / discovery --------------------------------------------- async health(): Promise<{ status: string }> { @@ -3320,6 +3386,460 @@ export class DaemonClient { } } +export class WorkspaceDaemonClient { + constructor( + private readonly client: DaemonClient, + private readonly workspaceSelector: string, + ) {} + + workspaceMcp(): Promise { + return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); + } + + workspaceSkills(): Promise { + return this.get('/skills', 'GET /workspaces/:workspace/skills'); + } + + workspaceProviders(): Promise { + return this.get('/providers', 'GET /workspaces/:workspace/providers'); + } + + workspaceHooks(): Promise { + return this.get('/hooks', 'GET /workspaces/:workspace/hooks'); + } + + workspaceEnv(): Promise { + return this.get('/env', 'GET /workspaces/:workspace/env'); + } + + workspacePreflight(): Promise { + return this.get('/preflight', 'GET /workspaces/:workspace/preflight'); + } + + workspaceTools(): Promise { + return this.get('/tools', 'GET /workspaces/:workspace/tools'); + } + + workspaceMemory(): Promise { + return this.get('/memory', 'GET /workspaces/:workspace/memory'); + } + + writeWorkspaceMemory( + req: Omit & { scope?: 'workspace' }, + clientId?: string, + ): Promise { + return this.post( + '/memory', + 'POST /workspaces/:workspace/memory', + { ...req, scope: 'workspace' }, + clientId, + ); + } + + listWorkspaceAgents(): Promise { + return this.get('/agents', 'GET /workspaces/:workspace/agents'); + } + + createWorkspaceAgent( + req: Omit & { + scope?: 'workspace' | 'project'; + }, + clientId?: string, + ): Promise { + return this.post( + '/agents', + 'POST /workspaces/:workspace/agents', + { ...req, scope: req.scope ?? 'workspace' }, + clientId, + ); + } + + getWorkspaceAgent(agentType: string): Promise { + return this.get( + `/agents/${urlEncode(agentType)}`, + 'GET /workspaces/:workspace/agents/:agentType', + ); + } + + updateWorkspaceAgent( + agentType: string, + req: DaemonUpdateAgentRequest, + opts: { scope?: 'workspace' | 'project'; clientId?: string } = {}, + ): Promise { + const query = opts.scope ? `?scope=${urlEncode(opts.scope)}` : ''; + return this.post( + `/agents/${urlEncode(agentType)}${query}`, + 'POST /workspaces/:workspace/agents/:agentType', + req, + opts.clientId, + ); + } + + async deleteWorkspaceAgent( + agentType: string, + opts: { scope?: 'workspace' | 'project'; clientId?: string } = {}, + ): Promise { + const query = opts.scope ? `?scope=${urlEncode(opts.scope)}` : ''; + return await this.client.workspaceNoContentRequest( + this.workspaceSelector, + `/agents/${urlEncode(agentType)}${query}`, + 'DELETE /workspaces/:workspace/agents/:agentType', + { + method: 'DELETE', + clientId: opts.clientId, + okNotFoundCode: 'agent_not_found', + }, + ); + } + + listWorkspaceSessionsPage( + options?: DaemonSessionListPageOptions, + ): Promise { + const requestedPageSize = + options?.pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + const pageSize = Math.max( + 1, + Math.min( + 1000, + Math.round( + Number.isFinite(requestedPageSize) + ? requestedPageSize + : DEFAULT_SESSION_LIST_PAGE_SIZE, + ), + ), + ); + const query = new URLSearchParams({ size: String(pageSize) }); + if (options?.cursor !== undefined) query.set('cursor', options.cursor); + if (options?.archiveState !== undefined) { + query.set('archiveState', options.archiveState); + } + if (options?.view !== undefined) query.set('view', options.view); + if (options?.group !== undefined) query.set('group', options.group); + return this.get( + `/sessions?${query.toString()}`, + 'GET /workspaces/:workspace/sessions', + ); + } + + async listWorkspaceSessions( + options?: DaemonSessionListPageOptions, + ): Promise { + const page = await this.listWorkspaceSessionsPage(options); + return page.sessions; + } + + listSessionGroups(): Promise { + return this.get( + '/session-groups', + 'GET /workspaces/:workspace/session-groups', + ); + } + + async createSessionGroup( + input: DaemonSessionGroupInput, + ): Promise { + const body = await this.post<{ group: DaemonSessionGroup }>( + '/session-groups', + 'POST /workspaces/:workspace/session-groups', + input, + ); + return body.group; + } + + async updateSessionGroup( + groupId: string, + update: DaemonSessionGroupUpdate, + ): Promise { + const body = await this.client.workspaceJsonRequest<{ + group: DaemonSessionGroup; + }>( + this.workspaceSelector, + `/session-groups/${urlEncode(groupId)}`, + 'PATCH /workspaces/:workspace/session-groups/:groupId', + { method: 'PATCH', body: update }, + ); + return body.group; + } + + deleteSessionGroup(groupId: string): Promise<{ deleted: boolean }> { + return this.client.workspaceJsonRequest<{ deleted: boolean }>( + this.workspaceSelector, + `/session-groups/${urlEncode(groupId)}`, + 'DELETE /workspaces/:workspace/session-groups/:groupId', + { method: 'DELETE' }, + ); + } + + deleteSessionsData( + sessionIds: string[], + clientId?: string, + ): Promise<{ + removed: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: string }>; + }> { + return this.post( + '/sessions/delete', + 'POST /workspaces/:workspace/sessions/delete', + { sessionIds }, + clientId, + ); + } + + archiveSessionsData( + sessionIds: string[], + clientId?: string, + ): Promise { + return this.post( + '/sessions/archive', + 'POST /workspaces/:workspace/sessions/archive', + { sessionIds }, + clientId, + ); + } + + unarchiveSessionsData( + sessionIds: string[], + clientId?: string, + ): Promise { + return this.post( + '/sessions/unarchive', + 'POST /workspaces/:workspace/sessions/unarchive', + { sessionIds }, + clientId, + ); + } + + readWorkspaceFile( + filePath: string, + opts: { maxBytes?: number; line?: number; limit?: number } = {}, + clientId?: string, + ): Promise { + const query = new URLSearchParams({ path: filePath }); + if (opts.maxBytes !== undefined) + query.set('maxBytes', String(opts.maxBytes)); + if (opts.line !== undefined) query.set('line', String(opts.line)); + if (opts.limit !== undefined) query.set('limit', String(opts.limit)); + return this.get( + `/file?${query.toString()}`, + 'GET /workspaces/:workspace/file', + clientId, + ); + } + + readWorkspaceFileBytes( + filePath: string, + opts: { offset?: number; maxBytes?: number } = {}, + clientId?: string, + ): Promise { + const query = new URLSearchParams({ path: filePath }); + if (opts.offset !== undefined) query.set('offset', String(opts.offset)); + if (opts.maxBytes !== undefined) + query.set('maxBytes', String(opts.maxBytes)); + return this.get( + `/file/bytes?${query.toString()}`, + 'GET /workspaces/:workspace/file/bytes', + clientId, + ); + } + + fileStat(filePath: string): Promise { + const query = new URLSearchParams({ path: filePath }); + return this.get( + `/stat?${query.toString()}`, + 'GET /workspaces/:workspace/stat', + ); + } + + dirList(dirPath: string): Promise { + const query = new URLSearchParams({ path: dirPath }); + return this.get( + `/list?${query.toString()}`, + 'GET /workspaces/:workspace/list', + ); + } + + glob(pattern: string): Promise { + const query = new URLSearchParams({ pattern }); + return this.get( + `/glob?${query.toString()}`, + 'GET /workspaces/:workspace/glob', + ); + } + + writeWorkspaceFile( + req: DaemonWorkspaceFileWriteRequest, + clientId?: string, + ): Promise { + return this.post( + '/file/write', + 'POST /workspaces/:workspace/file/write', + req, + clientId, + ); + } + + editWorkspaceFile( + req: DaemonWorkspaceFileEditRequest, + clientId?: string, + ): Promise { + return this.post( + '/file/edit', + 'POST /workspaces/:workspace/file/edit', + req, + clientId, + ); + } + + workspaceSettings(opts?: { + clientId?: string; + }): Promise { + return this.get( + '/settings', + 'GET /workspaces/:workspace/settings', + opts?.clientId, + ); + } + + setWorkspaceSetting( + scope: 'workspace', + key: string, + value: unknown, + opts?: { clientId?: string }, + ): Promise { + return this.post( + '/settings', + 'POST /workspaces/:workspace/settings', + { scope, key, value }, + opts?.clientId, + ); + } + + workspaceTrust(opts?: { + clientId?: string; + }): Promise { + return this.get( + '/trust', + 'GET /workspaces/:workspace/trust', + opts?.clientId, + ); + } + + requestWorkspaceTrustChange( + request: DaemonWorkspaceTrustChangeRequest, + clientId?: string, + ): Promise { + return this.post( + '/trust/request', + 'POST /workspaces/:workspace/trust/request', + request, + clientId, + ); + } + + workspacePermissions(opts?: { + clientId?: string; + }): Promise { + return this.get( + '/permissions', + 'GET /workspaces/:workspace/permissions', + opts?.clientId, + ); + } + + setWorkspacePermissionRules( + ruleType: DaemonPermissionRuleType, + rules: readonly string[], + opts?: { clientId?: string }, + ): Promise { + return this.post( + '/permissions', + 'POST /workspaces/:workspace/permissions', + { scope: 'workspace', ruleType, rules: [...rules] }, + opts?.clientId, + ); + } + + setWorkspaceToolEnabled( + toolName: string, + enabled: boolean, + opts?: { clientId?: string }, + ): Promise { + return this.post( + `/tools/${urlEncode(toolName)}/enable`, + 'POST /workspaces/:workspace/tools/:name/enable', + { enabled }, + opts?.clientId, + ); + } + + restartMcpServer( + serverName: string, + opts?: { clientId?: string; entryIndex?: number | '*'; timeoutMs?: number }, + ): Promise { + const query = + opts?.entryIndex === undefined + ? '' + : `?entryIndex=${urlEncode(String(opts.entryIndex))}`; + return this.post( + `/mcp/${urlEncode(serverName)}/restart${query}`, + 'POST /workspaces/:workspace/mcp/:server/restart', + {}, + opts?.clientId, + opts?.timeoutMs ?? MCP_RESTART_DEFAULT_TIMEOUT_MS, + ); + } + + reload(opts?: { + clientId?: string; + timeoutMs?: number; + }): Promise { + return this.post( + '/reload', + 'POST /workspaces/:workspace/reload', + {}, + opts?.clientId, + opts?.timeoutMs, + ); + } + + initWorkspace(opts?: { + force?: boolean; + clientId?: string; + }): Promise { + return this.post( + '/init', + 'POST /workspaces/:workspace/init', + opts?.force === true ? { force: true } : {}, + opts?.clientId, + ); + } + + private get(path: string, label: string, clientId?: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + path, + label, + { clientId }, + ); + } + + private post( + path: string, + label: string, + body: unknown, + clientId?: string, + timeoutMs?: number, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + path, + label, + { method: 'POST', body, clientId, timeoutMs }, + ); + } +} + /** * `AbortSignal.timeout` is in every Node version this package supports * (`engines.node >=22.0.0` ships it natively). The feature-detect below diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 36284dcee4..ed0bf0208f 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -8,6 +8,7 @@ export { DaemonClient, DaemonHttpError, DaemonPendingPromptLimitError, + WorkspaceDaemonClient, isDaemonTurnError, isNonBlockingAccepted, matchTurnEvent, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 504e2b0c99..de441211ca 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -15,6 +15,7 @@ export { DaemonClient, DaemonHttpError, DaemonPendingPromptLimitError, + WorkspaceDaemonClient, DaemonSessionClient, asKnownDaemonEvent, createDaemonSessionViewState, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 3d9ff3a819..2b94529345 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -4306,6 +4306,178 @@ describe('DaemonClient', () => { ); } }); + + it('workspaceById and workspaceByCwd call workspace-qualified agents routes', async () => { + const list = { + v: 1, + workspaceCwd: '/work/a', + agents: [], + }; + const detail = { + kind: 'agent' as const, + name: 'reviewer', + description: 'reviews code', + level: 'project' as const, + isBuiltin: false, + hasTools: false, + systemPrompt: 'you are a reviewer', + }; + const mutation = { ok: true, agent: detail }; + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'DELETE') return new Response(null, { status: 204 }); + if (req.url.includes('/agents/reviewer')) { + return req.method === 'GET' + ? jsonResponse(200, detail) + : jsonResponse(200, mutation); + } + if (req.url.endsWith('/agents')) { + return req.method === 'POST' + ? jsonResponse(201, mutation) + : jsonResponse(200, list); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const byId = client.workspaceById('workspace/id'); + const byCwd = client.workspaceByCwd('/tmp/work space'); + + await expect(byId.listWorkspaceAgents()).resolves.toEqual(list); + await expect( + byCwd.createWorkspaceAgent( + { + name: 'reviewer', + description: 'reviews code', + systemPrompt: 'you are a reviewer', + }, + 'client-1', + ), + ).resolves.toEqual(mutation); + await expect(byId.getWorkspaceAgent('reviewer')).resolves.toEqual(detail); + await expect( + byId.updateWorkspaceAgent( + 'reviewer', + { description: 'new description' }, + { scope: 'project', clientId: 'client-2' }, + ), + ).resolves.toEqual(mutation); + await expect( + byId.deleteWorkspaceAgent('reviewer', { + scope: 'workspace', + clientId: 'client-3', + }), + ).resolves.toBeUndefined(); + + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/workspaces/workspace%2Fid/agents'], + ['POST', 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/agents'], + ['GET', 'http://daemon/workspaces/workspace%2Fid/agents/reviewer'], + [ + 'POST', + 'http://daemon/workspaces/workspace%2Fid/agents/reviewer?scope=project', + ], + [ + 'DELETE', + 'http://daemon/workspaces/workspace%2Fid/agents/reviewer?scope=workspace', + ], + ]); + expect(JSON.parse(calls[1]!.body!)).toMatchObject({ + name: 'reviewer', + scope: 'workspace', + }); + expect(calls[1]?.headers['x-qwen-client-id']).toBe('client-1'); + expect(calls[3]?.headers['x-qwen-client-id']).toBe('client-2'); + expect(calls[4]?.headers['x-qwen-client-id']).toBe('client-3'); + }); + + it('workspace-qualified deleteWorkspaceAgent preserves idempotent structured 404 handling', async () => { + { + const { fetch } = recordingFetch(() => + jsonResponse(404, { error: 'not found', code: 'agent_not_found' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.workspaceById('workspace-id').deleteWorkspaceAgent('x'), + ).resolves.toBeUndefined(); + } + { + const { fetch } = recordingFetch( + () => + new Response('Not Found', { + status: 404, + headers: { 'content-type': 'text/plain' }, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.workspaceById('workspace-id').deleteWorkspaceAgent('x'), + ).rejects.toBeInstanceOf(DaemonHttpError); + } + }); + + it('workspaceById destructive session helpers use workspace-qualified routes', async () => { + const replies: Record = { + '/sessions/delete': { removed: ['s-1'], notFound: [], errors: [] }, + '/sessions/archive': { + archived: ['s-1'], + alreadyArchived: [], + notFound: [], + errors: [], + }, + '/sessions/unarchive': { + unarchived: ['s-1'], + alreadyActive: [], + notFound: [], + errors: [], + }, + }; + const { fetch, calls } = recordingFetch((req) => { + const url = new URL(req.url); + const suffix = url.pathname.replace('/workspaces/workspace-id', ''); + return jsonResponse(200, replies[suffix] ?? { unexpected: suffix }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const workspace = client.workspaceById('workspace-id'); + + await expect( + workspace.deleteSessionsData(['s-1'], 'client-1'), + ).resolves.toEqual(replies['/sessions/delete']); + await expect( + workspace.archiveSessionsData(['s-1'], 'client-2'), + ).resolves.toEqual(replies['/sessions/archive']); + await expect( + workspace.unarchiveSessionsData(['s-1'], 'client-3'), + ).resolves.toEqual(replies['/sessions/unarchive']); + + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['POST', 'http://daemon/workspaces/workspace-id/sessions/delete'], + ['POST', 'http://daemon/workspaces/workspace-id/sessions/archive'], + ['POST', 'http://daemon/workspaces/workspace-id/sessions/unarchive'], + ]); + expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ + 'client-1', + 'client-2', + 'client-3', + ]); + for (const call of calls) { + expect(JSON.parse(call.body!)).toEqual({ sessionIds: ['s-1'] }); + } + }); + + it('workspaceByCwd deleteSessionGroup uses workspace-qualified group route', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { deleted: true }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.workspaceByCwd('/tmp/work space').deleteSessionGroup('group/1'), + ).resolves.toEqual({ deleted: true }); + + expect(calls[0]?.method).toBe('DELETE'); + expect(calls[0]?.url).toBe( + 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/session-groups/group%2F1', + ); + }); }); describe('addRuntimeMcpServer (T2.8 #4514)', () => {