Skip to content

Commit f5d36aa

Browse files
doudouOUCqwencoder
andauthored
feat(cli): Add workspace-qualified core REST routes (#6567)
* feat(cli): Add workspace-qualified core REST routes Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): Preserve encoded workspace cwd selectors Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: fix CI failure on PR #6567 Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> * codex: fix CI failure on PR #6567 Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
1 parent c412d62 commit f5d36aa

29 files changed

Lines changed: 4447 additions & 135 deletions

docs/developers/qwen-serve-protocol.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
187187
'non_blocking_prompt', 'session_language', 'session_rewind',
188188
'workspace_hooks', 'session_hooks', 'workspace_extensions',
189189
'session_branch', 'rate_limit', 'workspace_reload',
190+
'multi_workspace_sessions', 'workspace_qualified_rest_core',
190191
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
191192
```
192193

@@ -208,6 +209,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
208209

209210
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.
210211

212+
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. File write routes and all other plural core routes require a trusted workspace and return `403 { code: "untrusted_workspace" }` when the selected runtime is untrusted. This plural trust gate is intentionally stricter than some legacy primary-workspace read routes, which keep their existing compatibility behavior and are not drop-in replacements. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool toggle, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing.
213+
211214
`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.
212215

213216
`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
@@ -227,6 +230,10 @@ The write tag means the route contract exists; it does not mean the current
227230
deployment is open for anonymous mutation. Write/edit are strict mutation
228231
routes and require a configured bearer token even on loopback.
229232

233+
When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, and `/workspaces/:workspace/file/edit`.
234+
235+
The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope.
236+
230237
`daemon_status` advertises `GET /daemon/status`, the consolidated read-only
231238
operator diagnostic snapshot documented below.
232239

@@ -1333,14 +1340,16 @@ Use `/load` when the client has no history rendered (cold reconnect, picker →
13331340
13341341
### `GET /workspace/:id/sessions` and `GET /workspaces/:workspace/sessions`
13351342

1336-
List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` is a plural alias with the same response shape. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted non-primary workspaces return `403 { code: "untrusted_workspace" }`. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor.
1343+
List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` has the same response shape but follows the plural core trust gate. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted workspaces on plural routes return `403 { code: "untrusted_workspace" }`; legacy primary routes keep their existing compatibility behavior. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor.
13371344

13381345
```bash
13391346
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions
13401347
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions?archiveState=archived
13411348
curl http://127.0.0.1:4170/workspaces/<workspace-id>/sessions
13421349
```
13431350

1351+
When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations and group CRUD are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}` and `/workspaces/:workspace/session-groups`. Workspace-less batch routes remain primary-workspace-only for compatibility.
1352+
13441353
Query parameters:
13451354

13461355
| Field | Required | Notes |

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ describe('qwen serve — capabilities envelope', () => {
311311
'workspace_extensions',
312312
'session_branch',
313313
'workspace_reload',
314+
'workspace_qualified_rest_core',
314315
'voice_transcribe',
315316
]);
316317
});

