Skip to content
38 changes: 38 additions & 0 deletions docs/design/daemon-multi-workspace-phase4b-voice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Workspace-qualified Voice

## Goal

Expose the existing daemon Voice settings, batch transcription, and streaming
transcription surfaces for every trusted workspace runtime without changing
legacy primary-only routes.

## Design

`GET`/`POST /workspaces/:workspace/voice`,
`POST /workspaces/:workspace/voice/transcribe`, and
`WS /workspaces/:workspace/voice/stream` resolve a registered trusted runtime
by id or encoded cwd. They use that runtime's cwd, effective environment,
bridge, and workspace settings. Voice setting writes through plural REST always
use workspace scope; secondary ACP voice writes use the same scope so they
cannot mutate shared user settings.

One process-scoped `WorkspaceVoiceCoordinator` owns the existing limit of
eight active Voice operations. It accounts for both WebSocket and REST batch
work across legacy and workspace-qualified paths. A removal drain rejects new
admission but leaves existing Voice work visible to the non-force removal
activity snapshot. Runtime disposal aborts only the selected runtime's Voice
leases before its bridge is shut down.

## Compatibility

Legacy `/workspace/voice`, `/workspace/voice/transcribe`, and `/voice/stream`
remain bound to the primary workspace. ACP method names and Voice settings
schema are unchanged. `workspace_qualified_voice` advertises route selection;
clients combine it with the existing Voice modality capability tags.

Unknown workspace selectors return `400 workspace_mismatch`; registered but
untrusted runtimes return `403 untrusted_workspace` before Voice settings or
audio are read. The shared eight-operation admission cap covers batch and
streaming work for both legacy and plural routes. Batch capacity failures return
`503 voice_capacity_exceeded` with `Retry-After: 5`; streaming capacity failures
send an error frame and close with code `1013`.
9 changes: 7 additions & 2 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'multi_workspace_sessions', 'multi_workspace_session_rewind',
'multi_workspace_session_shell', 'persistent_workspace_registration',
'workspace_qualified_rest_core', 'workspace_persisted_transcript',
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
'workspace_qualified_voice', 'client_mcp_over_ws', 'cdp_tunnel_over_ws',
'browser_automation_mcp']
```

> Conditional tags appear only when their matching deployment toggle is on (see the table below). F3's `permission_mediation` tag is always-on and carries `modes: ['first-responder', 'designated', 'consensus', 'local-only']` so SDK clients can introspect the build-supported set; the runtime-active strategy is at `body.policy.permission`.
Expand Down Expand Up @@ -221,6 +222,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

`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. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. 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. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.

`workspace_qualified_voice` advertises Voice routes selected by a trusted workspace runtime: `GET` and `POST /workspaces/:workspace/voice`, `POST /workspaces/:workspace/voice/transcribe`, and `WS /workspaces/:workspace/voice/stream`. The selector follows the same id-or-encoded-absolute-cwd rules as other plural routes. An unknown selector returns `400 { code: "workspace_mismatch" }`; an untrusted selector returns `403 { code: "untrusted_workspace" }` without falling back to primary. Legacy `/workspace/voice`, `/workspace/voice/transcribe`, and `/voice/stream` remain primary-only. Clients gate settings on `workspace_qualified_voice` plus `workspace_voice`, batch transcription on it plus `workspace_voice_transcription`, and streaming on it plus `voice_transcribe`.
Comment thread
doudouOUC marked this conversation as resolved.
Outdated

`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. In addition to `clientCount` and `hasActivePrompt`, live sessions expose `isWaitingForPermission`, `isWaitingForUserQuestion`, `pendingInteractionCount`, and a retained `turnError` after a failed turn. The error clears when the next prompt actually starts. Both the single-session status response and workspace session lists include `turnError` and `pendingInteractions`: render-ready permission actions or `ask_user_question` questions plus the `requestId` and selectable options required by the existing permission vote routes. Each user question has an `answerKey`; vote with `answers`, for example `{ "0": "Polling" }`, keyed by that value. Persisted-only sessions omit runtime state because no runtime exists. Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
Expand Down Expand Up @@ -258,6 +261,7 @@ operator diagnostic snapshot documented below.
| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
| `workspace_settings` | the daemon was created with settings persistence available. |
| `workspace_qualified_voice` | multi-workspace runtimes are active, so the workspace-qualified Voice routes can select a secondary runtime. |
| `session_shell_command` | session shell execution is explicitly enabled. |
| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. |
| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. |
Expand Down Expand Up @@ -631,7 +635,8 @@ Non-force removal returns `409 workspace_busy` with an `activity` snapshot when
"pendingSessionStarts": 0,
"acpConnections": 1,
"memoryTasks": 0,
"channelWorkers": 0
"channelWorkers": 0,
"voiceSessions": 0
}
}
```
Expand Down
94 changes: 74 additions & 20 deletions packages/cli/src/serve/acp-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ function isActiveDrainCorrelation(
);
}

/** Prefix/suffix of the Phase 4 workspace-qualified ACP WS path. */
const PLURAL_ACP_WS_PREFIX = '/workspaces/';
/** Prefix of workspace-qualified WebSocket routes. */
Comment thread
doudouOUC marked this conversation as resolved.
const PLURAL_WS_PREFIX = '/workspaces/';
const PLURAL_ACP_WS_SUFFIX = '/acp';
const PLURAL_VOICE_WS_SUFFIX = '/voice/stream';

/**
* Extract the raw (undecoded, un-normalized) pathname from a request-target.
Expand All @@ -121,29 +122,29 @@ function rawRequestPathname(reqUrl: string | undefined): string {
}

/**
* Match `/workspaces/<selector>/acp` (with an optional single trailing slash)
* against a RAW request-target pathname and return the still-encoded selector,
* or null when the shape does not match. Rejects empty selectors, extra path
* segments (slash/backslash), and dot-segment traversal shapes -- including
* percent-encoded variants -- so decoding afterwards can never reintroduce a
* `/` or `..` that bypassed classification.
* Match `/workspaces/<selector><suffix>` (with an optional single trailing
* slash) against a RAW request-target pathname and return the still-encoded
* selector, or null when the shape does not match. Rejects empty selectors,
* extra path segments (slash/backslash), and dot-segment traversal shapes --
* including percent-encoded variants -- so decoding afterwards can never
* reintroduce a `/` or `..` that bypassed classification.
*/
function pluralAcpRawSelector(rawPath: string): string | null {
function pluralWorkspaceRawSelector(
rawPath: string,
suffix: string,
): string | null {
let p = rawPath;
if (p.endsWith(`${PLURAL_ACP_WS_SUFFIX}/`)) {
if (p.endsWith(`${suffix}/`)) {
p = p.slice(0, -1);
}
if (
!p.startsWith(PLURAL_ACP_WS_PREFIX) ||
!p.endsWith(PLURAL_ACP_WS_SUFFIX) ||
p.length <= PLURAL_ACP_WS_PREFIX.length + PLURAL_ACP_WS_SUFFIX.length
!p.startsWith(PLURAL_WS_PREFIX) ||
!p.endsWith(suffix) ||
p.length <= PLURAL_WS_PREFIX.length + suffix.length
) {
return null;
}
const selector = p.slice(
PLURAL_ACP_WS_PREFIX.length,
p.length - PLURAL_ACP_WS_SUFFIX.length,
);
const selector = p.slice(PLURAL_WS_PREFIX.length, p.length - suffix.length);
if (
selector.length === 0 ||
selector.includes('/') ||
Expand Down Expand Up @@ -436,6 +437,11 @@ export interface MountAcpHttpOptions {
* upgrade listener's security checks. Matched paths skip the ACP init flow.
*/
extraWsRoutes?: readonly ExtraWsRoute[];
workspaceVoiceConnection?: (
runtime: WorkspaceRuntime,
ws: WebSocket,
req: IncomingMessage,
) => void;
}

/**
Expand Down Expand Up @@ -1383,7 +1389,8 @@ export function mountAcpHttp(
// rather than `url.pathname`. WHATWG URL normalizes dot-segments, so
// `/workspaces/%2e%2e/acp` would collapse to `/acp` and silently bind to
// the primary mount. `rawRequestPathname` keeps it un-normalized and
// `pluralAcpRawSelector` rejects traversal / backslash / empty selectors.
// `pluralWorkspaceRawSelector` rejects traversal / backslash / empty
// selectors for both ACP and Voice workspace-qualified routes.
const rawPath = rawRequestPathname(req.url);
const isCdpPath =
opts.cdpTunnelOverWs === true &&
Expand All @@ -1393,10 +1400,20 @@ export function mountAcpHttp(
(route) => route.path === rawPath,
);
const pluralRawSelector = workspaceQualifiedAcpEnabled
? pluralAcpRawSelector(rawPath)
? pluralWorkspaceRawSelector(rawPath, PLURAL_ACP_WS_SUFFIX)
: null;
const isPluralAcpShape = pluralRawSelector !== null;
if (rawPath !== path && !isCdpPath && !extraRoute && !isPluralAcpShape) {
const pluralVoiceRawSelector = opts.workspaceVoiceConnection
? pluralWorkspaceRawSelector(rawPath, PLURAL_VOICE_WS_SUFFIX)
: null;
const isPluralVoiceShape = pluralVoiceRawSelector !== null;
if (
rawPath !== path &&
!isCdpPath &&
!extraRoute &&
!isPluralAcpShape &&
!isPluralVoiceShape
) {
logReject(`unknown-path ${logSafe(rawPath)}`);
socket.destroy();
return;
Expand Down Expand Up @@ -1504,6 +1521,43 @@ export function mountAcpHttp(
return;
}

if (isPluralVoiceShape) {
let selector: string;
try {
selector = decodeURIComponent(pluralVoiceRawSelector!);
} catch {
logReject('workspace-selector-decode-error');
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
const wsRegistry = opts.workspaceRegistry;
const runtime = wsRegistry
? (wsRegistry.getManagedByWorkspaceId(selector) ??
resolveManagedWorkspaceRuntimeByPathSelector(wsRegistry, selector))
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
: undefined;
if (!runtime) {
logReject(`workspace-mismatch ${logSafe(selector)}`);
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
if (!runtime.trusted) {
logReject(`untrusted-workspace ${runtime.workspaceId}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss!.handleUpgrade(req, socket, head, (ws: WebSocket) => {
if (disposed) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The disposed check inside the Voice WS upgrade callback correctly closes the socket with code 1012 on server shutdown, but no test exercises this path. The existing test "returns 503 server_disposed after dispose()" only covers REST endpoints, not the Voice WS upgrade flow. Consider adding a test that calls handle.close() then attempts a Voice WS upgrade, asserting the socket closes with code 1012.

— qwen3.7-max via Qwen Code /review

ws.close(1012, 'Server shutting down');
return;
}
opts.workspaceVoiceConnection!(runtime, ws, req);
});
return;
}

// ── Phase 4: resolve the target ACP mount for this upgrade ──
// Legacy `/acp` binds to the primary mount; `/workspaces/:workspace/acp`
// resolves the registered runtime's mount. The shared security checks
Expand Down
67 changes: 64 additions & 3 deletions packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
let secondaryBridge: HttpAcpBridge;
let workspaceRegistry: ReturnType<typeof createWorkspaceRegistry>;
let secondaryRuntime: WorkspaceRuntime;
let workspaceVoiceConnection: ReturnType<typeof vi.fn>;

beforeEach(async () => {
primaryBridge = makeBridge();
Expand Down Expand Up @@ -176,6 +177,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
});
cdpRegistry = new CdpTunnelRegistry();
checkRate = vi.fn().mockReturnValue(true);
workspaceVoiceConnection = vi.fn(
(runtime: WorkspaceRuntime, ws: WebSocket) => {
ws.send(JSON.stringify({ workspaceCwd: runtime.workspaceCwd }));
ws.close(1000, 'done');
},
);

const app = express();
app.use(express.json());
Expand All @@ -190,6 +197,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
checkRate,
sessionShellCommandEnabled: true,
workspaceRememberLane: new WorkspaceRememberTaskLane(primaryBridge),
workspaceVoiceConnection,
});

await new Promise<void>((resolve) => {
Expand Down Expand Up @@ -674,6 +682,59 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
);
});

it('routes workspace-qualified Voice WS by id and encoded cwd', async () => {
const connect = (selector: string) =>
new Promise<{ workspaceCwd?: string }>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${port}/workspaces/${selector}/voice/stream`,
{ handshakeTimeout: 2000 },
);
ws.on('message', (data: WebSocket.RawData) => {
resolve(JSON.parse(data.toString()) as { workspaceCwd?: string });
});
ws.on('error', reject);
});

await expect(connect('secondary-id')).resolves.toEqual({
workspaceCwd: '/ws-b',
});
await expect(connect(encodeURIComponent('/ws-b'))).resolves.toEqual({
workspaceCwd: '/ws-b',
});
expect(workspaceVoiceConnection).toHaveBeenCalledTimes(2);
expect(workspaceVoiceConnection.mock.calls[0]?.[0]).toBe(secondaryRuntime);
expect(workspaceVoiceConnection.mock.calls[1]?.[0]).toBe(secondaryRuntime);
});

it('rejects unknown and untrusted workspace-qualified Voice WS upgrades', async () => {
const status = (selector: string) =>
new Promise<number>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${port}/workspaces/${selector}/voice/stream`,
{ handshakeTimeout: 2000 },
);
ws.on('unexpected-response', (_req, response) => {
resolve(response.statusCode ?? 0);
ws.terminate();
});
ws.on('open', () => {
ws.close();
reject(new Error('rejected Voice WS upgrade should not open'));
});
ws.on('error', (err) =>
reject(
new Error(
`unexpected Voice WS error for ${selector}: ${err.message}`,
),
),
);
});

await expect(status('missing')).resolves.toBe(400);
await expect(status('untrusted-id')).resolves.toBe(403);
expect(workspaceVoiceConnection).not.toHaveBeenCalled();
});

it('rejects a WS upgrade to an untrusted workspace', async () => {
const status = await new Promise<number>((resolve, reject) => {
const ws = new WebSocket(
Expand All @@ -688,9 +749,9 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
ws.close();
reject(new Error('untrusted WS upgrade should not open'));
});
// Some ws versions surface a rejected upgrade as an error rather than
// `unexpected-response`; treat that as the expected 403.
ws.on('error', () => resolve(403));
ws.on('error', (err) =>
reject(new Error(`unexpected ACP WS error: ${err.message}`)),
);
});
expect(status).toBe(403);
});
Expand Down
Loading
Loading