feat(cli): Add workspace-qualified core REST routes (#6567)

* feat(cli): Add workspace-qualified core REST routes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): Preserve encoded workspace cwd selectors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: fix CI failure on PR #6567

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: fix CI failure on PR #6567

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-07-09 23:01:55 +08:00 committed by GitHub
parent c412d62981
commit f5d36aa5f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 4451 additions and 139 deletions

View file

@ -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,

View file

@ -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: [

View file

@ -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;
}

View file

@ -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;

View file

@ -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 () => {

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
});
}

View file

@ -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<void> {
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<void> {
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);
},
);
}

View file

@ -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 });
}
},
);
}

View file

@ -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<string, unknown>,
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 });
}
},
);
}

View file

@ -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<WorkspacePermissionsRouteDeps, 'mutate' | 'safeBody'> & {
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',
});
}
},
);
}

View file

@ -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,
});
},
);
}

View file

@ -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<RegisterWorkspaceStatusRoutesDeps, 'sendBridgeError'> & {
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<RegisterWorkspaceStatusRoutesDeps, 'sendBridgeError'> & {
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 });
}
});
}
}

View file

@ -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 });
}
},
);
}

View file

@ -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<WorkspaceTrustRouteDeps, 'mutate' | 'safeBody'> & {
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);
}
},
);
}

View file

@ -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);
});

View file

@ -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

View file

@ -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);

View file

@ -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' };
}

View file

@ -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<string, unknown>;
}
export interface WorkspaceQualifiedAgentsRouteDeps {
workspaceRegistry: WorkspaceRegistry;
mutate: (opts?: { strict?: boolean }) => RequestHandler;
parseClientId: (req: Request, res: Response) => string | undefined | null;
safeBody: (req: Request) => Record<string, unknown>;
}
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<string, unknown>,
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<string, unknown>,
level: SubagentLevel,

View file

@ -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/<x>/.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<WorkspaceMemoryRouteDeps, 'bridge' | 'boundWorkspace'> & {
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,
});
}
},

File diff suppressed because it is too large Load diff

View file

@ -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,
});
}

View file

@ -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

View file

