Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'non_blocking_prompt', 'session_language', 'session_rewind',
'workspace_hooks', 'session_hooks', 'workspace_extensions',
'session_branch', 'rate_limit', 'workspace_reload',
'multi_workspace_sessions', 'workspace_qualified_rest_core',
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
```

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

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

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

`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.

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

When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, and `/workspaces/:workspace/file/edit`.

The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope.

`daemon_status` advertises `GET /daemon/status`, the consolidated read-only
operator diagnostic snapshot documented below.

Expand Down Expand Up @@ -1333,14 +1340,16 @@ Use `/load` when the client has no history rendered (cold reconnect, picker →

### `GET /workspace/:id/sessions` and `GET /workspaces/:workspace/sessions`

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

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

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

Query parameters:

| Field | Required | Notes |
Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_extensions',
'session_branch',
'workspace_reload',
'workspace_qualified_rest_core',
'voice_transcribe',
]);
});
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Comment thread
doudouOUC marked this conversation as resolved.
// 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,
Expand Down
163 changes: 161 additions & 2 deletions packages/cli/src/serve/multi-workspace-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ function makeDaemonLog(): DaemonLogger {
}

function makeHarness(opts?: {
primaryTrusted?: boolean;
secondaryTrusted?: boolean;
secondaryChannelLive?: boolean;
daemonLog?: DaemonLogger;
Expand All @@ -370,7 +371,7 @@ function makeHarness(opts?: {
workspaceId: 'primary-id',
workspaceCwd: PRIMARY_CWD,
primary: true,
trusted: true,
trusted: opts?.primaryTrusted ?? true,
bridge: primaryBridge,
}),
makeRuntime({
Expand Down Expand Up @@ -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([]);

Expand All @@ -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',
Expand All @@ -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([]);
});

Expand Down Expand Up @@ -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([]);

Expand All @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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: [
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/serve/routes/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading