Skip to content
41 changes: 41 additions & 0 deletions docs/design/daemon-multi-workspace-phase4b-voice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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 all qualified
Voice modalities when the shared ACP/Voice WebSocket listener is enabled. The
existing Voice modality capability tags remain
primary-workspace signals and are not prerequisites for a secondary runtime,
whose configuration is validated by the selected route.

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`.
10 changes: 7 additions & 3 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'session_branch', 'rate_limit', 'workspace_reload',
'multi_workspace_sessions', 'multi_workspace_session_rewind',
'multi_workspace_session_shell', 'persistent_workspace_registration',
'workspace_qualified_rest_core', 'extension_management_v2',
'workspace_persisted_transcript',
'workspace_qualified_rest_core', 'workspace_qualified_voice',
'extension_management_v2', 'workspace_persisted_transcript',
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
```

Expand Down Expand Up @@ -222,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. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. 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 and skill toggles, 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`. It is advertised only when multi-workspace runtimes and the shared ACP/Voice WebSocket listener are both enabled. The selector follows the same id-or-encoded-absolute-cwd rules as other plural routes. For REST, an unknown selector returns `400 { code: "workspace_mismatch" }` and an untrusted selector returns `403 { code: "untrusted_workspace" }`; WebSocket upgrade rejection exposes the corresponding HTTP 400/403 status without a structured JSON envelope. Neither transport falls back to primary. Legacy `/workspace/voice`, `/workspace/voice/transcribe`, and `/voice/stream` remain primary-only. Clients use `workspace_qualified_voice` for all qualified Voice modalities and let the selected runtime report configuration-specific errors. The legacy `workspace_voice`, `workspace_voice_transcription`, and `voice_transcribe` tags describe only the primary-bound routes and must not hide a qualified secondary configuration.

`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 @@ -412,6 +414,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 and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for 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 @@ -785,7 +788,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
107 changes: 86 additions & 21 deletions packages/cli/src/serve/acp-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
WorkspaceRuntime,
} from '../workspace-registry.js';
import {
isPortableAbsolutePath,
resolveManagedWorkspaceRuntimeFromParam,
resolveManagedWorkspaceRuntimeByPathSelector,
} from '../workspace-route-runtime.js';
Expand Down Expand Up @@ -100,9 +101,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 +123,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 +438,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 +1390,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 +1401,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 +1522,48 @@ 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) ??
(isPortableAbsolutePath(selector)
? resolveManagedWorkspaceRuntimeByPathSelector(
wsRegistry,
selector,
)
: undefined))
: 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 All @@ -1525,7 +1585,12 @@ export function mountAcpHttp(
const wsRegistry = opts.workspaceRegistry;
const rt = wsRegistry
? (wsRegistry.getManagedByWorkspaceId(selector) ??
resolveManagedWorkspaceRuntimeByPathSelector(wsRegistry, selector))
(isPortableAbsolutePath(selector)
? resolveManagedWorkspaceRuntimeByPathSelector(
wsRegistry,
selector,
)
: undefined))
: undefined;
if (!rt) {
logReject(`workspace-mismatch ${logSafe(selector)}`);
Expand Down
Loading
Loading