basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6import express from 'express';7import { hashDaemonWorkspace } from '@qwen-code/qwen-code-core';8import { allowOriginCors, bearerAuth, createMutationGate, denyBrowserOriginCors, hostAllowlist, parseAllowOriginPatterns, } from './auth.js';9import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js';10import { createDaemonStatusProvider } from './daemon-status-provider.js';11import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js';12import { createWorkspaceSkillsStatusProvider } from './workspace-skills-status.js';13import { mountAcpHttp } from './acp-http/index.js';14import { createVoiceWsConnectionHandler } from './voice/voice-ws.js';15import { ClientMcpSenderRegistry, createClientMcpServerProvider, } from './acp-http/client-mcp-sender-registry.js';16import { CdpTunnelRegistry } from './cdp-tunnel/cdp-tunnel-registry.js';17import { canonicalizeWorkspace, createAcpSessionBridge, } from './acp-session-bridge.js';18import {} from './types.js';19import { mountWebShellAssets, mountWebShellSpaFallback, } from './web-shell-static.js';20import { mountWorkspaceMemoryRoutes } from './workspace-memory.js';21import { mountWorkspaceMemoryRememberRoutes, WorkspaceRememberTaskLane, } from './workspace-remember.js';22import { mountWorkspaceAgentsRoutes } from './workspace-agents.js';23import { registerDaemonStatusRoutes } from './routes/daemon-status.js';24import { createHealthDemoRoutes } from './routes/health-demo.js';25import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js';26import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js';27import { registerWorkspaceFileReadRoutes } from './routes/workspace-file-read.js';28import { registerWorkspaceFileWriteRoutes } from './routes/workspace-file-write.js';29import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js';30import { registerWorkspaceTrustRoutes } from './routes/workspace-trust.js';31import { registerPermissionRoutes } from './routes/permission.js';32import { registerSessionRoutes } from './routes/session.js';33import { registerScheduledTasksRoutes } from './routes/scheduled-tasks.js';34import { registerUsageStatsRoutes } from './routes/usage-stats.js';35import { startScheduledTaskKeepalive, rehydrateScheduledTaskSessions, } from './scheduled-task-keepalive.js';36import { registerWorkspaceDiagnosticStatusRoutes, registerWorkspaceStatusRoutes, } from './routes/workspace-status.js';37import { createDaemonWorkspaceService, } from './workspace-service/index.js';38import { registerCapabilitiesRoutes } from './routes/capabilities.js';39import { registerWorkspacePermissionsRoutes } from './routes/workspace-permissions.js';40import { registerWorkspaceSettingsRoutes } from './routes/workspace-settings.js';41import { getActiveSseCount, registerSseEventsRoutes, } from './routes/sse-events.js';42import { registerWorkspaceVoiceRoutes, } from './routes/workspace-voice.js';43import { registerA2uiActionRoutes } from './routes/a2ui-action.js';44import { setRateLimiter } from './rate-limit.js';45import { createTotalSessionAdmissionController, } from './total-session-admission.js';46import { sendBridgeError as sendBridgeErrorResponse, sendPermissionVoteError as sendPermissionVoteErrorResponse, } from './server/error-response.js';47import { resolveBridgeFsFactory } from './server/fs-factory.js';48import { createBuildWorkspaceCtx, parseAndValidateWorkspaceClientId, parseClientIdHeader, safeBody, } from './server/request-helpers.js';49import { daemonTelemetryMiddleware } from './server/telemetry.js';50import { installAccessLogMiddleware } from './server/access-log.js';51import { setupDeviceFlowRegistry } from './server/device-flow-registry.js';52import { installFinalErrorHandler, installJsonBodyParser, } from './server/error-handlers.js';53import { installRateLimiter } from './server/rate-limiter-setup.js';54import { createServeFeatures } from './server/serve-features.js';55import { SessionArchiveCoordinator } from './server/session-archive.js';56import { installSelfOriginStripMiddleware } from './server/self-origin.js';57import { createSingleWorkspaceRegistry, } from './workspace-registry.js';58import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js';59import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js';60import { registerWorkspaceToolsRoutes } from './routes/workspace-tools.js';61export { createDefaultFsAuditEmit, resolveBoundWorkspacesFromIdeEnv, resolveBridgeFsFactory, } from './server/fs-factory.js';62export { PromptDeadlineExceededError, resolvePromptDeadlineMs, } from './server/prompt-deadline.js';63export { detectFromLoopback } from './server/request-helpers.js';64export { InvalidCursorError, listWorkspaceSessionsForResponse, } from './server/session-list.js';65export { getActiveSseCount } from './routes/sse-events.js';66/**67 * Module-scoped once-per-process guard for the `createServeApp`68 * default-trust stderr warning. Without this, tests calling69 * `createServeApp` repeatedly would flood stderr with identical lines.70 */71let warnedDefaultTrust = false;72function describeRegistryPrimaryForConflict(registry) {73 return (`registry primary cwd=${JSON.stringify(registry.primary.workspaceCwd)}, ` +74 `workspaceId=${JSON.stringify(registry.primary.workspaceId)}`);75}76function getRuntimeEffectiveEnv(metadata) {77 return metadata?.effectiveEnv;78}79/**80 * Build the Express app for `qwen serve`. Pure function — no side effects on81 * the network or process; `runQwenServe` does the listen/signal handling.82 *83 * `getPort` is invoked lazily by the host-allowlist middleware so callers84 * binding to port 0 (ephemeral) can supply the actual port after `listen()`85 * resolves. Defaults to `opts.port` for callers (e.g. tests) that pin a port86 * up front.87 *88 * Route modules are registered below in middleware order. Keep this file as89 * the assembly point so auth/rate-limit/body-parser/REST/ACP/Web Shell order90 * stays reviewable in one place.91 *92 * **Workspace validation contract.** `createServeApp` itself does NOT93 * verify that `opts.workspace` exists or is a directory — it94 * canonicalizes via `canonicalizeWorkspace`, which falls back to95 * `path.resolve` on ENOENT so the app boots even against a missing96 * path. `runQwenServe` is the production entry point and DOES97 * perform the `fs.statSync` + `isDirectory()` boot-loud check before98 * calling this function. Tests inject synthetic paths (`/work/bound`99 * etc.) on purpose: they want to exercise the route layer's100 * canonicalization and `workspace_mismatch` translation without101 * needing a real directory on disk. If a future entry point binds102 * `createServeApp` directly to user input, it MUST replicate the103 * `runQwenServe` validation (or call into a shared helper if one is104 * extracted) — otherwise a non-existent `--workspace` would boot105 * a "healthy"-looking daemon whose every spawn fails with cryptic106 * child-process ENOENT.107 */108// Mirrors the bridge's session-idle reaper default (30 min). Used only to109// size the scheduled-task keepalive interval when no explicit timeout is set.110const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;111// Bounds for the keepalive interval: ≥30s (avoid busy-looping on a tiny custom112// timeout) and ≤10min (stay well inside the 30-min default reaper window).113const KEEPALIVE_MIN_INTERVAL_MS = 30_000;114const KEEPALIVE_MAX_INTERVAL_MS = 10 * 60_000;115/**116 * Sizes the keepalive heartbeat interval so a resident task session is beaten117 * BEFORE the idle reaper closes it. Targets a third of the reaper window, but118 * never exceeds HALF of it — so at least one heartbeat lands in time even for a119 * small custom timeout, where the 30s floor would otherwise overshoot the whole120 * window and let the session be reaped before the first beat. When the reaper is121 * disabled (idle timeout ≤ 0) sessions are never reaped, so heartbeats aren't122 * needed — the loop still runs (to revive re-enabled bound sessions) but at the123 * relaxed max cadence.124 */125export function computeKeepaliveIntervalMs(idleTimeoutMs) {126 if (idleTimeoutMs <= 0)127 return KEEPALIVE_MAX_INTERVAL_MS;128 const target = Math.min(Math.max(KEEPALIVE_MIN_INTERVAL_MS, Math.floor(idleTimeoutMs / 3)), KEEPALIVE_MAX_INTERVAL_MS);129 return Math.max(1, Math.min(target, Math.floor(idleTimeoutMs / 2)));130}131export function createServeApp(opts, getPort = () => opts.port, deps = {}) {132 const app = express();133 // Forward `maxSessions` into the default-constructed bridge so134 // direct callers of `createServeApp` (tests, embeds) get the same135 // cap they configured via `ServeOptions`. Previously the default136 // bridge silently fell back to `DEFAULT_MAX_SESSIONS` (20) and137 // only the `runQwenServe` path piped the option through.138 //139 // The daemon is bound to exactly one workspace. The value advertised140 // on `/capabilities`, used for the `POST /session` cwd fallback,141 // AND passed into the bridge must be the SAME canonical form.142 // `deps.boundWorkspace` is the pre-canonicalized fast-path from143 // `runQwenServe`; when omitted we canonicalize ourselves.144 const injectedWorkspaceRegistry = deps.workspaceRegistry;145 const boundWorkspace = injectedWorkspaceRegistry?.primary.workspaceCwd ??146 deps.boundWorkspace ??147 canonicalizeWorkspace(opts.workspace ?? process.cwd());148 if (injectedWorkspaceRegistry) {149 const primary = injectedWorkspaceRegistry.primary;150 const registryConflictCandidates = [151 {152 depName: 'deps.boundWorkspace',153 depValue: deps.boundWorkspace,154 registryValue: primary.workspaceCwd,155 detail: `deps.boundWorkspace=${JSON.stringify(deps.boundWorkspace)}`,156 },157 {158 depName: 'deps.bridge',159 depValue: deps.bridge,160 registryValue: primary.bridge,161 detail: 'deps.bridge is a different object',162 },163 {164 depName: 'deps.workspace',165 depValue: deps.workspace,166 registryValue: primary.workspaceService,167 detail: 'deps.workspace is a different object',168 },169 {170 depName: 'deps.fsFactory',171 depValue: deps.fsFactory,172 registryValue: primary.routeFileSystemFactory,173 detail: 'deps.fsFactory is a different object',174 },175 {176 depName: 'deps.clientMcpSenderRegistry',177 depValue: deps.clientMcpSenderRegistry,178 registryValue: primary.clientMcpSenderRegistry,179 detail: 'deps.clientMcpSenderRegistry is a different object',180 },181 ];182 for (const candidate of registryConflictCandidates) {183 if (candidate.depValue === undefined ||184 candidate.depValue === candidate.registryValue) {185 continue;186 }187 throw new Error('createServeApp: workspaceRegistry conflicts with ' +188 `${candidate.depName}: ${describeRegistryPrimaryForConflict(injectedWorkspaceRegistry)}; ${candidate.detail}.`);189 }190 }191 // Construct `fsFactory` BEFORE the bridge so the bridge can wire it192 // through `BridgeFileSystem` for ACP-side writeTextFile/readTextFile.193 // Default trust is `false` (test-safe). Embeds without `deps.fsFactory`194 // or `deps.bridge` will see agent writes rejected with195 // `untrusted_workspace` — warn once so the asymmetry is visible.196 if (!injectedWorkspaceRegistry &&197 !deps.fsFactory &&198 !deps.bridge &&199 !warnedDefaultTrust) {200 warnedDefaultTrust = true;201 process.stderr.write('qwen serve: createServeApp default fsFactory uses trusted=false ' +202 '— agent ACP writeTextFile calls will reject with untrusted_workspace. ' +203 'Inject deps.fsFactory (with explicit trust) or deps.bridge to override.\n');204 }205 const fsFactory = injectedWorkspaceRegistry?.primary.routeFileSystemFactory ??206 resolveBridgeFsFactory({207 boundWorkspaces: [boundWorkspace],208 injected: deps.fsFactory,209 trusted: false,210 });211 const tokenConfigured = typeof opts.token === 'string' && opts.token.length > 0;212 const sessionShellCommandEnabled = opts.enableSessionShell === true && tokenConfigured;213 // Reverse tool channel (issue #5626, Phase 2). Process-scoped registry that214 // bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP215 // child's `client_mcp/message` ext-method. Prefer the registry `runQwenServe`216 // already wired into its injected bridge (`deps.clientMcpSenderRegistry`) so217 // the bridge that answers the child and the WS provider share ONE map.218 // Standalone `createServeApp` (no injected bridge) builds its own and wires219 // it into the bridge it creates below. Inert until a WS client sends220 // `mcp_register` (gated by `clientMcpOverWs`).221 // Guard the split-brain case: an injected `deps.bridge` was already wired to222 // its own sender, so building a fresh registry here would leave the bridge223 // and this registry pointing at different maps. A caller injecting the bridge224 // must inject the matching registry too. Only enforced when `clientMcpOverWs`225 // is active — that's the only path that processes `mcp_*` frames, so without226 // it the registry is inert and a mismatch can't manifest (and the vast227 // majority of tests inject a fake bridge without ever touching client-MCP).228 if (opts.clientMcpOverWs === true &&229 deps.bridge &&230 !injectedWorkspaceRegistry &&231 !deps.clientMcpSenderRegistry) {232 throw new Error('createServeApp: deps.bridge requires deps.clientMcpSenderRegistry ' +233 'when clientMcpOverWs is enabled (the bridge is already wired to its ' +234 'own sender; a fresh registry here would be an orphan).');235 }236 const clientMcpSenderRegistry = injectedWorkspaceRegistry?.primary.clientMcpSenderRegistry ??237 deps.clientMcpSenderRegistry ??238 new ClientMcpSenderRegistry();239 const primaryRuntimeEnvMetadata = injectedWorkspaceRegistry?.primary.env ?? deps.primaryRuntimeEnv;240 const primaryEffectiveEnv = getRuntimeEffectiveEnv(primaryRuntimeEnvMetadata);241 const { languageCodes, currentServeFeatures, invalidateServeFeaturesCache } = createServeFeatures({242 opts,243 boundWorkspace,244 persistSettingAvailable: deps.persistSetting !== undefined,245 // Registry injection supplies the primary workspace service through the246 // runtime, so it has the same reload surface as legacy deps.workspace.247 reloadAvailable: deps.workspace !== undefined || injectedWorkspaceRegistry !== undefined,248 sessionShellCommandEnabled,249 });250 const statusProvider = deps.statusProvider ??251 createDaemonStatusProvider(primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {});252 let defaultBridgeForAdmission;253 const totalSessionAdmission = !deps.bridge && !injectedWorkspaceRegistry254 ? createTotalSessionAdmissionController({255 maxTotalSessions: opts.maxTotalSessions,256 getBridges: () => defaultBridgeForAdmission ? [defaultBridgeForAdmission] : [],257 })258 : undefined;259 const bridge = injectedWorkspaceRegistry?.primary.bridge ??260 deps.bridge ??261 createAcpSessionBridge({262 maxSessions: opts.maxSessions,263 ...(totalSessionAdmission264 ? { freshSessionAdmission: totalSessionAdmission.admit }265 : {}),266 maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,267 eventRingSize: opts.eventRingSize,268 permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs,269 boundWorkspace,270 sessionShellCommandEnabled,271 // Wire the production status provider so direct embeds / tests272 // that don't inject `deps.bridge` get daemon env + preflight cells.273 statusProvider,274 // Wire the WorkspaceFileSystem adapter so ACP writeTextFile /275 // readTextFile pick up trust / TOCTOU / audit.276 fileSystem: createBridgeFileSystemAdapter(fsFactory),277 // Reverse tool channel: answer the child's `client_mcp/message`278 // ext-method by reaching the WS connection that hosts the named server.279 clientMcpSender: clientMcpSenderRegistry.lookup,280 });281 if (!injectedWorkspaceRegistry && !deps.bridge) {282 defaultBridgeForAdmission = bridge;283 }284 const archiveCoordinator = new SessionArchiveCoordinator();285 installSelfOriginStripMiddleware(app, getPort);286 // Park the factory on `app.locals` so route handlers can pick it up287 // via `req.app.locals.fsFactory` without re-threading the value288 // through every handler signature.289 app.locals.fsFactory =290 fsFactory;291 // Surface the bound workspace on `app.locals` so file routes can292 // compute workspace-relative response paths without re-resolving.293 app.locals.boundWorkspace = boundWorkspace;294 const { deviceFlowRegistry, getSupportedDeviceFlowProviders } = setupDeviceFlowRegistry({295 app,296 bridge,297 registry: deps.deviceFlowRegistry,298 providers: deps.deviceFlowProviders,299 });300 const { daemonLog } = deps;301 const sendBridgeError = (res, err, ctx) => sendBridgeErrorResponse(res, err, ctx, daemonLog);302 const sendPermissionVoteError = (res, err, ctx) => sendPermissionVoteErrorResponse(res, err, ctx, daemonLog);303 const workspace = injectedWorkspaceRegistry?.primary.workspaceService ??304 deps.workspace ??305 createDaemonWorkspaceService({306 boundWorkspace,307 contextFilename: deps.contextFilename ?? 'QWEN.md',308 statusProvider,309 workspaceProvidersStatusProvider: createWorkspaceProvidersStatusProvider(primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {}),310 workspaceSkillsStatusProvider: createWorkspaceSkillsStatusProvider(),311 isChannelLive: () => bridge.isChannelLive(),312 persistDisabledTools: deps.persistDisabledTools ??313 (async () => {314 throw new Error('setWorkspaceToolEnabled requires persistDisabledTools in ServeAppDeps');315 }),316 queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle),317 invokeWorkspaceCommand: (method, params, invokeOpts) => bridge.invokeWorkspaceCommand(method, params, invokeOpts),318 refreshExtensionsForAllSessions: () => bridge.refreshExtensionsForAllSessions(),319 ...(deps.persistSetting ? { persistSetting: deps.persistSetting } : {}),320 ...(deps.persistSettings321 ? { persistSettings: deps.persistSettings }322 : {}),323 publishWorkspaceEvent: (event) => {324 if (event.type === 'settings_changed' ||325 event.type === 'settings_reloaded') {326 invalidateServeFeaturesCache();327 }328 bridge.publishWorkspaceEvent(event);329 },330 });331 const workspaceRegistry = injectedWorkspaceRegistry ??332 createSingleWorkspaceRegistry({333 workspaceId: hashDaemonWorkspace(boundWorkspace),334 workspaceCwd: boundWorkspace,335 primary: true,336 trusted: deps.primaryWorkspaceTrusted ?? false,337 env: primaryRuntimeEnvMetadata ?? {338 mode: 'parent-process',339 overlayKeys: [],340 },341 bridge,342 workspaceService: workspace,343 routeFileSystemFactory: fsFactory,344 clientMcpSenderRegistry,345 });346 app.locals.workspaceRegistry =347 workspaceRegistry;348 const primaryRuntime = workspaceRegistry.primary;349 const primaryBoundWorkspace = primaryRuntime.workspaceCwd;350 const primaryBridge = primaryRuntime.bridge;351 const primaryWorkspace = primaryRuntime.workspaceService;352 const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory;353 // Order matters: rejection guards (CORS / Host allowlist / bearer auth)354 // run BEFORE the JSON body parser. Otherwise an unauthenticated POST355 // gets a full 10MB `JSON.parse` before the 401 fires — a trivially356 // amplified CPU/memory cost from any wrong-token client.357 //358 // When `--allow-origin` is configured, install the359 // allowlist middleware instead of the deny-wall. The allowlist owns360 // both halves of the policy (matched → CORS headers + pass-through or361 // 204 preflight; unmatched → 403 with the same error envelope as the362 // wall). When `--allow-origin` is empty/undefined, the deny-wall stays363 // installed. Pattern parsing happens in `run-qwen-serve.ts` for validation;364 // here we still keep the wildcard/no-token invariant for embedded365 // callers that construct the app directly.366 if (opts.allowOrigins && opts.allowOrigins.length > 0) {367 const parsedAllowOrigins = parseAllowOriginPatterns(opts.allowOrigins);368 if (parsedAllowOrigins.allowAny && !opts.token) {369 throw new Error(`Refusing to start with --allow-origin '*' but no bearer token ` +370 `configured. '*' admits any cross-origin browser to the API; ` +371 `without a token, any local page can drive the daemon. Set a ` +372 `token or list specific origins instead of '*'.`);373 }374 app.use(allowOriginCors(parsedAllowOrigins));375 }376 else {377 app.use(denyBrowserOriginCors);378 }379 app.use(hostAllowlist(opts.hostname, getPort));380 const healthDemoRoutes = createHealthDemoRoutes({381 opts,382 getPort,383 bridge: primaryBridge,384 getActiveSseCount,385 getRateLimiter: () => rateLimiter,386 });387 if (healthDemoRoutes.exposeHealthPreAuth) {388 healthDemoRoutes.register(app);389 }390 installAccessLogMiddleware(app, daemonLog);391 // Serve the Web Shell static assets (/ and /assets) BEFORE bearerAuth. The392 // static shell carries no secrets and a browser cannot attach an393 // Authorization header to a `<script src>` subresource or an address-bar394 // navigation, so gating it would just break the UI — the front-end's own395 // API calls still carry the bearer (getDaemonAuthHeaders) and every API396 // route below stays token-gated. The SPA deep-link fallback is registered397 // LATER (after all API routes, see mountWebShellSpaFallback) so authed398 // routes win over the shell. The assets dir is resolved by the caller399 // (runQwenServe) and injected via deps.webShellDir; `--no-web` sets400 // opts.serveWebShell=false to opt out.401 const webShellDir = opts.serveWebShell !== false ? deps.webShellDir : undefined;402 // Extension origins (chrome-extension://…) explicitly allowed via403 // --allow-origin may frame the Web Shell so the extension can host the UI in404 // a Chrome side panel (issue #5626). All other origins still get405 // frame-ancestors 'none' + X-Frame-Options: DENY.406 const webShellFrameAncestors = opts.allowOrigins && opts.allowOrigins.length > 0407 ? [...parseAllowOriginPatterns(opts.allowOrigins).origins].filter((o) => o.startsWith('chrome-extension://') ||408 o.startsWith('moz-extension://'))409 : [];410 if (webShellDir) {411 mountWebShellAssets(app, webShellDir, webShellFrameAncestors);412 }413 app.use(bearerAuth(opts.token));414 // Rate limiter: after auth (only count authenticated requests),415 // before body parser (reject early without burning JSON.parse CPU).416 const rateLimiter = installRateLimiter(app, opts, daemonLog);417 installJsonBodyParser(app);418 if (!healthDemoRoutes.exposeHealthPreAuth) {419 // Non-loopback OR loopback with `--require-auth`: register420 // `/health` and `/demo` AFTER `bearerAuth` so probes must carry421 // the token. Otherwise unauthenticated callers can ping any422 // reachable address:port to confirm a daemon exists (and `/demo`423 // leaks the full API surface).424 healthDemoRoutes.register(app);425 }426 // Mutation-route gate factory. Non-strict mode is passthrough;427 // `{ strict: true }` requires a token even on loopback defaults.428 const mutate = createMutationGate({429 tokenConfigured,430 requireAuth: opts.requireAuth === true,431 });432 app.use(daemonTelemetryMiddleware(() => primaryBoundWorkspace, deps.recordDaemonRequest));433 const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace);434 const acpHandleRef = {};435 const workspaceRememberLane = new WorkspaceRememberTaskLane(primaryBridge);436 // Plan C CDP tunnel (issue #5626): process-scoped registry pairing the437 // extension `/acp` connection with the `/cdp` puppeteer endpoint. Inert until438 // both ends connect (gated by `cdpTunnelOverWs`).439 const cdpTunnelRegistry = opts.cdpTunnelOverWs === true ? new CdpTunnelRegistry() : undefined;440 registerDaemonStatusRoutes(app, {441 opts,442 boundWorkspace: primaryBoundWorkspace,443 bridge: primaryBridge,444 workspace: primaryWorkspace,445 daemonLog,446 startup: deps.startup,447 qwenCodeVersion: deps.qwenCodeVersion,448 getAcpHandle: () => acpHandleRef.current,449 getRateLimiter: () => rateLimiter,450 getRestSseActive: getActiveSseCount,451 currentServeFeatures,452 getSupportedDeviceFlowProviders,453 deviceFlowRegistry,454 sessionShellCommandEnabled,455 getChannelWorkerSnapshot: deps.getChannelWorkerSnapshot,456 getPerfSnapshot: deps.getPerfSnapshot,457 getMetricsSeries: deps.getMetricsSeries,458 getTotalSessionAdmissionSnapshot: deps.getTotalSessionAdmissionSnapshot ?? totalSessionAdmission?.snapshot,459 });460 registerCapabilitiesRoutes(app, {461 qwenCodeVersion: deps.qwenCodeVersion,462 mode: opts.mode,463 currentServeFeatures,464 boundWorkspace: primaryBoundWorkspace,465 permissionPolicy: primaryBridge.permissionPolicy,466 maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,467 languageCodes,468 });469 registerWorkspaceStatusRoutes(app, {470 boundWorkspace: primaryBoundWorkspace,471 bridge: primaryBridge,472 workspace: primaryWorkspace,473 sendBridgeError,474 });475 // Workspace memory + agents CRUD routes.476 mountWorkspaceMemoryRoutes(app, {477 bridge: primaryBridge,478 boundWorkspace: primaryBoundWorkspace,479 mutate,480 parseClientId: parseClientIdHeader,481 safeBody,482 });483 mountWorkspaceMemoryRememberRoutes(app, {484 bridge: primaryBridge,485 lane: workspaceRememberLane,486 mutate,487 parseClientId: parseClientIdHeader,488 safeBody,489 });490 mountWorkspaceAgentsRoutes(app, {491 bridge: primaryBridge,492 boundWorkspace: primaryBoundWorkspace,493 mutate,494 parseClientId: parseClientIdHeader,495 safeBody,496 });497 registerWorkspaceDiagnosticStatusRoutes(app, {498 boundWorkspace: primaryBoundWorkspace,499 bridge: primaryBridge,500 workspace: primaryWorkspace,501 sendBridgeError,502 });503 registerWorkspaceExtensionRoutes(app, {504 boundWorkspace: primaryBoundWorkspace,505 bridge: primaryBridge,506 workspace: primaryWorkspace,507 mutate,508 safeBody,509 sendBridgeError,510 ...(deps.maxExtensionOperationHistory === undefined511 ? {}512 : { maxExtensionOperationHistory: deps.maxExtensionOperationHistory }),513 });514 // Workspace file routes (read-only + mutation).515 registerWorkspaceFileReadRoutes(app, {516 parseClientId: parseClientIdHeader,517 });518 registerWorkspaceFileWriteRoutes(app, {519 bridge: primaryBridge,520 mutate,521 parseClientId: parseClientIdHeader,522 safeBody,523 });524 registerWorkspaceSetupGithubRoutes(app, {525 boundWorkspace: primaryBoundWorkspace,526 bridge: primaryBridge,527 mutate,528 parseClientId: parseClientIdHeader,529 safeBody,530 });531 registerWorkspaceTrustRoutes(app, {532 boundWorkspace: primaryBoundWorkspace,533 workspace: primaryWorkspace,534 mutate,535 safeBody,536 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),537 });538 const broadcastSettingsChanged = (key, value, scope, clientId) => {539 invalidateServeFeaturesCache();540 primaryBridge.publishWorkspaceEvent({541 type: 'settings_changed',542 data: { key, value, scope },543 ...(clientId ? { originatorClientId: clientId } : {}),544 });545 };546 if (deps.persistSetting) {547 const persistSetting = deps.persistSetting;548 registerWorkspaceSettingsRoutes(app, {549 boundWorkspace: primaryBoundWorkspace,550 mutate,551 safeBody,552 persistSetting: async (...args) => {553 await persistSetting(...args);554 },555 broadcastSettingsChanged,556 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),557 });558 }559 registerWorkspacePermissionsRoutes(app, {560 boundWorkspace: primaryBoundWorkspace,561 mutate,562 safeBody,563 workspace: primaryWorkspace,564 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),565 });566 registerWorkspaceVoiceRoutes(app, {567 boundWorkspace: primaryBoundWorkspace,568 mutate,569 safeBody,570 persistSetting: deps.persistSetting,571 persistSettings: deps.persistSettings,572 transcribe: deps.voiceTranscriber,573 broadcastSettingsChanged,574 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),575 });576 // A2UI action inbound (the upstream half of A2UI-over-MCP): user577 // interactions from web clients are proxied to the UI MCP server's578 // standard `action` tool.579 registerA2uiActionRoutes(app, {580 boundWorkspace: primaryBoundWorkspace,581 mutate,582 safeBody,583 env: getRuntimeEffectiveEnv(primaryRuntime.env),584 // UI-server discovery uses the daemon's workspace MCP status, which585 // includes servers registered at runtime.586 getMcpServers: async () => {587 const ctx = buildWorkspaceCtx('POST /session/:id/a2ui-action');588 const status = await primaryWorkspace.getWorkspaceMcpStatus(ctx);589 return (status.servers ?? []);590 },591 });592 registerWorkspaceAuthRoutes(app, {593 mutate,594 deviceFlowRegistry,595 getSupportedDeviceFlowProviders,596 sendBridgeError,597 boundWorkspace: primaryBoundWorkspace,598 allowPrivateAuthBaseUrl: opts.allowPrivateAuthBaseUrl === true,599 installAuthProvider: deps.installAuthProvider,600 });601 registerSessionRoutes(app, {602 boundWorkspace: primaryBoundWorkspace,603 bridge: primaryBridge,604 archiveCoordinator,605 mutate,606 sendBridgeError,607 daemonLog,608 promptDeadlineMs: opts.promptDeadlineMs,609 sessionShellCommandEnabled,610 languageCodes,611 });612 registerWorkspaceMcpControlRoutes(app, {613 boundWorkspace: primaryBoundWorkspace,614 bridge: primaryBridge,615 workspace: primaryWorkspace,616 mutate,617 safeBody,618 sendBridgeError,619 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),620 });621 registerWorkspaceLifecycleRoutes(app, {622 boundWorkspace: primaryBoundWorkspace,623 workspace: primaryWorkspace,624 mutate,625 safeBody,626 sendBridgeError,627 invalidateServeFeaturesCache,628 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),629 });630 registerWorkspaceToolsRoutes(app, {631 boundWorkspace: primaryBoundWorkspace,632 workspace: primaryWorkspace,633 mutate,634 safeBody,635 sendBridgeError,636 parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge),637 });638 // Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page).639 // Reads/writes the per-project cron file only; firing stays with the640 // session-side scheduler. Non-strict mutate: creating a scheduled prompt641 // is the same capability class as POST /session/:id/prompt.642 //643 // The bridge is passed ONLY when resident task-session management is enabled.644 // Binding a task to a dedicated session is only safe when something keeps that645 // session resident and reloads it after a restart (the keepalive + rehydration646 // below); without it, a bound task would fire only inside a session nothing647 // revives and silently go dormant. So embedders that leave the manager off648 // get UNBOUND tasks (shared-owner firing) instead.649 registerScheduledTasksRoutes(app, {650 boundWorkspace: primaryBoundWorkspace,651 mutate,652 safeBody,653 bridge: deps.manageScheduledTaskSessions ? bridge : undefined,654 });655 // Read-only token-usage dashboard (Daemon Status "统计" tab). Aggregate local656 // usage only; open GET like /daemon/status, with its own short TTL cache.657 registerUsageStatsRoutes(app);658 // Resident management of scheduled-task-owned sessions — opt-in, so tests and659 // embeds that call createServeApp neither spawn sessions on boot nor hold a660 // heartbeat timer (both would read the bound workspace's real tasks file).661 if (deps.manageScheduledTaskSessions) {662 // Keepalive: keep task sessions resident so their in-child schedulers keep663 // ticking rather than being idle-reaped, AND revive a re-enabled bound664 // session the reaper already let go. The revive loop is needed even when the665 // reaper is disabled (idle timeout ≤ 0), because archiving a task closes its666 // session — so this always runs when task sessions are managed, not only667 // when a reaper is active.668 const idleTimeoutMs = opts.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;669 const keepalive = startScheduledTaskKeepalive({670 bridge,671 boundWorkspace,672 intervalMs: computeKeepaliveIntervalMs(idleTimeoutMs),673 });674 // Park the stop fn on `app.locals` (same pattern as `fsFactory` /675 // `boundWorkspace` / `acpHandle` above) so the shutdown sequence in676 // run-qwen-serve.ts can invoke it without threading it back through the677 // createServeApp return type.678 app.locals.stopScheduledTaskKeepalive = keepalive.stop;679 // Rehydrate task-owned sessions on boot so their schedulers re-arm after a680 // restart (a bound task fires only in its own session, which nothing else681 // reloads). Fire-and-forget so it never delays the server coming up; a682 // no-op when there are no bound tasks. Deliberately not awaited.683 void rehydrateScheduledTaskSessions({684 bridge,685 boundWorkspace,686 onError: (sessionId, err) => {687 process.stderr.write(`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`);688 },689 // Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already690 // catches readCronTasks failures and per-session load errors internally691 // (returning { loaded, failed }), so this only guards an unexpected throw692 // from the function entry itself. Log rather than swallow it — a silent693 // failure here leaves every bound task dormant with no diagnostic.694 }).catch((err) => {695 process.stderr.write(`qwen serve: unexpected scheduled-task rehydration failure: ${err instanceof Error ? err.message : String(err)}\n`);696 });697 }698 registerPermissionRoutes(app, {699 bridge: primaryBridge,700 mutate,701 sendPermissionVoteError,702 });703 registerSseEventsRoutes(app, {704 bridge: primaryBridge,705 daemonLog,706 writerIdleTimeoutMs: opts.writerIdleTimeoutMs,707 sendBridgeError,708 });709 // Official ACP Streamable HTTP transport (RFD #721) mounted at `/acp`710 // alongside the REST surface, sharing this same `bridge` instance.711 // Additive + toggleable (`QWEN_SERVE_ACP_HTTP=0` opts out).712 // See `docs/design/daemon-acp-http/README.md` for the dual-transport713 // decision. Mounted AFTER the REST routes (distinct path, no overlap)714 // and BEFORE the final error handler so malformed `/acp` bodies still715 // route through the JSON error contract below.716 acpHandleRef.current = mountAcpHttp(app, primaryBridge, {717 boundWorkspace: primaryBoundWorkspace,718 archiveCoordinator,719 workspace: primaryWorkspace,720 fsFactory: primaryRouteFileSystemFactory,721 deviceFlowRegistry,722 token: opts.token,723 // Mirror the REST CORS allowlist onto the WS CSRF wall so an724 // explicitly permitted origin (e.g. the extension's725 // `chrome-extension://<id>`) can open the reverse tool channel.726 allowedOrigins: opts.allowOrigins && opts.allowOrigins.length > 0727 ? parseAllowOriginPatterns(opts.allowOrigins)728 : undefined,729 hostname: opts.hostname,730 sessionShellCommandEnabled,731 workspaceRememberLane,732 checkRate: rateLimiter?.checkRate,733 clientMcpOverWs: opts.clientMcpOverWs === true,734 // Reverse tool channel (issue #5626, Phase 2). Per-connection provider:735 // on `mcp_register` it records the WS registrar's sender in the shared736 // registry and adds an SDK-type runtime MCP server in the ACP child737 // (originator = the connection id). Only meaningful when738 // `clientMcpOverWs` is on; the WS layer never builds a provider otherwise.739 ...(opts.clientMcpOverWs === true740 ? {741 clientMcpProviderFactory: (connectionId) => createClientMcpServerProvider(primaryRuntime.clientMcpSenderRegistry, primaryBridge, connectionId),742 }743 : {}),744 // Plan C CDP tunnel (issue #5626): the `/cdp` branch + `cdp_*` routing745 // activate only when the flag is on and a registry is supplied.746 cdpTunnelOverWs: opts.cdpTunnelOverWs === true,747 ...(cdpTunnelRegistry ? { cdpTunnelRegistry } : {}),748 // Browser captures audio and streams raw PCM here; the daemon transcribes749 // server-side via the reused CLI voice pipeline. Shares the ACP upgrade750 // listener's loopback/CSRF/bearer checks.751 extraWsRoutes: [752 {753 path: '/voice/stream',754 onConnection: createVoiceWsConnectionHandler(primaryBoundWorkspace, {755 env: getRuntimeEffectiveEnv(primaryRuntime.env),756 }),757 },758 ],759 });760 if (acpHandleRef.current) {761 app.locals['acpHandle'] = acpHandleRef.current;762 }763 // Web Shell SPA deep-link fallback — registered AFTER every API route (and764 // just before the error handler) so real routes, including their bearerAuth765 // 401s, always win; only genuine 404 misses fall through to the shell. This766 // is what keeps an attacker-controlled `Accept: text/html` from coaxing the767 // 200 shell out of an authed route.768 if (webShellDir) {769 mountWebShellSpaFallback(app, webShellDir, webShellFrameAncestors);770 }771 installFinalErrorHandler(app);772 if (rateLimiter) {773 setRateLimiter(app, rateLimiter);774 }775 return app;776}777//# sourceMappingURL=server.js.map