packages/cli/src/serve/capabilities.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ export const SERVE_CAPABILITY_REGISTRY = {
269269
// Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised
270270
// only when one daemon hosts more than one registered workspace runtime.
271271
multi_workspace_sessions: { since: 'v1' },
272+
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
273+
// Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory,
274+
// workspace agent CRUD, and persisted session organization surfaces.
275+
// Workspace-qualified settings also require the existing
276+
// `workspace_settings` tag because that surface depends on settings
277+
// persistence. ACP/WebSocket, auth, voice, and extensions stay on their
278+
// existing primary-workspace routes in this phase.
279+
workspace_qualified_rest_core: { since: 'v1' },
272280
// Phase 2 "reverse tool channel" (issue #5626). A connected WS client (e.g.
273281
// the Chrome extension) can host an MCP server that the daemon's agent
274282
// calls by carrying `mcp_message` JSON-RPC frames over the daemon WS,

packages/cli/src/serve/multi-workspace-sessions.test.ts

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ function makeDaemonLog(): DaemonLogger {
348348
}
349349

350350
function makeHarness(opts?: {
351+
primaryTrusted?: boolean;
351352
secondaryTrusted?: boolean;
352353
secondaryChannelLive?: boolean;
353354
daemonLog?: DaemonLogger;
@@ -370,7 +371,7 @@ function makeHarness(opts?: {
370371
workspaceId: 'primary-id',
371372
workspaceCwd: PRIMARY_CWD,
372373
primary: true,
373-
trusted: true,
374+
trusted: opts?.primaryTrusted ?? true,
374375
bridge: primaryBridge,
375376
}),
376377
makeRuntime({
@@ -488,6 +489,8 @@ describe('multi-workspace session dispatch', () => {
488489
expect(unknownRes.status).toBe(400);
489490
expect(unknownRes.body.code).toBe('workspace_mismatch');
490491
expect(unknownRes.body.workspaceCount).toBe(2);
492+
expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD);
493+
expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD);
491494
expect(unknown.primaryBridge.spawnCalls).toEqual([]);
492495
expect(unknown.secondaryBridge.spawnCalls).toEqual([]);
493496

@@ -500,6 +503,9 @@ describe('multi-workspace session dispatch', () => {
500503

501504
expect(untrustedRes.status).toBe(403);
502505
expect(untrustedRes.body.code).toBe('untrusted_workspace');
506+
expect(untrustedRes.body.error).toBe('Workspace is not trusted.');
507+
expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD);
508+
expect(untrustedRes.body.workspaceId).toBe('secondary-id');
503509
expect(untrusted.secondaryBridge.spawnCalls).toEqual([]);
504510
expect(daemonLog.warn).toHaveBeenCalledWith(
505511
'session routing failed',
@@ -520,7 +526,10 @@ describe('multi-workspace session dispatch', () => {
520526

521527
expect(res.status).toBe(403);
522528
expect(res.body.code).toBe('untrusted_workspace');
529+
expect(res.body.error).toBe('Workspace is not trusted.');
530+
expect(res.body.sessionId).toBe('secondary-session');
523531
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
532+
expect(res.body.workspaceId).toBe('secondary-id');
524533
expect(secondaryBridge.promptCalls).toEqual([]);
525534
});
526535

@@ -720,6 +729,8 @@ describe('multi-workspace session dispatch', () => {
720729
expect(unknownRes.status).toBe(400);
721730
expect(unknownRes.body.code).toBe('workspace_mismatch');
722731
expect(unknownRes.body.workspaceCount).toBe(2);
732+
expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD);
733+
expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD);
723734
expect(unknown.primaryBridge.restoreCalls).toEqual([]);
724735
expect(unknown.secondaryBridge.restoreCalls).toEqual([]);
725736

@@ -732,6 +743,9 @@ describe('multi-workspace session dispatch', () => {
732743

733744
expect(untrustedRes.status).toBe(403);
734745
expect(untrustedRes.body.code).toBe('untrusted_workspace');
746+
expect(untrustedRes.body.error).toBe('Workspace is not trusted.');
747+
expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD);
748+
expect(untrustedRes.body.workspaceId).toBe('secondary-id');
735749
expect(untrusted.primaryBridge.restoreCalls).toEqual([]);
736750
expect(untrusted.secondaryBridge.restoreCalls).toEqual([]);
737751
expect(daemonLog.warn).toHaveBeenCalledWith(
@@ -847,6 +861,8 @@ describe('multi-workspace session dispatch', () => {
847861
expect(unknown.status).toBe(400);
848862
expect(unknown.body.code).toBe('workspace_mismatch');
849863
expect(unknown.body.workspaceCount).toBe(2);
864+
expect(unknown.body.boundWorkspace).toBe(PRIMARY_CWD);
865+
expect(unknown.body.requestedWorkspace).toBe(UNKNOWN_CWD);
850866
});
851867

852868
it('lists active persisted non-primary sessions by encoded workspace cwd', async () => {
@@ -989,7 +1005,9 @@ describe('multi-workspace session dispatch', () => {
9891005

9901006
expect(res.status).toBe(403);
9911007
expect(res.body.code).toBe('untrusted_workspace');
992-
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
1008+
expect(res.body.error).toBe('Workspace is not trusted.');
1009+
expect(res.body).not.toHaveProperty('workspaceCwd');
1010+
expect(res.body).not.toHaveProperty('workspaceId');
9931011
expect(secondaryBridge.listCalls).toEqual([]);
9941012
expect(daemonLog.warn).toHaveBeenCalledWith(
9951013
'session routing failed',
@@ -1001,6 +1019,147 @@ describe('multi-workspace session dispatch', () => {
10011019
);
10021020
});
10031021

1022+
it('rejects untrusted primary workspace on plural session routes', async () => {
1023+
const daemonLog = makeDaemonLog();
1024+
const { app } = makeHarness({
1025+
primaryTrusted: false,
1026+
daemonLog,
1027+
});
1028+
1029+
const res = await request(app)
1030+
.get('/workspaces/primary-id/session-groups')
1031+
.set('Host', host());
1032+
1033+
expect(res.status).toBe(403);
1034+
expect(res.body.code).toBe('untrusted_workspace');
1035+
expect(res.body.error).toBe('Workspace is not trusted.');
1036+
expect(res.body).not.toHaveProperty('workspaceCwd');
1037+
expect(res.body).not.toHaveProperty('workspaceId');
1038+
expect(daemonLog.warn).toHaveBeenCalledWith(
1039+
'session routing failed',
1040+
expect.objectContaining({
1041+
route: 'GET /workspaces/:workspace/session-groups',
1042+
resolutionKind: 'untrusted_workspace',
1043+
workspaceCwd: PRIMARY_CWD,
1044+
}),
1045+
);
1046+
});
1047+
1048+
it('routes plural batch archive, unarchive, and delete to the selected workspace', async () => {
1049+
await withRuntimeDir(async () => {
1050+
const archiveId = '550e8400-e29b-41d4-a716-446655440120';
1051+
const deleteId = '550e8400-e29b-41d4-a716-446655440121';
1052+
await writeStoredSession({
1053+
sessionId: archiveId,
1054+
cwd: SECONDARY_CWD,
1055+
timestamp: '2026-07-08T00:10:00.000Z',
1056+
prompt: 'secondary archive target',
1057+
mtime: new Date('2026-07-08T00:10:00.000Z'),
1058+
});
1059+
await writeStoredSession({
1060+
sessionId: deleteId,
1061+
cwd: SECONDARY_CWD,
1062+
timestamp: '2026-07-08T00:11:00.000Z',
1063+
prompt: 'secondary delete target',
1064+
mtime: new Date('2026-07-08T00:11:00.000Z'),
1065+
});
1066+
const { app, primaryBridge, secondaryBridge } = makeHarness({
1067+
secondarySummaries: [],
1068+
});
1069+
1070+
const archived = await request(app)
1071+
.post('/workspaces/secondary-id/sessions/archive')
1072+
.set('Host', host())
1073+
.send({ sessionIds: [archiveId] })
1074+
.expect(200);
1075+
expect(archived.body).toMatchObject({
1076+
archived: [archiveId],
1077+
alreadyArchived: [],
1078+
notFound: [],
1079+
errors: [],
1080+
});
1081+
1082+
const unarchived = await request(app)
1083+
.post('/workspaces/secondary-id/sessions/unarchive')
1084+
.set('Host', host())
1085+
.send({ sessionIds: [archiveId] })
1086+
.expect(200);
1087+
expect(unarchived.body).toMatchObject({
1088+
unarchived: [archiveId],
1089+
alreadyActive: [],
1090+
notFound: [],
1091+
errors: [],
1092+
});
1093+
1094+
const deleted = await request(app)
1095+
.post('/workspaces/secondary-id/sessions/delete')
1096+
.set('Host', host())
1097+
.send({ sessionIds: [deleteId] })
1098+
.expect(200);
1099+
expect(deleted.body).toMatchObject({
1100+
removed: [deleteId],
1101+
notFound: [],
1102+
errors: [],
1103+
});
1104+
expect(primaryBridge.closeCalls).toEqual([]);
1105+
expect(secondaryBridge.closeCalls).toEqual([archiveId, deleteId]);
1106+
});
1107+
});
1108+
1109+
it('routes plural session group CRUD to the selected workspace', async () => {
1110+
await withRuntimeDir(async () => {
1111+
const { app } = makeHarness();
1112+
1113+
const created = await request(app)
1114+
.post('/workspaces/secondary-id/session-groups')
1115+
.set('Host', host())
1116+
.send({ name: 'Secondary Group', color: 'blue' })
1117+
.expect(201);
1118+
expect(created.body.group).toMatchObject({
1119+
name: 'Secondary Group',
1120+
color: 'blue',
1121+
});
1122+
const groupId = created.body.group.id as string;
1123+
1124+
const secondaryList = await request(app)
1125+
.get('/workspaces/secondary-id/session-groups')
1126+
.set('Host', host())
1127+
.expect(200);
1128+
expect(
1129+
(secondaryList.body.groups as Array<{ id: string }>).map(
1130+
(group) => group.id,
1131+
),
1132+
).toContain(groupId);
1133+
1134+
const primaryList = await request(app)
1135+
.get('/workspaces/primary-id/session-groups')
1136+
.set('Host', host())
1137+
.expect(200);
1138+
expect(
1139+
(primaryList.body.groups as Array<{ id: string }>).map(
1140+
(group) => group.id,
1141+
),
1142+
).not.toContain(groupId);
1143+
1144+
const updated = await request(app)
1145+
.patch(`/workspaces/secondary-id/session-groups/${groupId}`)
1146+
.set('Host', host())
1147+
.send({ name: 'Secondary Renamed', order: 10 })
1148+
.expect(200);
1149+
expect(updated.body.group).toMatchObject({
1150+
id: groupId,
1151+
name: 'Secondary Renamed',
1152+
order: 10,
1153+
});
1154+
1155+
const deleted = await request(app)
1156+
.delete(`/workspaces/secondary-id/session-groups/${groupId}`)
1157+
.set('Host', host())
1158+
.expect(200);
1159+
expect(deleted.body).toEqual({ deleted: true });
1160+
});
1161+
});
1162+
10041163
it('pages live non-primary workspace sessions with a stable cursor', async () => {
10051164
const { app } = makeHarness({
10061165
secondarySummaries: [

packages/cli/src/serve/routes/session-runtime.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
WorkspaceRegistry,
1111
WorkspaceRuntime,
1212
} from '../workspace-registry.js';
13+
import { sendUntrustedWorkspaceResponse } from '../workspace-route-runtime.js';
1314

1415
export function requireSessionRuntime(opts: {
1516
sessionId: string;
@@ -43,12 +44,10 @@ export function requireSessionRuntime(opts: {
4344
workspaceCwd: runtime.workspaceCwd,
4445
...details,
4546
});
46-
res.status(403).json({
47-
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
48-
code: 'untrusted_workspace',
47+
sendUntrustedWorkspaceResponse(res, {
4948
sessionId,
50-
workspaceId: runtime.workspaceId,
5149
workspaceCwd: runtime.workspaceCwd,
50+
workspaceId: runtime.workspaceId,
5251
});
5352
return undefined;
5453
}

0 commit comments

Comments
 (0)