mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
c412d62981
commit
f5d36aa5f1
29 changed files with 4451 additions and 139 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
1079
packages/cli/src/serve/workspace-qualified-rest.test.ts
Normal file
1079
packages/cli/src/serve/workspace-qualified-rest.test.ts
Normal file
File diff suppressed because it is too large
Load diff
148
packages/cli/src/serve/workspace-route-runtime.ts
Normal file
148
packages/cli/src/serve/workspace-route-runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export {
|
|||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
DaemonPendingPromptLimitError,
|
||||
WorkspaceDaemonClient,
|
||||
isDaemonTurnError,
|
||||
isNonBlockingAccepted,
|
||||
matchTurnEvent,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
DaemonPendingPromptLimitError,
|
||||
WorkspaceDaemonClient,
|
||||
DaemonSessionClient,
|
||||
asKnownDaemonEvent,
|
||||
createDaemonSessionViewState,
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue