From c6dc8098d475bfc95b18a8fd2eba7b86ed5841ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Thu, 25 Jun 2026 12:44:33 +0800 Subject: [PATCH 01/18] feat(serve): add runtime context injection for per-turn system-reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a key-value RuntimeContext store on Config that external callers (daemon API, SDK) can populate with session-scoped dynamic context such as operator identity and always-apply rules. Entries are injected as blocks on every UserQuery/Cron turn, replacing the current pattern of rewriting QWEN.md for transient state. Full stack: Config store → per-turn injection in sendMessageStream → ACP ext-method → bridge implementation → daemon HTTP route POST /session/:id/runtime-context → SDK DaemonClient/SessionClient. --- packages/acp-bridge/src/bridge.ts | 21 + packages/acp-bridge/src/bridgeTypes.ts | 11 + packages/acp-bridge/src/status.ts | 1 + packages/cli/src/acp-integration/acpAgent.ts | 35 + packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/server.ts | 5953 ++++++++++++++++- packages/core/src/config/config.test.ts | 798 +-- packages/core/src/config/config.ts | 76 +- packages/core/src/core/client.ts | 8 + .../sdk-typescript/src/daemon/DaemonClient.ts | 24 + .../src/daemon/DaemonSessionClient.ts | 10 + 11 files changed, 5848 insertions(+), 1090 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 85b4a2c1bc2..b0a843e6d18 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4785,6 +4785,27 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }, + async setSessionRuntimeContext(sessionId, entries, _context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + + const response = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { sessionId, entries }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + ), + getTransportClosedReject(entry), + ])) as { keys: string[] }; + + return { sessionId, keys: response.keys }; + }, + async generateSessionRecap(sessionId, _context) { // Thin pass-through to `qwen/control/session/ // recap` — the ACP child runs `generateSessionRecap` against the diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 7f1491e90eb..ec8e5718ef9 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -740,6 +740,17 @@ export interface AcpSessionBridge { persisted: boolean; }>; + /** + * Set, update, or remove runtime context entries on a live session. + * Entries are injected as per-turn blocks on the + * next model call. Passing an empty string for a value removes that key. + */ + setSessionRuntimeContext( + sessionId: string, + entries: Record, + context?: BridgeClientRequestContext, + ): Promise<{ sessionId: string; keys: string[] }>; + /** * Generate a one-sentence "where did I leave off" recap of a live * session. Forwards through `qwen/control/session/recap`, which diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 0eac9edd4fe..275d1d2f6ff 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -146,6 +146,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionGoalClear: 'qwen/control/session/goal/clear', workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', + sessionRuntimeContext: 'qwen/control/session/runtime_context', workspaceReload: 'qwen/control/workspace/reload', workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh', /** diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0c343a9b3e5..dff08222bfe 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6143,6 +6143,41 @@ class QwenAgent implements Agent { return { language: resolvedLanguage, outputLanguage, refreshed }; } + case SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext: { + const sessionId = params['sessionId']; + const entries = params['entries']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + if ( + typeof entries !== 'object' || + entries === null || + Array.isArray(entries) + ) { + throw RequestError.invalidParams( + undefined, + '`entries` must be a non-null object', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const appliedKeys: string[] = []; + for (const [key, value] of Object.entries( + entries as Record, + )) { + if (typeof value !== 'string') continue; + if (value === '') { + config.removeRuntimeContextEntry(key); + appliedKeys.push(key); + } else if (config.setRuntimeContextEntry(key, value)) { + appliedKeys.push(key); + } + } + return { keys: appliedKeys }; + } case SERVE_CONTROL_EXT_METHODS.sessionRecap: { // Generate a one-sentence "where did I leave off" summary. // Best-effort: returns `null` on short history or model failure. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index a4562c2435a..44a4999b3ac 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -245,6 +245,7 @@ export const SERVE_CAPABILITY_REGISTRY = { writer_idle_timeout: { since: 'v1' }, non_blocking_prompt: { since: 'v1' }, session_language: { since: 'v1' }, + session_runtime_context: { since: 'v1' }, session_rewind: { since: 'v1' }, workspace_hooks: { since: 'v1' }, session_hooks: { since: 'v1' }, diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 75c0ba02097..e664db28bf2 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -4,11 +4,39 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as crypto from 'node:crypto'; +import * as net from 'node:net'; +import * as path from 'node:path'; import express from 'express'; -import type { Application } from 'express'; +import type { Application, NextFunction, Request, Response } from 'express'; +import { + APPROVAL_MODES, + ALL_PROVIDERS, + BTW_MAX_INPUT_LENGTH, + ExtensionUpdateState, + ExtensionManager, + checkForExtensionUpdate, + redactUrlCredentials, + SettingScope, + parseInstallSource, + SessionService, + shouldShowStep, + TrustGateError, + addDaemonRequestAttribute, + emitDaemonLog, + hashDaemonWorkspace, + recordDaemonBridgeError, + recordDaemonError, + recordDaemonHttpRequest, + recordDaemonHttpResponse, + withDaemonRequestSpan, + type ApprovalMode, + type Extension, + type ExtensionInstallMetadata, + type ExtensionSetting, +} from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; import type { DaemonLogger } from './daemon-logger.js'; -import type { DaemonStartupSnapshot } from './daemon-status.js'; -import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js'; import { allowOriginCors, bearerAuth, @@ -17,118 +45,185 @@ import { hostAllowlist, parseAllowOriginPatterns, } from './auth.js'; -import type { - DeviceFlowProvider, +import { DeviceFlowRegistry, + setDeviceFlowRegistry, + TooManyActiveDeviceFlowsError, + UnsupportedDeviceFlowProviderError, + UpstreamDeviceFlowError, + type DeviceFlowEventSink, + type DeviceFlowProvider, + type DeviceFlowProviderId, + type DeviceFlowPublicView, } from './auth/device-flow.js'; -import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; +import { + mapDomainErrorToErrorKind, + type DaemonStatusProvider, +} from '@qwen-code/acp-bridge'; +import { QwenOAuthDeviceFlowProvider } from './auth/qwen-device-flow-provider.js'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; import { createDaemonStatusProvider } from './daemon-status-provider.js'; import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; +import { isServeDebugMode } from './debug-mode.js'; +import { SUPPORTED_LANGUAGES } from '../i18n/index.js'; +import { loadSettings } from '../config/settings.js'; +import { getWorkspaceTrustStatus } from '../config/trustedFolders.js'; +import { isLoopbackBind } from './loopback-binds.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'; + buildDaemonStatusResponse, + type DaemonStartupSnapshot, + parseDaemonStatusDetail, +} from './daemon-status.js'; import { canonicalizeWorkspace, + CancelSentinelCollisionError, + BranchWhilePromptActiveError, createAcpSessionBridge, + InvalidClientIdError, + InvalidPermissionOptionError, + InvalidSessionMetadataError, + InvalidSessionScopeError, + MAX_WORKSPACE_PATH_LENGTH, + McpServerNotFoundError, + McpServerRestartFailedError, + PermissionForbiddenError, + PermissionPolicyNotImplementedError, + PromptQueueFullError, + RestoreInProgressError, + SessionBusyError, + InvalidRewindTargetError, + SessionLimitExceededError, + SessionNotFoundError, + SessionShellClientRequiredError, + SessionShellDisabledError, + WorkspaceInitConflictError, + WorkspaceInitPathEscapeError, + WorkspaceInitSymlinkError, + WorkspaceInitRaceError, + WorkspaceMismatchError, + type BridgeSessionSummary, type AcpSessionBridge, } from './acp-session-bridge.js'; import { + getAdvertisedServeFeatures, + getServeProtocolVersions, +} from './capabilities.js'; +import { SubscriberLimitExceededError, type BridgeEvent } from './event-bus.js'; +import { + CAPABILITIES_SCHEMA_VERSION, + type CapabilitiesEnvelope, + type ServeAuthProviderCatalog, + type ServeAuthProviderDescriptor, type ServeAuthProviderInstallRequest, type ServeAuthProviderInstallResult, type ServeOptions, } from './types.js'; +import { getDemoHtml } from './demo.js'; import { mountWebShellAssets, mountWebShellSpaFallback, } from './web-shell-static.js'; import { mountWorkspaceMemoryRoutes } from './workspace-memory.js'; -import { - mountWorkspaceMemoryRememberRoutes, - WorkspaceRememberTaskLane, -} from './workspace-remember.js'; import { mountWorkspaceAgentsRoutes } 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 { + createWorkspaceFileSystemFactory, + type WorkspaceFileSystemFactory, +} from './fs/index.js'; import { registerWorkspaceFileReadRoutes } from './routes/workspace-file-read.js'; import { registerWorkspaceFileWriteRoutes } from './routes/workspace-file-write.js'; import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js'; import { registerWorkspaceTrustRoutes } from './routes/workspace-trust.js'; -import { registerPermissionRoutes } from './routes/permission.js'; -import { registerSessionRoutes } from './routes/session.js'; -import { - registerWorkspaceDiagnosticStatusRoutes, - registerWorkspaceStatusRoutes, -} from './routes/workspace-status.js'; import { createDaemonWorkspaceService, type DaemonWorkspaceService, + type WorkspaceRequestContext, } from './workspace-service/index.js'; -import { registerCapabilitiesRoutes } from './routes/capabilities.js'; import { registerWorkspacePermissionsRoutes } from './routes/workspace-permissions.js'; import { registerWorkspaceSettingsRoutes } from './routes/workspace-settings.js'; -import { - getActiveSseCount, - registerSseEventsRoutes, -} from './routes/sse-events.js'; import { registerWorkspaceVoiceRoutes, type WorkspaceVoiceRouteDeps, } from './routes/workspace-voice.js'; +import { hasConfiguredBatchVoiceTranscriptionModel } from '../services/voice-service.js'; import { registerA2uiActionRoutes } from './routes/a2ui-action.js'; -import { setRateLimiter } from './rate-limit.js'; import { - sendBridgeError as sendBridgeErrorResponse, - sendPermissionVoteError as sendPermissionVoteErrorResponse, - type SendBridgeError, -} from './server/error-response.js'; -import { resolveBridgeFsFactory } from './server/fs-factory.js'; + createRateLimiter, + setRateLimiter, + type RateLimiterInstance, +} from './rate-limit.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 { installSelfOriginStripMiddleware } from './server/self-origin.js'; -import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js'; -import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js'; -import { registerWorkspaceToolsRoutes } from './routes/workspace-tools.js'; - -export { - createDefaultFsAuditEmit, - 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'; + STATUS_SCHEMA_VERSION, + type ServeExtensionCapabilities, + type ServeExtensionEntry, + type ServeWorkspaceExtensionsStatus, +} from './status.js'; + +let activeSseCount = 0; +export function getActiveSseCount(): number { + return activeSseCount; +} + +function isWorkspaceVoiceTranscriptionAvailable( + boundWorkspace: string, +): boolean { + try { + return hasConfiguredBatchVoiceTranscriptionModel( + loadSettings(boundWorkspace), + ); + } catch (err) { + writeStderrLine( + `qwen serve: workspace voice transcription capability check failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } +} +/** + * Build a no-op fs-audit emitter that logs a warning every + * `WARN_EVERY` dropped events. The default factory uses this so a + * regression that silently strips audit events shows up in operator + * logs instead of disappearing. `runQwenServe` replaces this with a + * real per-session emit, so legitimate production traffic never hits + * the warning. + */ +export function createDefaultFsAuditEmit(): (event: BridgeEvent) => void { + const WARN_EVERY = 100; + let droppedCount = 0; + return (event: BridgeEvent) => { + droppedCount += 1; + if (droppedCount === 1 || droppedCount % WARN_EVERY === 0) { + const data = event.data as + | { errorKind?: string; pathHash?: string; intent?: string } + | undefined; + const ctx: string[] = []; + if (data?.errorKind) ctx.push(`errorKind=${data.errorKind}`); + if (data?.intent) ctx.push(`intent=${data.intent}`); + if (data?.pathHash) ctx.push(`pathHash=${data.pathHash}`); + const ctxStr = ctx.length > 0 ? ` (${ctx.join(' ')})` : ''; + writeStderrLine( + `qwen serve: fs audit emit is the default no-op — ${droppedCount} event(s) dropped so far. ` + + `Latest type=${event.type}${ctxStr}. ` + + `Inject deps.fsFactory in createServeApp to wire audit into the EventBus.`, + ); + } + }; +} + +/** + * Shared `WorkspaceFileSystemFactory` construction used by both + * `runQwenServe` and `createServeApp`'s default bridge wiring. + * Centralizes the "use the injected factory if provided, otherwise + * build one with the given trust + audit-emit posture" logic. + * + * Trust is intentionally a **required** parameter — the two call + * sites have different correct defaults: + * - `runQwenServe` defaults to `trusted: true` + * - `createServeApp` defaults to `trusted: false` (test-safe) + */ /** * Module-scoped once-per-process guard for the `createServeApp` * default-trust stderr warning. Without this, tests calling @@ -136,6 +231,500 @@ export { getActiveSseCount } from './routes/sse-events.js'; */ let warnedDefaultTrust = false; +export function resolveBridgeFsFactory(input: { + boundWorkspace: string; + injected?: WorkspaceFileSystemFactory; + trusted: boolean; + emit?: (event: BridgeEvent) => void; + customIgnoreFiles?: string[]; +}): WorkspaceFileSystemFactory { + if (input.injected) return input.injected; + return createWorkspaceFileSystemFactory({ + boundWorkspace: input.boundWorkspace, + trusted: input.trusted, + emit: input.emit ?? createDefaultFsAuditEmit(), + ...(input.customIgnoreFiles !== undefined + ? { customIgnoreFiles: input.customIgnoreFiles } + : {}), + }); +} + +const DEFAULT_SESSION_PAGE_SIZE = 20; +const MAX_SESSION_PAGE_SIZE = 100; + +export interface ListWorkspaceSessionsOptions { + cursor?: string; + size?: number; +} + +export interface ListWorkspaceSessionsResult { + sessions: BridgeSessionSummary[]; + nextCursor?: string; +} + +export class InvalidCursorError extends Error { + constructor(cursor: string) { + super(`Invalid cursor: "${cursor}" is not a valid numeric cursor`); + this.name = 'InvalidCursorError'; + } +} + +function parseSessionCursor(cursor: string): number | undefined { + if (cursor === '') return undefined; + const trimmed = cursor.trim(); + const parsed = Number(trimmed); + if ( + trimmed === '' || + !Number.isFinite(parsed) || + parsed < 0 || + parsed > Number.MAX_SAFE_INTEGER + ) { + throw new InvalidCursorError(cursor); + } + return parsed; +} + +export async function listWorkspaceSessionsForResponse( + bridge: AcpSessionBridge, + workspaceCwd: string, + options?: ListWorkspaceSessionsOptions, +): Promise { + const rawSize = options?.size; + const requestedSize = + typeof rawSize === 'number' && Number.isSafeInteger(rawSize) + ? rawSize + : DEFAULT_SESSION_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE); + + let numericCursor: number | undefined; + if (options?.cursor != null) { + numericCursor = parseSessionCursor(options.cursor); + } + const isFirstPage = numericCursor === undefined; + + const sessionService = new SessionService(workspaceCwd); + const persisted = await sessionService.listSessions({ + cursor: numericCursor, + size: pageSize, + }); + const bySessionId = new Map(); + + for (const item of persisted.items) { + bySessionId.set(item.sessionId, { + sessionId: item.sessionId, + workspaceCwd: item.cwd, + createdAt: item.startTime, + updatedAt: new Date(item.mtime).toISOString(), + displayName: item.customTitle || item.prompt, + clientCount: 0, + hasActivePrompt: false, + }); + } + + const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + for (const live of liveSessions) { + const existing = bySessionId.get(live.sessionId); + if (existing) { + bySessionId.set(live.sessionId, { + ...existing, + ...live, + createdAt: existing.createdAt, + displayName: live.displayName ?? existing.displayName, + updatedAt: live.updatedAt ?? existing.updatedAt, + clientCount: live.clientCount, + hasActivePrompt: live.hasActivePrompt, + }); + } else if ( + isFirstPage && + !(await sessionService.sessionExists(live.sessionId)) + ) { + bySessionId.set(live.sessionId, { + ...live, + createdAt: live.createdAt, + clientCount: live.clientCount, + hasActivePrompt: live.hasActivePrompt, + }); + } + } + + const sessions = [...bySessionId.values()].sort((a, b) => { + const aTime = Date.parse(a.updatedAt ?? a.createdAt); + const bTime = Date.parse(b.updatedAt ?? b.createdAt); + return bTime - aTime; + }); + + const nextCursor = + persisted.nextCursor != null ? String(persisted.nextCursor) : undefined; + + return { sessions, nextCursor }; +} + +function parseSessionPageSizeQuery(raw: unknown): number | undefined { + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + if (!/^[+-]?\d+$/.test(trimmed)) return undefined; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed)) return undefined; + if (Number.isSafeInteger(parsed)) return parsed; + return trimmed.startsWith('-') ? 1 : MAX_SESSION_PAGE_SIZE; +} + +const AUTH_PROVIDER_STEPS: ServeAuthProviderDescriptor['steps'] = [ + 'protocol', + 'baseUrl', + 'apiKey', + 'models', + 'advancedConfig', +]; + +function buildAuthProviderDescriptor( + provider: (typeof ALL_PROVIDERS)[number], +): ServeAuthProviderDescriptor { + const steps = AUTH_PROVIDER_STEPS.filter((step) => + shouldShowStep(provider, step), + ); + return { + id: provider.id, + label: provider.label, + description: provider.description, + ...(provider.uiGroup ? { uiGroup: provider.uiGroup } : {}), + protocol: provider.protocol, + ...(provider.protocolOptions + ? { protocolOptions: [...provider.protocolOptions] } + : {}), + ...(provider.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}), + ...(typeof provider.envKey === 'string' ? { envKey: provider.envKey } : {}), + ...(provider.models + ? { + models: provider.models.map((model) => ({ + id: model.id, + ...(model.contextWindowSize !== undefined + ? { contextWindowSize: model.contextWindowSize } + : {}), + ...(model.enableThinking !== undefined + ? { enableThinking: model.enableThinking } + : {}), + ...(model.modalities ? { modalities: model.modalities } : {}), + ...(model.description ? { description: model.description } : {}), + })), + } + : {}), + ...(provider.modelsEditable !== undefined + ? { modelsEditable: provider.modelsEditable } + : {}), + ...(provider.apiKeyPlaceholder + ? { apiKeyPlaceholder: provider.apiKeyPlaceholder } + : {}), + ...(typeof provider.documentationUrl === 'string' + ? { documentationUrl: provider.documentationUrl } + : {}), + ...(provider.showAdvancedConfig !== undefined + ? { showAdvancedConfig: provider.showAdvancedConfig } + : {}), + ...(provider.uiLabels ? { uiLabels: provider.uiLabels } : {}), + steps, + }; +} + +function buildAuthProviderCatalog( + workspaceCwd: string, +): ServeAuthProviderCatalog { + const providers = ALL_PROVIDERS.map(buildAuthProviderDescriptor); + const providerIdsByGroup = (group: string) => + providers + .filter((provider) => provider.uiGroup === group) + .map((provider) => provider.id); + return { + v: 1, + workspaceCwd, + providers, + groups: [ + { + id: 'alibaba', + label: 'Alibaba ModelStudio', + description: + 'Official recommended setup: Coding Plan, Token Plan, or Standard API Key', + providerIds: providerIdsByGroup('alibaba'), + }, + { + id: 'third-party', + label: 'Third-party Providers', + description: 'Choose a built-in provider and connect with an API key', + providerIds: providerIdsByGroup('third-party'), + }, + { + id: 'custom', + label: 'Custom Provider', + description: + 'Manually connect a local server, proxy, or unsupported provider', + providerIds: providerIdsByGroup('custom'), + }, + ], + }; +} + +function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const result = value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); + return result.length > 0 ? [...new Set(result)] : undefined; +} + +function parsePositiveBoundedInteger( + value: unknown, + max: number, +): number | undefined { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + !Number.isFinite(value) || + value <= 0 || + value > max + ) { + return undefined; + } + return value; +} + +function parseIPv4MappedHexSuffix(suffix: string): string | undefined { + const hexParts = suffix.split(':'); + if (hexParts.length !== 2) return undefined; + + const [hiRaw, loRaw] = hexParts; + if (!/^[0-9a-f]{1,4}$/i.test(hiRaw) || !/^[0-9a-f]{1,4}$/i.test(loRaw)) { + return undefined; + } + + const hi = Number.parseInt(hiRaw, 16); + const lo = Number.parseInt(loRaw, 16); + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + +function parseIPv6FirstHextet(host: string): number | undefined { + const first = host.split(':', 1)[0]; + if (!first || !/^[0-9a-f]{1,4}$/i.test(first)) return undefined; + return Number.parseInt(first, 16); +} + +function parseLegacyIPv4Part(value: string, max: number): number | undefined { + let parsed: number; + if (/^0x[0-9a-f]+$/i.test(value)) { + parsed = Number.parseInt(value.slice(2), 16); + } else if (/^0[0-7]+$/.test(value)) { + parsed = Number.parseInt(value, 8); + } else if (/^[0-9]+$/.test(value)) { + parsed = Number.parseInt(value, 10); + } else { + return undefined; + } + return parsed <= max ? parsed : undefined; +} + +// Match URL parsers that still accept inet_aton-style IPv4 aliases, so blocked +// host checks also catch SSH sources such as git@0177.1:owner/repo.git. +function parseLegacyIPv4Host(host: string): string | undefined { + const parts = host.split('.'); + if (parts.length < 1 || parts.length > 4 || parts.some((part) => !part)) { + return undefined; + } + + // In legacy one-, two-, and three-part IPv4 forms, the final part carries the + // remaining bytes rather than a single octet. + const maxLastPart = [0xffffffff, 0xffffff, 0xffff, 0xff][parts.length - 1]; + if (maxLastPart === undefined) return undefined; + + const parsed = parts.map((part, index) => + parseLegacyIPv4Part(part, index === parts.length - 1 ? maxLastPart : 0xff), + ); + if (parsed.some((part) => part === undefined)) return undefined; + + const values = parsed as number[]; + const numeric = + values.length === 1 + ? values[0] + : values.length === 2 + ? values[0] * 0x1000000 + values[1] + : values.length === 3 + ? values[0] * 0x1000000 + values[1] * 0x10000 + values[2] + : values[0] * 0x1000000 + + values[1] * 0x10000 + + values[2] * 0x100 + + values[3]; + + return [ + Math.floor(numeric / 0x1000000) & 0xff, + Math.floor(numeric / 0x10000) & 0xff, + Math.floor(numeric / 0x100) & 0xff, + numeric & 0xff, + ].join('.'); +} + +function isBlockedAuthProviderHost(hostname: string): boolean { + const stripped = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname; + const host = stripped.toLowerCase(); + if (host === 'localhost' || host.endsWith('.localhost')) return true; + + const bareHost = + host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + const blocklistHost = parseLegacyIPv4Host(bareHost) ?? bareHost; + const ipVersion = net.isIP(blocklistHost); + if (ipVersion === 4) { + const parts = blocklistHost.split('.').map((part) => Number(part)); + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b !== undefined && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b !== undefined && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + + if (ipVersion === 6) { + if (blocklistHost === '::' || blocklistHost === '::1') return true; + const firstHextet = parseIPv6FirstHextet(blocklistHost); + if ( + firstHextet !== undefined && + ((firstHextet >= 0xfe80 && firstHextet <= 0xfebf) || + (firstHextet & 0xfe00) === 0xfc00) + ) { + return true; + } + if (blocklistHost.startsWith('::ffff:')) { + const suffix = blocklistHost.slice('::ffff:'.length); + if (net.isIP(suffix) === 4) { + return isBlockedAuthProviderHost(suffix); + } + const mappedIPv4 = parseIPv4MappedHexSuffix(suffix); + return mappedIPv4 ? isBlockedAuthProviderHost(mappedIPv4) : true; + } + } + + return false; +} + +function parseAuthProviderBaseUrl( + value: unknown, + allowPrivateBaseUrl: boolean, +): string | undefined | null { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return undefined; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + if (!allowPrivateBaseUrl && isBlockedAuthProviderHost(parsed.hostname)) { + return null; + } + return parsed.toString().replace(/\/$/, ''); +} + +type AuthProviderParseResult = + | { ok: true; value: ServeAuthProviderInstallRequest } + | { ok: false; code: string; error: string }; + +function parseAuthProviderInstallRequest( + body: Record, + options?: { allowPrivateBaseUrl?: boolean }, +): AuthProviderParseResult { + const providerId = body['providerId']; + const apiKey = body['apiKey']; + if ( + typeof providerId !== 'string' || + providerId.trim().length === 0 || + typeof apiKey !== 'string' || + apiKey.trim().length === 0 + ) { + return { + ok: false, + code: 'invalid_request', + error: '`providerId` and `apiKey` are required', + }; + } + const protocol = body['protocol']; + const baseUrl = parseAuthProviderBaseUrl( + body['baseUrl'], + options?.allowPrivateBaseUrl === true, + ); + if (baseUrl === null) { + return { + ok: false, + code: 'invalid_base_url', + error: + '`baseUrl` must be an http(s) URL without credentials or blocked private-network host', + }; + } + const modelIds = parseStringArray(body['modelIds']); + const rawAdvanced = + body['advancedConfig'] && typeof body['advancedConfig'] === 'object' + ? (body['advancedConfig'] as Record) + : undefined; + const rawMultimodal = + rawAdvanced?.['multimodal'] && typeof rawAdvanced['multimodal'] === 'object' + ? (rawAdvanced['multimodal'] as Record) + : undefined; + const contextWindowSize = parsePositiveBoundedInteger( + rawAdvanced?.['contextWindowSize'], + 10_000_000, + ); + const maxTokens = parsePositiveBoundedInteger( + rawAdvanced?.['maxTokens'], + 10_000_000, + ); + const advancedConfig = rawAdvanced + ? { + ...(typeof rawAdvanced['enableThinking'] === 'boolean' + ? { enableThinking: rawAdvanced['enableThinking'] } + : {}), + ...(rawMultimodal + ? { + multimodal: { + ...(typeof rawMultimodal['image'] === 'boolean' + ? { image: rawMultimodal['image'] } + : {}), + ...(typeof rawMultimodal['pdf'] === 'boolean' + ? { pdf: rawMultimodal['pdf'] } + : {}), + ...(typeof rawMultimodal['audio'] === 'boolean' + ? { audio: rawMultimodal['audio'] } + : {}), + ...(typeof rawMultimodal['video'] === 'boolean' + ? { video: rawMultimodal['video'] } + : {}), + }, + } + : {}), + ...(contextWindowSize !== undefined ? { contextWindowSize } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + } + : undefined; + return { + ok: true, + value: { + providerId: providerId.trim(), + ...(typeof protocol === 'string' && protocol.trim() + ? { + protocol: + protocol.trim() as ServeAuthProviderInstallRequest['protocol'], + } + : {}), + ...(baseUrl ? { baseUrl } : {}), + apiKey, + ...(modelIds ? { modelIds } : {}), + ...(advancedConfig ? { advancedConfig } : {}), + }, + }; +} + export interface ServeAppDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ bridge?: AcpSessionBridge; @@ -183,7 +772,6 @@ export interface ServeAppDeps { * and a stderr audit sink. */ deviceFlowRegistry?: DeviceFlowRegistry; - maxExtensionOperationHistory?: number; /** * Extra device-flow providers for tests / future extensions. * Production builds register only `QwenOAuthDeviceFlowProvider`; @@ -207,7 +795,6 @@ export interface ServeAppDeps { */ daemonLog?: DaemonLogger; startup?: DaemonStartupSnapshot; - getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot; workspace?: DaemonWorkspaceService; statusProvider?: DaemonStatusProvider; persistDisabledTools?: ( @@ -230,20 +817,241 @@ export interface ServeAppDeps { value: unknown; }>, ) => Promise; - /** - * 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; voiceTranscriber?: WorkspaceVoiceRouteDeps['transcribe']; } +function resolveDaemonTelemetryRoute( + req: Request, +): + | { route: string; sessionId?: string; permissionRequestId?: string } + | undefined { + const path = req.path.replace(/\/$/, '') || '/'; + if (req.method === 'POST' && path === '/session') { + return { route: 'POST /session' }; + } + if (req.method === 'POST' && path === '/sessions/delete') { + return { route: 'POST /sessions/delete' }; + } + if (req.method === 'GET' && path === '/daemon/status') { + return { route: 'GET /daemon/status' }; + } + const sessionAction = path.match( + /^\/session\/([^/]+)\/(load|resume|prompt|cancel|recap|btw|mid-turn-message|model|shell|detach|rewind|approval-mode|language|runtime-context|a2ui-action)$/, + ); + const sessionActionId = sessionAction?.[1]; + const sessionActionName = sessionAction?.[2]; + if (sessionActionId && sessionActionName && req.method === 'POST') { + return { + route: `POST /session/:id/${sessionActionName}`, + sessionId: sessionActionId, + }; + } + const sessionMetadata = path.match(/^\/session\/([^/]+)\/metadata$/); + if (sessionMetadata?.[1] && req.method === 'PATCH') { + return { + route: 'PATCH /session/:id/metadata', + sessionId: sessionMetadata[1], + }; + } + const sessionPermission = path.match( + /^\/session\/([^/]+)\/permission\/([^/]+)$/, + ); + if ( + sessionPermission?.[1] && + sessionPermission?.[2] && + req.method === 'POST' + ) { + const rawRequestId = sessionPermission[2]; + return { + route: 'POST /session/:id/permission/:requestId', + sessionId: sessionPermission[1], + ...(rawRequestId.length <= MAX_CLIENT_ID_LENGTH && + CLIENT_ID_RE.test(rawRequestId) + ? { permissionRequestId: rawRequestId } + : {}), + }; + } + const globalPermission = path.match(/^\/permission\/([^/]+)$/); + if (globalPermission?.[1] && req.method === 'POST') { + const rawRequestId = globalPermission[1]; + return { + route: 'POST /permission/:requestId', + ...(rawRequestId.length <= MAX_CLIENT_ID_LENGTH && + CLIENT_ID_RE.test(rawRequestId) + ? { permissionRequestId: rawRequestId } + : {}), + }; + } + const deleteSession = path.match(/^\/session\/([^/]+)$/); + const deleteSessionId = deleteSession?.[1]; + if (deleteSessionId && req.method === 'DELETE') { + return { route: 'DELETE /session/:id', sessionId: deleteSessionId }; + } + if (req.method === 'GET' && /^\/workspace\/[^/]+\/sessions$/.test(path)) { + return { route: 'GET /workspace/:id/sessions' }; + } + if (req.method === 'POST' && path === '/workspace/init') { + return { route: 'POST /workspace/init' }; + } + if (req.method === 'POST' && path === '/workspace/setup-github') { + return { route: 'POST /workspace/setup-github' }; + } + if (req.method === 'POST' && path === '/workspace/reload') { + return { route: 'POST /workspace/reload' }; + } + const mcpRestart = path.match(/^\/workspace\/mcp\/([^/]+)\/restart$/); + if (mcpRestart?.[1] && req.method === 'POST') { + return { route: 'POST /workspace/mcp/:server/restart' }; + } + if (req.method === 'POST' && path === '/workspace/mcp/servers') { + return { route: 'POST /workspace/mcp/servers' }; + } + const mcpDelete = path.match(/^\/workspace\/mcp\/servers\/([^/]+)$/); + if (mcpDelete?.[1] && req.method === 'DELETE') { + return { route: 'DELETE /workspace/mcp/servers/:name' }; + } + if (req.method === 'POST' && path === '/workspace/auth/device-flow') { + return { route: 'POST /workspace/auth/device-flow' }; + } + const deviceFlowDelete = path.match( + /^\/workspace\/auth\/device-flow\/([^/]+)$/, + ); + if (deviceFlowDelete?.[1] && req.method === 'DELETE') { + return { route: 'DELETE /workspace/auth/device-flow/:id' }; + } + const toolEnable = path.match(/^\/workspace\/tools\/([^/]+)\/enable$/); + if (toolEnable?.[1] && req.method === 'POST') { + return { route: 'POST /workspace/tools/:name/enable' }; + } + if (path === '/workspace/settings') { + if (req.method === 'GET') return { route: 'GET /workspace/settings' }; + if (req.method === 'POST') return { route: 'POST /workspace/settings' }; + } + if (path === '/workspace/permissions') { + if (req.method === 'GET') return { route: 'GET /workspace/permissions' }; + if (req.method === 'POST') return { route: 'POST /workspace/permissions' }; + } + if (path === '/workspace/trust') { + if (req.method === 'GET') return { route: 'GET /workspace/trust' }; + } + if (req.method === 'POST' && path === '/workspace/trust/request') { + return { route: 'POST /workspace/trust/request' }; + } + if (path === '/workspace/voice') { + if (req.method === 'GET') return { route: 'GET /workspace/voice' }; + if (req.method === 'POST') return { route: 'POST /workspace/voice' }; + } + if (req.method === 'POST' && path === '/workspace/voice/transcribe') { + return { route: 'POST /workspace/voice/transcribe' }; + } + return undefined; +} + +function daemonTelemetryMiddleware( + boundWorkspace: string, +): (req: Request, res: Response, next: NextFunction) => void { + const workspaceHash = hashDaemonWorkspace(boundWorkspace); + return (req, res, next) => { + const route = resolveDaemonTelemetryRoute(req); + if (!route) { + next(); + return; + } + const rawClientId = req.get(CLIENT_ID_HEADER); + const clientId = + rawClientId !== undefined && + rawClientId !== '' && + rawClientId.length <= MAX_CLIENT_ID_LENGTH && + CLIENT_ID_RE.test(rawClientId) + ? rawClientId + : undefined; + const startMs = Date.now(); + void withDaemonRequestSpan( + { + method: req.method, + route: route.route, + workspaceHash, + ...(route.sessionId ? { sessionId: route.sessionId } : {}), + ...(route.permissionRequestId + ? { permissionRequestId: route.permissionRequestId } + : {}), + ...(clientId ? { clientId } : {}), + }, + async (span) => + await new Promise((resolve, reject) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + recordDaemonHttpResponse(span, res.statusCode); + recordDaemonHttpRequest( + Date.now() - startMs, + route.route, + res.statusCode, + ); + resolve(); + }; + res.once('finish', finish); + res.once('close', finish); + try { + next(); + } catch (error) { + recordDaemonError(span, error); + reject(error); + } + }), + ).catch(next); + }; +} + +/** + * Sentinel passed as `AbortController.abort(reason)` when a prompt + * exceeds its server-configured wallclock. Exported so tests can + * match on the class identity. + */ +export class PromptDeadlineExceededError extends Error { + readonly deadlineMs: number; + constructor(deadlineMs: number) { + super(`prompt exceeded the ${deadlineMs}ms deadline`); + this.name = 'PromptDeadlineExceededError'; + this.deadlineMs = deadlineMs; + } +} + +/** + * Resolve the effective per-prompt wallclock from the server flag + + * an optional request body override. Returns `undefined` when no + * deadline applies. The request override may SHORTEN the deadline but + * never EXTEND it — operators stay the upper bound. + */ +export function resolvePromptDeadlineMs( + serverMs: number | undefined, + requestMs: number | undefined, +): number | undefined { + if (serverMs === undefined || !Number.isFinite(serverMs) || serverMs <= 0) { + return undefined; + } + if ( + requestMs === undefined || + !Number.isFinite(requestMs) || + requestMs <= 0 + ) { + return serverMs; + } + return Math.min(serverMs, requestMs); +} + +// Keep in sync with acp-bridge bridge.ts and SDK DaemonClient.ts. +const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; + +function advertisedMaxPendingPromptsPerSession( + value: number | undefined, +): number | null { + if (value === undefined) return DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION; + if (value === 0 || value === Number.POSITIVE_INFINITY) return null; + return value; +} + /** * Build the Express app for `qwen serve`. Pure function — no side effects on * the network or process; `runQwenServe` does the listen/signal handling. @@ -253,9 +1061,30 @@ export interface ServeAppDeps { * 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. + * Supported routes: + * - `GET /health` + * - `GET /daemon/status` + * - `GET /capabilities` + * - `GET /workspace/mcp` + * - `GET /workspace/skills` + * - `GET /workspace/providers` + * - `GET /workspace/env` + * - `GET /workspace/preflight` + * - `POST /session` + * - `POST /session/:id/load` + * - `POST /session/:id/resume` + * - `GET /workspace/:id/sessions` + * - `GET /session/:id/context` + * - `GET /session/:id/supported-commands` + * - `GET /session/:id/tasks` + * - `GET /session/:id/lsp` + * - `POST /session/:id/prompt` + * - `POST /session/:id/cancel` + * - `POST /session/:id/heartbeat` + * - `POST /session/:id/model` + * - `GET /session/:id/events` (SSE) + * - `POST /session/:id/permission/:requestId` + * - `POST /permission/:requestId` * * **Workspace validation contract.** `createServeApp` itself does NOT * verify that `opts.workspace` exists or is a directory — it @@ -311,46 +1140,19 @@ export function createServeApp( injected: deps.fsFactory, trusted: false, }); + let cachedVoiceTranscriptionAvailable: boolean | undefined; + const invalidateServeFeaturesCache = () => { + cachedVoiceTranscriptionAvailable = undefined; + }; + const getCachedVoiceTranscriptionAvailable = () => { + cachedVoiceTranscriptionAvailable ??= + isWorkspaceVoiceTranscriptionAvailable(boundWorkspace); + return cachedVoiceTranscriptionAvailable; + }; 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 && - !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 = - deps.clientMcpSenderRegistry ?? new ClientMcpSenderRegistry(); - const { languageCodes, currentServeFeatures, invalidateServeFeaturesCache } = - createServeFeatures({ - opts, - boundWorkspace, - persistSettingAvailable: deps.persistSetting !== undefined, - reloadAvailable: deps.workspace !== undefined, - sessionShellCommandEnabled, - }); const statusProvider = deps.statusProvider ?? createDaemonStatusProvider(); const bridge = deps.bridge ?? @@ -367,12 +1169,36 @@ export function createServeApp( // 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, }); - installSelfOriginStripMiddleware(app, getPort); + // Allow same-origin requests from the demo page. Browsers send an + // `Origin` header on same-origin POST/fetch calls; `denyBrowserOriginCors` + // below would reject them. This middleware strips `Origin` when it + // matches the daemon's own address so the demo page's API calls pass + // through. Only loopback origins are matched — non-loopback deployments + // require the operator to front the daemon with a reverse proxy for + // browser access anyway (per the threat-model docs). + let cachedStripPort = -1; + let cachedSelfOrigins: Set = new Set(); + app.use((req: import('express').Request, _res, next) => { + const origin = req.headers.origin; + if (origin) { + const port = getPort(); + if (port !== cachedStripPort) { + cachedStripPort = port; + cachedSelfOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + `http://host.docker.internal:${port}`, + ]); + } + if (cachedSelfOrigins.has(origin)) { + delete req.headers.origin; + } + } + next(); + }); // Park the factory on `app.locals` so route handlers can pick it up // via `req.app.locals.fsFactory` without re-threading the value @@ -383,23 +1209,82 @@ export function createServeApp( // 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, + // Wire the device-flow registry. Default builds a single Qwen + // provider; tests inject `deps.deviceFlowRegistry` or + // `deps.deviceFlowProviders` to stub the OAuth client only. + const deviceFlowProviderMap = new Map< + DeviceFlowProviderId, + DeviceFlowProvider + >(); + for (const provider of deps.deviceFlowProviders ?? []) { + deviceFlowProviderMap.set(provider.providerId, provider); + } + if (!deviceFlowProviderMap.has('qwen-oauth')) { + deviceFlowProviderMap.set('qwen-oauth', new QwenOAuthDeviceFlowProvider()); + } + const deviceFlowEventSink: DeviceFlowEventSink = { + publish(emission, originatorClientId) { + bridge.publishWorkspaceEvent({ + type: `auth_device_flow_${emission.type}`, + data: emission.data, + ...(originatorClientId ? { originatorClientId } : {}), + }); + }, + }; + const deviceFlowRegistry = + deps.deviceFlowRegistry ?? + new DeviceFlowRegistry({ + events: deviceFlowEventSink, + audit: { + record(line) { + // Structured stderr breadcrumb; deviceFlowId truncated to first + // 8 chars so log + // skimmers can follow a flow without retaining full uuids. + const id = line.deviceFlowId.slice(0, 8); + const parts = [ + `[serve] auth.device-flow:`, + `provider=${line.providerId}`, + `deviceFlowId=${id}...`, + line.clientId ? `clientId=${line.clientId}` : 'clientId=-', + `status=${line.status}`, + ]; + if (line.errorKind) parts.push(`errorKind=${line.errorKind}`); + if (line.expiresInMs !== undefined) { + parts.push(`expiresInMs=${Math.max(0, line.expiresInMs)}`); + } + // Include `line.hint` for operator-only breadcrumbs that + // aren't surfaced over SSE. Bound at 1 KiB. + if (line.hint) { + const STDERR_HINT_MAX = 1_024; + const hint = + line.hint.length > STDERR_HINT_MAX + ? `${line.hint.slice(0, STDERR_HINT_MAX)}…[+${line.hint.length - STDERR_HINT_MAX} bytes truncated]` + : line.hint; + // Quote the hint so multi-word values stay parseable. + parts.push(`hint=${JSON.stringify(hint)}`); + } + writeStderrLine(parts.join(' ')); + }, + }, + resolveProvider: (providerId) => deviceFlowProviderMap.get(providerId), }); + // Park the registry on `app.locals` so request handlers can reach it. + // Typed accessor prevents a string-key typo from silently detaching + // `runQwenServe`'s shutdown dispose call. + setDeviceFlowRegistry(app, deviceFlowRegistry); const { daemonLog } = deps; - const sendBridgeError: SendBridgeError = (res, err, ctx) => - sendBridgeErrorResponse(res, err, ctx, daemonLog); + const sendBridgeError = ( + res: import('express').Response, + err: unknown, + ctx?: BridgeErrorContext, + ) => sendBridgeErrorImpl(res, err, ctx, daemonLog); const sendPermissionVoteError = ( res: import('express').Response, err: unknown, ctx: { route: string; sessionId?: string }, - ) => sendPermissionVoteErrorResponse(res, err, ctx, daemonLog); + ) => sendPermissionVoteErrorImpl(res, err, ctx, daemonLog); const workspace: DaemonWorkspaceService = deps.workspace ?? @@ -437,90 +1322,727 @@ export function createServeApp( bridge.publishWorkspaceEvent(event); }, }); - // 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 '*'.`, + let extensionInstallQueue: Promise = Promise.resolve(); + let extensionInstallQueueDepth = 0; + const MAX_EXTENSION_INSTALL_QUEUE_DEPTH = 10; + const enqueueExtensionInstall = async (run: () => Promise) => { + if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) { + throw new Error('Extension operation queue is full'); + } + extensionInstallQueueDepth += 1; + const next = extensionInstallQueue.then(run, run).finally(() => { + extensionInstallQueueDepth -= 1; + }); + extensionInstallQueue = next.catch(() => undefined); + return next; + }; + const EXTENSION_MUTATION_TIMEOUT_MS = 10 * 60_000; + const EXTENSION_REFRESH_TIMEOUT_MS = 30_000; + const isExtensionQueueFullError = (err: unknown): boolean => + err instanceof Error && err.message === 'Extension operation queue is full'; + const sendExtensionQueueFull = (res: Response) => { + res.status(429).json({ + error: 'Extension operation queue is full', + code: 'extension_queue_full', + }); + }; + const withExtensionTimeout = async ( + promise: Promise, + timeoutMs: number, + operation: string, + ): Promise => + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`${operation} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timeout); + reject(err); + }, ); + }); + const createExtensionManager = () => + new ExtensionManager({ + workspaceDir: boundWorkspace, + isWorkspaceTrusted: + getWorkspaceTrustStatus( + loadSettings(boundWorkspace).merged, + boundWorkspace, + ).effective.state === 'trusted', + requestConsent: () => Promise.resolve(), + requestSetting: async (setting: ExtensionSetting) => { + throw new Error( + `Extension setting "${setting.envVar}" requires interactive configuration and is not supported over the daemon install endpoint.`, + ); + }, + requestChoicePlugin: async () => { + throw new Error( + 'Marketplace plugin selection is not supported over the daemon install endpoint. Specify a plugin name in the source.', + ); + }, + }); + const validateExtensionMutationClient = ( + req: Request, + res: Response, + route: string, + ): boolean => { + const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); + if (clientId === null) return false; + if (clientId === undefined) { + res.status(400).json({ + error: 'Missing X-Qwen-Client-Id header', + code: 'missing_client_id', + }); + return false; } - app.use(allowOriginCors(parsedAllowOrigins)); - } else { - app.use(denyBrowserOriginCors); - } - app.use(hostAllowlist(opts.hostname, getPort)); - - const healthDemoRoutes = createHealthDemoRoutes({ - opts, - getPort, - bridge, - 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 `