@ -671,6 +671,72 @@ export class DaemonClient {
);
}
/** @internal */
async workspaceJsonRequest<T>(
workspaceSelector: string,
path: string,
label: string,
opts: {
method?: string;
body?: unknown;
clientId?: string;
timeoutMs?: number;
} = {},
): Promise<T> {
return await this.jsonRequest<T>(
`/workspaces/${workspaceSelector}${path}`,
label,
opts,
);
}
/** @internal */
async workspaceNoContentRequest(
workspaceSelector: string,
path: string,
label: string,
opts: {
method?: string;
clientId?: string;
timeoutMs?: number;
okNotFoundCode?: string;
} = {},
): Promise<void> {
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<DaemonWorkspaceMcpStatus> {
return this.get('/mcp', 'GET /workspaces/:workspace/mcp');
}
workspaceSkills(): Promise<DaemonWorkspaceSkillsStatus> {
return this.get('/skills', 'GET /workspaces/:workspace/skills');
}
workspaceProviders(): Promise<DaemonWorkspaceProvidersStatus> {
return this.get('/providers', 'GET /workspaces/:workspace/providers');
}
workspaceHooks(): Promise<DaemonWorkspaceHooksStatus> {
return this.get('/hooks', 'GET /workspaces/:workspace/hooks');
}
workspaceEnv(): Promise<DaemonWorkspaceEnvStatus> {
return this.get('/env', 'GET /workspaces/:workspace/env');
}
workspacePreflight(): Promise<DaemonWorkspacePreflightStatus> {
return this.get('/preflight', 'GET /workspaces/:workspace/preflight');
}
workspaceTools(): Promise<DaemonWorkspaceToolsStatus> {
return this.get('/tools', 'GET /workspaces/:workspace/tools');
}
workspaceMemory(): Promise<DaemonWorkspaceMemoryStatus> {
return this.get('/memory', 'GET /workspaces/:workspace/memory');
}
writeWorkspaceMemory(
req: Omit<DaemonWriteMemoryRequest, 'scope'> & { scope?: 'workspace' },
clientId?: string,
): Promise<DaemonWriteMemoryResult> {
return this.post(
'/memory',
'POST /workspaces/:workspace/memory',
{ ...req, scope: 'workspace' },
clientId,
);
}
listWorkspaceAgents(): Promise<DaemonWorkspaceAgentsStatus> {
return this.get('/agents', 'GET /workspaces/:workspace/agents');
}
createWorkspaceAgent(
req: Omit<DaemonCreateAgentRequest, 'scope'> & {
scope?: 'workspace' | 'project';
},
clientId?: string,
): Promise<DaemonAgentMutationResult> {
return this.post(
'/agents',
'POST /workspaces/:workspace/agents',
{ ...req, scope: req.scope ?? 'workspace' },
clientId,
);
}
getWorkspaceAgent(agentType: string): Promise<DaemonWorkspaceAgentDetail> {
return this.get(
`/agents/${urlEncode(agentType)}`,
'GET /workspaces/:workspace/agents/:agentType',
);
}
updateWorkspaceAgent(
agentType: string,
req: DaemonUpdateAgentRequest,
opts: { scope?: 'workspace' | 'project'; clientId?: string } = {},
): Promise<DaemonAgentMutationResult> {
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<void> {
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<DaemonSessionListPage> {
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<DaemonSessionSummary[]> {
const page = await this.listWorkspaceSessionsPage(options);
return page.sessions;
}
listSessionGroups(): Promise<DaemonSessionGroupCatalog> {
return this.get(
'/session-groups',
'GET /workspaces/:workspace/session-groups',
);
}
async createSessionGroup(
input: DaemonSessionGroupInput,
): Promise<DaemonSessionGroup> {
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<DaemonSessionGroup> {
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<DaemonArchiveSessionsResult> {
return this.post(
'/sessions/archive',
'POST /workspaces/:workspace/sessions/archive',
{ sessionIds },
clientId,
);
}
unarchiveSessionsData(
sessionIds: string[],
clientId?: string,
): Promise<DaemonUnarchiveSessionsResult> {
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<DaemonWorkspaceFile> {
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<DaemonWorkspaceFileBytes> {
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<unknown> {
const query = new URLSearchParams({ path: filePath });
return this.get(
`/stat?${query.toString()}`,
'GET /workspaces/:workspace/stat',
);
}
dirList(dirPath: string): Promise<unknown> {
const query = new URLSearchParams({ path: dirPath });
return this.get(
`/list?${query.toString()}`,
'GET /workspaces/:workspace/list',
);
}
glob(pattern: string): Promise<unknown> {
const query = new URLSearchParams({ pattern });
return this.get(
`/glob?${query.toString()}`,
'GET /workspaces/:workspace/glob',
);
}
writeWorkspaceFile(
req: DaemonWorkspaceFileWriteRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileWriteResult> {
return this.post(
'/file/write',
'POST /workspaces/:workspace/file/write',
req,
clientId,
);
}
editWorkspaceFile(
req: DaemonWorkspaceFileEditRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileEditResult> {
return this.post(
'/file/edit',
'POST /workspaces/:workspace/file/edit',
req,
clientId,
);
}
workspaceSettings(opts?: {
clientId?: string;
}): Promise<DaemonWorkspaceSettingsStatus> {
return this.get(
'/settings',
'GET /workspaces/:workspace/settings',
opts?.clientId,
);
}
setWorkspaceSetting(
scope: 'workspace',
key: string,
value: unknown,
opts?: { clientId?: string },
): Promise<DaemonSettingUpdateResult> {
return this.post(
'/settings',
'POST /workspaces/:workspace/settings',
{ scope, key, value },
opts?.clientId,
);
}
workspaceTrust(opts?: {
clientId?: string;
}): Promise<DaemonWorkspaceTrustStatus> {
return this.get(
'/trust',
'GET /workspaces/:workspace/trust',
opts?.clientId,
);
}
requestWorkspaceTrustChange(
request: DaemonWorkspaceTrustChangeRequest,
clientId?: string,
): Promise<DaemonWorkspaceTrustChangeResult> {
return this.post(
'/trust/request',
'POST /workspaces/:workspace/trust/request',
request,
clientId,
);
}
workspacePermissions(opts?: {
clientId?: string;
}): Promise<DaemonWorkspacePermissionsStatus> {
return this.get(
'/permissions',
'GET /workspaces/:workspace/permissions',
opts?.clientId,
);
}
setWorkspacePermissionRules(
ruleType: DaemonPermissionRuleType,
rules: readonly string[],
opts?: { clientId?: string },
): Promise<DaemonWorkspacePermissionsStatus> {
return this.post(
'/permissions',
'POST /workspaces/:workspace/permissions',
{ scope: 'workspace', ruleType, rules: [...rules] },
opts?.clientId,
);
}
setWorkspaceToolEnabled(
toolName: string,
enabled: boolean,
opts?: { clientId?: string },
): Promise<DaemonToolToggleResult> {
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<DaemonMcpRestartResult> {
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<DaemonReloadResponse> {
return this.post(
'/reload',
'POST /workspaces/:workspace/reload',
{},
opts?.clientId,
opts?.timeoutMs,
);
}
initWorkspace(opts?: {
force?: boolean;
clientId?: string;
}): Promise<DaemonInitWorkspaceResult> {
return this.post(
'/init',
'POST /workspaces/:workspace/init',
opts?.force === true ? { force: true } : {},
opts?.clientId,
);
}
private get<T>(path: string, label: string, clientId?: string): Promise<T> {
return this.client.workspaceJsonRequest<T>(
this.workspaceSelector,
path,
label,
{ clientId },
);
}
private post<T>(
path: string,
label: string,
body: unknown,
clientId?: string,
timeoutMs?: number,
): Promise<T> {
return this.client.workspaceJsonRequest<T>(
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

View file

@ -8,6 +8,7 @@ export {
DaemonClient,
DaemonHttpError,
DaemonPendingPromptLimitError,
WorkspaceDaemonClient,
isDaemonTurnError,
isNonBlockingAccepted,
matchTurnEvent,

View file

@ -15,6 +15,7 @@ export {
DaemonClient,
DaemonHttpError,
DaemonPendingPromptLimitError,
WorkspaceDaemonClient,
DaemonSessionClient,
asKnownDaemonEvent,
createDaemonSessionViewState,

View file

@ -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<string, unknown> = {
'/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)', () => {