docs/design/daemon-multi-workspace-phase4-acp.md
This document designs Phase 4 of issue #6378: workspace-qualified ACP for
qwen serve. It builds directly on the Phase 3 workspace-qualified REST branch
(codex/phase3-workspace-qualified-rest, PR #6567), which is not yet merged
(state CHANGES_REQUESTED). Phase 4 mounts a per-workspace ACP endpoint at
/workspaces/:workspace/acp, gives each workspace runtime its own ACP
dispatcher and connection state, and lets the Web Shell pick a workspace from
/capabilities. Legacy /acp stays bound to the primary runtime so existing
Web Shell and ACP clients are unaffected.
Phase 4 is scoped to the ACP transport (Streamable HTTP + the reverse /acp
WebSocket, its mirrored workspace methods, and reverse MCP/CDP). Voice
(/workspaces/:workspace/voice/stream) and daemon-managed channel workers are
Phase 4b; dynamic workspace add/remove is Phase 5. Neither is in scope
here.
The core finding from the seam investigation: Phase 4 is mostly a wiring and
routing change, not a rewrite. AcpDispatcher is already workspace-bound by
construction, its workspaceCwd consistency check already exists, Phase 3
already made the mirrored REST surface per-runtime, and clientMcpSenderRegistry
is already a per-runtime field. The real work is (1) turning the single ACP mount
into one dispatcher per runtime (each with its own remember-lane; still one
mountAcpHttp call and one upgrade listener; an AcpHttpHandle that owns every
runtime's registry), (2) extending that WebSocket upgrade listener to dispatch by
URL path, (3) keeping the device-flow registry daemon-global and shared across
every mount (with best-effort event-sink fan-out to each trusted runtime's
bridge), and (4) syncing the
new workspace_qualified_acp capability tag across the SDK/CLI capability types
and tests.
Review surfaced a Critical: an earlier iteration made the device-flow registry
per-runtime, which left secondary mounts unauthenticated (device_flow "not configured"). The ACP mount was reworked along eight axes; the final
architecture is:
mountAcpHttp call owns a primaryMount
plus a secondaryMounts map (one RuntimeAcpMount per non-primary runtime),
each carrying a primary flag. HTTP and WS both resolve a mount by selector
and delegate to shared handlers.primaryMount, and resolves a per-runtime mount otherwise.
Untrusted non-primary workspaces are rejected (403) on both the HTTP and WS
paths before any child is spawned.new URL().pathname, which normalizes %2e%2e), so a
non-normalized dot-segment / backslash selector is destroyed before routing.opts.deviceFlowRegistry; auth-flow events fan out
best-effort to every trusted runtime's bridge (resolveEventBridges).activeMount.primary; the plural POST returns the dispatch promise.dispose(), the shared HTTP handlers
return 503 server_disposed instead of racing torn-down registries during
the shutdown drain. dispose() is idempotent.AcpHttpHandle.getSnapshot() sums connection
and WS-stream counts across the primary and every secondary mount, so daemon
metrics report all workspaces' ACP connections, not just the primary's.resolveAcpHttpEnabled() is the single
interpretation of QWEN_SERVE_ACP_HTTP; workspace_qualified_acp is
advertised only when the ACP HTTP surface is enabled and multi-workspace
sessions are active.The mount architecture above remains unchanged. The final repair pass closes
six boundary gaps without replacing AcpHttpHandle or introducing a new route
policy module.
/acp only./workspaces/<single-selector>/acp transport path, including the
route's existing case and trailing-slash behavior. Nearby paths remain
limited. The ACP transport remains responsible for applying the JSON-RPC
method tier, so a qualified prompt consumes only the prompt bucket rather
than both mutation and prompt buckets.URIError instances and marked with HTTP status 400
return a structured 400 invalid_request. Other thrown URIError values and
unrelated failures retain the generic 500 handling. The WebSocket path keeps
its existing explicit 400 response.logSafe sanitizer, so
encoded terminal controls cannot forge or split stderr lines.dispose() is an irreversible lifecycle transition.
After it runs, attachServer() cannot recreate a WebSocket server or upgrade
listener. Repeated dispose() and attachServer() calls remain harmless.workspaceId,
workspaceCwd, and primary. Summary counters remain unchanged, the public
primary registry remains available for compatibility, and daemon
detail=full reads the aggregate connection list. The existing connection
cap remains a per-mount limit because every mount is constructed with the
same configured cap.Each contract is pinned by a regression test written before its production change. Verification includes the focused ACP, rate-limit, daemon-status, and serve-server suites plus build, typecheck, lint, and the serve fast-path bundle closure check.
Phase 4 consumes these Phase 3 seams. Because PR #6567 is CHANGES_REQUESTED,
treat them as to-be-stabilized; Phase 4 implementation must rebase onto the
merged Phase 3.
packages/cli/src/serve/workspace-route-runtime.ts:
resolveRegisteredWorkspaceRuntimeByPathSelector(registry, selector) — pure
function, returns WorkspaceRuntime | undefined. Reusable by the WS
upgrade listener (see Open Questions).resolveWorkspaceRuntimeFromParam(registry, req, res, param) — Express-bound
(writes res.status().json()). Usable for the HTTP ACP routes, not for the
WS upgrade path (the upgrade listener has only a raw IncomingMessage +
socket, no Express res).requireTrustedWorkspaceRuntime(runtime, res) — Express-bound trust gate,
reused by the HTTP ACP routes.isPortableAbsolutePath / sendWorkspaceMismatch — reused for selector
parsing and error shape.server.ts
(registerWorkspaceQualified{FileRead,FileWrite,Trust,Status,Permissions,Settings,Lifecycle,McpControl,Tools}Routes).
The ACP dispatcher mirrors these surfaces; Phase 4 relies on their per-runtime
behavior existing./capabilities workspaces[] (Phase 2a), built in
packages/cli/src/serve/routes/capabilities.ts (L79-84) and mirrored in
packages/cli/src/serve/daemon-status.ts (L432-437) with id / cwd /
primary / trusted per runtime. Feature-flag declarations and their
advertise/toggle predicates live in
packages/cli/src/serve/capabilities.ts.mountAcpHttp(app, primaryBridge, opts) in
packages/cli/src/serve/acp-http/index.ts is called once from server.ts
(L1226-1275) with all-primary inputs: primaryBridge,
primaryBoundWorkspace, primaryWorkspace, primaryRouteFileSystemFactory,
the app-global deviceFlowRegistry, primaryRuntime.clientMcpSenderRegistry,
and primaryRuntime.env (for the voice extraWsRoute).mountAcpHttp builds a single AcpDispatcher and a
single ConnectionRegistry, and returns an AcpHttpHandle whose registry is
that single registry and whose attachServer installs exactly one
httpServer.on('upgrade', ...) listener (index.ts L1536, L1555). dispose
removes that one listener and closes that one registry (index.ts L1543-1553).setupWebSocket, upgrade
handler at L903-1045). It is installed once via
AcpHttpHandle.attachServer(server) after listen(). It:
opts.path (/acp), not /cdp, and not an
extraWsRoutes entry — socket.destroy() on unknown path (index.ts
L935-939),/cdp -> attachCdpClient; extraRoute -> onConnection;
else the ACP initialize handshake.'upgrade' listener cannot
coexist because this one destroys unknown paths. Phase 4 must extend this one
listener, not add another.AcpDispatcher (dispatch.ts L644-656) is already workspace-bound by
constructor: bridge, boundWorkspace, workspace, workspaceRememberLane,
fsFactory?, deviceFlowRegistry?, sessionShellCommandEnabled, registry?,
archiveCoordinator. Every mirrored workspace method it serves reads these
fields, so binding a dispatcher to a runtime automatically scopes file /
permissions / settings / trust / tools / mcp / memory / agents / auth to that
runtime.workspaceRememberLane = new WorkspaceRememberTaskLane(primaryBridge)
(server.ts L816) and archiveCoordinator = new SessionArchiveCoordinator()
(server.ts L596). sessionShellCommandEnabled is a global policy, safe to
share.parseRequestedWorkspace (dispatch.ts
L694-697) throws WorkspaceMismatchError when a request's workspaceCwd does
not equal this.boundWorkspace; the error maps to INVALID_PARAMS (L577).WorkspaceRuntime (workspace-registry.ts L28-38) carries
clientMcpSenderRegistry per runtime but has no deviceFlowRegistry
field — device-flow is still app-global (setupDeviceFlowRegistry({ app, bridge }) at server.ts L609, bound to the primary bridge).Keep Option B: one daemon, N independent workspace runtimes. For ACP:
AcpDispatcher + ConnectionRegistry +
reverse-MCP provider factory, all bound to that runtime's bridge /
workspace / routeFileSystemFactory / clientMcpSenderRegistry / env.
Every dispatcher receives the same daemon-global device-flow registry./acp stays bound to the primary runtime's dispatcher (unchanged wire
behavior)./workspaces/:workspace/acp binds to the resolved runtime's dispatcher.mountAcpHttp is still called exactly once and installs exactly
one httpServer.on('upgrade', ...) listener. It changes from "single bridge +
opts" to accepting the WorkspaceRegistry (plus shared, non-workspace
concerns: token, allowedOrigins, hostname, checkRate,
sessionShellCommandEnabled, cdpTunnelRegistry). Internally it builds a
Map<workspaceId, RuntimeAcpMount>; the primary entry stays addressable by the
legacy /acp path.RuntimeAcpMount is constructed with that runtime's own bridge,
workspace, routeFileSystemFactory, clientMcpSenderRegistry, env, a new
per-runtime WorkspaceRememberTaskLane(runtime.bridge), its AcpDispatcher,
and its ConnectionRegistry. The daemon-global device-flow registry,
archiveCoordinator, and sessionShellCommandEnabled are shared.POST, GET (SSE), and DELETE on the plural path (Express,
via resolveWorkspaceRuntimeFromParam; today each closes over the single
dispatcher at index.ts L533/L675/L849), plus the WS upgrade branch (below).
Legacy /acp POST/GET/DELETE/upgrade keep dispatching to primary.AcpHttpHandle must grow from a single registry to owning every runtime's
dispatcher + ConnectionRegistry; dispose closes all of them and removes the
single upgrade listener.session/new / load / resume on a plural mount must
fire the same bridge-lifecycle register / remove callbacks that feed the
Phase 2b WorkspaceSessionOwnerIndex (workspace-registry.ts L48-119). A session
created over /workspaces/B/acp must then be discoverable by REST owner-routed
reads (context, stats, etc.) and vice versa. Phase 2b already scoped this index
to cover "REST and the later ACP dispatcher"; Phase 4 is where the ACP side is
actually wired.The upgrade listener is the one place ACP routing is not Express-driven, so it needs explicit path handling.
pathname === '/acp' | '/cdp' | extraRoute.
Phase 4 adds a branch for /workspaces/:workspace/acp:
:workspace selector segment.resolveRegisteredWorkspaceRuntimeByPathSelector(registry, decodeURIComponent(selector))
(id-first, then encoded canonical cwd, matching the REST resolver).socket.write('HTTP/1.1 400 ...') + destroy()), mirroring the REST
workspace_mismatch. No fallback to primary.ConnectionRegistry (not the primary ones)./cdp and voice extraWsRoutes stay primary-bound in Phase 4 (voice
is 4b). The /cdp branch is unchanged./acp upgrade continues to bind to the primary dispatcher.%2F in the encoded cwd selector: the daemon parses the raw upgrade URL
itself (new URL(req.url, ...)), so it is not subject to Express path
decoding, but reverse proxies may still normalize %2F. Recommend the
id-based selector for WS in proxy deployments (same guidance as Phase 2b/3
REST). The HTTP plural routes instead reuse resolveWorkspaceRuntimeFromParam,
which reads req.params (Express decodes once), so they inherit the Phase 3
encoded-selector handling for free.checkRate is threaded through opts); the
Phase 1 request-time workspace hashing only covers Express routes.Superseded. This section is the pre-rework design (a per-runtime device-flow registry). Review found it left secondary mounts unauthenticated, so the shipped implementation instead keeps a single daemon-global registry shared by every mount with best-effort event-sink fan-out — see "Systematic rework" axis 4 above. The subsections below are retained only as design-history context and do not describe the shipped behavior.
Device-flow is the one mirrored surface that is still app-global and must change.
deviceFlowRegistry to WorkspaceRuntime (or build one per runtime inside
mountAcpHttp). Each runtime's dispatcher receives its own registry.setupDeviceFlowRegistry must be invoked per runtime (bound to that runtime's
bridge/env), not once against the primary bridge.GET/DELETE /workspaces/:workspace/auth/device-flow/:id and the ACP
_qwen/workspace/auth/device_flow/* methods) must resolve the target runtime's
registry and reject/hide flows that belong to another workspace.app.locals.deviceFlowRegistry.boundWorkspace-scoped inside the
dispatcher; per-runtime dispatchers make this correct automatically. Legacy
primary auth routes keep writing primary.The reverse /acp WS mirrors a large REST surface (index.ts WS_READ_METHODS
L186-219 and dispatch.ts vendor methods): file read/list/glob/stat, workspace
mcp / skills / providers / env / preflight / trust / permissions / voice / tools
/ agents / memory / auth, session groups, setup-github. Because these all read
the dispatcher's constructor fields, binding a dispatcher to a runtime scopes
them for free. Phase 4 does not re-implement them; it only ensures each
runtime's dispatcher is constructed with that runtime's dependencies. That set
explicitly includes the per-runtime deviceFlowRegistry and
WorkspaceRememberTaskLane: if either is left as the primary singleton,
non-primary _qwen/workspace/memory/remember and auth/device_flow calls would
silently run against the primary bridge.
Consistency guarantee: since each mounted dispatcher is runtime-bound and
parseRequestedWorkspace already throws WorkspaceMismatchError when a
request's workspaceCwd differs from boundWorkspace, a client that connects to
/workspaces/A/acp but sends workspaceCwd: B in params is rejected. Phase 4
should add a test asserting this, and confirm the same guard covers session/new
(parseOptionalWorkspaceCwd, dispatch.ts L1059).
clientMcpProviderFactory currently closes over
primaryRuntime.clientMcpSenderRegistry + primaryBridge (server.ts
L1252-1257). Per-runtime mounts build the factory from the resolved runtime's
clientMcpSenderRegistry + bridge, so a WS connection on /workspaces/B/acp
registers client-hosted MCP servers in B's runtime only.ClientMcpWsConnection and cdpEndpoint stay per-connection;
they simply attach to the owning runtime's dispatcher.cdpTunnelRegistry is process-scoped and the CDP bridge is claimed
by an extension /acp connection whose clientInfo.name === 'qwen-cdp-bridge'.
Phase 4 keeps CDP claiming on legacy /acp (primary) as the pragmatic default;
workspace-scoped CDP is called out as an Open Question rather than solved here,
because a single loopback puppeteer client + one /cdp endpoint does not map
cleanly to N runtimes. Concretely, non-primary RuntimeAcpMounts must leave
the cdpTunnelOverWs / /cdp branch and the chrome-devtools runtime-MCP
registration off; only the primary mount wires them./workspaces/:workspace/acp, the ownership-granting ops
(session/new, session/load, session/resume; dispatch.ts
CONN_ROUTED_METHODS L239-243) must reject with an untrusted_workspace error
and not spawn, matching the REST 403 untrusted_workspace semantics already
implemented in routes/session-runtime.ts (L39-53) and routes/session.ts
(session create/load/resume trust gates plus session_workspace_conflict).requireTrustedWorkspaceRuntime for the HTTP ACP routes; for the WS path the
equivalent check runs on the resolved runtime's trusted flag before the
handshake grants a session.workspace_qualified_acp) in
packages/cli/src/serve/capabilities.ts (flag declaration + advertise/toggle
predicate), advertised only when more than one runtime is registered and ACP is
enabled (mirror the multi_workspace_sessions gating at capabilities.ts
L408-409). If Phase 4 lands across multiple PRs, do not advertise the tag until
the full plural ACP loop (HTTP + WS + device-flow + owner-index wiring) is
complete, so clients never build /workspaces/:id/acp URLs against a half-wired
surface (same half-enable guard philosophy as the Phase 2a feature gate).
Update the note on workspace_qualified_rest_core (L264-271) that currently
says "ACP/WebSocket, auth, voice, and extensions stay on their existing
primary-workspace routes in this phase."capabilities.ts. It must be synced to: the
/capabilities response builder in routes/capabilities.ts, the SDK
capability types (packages/sdk-typescript/src/daemon/types.ts), the CLI serve
types (packages/cli/src/serve/types.ts), and the feature-set assertion in
server.test.ts (L376-381). This is required Phase 4 work, not optional.workspaces[] already exists (Phase 2a), built in routes/capabilities.ts
(L79-84) and daemon-status.ts (L432-437) with id / cwd / primary /
trusted per runtime. The Web Shell reads it and builds /workspaces/:id/acp
connection URLs; the picker disables (or read-only marks) untrusted entries.DaemonClient (added in Phase 3) already reads caps.workspaces[].cwd
for session routing; a workspace-qualified ACP connect helper is the natural
extension. The capability-type sync above is required; the connect helper
itself can follow.workspace_mismatch: unknown WS/HTTP selector -> 400-class reject; never fall
back to primary.untrusted_workspace: ownership-granting ACP op on an untrusted runtime ->
reject, no spawn.workspaceCwd param mismatch: WorkspaceMismatchError -> INVALID_PARAMS
(already wired).ConnectionRegistry, then dispose the
single daemon-global device-flow registry once.checkRate keyed per
connection/session (index.ts L627-641, L1175-1178). The plural mounts share the
one limiter; keys must stay unambiguous across runtimes so one workspace cannot
exhaust or bypass another's budget.maxConnections is enforced per-runtime ConnectionRegistry, so
total ACP connections scale to N x maxConnections (a per-workspace budget,
matching the maxSessions per-workspace model). Fresh-session total stays
bounded by the Phase 2a maxTotalSessions admission at the bridge seam, which
ACP session creation already passes through./workspaces/:workspace/voice/stream and per-workspace voice settings (4b)./acp (primary),
/workspaces/:id/acp (resolved), unknown selector (reject), %2F-encoded cwd
selector, and that shared security checks still run for the plural path./workspaces/A/acp cannot see or
drive a session owned by B; session/list and mirrored reads return only A's
view./workspaces/B/acp is
resolvable by REST owner-routed reads (e.g. GET /session/:id/stats) and by
resolveLiveSessionOwner, confirming ACP creation feeds the owner index.workspaceCwd: B -> WorkspaceMismatchError.session/new|load|resume on an untrusted runtime -> rejected, no
child spawned.mcp_register on /workspaces/B/acp lands in B's
clientMcpSenderRegistry and B's bridge only./workspaces/A/acp and /workspaces/B/acp
are metered independently and neither can bypass the shared limiter.workspace_qualified_acp advertised only with >1 runtime;
workspaces[] shape unchanged.resolveRegisteredWorkspaceRuntimeByPathSelector as a pure function.
The WS upgrade listener cannot use the Express-bound
resolveWorkspaceRuntimeFromParam. Phase 4 depends on the pure resolver
staying free of req/res coupling. If Phase 3 review changes that seam,
preserve a pure (registry, selector) => runtime | undefined entry point./cdp
endpoint does not map cleanly to N runtimes. Phase 4 keeps CDP on primary;
confirm that is acceptable or scope a workspace-qualified CDP follow-up._qwen/workspace/voice reads.archiveCoordinator scope. It is a single SessionArchiveCoordinator
today (server.ts L596). Confirm sharing it across runtimes is safe given Phase
3's workspace-qualified archive/organization, or make it per-runtime.