qwen-code/packages/cli/src/serve/server.ts
jinye f5d36aa5f1
feat(cli): Add workspace-qualified core REST routes (#6567)
* feat(cli): Add workspace-qualified core REST routes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): Preserve encoded workspace cwd selectors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: fix CI failure on PR #6567

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: fix CI failure on PR #6567

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6567)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-07-09 15:01:55 +00:00

1325 lines
50 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import type { Application } from 'express';
import type { DaemonStatusProvider } from '@qwen-code/acp-bridge';
import { hashDaemonWorkspace } from '@qwen-code/qwen-code-core';
import type { DaemonLogger } from './daemon-logger.js';
import type {
DaemonMetricsBucket,
DaemonPerfSnapshot,
DaemonStartupSnapshot,
} from './daemon-status.js';
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
import {
allowOriginCors,
bearerAuth,
createMutationGate,
denyBrowserOriginCors,
hostAllowlist,
parseAllowOriginPatterns,
} from './auth.js';
import type {
DeviceFlowProvider,
DeviceFlowRegistry,
} from './auth/device-flow.js';
import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js';
import { createDaemonStatusProvider } from './daemon-status-provider.js';
import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js';
import { createWorkspaceSkillsStatusProvider } from './workspace-skills-status.js';
import { mountAcpHttp, type AcpHttpHandle } from './acp-http/index.js';
import { createVoiceWsConnectionHandler } from './voice/voice-ws.js';
import {
ClientMcpSenderRegistry,
createClientMcpServerProvider,
} from './acp-http/client-mcp-sender-registry.js';
import { CdpTunnelRegistry } from './cdp-tunnel/cdp-tunnel-registry.js';
import {
canonicalizeWorkspace,
createAcpSessionBridge,
type AcpSessionBridge,
} from './acp-session-bridge.js';
import {
type ServeAuthProviderInstallRequest,
type ServeAuthProviderInstallResult,
type ServeOptions,
} from './types.js';
import {
mountWebShellAssets,
mountWebShellSpaFallback,
} from './web-shell-static.js';
import {
mountWorkspaceMemoryRoutes,
mountWorkspaceQualifiedMemoryRoutes,
} from './workspace-memory.js';
import {
mountWorkspaceMemoryRememberRoutes,
WorkspaceRememberTaskLane,
} from './workspace-remember.js';
import {
mountWorkspaceAgentsRoutes,
mountWorkspaceQualifiedAgentsRoutes,
} from './workspace-agents.js';
import { registerDaemonStatusRoutes } from './routes/daemon-status.js';
import { createHealthDemoRoutes } from './routes/health-demo.js';
import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js';
import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js';
import type { WorkspaceFileSystemFactory } from './fs/index.js';
import {
registerWorkspaceFileReadRoutes,
registerWorkspaceQualifiedFileReadRoutes,
} from './routes/workspace-file-read.js';
import {
registerWorkspaceFileWriteRoutes,
registerWorkspaceQualifiedFileWriteRoutes,
} from './routes/workspace-file-write.js';
import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js';
import {
registerWorkspaceQualifiedTrustRoutes,
registerWorkspaceTrustRoutes,
} from './routes/workspace-trust.js';
import { registerPermissionRoutes } from './routes/permission.js';
import { registerSessionRoutes } from './routes/session.js';
import { registerScheduledTasksRoutes } from './routes/scheduled-tasks.js';
import { registerUsageStatsRoutes } from './routes/usage-stats.js';
import {
startScheduledTaskKeepalive,
rehydrateScheduledTaskSessions,
} from './scheduled-task-keepalive.js';
import {
registerWorkspaceDiagnosticStatusRoutes,
registerWorkspaceQualifiedDiagnosticStatusRoutes,
registerWorkspaceQualifiedStatusRoutes,
registerWorkspaceStatusRoutes,
} from './routes/workspace-status.js';
import {
createDaemonWorkspaceService,
type DaemonWorkspaceService,
} from './workspace-service/index.js';
import { registerCapabilitiesRoutes } from './routes/capabilities.js';
import {
registerWorkspacePermissionsRoutes,
registerWorkspaceQualifiedPermissionsRoutes,
} from './routes/workspace-permissions.js';
import {
registerWorkspaceQualifiedSettingsRoutes,
registerWorkspaceSettingsRoutes,
} from './routes/workspace-settings.js';
import {
getActiveSseCount,
registerSseEventsRoutes,
} from './routes/sse-events.js';
import {
registerWorkspaceVoiceRoutes,
type WorkspaceVoiceRouteDeps,
} from './routes/workspace-voice.js';
import { registerA2uiActionRoutes } from './routes/a2ui-action.js';
import { setRateLimiter } from './rate-limit.js';
import {
createTotalSessionAdmissionController,
type TotalSessionAdmissionSnapshot,
} from './total-session-admission.js';
import {
sendBridgeError as sendBridgeErrorResponse,
sendPermissionVoteError as sendPermissionVoteErrorResponse,
type SendBridgeError,
} from './server/error-response.js';
import { resolveBridgeFsFactory } from './server/fs-factory.js';
import {
createBuildWorkspaceCtx,
parseAndValidateWorkspaceClientId,
parseClientIdHeader,
safeBody,
} from './server/request-helpers.js';
import { daemonTelemetryMiddleware } from './server/telemetry.js';
import { installAccessLogMiddleware } from './server/access-log.js';
import { setupDeviceFlowRegistry } from './server/device-flow-registry.js';
import {
installFinalErrorHandler,
installJsonBodyParser,
} from './server/error-handlers.js';
import { installRateLimiter } from './server/rate-limiter-setup.js';
import { createServeFeatures } from './server/serve-features.js';
import { SessionArchiveCoordinator } from './server/session-archive.js';
import { installSelfOriginStripMiddleware } from './server/self-origin.js';
import {
createSingleWorkspaceRegistry,
createWorkspaceSessionOwnerIndex,
type WorkspaceRegistry,
type WorkspaceRuntimeEnvMetadata,
} from './workspace-registry.js';
import {
isPortableAbsolutePath,
resolveRegisteredWorkspaceRuntimeByPathSelector,
} from './workspace-route-runtime.js';
import {
registerWorkspaceLifecycleRoutes,
registerWorkspaceQualifiedLifecycleRoutes,
} from './routes/workspace-lifecycle.js';
import {
registerWorkspaceMcpControlRoutes,
registerWorkspaceQualifiedMcpControlRoutes,
} from './routes/workspace-mcp-control.js';
import { registerWorkspaceChannelControlRoutes } from './routes/workspace-channel-control.js';
import {
registerWorkspaceQualifiedToolsRoutes,
registerWorkspaceToolsRoutes,
} from './routes/workspace-tools.js';
export {
createDefaultFsAuditEmit,
resolveBoundWorkspacesFromIdeEnv,
resolveBridgeFsFactory,
} from './server/fs-factory.js';
export {
PromptDeadlineExceededError,
resolvePromptDeadlineMs,
} from './server/prompt-deadline.js';
export { detectFromLoopback } from './server/request-helpers.js';
export {
InvalidCursorError,
listWorkspaceSessionsForResponse,
} from './server/session-list.js';
export type {
ListWorkspaceSessionsOptions,
ListWorkspaceSessionsResult,
} from './server/session-list.js';
export { getActiveSseCount } from './routes/sse-events.js';
/**
* Module-scoped once-per-process guard for the `createServeApp`
* default-trust stderr warning. Without this, tests calling
* `createServeApp` repeatedly would flood stderr with identical lines.
*/
let warnedDefaultTrust = false;
function describeRegistryPrimaryForConflict(
registry: WorkspaceRegistry,
): string {
return (
`registry primary cwd=${JSON.stringify(registry.primary.workspaceCwd)}, ` +
`workspaceId=${JSON.stringify(registry.primary.workspaceId)}`
);
}
function getRuntimeEffectiveEnv(
metadata: WorkspaceRuntimeEnvMetadata | undefined,
): Readonly<Record<string, string | undefined>> | undefined {
return metadata?.effectiveEnv;
}
export interface ServeAppDeps {
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
bridge?: AcpSessionBridge;
/**
* Enables resident management of scheduled-task-owned sessions: a periodic
* keepalive (so their schedulers aren't idle-reaped) and a boot-time
* rehydration (so they re-arm after a restart). Opt-in — only the real
* long-running daemon (`runQwenServe`) sets it. Tests and direct embeds
* leave it off so `createServeApp` neither spawns sessions on boot nor holds
* a heartbeat timer.
*/
manageScheduledTaskSessions?: boolean;
/**
* Directory of the built Web Shell SPA (`index.html` + `assets/`). When
* set (and `opts.serveWebShell !== false`), `createServeApp` mounts the
* UI at the daemon root before `bearerAuth`. Production `runQwenServe`
* resolves this via `resolveWebShellDir()` and injects it here; direct
* embeds / tests opt in by passing a fixture dir, so the default
* `createServeApp` (no injection) stays API-only and existing route tests
* are unaffected.
*/
webShellDir?: string;
/**
* Qwen Code version advertised to web/SDK clients. Production passes the
* resolved CLI package version; tests/direct embeds may omit it.
*/
qwenCodeVersion?: string;
/**
* Pre-canonicalized workspace path. When supplied, `createServeApp`
* skips its own `canonicalizeWorkspace` call (which would issue a
* redundant `realpathSync.native` syscall — idempotent, but a hot
* boot-time stat we can avoid). `runQwenServe` passes this after
* its own boot-time canonicalize so the value used by
* `/capabilities`, the `POST /session` cwd fallback, and the
* bridge are all the SAME canonical form. Callers that haven't
* canonicalized yet (tests, direct embeds) omit this and
* `createServeApp` falls back to canonicalizing `opts.workspace ??
* process.cwd()` itself.
*/
boundWorkspace?: string;
/**
* Workspace filesystem boundary factory. When supplied, file routes
* pull a per-request `WorkspaceFileSystem` off it; when omitted,
* `createServeApp` builds a strict default (`trusted: false`,
* warn-once no-op `emit`) so an upstream refactor that forgets to
* inject `fsFactory` never silently allows writes against an
* untrusted workspace.
*/
fsFactory?: WorkspaceFileSystemFactory;
/**
* Device-flow auth registry. Tests inject a fake; production callers
* omit this and `createServeApp` constructs a default wired to the
* shipped Qwen provider, the bridge's `publishWorkspaceEvent`,
* and a stderr audit sink.
*/
deviceFlowRegistry?: DeviceFlowRegistry;
maxExtensionOperationHistory?: number;
/**
* Extra device-flow providers for tests / future extensions.
* Production builds register only `QwenOAuthDeviceFlowProvider`;
* passing extra entries here registers them in addition.
*/
deviceFlowProviders?: DeviceFlowProvider[];
/**
* Installs an LLM auth provider by applying the same provider install plan
* used by interactive `/auth`. Production `runQwenServe` injects a
* settings-backed implementation; tests/direct embeds may omit it, in which
* case the route reports `not_implemented`.
*/
installAuthProvider?: (
req: ServeAuthProviderInstallRequest,
) => Promise<ServeAuthProviderInstallResult>;
/**
* Optional daemon logger. When provided, `sendBridgeError` routes
* each 5xx error through `daemonLog.error(...)` (which tees to stderr +
* the daemon log file). When omitted, falls back to existing
* stderr-only behavior.
*/
daemonLog?: DaemonLogger;
startup?: DaemonStartupSnapshot;
getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot;
/**
* Stop and relaunch the daemon-managed channel worker so it re-reads
* settings.json. Wired only when the daemon owns a channel worker; its
* presence gates the `channel_reload` capability and the
* `POST /workspace/channel/reload` route.
*/
reloadChannelWorker?: () => Promise<ChannelWorkerSnapshot>;
getPerfSnapshot?: () => DaemonPerfSnapshot;
/** Rolling metrics series for the Daemon Status charts (oldest→newest). */
getMetricsSeries?: () => DaemonMetricsBucket[];
getTotalSessionAdmissionSnapshot?: () => TotalSessionAdmissionSnapshot;
/**
* Sink fed one (durationMs, statusCode) per matched daemon HTTP request, so
* the metrics ring can bucket request rate and latency for the charts.
*/
recordDaemonRequest?: (durationMs: number, statusCode: number) => void;
workspace?: DaemonWorkspaceService;
statusProvider?: DaemonStatusProvider;
persistDisabledTools?: (
workspace: string,
toolName: string,
enabled: boolean,
) => Promise<void>;
contextFilename?: string;
persistSetting?: (
workspace: string,
scope: import('../config/settings.js').SettingScope,
key: string,
value: unknown,
) => Promise<void | import('../config/settings.js').LoadedSettings>;
persistSettings?: (
workspace: string,
writes: Array<{
scope: import('../config/settings.js').SettingScope;
key: string;
value: unknown;
}>,
) => Promise<void>;
sessionArtifactsPersistenceAvailable?: boolean;
/**
* Reverse tool channel (issue #5626, Phase 2). Shared sender registry that
* bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP
* child's `client_mcp/message` ext-method. `runQwenServe` constructs ONE and
* passes the SAME instance here AND to its `createAcpSessionBridge` call (as
* `clientMcpSender: registry.lookup`) so the bridge that answers the child
* and the WS provider that registers senders agree. When omitted (the
* standalone `createServeApp` path with no injected bridge), `createServeApp`
* builds its own registry and wires it into the bridge it creates.
*/
clientMcpSenderRegistry?: ClientMcpSenderRegistry;
workspaceRegistry?: WorkspaceRegistry;
primaryWorkspaceTrusted?: boolean;
primaryRuntimeEnv?: WorkspaceRuntimeEnvMetadata;
voiceTranscriber?: WorkspaceVoiceRouteDeps['transcribe'];
}
/**
* Build the Express app for `qwen serve`. Pure function — no side effects on
* the network or process; `runQwenServe` does the listen/signal handling.
*
* `getPort` is invoked lazily by the host-allowlist middleware so callers
* binding to port 0 (ephemeral) can supply the actual port after `listen()`
* resolves. Defaults to `opts.port` for callers (e.g. tests) that pin a port
* up front.
*
* Route modules are registered below in middleware order. Keep this file as
* the assembly point so auth/rate-limit/body-parser/REST/ACP/Web Shell order
* stays reviewable in one place.
*
* **Workspace validation contract.** `createServeApp` itself does NOT
* verify that `opts.workspace` exists or is a directory — it
* canonicalizes via `canonicalizeWorkspace`, which falls back to
* `path.resolve` on ENOENT so the app boots even against a missing
* path. `runQwenServe` is the production entry point and DOES
* perform the `fs.statSync` + `isDirectory()` boot-loud check before
* calling this function. Tests inject synthetic paths (`/work/bound`
* etc.) on purpose: they want to exercise the route layer's
* canonicalization and `workspace_mismatch` translation without
* needing a real directory on disk. If a future entry point binds
* `createServeApp` directly to user input, it MUST replicate the
* `runQwenServe` validation (or call into a shared helper if one is
* extracted) — otherwise a non-existent `--workspace` would boot
* a "healthy"-looking daemon whose every spawn fails with cryptic
* child-process ENOENT.
*/
// Mirrors the bridge's session-idle reaper default (30 min). Used only to
// size the scheduled-task keepalive interval when no explicit timeout is set.
const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;
// Bounds for the keepalive interval: ≥30s (avoid busy-looping on a tiny custom
// timeout) and ≤10min (stay well inside the 30-min default reaper window).
const KEEPALIVE_MIN_INTERVAL_MS = 30_000;
const KEEPALIVE_MAX_INTERVAL_MS = 10 * 60_000;
/**
* Sizes the keepalive heartbeat interval so a resident task session is beaten
* BEFORE the idle reaper closes it. Targets a third of the reaper window, but
* never exceeds HALF of it — so at least one heartbeat lands in time even for a
* small custom timeout, where the 30s floor would otherwise overshoot the whole
* window and let the session be reaped before the first beat. When the reaper is
* disabled (idle timeout ≤ 0) sessions are never reaped, so heartbeats aren't
* needed — the loop still runs (to revive re-enabled bound sessions) but at the
* relaxed max cadence.
*/
export function computeKeepaliveIntervalMs(idleTimeoutMs: number): number {
if (idleTimeoutMs <= 0) return KEEPALIVE_MAX_INTERVAL_MS;
const target = Math.min(
Math.max(KEEPALIVE_MIN_INTERVAL_MS, Math.floor(idleTimeoutMs / 3)),
KEEPALIVE_MAX_INTERVAL_MS,
);
return Math.max(1, Math.min(target, Math.floor(idleTimeoutMs / 2)));
}
export function createServeApp(
opts: ServeOptions,
getPort: () => number = () => opts.port,
deps: ServeAppDeps = {},
): Application {
const app = express();
// Forward `maxSessions` into the default-constructed bridge so
// direct callers of `createServeApp` (tests, embeds) get the same
// cap they configured via `ServeOptions`. Previously the default
// bridge silently fell back to `DEFAULT_MAX_SESSIONS` (20) and
// only the `runQwenServe` path piped the option through.
//
// The primary workspace value advertised on `/capabilities`, used for the
// `POST /session` cwd fallback, AND passed into the primary bridge must be
// the SAME canonical form.
// `deps.boundWorkspace` is the pre-canonicalized fast-path from
// `runQwenServe`; when omitted we canonicalize ourselves.
const injectedWorkspaceRegistry = deps.workspaceRegistry;
const boundWorkspace =
injectedWorkspaceRegistry?.primary.workspaceCwd ??
deps.boundWorkspace ??
canonicalizeWorkspace(opts.workspace ?? process.cwd());
if (injectedWorkspaceRegistry) {
const primary = injectedWorkspaceRegistry.primary;
const registryConflictCandidates = [
{
depName: 'deps.boundWorkspace',
depValue: deps.boundWorkspace,
registryValue: primary.workspaceCwd,
detail: `deps.boundWorkspace=${JSON.stringify(deps.boundWorkspace)}`,
},
{
depName: 'deps.bridge',
depValue: deps.bridge,
registryValue: primary.bridge,
detail: 'deps.bridge is a different object',
},
{
depName: 'deps.workspace',
depValue: deps.workspace,
registryValue: primary.workspaceService,
detail: 'deps.workspace is a different object',
},
{
depName: 'deps.fsFactory',
depValue: deps.fsFactory,
registryValue: primary.routeFileSystemFactory,
detail: 'deps.fsFactory is a different object',
},
{
depName: 'deps.clientMcpSenderRegistry',
depValue: deps.clientMcpSenderRegistry,
registryValue: primary.clientMcpSenderRegistry,
detail: 'deps.clientMcpSenderRegistry is a different object',
},
];
for (const candidate of registryConflictCandidates) {
if (
candidate.depValue === undefined ||
candidate.depValue === candidate.registryValue
) {
continue;
}
throw new Error(
'createServeApp: workspaceRegistry conflicts with ' +
`${candidate.depName}: ${describeRegistryPrimaryForConflict(
injectedWorkspaceRegistry,
)}; ${candidate.detail}.`,
);
}
}
// Construct `fsFactory` BEFORE the bridge so the bridge can wire it
// through `BridgeFileSystem` for ACP-side writeTextFile/readTextFile.
// Default trust is `false` (test-safe). Embeds without `deps.fsFactory`
// or `deps.bridge` will see agent writes rejected with
// `untrusted_workspace` — warn once so the asymmetry is visible.
if (
!injectedWorkspaceRegistry &&
!deps.fsFactory &&
!deps.bridge &&
!warnedDefaultTrust
) {
warnedDefaultTrust = true;
process.stderr.write(
'qwen serve: createServeApp default fsFactory uses trusted=false ' +
'— agent ACP writeTextFile calls will reject with untrusted_workspace. ' +
'Inject deps.fsFactory (with explicit trust) or deps.bridge to override.\n',
);
}
const fsFactory =
injectedWorkspaceRegistry?.primary.routeFileSystemFactory ??
resolveBridgeFsFactory({
boundWorkspaces: [boundWorkspace],
injected: deps.fsFactory,
trusted: false,
});
const tokenConfigured =
typeof opts.token === 'string' && opts.token.length > 0;
const sessionShellCommandEnabled =
opts.enableSessionShell === true && tokenConfigured;
// Reverse tool channel (issue #5626, Phase 2). Process-scoped registry that
// bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP
// child's `client_mcp/message` ext-method. Prefer the registry `runQwenServe`
// already wired into its injected bridge (`deps.clientMcpSenderRegistry`) so
// the bridge that answers the child and the WS provider share ONE map.
// Standalone `createServeApp` (no injected bridge) builds its own and wires
// it into the bridge it creates below. Inert until a WS client sends
// `mcp_register` (gated by `clientMcpOverWs`).
// Guard the split-brain case: an injected `deps.bridge` was already wired to
// its own sender, so building a fresh registry here would leave the bridge
// and this registry pointing at different maps. A caller injecting the bridge
// must inject the matching registry too. Only enforced when `clientMcpOverWs`
// is active — that's the only path that processes `mcp_*` frames, so without
// it the registry is inert and a mismatch can't manifest (and the vast
// majority of tests inject a fake bridge without ever touching client-MCP).
if (
opts.clientMcpOverWs === true &&
deps.bridge &&
!injectedWorkspaceRegistry &&
!deps.clientMcpSenderRegistry
) {
throw new Error(
'createServeApp: deps.bridge requires deps.clientMcpSenderRegistry ' +
'when clientMcpOverWs is enabled (the bridge is already wired to its ' +
'own sender; a fresh registry here would be an orphan).',
);
}
const clientMcpSenderRegistry =
injectedWorkspaceRegistry?.primary.clientMcpSenderRegistry ??
deps.clientMcpSenderRegistry ??
new ClientMcpSenderRegistry();
const primaryRuntimeEnvMetadata =
injectedWorkspaceRegistry?.primary.env ?? deps.primaryRuntimeEnv;
const primaryEffectiveEnv = getRuntimeEffectiveEnv(primaryRuntimeEnvMetadata);
const { languageCodes, currentServeFeatures, invalidateServeFeaturesCache } =
createServeFeatures({
opts,
boundWorkspace,
persistSettingAvailable: deps.persistSetting !== undefined,
sessionArtifactsPersistenceAvailable:
deps.sessionArtifactsPersistenceAvailable !== false,
// Registry injection supplies the primary workspace service through the
// runtime, so it has the same reload surface as legacy deps.workspace.
reloadAvailable:
deps.workspace !== undefined || injectedWorkspaceRegistry !== undefined,
// Advertise `channel_reload` only when BOTH deps the route needs are
// wired — the same condition that gates route registration below — so an
// embedder can never see the capability advertised while the route 404s.
channelReloadAvailable:
deps.getChannelWorkerSnapshot !== undefined &&
deps.reloadChannelWorker !== undefined,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled:
(injectedWorkspaceRegistry?.list().length ?? 1) > 1,
});
const statusProvider =
deps.statusProvider ??
createDaemonStatusProvider(
primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {},
);
let defaultBridgeForAdmission: AcpSessionBridge | undefined;
const totalSessionAdmission =
!deps.bridge && !injectedWorkspaceRegistry
? createTotalSessionAdmissionController({
maxTotalSessions: opts.maxTotalSessions,
getBridges: () =>
defaultBridgeForAdmission ? [defaultBridgeForAdmission] : [],
})
: undefined;
const defaultSessionOwnerIndex = !injectedWorkspaceRegistry
? createWorkspaceSessionOwnerIndex()
: undefined;
const bridge =
injectedWorkspaceRegistry?.primary.bridge ??
deps.bridge ??
createAcpSessionBridge({
maxSessions: opts.maxSessions,
...(totalSessionAdmission
? { freshSessionAdmission: totalSessionAdmission.admit }
: {}),
...(defaultSessionOwnerIndex
? {
sessionLifecycle:
defaultSessionOwnerIndex.handleBridgeSessionLifecycle,
}
: {}),
maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,
eventRingSize: opts.eventRingSize,
compactedReplayMaxBytes: opts.compactedReplayMaxBytes,
permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs,
boundWorkspace,
sessionShellCommandEnabled,
// Wire the production status provider so direct embeds / tests
// that don't inject `deps.bridge` get daemon env + preflight cells.
statusProvider,
// Wire the WorkspaceFileSystem adapter so ACP writeTextFile /
// readTextFile pick up trust / TOCTOU / audit.
fileSystem: createBridgeFileSystemAdapter(fsFactory),
// Reverse tool channel: answer the child's `client_mcp/message`
// ext-method by reaching the WS connection that hosts the named server.
clientMcpSender: clientMcpSenderRegistry.lookup,
});
if (!injectedWorkspaceRegistry && !deps.bridge) {
defaultBridgeForAdmission = bridge;
}
const archiveCoordinator = new SessionArchiveCoordinator();
installSelfOriginStripMiddleware(app, getPort);
// Park the factory on `app.locals` so route handlers can pick it up
// via `req.app.locals.fsFactory` without re-threading the value
// through every handler signature.
(app.locals as { fsFactory?: WorkspaceFileSystemFactory }).fsFactory =
fsFactory;
// Surface the bound workspace on `app.locals` so file routes can
// compute workspace-relative response paths without re-resolving.
(app.locals as { boundWorkspace?: string }).boundWorkspace = boundWorkspace;
const { deviceFlowRegistry, getSupportedDeviceFlowProviders } =
setupDeviceFlowRegistry({
app,
bridge,
registry: deps.deviceFlowRegistry,
providers: deps.deviceFlowProviders,
});
const { daemonLog } = deps;
const sendBridgeError: SendBridgeError = (res, err, ctx) =>
sendBridgeErrorResponse(res, err, ctx, daemonLog);
const sendPermissionVoteError = (
res: import('express').Response,
err: unknown,
ctx: { route: string; sessionId?: string },
) => sendPermissionVoteErrorResponse(res, err, ctx, daemonLog);
const workspace: DaemonWorkspaceService =
injectedWorkspaceRegistry?.primary.workspaceService ??
deps.workspace ??
createDaemonWorkspaceService({
boundWorkspace,
contextFilename: deps.contextFilename ?? 'QWEN.md',
statusProvider,
workspaceProvidersStatusProvider: createWorkspaceProvidersStatusProvider(
primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {},
),
workspaceSkillsStatusProvider: createWorkspaceSkillsStatusProvider(),
isChannelLive: () => bridge.isChannelLive(),
persistDisabledTools:
deps.persistDisabledTools ??
(async () => {
throw new Error(
'setWorkspaceToolEnabled requires persistDisabledTools in ServeAppDeps',
);
}),
queryWorkspaceStatus: (method, idle) =>
bridge.queryWorkspaceStatus(method, idle),
invokeWorkspaceCommand: (method, params, invokeOpts) =>
bridge.invokeWorkspaceCommand(method, params, invokeOpts),
preheatAcpChild: () => bridge.preheat(),
refreshExtensionsForAllSessions: () =>
bridge.refreshExtensionsForAllSessions(),
...(deps.persistSetting ? { persistSetting: deps.persistSetting } : {}),
...(deps.persistSettings
? { persistSettings: deps.persistSettings }
: {}),
publishWorkspaceEvent: (event) => {
if (
event.type === 'settings_changed' ||
event.type === 'settings_reloaded'
) {
invalidateServeFeaturesCache();
}
bridge.publishWorkspaceEvent(event);
},
});
const workspaceRegistry =
injectedWorkspaceRegistry ??
createSingleWorkspaceRegistry(
{
workspaceId: hashDaemonWorkspace(boundWorkspace),
workspaceCwd: boundWorkspace,
primary: true,
trusted: deps.primaryWorkspaceTrusted ?? false,
env: primaryRuntimeEnvMetadata ?? {
mode: 'parent-process',
overlayKeys: [],
},
bridge,
workspaceService: workspace,
routeFileSystemFactory: fsFactory,
clientMcpSenderRegistry,
},
defaultSessionOwnerIndex
? { sessionOwnerIndex: defaultSessionOwnerIndex }
: {},
);
(app.locals as { workspaceRegistry?: WorkspaceRegistry }).workspaceRegistry =
workspaceRegistry;
const primaryRuntime = workspaceRegistry.primary;
const primaryBoundWorkspace = primaryRuntime.workspaceCwd;
const primaryBridge = primaryRuntime.bridge;
const primaryWorkspace = primaryRuntime.workspaceService;
const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory;
// Order matters: rejection guards (CORS / Host allowlist / bearer auth)
// run BEFORE the JSON body parser. Otherwise an unauthenticated POST
// gets a full 10MB `JSON.parse` before the 401 fires — a trivially
// amplified CPU/memory cost from any wrong-token client.
//
// When `--allow-origin` is configured, install the
// allowlist middleware instead of the deny-wall. The allowlist owns
// both halves of the policy (matched → CORS headers + pass-through or
// 204 preflight; unmatched → 403 with the same error envelope as the
// wall). When `--allow-origin` is empty/undefined, the deny-wall stays
// installed. Pattern parsing happens in `run-qwen-serve.ts` for validation;
// here we still keep the wildcard/no-token invariant for embedded
// callers that construct the app directly.
if (opts.allowOrigins && opts.allowOrigins.length > 0) {
const parsedAllowOrigins = parseAllowOriginPatterns(opts.allowOrigins);
if (parsedAllowOrigins.allowAny && !opts.token) {
throw new Error(
`Refusing to start with --allow-origin '*' but no bearer token ` +
`configured. '*' admits any cross-origin browser to the API; ` +
`without a token, any local page can drive the daemon. Set a ` +
`token or list specific origins instead of '*'.`,
);
}
app.use(allowOriginCors(parsedAllowOrigins));
} else {
app.use(denyBrowserOriginCors);
}
app.use(hostAllowlist(opts.hostname, getPort));
const healthDemoRoutes = createHealthDemoRoutes({
opts,
getPort,
bridge: primaryBridge,
getActiveSseCount,
getRateLimiter: () => rateLimiter,
});
if (healthDemoRoutes.exposeHealthPreAuth) {
healthDemoRoutes.register(app);
}
installAccessLogMiddleware(app, daemonLog);
// Serve the Web Shell static assets (/ and /assets) BEFORE bearerAuth. The
// static shell carries no secrets and a browser cannot attach an
// Authorization header to a `<script src>` subresource or an address-bar
// navigation, so gating it would just break the UI — the front-end's own
// API calls still carry the bearer (getDaemonAuthHeaders) and every API
// route below stays token-gated. The SPA deep-link fallback is registered
// LATER (after all API routes, see mountWebShellSpaFallback) so authed
// routes win over the shell. The assets dir is resolved by the caller
// (runQwenServe) and injected via deps.webShellDir; `--no-web` sets
// opts.serveWebShell=false to opt out.
const webShellDir =
opts.serveWebShell !== false ? deps.webShellDir : undefined;
// Extension origins (chrome-extension://…) explicitly allowed via
// --allow-origin may frame the Web Shell so the extension can host the UI in
// a Chrome side panel (issue #5626). All other origins still get
// frame-ancestors 'none' + X-Frame-Options: DENY.
const webShellFrameAncestors =
opts.allowOrigins && opts.allowOrigins.length > 0
? [...parseAllowOriginPatterns(opts.allowOrigins).origins].filter(
(o) =>
o.startsWith('chrome-extension://') ||
o.startsWith('moz-extension://'),
)
: [];
if (webShellDir) {
mountWebShellAssets(app, webShellDir, webShellFrameAncestors);
}
app.use(bearerAuth(opts.token));
// Rate limiter: after auth (only count authenticated requests),
// before body parser (reject early without burning JSON.parse CPU).
const rateLimiter = installRateLimiter(app, opts, daemonLog);
installJsonBodyParser(app);
if (!healthDemoRoutes.exposeHealthPreAuth) {
// Non-loopback OR loopback with `--require-auth`: register
// `/health` and `/demo` AFTER `bearerAuth` so probes must carry
// the token. Otherwise unauthenticated callers can ping any
// reachable address:port to confirm a daemon exists (and `/demo`
// leaks the full API surface).
healthDemoRoutes.register(app);
}
// Mutation-route gate factory. Non-strict mode is passthrough;
// `{ strict: true }` requires a token even on loopback defaults.
const mutate = createMutationGate({
tokenConfigured,
requireAuth: opts.requireAuth === true,
});
app.use(
daemonTelemetryMiddleware((req) => {
const match = req.path.match(/^\/workspaces\/([^/]+)/);
const rawSelector = match?.[1];
if (rawSelector) {
try {
const selector = decodeURIComponent(rawSelector);
const byId = workspaceRegistry.getByWorkspaceId(selector);
if (byId) return byId.workspaceCwd;
if (isPortableAbsolutePath(selector)) {
const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector(
workspaceRegistry,
selector,
);
if (runtime) return runtime.workspaceCwd;
}
} catch {
return primaryBoundWorkspace;
}
}
return primaryBoundWorkspace;
}, deps.recordDaemonRequest),
);
const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace);
const acpHandleRef: { current?: AcpHttpHandle } = {};
const workspaceRememberLane = new WorkspaceRememberTaskLane(primaryBridge);
// Plan C CDP tunnel (issue #5626): process-scoped registry pairing the
// extension `/acp` connection with the `/cdp` puppeteer endpoint. Inert until
// both ends connect (gated by `cdpTunnelOverWs`).
const cdpTunnelRegistry =
opts.cdpTunnelOverWs === true ? new CdpTunnelRegistry() : undefined;
registerDaemonStatusRoutes(app, {
opts,
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspaceRegistry,
workspace: primaryWorkspace,
daemonLog,
startup: deps.startup,
qwenCodeVersion: deps.qwenCodeVersion,
getAcpHandle: () => acpHandleRef.current,
getRateLimiter: () => rateLimiter,
getRestSseActive: getActiveSseCount,
currentServeFeatures,
getSupportedDeviceFlowProviders,
deviceFlowRegistry,
sessionShellCommandEnabled,
getChannelWorkerSnapshot: deps.getChannelWorkerSnapshot,
getPerfSnapshot: deps.getPerfSnapshot,
getMetricsSeries: deps.getMetricsSeries,
getTotalSessionAdmissionSnapshot:
deps.getTotalSessionAdmissionSnapshot ?? totalSessionAdmission?.snapshot,
});
registerCapabilitiesRoutes(app, {
qwenCodeVersion: deps.qwenCodeVersion,
mode: opts.mode,
currentServeFeatures,
boundWorkspace: primaryBoundWorkspace,
workspaceRegistry,
permissionPolicy: primaryBridge.permissionPolicy,
maxSessionsPerWorkspace: opts.maxSessions,
maxTotalSessions: opts.maxTotalSessions,
maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,
languageCodes,
});
registerWorkspaceStatusRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspace: primaryWorkspace,
mutate,
sendBridgeError,
});
registerWorkspaceQualifiedStatusRoutes(app, {
workspaceRegistry,
sendBridgeError,
});
// Workspace memory + agents CRUD routes.
mountWorkspaceMemoryRoutes(app, {
bridge: primaryBridge,
boundWorkspace: primaryBoundWorkspace,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
mountWorkspaceQualifiedMemoryRoutes(app, {
workspaceRegistry,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
mountWorkspaceMemoryRememberRoutes(app, {
bridge: primaryBridge,
lane: workspaceRememberLane,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
mountWorkspaceAgentsRoutes(app, {
bridge: primaryBridge,
boundWorkspace: primaryBoundWorkspace,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
mountWorkspaceQualifiedAgentsRoutes(app, {
workspaceRegistry,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
registerWorkspaceDiagnosticStatusRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspace: primaryWorkspace,
mutate,
sendBridgeError,
});
registerWorkspaceQualifiedDiagnosticStatusRoutes(app, {
workspaceRegistry,
sendBridgeError,
});
registerWorkspaceExtensionRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspace: primaryWorkspace,
mutate,
safeBody,
sendBridgeError,
...(deps.maxExtensionOperationHistory === undefined
? {}
: { maxExtensionOperationHistory: deps.maxExtensionOperationHistory }),
});
// Workspace file routes (read-only + mutation).
registerWorkspaceFileReadRoutes(app, {
parseClientId: parseClientIdHeader,
});
registerWorkspaceQualifiedFileReadRoutes(app, {
parseClientId: parseClientIdHeader,
workspaceRegistry,
});
registerWorkspaceFileWriteRoutes(app, {
bridge: primaryBridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
registerWorkspaceQualifiedFileWriteRoutes(app, {
bridge: primaryBridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
workspaceRegistry,
});
registerWorkspaceSetupGithubRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
registerWorkspaceTrustRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
workspace: primaryWorkspace,
mutate,
safeBody,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedTrustRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
});
const broadcastSettingsChanged = (
key: string,
value: unknown,
scope: string,
clientId: string | undefined,
) => {
invalidateServeFeaturesCache();
primaryBridge.publishWorkspaceEvent({
type: 'settings_changed',
data: { key, value, scope },
...(clientId ? { originatorClientId: clientId } : {}),
});
};
if (deps.persistSetting) {
const persistSetting = deps.persistSetting;
registerWorkspaceSettingsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
mutate,
safeBody,
persistSetting: async (...args) => {
await persistSetting(...args);
},
broadcastSettingsChanged,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedSettingsRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
persistSetting: async (...args) => {
await persistSetting(...args);
},
invalidateServeFeaturesCache,
});
}
registerWorkspacePermissionsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
mutate,
safeBody,
workspace: primaryWorkspace,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedPermissionsRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
});
registerWorkspaceVoiceRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
mutate,
safeBody,
persistSetting: deps.persistSetting,
persistSettings: deps.persistSettings,
transcribe: deps.voiceTranscriber,
broadcastSettingsChanged,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
// A2UI action inbound (the upstream half of A2UI-over-MCP): user
// interactions from web clients are proxied to the UI MCP server's
// standard `action` tool.
registerA2uiActionRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
mutate,
safeBody,
env: getRuntimeEffectiveEnv(primaryRuntime.env),
// UI-server discovery uses the daemon's workspace MCP status, which
// includes servers registered at runtime.
getMcpServers: async () => {
const ctx = buildWorkspaceCtx('POST /session/:id/a2ui-action');
const status = await primaryWorkspace.getWorkspaceMcpStatus(ctx);
return (status.servers ?? []) as Array<{
name: string;
mcpStatus?: string;
config?: Record<string, unknown>;
}>;
},
});
registerWorkspaceAuthRoutes(app, {
mutate,
deviceFlowRegistry,
getSupportedDeviceFlowProviders,
sendBridgeError,
boundWorkspace: primaryBoundWorkspace,
allowPrivateAuthBaseUrl: opts.allowPrivateAuthBaseUrl === true,
installAuthProvider: deps.installAuthProvider,
});
registerSessionRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspaceRegistry,
archiveCoordinator,
mutate,
sendBridgeError,
daemonLog,
promptDeadlineMs: opts.promptDeadlineMs,
sessionShellCommandEnabled,
languageCodes,
});
registerWorkspaceMcpControlRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspace: primaryWorkspace,
mutate,
safeBody,
sendBridgeError,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedMcpControlRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
sendBridgeError,
});
if (deps.getChannelWorkerSnapshot && deps.reloadChannelWorker) {
const getChannelWorkerSnapshot = deps.getChannelWorkerSnapshot;
const reloadChannelWorker = deps.reloadChannelWorker;
registerWorkspaceChannelControlRoutes(app, {
getChannelWorkerSnapshot,
reloadChannelWorker,
mutate,
sendBridgeError,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
}
registerWorkspaceLifecycleRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
workspace: primaryWorkspace,
mutate,
safeBody,
sendBridgeError,
invalidateServeFeaturesCache,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedLifecycleRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
sendBridgeError,
invalidateServeFeaturesCache,
});
registerWorkspaceToolsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
workspace: primaryWorkspace,
mutate,
safeBody,
sendBridgeError,
parseAndValidateClientId: (req, res) =>
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
});
registerWorkspaceQualifiedToolsRoutes(app, {
workspaceRegistry,
mutate,
safeBody,
sendBridgeError,
});
// Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page).
// Reads/writes the per-project cron file only; firing stays with the
// session-side scheduler. Non-strict mutate: creating a scheduled prompt
// is the same capability class as POST /session/:id/prompt.
//
// The bridge is passed ONLY when resident task-session management is enabled.
// Binding a task to a dedicated session is only safe when something keeps that
// session resident and reloads it after a restart (the keepalive + rehydration
// below); without it, a bound task would fire only inside a session nothing
// revives and silently go dormant. So embedders that leave the manager off
// get UNBOUND tasks (shared-owner firing) instead.
registerScheduledTasksRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
mutate,
safeBody,
bridge: deps.manageScheduledTaskSessions ? bridge : undefined,
});
// Read-only token-usage dashboard (Daemon Status "统计" tab). Aggregate local
// usage only; open GET like /daemon/status, with its own short TTL cache.
registerUsageStatsRoutes(app);
// Resident management of scheduled-task-owned sessions — opt-in, so tests and
// embeds that call createServeApp neither spawn sessions on boot nor hold a
// heartbeat timer (both would read the bound workspace's real tasks file).
if (deps.manageScheduledTaskSessions) {
// Keepalive: keep task sessions resident so their in-child schedulers keep
// ticking rather than being idle-reaped, AND revive a re-enabled bound
// session the reaper already let go. The revive loop is needed even when the
// reaper is disabled (idle timeout ≤ 0), because archiving a task closes its
// session — so this always runs when task sessions are managed, not only
// when a reaper is active.
const idleTimeoutMs =
opts.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;
const keepalive = startScheduledTaskKeepalive({
bridge,
boundWorkspace,
intervalMs: computeKeepaliveIntervalMs(idleTimeoutMs),
});
// Park the stop fn on `app.locals` (same pattern as `fsFactory` /
// `boundWorkspace` / `acpHandle` above) so the shutdown sequence in
// run-qwen-serve.ts can invoke it without threading it back through the
// createServeApp return type.
(
app.locals as { stopScheduledTaskKeepalive?: () => void }
).stopScheduledTaskKeepalive = keepalive.stop;
// Rehydrate task-owned sessions on boot so their schedulers re-arm after a
// restart (a bound task fires only in its own session, which nothing else
// reloads). Fire-and-forget so it never delays the server coming up; a
// no-op when there are no bound tasks. Deliberately not awaited.
void rehydrateScheduledTaskSessions({
bridge,
boundWorkspace,
onError: (sessionId, err) => {
process.stderr.write(
`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
},
// Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already
// catches readCronTasks failures and per-session load errors internally
// (returning { loaded, failed }), so this only guards an unexpected throw
// from the function entry itself. Log rather than swallow it — a silent
// failure here leaves every bound task dormant with no diagnostic.
}).catch((err) => {
process.stderr.write(
`qwen serve: unexpected scheduled-task rehydration failure: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
});
}
registerPermissionRoutes(app, {
bridge: primaryBridge,
workspaceRegistry,
daemonLog,
mutate,
sendPermissionVoteError,
});
registerSseEventsRoutes(app, {
bridge: primaryBridge,
workspaceRegistry,
daemonLog,
writerIdleTimeoutMs: opts.writerIdleTimeoutMs,
sendBridgeError,
});
// Official ACP Streamable HTTP transport (RFD #721) mounted at `/acp`
// alongside the REST surface, sharing this same `bridge` instance.
// Additive + toggleable (`QWEN_SERVE_ACP_HTTP=0` opts out).
// See `docs/design/daemon-acp-http/README.md` for the dual-transport
// decision. Mounted AFTER the REST routes (distinct path, no overlap)
// and BEFORE the final error handler so malformed `/acp` bodies still
// route through the JSON error contract below.
acpHandleRef.current = mountAcpHttp(app, primaryBridge, {
boundWorkspace: primaryBoundWorkspace,
archiveCoordinator,
workspace: primaryWorkspace,
fsFactory: primaryRouteFileSystemFactory,
deviceFlowRegistry,
token: opts.token,
// Mirror the REST CORS allowlist onto the WS CSRF wall so an
// explicitly permitted origin (e.g. the extension's
// `chrome-extension://<id>`) can open the reverse tool channel.
allowedOrigins:
opts.allowOrigins && opts.allowOrigins.length > 0
? parseAllowOriginPatterns(opts.allowOrigins)
: undefined,
hostname: opts.hostname,
sessionShellCommandEnabled,
workspaceRememberLane,
checkRate: rateLimiter?.checkRate,
clientMcpOverWs: opts.clientMcpOverWs === true,
// Reverse tool channel (issue #5626, Phase 2). Per-connection provider:
// on `mcp_register` it records the WS registrar's sender in the shared
// registry and adds an SDK-type runtime MCP server in the ACP child
// (originator = the connection id). Only meaningful when
// `clientMcpOverWs` is on; the WS layer never builds a provider otherwise.
...(opts.clientMcpOverWs === true
? {
clientMcpProviderFactory: (connectionId: string) =>
createClientMcpServerProvider(
primaryRuntime.clientMcpSenderRegistry,
primaryBridge,
connectionId,
),
}
: {}),
// Plan C CDP tunnel (issue #5626): the `/cdp` branch + `cdp_*` routing
// activate only when the flag is on and a registry is supplied.
cdpTunnelOverWs: opts.cdpTunnelOverWs === true,
...(cdpTunnelRegistry ? { cdpTunnelRegistry } : {}),
// Browser captures audio and streams raw PCM here; the daemon transcribes
// server-side via the reused CLI voice pipeline. Shares the ACP upgrade
// listener's loopback/CSRF/bearer checks.
extraWsRoutes: [
{
path: '/voice/stream',
onConnection: createVoiceWsConnectionHandler(primaryBoundWorkspace, {
env: getRuntimeEffectiveEnv(primaryRuntime.env),
}),
},
],
});
if (acpHandleRef.current) {
app.locals['acpHandle'] = acpHandleRef.current;
}
// Web Shell SPA deep-link fallback — registered AFTER every API route (and
// just before the error handler) so real routes, including their bearerAuth
// 401s, always win; only genuine 404 misses fall through to the shell. This
// is what keeps an attacker-controlled `Accept: text/html` from coaxing the
// 200 shell out of an authed route.
if (webShellDir) {
mountWebShellSpaFallback(app, webShellDir, webShellFrameAncestors);
}
installFinalErrorHandler(app);
if (rateLimiter) {
setRateLimiter(app, rateLimiter);
}
return app;
}