-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(serve): Add workspace-qualified Voice #6839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5424ea9
82aa249
54334c7
02ab8fc
3a672c2
f882a9a
dbe16dc
c50ae27
809be56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import type { | |
| WorkspaceRuntime, | ||
| } from '../workspace-registry.js'; | ||
| import { | ||
| isPortableAbsolutePath, | ||
| resolveManagedWorkspaceRuntimeFromParam, | ||
| resolveManagedWorkspaceRuntimeByPathSelector, | ||
| } from '../workspace-route-runtime.js'; | ||
|
|
@@ -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. */ | ||
| 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. | ||
|
|
@@ -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('/') || | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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 && | ||
|
|
@@ -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; | ||
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The — 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 | ||
|
|
@@ -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)}`); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.