diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 350024e98bf..6215f0b34be 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -296,7 +296,10 @@ warning severity, otherwise `ok`. Issue codes are stable and include `session_capacity_high`, `connection_capacity_high`, `pending_permissions`, `acp_channel_down`, `preflight_error`, `mcp_budget_warning`, `mcp_budget_exhausted`, `rate_limit_hits`, and -`workspace_status_unavailable`. +`workspace_status_unavailable`. During the short window after the listener is +ready but before the full runtime is mounted, `/daemon/status` may report +`daemon_runtime_starting`; if the async runtime mount fails, it reports +`daemon_runtime_failed` while non-status runtime routes return `503`. Security: the response never includes bearer tokens, client ids, full ACP connection ids, device-flow user codes, or verification URLs. `summary` omits diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 7660f914cdd..a3e9b41c663 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -7,6 +7,7 @@ */ import { initStartupProfiler } from './src/utils/startupProfiler.js'; +import { isServeFastPathArgv } from './src/serve/fast-path-argv.js'; // Must run before any other imports to capture the earliest possible T0. initStartupProfiler(); @@ -16,14 +17,12 @@ import { initCpuProfiler } from './src/utils/cpuProfiler.js'; // QWEN_CODE_CPU_PROFILE=1, capturing as much of the startup as possible. initCpuProfiler(); -import './src/gemini.js'; -import { main } from './src/gemini.js'; -import { FatalError } from '@qwen-code/qwen-code-core'; -import { AlreadyReportedError } from './src/utils/errors.js'; -import { writeStderrLine } from './src/utils/stdioHelpers.js'; - // --- Global Entry Point --- +function writeStderrLine(line: string): void { + process.stderr.write(line.endsWith('\n') ? line : `${line}\n`); +} + // Suppress known race conditions in @lydell/node-pty. // // PTY errors that are expected due to timing races between process exit @@ -76,20 +75,22 @@ const isExpectedPtyRaceError = (error: unknown): boolean => { return false; }; -process.on('uncaughtException', (error) => { - if (isExpectedPtyRaceError(error)) { - return; +async function runCliEntry(): Promise { + if (isServeFastPathArgv(process.argv.slice(2))) { + const { tryRunServeFastPath } = await import('./src/serve/fast-path.js'); + if (await tryRunServeFastPath()) return; } - if (error instanceof Error) { - writeStderrLine(error.stack ?? error.message); - } else { - writeStderrLine(String(error)); - } - process.exit(1); -}); + const { main } = await import('./src/gemini.js'); + await main(); +} + +async function handleCriticalError(error: unknown): Promise { + const [{ FatalError }, { AlreadyReportedError }] = await Promise.all([ + import('@qwen-code/qwen-code-core'), + import('./src/utils/errors.js'), + ]); -main().catch((error) => { if (error instanceof FatalError) { let errorMessage = error.message; if (!process.env['NO_COLOR']) { @@ -114,4 +115,36 @@ main().catch((error) => { console.error(String(error)); } process.exit(1); +} + +process.on('uncaughtException', (error) => { + if (isExpectedPtyRaceError(error)) { + return; + } + + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + process.exit(1); +}); + +runCliEntry().catch((error: unknown) => { + void handleCriticalError(error).catch((handlerError: unknown) => { + console.error('An unexpected critical error occurred:'); + console.error('Original error:'); + if (error instanceof Error) { + console.error(error.stack); + } else { + console.error(String(error)); + } + console.error('Error handler failed:'); + if (handlerError instanceof Error) { + console.error(handlerError.stack); + } else { + console.error(String(handlerError)); + } + process.exit(1); + }); }); diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts index b78ea66089d..3916ec4e992 100644 --- a/packages/cli/src/commands/extensions/update.test.ts +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { updateCommand, handleUpdate } from './update.js'; import yargs from 'yargs'; -import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { ExtensionUpdateState } from '@qwen-code/qwen-code-core'; const mockGetLoadedExtensions = vi.hoisted(() => vi.fn()); const mockUpdateExtension = vi.hoisted(() => vi.fn()); @@ -28,13 +28,6 @@ vi.mock('./utils.js', () => ({ vi.mock('@qwen-code/qwen-code-core', () => ({ checkForExtensionUpdate: mockCheckForExtensionUpdate, -})); - -vi.mock('../../utils/errors.js', () => ({ - getErrorMessage: vi.fn((error: Error) => error.message), -})); - -vi.mock('../../ui/state/extensions.js', () => ({ ExtensionUpdateState: { UPDATE_AVAILABLE: 'update available', UP_TO_DATE: 'up to date', @@ -42,6 +35,10 @@ vi.mock('../../ui/state/extensions.js', () => ({ }, })); +vi.mock('../../utils/errors.js', () => ({ + getErrorMessage: vi.fn((error: Error) => error.message), +})); + vi.mock('../../utils/stdioHelpers.js', () => ({ writeStdoutLine: mockWriteStdoutLine, writeStderrLine: mockWriteStderrLine, diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index d47816b1cd4..26e781cafdc 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -7,9 +7,9 @@ import type { CommandModule } from 'yargs'; import { getErrorMessage } from '../../utils/errors.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; -import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { checkForExtensionUpdate, + ExtensionUpdateState, type ExtensionUpdateInfo, } from '@qwen-code/qwen-code-core'; import { getExtensionManager } from './utils.js'; diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index 328ee6f7d05..ae27f3ef26e 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import yargs, { type Argv } from 'yargs'; import { serveCommand, maybeOpenWebShellBrowser } from './serve.js'; @@ -20,7 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { shouldLaunchBrowser: mockShouldLaunchBrowser, }; }); -vi.mock('../serve/index.js', () => ({ +vi.mock('../serve/run-qwen-serve.js', () => ({ runQwenServe: mockRunQwenServe, })); @@ -145,6 +149,9 @@ describe('maybeOpenWebShellBrowser', () => { vi.clearAllMocks(); mockShouldLaunchBrowser.mockReturnValue(true); }); + afterEach(() => { + vi.restoreAllMocks(); + }); const firstOpenedUrl = () => String(mockOpenBrowserSecurely.mock.calls[0]?.[0]); @@ -196,6 +203,28 @@ describe('maybeOpenWebShellBrowser', () => { expect(firstOpenedUrl()).not.toContain('?token='); }); + it('skips --open when the runtime failed to mount', async () => { + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + + await maybeOpenWebShellBrowser( + { + url: 'http://127.0.0.1:4170/', + webShellMounted: true, + runtimeReady: Promise.reject(new Error('runtime boom')), + }, + true, + ); + + expect(mockOpenBrowserSecurely).not.toHaveBeenCalled(); + expect(stderrWrites.join('')).toContain( + 'qwen serve: Web Shell runtime not ready; skipping --open: runtime boom', + ); + }); + it('swallows openBrowserSecurely failures (never throws)', async () => { mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('boom')); await expect( @@ -206,3 +235,178 @@ describe('maybeOpenWebShellBrowser', () => { ).resolves.toBeUndefined(); }); }); + +describe('serve startup import boundary', () => { + it('reaches listening through the dev entrypoint without loading interactive Ink internals first', async () => { + const workspace = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-')), + ); + const qwenHome = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-home-')), + ); + const root = path.resolve(process.cwd(), '../..'); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + QWEN_CODE_NO_RELAUNCH: '1', + QWEN_CODE_SUPPRESS_YOLO_WARNING: '1', + QWEN_HOME: qwenHome, + QWEN_RUNTIME_DIR: workspace, + QWEN_SERVE_RATE_LIMIT: '0', + }; + delete childEnv['VITEST_WORKER_ID']; + const child = spawn( + process.execPath, + [ + path.join(root, 'scripts/dev.js'), + 'serve', + '--port', + '0', + '--hostname', + '127.0.0.1', + '--workspace', + workspace, + '--no-web', + '--no-open', + '--rate-limit-prompt', + '0', + '--rate-limit-window-ms', + '1', + ], + { + cwd: root, + detached: process.platform !== 'win32', + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + let childExited = false; + const exited = new Promise((resolve) => { + child.once('exit', () => { + childExited = true; + resolve(); + }); + }); + const waitForExit = (ms: number) => + Promise.race([ + exited, + new Promise<'timeout'>((resolve) => setTimeout(resolve, ms, 'timeout')), + ]); + const cleanup = async () => { + if (child.pid === undefined) return; + const childPid = child.pid; + const signalProcessTree = (signal: NodeJS.Signals) => { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(childPid), '/T', '/F']); + return; + } + process.kill(-childPid, signal); + }; + try { + signalProcessTree('SIGTERM'); + } catch { + // Process may have already exited. + } + if (!childExited) { + await waitForExit(2_000); + } + if (process.platform !== 'win32') { + try { + signalProcessTree('SIGKILL'); + } catch { + // Process may have already exited. + } + if (!childExited) { + await waitForExit(2_000); + } + } + }; + const removeTempDir = async (dir: string) => { + for (let attempt = 0; attempt < 5; attempt++) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (err) { + if (attempt === 4) throw err; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + }; + const processGroupHasMembers = (pgid: number): boolean => { + if (process.platform === 'win32') return false; + const result = spawnSync('ps', ['-o', 'pid=', '-g', String(pgid)], { + encoding: 'utf8', + }); + if (result.status !== 0) return false; + return result.stdout + .split(/\s+/) + .some((pid) => pid.length > 0 && Number(pid) > 0); + }; + const waitForProcessGroupExit = async (pgid: number) => { + if (process.platform === 'win32') return; + for (let attempt = 0; attempt < 20; attempt++) { + if (!processGroupHasMembers(pgid)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`serve process group ${pgid} did not exit`); + }; + + try { + const reachedListening = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + void cleanup(); + reject( + new Error( + `serve did not reach listening\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ), + ); + }, 15_000); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (stdout.includes('qwen serve listening on')) { + clearTimeout(timeout); + void cleanup(); + resolve(true); + } + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + if ( + stderr.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') || + stderr.includes('ink/dom') || + stderr.includes('ink/components/CursorContext') + ) { + clearTimeout(timeout); + void cleanup(); + reject(new Error(stderr)); + } + }); + child.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + child.on('exit', (code, signal) => { + if (stdout.includes('qwen serve listening on')) return; + clearTimeout(timeout); + reject( + new Error( + `serve exited before listening: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ), + ); + }); + }); + + expect(reachedListening).toBe(true); + } finally { + await cleanup(); + if (child.pid !== undefined) { + await waitForProcessGroupExit(child.pid); + } + await removeTempDir(workspace); + await removeTempDir(qwenHome); + } + }, 20_000); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a5f22fc552a..87366e744c9 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -49,10 +49,25 @@ function blockForever(): Promise { * Exported for tests. */ export async function maybeOpenWebShellBrowser( - handle: { url: string; webShellMounted: boolean; resolvedToken?: string }, + handle: { + url: string; + webShellMounted: boolean; + resolvedToken?: string; + runtimeReady?: Promise; + }, open: boolean, ): Promise { if (!open || !handle.webShellMounted || !shouldLaunchBrowser()) return; + try { + await handle.runtimeReady; + } catch (runtimeErr) { + writeStderrLine( + `qwen serve: Web Shell runtime not ready; skipping --open: ${ + runtimeErr instanceof Error ? runtimeErr.message : String(runtimeErr) + }`, + ); + return; + } try { const target = new URL(handle.url); // Node's URL returns the IPv6 wildcard as `[::]` (bracketed), never `::`. @@ -474,9 +489,9 @@ export const serveCommand: CommandModule = { } } - // Lazy-load the serve module so non-serve invocations don't pay for - // express + body-parser + qs in their startup path. - const { runQwenServe } = await import('../serve/index.js'); + // Lazy-load the slim serve runner so the yargs fallback path does not pull + // the public serve barrel, which also exports REST/ACP runtime modules. + const { runQwenServe } = await import('../serve/run-qwen-serve.js'); try { const handle = await runQwenServe({ port: argv.port, diff --git a/packages/cli/src/config/default-theme-names.ts b/packages/cli/src/config/default-theme-names.ts new file mode 100644 index 00000000000..f9595bb0ca1 --- /dev/null +++ b/packages/cli/src/config/default-theme-names.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_LIGHT_THEME_NAME = 'Default Light'; +export const DEFAULT_DARK_THEME_NAME = 'Default'; diff --git a/packages/cli/src/config/environment.ts b/packages/cli/src/config/environment.ts new file mode 100644 index 00000000000..7887bb1745c --- /dev/null +++ b/packages/cli/src/config/environment.ts @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as dotenv from 'dotenv'; +import { getErrorMessage, QWEN_DIR, Storage } from '@qwen-code/qwen-code-core'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + DEFAULT_EXCLUDED_ENV_VARS, + HOME_ENV_BOOTSTRAP_KEYS, + PROJECT_ENV_HARDCODED_EXCLUSIONS, +} from './shared-env-keys.js'; +export { + DEFAULT_EXCLUDED_ENV_VARS, + ENV_CORRUPTED_PATH, + ENV_WAS_RECOVERED, +} from './shared-env-keys.js'; +import type { Settings } from './settingsSchema.js'; + +export const SETTINGS_DIRECTORY_NAME = QWEN_DIR; + +const RELOAD_EXCLUDED_KEYS = new Set([ + ...PROJECT_ENV_HARDCODED_EXCLUSIONS, + 'QWEN_SERVER_TOKEN', + 'QWEN_CLI_ENTRY', + 'NODE_OPTIONS', + 'NODE_PATH', + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'LD_PRELOAD', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'BASH_ENV', + 'ENV', + 'PATH', + 'HOME', + 'TMPDIR', + 'TMP', + 'TEMP', +]); + +const dotEnvSourcedKeys = new Set(); +const settingsEnvSourcedKeys = new Set(); +const lastReloadSnapshot = new Map(); +let lastReloadSnapshotSeeded = false; + +/** + * Returns the set of normalized .env file paths that count as user-level. + * + * User-level paths cover the home `.env` and the global Qwen config dir + * `.env` (which respects `QWEN_HOME`). When `QWEN_HOME` redirects elsewhere, + * the legacy `/.qwen/.env` is also included so credentials users + * left there continue to load (and the trust check in untrusted workspaces + * still allows reading it). + */ +function getUserLevelEnvPaths(): Set { + const homeDir = os.homedir(); + const globalQwenDir = Storage.getGlobalQwenDir(); + const paths = new Set([ + path.normalize(path.join(homeDir, '.env')), + path.normalize(path.join(globalQwenDir, '.env')), + ]); + const legacyQwenEnv = path.normalize(path.join(homeDir, QWEN_DIR, '.env')); + paths.add(legacyQwenEnv); + return paths; +} + +/** + * Pre-resolves QWEN_HOME and QWEN_RUNTIME_DIR from user-level `.env` files + * before any settings or storage paths are read. Required because + * module-load `Storage.getGlobalQwenDir()` would otherwise snapshot legacy + * paths for settings.json, OAuth tokens, installation_id, etc., while the + * regular `.env` load only runs later — splitting global state between + * `~/.qwen/...` and `/...`. + */ +let homeEnvBootstrapped = false; +export function preResolveHomeEnvOverrides(): void { + if (homeEnvBootstrapped) { + return; + } + homeEnvBootstrapped = true; + + if (HOME_ENV_BOOTSTRAP_KEYS.every((key) => process.env[key])) { + return; + } + + // Storage.getGlobalQwenDir() shares the same homedir resolution as the + // rest of the storage layer; when QWEN_HOME is unset it equals + // `/.qwen`, so path.dirname() recovers ``. + const initialQwenHome = process.env['QWEN_HOME']; + const initialQwenDir = Storage.getGlobalQwenDir(); + const candidates: string[] = [path.join(initialQwenDir, '.env')]; + if (!initialQwenHome) { + candidates.push(path.join(path.dirname(initialQwenDir), '.env')); + } + + for (const candidate of candidates) { + readHomeEnvInto(candidate); + } + + // If QWEN_HOME was just discovered, also read /.env so + // QWEN_RUNTIME_DIR can be sourced from there. + const discoveredQwenHome = process.env['QWEN_HOME']; + if (discoveredQwenHome && discoveredQwenHome !== initialQwenHome) { + const discoveredDir = Storage.getGlobalQwenDir(); + if (discoveredDir !== initialQwenDir) { + readHomeEnvInto(path.join(discoveredDir, '.env')); + } + } +} + +function readHomeEnvInto(file: string): void { + if (!fs.existsSync(file)) { + return; + } + try { + const parsed = dotenv.parse(fs.readFileSync(file, 'utf-8')); + for (const key of PROJECT_ENV_HARDCODED_EXCLUSIONS) { + if (parsed[key] && !Object.hasOwn(process.env, key)) { + process.env[key] = parsed[key]; + } + } + } catch (_e) { + // Match the dotenv quiet-mode behavior used by loadEnvironment below. + } +} + +/** Test-only: reset the home-env bootstrap latch. */ +export function resetHomeEnvBootstrapForTesting(): void { + homeEnvBootstrapped = false; +} + +/** Test-only: reset environment reload provenance between tests. */ +export function resetEnvironmentTrackingForTesting(): void { + dotEnvSourcedKeys.clear(); + settingsEnvSourcedKeys.clear(); + lastReloadSnapshot.clear(); + lastReloadSnapshotSeeded = false; +} + +/** + * Collects environment variables from user-level `.env` files and returns + * them as a plain dictionary **without** mutating `process.env`. + * + * Candidates are iterated most-specific-first (`~/.qwen/.env` before + * `~/.env`). `??=` ensures the first file to define a key wins, matching + * dotenv's first-occurrence-wins semantics used elsewhere. + */ +export function getHomeEnvFallbackVars( + onReadError?: (message: string) => void, +): Record { + const globalQwenDir = Storage.getGlobalQwenDir(); + const candidates = [path.join(globalQwenDir, '.env')]; + // When QWEN_HOME is set, skip ~/.env to avoid surprise cross-contamination + // from a shared home .env. getUserLevelEnvPaths() always includes ~/.env + // because loadEnvironment() populates process.env independently — the two + // scopes are intentionally different. + if (!process.env['QWEN_HOME']) { + candidates.push(path.join(path.dirname(globalQwenDir), '.env')); + } + + const result: Record = {}; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + try { + const parsed = dotenv.parse(fs.readFileSync(candidate, 'utf-8')); + for (const key in parsed) { + if (Object.hasOwn(parsed, key) && !Object.hasOwn(process.env, key)) { + result[key] ??= parsed[key]!; + } + } + } catch (e) { + onReadError?.( + `Failed to read home .env candidate ${candidate}: ${getErrorMessage(e)}`, + ); + } + } + return result; +} + +/** + * Finds the .env files to load, respecting workspace trust settings. + * + * When workspace is untrusted, only allow user-level .env files at: + * - ~/.qwen/.env + * - ~/.env + * - /.env (when set) + */ +function findEnvFiles( + settings: Settings, + startDir: string, + userLevelPaths: Set = getUserLevelEnvPaths(), +): string[] { + const homeDir = os.homedir(); + let realStartDir = path.resolve(startDir); + try { + realStartDir = fs.realpathSync(realStartDir); + } catch { + // Match loadSettings(): use the resolved path when realpath is unavailable. + } + const isTrusted = isWorkspaceTrusted( + settings, + undefined, + realStartDir, + ).isTrusted; + + const globalQwenDir = Storage.getGlobalQwenDir(); + const legacyQwenDir = path.normalize(path.join(homeDir, QWEN_DIR)); + const hasCustomConfigDir = path.normalize(globalQwenDir) !== legacyQwenDir; + const found: string[] = []; + const seen = new Set(); + + const canUseEnvFile = (filePath: string): boolean => + isTrusted !== false || userLevelPaths.has(path.normalize(filePath)); + + // Home-dir candidates in priority order: globalQwenDir/.env, then legacy + // ~/.qwen/.env (only when QWEN_HOME redirects), then ~/.env. + const pushCandidate = (filePath: string): boolean => { + const normalized = path.normalize(filePath); + if ( + !seen.has(normalized) && + fs.existsSync(filePath) && + canUseEnvFile(filePath) + ) { + seen.add(normalized); + found.push(filePath); + return true; + } + return false; + }; + + const pushHomeCandidates = (): void => { + const candidates = [path.join(globalQwenDir, '.env')]; + if (hasCustomConfigDir) { + candidates.push(path.join(legacyQwenDir, '.env')); + } + candidates.push(path.join(homeDir, '.env')); + for (const candidate of candidates) { + pushCandidate(candidate); + } + }; + + let currentDir = realStartDir; + let visitedHomeDir = false; + while (true) { + if (currentDir === homeDir) { + visitedHomeDir = true; + pushHomeCandidates(); + return found; + } else { + // Workspace step: prefer .qwen/.env, then plain .env. + const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env'); + if (pushCandidate(geminiEnvPath)) { + pushHomeCandidates(); + return found; + } + const envPath = path.join(currentDir, '.env'); + if (pushCandidate(envPath)) { + pushHomeCandidates(); + return found; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + if (!visitedHomeDir) { + pushHomeCandidates(); + } + return found; + } + currentDir = parentDir; + } +} + +export function setUpCloudShellEnvironment(envFilePath: string | null): void { + // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: + // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project + // set by the user using "gcloud config set project" we do not want to + // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in + // one of the .env files, we set the Cloud Shell-specific default here. + if (envFilePath && fs.existsSync(envFilePath)) { + const envFileContent = fs.readFileSync(envFilePath); + const parsedEnv = dotenv.parse(envFileContent); + if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { + // .env file takes precedence in Cloud Shell + process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; + } else { + // If not in .env, set to default and override global + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; + } + } else { + // If no .env file, set to default and override global + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; + } +} + +function setUpCloudShellEnvironmentFromFiles(envFilePaths: string[]): void { + for (const envFilePath of envFilePaths) { + if (!fs.existsSync(envFilePath)) { + continue; + } + const envFileContent = fs.readFileSync(envFilePath); + const parsedEnv = dotenv.parse(envFileContent); + if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { + process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; + return; + } + } + + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; +} + +/** + * Loads environment variables from .env files and settings.env. + * + * Priority order (highest to lowest): + * 1. CLI flags + * 2. process.env (system/export/inline environment variables) + * 3. .env files (no-override mode) + * 4. settings.env (no-override mode) + * 5. defaults + */ +export function loadEnvironment( + settings: Settings, + startDir: string = process.cwd(), +): void { + const userLevelPaths = getUserLevelEnvPaths(); + const envFilePaths = findEnvFiles(settings, startDir, userLevelPaths); + + // Cloud Shell environment variable handling + if (process.env['CLOUD_SHELL'] === 'true') { + setUpCloudShellEnvironmentFromFiles(envFilePaths); + } + + // Step 1: Load from .env files (higher priority than settings.env) + // Only set if not already present in process.env (no-override mode) + for (const envFilePath of envFilePaths) { + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const normalizedEnvFilePath = path.normalize(envFilePath); + // homeScoped: `.env` lives under the user's home Qwen dir or `~/.env` — + // only these may set QWEN_HOME / QWEN_RUNTIME_DIR. + // qwenScoped: any `.env` whose immediate parent is `.qwen` (including + // `/.qwen/.env`) — exempt from the user `excludedEnvVars` list. + const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); + const isQwenScopedEnvFile = + isHomeScopedEnvFile || + path.basename(path.dirname(normalizedEnvFilePath)) === QWEN_DIR; + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + if ( + !isHomeScopedEnvFile && + PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) + ) { + continue; + } + if (!isQwenScopedEnvFile && excludedVars.includes(key)) { + continue; + } + + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + dotEnvSourcedKeys.add(key); + } + // Seed snapshot with ALL parsed keys (not just written ones) + // so child processes can detect deletions on first reload. + if (!lastReloadSnapshotSeeded && !lastReloadSnapshot.has(key)) { + lastReloadSnapshot.set(key, parsedEnv[key]!); + } + } + } + } catch (_e) { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } + } + + // Step 2: settings.env fallback (lowest priority, no-override). + // Storage-routing vars must never come from settings.json — a workspace + // settings.json could otherwise redirect global state after path bootstrap. + if (settings.env) { + for (const [key, value] of Object.entries(settings.env)) { + if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) { + continue; + } + if (!Object.hasOwn(process.env, key) && typeof value === 'string') { + process.env[key] = value; + settingsEnvSourcedKeys.add(key); + } + if ( + !lastReloadSnapshotSeeded && + typeof value === 'string' && + !lastReloadSnapshot.has(key) + ) { + lastReloadSnapshot.set(key, value); + } + } + } + lastReloadSnapshotSeeded = true; +} + +export interface EnvReloadResult { + updatedKeys: string[]; + removedKeys: string[]; +} + +/** + * Only keys previously set by loadEnvironment() are overwritten; + * shell-exported variables are never touched. + * Fully synchronous — no TOCTOU window between delete and re-add. + */ +export function reloadEnvironment( + settings: Settings, + workspaceCwd: string, +): EnvReloadResult { + const userLevelPaths = getUserLevelEnvPaths(); + const envFilePaths = findEnvFiles(settings, workspaceCwd, userLevelPaths); + + if (process.env['CLOUD_SHELL'] === 'true') { + setUpCloudShellEnvironmentFromFiles(envFilePaths); + } + + // Build the set of new keys from .env (higher priority) + settings.env + let dotEnvReadFailed = false; + const newDotEnvKeys = new Map(); + const newSettingsEnvKeys = new Map(); + + for (const envFilePath of envFilePaths) { + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + const excludedVars = + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const normalizedEnvFilePath = path.normalize(envFilePath); + const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); + const isQwenScopedEnvFile = + isHomeScopedEnvFile || + path.basename(path.dirname(normalizedEnvFilePath)) === QWEN_DIR; + + for (const key in parsedEnv) { + if (!Object.hasOwn(parsedEnv, key)) continue; + if (RELOAD_EXCLUDED_KEYS.has(key)) continue; + if ( + !isHomeScopedEnvFile && + PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) + ) { + continue; + } + if (!isQwenScopedEnvFile && excludedVars.includes(key)) continue; + if (!newDotEnvKeys.has(key)) { + newDotEnvKeys.set(key, parsedEnv[key]!); + } + } + } catch { + dotEnvReadFailed = true; + } + } + + if (settings.env) { + for (const [key, value] of Object.entries(settings.env)) { + if (RELOAD_EXCLUDED_KEYS.has(key)) continue; + if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) continue; + if (typeof value !== 'string') continue; + if (newDotEnvKeys.has(key)) continue; + // When .env read failed, use the snapshot as the shadow set so + // settings.env keys that were previously shadowed by .env don't + // accidentally overwrite the still-live .env values in process.env. + if (dotEnvReadFailed && lastReloadSnapshot.has(key)) continue; + newSettingsEnvKeys.set(key, value); + } + } + + // Union of all new keys + const allNewKeys = new Set([ + ...newDotEnvKeys.keys(), + ...newSettingsEnvKeys.keys(), + ]); + + const updatedKeys: string[] = []; + const removedKeys: string[] = []; + + // Delete keys previously known (from tracking Sets OR the boot snapshot) + // that are no longer in any source file. The snapshot covers keys that + // ACP children inherited from the daemon without tracking. + // Skip deletion entirely if the .env file became unreadable — treat as + // transient I/O failure rather than intentional key removal. + if (!dotEnvReadFailed) { + const previouslyKnown = new Set([ + ...lastReloadSnapshot.keys(), + ...dotEnvSourcedKeys, + ...settingsEnvSourcedKeys, + ]); + for (const key of previouslyKnown) { + if (!allNewKeys.has(key) && !RELOAD_EXCLUDED_KEYS.has(key)) { + delete process.env[key]; + removedKeys.push(key); + } + } + } + + // Force-write all source keys. RELOAD_EXCLUDED_KEYS are already filtered + // at parse time so dangerous keys (PATH, HOME, etc.) never reach here. + // This unconditional write is necessary because ACP children inherit + // daemon env without tracking, so the tracking-based guard would miss them. + for (const [key, value] of newDotEnvKeys) { + if (process.env[key] !== value) { + updatedKeys.push(key); + } + process.env[key] = value; + } + for (const [key, value] of newSettingsEnvKeys) { + if (process.env[key] !== value) { + updatedKeys.push(key); + } + process.env[key] = value; + } + + // Update tracking sets and snapshot only when the .env file was readable. + // A transient read failure must not wipe provenance — the stale tracking + // state is needed so the next successful reload can still detect deletions. + if (!dotEnvReadFailed) { + dotEnvSourcedKeys.clear(); + for (const key of newDotEnvKeys.keys()) { + dotEnvSourcedKeys.add(key); + } + lastReloadSnapshot.clear(); + for (const [key, value] of newDotEnvKeys) { + lastReloadSnapshot.set(key, value); + } + for (const [key, value] of newSettingsEnvKeys) { + lastReloadSnapshot.set(key, value); + } + } + // settings.env is always readable (from settings.json, not a file), + // so its tracking set is always updated. + settingsEnvSourcedKeys.clear(); + for (const key of newSettingsEnvKeys.keys()) { + settingsEnvSourcedKeys.add(key); + } + + return { updatedKeys, removedKeys }; +} diff --git a/packages/cli/src/config/path-comparison.ts b/packages/cli/src/config/path-comparison.ts new file mode 100644 index 00000000000..cc9c2a7aaa8 --- /dev/null +++ b/packages/cli/src/config/path-comparison.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function isWithinRoot(childPath: string, parentPath: string): boolean { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath === '' || + (!relativePath.startsWith(`..${path.sep}`) && + relativePath !== '..' && + !path.isAbsolute(relativePath)) + ); +} + +export function getPathComparisonVariants(rawPath: string): Set { + const variants = new Set([path.normalize(path.resolve(rawPath))]); + try { + variants.add(path.normalize(fs.realpathSync(rawPath))); + } catch { + // Non-existent paths still compare by their resolved lexical form. + } + return variants; +} + +export function arePathsEquivalent(left: string, right: string): boolean { + const rightVariants = getPathComparisonVariants(right); + for (const leftVariant of getPathComparisonVariants(left)) { + if (rightVariants.has(leftVariant)) { + return true; + } + } + return false; +} diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 8e9eb77c57f..ba570bc3ef9 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -96,6 +96,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const USER_SETTINGS_PATH = getUserSettingsPath(); const MOCK_WORKSPACE_DIR = '/mock/workspace'; +const RESOLVED_MOCK_WORKSPACE_DIR = pathActual.resolve(MOCK_WORKSPACE_DIR); // Use the (mocked) SETTINGS_DIRECTORY_NAME for consistency const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join( MOCK_WORKSPACE_DIR, @@ -215,6 +216,32 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({}); }); + it('loads .env starting from the explicit workspace directory', () => { + const envKey = 'LOAD_SETTINGS_WORKSPACE_ENV_MARKER'; + const workspaceEnvPath = pathActual.join( + RESOLVED_MOCK_WORKSPACE_DIR, + '.env', + ); + delete process.env[envKey]; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p.toString() === workspaceEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation((p: fs.PathLike) => { + if (p.toString() === workspaceEnvPath) { + return `${envKey}=from-workspace\n`; + } + return '{}'; + }); + + try { + loadSettings(MOCK_WORKSPACE_DIR); + + expect(process.env[envKey]).toBe('from-workspace'); + } finally { + delete process.env[envKey]; + } + }); + describe('home directory workspace scope', () => { it('should mark workspace settings inactive when workspace is the home directory', () => { const homeDir = '/mock/home/user'; @@ -3888,7 +3915,11 @@ describe('Settings Loading and Merging', () => { }); it('should allow .env file to override settings.env values', () => { - const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env')); + const geminiEnvPath = path.join( + RESOLVED_MOCK_WORKSPACE_DIR, + QWEN_DIR, + '.env', + ); const userSettingsContent: Settings = { env: { ENV_OVERRIDE_TEST: 'from_settings', @@ -4108,6 +4139,9 @@ describe('Settings Loading and Merging', () => { describe('QWEN_HOME custom directory', () => { const originalQwenHome = process.env['QWEN_HOME']; + const originalQwenRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const originalTrustedFoldersPath = + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; beforeEach(() => { delete process.env['DEBUG']; @@ -4121,6 +4155,17 @@ describe('Settings Loading and Merging', () => { } else { process.env['QWEN_HOME'] = originalQwenHome; } + if (originalQwenRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalQwenRuntimeDir; + } + if (originalTrustedFoldersPath === undefined) { + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + } else { + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = + originalTrustedFoldersPath; + } delete process.env['DEBUG']; delete process.env['DEBUG_MODE']; delete process.env['QWEN_HOME_TEST_VAR']; @@ -4160,6 +4205,7 @@ describe('Settings Loading and Merging', () => { delete process.env['QWEN_HOME']; delete process.env['QWEN_RUNTIME_DIR']; delete process.env['QWEN_CODE_MCP_APPROVALS_PATH']; + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; const cwdSpy = vi .spyOn(process, 'cwd') @@ -4181,6 +4227,7 @@ describe('Settings Loading and Merging', () => { 'QWEN_HOME=/tmp/hijack', 'QWEN_RUNTIME_DIR=/tmp/hijack-runtime', 'QWEN_CODE_MCP_APPROVALS_PATH=/tmp/preapproved.json', + 'QWEN_CODE_TRUSTED_FOLDERS_PATH=/tmp/trusted.json', 'OTHER_VAR=ok', ].join('\n'); return '{}'; @@ -4193,6 +4240,7 @@ describe('Settings Loading and Merging', () => { expect(process.env['QWEN_HOME']).toBeUndefined(); expect(process.env['QWEN_RUNTIME_DIR']).toBeUndefined(); expect(process.env['QWEN_CODE_MCP_APPROVALS_PATH']).toBeUndefined(); + expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBeUndefined(); // Other vars from the same project .env still load. expect(process.env['OTHER_VAR']).toEqual('ok'); @@ -4200,6 +4248,37 @@ describe('Settings Loading and Merging', () => { cwdSpy.mockRestore(); }); + it('pre-resolves trusted-folders path from a user-level .env', () => { + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + const customHome = '/tmp/qwen-home-trust'; + const customGlobalEnvPath = path.join(customHome, '.env'); + process.env['QWEN_HOME'] = customHome; + process.env['QWEN_RUNTIME_DIR'] = '/tmp/qwen-runtime'; + + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => + [USER_SETTINGS_PATH, customGlobalEnvPath].includes(p.toString()), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) return JSON.stringify({}); + if (p === customGlobalEnvPath) { + return 'QWEN_CODE_TRUSTED_FOLDERS_PATH=/tmp/custom-trust.json'; + } + return '{}'; + }, + ); + + loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged); + + expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBe( + '/tmp/custom-trust.json', + ); + }); + it('still honors QWEN_HOME from a user-level .env (~/.qwen/.env)', () => { delete process.env['QWEN_HOME']; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 72e98a24181..f41a8c83bdd 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -6,12 +6,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir, platform } from 'node:os'; -import * as dotenv from 'dotenv'; +import { homedir } from 'node:os'; import process from 'node:process'; import { FatalConfigError, - QWEN_DIR, getErrorMessage, Storage, createDebugLogger, @@ -22,8 +20,6 @@ import type { McpServerScope, } from '@qwen-code/qwen-code-core'; import stripJsonComments from 'strip-json-comments'; -import { DefaultLight } from '../ui/themes/default-light.js'; -import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { hasOwnModelProviders } from './modelProvidersScope.js'; import { @@ -43,6 +39,37 @@ import { V1_TO_V2_MIGRATION_MAP, V2_CONTAINER_KEYS, } from './migration/versions/v1-to-v2-shared.js'; +import { + ENV_CORRUPTED_PATH, + ENV_WAS_RECOVERED, + getHomeEnvFallbackVars, + loadEnvironment, + preResolveHomeEnvOverrides, +} from './environment.js'; +import { + DEFAULT_DARK_THEME_NAME, + DEFAULT_LIGHT_THEME_NAME, +} from './default-theme-names.js'; +import { + getSystemDefaultsPath, + getSystemSettingsPath, +} from './storage-paths-lite.js'; + +export { + DEFAULT_EXCLUDED_ENV_VARS, + ENV_CORRUPTED_PATH, + ENV_WAS_RECOVERED, + getHomeEnvFallbackVars, + loadEnvironment, + preResolveHomeEnvOverrides, + reloadEnvironment, + resetEnvironmentTrackingForTesting, + resetHomeEnvBootstrapForTesting, + setUpCloudShellEnvironment, + SETTINGS_DIRECTORY_NAME, +} from './environment.js'; +export { getSystemDefaultsPath, getSystemSettingsPath }; +export type { EnvReloadResult } from './environment.js'; const debugLogger = createDebugLogger('SETTINGS'); @@ -63,8 +90,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { export type { Settings, MemoryImportFormat }; -export const SETTINGS_DIRECTORY_NAME = QWEN_DIR; - // Lazy getters: must NOT be top-level consts. `QWEN_HOME` may be resolved // from `~/.env` or `~/.qwen/.env` by `preResolveHomeEnvOverrides()` in // `loadSettings()`, which runs after this module is imported. A const @@ -76,51 +101,6 @@ export function getUserSettingsPath(): string { export function getUserSettingsDir(): string { return path.dirname(getUserSettingsPath()); } -export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; - -// Env var names used for inter-process communication of corruption state. -// Defined as constants to avoid duplicated string literals. -export const ENV_CORRUPTED_PATH = 'QWEN_CODE_SETTINGS_CORRUPTED_PATH'; -export const ENV_WAS_RECOVERED = 'QWEN_CODE_SETTINGS_WAS_RECOVERED'; - -// QWEN_HOME and QWEN_RUNTIME_DIR control where global state (settings, OAuth -// credentials, installation IDs, etc.) is written. A project `.env` must never -// redirect these — that would split global state between the real home and a -// project-controlled directory. Always excluded from project .env files, -// regardless of user-configurable `advanced.excludedEnvVars`. -const PROJECT_ENV_HARDCODED_EXCLUSIONS = [ - 'QWEN_HOME', - 'QWEN_RUNTIME_DIR', - 'QWEN_CODE_MCP_APPROVALS_PATH', - ENV_CORRUPTED_PATH, - ENV_WAS_RECOVERED, -]; - -const RELOAD_EXCLUDED_KEYS = new Set([ - ...PROJECT_ENV_HARDCODED_EXCLUSIONS, - 'QWEN_SERVER_TOKEN', - 'QWEN_CLI_ENTRY', - 'NODE_OPTIONS', - 'NODE_PATH', - 'NODE_TLS_REJECT_UNAUTHORIZED', - 'LD_PRELOAD', - 'LD_AUDIT', - 'LD_LIBRARY_PATH', - 'DYLD_INSERT_LIBRARIES', - 'DYLD_LIBRARY_PATH', - 'BASH_ENV', - 'ENV', - 'PATH', - 'HOME', - 'TMPDIR', - 'TMP', - 'TEMP', -]); - -const dotEnvSourcedKeys = new Set(); -const settingsEnvSourcedKeys = new Set(); -const lastReloadSnapshot = new Map(); -let lastReloadSnapshotSeeded = false; // Settings version to track migration state export const SETTINGS_VERSION = 4; @@ -194,29 +174,6 @@ export function migrateLegacyPermissions( return result; } -export function getSystemSettingsPath(): string { - if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { - return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; - } - if (platform() === 'darwin') { - return '/Library/Application Support/QwenCode/settings.json'; - } else if (platform() === 'win32') { - return 'C:\\ProgramData\\qwen-code\\settings.json'; - } else { - return '/etc/qwen-code/settings.json'; - } -} - -export function getSystemDefaultsPath(): string { - if (process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']) { - return process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; - } - return path.join( - path.dirname(getSystemSettingsPath()), - 'system-defaults.json', - ); -} - export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { @@ -557,7 +514,7 @@ export class LoadedSettings { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const resolved = resolveEnvVarsInObject( parsed as Settings, - getHomeEnvFallbackVars(), + getHomeEnvFallbackVars((message) => debugLogger.warn(message)), ); file.settings = resolved; file.originalSettings = structuredClone(parsed) as Settings; @@ -616,153 +573,6 @@ export function createMinimalSettings(): LoadedSettings { ); } -/** - * Returns the set of normalized .env file paths that count as user-level. - * - * User-level paths cover the home `.env` and the global Qwen config dir - * `.env` (which respects `QWEN_HOME`). When `QWEN_HOME` redirects elsewhere, - * the legacy `/.qwen/.env` is also included so credentials users - * left there continue to load (and the trust check in untrusted workspaces - * still allows reading it). - */ -function getUserLevelEnvPaths(): Set { - const homeDir = homedir(); - const globalQwenDir = Storage.getGlobalQwenDir(); - const paths = new Set([ - path.normalize(path.join(homeDir, '.env')), - path.normalize(path.join(globalQwenDir, '.env')), - ]); - const legacyQwenEnv = path.normalize(path.join(homeDir, QWEN_DIR, '.env')); - paths.add(legacyQwenEnv); - return paths; -} - -/** - * Pre-resolves QWEN_HOME and QWEN_RUNTIME_DIR from user-level `.env` files - * before any settings or storage paths are read. Required because - * module-load `Storage.getGlobalQwenDir()` would otherwise snapshot legacy - * paths for settings.json, OAuth tokens, installation_id, etc., while the - * regular `.env` load (inside `loadSettings`) only runs later — splitting - * global state between `~/.qwen/...` and `/...`. - * - * Only home-scoped paths are consulted; project `.env` files are barred from - * changing these vars by `PROJECT_ENV_HARDCODED_EXCLUSIONS`. - * - * Exported so `main()` can run it before yargs subcommand handlers (e.g. - * `channel status`/`stop`) — those `process.exit` before `loadSettings()` - * gets a chance to bootstrap. - */ -let homeEnvBootstrapped = false; -export function preResolveHomeEnvOverrides(): void { - if (homeEnvBootstrapped) { - return; - } - homeEnvBootstrapped = true; - - if (process.env['QWEN_HOME'] && process.env['QWEN_RUNTIME_DIR']) { - return; - } - - // Storage.getGlobalQwenDir() shares the same homedir resolution as the - // rest of the storage layer; when QWEN_HOME is unset it equals - // `/.qwen`, so path.dirname() recovers ``. - const initialQwenHome = process.env['QWEN_HOME']; - const initialQwenDir = Storage.getGlobalQwenDir(); - const candidates: string[] = [path.join(initialQwenDir, '.env')]; - if (!initialQwenHome) { - candidates.push(path.join(path.dirname(initialQwenDir), '.env')); - } - - for (const candidate of candidates) { - readHomeEnvInto(candidate); - } - - // If QWEN_HOME was just discovered, also read /.env so - // QWEN_RUNTIME_DIR can be sourced from there (mirrors the VS Code - // companion's bootstrapHomeEnvOverrides — without this third pass the - // CLI and companion would diverge on the runtime dir). - const discoveredQwenHome = process.env['QWEN_HOME']; - if (discoveredQwenHome && discoveredQwenHome !== initialQwenHome) { - const discoveredDir = Storage.getGlobalQwenDir(); - if (discoveredDir !== initialQwenDir) { - readHomeEnvInto(path.join(discoveredDir, '.env')); - } - } -} - -function readHomeEnvInto(file: string): void { - if (!fs.existsSync(file)) { - return; - } - try { - const parsed = dotenv.parse(fs.readFileSync(file, 'utf-8')); - for (const key of PROJECT_ENV_HARDCODED_EXCLUSIONS) { - if (parsed[key] && !Object.hasOwn(process.env, key)) { - process.env[key] = parsed[key]; - } - } - } catch (_e) { - // Match the dotenv quiet-mode behavior used by loadEnvironment below. - } -} - -/** Test-only: reset the home-env bootstrap latch. */ -export function resetHomeEnvBootstrapForTesting(): void { - homeEnvBootstrapped = false; -} - -/** Test-only: reset environment reload provenance between tests. */ -export function resetEnvironmentTrackingForTesting(): void { - dotEnvSourcedKeys.clear(); - settingsEnvSourcedKeys.clear(); - lastReloadSnapshot.clear(); - lastReloadSnapshotSeeded = false; -} - -/** - * Collects environment variables from user-level `.env` files and returns - * them as a plain dictionary **without** mutating `process.env`. - * - * Candidates are iterated most-specific-first (`~/.qwen/.env` before - * `~/.env`). `??=` ensures the first file to define a key wins, matching - * dotenv's first-occurrence-wins semantics used elsewhere. - * - * Note: this dict intentionally does NOT filter PROJECT_ENV_HARDCODED_EXCLUSIONS - * or advanced.excludedEnvVars — substitution scope is narrower than process.env - * population handled by preResolveHomeEnvOverrides / readHomeEnvInto. - */ -function getHomeEnvFallbackVars(): Record { - const globalQwenDir = Storage.getGlobalQwenDir(); - const candidates = [path.join(globalQwenDir, '.env')]; - // When QWEN_HOME is set, skip ~/.env to avoid surprise cross-contamination - // from a shared home .env. getUserLevelEnvPaths() always includes ~/.env - // because loadEnvironment() populates process.env independently — the two - // scopes are intentionally different. - if (!process.env['QWEN_HOME']) { - candidates.push(path.join(path.dirname(globalQwenDir), '.env')); - } - - const result: Record = {}; - for (const candidate of candidates) { - if (!fs.existsSync(candidate)) { - continue; - } - try { - const parsed = dotenv.parse(fs.readFileSync(candidate, 'utf-8')); - for (const key in parsed) { - if (Object.hasOwn(parsed, key) && !Object.hasOwn(process.env, key)) { - result[key] ??= parsed[key]!; - } - } - } catch (e) { - debugLogger.warn( - `Failed to read home .env candidate ${candidate}: ${getErrorMessage(e)}`, - ); - } - } - return result; -} - /** * Surfaces a one-shot warning when QWEN_HOME has been redirected but the * user hasn't migrated their existing global state. Auto-copying OAuth @@ -805,363 +615,6 @@ function detectQwenHomeRedirectWithoutMigration( ); } -/** - * Finds the .env files to load, respecting workspace trust settings. - * - * When workspace is untrusted, only allow user-level .env files at: - * - ~/.qwen/.env - * - ~/.env - * - /.env (when set) - */ -function findEnvFiles( - settings: Settings, - startDir: string, - userLevelPaths: Set = getUserLevelEnvPaths(), -): string[] { - const homeDir = homedir(); - const isTrusted = isWorkspaceTrusted(settings).isTrusted; - - const globalQwenDir = Storage.getGlobalQwenDir(); - const legacyQwenDir = path.normalize(path.join(homeDir, QWEN_DIR)); - const hasCustomConfigDir = path.normalize(globalQwenDir) !== legacyQwenDir; - const found: string[] = []; - const seen = new Set(); - - const canUseEnvFile = (filePath: string): boolean => - isTrusted !== false || userLevelPaths.has(path.normalize(filePath)); - - const pushCandidate = (filePath: string): boolean => { - const normalized = path.normalize(filePath); - if ( - !seen.has(normalized) && - fs.existsSync(filePath) && - canUseEnvFile(filePath) - ) { - seen.add(normalized); - found.push(filePath); - return true; - } - return false; - }; - - // Home-dir candidates in priority order: globalQwenDir/.env, then legacy - // ~/.qwen/.env (only when QWEN_HOME redirects), then ~/.env. - // Users who add `QWEN_HOME=` to an existing global env file shouldn't lose - // credentials still in the legacy file; routing vars inside it are already - // pinned by `preResolveHomeEnvOverrides` (no-override). - const pushHomeCandidates = (): void => { - const candidates = [path.join(globalQwenDir, '.env')]; - if (hasCustomConfigDir) { - candidates.push(path.join(legacyQwenDir, '.env')); - } - candidates.push(path.join(homeDir, '.env')); - for (const candidate of candidates) { - pushCandidate(candidate); - } - }; - - let currentDir = path.resolve(startDir); - let visitedHomeDir = false; - while (true) { - if (currentDir === homeDir) { - visitedHomeDir = true; - pushHomeCandidates(); - return found; - } else { - // Workspace step: prefer .qwen/.env, then plain .env. - const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env'); - if (pushCandidate(geminiEnvPath)) { - pushHomeCandidates(); - return found; - } - const envPath = path.join(currentDir, '.env'); - if (pushCandidate(envPath)) { - pushHomeCandidates(); - return found; - } - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir || !parentDir) { - if (!visitedHomeDir) { - pushHomeCandidates(); - } - return found; - } - currentDir = parentDir; - } -} - -export function setUpCloudShellEnvironment(envFilePath: string | null): void { - // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: - // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project - // set by the user using "gcloud config set project" we do not want to - // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in - // one of the .env files, we set the Cloud Shell-specific default here. - if (envFilePath && fs.existsSync(envFilePath)) { - const envFileContent = fs.readFileSync(envFilePath); - const parsedEnv = dotenv.parse(envFileContent); - if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { - // .env file takes precedence in Cloud Shell - process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; - } else { - // If not in .env, set to default and override global - process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; - } - } else { - // If no .env file, set to default and override global - process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; - } -} - -function setUpCloudShellEnvironmentFromFiles(envFilePaths: string[]): void { - for (const envFilePath of envFilePaths) { - if (!fs.existsSync(envFilePath)) { - continue; - } - const envFileContent = fs.readFileSync(envFilePath); - const parsedEnv = dotenv.parse(envFileContent); - if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { - process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; - return; - } - } - - process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; -} - -/** - * Loads environment variables from .env files and settings.env. - * - * Priority order (highest to lowest): - * 1. CLI flags - * 2. process.env (system/export/inline environment variables) - * 3. .env files (no-override mode) - * 4. settings.env (no-override mode) - * 5. defaults - */ -export function loadEnvironment(settings: Settings): void { - const userLevelPaths = getUserLevelEnvPaths(); - const envFilePaths = findEnvFiles(settings, process.cwd(), userLevelPaths); - - // Cloud Shell environment variable handling - if (process.env['CLOUD_SHELL'] === 'true') { - setUpCloudShellEnvironmentFromFiles(envFilePaths); - } - - // Step 1: Load from .env files (higher priority than settings.env) - // Only set if not already present in process.env (no-override mode) - for (const envFilePath of envFilePaths) { - try { - const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); - const parsedEnv = dotenv.parse(envFileContent); - - const excludedVars = - settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; - const normalizedEnvFilePath = path.normalize(envFilePath); - // homeScoped: `.env` lives under the user's home Qwen dir or `~/.env` — - // only these may set QWEN_HOME / QWEN_RUNTIME_DIR. - // qwenScoped: any `.env` whose immediate parent is `.qwen` (including - // `/.qwen/.env`) — exempt from the user `excludedEnvVars` list. - const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); - const isQwenScopedEnvFile = - isHomeScopedEnvFile || - path.basename(path.dirname(normalizedEnvFilePath)) === QWEN_DIR; - - for (const key in parsedEnv) { - if (Object.hasOwn(parsedEnv, key)) { - if ( - !isHomeScopedEnvFile && - PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) - ) { - continue; - } - if (!isQwenScopedEnvFile && excludedVars.includes(key)) { - continue; - } - - if (!Object.hasOwn(process.env, key)) { - process.env[key] = parsedEnv[key]; - dotEnvSourcedKeys.add(key); - } - // Seed snapshot with ALL parsed keys (not just written ones) - // so child processes can detect deletions on first reload. - if (!lastReloadSnapshotSeeded && !lastReloadSnapshot.has(key)) { - lastReloadSnapshot.set(key, parsedEnv[key]!); - } - } - } - } catch (_e) { - // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. - } - } - - // Step 2: settings.env fallback (lowest priority, no-override). - // Storage-routing vars must never come from settings.json — a workspace - // settings.json could otherwise redirect global state after path bootstrap. - if (settings.env) { - for (const [key, value] of Object.entries(settings.env)) { - if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) { - continue; - } - if (!Object.hasOwn(process.env, key) && typeof value === 'string') { - process.env[key] = value; - settingsEnvSourcedKeys.add(key); - } - if ( - !lastReloadSnapshotSeeded && - typeof value === 'string' && - !lastReloadSnapshot.has(key) - ) { - lastReloadSnapshot.set(key, value); - } - } - } - lastReloadSnapshotSeeded = true; -} - -export interface EnvReloadResult { - updatedKeys: string[]; - removedKeys: string[]; -} - -/** - * Only keys previously set by loadEnvironment() are overwritten; - * shell-exported variables are never touched. - * Fully synchronous — no TOCTOU window between delete and re-add. - */ -export function reloadEnvironment( - settings: Settings, - workspaceCwd: string, -): EnvReloadResult { - const userLevelPaths = getUserLevelEnvPaths(); - const envFilePaths = findEnvFiles(settings, workspaceCwd, userLevelPaths); - - if (process.env['CLOUD_SHELL'] === 'true') { - setUpCloudShellEnvironmentFromFiles(envFilePaths); - } - - // Build the set of new keys from .env (higher priority) + settings.env - let dotEnvReadFailed = false; - const newDotEnvKeys = new Map(); - const newSettingsEnvKeys = new Map(); - - for (const envFilePath of envFilePaths) { - try { - const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); - const parsedEnv = dotenv.parse(envFileContent); - const excludedVars = - settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; - const normalizedEnvFilePath = path.normalize(envFilePath); - const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); - const isQwenScopedEnvFile = - isHomeScopedEnvFile || - path.basename(path.dirname(normalizedEnvFilePath)) === QWEN_DIR; - - for (const key in parsedEnv) { - if (!Object.hasOwn(parsedEnv, key)) continue; - if (RELOAD_EXCLUDED_KEYS.has(key)) continue; - if ( - !isHomeScopedEnvFile && - PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) - ) { - continue; - } - if (!isQwenScopedEnvFile && excludedVars.includes(key)) continue; - if (!newDotEnvKeys.has(key)) { - newDotEnvKeys.set(key, parsedEnv[key]!); - } - } - } catch { - dotEnvReadFailed = true; - } - } - - if (settings.env) { - for (const [key, value] of Object.entries(settings.env)) { - if (RELOAD_EXCLUDED_KEYS.has(key)) continue; - if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) continue; - if (typeof value !== 'string') continue; - if (newDotEnvKeys.has(key)) continue; - // When .env read failed, use the snapshot as the shadow set so - // settings.env keys that were previously shadowed by .env don't - // accidentally overwrite the still-live .env values in process.env. - if (dotEnvReadFailed && lastReloadSnapshot.has(key)) continue; - newSettingsEnvKeys.set(key, value); - } - } - - // Union of all new keys - const allNewKeys = new Set([ - ...newDotEnvKeys.keys(), - ...newSettingsEnvKeys.keys(), - ]); - - const updatedKeys: string[] = []; - const removedKeys: string[] = []; - - // Delete keys previously known (from tracking Sets OR the boot snapshot) - // that are no longer in any source file. The snapshot covers keys that - // ACP children inherited from the daemon without tracking. - // Skip deletion entirely if the .env file became unreadable — treat as - // transient I/O failure rather than intentional key removal. - if (!dotEnvReadFailed) { - const previouslyKnown = new Set([ - ...lastReloadSnapshot.keys(), - ...dotEnvSourcedKeys, - ...settingsEnvSourcedKeys, - ]); - for (const key of previouslyKnown) { - if (!allNewKeys.has(key) && !RELOAD_EXCLUDED_KEYS.has(key)) { - delete process.env[key]; - removedKeys.push(key); - } - } - } - - // Force-write all source keys. RELOAD_EXCLUDED_KEYS are already filtered - // at parse time so dangerous keys (PATH, HOME, etc.) never reach here. - // This unconditional write is necessary because ACP children inherit - // daemon env without tracking, so the tracking-based guard would miss them. - for (const [key, value] of newDotEnvKeys) { - if (process.env[key] !== value) { - updatedKeys.push(key); - } - process.env[key] = value; - } - for (const [key, value] of newSettingsEnvKeys) { - if (process.env[key] !== value) { - updatedKeys.push(key); - } - process.env[key] = value; - } - - // Update tracking sets and snapshot only when the .env file was readable. - // A transient read failure must not wipe provenance — the stale tracking - // state is needed so the next successful reload can still detect deletions. - if (!dotEnvReadFailed) { - dotEnvSourcedKeys.clear(); - for (const key of newDotEnvKeys.keys()) { - dotEnvSourcedKeys.add(key); - } - lastReloadSnapshot.clear(); - for (const [key, value] of newDotEnvKeys) { - lastReloadSnapshot.set(key, value); - } - for (const [key, value] of newSettingsEnvKeys) { - lastReloadSnapshot.set(key, value); - } - } - // settings.env is always readable (from settings.json, not a file), - // so its tracking set is always updated. - settingsEnvSourcedKeys.clear(); - for (const key of newSettingsEnvKeys.keys()) { - settingsEnvSourcedKeys.add(key); - } - - return { updatedKeys, removedKeys }; -} - export const CORRUPTED_SUFFIX = '.corrupted'; /** @@ -1446,7 +899,9 @@ export function loadSettings( // effective precedence is: process.env > home .env > unresolved placeholder. // The resolver checks customEnv before process.env, but since customEnv // never contains a process.env key, process.env always wins. - const homeEnvFallback = getHomeEnvFallbackVars(); + const homeEnvFallback = getHomeEnvFallbackVars((message) => + debugLogger.warn(message), + ); systemSettings = resolveEnvVarsInObject( systemResult.settings, homeEnvFallback, @@ -1463,14 +918,14 @@ export function loadSettings( // Support legacy theme names if (userSettings.ui?.theme === 'VS') { - userSettings.ui.theme = DefaultLight.name; + userSettings.ui.theme = DEFAULT_LIGHT_THEME_NAME; } else if (userSettings.ui?.theme === 'VS2015') { - userSettings.ui.theme = DefaultDark.name; + userSettings.ui.theme = DEFAULT_DARK_THEME_NAME; } if (workspaceSettings.ui?.theme === 'VS') { - workspaceSettings.ui.theme = DefaultLight.name; + workspaceSettings.ui.theme = DEFAULT_LIGHT_THEME_NAME; } else if (workspaceSettings.ui?.theme === 'VS2015') { - workspaceSettings.ui.theme = DefaultDark.name; + workspaceSettings.ui.theme = DEFAULT_DARK_THEME_NAME; } // For the initial trust check, we can only use user and system settings. @@ -1481,7 +936,11 @@ export function loadSettings( userSettings, ); const isTrusted = - isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; + isWorkspaceTrusted( + initialTrustCheckSettings as Settings, + undefined, + realWorkspaceDir, + ).isTrusted ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( @@ -1495,7 +954,7 @@ export function loadSettings( // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle if (!opts.skipLoadEnvironment) { - loadEnvironment(tempMergedSettings); + loadEnvironment(tempMergedSettings, workspaceDir); } // Create LoadedSettings first diff --git a/packages/cli/src/config/shared-env-keys.ts b/packages/cli/src/config/shared-env-keys.ts new file mode 100644 index 00000000000..a19725c7e87 --- /dev/null +++ b/packages/cli/src/config/shared-env-keys.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; + +export const ENV_CORRUPTED_PATH = 'QWEN_CODE_SETTINGS_CORRUPTED_PATH'; +export const ENV_WAS_RECOVERED = 'QWEN_CODE_SETTINGS_WAS_RECOVERED'; + +// QWEN_HOME and QWEN_RUNTIME_DIR control where global state (settings, OAuth +// credentials, installation IDs, etc.) is written. A project `.env` must never +// redirect these — that would split global state between the real home and a +// project-controlled directory. Always excluded from project .env files, +// regardless of user-configurable `advanced.excludedEnvVars`. +export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [ + 'QWEN_HOME', + 'QWEN_RUNTIME_DIR', + 'QWEN_CODE_MCP_APPROVALS_PATH', + 'QWEN_CODE_TRUSTED_FOLDERS_PATH', + ENV_CORRUPTED_PATH, + ENV_WAS_RECOVERED, +]; + +export const HOME_ENV_BOOTSTRAP_KEYS = [ + 'QWEN_HOME', + 'QWEN_RUNTIME_DIR', + 'QWEN_CODE_MCP_APPROVALS_PATH', + 'QWEN_CODE_TRUSTED_FOLDERS_PATH', +] as const; diff --git a/packages/cli/src/config/storage-paths-lite.ts b/packages/cli/src/config/storage-paths-lite.ts new file mode 100644 index 00000000000..72110439ad0 --- /dev/null +++ b/packages/cli/src/config/storage-paths-lite.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; + +// Keep this literal in sync with core's QWEN_DIR. This lite module must not +// import @qwen-code/qwen-code-core because it runs before serve listener ready. +export const SETTINGS_DIRECTORY_NAME = '.qwen'; + +export function resolveConfigPathLite(dir: string, cwd?: string): string { + let resolved = dir; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + const relativeSegments = + resolved === '~' + ? [] + : resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean); + resolved = path.join(os.homedir(), ...relativeSegments); + } + if (!path.isAbsolute(resolved)) { + resolved = path.resolve(cwd || process.cwd(), resolved); + } + return resolved; +} + +export function getGlobalQwenDirLite(): string { + const envDir = process.env['QWEN_HOME']; + if (envDir) { + return resolveConfigPathLite(envDir); + } + const homeDir = os.homedir(); + if (!homeDir) { + return path.join(os.tmpdir(), SETTINGS_DIRECTORY_NAME); + } + return path.join(homeDir, SETTINGS_DIRECTORY_NAME); +} + +export function getSystemSettingsPath(): string { + if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { + return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; + } + if (os.platform() === 'darwin') { + return '/Library/Application Support/QwenCode/settings.json'; + } + if (os.platform() === 'win32') { + return 'C:\\ProgramData\\qwen-code\\settings.json'; + } + return '/etc/qwen-code/settings.json'; +} + +export function getSystemDefaultsPath(): string { + if (process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']) { + return process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; + } + return path.join( + path.dirname(getSystemSettingsPath()), + 'system-defaults.json', + ); +} diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 5378e48ff5e..0efed24a7d3 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -550,6 +550,25 @@ describe('isWorkspaceTrusted', () => { source: 'file', }); }); + + it('should match distrust rules through canonical symlink paths', () => { + mockCwd = '/real/project'; + mockRules['/link/project'] = TrustLevel.DO_NOT_TRUST; + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { + const value = String(p); + if (value === '/link/project' || value === '/real/project') { + return '/real/project'; + } + return value; + }); + + expect( + isWorkspaceTrusted(mockSettings, undefined, '/real/project'), + ).toEqual({ + isTrusted: false, + source: 'file', + }); + }); }); describe('isWorkspaceTrusted with IDE override', () => { @@ -616,6 +635,24 @@ describe('isWorkspaceTrusted with IDE override', () => { source: undefined, }); }); + + it('should not apply IDE trust to an explicit different workspace', () => { + ideContextStore.set({ workspaceState: { isTrusted: true } }); + vi.spyOn(process, 'cwd').mockReturnValue('/home/user/current'); + vi.spyOn(fs, 'existsSync').mockImplementation( + (p) => p === getTrustedFoldersPath(), + ); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ '/home/user/other': TrustLevel.DO_NOT_TRUST }), + ); + + expect( + isWorkspaceTrusted(mockSettings, undefined, '/home/user/other'), + ).toEqual({ + isTrusted: false, + source: 'file', + }); + }); }); describe('Trusted Folders Caching', () => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 936f398b7de..2a023e8323b 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -10,7 +10,6 @@ import { atomicWriteFileSync, FatalConfigError, getErrorMessage, - isWithinRoot, ideContextStore, Storage, } from '@qwen-code/qwen-code-core'; @@ -19,6 +18,11 @@ import { parse, stringify } from 'comment-json'; import stripJsonComments from 'strip-json-comments'; import { applyUpdates } from '../utils/commentJson.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { + arePathsEquivalent, + getPathComparisonVariants, + isWithinRoot, +} from './path-comparison.js'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; @@ -98,15 +102,26 @@ export class LoadedTrustedFolders { } } + const locationVariants = getPathComparisonVariants(location); for (const trustedPath of trustedPaths) { - if (isWithinRoot(location, trustedPath)) { - return true; + for (const locationVariant of locationVariants) { + for (const trustedVariant of getPathComparisonVariants(trustedPath)) { + if (isWithinRoot(locationVariant, trustedVariant)) { + return true; + } + } } } for (const untrustedPath of untrustedPaths) { - if (path.normalize(location) === path.normalize(untrustedPath)) { - return false; + for (const locationVariant of locationVariants) { + for (const untrustedVariant of getPathComparisonVariants( + untrustedPath, + )) { + if (locationVariant === untrustedVariant) { + return false; + } + } } } @@ -249,6 +264,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean { function getWorkspaceTrustFromLocalConfig( trustConfig?: Record, + workspacePath: string = process.cwd(), ): TrustResult { const folders = loadTrustedFolders(); @@ -265,7 +281,7 @@ function getWorkspaceTrustFromLocalConfig( ); } - const isTrusted = folders.isPathTrusted(process.cwd()); + const isTrusted = folders.isPathTrusted(workspacePath); return { isTrusted, source: isTrusted !== undefined ? 'file' : undefined, @@ -275,16 +291,21 @@ function getWorkspaceTrustFromLocalConfig( export function isWorkspaceTrusted( settings: Settings, trustConfig?: Record, + workspacePath?: string, ): TrustResult { if (!isFolderTrustEnabled(settings)) { return { isTrusted: true, source: undefined }; } const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted; - if (ideTrust !== undefined) { + if ( + ideTrust !== undefined && + (workspacePath === undefined || + arePathsEquivalent(workspacePath, process.cwd())) + ) { return { isTrusted: ideTrust, source: 'ide' }; } // Fall back to the local user configuration - return getWorkspaceTrustFromLocalConfig(trustConfig); + return getWorkspaceTrustFromLocalConfig(trustConfig, workspacePath); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 1c156a8e401..64a12ec3835 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -13,13 +13,14 @@ import { afterEach, type MockInstance, } from 'vitest'; +import { readFileSync } from 'node:fs'; import { createNonInteractivePromptId, main, setupUnhandledRejectionHandler, validateDnsResolutionOrder, - startInteractiveUI, } from './gemini.js'; +import { startInteractiveUI } from './ui/startInteractiveUI.js'; import type { CliArgs } from './config/config.js'; import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; @@ -29,6 +30,31 @@ import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockHandleListExtensions = vi.hoisted(() => vi.fn()); +describe('gemini import boundary', () => { + it('does not statically import ACP or noninteractive auth branches', () => { + const source = readFileSync('src/gemini.tsx', 'utf8'); + + expect(source).not.toContain( + "import { runAcpAgent } from './acp-integration/acpAgent.js'", + ); + expect(source).not.toContain( + "import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'", + ); + expect(source).not.toContain( + "import { initializeApp } from './core/initializer.js'", + ); + expect(source).toMatch( + /await import\(\s*['"]\.\/acp-integration\/acpAgent\.js['"]\s*\)/, + ); + expect(source).toMatch( + /await import\(\s*['"]\.\/validateNonInterActiveAuth\.js['"]\s*\)/, + ); + expect(source).toMatch( + /await import\(\s*['"]\.\/core\/initializer\.js['"]\s*\)/, + ); + }); +}); + // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { constructor(readonly code?: string | number | null | undefined) { @@ -116,6 +142,10 @@ vi.mock('./commands/extensions/list.js', () => ({ handleList: mockHandleListExtensions, })); +vi.mock('./ui/AppContainer.js', () => ({ + AppContainer: () => null, +})); + // Stub the settings watcher: main() constructs one and calls startWatching() // in non-bare mode. The real implementation reads settings.user/.workspace // paths and arms chokidar file watchers, neither of which these main()-flow diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 489390a8345..32290859ad9 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -14,18 +14,14 @@ import { Storage, SessionService, setStartupEventSink, - type Config, createDebugLogger, - writeRuntimeStatus, persistSessionUsage, uiTelemetryService, } from '@qwen-code/qwen-code-core'; -import { render } from 'ink'; import dns from 'node:dns'; import os from 'node:os'; -import path, { basename } from 'node:path'; +import path from 'node:path'; import v8 from 'node:v8'; -import React from 'react'; import { validateAuthMethod } from './config/auth.js'; import * as cliConfig from './config/config.js'; import { @@ -33,7 +29,7 @@ import { loadCliConfig, parseArguments, } from './config/config.js'; -import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; +import type { DnsResolutionOrder } from './config/settings.js'; import { ENV_CORRUPTED_PATH, ENV_WAS_RECOVERED, @@ -43,42 +39,19 @@ import { preResolveHomeEnvOverrides, } from './config/settings.js'; import { SettingsWatcher } from './config/settingsWatcher.js'; -import { - initializeApp, - type InitializationResult, -} from './core/initializer.js'; -import { handleList as handleListExtensions } from './commands/extensions/list.js'; import { initializeI18n, resolveLanguageSetting } from './i18n/index.js'; -import { runNonInteractive } from './nonInteractiveCli.js'; import { setupStartupWorktree, persistStartupWorktreeSidecar, buildStartupWorktreeNotice, type StartupWorktreeContext, } from './startup/worktreeStartup.js'; -import { runNonInteractiveStreamJson } from './nonInteractive/session.js'; -import { AppContainer } from './ui/AppContainer.js'; -import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; -import { KeypressProvider } from './ui/contexts/KeypressContext.js'; -import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; -import { SettingsContext } from './ui/contexts/SettingsContext.js'; -import { VimModeProvider } from './ui/contexts/VimModeContext.js'; -import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; -import { BackgroundTaskViewProvider } from './ui/contexts/BackgroundTaskViewContext.js'; -import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; -import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js'; -import { - detectAndEnableKittyProtocol, - disableKittyProtocol, -} from './ui/utils/kittyProtocolDetector.js'; -import { checkForUpdates } from './ui/utils/updateCheck.js'; import { cleanupCheckpoints, registerCleanup, runExitCleanup, } from './utils/cleanup.js'; import { AppEvent, appEvents } from './utils/events.js'; -import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { readStdin } from './utils/readStdin.js'; import { profileCheckpoint, @@ -94,25 +67,11 @@ import { import { start_sandbox } from './utils/sandbox.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; -import { getCliVersion } from './utils/version.js'; import { initializeWarningHandler } from './utils/warningHandler.js'; import { writeStderrLine } from './utils/stdioHelpers.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; -import { computeWindowTitle, writeTerminalTitle } from './utils/windowTitle.js'; -import { - startEarlyInputCapture, - stopAndGetCapturedInput, -} from './utils/earlyInputCapture.js'; import { preconnectApi } from './utils/apiPreconnect.js'; -import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; -import { DualOutputBridge } from './dualOutput/DualOutputBridge.js'; -import { DualOutputContext } from './dualOutput/DualOutputContext.js'; -import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js'; -import { RemoteInputContext } from './remoteInput/RemoteInputContext.js'; -import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js'; -import { installSynchronizedOutput } from './ui/utils/synchronizedOutput.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -170,7 +129,6 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { } import { loadSandboxConfig } from './config/sandboxConfig.js'; -import { runAcpAgent } from './acp-integration/acpAgent.js'; export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; @@ -236,185 +194,6 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void { }; } -export async function startInteractiveUI( - config: Config, - settings: LoadedSettings, - startupWarnings: string[], - workspaceRoot: string = process.cwd(), - initializationResult: InitializationResult, -) { - const version = await getCliVersion(); - setWindowTitle(settings, basename(workspaceRoot)); - - // Write a small runtime.json sidecar next to the chat log so external - // tools (terminal multiplexers, IDE integrations, status daemons) can - // map the running PID back to its session id and work directory. - // Best-effort: a read-only filesystem must not prevent the UI from - // starting up. Marking the runtime status as enabled is what arms the - // session-swap refresh in `Config.refreshSessionId()` — without this - // call, the sidecar would never update on `/clear` or `/resume`. - try { - const sessionId = config.getSessionId(); - const runtimeStatusPath = config.storage.getRuntimeStatusPath(sessionId); - await writeRuntimeStatus(runtimeStatusPath, { - sessionId, - workDir: config.getTargetDir(), - qwenVersion: version, - }); - config.markRuntimeStatusEnabled(); - } catch { - // ignored: best-effort, never block UI startup. - } - - const restoreTerminalRedrawOptimizer = - process.stdout.isTTY && !config.getScreenReader() - ? installTerminalRedrawOptimizer(process.stdout) - : () => {}; - const restoreSynchronizedOutput = - process.stdout.isTTY && !config.getScreenReader() - ? installSynchronizedOutput(process.stdout) - : () => {}; - - // Create dual output bridge if --json-fd or --json-file is specified. - // Errors are caught so a bad fd/path degrades gracefully instead of - // preventing the TUI from launching. - let dualOutputBridge: DualOutputBridge | null = null; - const jsonFd = config.getJsonFd?.(); - const jsonFile = config.getJsonFile?.(); - try { - if (jsonFd != null) { - dualOutputBridge = new DualOutputBridge( - config, - { fd: jsonFd }, - { version }, - ); - } else if (jsonFile != null) { - dualOutputBridge = new DualOutputBridge( - config, - { filePath: jsonFile }, - { version }, - ); - } - } catch (err) { - debugLogger.error('Failed to initialize dual output bridge:', err); - writeStderrLine( - `Warning: dual output disabled — ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Create remote input watcher if --input-file is specified. - // This enables bidirectional sync: an external process writes JSONL - // commands to this file, and the TUI processes them as user messages. - let remoteInputWatcher: RemoteInputWatcher | null = null; - const inputFile = config.getInputFile?.(); - if (inputFile) { - try { - remoteInputWatcher = new RemoteInputWatcher(inputFile); - } catch (err) { - debugLogger.error('Failed to initialize remote input watcher:', err); - writeStderrLine( - `Warning: remote input disabled — ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Drain the early-captured input exactly once, before any React rendering. - // Must be outside any component/effect so StrictMode's mount/cleanup/remount - // always reads from the same stable prop rather than the (now empty) module buffer. - const initialCapturedInput = stopAndGetCapturedInput(); - - // Create wrapper component to use hooks inside render - const AppWrapper = () => { - const kittyProtocolStatus = useKittyKeyboardProtocol(); - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); - return ( - - - - - - - - - - - - - - - - - - ); - }; - - const useVP = settings.merged.ui?.useTerminalBuffer ?? false; - const instance = render( - process.env['DEBUG'] ? ( - - - - ) : ( - - ), - { - exitOnCtrlC: false, - isScreenReaderEnabled: config.getScreenReader(), - alternateScreen: useVP, - }, - ); - // Records the moment Ink's `render()` call has returned, which is - // synchronous and happens before React reconciliation actually pushes - // bytes to the terminal. We intentionally keep the legacy name - // `first_paint` for backward compatibility with previously-collected - // profile files; the value is best read as "render call returned" - // rather than literal pixel paint. AppContainer's mount effect runs - // after this — it carries the `config_initialize_*` and - // `input_enabled` checkpoints that complete the first-screen picture. - profileCheckpoint('first_paint'); - - // Check for updates only if enableAutoUpdate is not explicitly disabled. - // Using !== false ensures updates are enabled by default when undefined. - if (settings.merged.general?.enableAutoUpdate !== false) { - checkForUpdates() - .then((info) => { - handleAutoUpdate(info, settings, config.getProjectRoot()); - }) - .catch((err) => { - // Silently ignore update check errors. - debugLogger.warn(`Update check failed: ${err}`); - }); - } - - registerCleanup(async () => { - remoteInputWatcher?.shutdown(); - await dualOutputBridge?.shutdown(); - // Explicitly disable the Kitty keyboard protocol before unmounting Ink so - // that the disable escape sequence is written while stdout is still fully - // operational, preventing garbled terminal output after the app exits. - disableKittyProtocol(); - instance.unmount(); - restoreSynchronizedOutput(); - restoreTerminalRedrawOptimizer(); - }); -} - export async function main() { profileCheckpoint('main_entry'); // Bridge core-package startup events (Config.initialize, MCP discovery, @@ -481,6 +260,9 @@ export async function main() { await initializeI18n( resolveLanguageSetting(settings.merged.general?.language as string), ); + const { handleList: handleListExtensions } = await import( + './commands/extensions/list.js' + ); await handleListExtensions(); process.exit(0); } @@ -499,6 +281,9 @@ export async function main() { validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), ); + const { themeManager, AUTO_THEME_NAME } = await import( + './ui/themes/theme-manager.js' + ); // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); @@ -739,6 +524,9 @@ export async function main() { if (argv.resume === '') { // No argument — show picker + const { showResumeSessionPicker } = await import( + './ui/components/StandaloneSessionPicker.js' + ); resolvedSessionId = await showResumeSessionPicker(); } else if (!cliConfig.isValidSessionId(argv.resume)) { // Non-UUID argument — treat as custom title search @@ -751,6 +539,9 @@ export async function main() { writeStderrLine( `Multiple sessions found with title "${argv.resume}". Please select one:`, ); + const { showResumeSessionPicker } = await import( + './ui/components/StandaloneSessionPicker.js' + ); resolvedSessionId = await showResumeSessionPicker( process.cwd(), matches, @@ -900,6 +691,12 @@ export async function main() { registerCleanup(installInteractiveSignalHandlers(wasRaw)); } if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { + const { startEarlyInputCapture, stopAndGetCapturedInput } = await import( + './utils/earlyInputCapture.js' + ); + const { detectAndEnableKittyProtocol } = await import( + './ui/utils/kittyProtocolDetector.js' + ); // Set this as early as possible to avoid spurious characters from // input showing up in the output. process.stdin.setRawMode(true); @@ -931,7 +728,12 @@ export async function main() { } } - setMaxSizedBoxDebugging(isDebugMode); + if (config.isInteractive()) { + const { setMaxSizedBoxDebugging } = await import( + './ui/components/shared/MaxSizedBox.js' + ); + setMaxSizedBoxDebugging(isDebugMode); + } // Check input format early to determine initialization flow // In TTY mode, ignore stream-json input format to prevent process from hanging @@ -943,10 +745,12 @@ export async function main() { // For stream-json mode, defer config.initialize() until after the initialize control request // For other modes, initialize normally + const { initializeApp } = await import('./core/initializer.js'); const initializationResult = await initializeApp(config, settings); profileCheckpoint('after_initialize_app'); if (config.getExperimentalZedIntegration()) { + const { runAcpAgent } = await import('./acp-integration/acpAgent.js'); await runAcpAgent(config, settings, argv); // Clean up child processes and force exit, matching other non-interactive modes await runExitCleanup(); @@ -1038,6 +842,7 @@ export async function main() { // startInteractiveUI) and so the first paint uses the refined theme // when the probe finishes in time. await themeAutoDetectionComplete; + const { startInteractiveUI } = await import('./ui/startInteractiveUI.js'); await startInteractiveUI( config, settings, @@ -1152,6 +957,9 @@ export async function main() { } } + const { validateNonInteractiveAuth } = await import( + './validateNonInterActiveAuth.js' + ); const nonInteractiveConfig = await validateNonInteractiveAuth( settings.merged.security?.auth?.useExternal, config, @@ -1162,6 +970,9 @@ export async function main() { if (inputFormat === InputFormat.STREAM_JSON) { const trimmedInput = (input ?? '').trim(); + const { runNonInteractiveStreamJson } = await import( + './nonInteractive/session.js' + ); await runNonInteractiveStreamJson( nonInteractiveConfig, @@ -1199,6 +1010,7 @@ export async function main() { debugLogger.debug(`Session ID: ${config.getSessionId()}`); + const { runNonInteractive } = await import('./nonInteractiveCli.js'); const exitCode = await runNonInteractive( nonInteractiveConfig, settings, @@ -1218,23 +1030,3 @@ export async function main() { export function createNonInteractivePromptId(sessionId: string): string { return `${sessionId}########0`; } - -function setWindowTitle(settings: LoadedSettings, folderName?: string) { - if ( - settings.merged.ui?.hideWindowTitle || - settings.merged.ui?.showStatusInTitle === false - ) { - return; - } - const windowTitle = computeWindowTitle(folderName); - writeTerminalTitle((value) => process.stdout.write(value), windowTitle); - - process.on('exit', () => { - try { - writeTerminalTitle((value) => process.stdout.write(value), ''); - } catch { - // Best-effort: clearing the title during exit must not produce - // a visible error (e.g. EPIPE if stdout is already closed). - } - }); -} diff --git a/packages/cli/src/serve/daemon-logger.ts b/packages/cli/src/serve/daemon-logger.ts index 274386e1e5f..c4a77b955e7 100644 --- a/packages/cli/src/serve/daemon-logger.ts +++ b/packages/cli/src/serve/daemon-logger.ts @@ -7,8 +7,11 @@ import * as nodeFs from 'node:fs'; import * as nodePath from 'node:path'; import * as crypto from 'node:crypto'; +import { + getGlobalQwenDirLite, + resolveConfigPathLite, +} from '../config/storage-paths-lite.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; -import { Storage, updateSymlink } from '@qwen-code/qwen-code-core'; export type DaemonLogLevel = 'INFO' | 'WARN' | 'ERROR'; @@ -109,6 +112,38 @@ const NOOP_LOGGER: DaemonLogger = { flush: () => Promise.resolve(), }; +function getRuntimeBaseDir(runtimeOutputDir?: string, cwd?: string): string { + const envDir = process.env['QWEN_RUNTIME_DIR']; + if (envDir) return resolveConfigPathLite(envDir); + if (runtimeOutputDir) return resolveConfigPathLite(runtimeOutputDir, cwd); + return getGlobalQwenDirLite(); +} + +export function resolveDaemonLogBaseDir( + runtimeOutputDir?: string, + cwd?: string, +): string { + return nodePath.join(getRuntimeBaseDir(runtimeOutputDir, cwd), 'debug'); +} + +async function updateSymlinkBestEffort( + linkPath: string, + targetPath: string, +): Promise { + const linkDir = nodePath.dirname(linkPath); + const relativeTarget = nodePath.relative(linkDir, targetPath); + try { + await nodeFs.promises.unlink(linkPath); + } catch { + // Missing or inaccessible alias is non-fatal. + } + try { + await nodeFs.promises.symlink(relativeTarget, linkPath); + } catch { + // Symlink creation is best-effort; no copy fallback for live log files. + } +} + function isOptedOut(): boolean { const raw = process.env['QWEN_DAEMON_LOG_FILE']; if (!raw) return false; @@ -130,7 +165,7 @@ export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { const pid = opts.pid ?? process.pid; const now = opts.now ?? (() => new Date()); const stderr = opts.stderr ?? writeStderrLine; - const baseDir = opts.baseDir ?? Storage.getGlobalDebugDir(); + const baseDir = opts.baseDir ?? resolveDaemonLogBaseDir(); const daemonId = computeDaemonId(pid, opts.boundWorkspace); const daemonDir = nodePath.join(baseDir, 'daemon'); @@ -155,11 +190,7 @@ export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { try { const aliasPath = nodePath.join(daemonDir, 'latest'); - void updateSymlink(aliasPath, logPath, { fallbackCopy: false }).catch( - () => { - // Best-effort. Symlink failure must not degrade primary writes. - }, - ); + void updateSymlinkBestEffort(aliasPath, logPath); } catch { // Defensive: any sync throw is ignored. } diff --git a/packages/cli/src/serve/daemon-status.test.ts b/packages/cli/src/serve/daemon-status.test.ts index a6595b5263d..cff8398c5f2 100644 --- a/packages/cli/src/serve/daemon-status.test.ts +++ b/packages/cli/src/serve/daemon-status.test.ts @@ -189,6 +189,40 @@ describe('buildDaemonStatusResponse', () => { }, }); }); + + it('includes additive daemon startup timing when provided', async () => { + const options = makeOptions() as BuildDaemonStatusOptions & { + startup: { + processStartedAt: string; + listenerReadyAt?: string; + processToListenMs?: number; + runQwenServeToListenMs?: number; + preheat: { status: string; durationMs?: number; error?: string }; + }; + }; + options.startup = { + processStartedAt: '2026-06-23T08:00:00.000Z', + listenerReadyAt: '2026-06-23T08:00:01.250Z', + processToListenMs: 1250, + runQwenServeToListenMs: 500, + preheat: { status: 'succeeded', durationMs: 300 }, + }; + + const response = await buildDaemonStatusResponse('summary', options); + + expect(response).toMatchObject({ + status: 'ok', + daemon: { + startup: { + processStartedAt: '2026-06-23T08:00:00.000Z', + listenerReadyAt: '2026-06-23T08:00:01.250Z', + processToListenMs: 1250, + runQwenServeToListenMs: 500, + preheat: { status: 'succeeded', durationMs: 300 }, + }, + }, + }); + }); }); interface MakeOptionsInput { diff --git a/packages/cli/src/serve/daemon-status.ts b/packages/cli/src/serve/daemon-status.ts index c241a4719c5..b74d319832c 100644 --- a/packages/cli/src/serve/daemon-status.ts +++ b/packages/cli/src/serve/daemon-status.ts @@ -25,12 +25,32 @@ const SECTION_TIMEOUT_MS = 1_000; const CAPACITY_WARNING_RATIO = 0.8; export type DaemonStatusDetail = 'summary' | 'full'; -type DaemonStatusLevel = 'ok' | 'warning' | 'error'; +export type DaemonStatusLevel = 'ok' | 'warning' | 'error'; type SectionStatus = DaemonStatusLevel | 'unavailable'; type IssueSeverity = 'warning' | 'error'; type SectionSummary = Record; type StatusRecord = Record; +export type DaemonStartupPreheatStatus = + | 'external_bridge' + | 'not_scheduled' + | 'scheduled' + | 'running' + | 'succeeded' + | 'failed'; + +export interface DaemonStartupSnapshot { + processStartedAt: string; + listenerReadyAt?: string; + processToListenMs?: number; + runQwenServeToListenMs?: number; + preheat: { + status: DaemonStartupPreheatStatus; + durationMs?: number; + error?: string; + }; +} + export interface DaemonStatusIssue { code: | 'session_capacity_high' @@ -41,7 +61,9 @@ export interface DaemonStatusIssue { | 'mcp_budget_warning' | 'mcp_budget_exhausted' | 'rate_limit_hits' - | 'workspace_status_unavailable'; + | 'workspace_status_unavailable' + | 'daemon_runtime_starting' + | 'daemon_runtime_failed'; severity: IssueSeverity; message: string; section?: string; @@ -67,6 +89,7 @@ export interface BuildDaemonStatusOptions { supportedDeviceFlowProviders: readonly string[]; deviceFlowRegistry: DeviceFlowRegistry; sessionShellCommandEnabled: boolean; + startup?: DaemonStartupSnapshot; } interface DaemonStatusSection { @@ -94,6 +117,77 @@ interface FullDaemonStatus { }; } +interface DaemonStatusSecurity { + tokenConfigured: boolean; + requireAuth: boolean; + loopbackBind: boolean; + allowOriginConfigured: boolean; + allowOriginMode: string; + sessionShellCommandEnabled: boolean; +} + +interface DaemonStatusLimits { + maxSessions: number | null; + maxPendingPromptsPerSession: number | null; + listenerMaxConnections: number | null; + eventRingSize: number; + promptDeadlineMs: number | null; + writerIdleTimeoutMs: number | null; + channelIdleTimeoutMs: number; + sessionIdleTimeoutMs: number; + acpConnectionCap: number | null; +} + +interface DaemonStatusRuntime { + loading?: boolean; + error?: string; + sessions: { active: number }; + permissions: { + pending: number; + policy: string; + }; + channel: { live: boolean }; + transport: { + restSseActive: number; + acp: { + enabled: boolean; + connections: number; + connectionStreams: number; + sessionStreams: number; + sseStreams: number; + wsStreams: number; + pendingClientRequests: number; + }; + }; + rateLimit: { + enabled: boolean; + rejectedSinceStart: Record; + }; + process: NodeJS.MemoryUsage; +} + +export interface DaemonStatusResponse { + v: 1; + detail: DaemonStatusDetail; + generatedAt: string; + status: DaemonStatusLevel; + issues: DaemonStatusIssue[]; + daemon: StatusRecord & { + pid: number; + uptimeMs: number; + mode: ServeOptions['mode']; + workspaceCwd: string; + }; + security: DaemonStatusSecurity; + limits: DaemonStatusLimits; + capabilities: { + protocolVersions: ServeProtocolVersions; + features: string[]; + }; + runtime: DaemonStatusRuntime; + full?: FullDaemonStatus; +} + class SectionTimeoutError extends Error { constructor( readonly section: string, @@ -117,7 +211,7 @@ export function parseDaemonStatusDetail( export async function buildDaemonStatusResponse( detail: DaemonStatusDetail, input: BuildDaemonStatusOptions, -): Promise> { +): Promise { const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot(); const acpSnapshot = input.acpHandle?.registry.getSnapshot(); const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits(); @@ -142,6 +236,7 @@ export async function buildDaemonStatusResponse( uptimeMs: Math.round(process.uptime() * 1000), mode: input.opts.mode, workspaceCwd: input.boundWorkspace, + ...(input.startup ? { startup: cloneStartup(input.startup) } : {}), ...(input.qwenCodeVersion ? { qwenCodeVersion: input.qwenCodeVersion } : {}), @@ -207,6 +302,28 @@ export async function buildDaemonStatusResponse( }; } +function cloneStartup(startup: DaemonStartupSnapshot): DaemonStartupSnapshot { + return { + processStartedAt: startup.processStartedAt, + ...(startup.listenerReadyAt + ? { listenerReadyAt: startup.listenerReadyAt } + : {}), + ...(startup.processToListenMs !== undefined + ? { processToListenMs: startup.processToListenMs } + : {}), + ...(startup.runQwenServeToListenMs !== undefined + ? { runQwenServeToListenMs: startup.runQwenServeToListenMs } + : {}), + preheat: { + status: startup.preheat.status, + ...(startup.preheat.durationMs !== undefined + ? { durationMs: startup.preheat.durationMs } + : {}), + ...(startup.preheat.error ? { error: startup.preheat.error } : {}), + }, + }; +} + async function buildFullStatus( input: BuildDaemonStatusOptions, bridgeSnapshot: BridgeDaemonStatusSnapshot, @@ -545,20 +662,22 @@ function rollupStatus(issues: readonly DaemonStatusIssue[]): DaemonStatusLevel { return 'ok'; } -function allowOriginMode( +export function allowOriginMode( allowOrigins: readonly string[] | undefined, ): 'none' | 'specific' | 'any' { if (!allowOrigins || allowOrigins.length === 0) return 'none'; return allowOrigins.includes('*') ? 'any' : 'specific'; } -function listenerMaxConnections(value: number | undefined): number | null { +export function listenerMaxConnections( + value: number | undefined, +): number | null { if (value === undefined) return DEFAULT_LISTENER_MAX_CONNECTIONS; if (value === 0 || value === Infinity) return null; return Number.isFinite(value) && value > 0 ? value : null; } -function positiveFiniteOrNull(value: number | undefined): number | null { +export function positiveFiniteOrNull(value: number | undefined): number | null { return value !== undefined && Number.isFinite(value) && value > 0 ? value : null; diff --git a/packages/cli/src/serve/env-snapshot.ts b/packages/cli/src/serve/env-snapshot.ts index 0f60a34c920..59c6c64ba24 100644 --- a/packages/cli/src/serve/env-snapshot.ts +++ b/packages/cli/src/serve/env-snapshot.ts @@ -14,7 +14,17 @@ import { type ServeEnvCell, type ServeWorkspaceEnvStatus, } from './status.js'; -import { formatMemoryUsage } from '../ui/utils/formatters.js'; + +function formatMemoryUsage(bytes: number): string { + const gb = bytes / (1024 * 1024 * 1024); + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${gb.toFixed(2)} GB`; +} /** * Whitelisted environment variables whose **presence** the daemon will diff --git a/packages/cli/src/serve/fast-path-argv.ts b/packages/cli/src/serve/fast-path-argv.ts new file mode 100644 index 00000000000..1ea6c83f4a9 --- /dev/null +++ b/packages/cli/src/serve/fast-path-argv.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function normalizeServeFastPathArgv( + rawArgv: readonly string[], +): string[] { + const argv = [...rawArgv]; + const firstArg = argv[0]?.replace(/\\/g, '/'); + if ( + firstArg !== undefined && + (firstArg.endsWith('/dist/qwen-cli/cli.js') || + firstArg.endsWith('/dist/cli.js') || + firstArg.endsWith('/dist/cli/cli.js')) + ) { + return argv.slice(1); + } + return argv; +} + +export function isServeFastPathArgv(rawArgv: readonly string[]): boolean { + return normalizeServeFastPathArgv(rawArgv)[0] === 'serve'; +} diff --git a/packages/cli/src/serve/fast-path-settings.ts b/packages/cli/src/serve/fast-path-settings.ts new file mode 100644 index 00000000000..8dcf8ae854e --- /dev/null +++ b/packages/cli/src/serve/fast-path-settings.ts @@ -0,0 +1,745 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as dotenv from 'dotenv'; +import stripJsonComments from 'strip-json-comments'; +import { V1_INDICATOR_KEYS } from '../config/migration/versions/v1-to-v2-shared.js'; +import { + DEFAULT_EXCLUDED_ENV_VARS, + HOME_ENV_BOOTSTRAP_KEYS, + PROJECT_ENV_HARDCODED_EXCLUSIONS, +} from '../config/shared-env-keys.js'; +import { + getGlobalQwenDirLite, + getSystemDefaultsPath, + getSystemSettingsPath, + SETTINGS_DIRECTORY_NAME, +} from '../config/storage-paths-lite.js'; +import { + getPathComparisonVariants, + isWithinRoot, +} from '../config/path-comparison.js'; +import type { Settings } from '../config/settingsSchema.js'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; + +type ServeFastPathPolicy = Pick< + NonNullable, + 'consensusQuorum' | 'permissionStrategy' +>; +type ServeFastPathPolicyInput = { + [Key in keyof ServeFastPathPolicy]?: unknown; +}; +export type ServeFastPathSettings = Pick< + Settings, + 'advanced' | 'context' | 'env' | 'security' | 'tools' +> & { + policy?: ServeFastPathPolicyInput; +}; +const V2_SETTINGS_VERSION = 2; +const TRUST_FOLDER = 'TRUST_FOLDER'; +const TRUST_PARENT = 'TRUST_PARENT'; +const DO_NOT_TRUST = 'DO_NOT_TRUST'; +type CachedTrustRule = { + level: 'trusted' | 'untrusted'; + variants: Set; +}; +let homeEnvBootstrapped = false; +let cachedTrustedFoldersPath: string | undefined; +let cachedTrustedFolderRules: CachedTrustRule[] | undefined; + +function getTrustedFoldersPathFastPath(): string { + return ( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] ?? + path.join(getGlobalQwenDirLite(), 'trustedFolders.json') + ); +} + +function getUserLevelEnvPathsFastPath(): Set { + const homeDir = os.homedir(); + const globalQwenDir = getGlobalQwenDirLite(); + return new Set([ + path.normalize(path.join(homeDir, '.env')), + path.normalize(path.join(globalQwenDir, '.env')), + path.normalize(path.join(homeDir, SETTINGS_DIRECTORY_NAME, '.env')), + ]); +} + +export function preResolveServeFastPathHomeEnvOverrides(): void { + if (homeEnvBootstrapped) return; + homeEnvBootstrapped = true; + + if (HOME_ENV_BOOTSTRAP_KEYS.every((key) => process.env[key])) { + return; + } + + const initialQwenHome = process.env['QWEN_HOME']; + const initialQwenDir = getGlobalQwenDirLite(); + readHomeEnvIntoFastPath(path.join(initialQwenDir, '.env')); + if (!initialQwenHome) { + readHomeEnvIntoFastPath(path.join(path.dirname(initialQwenDir), '.env')); + } + + const discoveredQwenHome = process.env['QWEN_HOME']; + if (discoveredQwenHome && discoveredQwenHome !== initialQwenHome) { + const discoveredDir = getGlobalQwenDirLite(); + if (discoveredDir !== initialQwenDir) { + readHomeEnvIntoFastPath(path.join(discoveredDir, '.env')); + } + } +} + +function readHomeEnvIntoFastPath(file: string): void { + if (!fs.existsSync(file)) return; + try { + const parsed = dotenv.parse(fs.readFileSync(file, 'utf8')); + for (const key of PROJECT_ENV_HARDCODED_EXCLUSIONS) { + if (parsed[key] && !Object.hasOwn(process.env, key)) { + process.env[key] = parsed[key]; + } + } + } catch { + // Match dotenv quiet-mode behavior used by the full environment loader. + } +} + +/** Test-only: reset the home-env bootstrap latch. */ +export function resetServeFastPathHomeEnvBootstrapForTesting(): void { + homeEnvBootstrapped = false; + cachedTrustedFoldersPath = undefined; + cachedTrustedFolderRules = undefined; +} + +function getHomeEnvFallbackVarsFastPath(): Record { + const globalQwenDir = getGlobalQwenDirLite(); + const candidates = [path.join(globalQwenDir, '.env')]; + if (!process.env['QWEN_HOME']) { + candidates.push(path.join(path.dirname(globalQwenDir), '.env')); + } + + const result: Record = {}; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + try { + const parsed = dotenv.parse(fs.readFileSync(candidate, 'utf8')); + for (const key in parsed) { + if (Object.hasOwn(parsed, key) && !Object.hasOwn(process.env, key)) { + result[key] ??= parsed[key]!; + } + } + } catch { + // Ignore home .env read failures on the fast path; full loader reports. + } + } + return result; +} + +function findEnvFilesFastPath( + settings: ServeFastPathSettings, + startDir: string, + userLevelPaths: Set = getUserLevelEnvPathsFastPath(), +): string[] { + const homeDir = os.homedir(); + let realStartDir = path.resolve(startDir); + try { + realStartDir = fs.realpathSync(realStartDir); + } catch { + // Match loadSettings(): use the resolved path when realpath is unavailable. + } + const isTrusted = isWorkspaceTrustedFastPath(settings, realStartDir); + + const globalQwenDir = getGlobalQwenDirLite(); + const legacyQwenDir = path.normalize( + path.join(homeDir, SETTINGS_DIRECTORY_NAME), + ); + const hasCustomConfigDir = path.normalize(globalQwenDir) !== legacyQwenDir; + const found: string[] = []; + const seen = new Set(); + + const canUseEnvFile = (filePath: string): boolean => + isTrusted !== false || userLevelPaths.has(path.normalize(filePath)); + + const pushCandidate = (filePath: string): boolean => { + const normalized = path.normalize(filePath); + if ( + !seen.has(normalized) && + fs.existsSync(filePath) && + canUseEnvFile(filePath) + ) { + seen.add(normalized); + found.push(filePath); + return true; + } + return false; + }; + + const pushHomeCandidates = (): void => { + const candidates = [path.join(globalQwenDir, '.env')]; + if (hasCustomConfigDir) { + candidates.push(path.join(legacyQwenDir, '.env')); + } + candidates.push(path.join(homeDir, '.env')); + for (const candidate of candidates) { + pushCandidate(candidate); + } + }; + + let currentDir = realStartDir; + let visitedHomeDir = false; + while (true) { + if (currentDir === homeDir) { + visitedHomeDir = true; + pushHomeCandidates(); + return found; + } else { + const qwenEnvPath = path.join( + currentDir, + SETTINGS_DIRECTORY_NAME, + '.env', + ); + if (pushCandidate(qwenEnvPath)) { + pushHomeCandidates(); + return found; + } + const envPath = path.join(currentDir, '.env'); + if (pushCandidate(envPath)) { + pushHomeCandidates(); + return found; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + if (!visitedHomeDir) { + pushHomeCandidates(); + } + return found; + } + currentDir = parentDir; + } +} + +function setUpCloudShellEnvironmentFromFilesFastPath( + envFilePaths: readonly string[], +): void { + for (const envFilePath of envFilePaths) { + if (!fs.existsSync(envFilePath)) continue; + const parsedEnv = dotenv.parse(fs.readFileSync(envFilePath)); + if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { + process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; + return; + } + } + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; +} + +export function loadServeFastPathEnvironment( + settings: ServeFastPathSettings, + startDir: string = process.cwd(), +): void { + const userLevelPaths = getUserLevelEnvPathsFastPath(); + const envFilePaths = findEnvFilesFastPath(settings, startDir, userLevelPaths); + + if (process.env['CLOUD_SHELL'] === 'true') { + setUpCloudShellEnvironmentFromFilesFastPath(envFilePaths); + } + + for (const envFilePath of envFilePaths) { + try { + const parsedEnv = dotenv.parse(fs.readFileSync(envFilePath, 'utf8')); + const excludedVars = + settings.advanced?.excludedEnvVars ?? DEFAULT_EXCLUDED_ENV_VARS; + const normalizedEnvFilePath = path.normalize(envFilePath); + const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); + const isQwenScopedEnvFile = + isHomeScopedEnvFile || + path.basename(path.dirname(normalizedEnvFilePath)) === + SETTINGS_DIRECTORY_NAME; + + for (const key in parsedEnv) { + if (!Object.hasOwn(parsedEnv, key)) continue; + if ( + !isHomeScopedEnvFile && + PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) + ) { + continue; + } + if (!isQwenScopedEnvFile && excludedVars.includes(key)) { + continue; + } + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + } + } + } catch { + // Errors are ignored to match dotenv quiet-mode behavior. + } + } + + if (settings.env) { + for (const [key, value] of Object.entries(settings.env)) { + if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) continue; + if (!Object.hasOwn(process.env, key) && typeof value === 'string') { + process.env[key] = value; + } + } + } +} + +function readTrustedFolderRulesFastPath(): readonly CachedTrustRule[] { + const trustedFoldersPath = getTrustedFoldersPathFastPath(); + if ( + cachedTrustedFolderRules && + cachedTrustedFoldersPath === trustedFoldersPath + ) { + return cachedTrustedFolderRules; + } + if (!fs.existsSync(trustedFoldersPath)) { + cachedTrustedFoldersPath = trustedFoldersPath; + cachedTrustedFolderRules = []; + return cachedTrustedFolderRules; + } + + let parsed: unknown; + try { + parsed = JSON.parse( + stripJsonComments(fs.readFileSync(trustedFoldersPath, 'utf8')), + ); + } catch (err) { + throw new Error( + `Failed to read serve fast path trusted folders from ${trustedFoldersPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (!isPlainObject(parsed)) { + throw new Error( + `Serve fast path trusted folders file ${trustedFoldersPath} must be a JSON object.`, + ); + } + const out: Record = {}; + for (const [rulePath, trustLevel] of Object.entries(parsed)) { + if (typeof trustLevel === 'string') { + out[rulePath] = trustLevel; + } + } + cachedTrustedFoldersPath = trustedFoldersPath; + cachedTrustedFolderRules = buildTrustedFolderRules(out); + return cachedTrustedFolderRules; +} + +function buildTrustedFolderRules( + trustedFolders: Record, +): CachedTrustRule[] { + const rules: CachedTrustRule[] = []; + for (const [rulePath, trustLevel] of Object.entries( + trustedFolders, + )) { + if (trustLevel === TRUST_FOLDER) { + rules.push({ + level: 'trusted', + variants: getPathComparisonVariants(rulePath), + }); + } else if (trustLevel === TRUST_PARENT) { + rules.push({ + level: 'trusted', + variants: getPathComparisonVariants(path.dirname(rulePath)), + }); + } else if (trustLevel === DO_NOT_TRUST) { + rules.push({ + level: 'untrusted', + variants: getPathComparisonVariants(rulePath), + }); + } + } + return rules; +} + +function isPathTrustedFastPath(location: string): boolean | undefined { + const rules = readTrustedFolderRulesFastPath(); + const locationVariants = getPathComparisonVariants(location); + for (const rule of rules) { + if (rule.level !== 'trusted') continue; + for (const locationVariant of locationVariants) { + for (const trustedVariant of rule.variants) { + if (isWithinRoot(locationVariant, trustedVariant)) { + return true; + } + } + } + } + + for (const rule of rules) { + if (rule.level !== 'untrusted') continue; + for (const locationVariant of locationVariants) { + for (const untrustedVariant of rule.variants) { + if (locationVariant === untrustedVariant) { + return false; + } + } + } + } + + return undefined; +} + +function isWorkspaceTrustedFastPath( + settings: ServeFastPathSettings, + realWorkspaceDir: string, +): boolean | undefined { + if (settings.security?.folderTrust?.enabled !== true) { + return true; + } + return isPathTrustedFastPath(realWorkspaceDir); +} + +function readSettingsSummary(filePath: string): ServeFastPathSettings { + if (!fs.existsSync(filePath)) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(stripJsonComments(fs.readFileSync(filePath, 'utf8'))); + } catch (err) { + throw new Error( + `Failed to read serve fast path settings from ${filePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (!isPlainObject(parsed)) { + throw new Error( + `Serve fast path settings file ${filePath} must be a JSON object.`, + ); + } + return pickFastPathSettings(parsed); +} + +function shouldUseLegacyFastPathKeys(value: Record): boolean { + const version = value['$version']; + if (typeof version === 'number' && version >= V2_SETTINGS_VERSION) { + return false; + } + return V1_INDICATOR_KEYS.some((key) => { + if (!(key in value)) return false; + const item = value[key]; + return !(typeof item === 'object' && item !== null && !Array.isArray(item)); + }); +} + +function pickFastPathSettings( + value: Record, +): ServeFastPathSettings { + const out: ServeFastPathSettings = {}; + const useLegacyKeys = shouldUseLegacyFastPathKeys(value); + const env = value['env']; + if (isPlainObject(env)) { + out.env = pickStringRecord(env); + } + + const advanced = value['advanced']; + if (isPlainObject(advanced)) { + const pickedAdvanced: NonNullable = {}; + const excludedEnvVars = advanced['excludedEnvVars']; + if (excludedEnvVars !== undefined && !isStringArray(excludedEnvVars)) { + throw new Error( + 'Serve fast path settings advanced.excludedEnvVars must be a string array.', + ); + } + if (excludedEnvVars !== undefined) { + pickedAdvanced.excludedEnvVars = excludedEnvVars; + } + const runtimeOutputDir = advanced['runtimeOutputDir']; + if ( + runtimeOutputDir !== undefined && + typeof runtimeOutputDir !== 'string' + ) { + throw new Error( + 'Serve fast path settings advanced.runtimeOutputDir must be a string.', + ); + } + if (runtimeOutputDir !== undefined) { + pickedAdvanced.runtimeOutputDir = runtimeOutputDir; + } + if (Object.keys(pickedAdvanced).length > 0) { + out.advanced = pickedAdvanced; + } + } + if (useLegacyKeys && out.advanced?.excludedEnvVars === undefined) { + const legacyExcludedEnvVars = value['excludedProjectEnvVars']; + if (isStringArray(legacyExcludedEnvVars)) { + out.advanced = { + ...(out.advanced ?? {}), + excludedEnvVars: legacyExcludedEnvVars, + }; + } + } + + const security = value['security']; + if (isPlainObject(security)) { + const folderTrust = security['folderTrust']; + if (isPlainObject(folderTrust)) { + const enabled = folderTrust['enabled']; + if (enabled !== undefined && typeof enabled !== 'boolean') { + throw new Error( + 'Serve fast path settings security.folderTrust.enabled must be a boolean.', + ); + } + if (enabled !== undefined) { + out.security = { folderTrust: { enabled } }; + } + } + } + if ( + useLegacyKeys && + out.security === undefined && + Object.hasOwn(value, 'folderTrust') + ) { + const legacyFolderTrust = value['folderTrust']; + if ( + legacyFolderTrust !== undefined && + typeof legacyFolderTrust !== 'boolean' + ) { + throw new Error( + 'Serve fast path settings folderTrust must be a boolean.', + ); + } + if (legacyFolderTrust !== undefined) { + out.security = { folderTrust: { enabled: legacyFolderTrust } }; + } + } + + const tools = value['tools']; + if (isPlainObject(tools)) { + const pickedTools: NonNullable = {}; + const approvalMode = tools['approvalMode']; + if (typeof approvalMode === 'string') { + pickedTools.approvalMode = approvalMode as NonNullable< + ServeFastPathSettings['tools'] + >['approvalMode']; + } + const sandbox = tools['sandbox']; + if (typeof sandbox === 'boolean' || typeof sandbox === 'string') { + pickedTools.sandbox = sandbox; + } + if (Object.keys(pickedTools).length > 0) { + out.tools = pickedTools; + } + } + const legacyApprovalMode = value['approvalMode']; + if ( + useLegacyKeys && + out.tools?.approvalMode === undefined && + typeof legacyApprovalMode === 'string' + ) { + out.tools = { + ...(out.tools ?? {}), + approvalMode: legacyApprovalMode as NonNullable< + ServeFastPathSettings['tools'] + >['approvalMode'], + }; + } + const legacySandbox = value['sandbox']; + if ( + useLegacyKeys && + out.tools?.sandbox === undefined && + (typeof legacySandbox === 'boolean' || typeof legacySandbox === 'string') + ) { + out.tools = { + ...(out.tools ?? {}), + sandbox: legacySandbox, + }; + } + + const context = value['context']; + if (isPlainObject(context)) { + const pickedContext: NonNullable = {}; + const fileName = context['fileName']; + if (typeof fileName === 'string' || isStringArray(fileName)) { + pickedContext.fileName = fileName; + } + const fileFiltering = context['fileFiltering']; + if (isPlainObject(fileFiltering)) { + const customIgnoreFiles = fileFiltering['customIgnoreFiles']; + if (isStringArray(customIgnoreFiles)) { + pickedContext.fileFiltering = { customIgnoreFiles }; + } + } + if (Object.keys(pickedContext).length > 0) { + out.context = pickedContext; + } + } + const legacyContextFileName = value['contextFileName']; + if ( + useLegacyKeys && + out.context?.fileName === undefined && + (typeof legacyContextFileName === 'string' || + isStringArray(legacyContextFileName)) + ) { + out.context = { + ...(out.context ?? {}), + fileName: legacyContextFileName, + }; + } + const legacyFileFiltering = value['fileFiltering']; + if ( + useLegacyKeys && + out.context?.fileFiltering === undefined && + isPlainObject(legacyFileFiltering) + ) { + const customIgnoreFiles = legacyFileFiltering['customIgnoreFiles']; + if (isStringArray(customIgnoreFiles)) { + out.context = { + ...(out.context ?? {}), + fileFiltering: { customIgnoreFiles }, + }; + } + } + + const policy = value['policy']; + if (isPlainObject(policy)) { + const pickedPolicy: NonNullable = {}; + if (Object.hasOwn(policy, 'permissionStrategy')) { + pickedPolicy.permissionStrategy = policy['permissionStrategy']; + } + if (Object.hasOwn(policy, 'consensusQuorum')) { + pickedPolicy.consensusQuorum = policy['consensusQuorum']; + } + out.policy = pickedPolicy; + } + + return out; +} + +function mergeFastPathSettings( + ...sources: readonly ServeFastPathSettings[] +): ServeFastPathSettings { + const merged: ServeFastPathSettings = {}; + for (const source of sources) { + if (source.env) { + merged.env = { ...(merged.env ?? {}), ...source.env }; + } + if (source.advanced?.excludedEnvVars) { + merged.advanced = { + ...(merged.advanced ?? {}), + excludedEnvVars: unique([ + ...(merged.advanced?.excludedEnvVars ?? []), + ...source.advanced.excludedEnvVars, + ]), + }; + } + if (source.advanced?.runtimeOutputDir !== undefined) { + merged.advanced = { + ...(merged.advanced ?? {}), + runtimeOutputDir: source.advanced.runtimeOutputDir, + }; + } + if (source.security?.folderTrust) { + merged.security = { + ...(merged.security ?? {}), + folderTrust: { + ...(merged.security?.folderTrust ?? {}), + ...source.security.folderTrust, + }, + }; + } + if (source.tools) { + merged.tools = { ...(merged.tools ?? {}), ...source.tools }; + } + if (source.context) { + merged.context = { + ...(merged.context ?? {}), + ...source.context, + ...(source.context.fileFiltering + ? { + fileFiltering: { + ...(merged.context?.fileFiltering ?? {}), + ...source.context.fileFiltering, + }, + } + : {}), + }; + } + if (source.policy) { + merged.policy = { ...(merged.policy ?? {}), ...source.policy }; + } + } + return merged; +} + +export function loadServeFastPathSettings( + workspaceDir: string, +): ServeFastPathSettings { + preResolveServeFastPathHomeEnvOverrides(); + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const resolvedHomeDir = path.resolve(os.homedir()); + let realWorkspaceDir = resolvedWorkspaceDir; + try { + realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); + } catch { + // Match loadSettings(): use the resolved path when realpath is unavailable. + } + + const system = readSettingsSummary(getSystemSettingsPath()); + const systemDefaults = readSettingsSummary(getSystemDefaultsPath()); + const user = readSettingsSummary( + path.join(getGlobalQwenDirLite(), 'settings.json'), + ); + const initialTrustCheckSettings = mergeFastPathSettings(system, user); + const isTrusted = + isWorkspaceTrustedFastPath(initialTrustCheckSettings, realWorkspaceDir) ?? + true; + let realHomeDir = resolvedHomeDir; + try { + realHomeDir = fs.realpathSync(resolvedHomeDir); + } catch { + // Match loadSettings(): fall back to the resolved path if unavailable. + } + + const workspaceSettingsPath = path.join( + realWorkspaceDir, + SETTINGS_DIRECTORY_NAME, + 'settings.json', + ); + const workspaceSettingsActive = realWorkspaceDir !== realHomeDir; + const workspaceFromDisk = workspaceSettingsActive + ? readSettingsSummary(workspaceSettingsPath) + : {}; + const workspace = isTrusted ? workspaceFromDisk : {}; + + const merged = mergeFastPathSettings(systemDefaults, user, workspace, system); + return resolveEnvVarsInObject( + merged as Settings, + getHomeEnvFallbackVarsFastPath(), + ) as ServeFastPathSettings; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === 'string') + ); +} + +function pickStringRecord( + value: Record, +): Record { + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'string') { + out[key] = item; + } + } + return out; +} + +function unique(values: readonly string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts new file mode 100644 index 00000000000..32c9edc3ac2 --- /dev/null +++ b/packages/cli/src/serve/fast-path.test.ts @@ -0,0 +1,1427 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import yargs, { type Argv } from 'yargs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import * as os from 'node:os'; +import { join } from 'node:path'; +import { QWEN_DIR, Storage } from '@qwen-code/qwen-code-core'; + +import { + bootstrapServeFastPathEnvironment, + parseServeFastPathArgs, + tryRunServeFastPath, + waitForServeRuntimeOrExit, +} from './fast-path.js'; +import { + loadServeFastPathEnvironment, + loadServeFastPathSettings, + preResolveServeFastPathHomeEnvOverrides, + resetServeFastPathHomeEnvBootstrapForTesting, +} from './fast-path-settings.js'; +import { + getGlobalQwenDirLite, + SETTINGS_DIRECTORY_NAME, +} from '../config/storage-paths-lite.js'; +import { + resetTrustedFoldersForTesting, + TrustLevel, +} from '../config/trustedFolders.js'; +import * as runQwenServeModule from './run-qwen-serve.js'; +import type { ServeFastPathSettings } from './fast-path-settings.js'; +import type { Settings } from '../config/settingsSchema.js'; +import { serveCommand } from '../commands/serve.js'; + +let tempWorkspace: string | undefined; +let tempLaunchCwd: string | undefined; +let tempQwenHome: string | undefined; +let tempSymlink: string | undefined; +const originalToken = process.env['QWEN_SERVER_TOKEN']; +const originalQwenHome = process.env['QWEN_HOME']; +const originalHome = process.env['HOME']; +const originalUserProfile = process.env['USERPROFILE']; +const originalQwenRuntimeDir = process.env['QWEN_RUNTIME_DIR']; +const originalMcpApprovalsPath = process.env['QWEN_CODE_MCP_APPROVALS_PATH']; +const originalSystemSettingsPath = + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; +const originalSystemDefaultsPath = + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; +const originalTrustedFoldersPath = + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; +const originalReferencedToken = process.env['FAST_PATH_REFERENCED_TOKEN']; +const originalRateLimit = process.env['QWEN_SERVE_RATE_LIMIT']; +const originalRateLimitPrompt = process.env['QWEN_SERVE_RATE_LIMIT_PROMPT']; +const originalCloudShell = process.env['CLOUD_SHELL']; +const originalGoogleCloudProject = process.env['GOOGLE_CLOUD_PROJECT']; +const originalCwd = process.cwd(); + +function useTempQwenHome(): string { + tempQwenHome = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-home-')), + ); + process.env['QWEN_HOME'] = tempQwenHome; + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = join( + tempQwenHome, + 'system-settings.json', + ); + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] = join( + tempQwenHome, + 'system-defaults.json', + ); + return tempQwenHome; +} + +function buildServeCommandParser(): Argv { + return (serveCommand.builder as (argv: Argv) => Argv)( + yargs([]).exitProcess(false).fail(false).locale('en'), + ); +} + +function pickServeFastPathComparable( + settings: Settings, +): ServeFastPathSettings { + const out: ServeFastPathSettings = {}; + if (settings.env) { + out.env = settings.env; + } + if (settings.advanced?.excludedEnvVars !== undefined) { + out.advanced = { + ...(out.advanced ?? {}), + excludedEnvVars: settings.advanced.excludedEnvVars, + }; + } + if (settings.advanced?.runtimeOutputDir !== undefined) { + out.advanced = { + ...(out.advanced ?? {}), + runtimeOutputDir: settings.advanced.runtimeOutputDir, + }; + } + if (settings.security?.folderTrust?.enabled !== undefined) { + out.security = { + folderTrust: { enabled: settings.security.folderTrust.enabled }, + }; + } + if (settings.tools?.approvalMode !== undefined) { + out.tools = { + ...(out.tools ?? {}), + approvalMode: settings.tools.approvalMode, + }; + } + if (settings.tools?.sandbox !== undefined) { + out.tools = { ...(out.tools ?? {}), sandbox: settings.tools.sandbox }; + } + if (settings.context?.fileName !== undefined) { + out.context = { + ...(out.context ?? {}), + fileName: settings.context.fileName, + }; + } + if (settings.context?.fileFiltering?.customIgnoreFiles !== undefined) { + out.context = { + ...(out.context ?? {}), + fileFiltering: { + customIgnoreFiles: settings.context.fileFiltering.customIgnoreFiles, + }, + }; + } + if (settings.policy?.permissionStrategy !== undefined) { + out.policy = { + ...(out.policy ?? {}), + permissionStrategy: settings.policy.permissionStrategy, + }; + } + if (settings.policy?.consensusQuorum !== undefined) { + out.policy = { + ...(out.policy ?? {}), + consensusQuorum: settings.policy.consensusQuorum, + }; + } + return out; +} + +afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + if (originalToken === undefined) { + delete process.env['QWEN_SERVER_TOKEN']; + } else { + process.env['QWEN_SERVER_TOKEN'] = originalToken; + } + if (originalQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = originalQwenHome; + } + if (originalHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env['USERPROFILE']; + } else { + process.env['USERPROFILE'] = originalUserProfile; + } + if (originalSystemSettingsPath === undefined) { + delete process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; + } else { + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = originalSystemSettingsPath; + } + if (originalSystemDefaultsPath === undefined) { + delete process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; + } else { + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] = originalSystemDefaultsPath; + } + if (originalTrustedFoldersPath === undefined) { + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + } else { + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = originalTrustedFoldersPath; + } + if (originalReferencedToken === undefined) { + delete process.env['FAST_PATH_REFERENCED_TOKEN']; + } else { + process.env['FAST_PATH_REFERENCED_TOKEN'] = originalReferencedToken; + } + if (originalRateLimit === undefined) { + delete process.env['QWEN_SERVE_RATE_LIMIT']; + } else { + process.env['QWEN_SERVE_RATE_LIMIT'] = originalRateLimit; + } + if (originalRateLimitPrompt === undefined) { + delete process.env['QWEN_SERVE_RATE_LIMIT_PROMPT']; + } else { + process.env['QWEN_SERVE_RATE_LIMIT_PROMPT'] = originalRateLimitPrompt; + } + if (originalCloudShell === undefined) { + delete process.env['CLOUD_SHELL']; + } else { + process.env['CLOUD_SHELL'] = originalCloudShell; + } + if (originalGoogleCloudProject === undefined) { + delete process.env['GOOGLE_CLOUD_PROJECT']; + } else { + process.env['GOOGLE_CLOUD_PROJECT'] = originalGoogleCloudProject; + } + if (originalQwenRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalQwenRuntimeDir; + } + if (originalMcpApprovalsPath === undefined) { + delete process.env['QWEN_CODE_MCP_APPROVALS_PATH']; + } else { + process.env['QWEN_CODE_MCP_APPROVALS_PATH'] = originalMcpApprovalsPath; + } + resetServeFastPathHomeEnvBootstrapForTesting(); + resetTrustedFoldersForTesting(); + if (tempWorkspace) { + rmSync(tempWorkspace, { recursive: true, force: true }); + tempWorkspace = undefined; + } + if (tempLaunchCwd) { + rmSync(tempLaunchCwd, { recursive: true, force: true }); + tempLaunchCwd = undefined; + } + if (tempQwenHome) { + rmSync(tempQwenHome, { recursive: true, force: true }); + tempQwenHome = undefined; + } + if (tempSymlink) { + rmSync(tempSymlink, { force: true }); + tempSymlink = undefined; + } +}); + +describe('CLI entry import boundary', () => { + it('does not statically import the full gemini entry before the serve fast path can run', () => { + const indexSource = readFileSync('index.ts', 'utf8'); + + expect(indexSource).not.toContain("import './src/gemini.js'"); + expect(indexSource).not.toContain("import { main } from './src/gemini.js'"); + expect(indexSource).not.toContain("process.argv[2] === 'serve'"); + expect(indexSource).toContain('import { isServeFastPathArgv }'); + expect(indexSource).toContain("await import('./src/serve/fast-path.js')"); + }); + + it('does not import the full settings loader on the serve fast path', () => { + const fastPathSource = readFileSync('src/serve/fast-path.ts', 'utf8'); + + expect(fastPathSource).not.toContain('../config/settings.js'); + expect(fastPathSource).not.toContain('../config/environment.js'); + expect(fastPathSource).not.toContain('@qwen-code/qwen-code-core'); + expect(fastPathSource).toContain('bootSettings: settings'); + expect(fastPathSource).toContain('resolveOnListen: true'); + }); + + it('keeps settings free of UI imports used before serve can listen', () => { + const settingsSource = readFileSync('src/config/settings.ts', 'utf8'); + + expect(settingsSource).not.toContain('../ui/'); + }); + + it('keeps extension command parsing free of UI state imports', () => { + const updateCommandSource = readFileSync( + 'src/commands/extensions/update.ts', + 'utf8', + ); + + expect(updateCommandSource).not.toContain('../../ui/'); + }); + + it('keeps runQwenServe from statically loading the full server and ACP runtime', () => { + const runServeSource = readFileSync('src/serve/run-qwen-serve.ts', 'utf8'); + + expect(runServeSource).not.toMatch(/from ['"]\.\/server\.js['"]/); + expect(runServeSource).not.toMatch(/from ['"]\.\/web-shell-static\.js['"]/); + expect(runServeSource).not.toMatch( + /from ['"]\.\/acp-session-bridge\.js['"]/, + ); + expect(runServeSource).not.toMatch( + /from ['"]@qwen-code\/acp-bridge\/bridge['"]/, + ); + expect(runServeSource).not.toMatch( + /from ['"]@qwen-code\/acp-bridge\/spawnChannel['"]/, + ); + expect(runServeSource).toContain("import('./server.js')"); + expect(runServeSource).toContain("import('@qwen-code/acp-bridge/bridge')"); + }); +}); + +describe('serve fast path argument parsing', () => { + it('parses the common daemon startup flags without loading the full CLI parser', () => { + const parsed = parseServeFastPathArgs([ + 'serve', + '--port', + '0', + '--hostname', + '127.0.0.1', + '--workspace', + '/tmp/workspace', + '--no-web', + '--no-open', + ]); + + expect(parsed).toEqual({ + kind: 'serve', + httpBridge: true, + open: false, + options: { + hostname: '127.0.0.1', + mcpBudgetMode: 'off', + mode: 'http-bridge', + port: 0, + serveWebShell: false, + workspace: '/tmp/workspace', + }, + }); + }); + + it('parses bundled entrypoint argv before serve', () => { + const parsed = parseServeFastPathArgs([ + '/repo/dist/cli.js', + 'serve', + '--port', + '0', + ]); + + expect(parsed).toMatchObject({ + kind: 'serve', + options: { port: 0 }, + }); + }); + + it('parses Windows bundled entrypoint argv before serve', () => { + const parsed = parseServeFastPathArgs([ + 'C:\\repo\\dist\\cli.js', + 'serve', + '--port', + '0', + ]); + + expect(parsed).toMatchObject({ + kind: 'serve', + options: { port: 0 }, + }); + }); + + it('falls back to the full parser for help and unknown options', () => { + expect(parseServeFastPathArgs(['serve', '--help'])).toEqual({ + kind: 'fallback', + }); + expect(parseServeFastPathArgs(['serve', '--unknown-option'])).toEqual({ + kind: 'fallback', + }); + }); + + it('handles every yargs serve long option or explicitly falls back', () => { + const options = ( + buildServeCommandParser() as unknown as { + getOptions(): { + key: Record; + alias: Record; + }; + } + ).getOptions(); + const longOptionNames = Object.keys(options.key).filter( + (name) => name.length > 1 && !options.alias[name]?.length, + ); + const sampleArgvByOption = new Map([ + ['port', ['--port', '0']], + ['hostname', ['--hostname', '127.0.0.1']], + ['token', ['--token', 'token']], + ['max-sessions', ['--max-sessions', '10']], + [ + 'max-pending-prompts-per-session', + ['--max-pending-prompts-per-session', '5'], + ], + ['max-connections', ['--max-connections', '256']], + ['event-ring-size', ['--event-ring-size', '8000']], + ['workspace', ['--workspace', process.cwd()]], + ['require-auth', ['--require-auth']], + ['enable-session-shell', ['--enable-session-shell']], + ['web', ['--no-web']], + ['open', ['--open']], + ['http-bridge', ['--no-http-bridge']], + ['mcp-client-budget', ['--mcp-client-budget', '10']], + ['mcp-budget-mode', ['--mcp-budget-mode', 'warn']], + ['allow-origin', ['--allow-origin', 'http://localhost:3000']], + ['allow-private-auth-base-url', ['--allow-private-auth-base-url']], + ['prompt-deadline-ms', ['--prompt-deadline-ms', '1000']], + ['writer-idle-timeout-ms', ['--writer-idle-timeout-ms', '1000']], + ['channel-idle-timeout-ms', ['--channel-idle-timeout-ms', '1000']], + ['session-reap-interval-ms', ['--session-reap-interval-ms', '1000']], + ['session-idle-timeout-ms', ['--session-idle-timeout-ms', '1000']], + [ + 'permission-response-timeout-ms', + ['--permission-response-timeout-ms', '1000'], + ], + ['rate-limit', ['--rate-limit']], + ['rate-limit-prompt', ['--rate-limit-prompt', '10']], + ['rate-limit-mutation', ['--rate-limit-mutation', '30']], + ['rate-limit-read', ['--rate-limit-read', '120']], + ['rate-limit-window-ms', ['--rate-limit-window-ms', '60000']], + ['experimental-lsp', ['--experimental-lsp']], + ['help', ['--help']], + ['version', ['--version']], + ]); + const expectedFallbackOptions = new Set(['help', 'version']); + + expect(longOptionNames.sort()).toEqual( + [...sampleArgvByOption.keys()].sort(), + ); + for (const [optionName, sampleArgv] of sampleArgvByOption) { + const parsed = parseServeFastPathArgs(['serve', ...sampleArgv]); + if (parsed.kind === 'fallback') { + expect(expectedFallbackOptions.has(optionName)).toBe(true); + } else { + expect(expectedFallbackOptions.has(optionName)).toBe(false); + } + } + }); + + it('matches yargs defaults for options materialized before runQwenServe', () => { + const yargsParsed = buildServeCommandParser().parseSync(''); + const fastPathParsed = parseServeFastPathArgs(['serve']); + + expect(fastPathParsed).toMatchObject({ + kind: 'serve', + options: { + hostname: yargsParsed['hostname'], + mode: 'http-bridge', + port: yargsParsed['port'], + }, + }); + expect(fastPathParsed).not.toHaveProperty('options.maxSessions'); + expect(fastPathParsed).not.toHaveProperty('options.maxConnections'); + expect(fastPathParsed).not.toHaveProperty('options.eventRingSize'); + expect(fastPathParsed).not.toHaveProperty( + 'options.maxPendingPromptsPerSession', + ); + }); + + it('keeps --experimental-lsp on the fast path', () => { + const parsed = parseServeFastPathArgs(['serve', '--experimental-lsp']); + + expect(parsed).toMatchObject({ + kind: 'serve', + options: { experimentalLsp: true }, + }); + }); + + it('returns false to let the full CLI handle fallback cases', async () => { + await expect(tryRunServeFastPath(['serve', '--help'])).resolves.toBe(false); + }); + + it('prints a breadcrumb when settings bootstrap falls back to the full CLI', async () => { + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-fallback-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync(join(tempWorkspace, '.qwen', 'settings.json'), '{'); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + + await expect( + tryRunServeFastPath(['serve', '--workspace', tempWorkspace]), + ).resolves.toBe(false); + + expect(stderrWrites.join('')).toContain( + 'qwen serve: fast-path bootstrap failed, falling back to full startup:', + ); + }); + + it.each([ + [ + ['serve', '--mcp-client-budget', '0'], + 'qwen serve: --mcp-client-budget must be a positive integer.', + ], + [ + ['serve', '--mcp-budget-mode', 'enforce'], + 'qwen serve: --mcp-budget-mode=enforce requires --mcp-client-budget=N.', + ], + [ + ['serve', '--max-pending-prompts-per-session=-1'], + 'qwen serve: --max-pending-prompts-per-session must be a non-negative integer (0 / Infinity = unlimited).', + ], + [ + ['serve', '--rate-limit', '--rate-limit-prompt=0'], + 'qwen serve: --rate-limit-prompt must be a positive integer.', + ], + ])( + 'validates %s before bootstrapping settings and environment', + async (argv, message) => { + const qwenHome = useTempQwenHome(); + writeFileSync(join(qwenHome, 'settings.json'), '{'); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + await expect(tryRunServeFastPath(argv)).rejects.toThrow( + 'process.exit(1)', + ); + expect(stderrWrites.join('')).toContain(message); + }, + ); + + it('does not enable rate limiting just because tuning flags are present', () => { + const parsed = parseServeFastPathArgs([ + 'serve', + '--rate-limit-prompt', + '0', + '--rate-limit-window-ms', + '1', + ]); + + expect(parsed.kind).toBe('serve'); + if (parsed.kind !== 'serve') return; + expect(parsed.options).not.toHaveProperty('rateLimit'); + expect(parsed.options.rateLimitPrompt).toBe(0); + expect(parsed.options.rateLimitWindowMs).toBe(1); + }); + + it('enables rate limiting from env and applies env tuning values', () => { + const parsed = parseServeFastPathArgs(['serve'], { + QWEN_SERVE_RATE_LIMIT: '1', + QWEN_SERVE_RATE_LIMIT_PROMPT: '10', + }); + + expect(parsed.kind).toBe('serve'); + if (parsed.kind !== 'serve') return; + expect(parsed.options.rateLimit).toBe(true); + expect(parsed.options.rateLimitPrompt).toBe(10); + }); + + it('discards rate limit env tuning when rate limiting is disabled', () => { + const parsed = parseServeFastPathArgs(['serve'], { + QWEN_SERVE_RATE_LIMIT_PROMPT: '10', + }); + + expect(parsed.kind).toBe('serve'); + if (parsed.kind !== 'serve') return; + expect(parsed.options).not.toHaveProperty('rateLimit'); + expect(parsed.options.rateLimitPrompt).toBeUndefined(); + }); + + it('rejects unsafe rate limit env integers instead of rounding them', () => { + const parsed = parseServeFastPathArgs(['serve', '--rate-limit'], { + QWEN_SERVE_RATE_LIMIT_PROMPT: String(Number.MAX_SAFE_INTEGER + 1), + }); + + expect(parsed.kind).toBe('serve'); + if (parsed.kind !== 'serve') return; + expect(parsed.options.rateLimitPrompt).toBeNaN(); + }); +}); + +describe('serve fast path environment bootstrap', () => { + it('keeps the lite settings directory name in sync with core QWEN_DIR', () => { + expect(SETTINGS_DIRECTORY_NAME).toBe(QWEN_DIR); + }); + + it('matches Storage.getGlobalQwenDir path resolution', () => { + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-storage-cwd-')), + ); + tempQwenHome = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-storage-home-')), + ); + process.chdir(tempWorkspace); + + for (const qwenHome of [ + undefined, + tempQwenHome, + '~', + '~/qwen-fast-path', + '~\\qwen-fast-path', + 'relative-qwen-home', + ]) { + if (qwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = qwenHome; + } + + expect(getGlobalQwenDirLite()).toBe(Storage.getGlobalQwenDir()); + } + }); + + it('closes the listener and exits when runtime startup fails after listen', async () => { + const stderrWrites: string[] = []; + const close = vi.fn().mockResolvedValue(undefined); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + await expect( + waitForServeRuntimeOrExit({ + runtimeReady: Promise.reject(new Error('runtime boom')), + close, + }), + ).rejects.toThrow('process.exit(1)'); + + expect(close).toHaveBeenCalledTimes(1); + expect(stderrWrites.join('')).toContain( + 'qwen serve: runtime startup failed after listener was ready: runtime boom', + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('validates rate limit env after settings bootstrap enables rate limiting', async () => { + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-rate-limit-env-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify({ + env: { + QWEN_SERVE_RATE_LIMIT: '1', + QWEN_SERVE_RATE_LIMIT_PROMPT: '0', + }, + }), + ); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + await expect( + tryRunServeFastPath([ + 'serve', + '--workspace', + tempWorkspace, + '--port', + '0', + '--hostname', + '127.0.0.1', + '--no-open', + '--no-web', + ]), + ).rejects.toThrow('process.exit(1)'); + + expect(stderrWrites.join('')).toContain( + 'qwen serve: --rate-limit-prompt must be a positive integer.', + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('exits when runQwenServe fails after settings bootstrap succeeds', async () => { + useTempQwenHome(); + vi.spyOn(runQwenServeModule, 'runQwenServe').mockRejectedValue( + new Error('listen boom'), + ); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + await expect( + tryRunServeFastPath([ + 'serve', + '--port', + '0', + '--hostname', + '127.0.0.1', + '--no-open', + '--no-web', + ]), + ).rejects.toThrow('process.exit(1)'); + + expect(stderrWrites.join('')).toContain('qwen serve: listen boom'); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('rejects malformed user settings so the full settings loader can handle it', async () => { + const qwenHome = useTempQwenHome(); + writeFileSync(join(qwenHome, 'settings.json'), '{'); + + await expect(bootstrapServeFastPathEnvironment(undefined)).rejects.toThrow( + /settings/i, + ); + }, 10_000); + + it('falls back to the full CLI when fast-path settings bootstrap fails', async () => { + const qwenHome = useTempQwenHome(); + writeFileSync(join(qwenHome, 'settings.json'), '{'); + + await expect( + tryRunServeFastPath(['serve', '--port', '0', '--no-open', '--no-web']), + ).resolves.toBe(false); + }, 10_000); + + it.each([ + [ + 'advanced.excludedEnvVars', + { advanced: { excludedEnvVars: 'QWEN_SERVER_TOKEN' } }, + ], + [ + 'advanced.runtimeOutputDir', + { advanced: { runtimeOutputDir: ['.qwen-runtime'] } }, + ], + [ + 'security.folderTrust.enabled', + { security: { folderTrust: { enabled: 'true' } } }, + ], + ])( + 'falls back to the full CLI when %s has an incompatible shape', + async (_field, settingsJson) => { + const qwenHome = useTempQwenHome(); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify(settingsJson), + ); + + await expect( + tryRunServeFastPath(['serve', '--port', '0', '--no-open', '--no-web']), + ).resolves.toBe(false); + }, + ); + + it('loads QWEN_SERVER_TOKEN from the workspace .env before the daemon starts', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-env-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', '.env'), + 'QWEN_SERVER_TOKEN=from-workspace-env\n', + ); + process.chdir(tempWorkspace); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-workspace-env'); + }); + + it('loads .env from --workspace even when launched from another directory', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-workspace-env-')), + ); + tempLaunchCwd = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-launch-cwd-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', '.env'), + 'QWEN_SERVER_TOKEN=from-explicit-workspace-env\n', + ); + process.chdir(tempLaunchCwd); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe( + 'from-explicit-workspace-env', + ); + }); + + it('loads home .env after workspace .env for daemon boot-time keys', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + delete process.env['QWEN_SERVE_RATE_LIMIT']; + delete process.env['QWEN_SERVE_RATE_LIMIT_PROMPT']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-layered-env-')), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVE_RATE_LIMIT_PROMPT=123\n', + ); + writeFileSync( + join(qwenHome, '.env'), + ['QWEN_SERVER_TOKEN=from-home-env', 'QWEN_SERVE_RATE_LIMIT=1'].join('\n'), + ); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVE_RATE_LIMIT_PROMPT']).toBe('123'); + expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-home-env'); + expect(process.env['QWEN_SERVE_RATE_LIMIT']).toBe('1'); + }); + + it('applies legacy excludedProjectEnvVars before loading workspace .env', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ excludedProjectEnvVars: ['QWEN_SERVER_TOKEN'] }), + ); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-legacy-env-')), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-workspace-env\n', + ); + process.chdir(tempWorkspace); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('loads QWEN_SERVER_TOKEN from workspace settings.env without the full settings loader', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-settings-env-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify({ + env: { QWEN_SERVER_TOKEN: 'from-workspace-settings-env' }, + }), + ); + process.chdir(tempWorkspace); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe( + 'from-workspace-settings-env', + ); + }); + + it('pre-resolves home env overrides in the same order as the full loader', () => { + delete process.env['QWEN_HOME']; + delete process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_CODE_MCP_APPROVALS_PATH']; + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + tempLaunchCwd = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-fake-home-')), + ); + process.env['HOME'] = tempLaunchCwd; + process.env['USERPROFILE'] = tempLaunchCwd; + tempQwenHome = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-discovered-home-')), + ); + mkdirSync(join(tempLaunchCwd, '.qwen'), { recursive: true }); + writeFileSync( + join(tempLaunchCwd, '.qwen', '.env'), + `QWEN_HOME=${tempQwenHome}\n`, + ); + writeFileSync( + join(tempLaunchCwd, '.env'), + 'QWEN_RUNTIME_DIR=from-home-env\n', + ); + writeFileSync( + join(tempQwenHome, '.env'), + [ + 'QWEN_CODE_MCP_APPROVALS_PATH=from-discovered-home', + 'QWEN_CODE_TRUSTED_FOLDERS_PATH=from-discovered-trust', + ].join('\n'), + ); + + preResolveServeFastPathHomeEnvOverrides(); + + expect(process.env['QWEN_HOME']).toBe(tempQwenHome); + expect(process.env['QWEN_RUNTIME_DIR']).toBe('from-home-env'); + expect(process.env['QWEN_CODE_MCP_APPROVALS_PATH']).toBe( + 'from-discovered-home', + ); + expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBe( + 'from-discovered-trust', + ); + }); + + it('still pre-resolves missing home-scoped keys when QWEN_HOME and runtime are already set', () => { + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + const qwenHome = useTempQwenHome(); + process.env['QWEN_RUNTIME_DIR'] = join(qwenHome, 'runtime'); + writeFileSync( + join(qwenHome, '.env'), + 'QWEN_CODE_TRUSTED_FOLDERS_PATH=from-existing-home\n', + ); + + preResolveServeFastPathHomeEnvOverrides(); + + expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBe( + 'from-existing-home', + ); + }); + + it('applies legacy settings keys consumed by the serve fast path', () => { + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-legacy-settings-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ + approvalMode: 'yolo', + contextFileName: 'LEGACY.md', + excludedProjectEnvVars: ['QWEN_SERVER_TOKEN'], + fileFiltering: { customIgnoreFiles: ['.legacy-ignore'] }, + folderTrust: true, + sandbox: false, + }), + ); + + const settings = loadServeFastPathSettings(tempWorkspace); + + expect(settings).toMatchObject({ + advanced: { excludedEnvVars: ['QWEN_SERVER_TOKEN'] }, + context: { + fileName: 'LEGACY.md', + fileFiltering: { customIgnoreFiles: ['.legacy-ignore'] }, + }, + security: { folderTrust: { enabled: true } }, + tools: { approvalMode: 'yolo', sandbox: false }, + }); + }); + + it('matches the full settings loader for fields consumed before listen', async () => { + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-settings-parity-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + const { SETTINGS_VERSION, loadSettings } = await import( + '../config/settings.js' + ); + const versioned = (settings: Record) => ({ + $version: SETTINGS_VERSION, + ...settings, + }); + writeFileSync( + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']!, + JSON.stringify( + versioned({ + env: { + FAST_PATH_DEFAULT_ONLY: 'default', + FAST_PATH_OVERLAP: 'default', + }, + advanced: { + excludedEnvVars: ['FAST_PATH_DEFAULT_EXCLUDED'], + runtimeOutputDir: '.default-runtime', + }, + context: { + fileName: 'DEFAULT.md', + fileFiltering: { customIgnoreFiles: ['.default-ignore'] }, + }, + security: { folderTrust: { enabled: false } }, + tools: { approvalMode: 'default' }, + }), + ), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify( + versioned({ + env: { + FAST_PATH_USER_ONLY: 'user', + FAST_PATH_OVERLAP: 'user', + }, + advanced: { + excludedEnvVars: ['FAST_PATH_USER_EXCLUDED'], + }, + security: { folderTrust: { enabled: true } }, + tools: { approvalMode: 'auto' }, + }), + ), + ); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify( + versioned({ + env: { + FAST_PATH_WORKSPACE_ONLY: 'workspace', + FAST_PATH_OVERLAP: 'workspace', + }, + advanced: { runtimeOutputDir: '.workspace-runtime' }, + context: { + fileName: 'WORKSPACE.md', + fileFiltering: { customIgnoreFiles: ['.workspace-ignore'] }, + }, + policy: { permissionStrategy: 'consensus', consensusQuorum: 3 }, + tools: { sandbox: true }, + }), + ), + ); + writeFileSync( + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']!, + JSON.stringify( + versioned({ + env: { + FAST_PATH_SYSTEM_ONLY: 'system', + FAST_PATH_OVERLAP: 'system', + }, + context: { fileName: 'SYSTEM.md' }, + tools: { approvalMode: 'yolo' }, + }), + ), + ); + expect(loadServeFastPathSettings(tempWorkspace)).toEqual( + pickServeFastPathComparable( + loadSettings(tempWorkspace, { skipLoadEnvironment: true }).merged, + ), + ); + }); + + it('loads runtimeOutputDir for daemon startup artifacts', () => { + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-runtime-dir-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ + advanced: { runtimeOutputDir: '.qwen-runtime' }, + }), + ); + + const settings = loadServeFastPathSettings(tempWorkspace); + + expect(settings.advanced?.runtimeOutputDir).toBe('.qwen-runtime'); + }); + + it('ignores stale legacy keys in current-version settings files', () => { + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-stale-legacy-settings-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ + $version: 5, + approvalMode: 'yolo', + contextFileName: 'LEGACY.md', + excludedProjectEnvVars: ['QWEN_SERVER_TOKEN'], + fileFiltering: { customIgnoreFiles: ['.legacy-ignore'] }, + folderTrust: true, + sandbox: false, + }), + ); + + const settings = loadServeFastPathSettings(tempWorkspace); + + expect(settings.advanced).toBeUndefined(); + expect(settings.context).toBeUndefined(); + expect(settings.security).toBeUndefined(); + expect(settings.tools).toBeUndefined(); + }); + + it('uses trusted-folders path from home .env before loading workspace env', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + delete process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + const qwenHome = useTempQwenHome(); + const customTrustedFoldersPath = join(qwenHome, 'custom-trusted.json'); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-home-trust-env-')), + ); + writeFileSync( + join(qwenHome, '.env'), + `QWEN_CODE_TRUSTED_FOLDERS_PATH=${customTrustedFoldersPath}\n`, + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + writeFileSync( + customTrustedFoldersPath, + JSON.stringify({ [tempWorkspace]: TrustLevel.DO_NOT_TRUST }), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-untrusted-workspace-env\n', + ); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBe( + customTrustedFoldersPath, + ); + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('uses legacy folderTrust before loading workspace env', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-legacy-trust-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ folderTrust: true }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ [tempWorkspace]: TrustLevel.DO_NOT_TRUST }), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-untrusted-workspace-env\n', + ); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('caches trusted folders during a single fast-path bootstrap', () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trust-cache-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ [tempWorkspace]: TrustLevel.TRUST_FOLDER }), + ); + writeFileSync(join(tempWorkspace, '.env'), 'QWEN_SERVER_TOKEN=trusted\n'); + + const settings = loadServeFastPathSettings(tempWorkspace); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ [tempWorkspace]: TrustLevel.DO_NOT_TRUST }), + ); + + loadServeFastPathEnvironment(settings, tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted'); + }); + + it('prioritizes trusted parent folders over nested distrust rules', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trust-precedence-')), + ); + const childWorkspace = join(tempWorkspace, 'child'); + mkdirSync(childWorkspace); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [tempWorkspace]: TrustLevel.TRUST_FOLDER, + [childWorkspace]: TrustLevel.DO_NOT_TRUST, + }), + ); + writeFileSync(join(childWorkspace, '.env'), 'QWEN_SERVER_TOKEN=trusted\n'); + + await bootstrapServeFastPathEnvironment(childWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted'); + }); + + it('treats TRUST_PARENT as trusting the containing folder', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trust-parent-')), + ); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [join(tempWorkspace, 'marker')]: TrustLevel.TRUST_PARENT, + }), + ); + writeFileSync(join(tempWorkspace, '.env'), 'QWEN_SERVER_TOKEN=trusted\n'); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted'); + }); + + it('matches Cloud Shell default project behavior for empty env values', async () => { + delete process.env['GOOGLE_CLOUD_PROJECT']; + process.env['CLOUD_SHELL'] = 'true'; + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-cloud-shell-')), + ); + writeFileSync(join(tempWorkspace, '.env'), 'GOOGLE_CLOUD_PROJECT=\n'); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca'); + }); + + it('expands process environment placeholders in workspace settings.env', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + useTempQwenHome(); + process.env['FAST_PATH_REFERENCED_TOKEN'] = 'from-referenced-env'; + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-settings-env-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify({ + env: { QWEN_SERVER_TOKEN: '${FAST_PATH_REFERENCED_TOKEN}' }, + }), + ); + process.chdir(tempWorkspace); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-referenced-env'); + }); + + it('expands home .env fallback placeholders in workspace settings.env', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + delete process.env['FAST_PATH_REFERENCED_TOKEN']; + const qwenHome = useTempQwenHome(); + writeFileSync( + join(qwenHome, '.env'), + 'FAST_PATH_REFERENCED_TOKEN=from-home-env\n', + ); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-settings-env-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify({ + env: { QWEN_SERVER_TOKEN: '${FAST_PATH_REFERENCED_TOKEN}' }, + }), + ); + process.chdir(tempWorkspace); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-home-env'); + }); + + it.each([ + ['malformed JSON', '{ "env": { "QWEN_SERVER_TOKEN": "broken" }'], + ['non-object JSON', '[]'], + ])( + 'rejects %s workspace settings so the full settings loader can handle it', + async (_name, settingsJson) => { + delete process.env['QWEN_SERVER_TOKEN']; + useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-bad-settings-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + settingsJson, + ); + process.chdir(tempWorkspace); + + await expect( + bootstrapServeFastPathEnvironment(tempWorkspace), + ).rejects.toThrow(/settings/i); + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }, + ); + + it('still reads invalid workspace settings before dropping an untrusted workspace from the merge', () => { + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-untrusted-settings-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ [tempWorkspace]: TrustLevel.DO_NOT_TRUST }), + ); + writeFileSync(join(tempWorkspace, '.qwen', 'settings.json'), '[]'); + process.chdir(tempWorkspace); + + expect(() => loadServeFastPathSettings(tempWorkspace!)).toThrow( + /settings/i, + ); + }); + + it('does not load env from an explicit untrusted workspace when launched elsewhere', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-untrusted-env-')), + ); + tempLaunchCwd = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trusted-launch-')), + ); + mkdirSync(join(tempWorkspace, '.qwen')); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [tempLaunchCwd]: TrustLevel.TRUST_FOLDER, + [tempWorkspace]: TrustLevel.DO_NOT_TRUST, + }), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-untrusted-workspace-env\n', + ); + writeFileSync( + join(tempWorkspace, '.qwen', 'settings.json'), + JSON.stringify({ + env: { QWEN_SERVER_TOKEN: 'from-untrusted-workspace-settings' }, + }), + ); + process.chdir(tempLaunchCwd); + + await bootstrapServeFastPathEnvironment(tempWorkspace); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('checks trust against the canonical explicit workspace path', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-real-untrusted-env-')), + ); + tempLaunchCwd = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-symlink-launch-')), + ); + tempSymlink = join(tempLaunchCwd, 'workspace-link'); + symlinkSync(tempWorkspace, tempSymlink, 'dir'); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [tempLaunchCwd]: TrustLevel.TRUST_FOLDER, + [tempWorkspace]: TrustLevel.DO_NOT_TRUST, + }), + ); + writeFileSync( + join(tempWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-symlinked-untrusted-workspace-env\n', + ); + process.chdir(tempLaunchCwd); + + await bootstrapServeFastPathEnvironment(tempSymlink); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/serve/fast-path.ts b/packages/cli/src/serve/fast-path.ts new file mode 100644 index 00000000000..96a35ef27cf --- /dev/null +++ b/packages/cli/src/serve/fast-path.ts @@ -0,0 +1,507 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RunHandle } from './run-qwen-serve.js'; +import { normalizeServeFastPathArgv } from './fast-path-argv.js'; +import type { ServeFastPathSettings } from './fast-path-settings.js'; +import type { ServeOptions } from './types.js'; + +type McpBudgetMode = NonNullable; + +interface ParsedServeFastPath { + kind: 'serve'; + open: boolean; + httpBridge: boolean; + options: ServeOptions; +} + +interface FallbackFastPath { + kind: 'fallback'; +} + +export type ServeFastPathParseResult = ParsedServeFastPath | FallbackFastPath; + +const HELP_AND_VERSION_FLAGS = new Set(['--help', '-h', '--version', '-v']); +const MCP_BUDGET_WARN_FRACTION = 0.75; + +const NUMBER_OPTIONS = new Map< + keyof ServeOptions | 'mcp-client-budget', + string +>([ + ['port', 'port'], + ['maxSessions', 'max-sessions'], + ['maxPendingPromptsPerSession', 'max-pending-prompts-per-session'], + ['maxConnections', 'max-connections'], + ['eventRingSize', 'event-ring-size'], + ['mcp-client-budget', 'mcp-client-budget'], + ['promptDeadlineMs', 'prompt-deadline-ms'], + ['writerIdleTimeoutMs', 'writer-idle-timeout-ms'], + ['channelIdleTimeoutMs', 'channel-idle-timeout-ms'], + ['sessionReapIntervalMs', 'session-reap-interval-ms'], + ['sessionIdleTimeoutMs', 'session-idle-timeout-ms'], + ['permissionResponseTimeoutMs', 'permission-response-timeout-ms'], + ['rateLimitPrompt', 'rate-limit-prompt'], + ['rateLimitMutation', 'rate-limit-mutation'], + ['rateLimitRead', 'rate-limit-read'], + ['rateLimitWindowMs', 'rate-limit-window-ms'], +]); + +const NUMBER_OPTION_BY_FLAG = invertOptionMap(NUMBER_OPTIONS); + +const STRING_OPTION_BY_FLAG = new Map([ + ['hostname', 'hostname'], + ['token', 'token'], + ['workspace', 'workspace'], +]); + +const BOOLEAN_OPTION_BY_FLAG = new Map< + string, + keyof ServeOptions | 'open' | 'http-bridge' +>([ + ['require-auth', 'requireAuth'], + ['enable-session-shell', 'enableSessionShell'], + ['web', 'serveWebShell'], + ['open', 'open'], + ['http-bridge', 'http-bridge'], + ['allow-private-auth-base-url', 'allowPrivateAuthBaseUrl'], + ['experimental-lsp', 'experimentalLsp'], + ['rate-limit', 'rateLimit'], +]); + +function invertOptionMap( + source: Map, +): Map { + const out = new Map(); + for (const [target, flag] of source) { + out.set(flag, target); + } + return out; +} + +function readOptionValue( + argv: readonly string[], + index: number, + inlineValue: string | undefined, +): { value: string; nextIndex: number } | null { + if (inlineValue !== undefined) { + return { value: inlineValue, nextIndex: index }; + } + const value = argv[index + 1]; + if (value === undefined || value.startsWith('-')) { + return null; + } + return { value, nextIndex: index + 1 }; +} + +function parseNumber(value: string): number | null { + if (value.trim() === '') return null; + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function parseBooleanValue(value: string): boolean | null { + if (value === 'true') return true; + if (value === 'false') return false; + return null; +} + +function parsePositiveIntegerEnv(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined; + const trimmed = raw?.trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) return Number.NaN; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Number.NaN; +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value === 'true'; +} + +function writeStderrLine(line: string): void { + process.stderr.write(line.endsWith('\n') ? line : `${line}\n`); +} + +function setServeOption( + options: ServeOptions, + key: keyof ServeOptions, + value: unknown, +): void { + (options as unknown as Record)[key] = value; +} + +function getRateLimitValidationError(options: ServeOptions): string | null { + for (const [name, value] of [ + ['--rate-limit-prompt', options.rateLimitPrompt], + ['--rate-limit-mutation', options.rateLimitMutation], + ['--rate-limit-read', options.rateLimitRead], + ] as const) { + if ( + value !== undefined && + (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) + ) { + return `qwen serve: ${name} must be a positive integer.`; + } + } + if ( + options.rateLimitWindowMs !== undefined && + (!Number.isFinite(options.rateLimitWindowMs) || + !Number.isInteger(options.rateLimitWindowMs) || + options.rateLimitWindowMs < 1000) + ) { + return 'qwen serve: --rate-limit-window-ms must be an integer >= 1000.'; + } + return null; +} + +function getServeFastPathValidationError( + parsed: ParsedServeFastPath, +): string | null { + const mcpClientBudget = parsed.options.mcpClientBudget; + if ( + mcpClientBudget !== undefined && + (!Number.isFinite(mcpClientBudget) || + !Number.isInteger(mcpClientBudget) || + mcpClientBudget <= 0) + ) { + return 'qwen serve: --mcp-client-budget must be a positive integer.'; + } + + if ( + parsed.options.mcpBudgetMode === 'enforce' && + mcpClientBudget === undefined + ) { + return 'qwen serve: --mcp-budget-mode=enforce requires --mcp-client-budget=N.'; + } + + const maxPendingPromptsPerSession = + parsed.options.maxPendingPromptsPerSession; + if ( + maxPendingPromptsPerSession !== undefined && + maxPendingPromptsPerSession !== Number.POSITIVE_INFINITY && + (!Number.isFinite(maxPendingPromptsPerSession) || + !Number.isInteger(maxPendingPromptsPerSession) || + maxPendingPromptsPerSession < 0) + ) { + return 'qwen serve: --max-pending-prompts-per-session must be a non-negative integer (0 / Infinity = unlimited).'; + } + + return null; +} + +function blockForever(): Promise { + return new Promise(() => {}); +} + +export async function waitForServeRuntimeOrExit( + handle: Pick, +): Promise { + try { + await handle.runtimeReady; + } catch (err) { + writeStderrLine( + `qwen serve: runtime startup failed after listener was ready: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + await handle.close().catch(() => undefined); + process.exit(1); + } +} + +function applyRateLimitEnvDefaults( + options: ServeOptions, + env: NodeJS.ProcessEnv, +): void { + if ( + options.rateLimit === undefined && + isTruthyEnv(env['QWEN_SERVE_RATE_LIMIT']) + ) { + options.rateLimit = true; + } + if (options.rateLimit) { + options.rateLimitPrompt ??= parsePositiveIntegerEnv( + env['QWEN_SERVE_RATE_LIMIT_PROMPT'], + ); + options.rateLimitMutation ??= parsePositiveIntegerEnv( + env['QWEN_SERVE_RATE_LIMIT_MUTATION'], + ); + options.rateLimitRead ??= parsePositiveIntegerEnv( + env['QWEN_SERVE_RATE_LIMIT_READ'], + ); + options.rateLimitWindowMs ??= parsePositiveIntegerEnv( + env['QWEN_SERVE_RATE_LIMIT_WINDOW_MS'], + ); + } +} + +function discardRateLimitTuningWhenDisabled(options: ServeOptions): void { + if (options.rateLimit === true) return; + delete options.rateLimitPrompt; + delete options.rateLimitMutation; + delete options.rateLimitRead; + delete options.rateLimitWindowMs; +} + +export async function bootstrapServeFastPathEnvironment( + workspace: string | undefined, +): Promise { + const { + loadServeFastPathEnvironment, + loadServeFastPathSettings, + preResolveServeFastPathHomeEnvOverrides, + } = await import('./fast-path-settings.js'); + preResolveServeFastPathHomeEnvOverrides(); + const workspaceDir = workspace ?? process.cwd(); + const settings = loadServeFastPathSettings(workspaceDir); + loadServeFastPathEnvironment(settings, workspaceDir); + return settings; +} + +export function parseServeFastPathArgs( + rawArgv: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): ServeFastPathParseResult { + const argv = normalizeServeFastPathArgv(rawArgv); + if (argv[0] !== 'serve') return { kind: 'fallback' }; + if (argv.some((arg) => HELP_AND_VERSION_FLAGS.has(arg))) { + return { kind: 'fallback' }; + } + + // Keep this lightweight mirror in sync with commands/serve.ts; unsupported + // flags intentionally fall back to the full yargs parser. + const options: ServeOptions = { + hostname: '127.0.0.1', + mode: 'http-bridge', + port: 4170, + }; + let open = false; + let httpBridge = true; + let mcpBudgetModeRaw: string | undefined; + let mcpClientBudget: number | undefined; + let explicitRateLimit: boolean | undefined; + + for (let i = 1; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--') return { kind: 'fallback' }; + if (!arg.startsWith('--')) return { kind: 'fallback' }; + + const withoutPrefix = arg.slice(2); + const equalsIndex = withoutPrefix.indexOf('='); + const rawFlag = + equalsIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, equalsIndex); + const inlineValue = + equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1); + const negated = rawFlag.startsWith('no-'); + const flag = negated ? rawFlag.slice(3) : rawFlag; + + const booleanTarget = BOOLEAN_OPTION_BY_FLAG.get(flag); + if (booleanTarget) { + let value = !negated; + if (inlineValue !== undefined) { + const parsed = parseBooleanValue(inlineValue); + if (parsed === null || negated) return { kind: 'fallback' }; + value = parsed; + } + if (booleanTarget === 'open') { + open = value; + } else if (booleanTarget === 'http-bridge') { + httpBridge = value; + } else { + setServeOption(options, booleanTarget, value); + if (booleanTarget === 'rateLimit') { + explicitRateLimit = value; + } + } + continue; + } + if (negated) return { kind: 'fallback' }; + + const numberTarget = NUMBER_OPTION_BY_FLAG.get(flag); + if (numberTarget) { + const read = readOptionValue(argv, i, inlineValue); + if (!read) return { kind: 'fallback' }; + i = read.nextIndex; + const value = parseNumber(read.value); + if (value === null) return { kind: 'fallback' }; + if (numberTarget === 'mcp-client-budget') { + mcpClientBudget = value; + } else { + setServeOption(options, numberTarget, value); + } + continue; + } + + const stringTarget = STRING_OPTION_BY_FLAG.get(flag); + if (stringTarget) { + const read = readOptionValue(argv, i, inlineValue); + if (!read) return { kind: 'fallback' }; + i = read.nextIndex; + setServeOption(options, stringTarget, read.value); + continue; + } + + if (flag === 'mcp-budget-mode') { + const read = readOptionValue(argv, i, inlineValue); + if (!read) return { kind: 'fallback' }; + i = read.nextIndex; + mcpBudgetModeRaw = read.value; + continue; + } + + if (flag === 'allow-origin') { + const read = readOptionValue(argv, i, inlineValue); + if (!read) return { kind: 'fallback' }; + i = read.nextIndex; + options.allowOrigins = [...(options.allowOrigins ?? []), read.value]; + continue; + } + + return { kind: 'fallback' }; + } + + if ( + mcpBudgetModeRaw !== undefined && + mcpBudgetModeRaw !== 'enforce' && + mcpBudgetModeRaw !== 'warn' && + mcpBudgetModeRaw !== 'off' + ) { + return { kind: 'fallback' }; + } + + const mcpBudgetMode = + (mcpBudgetModeRaw as McpBudgetMode | undefined) ?? + (mcpClientBudget !== undefined ? 'warn' : 'off'); + if (mcpClientBudget !== undefined) options.mcpClientBudget = mcpClientBudget; + options.mcpBudgetMode = mcpBudgetMode; + + if (explicitRateLimit !== undefined) { + options.rateLimit = explicitRateLimit; + } + applyRateLimitEnvDefaults(options, env); + return { kind: 'serve', open, httpBridge, options }; +} + +async function maybeOpenWebShellBrowser( + handle: RunHandle, + open: boolean, +): Promise { + if (!open) return; + const { maybeOpenWebShellBrowser: openBrowser } = await import( + '../commands/serve.js' + ); + await openBrowser(handle, true); +} + +async function emitHeadlessYoloWarning( + settings: ServeFastPathSettings | undefined, +) { + if (!settings) return; + try { + const { HEADLESS_YOLO_NO_SANDBOX_WARNING } = await import( + '../utils/headlessSafetyWarnings.js' + ); + const suppress = process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING']; + if ( + settings.tools?.approvalMode === 'yolo' && + !settings.tools?.sandbox && + !process.env['SANDBOX'] && + !isTruthyEnv(suppress) + ) { + writeStderrLine(HEADLESS_YOLO_NO_SANDBOX_WARNING); + } + } catch { + // Keep the warning best-effort, matching the yargs serve handler. + } +} + +function writeServeWarnings(parsed: ParsedServeFastPath): void { + if (!parsed.httpBridge) { + writeStderrLine( + 'qwen serve: --no-http-bridge (native mode) is not yet implemented; ' + + 'falling back to http-bridge.', + ); + } + if (parsed.options.token) { + writeStderrLine( + 'qwen serve: --token is visible in the process command line; ' + + 'prefer the QWEN_SERVER_TOKEN env var for any non-trivial deployment.', + ); + } + + const mcpClientBudget = parsed.options.mcpClientBudget; + if (mcpClientBudget !== undefined) { + const resolvedMcpMode = parsed.options.mcpBudgetMode ?? 'warn'; + writeStderrLine( + `qwen serve: --mcp-client-budget=${mcpClientBudget} mode=${resolvedMcpMode}` + + (resolvedMcpMode === 'enforce' + ? ' (servers past the cap will be refused at discovery)' + : resolvedMcpMode === 'warn' + ? ` (warnings at >=${Math.ceil(mcpClientBudget * MCP_BUDGET_WARN_FRACTION)}, no refusal)` + : ''), + ); + } +} + +export async function tryRunServeFastPath( + rawArgv: readonly string[] = process.argv.slice(2), +): Promise { + const parsed = parseServeFastPathArgs(rawArgv); + if (parsed.kind === 'fallback') return false; + + const validationError = + getServeFastPathValidationError(parsed) || + (parsed.options.rateLimit === true + ? getRateLimitValidationError(parsed.options) + : null); + if (validationError) { + writeStderrLine(validationError); + process.exit(1); + } + + let settings: ServeFastPathSettings | undefined; + try { + settings = await bootstrapServeFastPathEnvironment( + parsed.options.workspace, + ); + } catch (err) { + writeStderrLine( + `qwen serve: fast-path bootstrap failed, falling back to full startup: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } + applyRateLimitEnvDefaults(parsed.options, process.env); + discardRateLimitTuningWhenDisabled(parsed.options); + + const rateLimitError = getRateLimitValidationError(parsed.options); + if (rateLimitError) { + writeStderrLine(rateLimitError); + process.exit(1); + } + + writeServeWarnings(parsed); + + const { runQwenServe } = await import('./run-qwen-serve.js'); + let handle: RunHandle; + try { + handle = await runQwenServe(parsed.options, { + ...(settings ? { bootSettings: settings } : {}), + resolveOnListen: true, + }); + void emitHeadlessYoloWarning(settings); + await maybeOpenWebShellBrowser(handle, parsed.open); + } catch (err) { + writeStderrLine( + `qwen serve: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + await waitForServeRuntimeOrExit(handle); + await blockForever(); + return true; +} diff --git a/packages/cli/src/serve/routes/workspace-permissions.test.ts b/packages/cli/src/serve/routes/workspace-permissions.test.ts index f39204da2c3..02e8c3af284 100644 --- a/packages/cli/src/serve/routes/workspace-permissions.test.ts +++ b/packages/cli/src/serve/routes/workspace-permissions.test.ts @@ -747,7 +747,7 @@ describe('workspace permissions routes', () => { security: { folderTrust: { enabled: true } }, }); await writeJson(path.join(h.home, TRUSTED_FOLDERS_FILENAME), { - [process.cwd()]: TrustLevel.DO_NOT_TRUST, + [h.workspace]: TrustLevel.DO_NOT_TRUST, }); resetTrustedFoldersForTesting(); diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 6ed0f1d68c7..8576b2c73d0 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -7,19 +7,61 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { + createLazyBridgeProxy, extractContextFilename, InvalidPolicyConfigError, + resolveRuntimeStartupTimeoutMs, runQwenServe, + type RunHandle, validatePolicyConfig, + waitForRuntimeStartingForShutdown, } from './run-qwen-serve.js'; -import type { HttpAcpBridge } from './acp-session-bridge.js'; +import * as acpBridge from '@qwen-code/acp-bridge/bridge'; +import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; +import type { + BridgeDaemonStatusSnapshot, + HttpAcpBridge, +} from '@qwen-code/acp-bridge/bridgeTypes'; +import { Storage } from '@qwen-code/qwen-code-core'; +import * as qwenCore from '@qwen-code/qwen-code-core'; +import * as serverModule from './server.js'; + +const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = { + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 0, + pendingPermissionCount: 0, + channelLive: true, + permissionPolicy: 'first-responder', + sessions: [], +}; const mockCreateSpawnChannelFactoryOptions = vi.hoisted( () => [] as Array>, ); +async function getFreeLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + return port; +} + vi.mock('@qwen-code/acp-bridge/spawnChannel', async (importOriginal) => { const actual = await importOriginal(); @@ -334,6 +376,79 @@ describe('runQwenServe permissionResponseTimeoutMs validation', () => { }); }); +describe('runQwenServe pre-listen bridge option validation', () => { + let tmpDir: string; + + afterEach(() => { + vi.restoreAllMocks(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it.each([ + ['maxSessions', Number.NaN, /maxSessions/], + ['maxSessions', -1, /maxSessions/], + ['eventRingSize', 0, /eventRingSize/], + ['eventRingSize', 1.5, /eventRingSize/], + ['eventRingSize', Number.POSITIVE_INFINITY, /eventRingSize/], + ] as const)( + 'rejects invalid %s=%s before printing the listening line', + async (optionName, value, message) => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bridge-opt-')), + ); + const stdoutWrites: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutWrites.push(String(chunk)); + return true; + }); + + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + [optionName]: value, + }), + ).rejects.toThrow(message); + expect(stdoutWrites.join('')).not.toContain('qwen serve listening on'); + }, + ); + + it.each([ + ['rateLimitPrompt', 0, /rateLimitPrompt/], + ['rateLimitMutation', -1, /rateLimitMutation/], + ['rateLimitRead', 1.5, /rateLimitRead/], + ['rateLimitWindowMs', 999, /rateLimitWindowMs/], + ] as const)( + 'rejects invalid %s=%s before printing the listening line', + async (optionName, value, message) => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-rate-opt-')), + ); + const stdoutWrites: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutWrites.push(String(chunk)); + return true; + }); + + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + rateLimit: true, + [optionName]: value, + }), + ).rejects.toThrow(message); + expect(stdoutWrites.join('')).not.toContain('qwen serve listening on'); + }, + ); +}); + describe('runQwenServe session reaper timeout validation', () => { let tmpDir: string; @@ -411,6 +526,608 @@ describe('runQwenServe session reaper timeout validation', () => { ); }); +describe('runQwenServe runtime startup failures', () => { + let tmpDir: string; + + afterEach(() => { + vi.restoreAllMocks(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects the embedded run handle by default when the runtime fails to mount', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-')), + ); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }), + ).rejects.toThrow('runtime boom'); + }); + + it('closes the listener before rejecting when resolveOnListen is false and runtime startup fails', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-close-')), + ); + const port = await getFreeLoopbackPort(); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + + await expect( + runQwenServe({ + port, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }), + ).rejects.toThrow('runtime boom'); + + await expect( + fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(1000), + }), + ).rejects.toThrow(); + }); + + it('bounds shutdown waiting when runtime startup never settles', async () => { + const daemonLog = { warn: vi.fn() }; + + await expect( + waitForRuntimeStartingForShutdown( + new Promise(() => {}), + daemonLog, + 1, + ), + ).resolves.toBeUndefined(); + + expect(daemonLog.warn).toHaveBeenCalledWith( + '1ms runtime-startup wait reached during shutdown; continuing listener close', + ); + }); + + it('proxies bridge access only after the runtime bridge is ready', async () => { + const holder: { bridge?: HttpAcpBridge } = {}; + let runtimeStartupError: string | undefined; + const proxy = createLazyBridgeProxy( + () => holder.bridge, + () => runtimeStartupError, + ); + + expect(() => proxy.getDaemonStatusSnapshot()).toThrow( + 'Daemon bridge runtime is still starting.', + ); + + runtimeStartupError = 'runtime boom'; + expect(() => proxy.getDaemonStatusSnapshot()).toThrow( + 'Daemon bridge runtime is not available: runtime boom', + ); + + const getDaemonStatusSnapshot = vi.fn(function (this: HttpAcpBridge) { + return this === holder.bridge + ? BASE_BRIDGE_SNAPSHOT + : { + ...BASE_BRIDGE_SNAPSHOT, + channelLive: false, + }; + }); + runtimeStartupError = undefined; + holder.bridge = { getDaemonStatusSnapshot } as unknown as HttpAcpBridge; + + expect(proxy.getDaemonStatusSnapshot()).toBe(BASE_BRIDGE_SNAPSHOT); + expect(getDaemonStatusSnapshot).toHaveBeenCalledTimes(1); + }); + + it.each([ + [undefined, 120_000], + ['', 120_000], + ['5000', 5000], + ['0', 0], + ['abc', 120_000], + [String(Number.MAX_SAFE_INTEGER + 1), 120_000], + ])( + 'resolves QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS=%s to %s', + (envValue, expected) => { + const originalEnv = + process.env['QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS']; + try { + if (envValue === undefined) { + delete process.env['QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS']; + } else { + process.env['QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS'] = envValue; + } + + expect(resolveRuntimeStartupTimeoutMs(undefined)).toBe(expected); + } finally { + if (originalEnv === undefined) { + delete process.env['QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS']; + } else { + process.env['QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS'] = originalEnv; + } + } + }, + ); + + it('returns bootstrap 503 for unknown routes while runtime is still starting', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-starting-route-')), + ); + let resolveTelemetry: + | ((settings: qwenCore.TelemetrySettings) => void) + | undefined; + const telemetryPromise = new Promise( + (resolve) => { + resolveTelemetry = resolve; + }, + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue( + telemetryPromise, + ); + const bridge = { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + isChannelLive: vi.fn().mockReturnValue(true), + } as unknown as HttpAcpBridge; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true, runtimeStartupTimeoutMs: 0 }, + ); + + try { + const res = await fetch(`${handle.url}/unknown-route`); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ + error: 'Daemon runtime is still starting', + code: 'daemon_runtime_starting', + }); + } finally { + resolveTelemetry?.({ enabled: false }); + await handle.close(); + } + }); + + it('flushes runtime startup failures to the daemon log when closing', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-log-')), + ); + const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + process.env['QWEN_RUNTIME_DIR'] = tmpDir; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime boom'); + await handle.close(); + const daemonDir = path.join(tmpDir, 'debug', 'daemon'); + const logFile = fs + .readdirSync(daemonDir) + .find((file) => file.endsWith('.log')); + expect(logFile).toBeDefined(); + const logContent = fs.readFileSync( + path.join(daemonDir, logFile!), + 'utf8', + ); + expect(logContent).toContain('runtime startup failed'); + expect(logContent).toContain('runtime boom'); + } finally { + if (handle.server.listening) { + await handle.close(); + } + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + } + }); + + it('does not block shutdown on pending metrics flush', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-flush-pending-')), + ); + const forceFlushMetrics = vi.spyOn(qwenCore, 'forceFlushMetrics'); + forceFlushMetrics.mockReturnValue(new Promise(() => {})); + const bridge = { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + isChannelLive: vi.fn().mockReturnValue(true), + } as unknown as HttpAcpBridge; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + + await expect(handle.runtimeReady).resolves.toBeUndefined(); + let timeout: NodeJS.Timeout | undefined; + const closeResult = await Promise.race([ + handle.close().then(() => 'closed'), + new Promise<'timed-out'>((resolve) => { + timeout = setTimeout(() => resolve('timed-out'), 1_000); + timeout.unref(); + }), + ]); + if (timeout) clearTimeout(timeout); + + expect(closeResult).toBe('closed'); + expect(forceFlushMetrics).toHaveBeenCalledTimes(1); + expect(bridge.shutdown).toHaveBeenCalledTimes(1); + }); + + it('fails runtimeReady and health when runtime startup times out', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-timeout-')), + ); + let resolveTelemetry: + | ((settings: qwenCore.TelemetrySettings) => void) + | undefined; + const telemetryPromise = new Promise( + (resolve) => { + resolveTelemetry = resolve; + }, + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue( + telemetryPromise, + ); + const bridge = { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + isChannelLive: vi.fn().mockReturnValue(true), + } as unknown as HttpAcpBridge; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true, runtimeStartupTimeoutMs: 1 }, + ); + + try { + await expect(handle.runtimeReady).rejects.toThrow( + 'Daemon runtime startup timed out after 1ms.', + ); + const healthRes = await fetch(`${handle.url}/health`); + expect(healthRes.status).toBe(503); + expect(await healthRes.json()).toMatchObject({ + status: 'degraded', + error: 'Daemon runtime startup timed out after 1ms.', + }); + expect(() => handle.bridge.getDaemonStatusSnapshot()).toThrow( + 'Daemon bridge runtime is not available: Daemon runtime startup timed out after 1ms.', + ); + + resolveTelemetry?.({ enabled: false }); + await vi.waitFor(() => { + expect(bridge.shutdown).toHaveBeenCalledTimes(1); + }); + } finally { + await handle.close(); + } + }); + + it('reports bootstrap status and capabilities when fast path resolves on listen', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-')), + ); + const boundWorkspace = canonicalizeWorkspace(tmpDir); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime boom'); + const healthRes = await fetch(`${handle.url}/health`); + expect(healthRes.status).toBe(503); + expect(await healthRes.json()).toMatchObject({ + status: 'degraded', + error: 'runtime boom', + }); + const unknownRes = await fetch(`${handle.url}/unknown-route`); + expect(unknownRes.status).toBe(503); + expect(await unknownRes.json()).toMatchObject({ + error: 'Daemon runtime failed to start', + code: 'daemon_runtime_failed', + }); + + const capabilitiesRes = await fetch(`${handle.url}/capabilities`, { + headers: { Origin: handle.url }, + }); + expect(capabilitiesRes.status).toBe(200); + expect(await capabilitiesRes.json()).toMatchObject({ + v: 1, + protocolVersions: { current: 'v1', supported: ['v1'] }, + mode: 'http-bridge', + features: expect.arrayContaining([ + 'capabilities', + 'daemon_status', + 'workspace_settings', + 'workspace_reload', + ]), + modelServices: [], + workspaceCwd: boundWorkspace, + transports: ['rest'], + policy: { permission: 'first-responder' }, + limits: { maxPendingPromptsPerSession: 5 }, + }); + + const port = new URL(handle.url).port; + for (const origin of [ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + `http://host.docker.internal:${port}`, + ]) { + const sameOriginRes = await fetch(`${handle.url}/capabilities`, { + headers: { Origin: origin }, + }); + expect(sameOriginRes.status).toBe(200); + } + + const crossOriginRes = await fetch(`${handle.url}/capabilities`, { + headers: { Origin: 'http://example.com' }, + }); + expect(crossOriginRes.status).toBe(403); + + const res = await fetch(`${handle.url}/daemon/status`); + const body = (await res.json()) as { + status?: string; + issues?: Array<{ code?: string; severity?: string }>; + runtime?: { loading?: boolean; error?: string }; + }; + expect(body).toMatchObject({ + status: 'error', + issues: expect.arrayContaining([ + expect.objectContaining({ + code: 'daemon_runtime_failed', + severity: 'error', + }), + ]), + runtime: { loading: false, error: 'runtime boom' }, + }); + + const sameOriginRes = await fetch( + `${handle.url}/daemon/status?detail=full`, + { + headers: { Origin: handle.url }, + }, + ); + expect(sameOriginRes.status).toBe(200); + const sameOriginBody = await sameOriginRes.json(); + expect(sameOriginBody).toMatchObject({ + v: 1, + detail: 'full', + security: { allowOriginMode: 'none' }, + limits: { + maxSessions: 1, + maxPendingPromptsPerSession: 5, + listenerMaxConnections: 256, + eventRingSize: 8000, + promptDeadlineMs: null, + writerIdleTimeoutMs: null, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + acpConnectionCap: null, + }, + capabilities: { + protocolVersions: { current: 'v1', supported: ['v1'] }, + features: expect.arrayContaining(['daemon_status']), + }, + runtime: { + loading: false, + error: 'runtime boom', + sessions: { active: 0 }, + permissions: { pending: 0, policy: 'first-responder' }, + channel: { live: false }, + transport: { + restSseActive: 0, + acp: { enabled: false }, + }, + rateLimit: { + enabled: false, + rejectedSinceStart: { prompt: 0, mutation: 0, read: 0 }, + }, + }, + full: { + sessions: [], + acpConnections: [], + workspace: {}, + auth: { + supportedDeviceFlowProviders: [], + pendingDeviceFlowCount: 0, + }, + }, + }); + } finally { + await handle.close(); + } + }); + + it('shuts down a bridge when runtime mounting fails after bridge creation', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-partial-fail-')), + ); + const bridge = { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + } as unknown as HttpAcpBridge; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => { + throw new Error('runtime app boom'); + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime app boom'); + expect(bridge.shutdown).toHaveBeenCalledTimes(1); + } finally { + await handle.close(); + } + expect(bridge.shutdown).toHaveBeenCalledTimes(1); + }); + + it('cleans up runtime locals when closed immediately after listening', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-close-')), + ); + const bridge = { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + } as unknown as HttpAcpBridge; + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + const dispose = vi.fn(); + const attachServer = vi.fn(); + const originalCreateServeApp = serverModule.createServeApp; + vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => { + const app = originalCreateServeApp(...args); + app.locals['acpHandle'] = { + attachServer, + dispose, + registry: { getSnapshot: () => undefined }, + }; + return app; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + + await handle.close(); + + expect(bridge.shutdown).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); + describe('runQwenServe Web Shell signals on RunHandle', () => { let tmpDir: string; @@ -499,3 +1216,337 @@ describe('runQwenServe Web Shell signals on RunHandle', () => { }); }); }); + +describe('runQwenServe startup observability', () => { + let tmpDir: string; + + afterEach(() => { + vi.restoreAllMocks(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + function makeFakeBridge(): HttpAcpBridge { + return { + spawnOrAttach: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + getSession: vi.fn(), + getAllSessions: vi.fn().mockReturnValue([]), + publishWorkspaceEvent: vi.fn(), + getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }), + resume: vi.fn(), + preheat: vi.fn().mockResolvedValue(undefined), + getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT), + } as unknown as HttpAcpBridge; + } + + async function readStartup(handle: Pick) { + const res = await fetch(`${handle.url}/daemon/status`, { + headers: handle.resolvedToken + ? { Authorization: `Bearer ${handle.resolvedToken}` } + : undefined, + }); + const body = (await res.json()) as { + daemon?: { + startup?: { + processStartedAt?: string; + listenerReadyAt?: string; + processToListenMs?: number; + runQwenServeToListenMs?: number; + preheat?: { + status?: string; + durationMs?: number; + error?: string; + }; + }; + }; + }; + return body.daemon?.startup; + } + + async function waitForPreheatStatus( + handle: Pick, + status: string, + ) { + await handle.runtimeReady; + for (let i = 0; i < 20; i++) { + const startup = await readStartup(handle); + if (startup?.preheat?.status === status) return startup.preheat; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`preheat status did not become ${status}`); + } + + function installInternalBridge(preheat: () => Promise): HttpAcpBridge { + const bridge = makeFakeBridge(); + vi.mocked(bridge.preheat).mockImplementation(preheat); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + return bridge; + } + + it('keeps the stdout listening contract and exposes startup timing on stderr and status', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-')), + ); + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutWrites.push(String(chunk)); + return true; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { bridge: makeFakeBridge() }, + ); + + try { + expect(stdoutWrites).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^qwen serve listening on http:\/\/127\.0\.0\.1:\d+ \(mode=http-bridge, workspace=/, + ), + ]), + ); + expect(stderrWrites.join('')).toMatch( + /qwen serve: startup timing: processToListenMs=\d+ runQwenServeToListenMs=\d+/, + ); + + expect(await readStartup(handle)).toMatchObject({ + processStartedAt: expect.any(String), + listenerReadyAt: expect.any(String), + processToListenMs: expect.any(Number), + runQwenServeToListenMs: expect.any(Number), + preheat: { status: 'external_bridge' }, + }); + } finally { + await handle.close(); + } + }); + + it('uses boot runtimeOutputDir for daemon logs', async () => { + const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_RUNTIME_DIR']; + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-runtime-dir-')), + ); + const boundWorkspace = canonicalizeWorkspace(tmpDir); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + + let handle: RunHandle | undefined; + try { + handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { + bridge: makeFakeBridge(), + bootSettings: { + advanced: { runtimeOutputDir: '.qwen-runtime' }, + }, + }, + ); + const expectedDaemonDir = path.join( + boundWorkspace, + '.qwen-runtime', + 'debug', + 'daemon', + ); + expect(stderrWrites.join('')).toContain( + `qwen serve: daemon log → ${expectedDaemonDir}`, + ); + expect(fs.existsSync(expectedDaemonDir)).toBe(true); + } finally { + await handle?.close(); + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + } + }); + + it('uses explicit daemonLogBaseDir when provided by an embedder', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-log-dep-')), + ); + const logBaseDir = path.join(tmpDir, 'explicit-debug'); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { + bridge: makeFakeBridge(), + daemonLogBaseDir: logBaseDir, + }, + ); + + try { + const expectedDaemonDir = path.join(logBaseDir, 'daemon'); + expect(stderrWrites.join('')).toContain( + `qwen serve: daemon log → ${expectedDaemonDir}`, + ); + expect(fs.existsSync(expectedDaemonDir)).toBe(true); + } finally { + await handle.close(); + } + }); + + it('preserves Storage runtime base dir for default exported callers', async () => { + const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_RUNTIME_DIR']; + Storage.setRuntimeBaseDir(null); + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-storage-dir-')), + ); + fs.mkdirSync(path.join(tmpDir, '.qwen')); + fs.writeFileSync( + path.join(tmpDir, '.qwen', 'settings.json'), + JSON.stringify({ + advanced: { runtimeOutputDir: '.settings-runtime' }, + }), + ); + const runtimeBaseDir = path.join(tmpDir, 'storage-runtime'); + Storage.setRuntimeBaseDir(runtimeBaseDir); + const stderrWrites: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + + let handle: RunHandle | undefined; + try { + handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { bridge: makeFakeBridge() }, + ); + const expectedDaemonDir = path.join(runtimeBaseDir, 'debug', 'daemon'); + expect(stderrWrites.join('')).toContain( + `qwen serve: daemon log → ${expectedDaemonDir}`, + ); + expect(fs.existsSync(expectedDaemonDir)).toBe(true); + } finally { + await handle?.close(); + Storage.setRuntimeBaseDir(null); + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + } + }); + + it('tracks preheat running and succeeded states for an internally-created bridge', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-preheat-')), + ); + let resolvePreheat!: () => void; + const preheatPromise = new Promise((resolve) => { + resolvePreheat = resolve; + }); + const bridge = installInternalBridge(() => preheatPromise); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { preheatBridge: true }, + ); + + try { + await waitForPreheatStatus(handle, 'running'); + expect(bridge.preheat).toHaveBeenCalledTimes(1); + expect((await readStartup(handle))?.preheat).toMatchObject({ + status: 'running', + }); + + resolvePreheat(); + expect(await waitForPreheatStatus(handle, 'succeeded')).toMatchObject({ + status: 'succeeded', + durationMs: expect.any(Number), + }); + } finally { + await handle.close(); + } + }); + + it('tracks preheat failed state and error message for an internally-created bridge', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-preheat-')), + ); + const bridge = installInternalBridge(() => + Promise.reject(new Error('preheat boom')), + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { preheatBridge: true }, + ); + + try { + await waitForPreheatStatus(handle, 'failed'); + expect(bridge.preheat).toHaveBeenCalledTimes(1); + expect(await waitForPreheatStatus(handle, 'failed')).toMatchObject({ + status: 'failed', + durationMs: expect.any(Number), + error: 'preheat boom', + }); + } finally { + await handle.close(); + } + }); +}); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index c9d6c7b5417..2fe8855691f 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -5,84 +5,95 @@ */ import * as fs from 'node:fs'; -import { type Server } from 'node:http'; +import type { Server } from 'node:http'; import * as path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import express, { + type Application, + type NextFunction, + type Request, + type Response, +} from 'express'; import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; import type { BridgeEvent } from './event-bus.js'; import { getDeviceFlowRegistry } from './auth/device-flow.js'; import { - loadSettings, - reloadEnvironment, - SettingScope, -} from '../config/settings.js'; -import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; -import { - canonicalizeWorkspace, - createAcpSessionBridge, - type AcpSessionBridge, -} from './acp-session-bridge.js'; -import { - DEFAULT_OTLP_ENDPOINT, - DEFAULT_TELEMETRY_TARGET, - createDaemonBridgeTelemetry, - emitDaemonLog, - forceFlushMetrics, - hashDaemonWorkspace, - initializeDaemonMetrics, - initializeTelemetry, - recordDaemonCancel, - recordDaemonChannelLifecycle, - recordDaemonPromptDuration, - recordDaemonPromptQueueWait, - recordDaemonSessionLifecycle, - registerDaemonGaugeCallbacks, - findProviderById, - buildInstallPlan, - applyProviderInstallPlan, - resolveBaseUrl, - getDefaultModelIds, - resolveTelemetrySettings, - shutdownTelemetry, - type AuthType, - type ProviderSetupInputs, - type TelemetryRuntimeConfig, - type TelemetrySettings, + loadServeFastPathSettings, + preResolveServeFastPathHomeEnvOverrides, + type ServeFastPathSettings, +} from './fast-path-settings.js'; +import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; +import type { + AuthType, + ProviderSetupInputs, + TelemetryRuntimeConfig, + TelemetrySettings, } from '@qwen-code/qwen-code-core'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; -import { createDaemonStatusProvider } from './daemon-status-provider.js'; -import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; import { isLoopbackBind } from './loopback-binds.js'; -import { resolveWebShellDir } from './web-shell-static.js'; -import { parseAllowOriginPatterns } from './auth.js'; +import { resolveWebShellDir } from './web-shell-resolver.js'; +import { + allowOriginCors, + bearerAuth, + denyBrowserOriginCors, + hostAllowlist, + parseAllowOriginPatterns, +} from './auth.js'; import { createPermissionAuditPublisher, PermissionAuditRing, } from './permission-audit.js'; import { - createServeApp, - getActiveSseCount, - resolveBridgeFsFactory, -} from './server.js'; -import { initDaemonLogger, type DaemonLogger } from './daemon-logger.js'; -import { createSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel'; -import { createDaemonWorkspaceService } from './workspace-service/index.js'; -import { SERVE_CAPABILITY_REGISTRY } from './capabilities.js'; -import type { - ServeOptions, - ServeAuthProviderInstallRequest, - ServeAuthProviderInstallResult, + initDaemonLogger, + resolveDaemonLogBaseDir, + type DaemonLogger, +} from './daemon-logger.js'; +import { + getAdvertisedServeFeatures, + getServeProtocolVersions, + SERVE_CAPABILITY_REGISTRY, +} from './capabilities.js'; +import { + CAPABILITIES_SCHEMA_VERSION, + type CapabilitiesEnvelope, + type ServeAuthProviderInstallRequest, + type ServeAuthProviderInstallResult, + type ServeOptions, } from './types.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; import type { PermissionPolicy } from '@qwen-code/acp-bridge'; import { getCliVersion } from '../utils/version.js'; import { getRateLimiter } from './rate-limit.js'; import type { AcpHttpHandle } from './acp-http/index.js'; +import { + allowOriginMode, + listenerMaxConnections, + parseDaemonStatusDetail, + positiveFiniteOrNull, + type DaemonStatusIssue, + type DaemonStartupSnapshot, + type DaemonStatusResponse, +} from './daemon-status.js'; +import { + finalizeStartupProfile, + profileCheckpoint, +} from '../utils/startupProfiler.js'; const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN'; const QWEN_SERVE_PROMPT_DEADLINE_MS_ENV = 'QWEN_SERVE_PROMPT_DEADLINE_MS'; const QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS_ENV = 'QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS'; const SHUTDOWN_FORCE_CLOSE_MS = 5_000; +const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 120_000; +const RUNTIME_STARTUP_TIMEOUT_ENV = 'QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS'; +const MAX_EVENT_RING_SIZE = 1_000_000; +const DEFAULT_MAX_SESSIONS = 20; +const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; +const DEFAULT_EVENT_RING_SIZE = 8000; +const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000; +const WORKSPACE_SETTING_SCOPE = + 'Workspace' as import('../config/settings.js').SettingScope; function isPositiveIntegerMs(value: number): boolean { return Number.isFinite(value) && Number.isInteger(value) && value > 0; @@ -142,16 +153,20 @@ function createDaemonTelemetryRuntimeConfig( telemetry: TelemetrySettings, cliVersion: string, daemonSessionId: string, + defaults: { + otlpEndpoint: string; + telemetryTarget: NonNullable; + }, ): TelemetryRuntimeConfig { return { getTelemetryEnabled: () => telemetry.enabled ?? false, getTelemetryOtlpEndpoint: () => - telemetry.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, + telemetry.otlpEndpoint ?? defaults.otlpEndpoint, getTelemetryOtlpProtocol: () => telemetry.otlpProtocol ?? 'grpc', getTelemetryOtlpTracesEndpoint: () => telemetry.otlpTracesEndpoint, getTelemetryOtlpLogsEndpoint: () => telemetry.otlpLogsEndpoint, getTelemetryOtlpMetricsEndpoint: () => telemetry.otlpMetricsEndpoint, - getTelemetryTarget: () => telemetry.target ?? DEFAULT_TELEMETRY_TARGET, + getTelemetryTarget: () => telemetry.target ?? defaults.telemetryTarget, getTelemetryOutfile: () => telemetry.outfile, getTelemetryIncludeSensitiveSpanAttributes: () => telemetry.includeSensitiveSpanAttributes ?? false, @@ -204,7 +219,10 @@ export class InvalidPolicyConfigError extends Error { * source of truth) instead of repeating the four literals. */ export function validatePolicyConfig( - policyConfig: { permissionStrategy?: string; consensusQuorum?: number } = {}, + policyConfig: { + permissionStrategy?: unknown; + consensusQuorum?: unknown; + } = {}, onWarning: (message: string) => void = writeStderrLine, ): { permissionPolicy: PermissionPolicy | undefined; @@ -223,24 +241,28 @@ export function validatePolicyConfig( const validSet: ReadonlySet = new Set( SERVE_CAPABILITY_REGISTRY.permission_mediation.modes, ); + const permissionStrategy = policyConfig.permissionStrategy; + const consensusQuorum = policyConfig.consensusQuorum; if ( - policyConfig.permissionStrategy !== undefined && - !validSet.has(policyConfig.permissionStrategy) + permissionStrategy !== undefined && + (typeof permissionStrategy !== 'string' || + !validSet.has(permissionStrategy)) ) { throw new InvalidPolicyConfigError( `qwen serve: invalid policy.permissionStrategy ` + - `"${String(policyConfig.permissionStrategy)}"; must be one of ` + + `"${String(permissionStrategy)}"; must be one of ` + `${Array.from(validSet).join(', ')}`, ); } if ( - policyConfig.consensusQuorum !== undefined && - (!Number.isInteger(policyConfig.consensusQuorum) || - policyConfig.consensusQuorum < 1) + consensusQuorum !== undefined && + (typeof consensusQuorum !== 'number' || + !Number.isInteger(consensusQuorum) || + consensusQuorum < 1) ) { throw new InvalidPolicyConfigError( `qwen serve: invalid policy.consensusQuorum ` + - `${String(policyConfig.consensusQuorum)}; must be a positive integer`, + `${String(consensusQuorum)}; must be a positive integer`, ); } // When consensusQuorum is set but the active strategy doesn't @@ -248,12 +270,8 @@ export function validatePolicyConfig( // warning. Operators reading the warning at boot now see // consistent behavior all the way down. const consensusQuorumActive = - policyConfig.consensusQuorum !== undefined && - policyConfig.permissionStrategy === 'consensus'; - if ( - policyConfig.consensusQuorum !== undefined && - policyConfig.permissionStrategy !== 'consensus' - ) { + consensusQuorum !== undefined && permissionStrategy === 'consensus'; + if (consensusQuorum !== undefined && permissionStrategy !== 'consensus') { onWarning( 'qwen serve: policy.consensusQuorum is set but ' + 'policy.permissionStrategy is not "consensus"; the override will ' + @@ -261,11 +279,9 @@ export function validatePolicyConfig( ); } return { - permissionPolicy: policyConfig.permissionStrategy as - | PermissionPolicy - | undefined, + permissionPolicy: permissionStrategy as PermissionPolicy | undefined, permissionConsensusQuorum: consensusQuorumActive - ? policyConfig.consensusQuorum + ? consensusQuorum : undefined, }; } @@ -365,13 +381,22 @@ export interface RunHandle { * re-deriving it from argv/env. */ resolvedToken?: string; + /** Resolves when the full REST/Web/ACP runtime has been mounted. */ + runtimeReady: Promise; /** Resolves when the listener has fully closed and the bridge is drained. */ close(): Promise; } +type CoreRuntime = typeof import('@qwen-code/qwen-code-core'); +type ProviderConfig = NonNullable>; +type SettingsRuntime = typeof import('../config/settings.js'); +type LoadedSettingsAdapterRuntime = + typeof import('../config/loadedSettingsAdapter.js'); + function normalizeInstallModelIds( req: ServeAuthProviderInstallRequest, - provider: NonNullable>, + provider: ProviderConfig, + getDefaultModelIds: CoreRuntime['getDefaultModelIds'], ): string[] { const fromRequest = req.modelIds ?.map((id) => id.trim()) @@ -385,15 +410,23 @@ function normalizeInstallModelIds( function buildProviderSetupInputs( req: ServeAuthProviderInstallRequest, - provider: NonNullable>, + provider: ProviderConfig, + helpers: { + getDefaultModelIds: CoreRuntime['getDefaultModelIds']; + resolveBaseUrl: CoreRuntime['resolveBaseUrl']; + }, ): ProviderSetupInputs { const protocol = (req.protocol ?? provider.protocol) as AuthType; - const baseUrl = resolveBaseUrl(provider, req.baseUrl); + const baseUrl = helpers.resolveBaseUrl(provider, req.baseUrl); return { ...(provider.protocolOptions ? { protocol } : {}), baseUrl, apiKey: req.apiKey.trim(), - modelIds: normalizeInstallModelIds(req, provider), + modelIds: normalizeInstallModelIds( + req, + provider, + helpers.getDefaultModelIds, + ), ...(req.advancedConfig ? { advancedConfig: req.advancedConfig } : {}), }; } @@ -433,6 +466,28 @@ export interface RunQwenServeDeps { * audit emission stays visible in the operator log. */ fsAuditEmit?: (event: BridgeEvent) => void; + /** + * Lightweight settings summary already loaded by the serve fast path. + * Reusing it avoids a second pre-listen settings/env scan. + */ + bootSettings?: ServeFastPathSettings; + /** + * Pre-resolved daemon debug directory. The full CLI/exported API can pass + * Storage.getGlobalDebugDir(); the serve fast path intentionally avoids + * importing core before listen and instead derives this from bootSettings. + */ + daemonLogBaseDir?: string; + /** + * Internal CLI fast-path mode: resolve once the TCP listener is ready. + * The default preserves the embedded API contract by resolving only after + * the runtime bridge and routes are mounted. + */ + resolveOnListen?: boolean; + /** + * Bounds background runtime mounting after the listener is ready. Defaults to + * QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS, then 120s. Use 0 to disable. + */ + runtimeStartupTimeoutMs?: number; } function shouldPreheatBridge(deps: RunQwenServeDeps): boolean { @@ -440,6 +495,510 @@ function shouldPreheatBridge(deps: RunQwenServeDeps): boolean { return process.env['VITEST_WORKER_ID'] === undefined; } +let coreRuntimePromise: Promise | undefined; +function loadCoreRuntime(): Promise { + coreRuntimePromise ??= import('@qwen-code/qwen-code-core'); + return coreRuntimePromise; +} + +async function resolveDaemonLogBaseDirForRun(input: { + deps: RunQwenServeDeps; + bootSettings: ServeFastPathSettings | undefined; + boundWorkspace: string; +}): Promise { + if (input.deps.daemonLogBaseDir) { + return input.deps.daemonLogBaseDir; + } + if (input.deps.bootSettings === undefined) { + const core = await loadCoreRuntime(); + if (core.Storage.getRuntimeBaseDir() !== core.Storage.getGlobalQwenDir()) { + return core.Storage.getGlobalDebugDir(); + } + } + if (input.bootSettings?.advanced?.runtimeOutputDir !== undefined) { + return resolveDaemonLogBaseDir( + input.bootSettings.advanced.runtimeOutputDir, + input.boundWorkspace, + ); + } + if (input.deps.bootSettings !== undefined) { + return resolveDaemonLogBaseDir(undefined, input.boundWorkspace); + } + const core = await loadCoreRuntime(); + return core.Storage.getGlobalDebugDir(); +} + +let settingsRuntimePromise: + | Promise<{ + settings: SettingsRuntime; + loadedSettingsAdapter: LoadedSettingsAdapterRuntime; + }> + | undefined; +function loadSettingsRuntimeModules(): Promise<{ + settings: SettingsRuntime; + loadedSettingsAdapter: LoadedSettingsAdapterRuntime; +}> { + settingsRuntimePromise ??= Promise.all([ + import('../config/settings.js'), + import('../config/loadedSettingsAdapter.js'), + ]).then(([settings, loadedSettingsAdapter]) => ({ + settings, + loadedSettingsAdapter, + })); + return settingsRuntimePromise; +} + +async function loadServeRuntimeModules() { + const [ + serverModule, + bridgeModule, + spawnChannelModule, + workspaceModule, + daemonStatusProviderModule, + workspaceProvidersStatusModule, + ] = await Promise.all([ + import('./server.js'), + import('@qwen-code/acp-bridge/bridge'), + import('@qwen-code/acp-bridge/spawnChannel'), + import('./workspace-service/index.js'), + import('./daemon-status-provider.js'), + import('./workspace-providers-status.js'), + ]); + return { + createServeApp: serverModule.createServeApp, + getActiveSseCount: serverModule.getActiveSseCount, + resolveBridgeFsFactory: serverModule.resolveBridgeFsFactory, + createAcpSessionBridge: bridgeModule.createAcpSessionBridge, + createSpawnChannelFactory: spawnChannelModule.createSpawnChannelFactory, + createDaemonWorkspaceService: workspaceModule.createDaemonWorkspaceService, + createDaemonStatusProvider: + daemonStatusProviderModule.createDaemonStatusProvider, + createWorkspaceProvidersStatusProvider: + workspaceProvidersStatusModule.createWorkspaceProvidersStatusProvider, + }; +} + +function advertisedMaxSessions(value: number | undefined): number | null { + if (value === undefined) return DEFAULT_MAX_SESSIONS; + if (value === 0 || value === Number.POSITIVE_INFINITY) return null; + return value; +} + +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; +} + +function channelIdleTimeoutMs(value: number | undefined): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? Math.min(value, MAX_TIMEOUT_MS) + : 0; +} + +function sessionIdleTimeoutMs(value: number | undefined): number { + return value !== undefined + ? channelIdleTimeoutMs(value) + : DEFAULT_SESSION_IDLE_TIMEOUT_MS; +} + +function currentServeFeaturesForRunQwenServe( + opts: ServeOptions, + sessionShellCommandEnabled: boolean, +): string[] { + return getAdvertisedServeFeatures(undefined, { + requireAuth: opts.requireAuth === true, + mcpPoolActive: opts.mcpPoolActive !== false, + allowOriginActive: + opts.allowOrigins !== undefined && opts.allowOrigins.length > 0, + ...(opts.promptDeadlineMs !== undefined + ? { promptDeadlineMs: opts.promptDeadlineMs } + : {}), + ...(opts.writerIdleTimeoutMs !== undefined + ? { writerIdleTimeoutMs: opts.writerIdleTimeoutMs } + : {}), + persistSettingAvailable: true, + sessionShellCommandEnabled, + rateLimit: opts.rateLimit === true, + reloadAvailable: true, + }); +} + +function createBootstrapCapabilities(input: { + opts: ServeOptions; + boundWorkspace: string; + qwenCodeVersion?: string; + sessionShellCommandEnabled: boolean; + permissionPolicy: PermissionPolicy | undefined; +}): CapabilitiesEnvelope { + return { + v: CAPABILITIES_SCHEMA_VERSION, + protocolVersions: getServeProtocolVersions(), + ...(input.qwenCodeVersion + ? { qwenCodeVersion: input.qwenCodeVersion } + : {}), + mode: input.opts.mode, + features: currentServeFeaturesForRunQwenServe( + input.opts, + input.sessionShellCommandEnabled, + ), + modelServices: [], + workspaceCwd: input.boundWorkspace, + transports: ['rest'], + policy: { permission: input.permissionPolicy ?? 'first-responder' }, + limits: { + maxPendingPromptsPerSession: advertisedMaxPendingPromptsPerSession( + input.opts.maxPendingPromptsPerSession, + ), + }, + }; +} + +function validateRateLimitOptions(opts: ServeOptions): void { + if (opts.rateLimit !== true) return; + for (const [name, value] of [ + ['rateLimitPrompt', opts.rateLimitPrompt], + ['rateLimitMutation', opts.rateLimitMutation], + ['rateLimitRead', opts.rateLimitRead], + ] as const) { + if ( + value !== undefined && + (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) + ) { + throw new TypeError( + `Invalid ${name}: ${value}. Must be a positive integer.`, + ); + } + } + if ( + opts.rateLimitWindowMs !== undefined && + (!Number.isFinite(opts.rateLimitWindowMs) || + !Number.isInteger(opts.rateLimitWindowMs) || + opts.rateLimitWindowMs < 1000) + ) { + throw new TypeError( + `Invalid rateLimitWindowMs: ${opts.rateLimitWindowMs}. Must be an integer >= 1000.`, + ); + } +} + +function installSameOriginOriginStrip( + app: Application, + getPort: () => number, +): void { + let cachedStripPort = -1; + let cachedSelfOrigins: Set = new Set(); + app.use((req: Request, _res: Response, next: NextFunction) => { + 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(); + }); +} + +export function createLazyBridgeProxy( + getBridge: () => AcpSessionBridge | undefined, + getStartupError: () => string | undefined = () => undefined, +): AcpSessionBridge { + return new Proxy( + {}, + { + get(_target, prop) { + const bridge = getBridge(); + if (!bridge) { + const startupError = getStartupError(); + if (startupError) { + throw new Error( + `Daemon bridge runtime is not available: ${startupError}`, + ); + } + throw new Error('Daemon bridge runtime is still starting.'); + } + const value = Reflect.get(bridge, prop, bridge) as unknown; + return typeof value === 'function' ? value.bind(bridge) : value; + }, + }, + ) as AcpSessionBridge; +} + +export function resolveRuntimeStartupTimeoutMs( + override: number | undefined, +): number { + if (override !== undefined) { + return Number.isFinite(override) && override > 0 ? override : 0; + } + const raw = process.env[RUNTIME_STARTUP_TIMEOUT_ENV]; + if (raw === undefined || raw.trim() === '') { + return DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS; + } + const trimmed = raw.trim(); + if (trimmed === '0') return 0; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed > 0 + ? parsed + : DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS; +} + +export async function waitForRuntimeStartingForShutdown( + runtimeStarting: Promise | undefined, + daemonLog: Pick, + timeoutMs = SHUTDOWN_FORCE_CLOSE_MS, +): Promise { + if (!runtimeStarting) return; + + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + runtimeStarting, + new Promise((resolve) => { + timer = setTimeout(() => { + daemonLog.warn( + `${timeoutMs}ms runtime-startup wait reached during shutdown; continuing listener close`, + ); + resolve(); + }, timeoutMs); + timer.unref(); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +function createBootstrapServeApp(input: { + opts: ServeOptions; + getPort: () => number; + boundWorkspace: string; + startup: DaemonStartupSnapshot; + daemonLog: DaemonLogger; + qwenCodeVersion?: string; + sessionShellCommandEnabled: boolean; + permissionPolicy: PermissionPolicy | undefined; + getRuntimeError: () => string | undefined; +}): Application { + const { + opts, + getPort, + boundWorkspace, + startup, + daemonLog, + qwenCodeVersion, + sessionShellCommandEnabled, + permissionPolicy, + getRuntimeError, + } = input; + const app = express(); + + installSameOriginOriginStrip(app, getPort); + if (opts.allowOrigins && opts.allowOrigins.length > 0) { + app.use(allowOriginCors(parseAllowOriginPatterns(opts.allowOrigins))); + } else { + app.use(denyBrowserOriginCors); + } + app.use(hostAllowlist(opts.hostname, getPort)); + + const healthHandler = (_req: Request, res: Response): void => { + const runtimeError = getRuntimeError(); + if (runtimeError !== undefined) { + res.status(503).json({ + status: 'degraded', + error: runtimeError, + }); + return; + } + + res.status(200).json({ status: 'ok' }); + }; + const loopback = isLoopbackBind(opts.hostname); + const exposeHealthPreAuth = loopback && !opts.requireAuth; + if (exposeHealthPreAuth) { + app.get('/health', healthHandler); + } + + app.use(bearerAuth(opts.token)); + + if (!exposeHealthPreAuth) { + app.get('/health', healthHandler); + } + + app.get('/capabilities', (_req: Request, res: Response): void => { + res.status(200).json( + createBootstrapCapabilities({ + opts, + boundWorkspace, + qwenCodeVersion, + sessionShellCommandEnabled, + permissionPolicy, + }), + ); + }); + + app.get('/daemon/status', (req: Request, res: Response): void => { + const detail = parseDaemonStatusDetail(req.query['detail']); + if (!detail.ok || !detail.detail) { + res.status(400).json({ + error: 'detail must be one of: summary, full', + code: 'invalid_detail', + }); + return; + } + const runtimeError = getRuntimeError(); + const runtimeFailed = runtimeError !== undefined; + const issue: DaemonStatusIssue = runtimeError + ? { + code: 'daemon_runtime_failed', + severity: 'error', + message: runtimeError, + } + : { + code: 'daemon_runtime_starting', + severity: 'warning', + message: 'Daemon runtime is still starting.', + }; + const response: DaemonStatusResponse = { + v: 1, + detail: detail.detail, + generatedAt: new Date().toISOString(), + status: runtimeFailed ? 'error' : 'warning', + issues: [issue], + daemon: { + pid: process.pid, + uptimeMs: Math.round(process.uptime() * 1000), + mode: opts.mode, + workspaceCwd: boundWorkspace, + startup: { + ...startup, + preheat: { ...startup.preheat }, + }, + ...(qwenCodeVersion ? { qwenCodeVersion } : {}), + ...(daemonLog.getDaemonId() + ? { daemonId: daemonLog.getDaemonId() } + : {}), + ...(detail.detail === 'full' && daemonLog.getLogPath() + ? { logPath: daemonLog.getLogPath() } + : {}), + }, + security: { + tokenConfigured: Boolean(opts.token), + requireAuth: opts.requireAuth === true, + loopbackBind: loopback, + allowOriginConfigured: + opts.allowOrigins !== undefined && opts.allowOrigins.length > 0, + allowOriginMode: allowOriginMode(opts.allowOrigins), + sessionShellCommandEnabled, + }, + limits: { + maxSessions: advertisedMaxSessions(opts.maxSessions), + maxPendingPromptsPerSession: advertisedMaxPendingPromptsPerSession( + opts.maxPendingPromptsPerSession, + ), + listenerMaxConnections: listenerMaxConnections(opts.maxConnections), + eventRingSize: opts.eventRingSize ?? DEFAULT_EVENT_RING_SIZE, + promptDeadlineMs: positiveFiniteOrNull(opts.promptDeadlineMs), + writerIdleTimeoutMs: positiveFiniteOrNull(opts.writerIdleTimeoutMs), + channelIdleTimeoutMs: channelIdleTimeoutMs(opts.channelIdleTimeoutMs), + sessionIdleTimeoutMs: sessionIdleTimeoutMs(opts.sessionIdleTimeoutMs), + acpConnectionCap: null, + }, + capabilities: { + protocolVersions: getServeProtocolVersions(), + features: currentServeFeaturesForRunQwenServe( + opts, + sessionShellCommandEnabled, + ), + }, + runtime: { + loading: runtimeError === undefined, + ...(runtimeError ? { error: runtimeError } : {}), + sessions: { active: 0 }, + permissions: { + pending: 0, + policy: permissionPolicy ?? 'first-responder', + }, + channel: { live: false }, + transport: { + restSseActive: 0, + acp: { + enabled: false, + connections: 0, + connectionStreams: 0, + sessionStreams: 0, + sseStreams: 0, + wsStreams: 0, + pendingClientRequests: 0, + }, + }, + rateLimit: { + enabled: opts.rateLimit === true, + rejectedSinceStart: { + prompt: 0, + mutation: 0, + read: 0, + }, + }, + process: process.memoryUsage(), + }, + ...(detail.detail === 'full' + ? { + full: { + sessions: [], + acpConnections: [], + workspace: {}, + auth: { + supportedDeviceFlowProviders: [], + pendingDeviceFlowCount: 0, + }, + }, + } + : {}), + }; + + res.status(200).json(response); + }); + + app.use((_req: Request, res: Response): void => { + const runtimeError = getRuntimeError(); + res.status(503).json({ + error: runtimeError + ? 'Daemon runtime failed to start' + : 'Daemon runtime is still starting', + code: runtimeError ? 'daemon_runtime_failed' : 'daemon_runtime_starting', + }); + }); + + return app; +} + +function createDelegatingServeApp( + bootstrapApp: Application, + getRuntimeApp: () => Application | undefined, +): Application { + const app = express(); + app.use((req: Request, res: Response, next: NextFunction) => { + const target = getRuntimeApp() ?? bootstrapApp; + const handler = target as unknown as ( + req: Request, + res: Response, + next: NextFunction, + ) => void; + handler(req, res, next); + }); + return app; +} + /** * Validate options + start the listener. Resolves once the server is ready * to accept connections. @@ -455,6 +1014,22 @@ export async function runQwenServe( optsIn: Omit & { token?: string }, deps: RunQwenServeDeps = {}, ): Promise { + const runStartedAt = performance.now(); + const shouldPreheat = !deps.bridge && shouldPreheatBridge(deps); + const startup: DaemonStartupSnapshot = { + processStartedAt: new Date( + Date.now() - Math.round(process.uptime() * 1000), + ).toISOString(), + preheat: { + status: deps.bridge + ? 'external_bridge' + : shouldPreheat + ? 'scheduled' + : 'not_scheduled', + }, + }; + preResolveServeFastPathHomeEnvOverrides(); + // Trim both sources. Common gotcha: `export QWEN_SERVER_TOKEN=$(cat // token.txt)` keeps the file's trailing `\n` in the env value, so the // hashed-then-compared token never matches what well-behaved clients @@ -497,6 +1072,7 @@ export async function runQwenServe( promptDeadlineMs, writerIdleTimeoutMs, }; + validateRateLimitOptions(opts); // Catch the `--hostname localhost:4170` / `127.0.0.1:4170` // typo BEFORE the loopback / token check so the operator sees a @@ -636,9 +1212,49 @@ export async function runQwenServe( // `/capabilities` but another on `POST /session` responses. const boundWorkspace = canonicalizeWorkspace(rawWorkspace); + // Read a lightweight settings summary once at boot for startup-time fields + // used before the full runtime settings loader is allowed onto the hot path. + let contextFilenameForInit: string | undefined; + let permissionPolicy: PermissionPolicy | undefined; + let permissionConsensusQuorum: number | undefined; + let bootSettings: ServeFastPathSettings | undefined; + try { + bootSettings = + deps.bootSettings ?? loadServeFastPathSettings(boundWorkspace); + contextFilenameForInit = extractContextFilename( + bootSettings.context?.fileName, + ); + const policyConfig = bootSettings.policy ?? {}; + const resolved = validatePolicyConfig(policyConfig); + permissionPolicy = resolved.permissionPolicy; + permissionConsensusQuorum = resolved.permissionConsensusQuorum; + } catch (err) { + // Invalid policy values must fail startup loudly. Discriminate by + // error class rather than substring-matching the message. + if (err instanceof InvalidPolicyConfigError) { + throw err; + } + // All other settings-read failures (corrupted JSON, transient + // disk IO) fall back to defaults so the daemon stays bootable. + writeStderrLine( + `qwen serve: could not read settings for context.fileName / ` + + `policy.* (${err instanceof Error ? err.message : String(err)}); ` + + `falling back to defaults. Restart with a valid settings.json ` + + `to apply context.fileName / policy.* overrides.`, + ); + } + // Init daemon logger early so all subsequent lifecycle events // (bridge spawn diagnostics, shutdown errors) are captured to file. - const daemonLog: DaemonLogger = initDaemonLogger({ boundWorkspace }); + const daemonLogBaseDir = await resolveDaemonLogBaseDirForRun({ + deps, + bootSettings, + boundWorkspace, + }); + const daemonLog: DaemonLogger = initDaemonLogger({ + boundWorkspace, + baseDir: daemonLogBaseDir, + }); writeStderrLine( `qwen serve: daemon log → ${daemonLog.getLogPath() || '(disabled)'}`, ); @@ -679,6 +1295,14 @@ export async function runQwenServe( } assertTimerDelayInRange('promptDeadlineMs', opts.promptDeadlineMs); } + if (opts.maxSessions !== undefined) { + if (Number.isNaN(opts.maxSessions) || opts.maxSessions < 0) { + throw new TypeError( + `Invalid maxSessions: ${opts.maxSessions}. Must be a number >= 0 ` + + `(0 / Infinity = unlimited).`, + ); + } + } if (opts.maxPendingPromptsPerSession !== undefined) { if (!isNonNegativeIntegerOrInfinity(opts.maxPendingPromptsPerSession)) { throw new TypeError( @@ -686,6 +1310,18 @@ export async function runQwenServe( ); } } + if (opts.eventRingSize !== undefined) { + if ( + !Number.isInteger(opts.eventRingSize) || + opts.eventRingSize < 1 || + opts.eventRingSize > MAX_EVENT_RING_SIZE + ) { + throw new TypeError( + `Invalid eventRingSize: ${opts.eventRingSize}. ` + + `Must be a positive integer in [1, ${MAX_EVENT_RING_SIZE}].`, + ); + } + } if (opts.writerIdleTimeoutMs !== undefined) { if (!isPositiveIntegerMs(opts.writerIdleTimeoutMs)) { throw new TypeError( @@ -757,270 +1393,11 @@ export async function runQwenServe( QWEN_SERVE_MCP_BUDGET_MODE: opts.mcpBudgetMode, }; - // Read settings once at boot for the workspace context filename and - // policy fields (permissionStrategy / consensusQuorum). Wrap in - // try/catch so a corrupted settings.json doesn't block daemon boot - // — context filename falls back to the bridge's default; policy - // validation rethrows because invalid policy is an explicit operator - // misconfiguration. - let contextFilenameForInit: string | undefined; - let permissionPolicy: PermissionPolicy | undefined; - let permissionConsensusQuorum: number | undefined; - let bootSettings: ReturnType | undefined; - try { - bootSettings = loadSettings(boundWorkspace); - contextFilenameForInit = extractContextFilename( - bootSettings.merged.context?.fileName, - ); - const policyConfig = - ( - bootSettings.merged as { - policy?: { - permissionStrategy?: string; - consensusQuorum?: number; - }; - } - ).policy ?? {}; - const resolved = validatePolicyConfig(policyConfig); - permissionPolicy = resolved.permissionPolicy; - permissionConsensusQuorum = resolved.permissionConsensusQuorum; - } catch (err) { - // Invalid policy values must fail startup loudly. Discriminate by - // error class rather than substring-matching the message. - if (err instanceof InvalidPolicyConfigError) { - throw err; - } - // All other settings-read failures (corrupted JSON, transient - // disk IO) fall back to defaults so the daemon stays bootable. - writeStderrLine( - `qwen serve: could not read settings for context.fileName / ` + - `policy.* (${err instanceof Error ? err.message : String(err)}); ` + - `falling back to defaults. Restart with a valid settings.json ` + - `to apply context.fileName / policy.* overrides.`, - ); - } - - const daemonWorkspaceHash = hashDaemonWorkspace(boundWorkspace); - const daemonTelemetrySettings = await resolveTelemetrySettings({ - env: process.env, - settings: bootSettings?.merged.telemetry, - }); const cliVersion = await getCliVersion(); - initializeTelemetry( - createDaemonTelemetryRuntimeConfig( - daemonTelemetrySettings, - cliVersion, - `daemon:${daemonWorkspaceHash}:${process.pid}`, - ), - ); - initializeDaemonMetrics(); - const daemonTelemetry = createDaemonBridgeTelemetry(); - daemonTelemetry.metrics = { - sessionLifecycle(action) { - recordDaemonSessionLifecycle(action); - emitDaemonLog( - `Session ${action}.`, - { - 'qwen-code.workspace.hash': daemonWorkspaceHash, - }, - { - eventName: `qwen-code.daemon.session.${action}`, - }, - ); - }, - channelLifecycle(action, expected) { - recordDaemonChannelLifecycle(action, expected); - emitDaemonLog( - action === 'spawn' - ? 'ACP channel spawned.' - : `ACP channel exited (expected=${expected ?? true}).`, - { - ...(action === 'exit' - ? { 'qwen-code.daemon.channel.expected': expected ?? true } - : {}), - }, - { - eventName: `qwen-code.daemon.channel.${action}`, - ...(expected === false && action === 'exit' - ? { severityNumber: 13 } - : {}), - }, - ); - }, - promptQueueWait: recordDaemonPromptQueueWait, - promptDuration: recordDaemonPromptDuration, - cancelled: recordDaemonCancel, - }; - - // Allocate the audit ring + publisher in the daemon host (here) - // rather than inside the bridge factory, because the ring is the - // seam for exposing `GET /workspace/permission/audit` in the - // future. - const permissionAuditRing = new PermissionAuditRing(); - const permissionAuditPublisher = createPermissionAuditPublisher({ - ring: permissionAuditRing, - }); - // Construct `fsFactory` BEFORE the bridge so the bridge can wire it - // through `BridgeFileSystem` for ACP-side writeTextFile / readTextFile - // calls. See `bridge-file-system-adapter.ts` for the translation layer. const trustedWorkspace = deps.trustedWorkspace ?? true; - const customIgnoreFiles = - bootSettings?.merged.context?.fileFiltering?.customIgnoreFiles; - const fsFactory = resolveBridgeFsFactory({ - boundWorkspace, - injected: deps.fsFactory, - trusted: trustedWorkspace, - emit: deps.fsAuditEmit, - ...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}), - }); - - // Create a spawn channel factory that tees child-stderr diagnostics - // into the daemon log file (file-only, no duplicate stderr write). const diagnosticSink = (line: string, level?: 'info' | 'warn' | 'error') => daemonLog.raw(line, level); - const channelFactory = createSpawnChannelFactory({ - onDiagnosticLine: diagnosticSink, - ...(opts.experimentalLsp === true - ? { extraArgs: ['--experimental-lsp'] } - : {}), - }); - - const persistDisabledToolsFn = ( - workspace: string, - toolName: string, - enabled: boolean, - ): Promise => - withSettingsLock(workspace, async () => { - const fresh = loadSettings(workspace); - const wsScope = fresh.forScope(SettingScope.Workspace).settings; - const wsDisabled = wsScope.tools?.disabled; - const current = Array.isArray(wsDisabled) - ? wsDisabled.filter((v): v is string => typeof v === 'string') - : []; - const next = new Set(current); - if (enabled) next.delete(toolName); - else next.add(toolName); - fresh.setValue( - SettingScope.Workspace, - 'tools.disabled', - [...next].sort(), - ); - }); - - // Create the status provider once — shared between bridge and workspace - // service so both answer env/preflight cells from the same daemon-local - // implementation. - const statusProvider = createDaemonStatusProvider(); - const workspaceProvidersStatusProvider = - createWorkspaceProvidersStatusProvider(); - - const bridge = - deps.bridge ?? - createAcpSessionBridge({ - maxSessions: opts.maxSessions, - ...(opts.maxPendingPromptsPerSession !== undefined - ? { maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession } - : {}), - ...(opts.eventRingSize !== undefined - ? { eventRingSize: opts.eventRingSize } - : {}), - ...(opts.channelIdleTimeoutMs !== undefined - ? { channelIdleTimeoutMs: opts.channelIdleTimeoutMs } - : {}), - ...(opts.sessionReapIntervalMs !== undefined - ? { sessionReapIntervalMs: opts.sessionReapIntervalMs } - : {}), - ...(opts.sessionIdleTimeoutMs !== undefined - ? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs } - : {}), - ...(opts.permissionResponseTimeoutMs !== undefined - ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } - : {}), - boundWorkspace, - sessionShellCommandEnabled, - childEnvOverrides, - channelFactory, - onDiagnosticLine: diagnosticSink, - telemetry: daemonTelemetry, - // Wire the validated policy/quorum from settings into the - // bridge. - ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), - ...(permissionConsensusQuorum !== undefined - ? { permissionConsensusQuorum } - : {}), - permissionAudit: permissionAuditPublisher, - // #4175 PR 22b/2: inject the daemon-host status provider so the - // bridge can pull env / preflight cells through a typed seam - // instead of importing daemon-host helpers directly. Production - // implementation wraps `buildEnvStatusFromProcess` and the - // (lifted) `buildDaemonPreflightCells` body. - statusProvider, - // F1 follow-up (#4319): inject the WorkspaceFileSystem adapter so - // agent ACP `writeTextFile` / `readTextFile` calls go through - // PR 18's defensive fs layer (trust gate + atomic write + symlink - // resolution + audit emit) instead of `BridgeClient`'s inline - // raw-fs proxy. Closes the `ws.ts:613` follow-up thread. - fileSystem: createBridgeFileSystemAdapter(fsFactory), - // #4175 Wave 4 PR 17: `POST /session/:id/approval-mode` accepts - // an opt-in `persist: true` flag. We re-load settings on each - // persist call rather than caching a `LoadedSettings` handle — - // another writer (CLI, another daemon, an editor) could have - // touched the file between calls, so the freshest state wins - // over a stale in-memory cache. - // - // #4282 fold-in 4 (qwen-latest C2): both persist callbacks run - // through `withSettingsLock` — a per-workspace promise chain that - // serializes the read-modify-write cycle. Without the lock, two - // concurrent `POST /workspace/tools/:name/enable` requests could - // both read the same pre-modification state and the second write - // would silently overwrite the first toggle, leaving the disk - // copy out of sync with the SDK reducer's view. The lock costs - // one tick of latency per call but eliminates the lost-update - // window for the entire process; cross-daemon races against the - // same workspace file remain (rare; documented). - persistApprovalMode: (workspace, mode) => - withSettingsLock(workspace, async () => { - const fresh = loadSettings(workspace); - fresh.setValue(SettingScope.Workspace, 'tools.approvalMode', mode); - }), - }); - - // Construct the DaemonWorkspaceService AFTER the bridge so it can - // close over the bridge's generic delegation methods. This service - // owns workspace-scoped status queries, tool toggle, init, and MCP - // restart — routes in server.ts delegate here instead of reaching - // into the bridge for workspace concerns. - const workspaceService = createDaemonWorkspaceService({ - boundWorkspace, - contextFilename: contextFilenameForInit ?? 'QWEN.md', - // Daemon-host status provider for env + preflight cells. - statusProvider, - workspaceProvidersStatusProvider, - // Channel liveness check — proxied through the bridge's live-channel - // probe (not session count: a channel can be live with zero attached - // sessions during the cold-spawn window). - isChannelLive: () => bridge.isChannelLive(), - persistDisabledTools: persistDisabledToolsFn, - reloadDaemonEnv: (workspace) => - withSettingsLock(workspace, async () => { - const fresh = loadSettings(workspace, { skipLoadEnvironment: true }); - return reloadEnvironment(fresh.merged, workspace); - }), - queryWorkspaceStatus: (method, idle) => - bridge.queryWorkspaceStatus(method, idle), - invokeWorkspaceCommand: (method, params, invokeOpts) => - bridge.invokeWorkspaceCommand(method, params, invokeOpts), - refreshExtensionsForAllSessions: () => - bridge.refreshExtensionsForAllSessions(), - publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), - }); - - registerDaemonGaugeCallbacks({ - sessionCount: () => bridge.sessionCount, - sseCount: () => getActiveSseCount(), - heapUsed: () => process.memoryUsage().heapUsed, - }); let actualPort = opts.port; @@ -1065,71 +1442,303 @@ export async function runQwenServe( // webShellDir is already undefined whenever serveWebShell === false, so this // collapses to "did we resolve real assets". const webShellMounted = !!webShellDir; + let runtimeApp: Application | undefined; + let runtimeAppForCleanup: Application | undefined; + let bridgeRef: AcpSessionBridge | undefined = deps.bridge; + let runtimeStartupError: string | undefined; + let runtimeStarting: Promise | undefined; + let markRuntimeReady!: () => void; + let markRuntimeFailed!: (err: Error) => void; + let runtimeStartupSettled = false; + const runtimeReady = new Promise((resolve, reject) => { + markRuntimeReady = resolve; + markRuntimeFailed = reject; + }); + void runtimeReady.catch(() => {}); - // Pass the already-canonical `boundWorkspace` into `createServeApp` - // via `deps.boundWorkspace`. That field is the pre-canonicalized - // fast-path: createServeApp skips its own `canonicalizeWorkspace` - // call (which would issue a redundant `realpathSync.native` - // syscall — idempotent but unnecessary I/O at boot). Direct - // callers of createServeApp (tests / embeds) omit it and the - // server canonicalizes itself. - // - // `fsFactory` is constructed above (before the bridge) so the - // bridge can wire it through `BridgeFileSystem`. The HTTP read - // routes and ACP fs calls share the same factory instance. - const app = createServeApp(opts, () => actualPort, { - bridge, - webShellDir, - boundWorkspace, - qwenCodeVersion: cliVersion, - fsFactory, - daemonLog, - workspace: workspaceService, - persistDisabledTools: persistDisabledToolsFn, - persistSetting: (workspace, scope, key, value) => + const handleBridge = + deps.bridge ?? + createLazyBridgeProxy( + () => bridgeRef, + () => runtimeStartupError, + ); + + const buildRuntime = async (): Promise<{ + app: Application; + bridge: AcpSessionBridge; + }> => { + const [runtime, core, settingsRuntime] = await Promise.all([ + loadServeRuntimeModules(), + loadCoreRuntime(), + loadSettingsRuntimeModules(), + ]); + let runtimeBootSettings: + | ReturnType + | undefined; + try { + runtimeBootSettings = + settingsRuntime.settings.loadSettings(boundWorkspace); + } catch (err) { + writeStderrLine( + `qwen serve: could not read full settings for runtime startup ` + + `(${err instanceof Error ? err.message : String(err)}); falling back to defaults.`, + ); + } + const daemonWorkspaceHash = core.hashDaemonWorkspace(boundWorkspace); + const daemonTelemetrySettings = await core.resolveTelemetrySettings({ + env: process.env, + settings: runtimeBootSettings?.merged.telemetry, + }); + core.initializeTelemetry( + createDaemonTelemetryRuntimeConfig( + daemonTelemetrySettings, + cliVersion, + `daemon:${daemonWorkspaceHash}:${process.pid}`, + { + otlpEndpoint: core.DEFAULT_OTLP_ENDPOINT, + telemetryTarget: core.DEFAULT_TELEMETRY_TARGET, + }, + ), + ); + core.initializeDaemonMetrics(); + const daemonTelemetry = core.createDaemonBridgeTelemetry(); + daemonTelemetry.metrics = { + sessionLifecycle(action) { + core.recordDaemonSessionLifecycle(action); + core.emitDaemonLog( + `Session ${action}.`, + { + 'qwen-code.workspace.hash': daemonWorkspaceHash, + }, + { + eventName: `qwen-code.daemon.session.${action}`, + }, + ); + }, + channelLifecycle(action, expected) { + core.recordDaemonChannelLifecycle(action, expected); + core.emitDaemonLog( + action === 'spawn' + ? 'ACP channel spawned.' + : `ACP channel exited (expected=${expected ?? true}).`, + { + ...(action === 'exit' + ? { 'qwen-code.daemon.channel.expected': expected ?? true } + : {}), + }, + { + eventName: `qwen-code.daemon.channel.${action}`, + ...(expected === false && action === 'exit' + ? { severityNumber: 13 } + : {}), + }, + ); + }, + promptQueueWait: core.recordDaemonPromptQueueWait, + promptDuration: core.recordDaemonPromptDuration, + cancelled: core.recordDaemonCancel, + }; + // Allocate the audit ring + publisher in the daemon host (here) + // rather than inside the bridge factory, because the ring is the + // seam for exposing `GET /workspace/permission/audit` in the future. + const permissionAuditRing = new PermissionAuditRing(); + const permissionAuditPublisher = createPermissionAuditPublisher({ + ring: permissionAuditRing, + }); + const customIgnoreFiles = + runtimeBootSettings?.merged.context?.fileFiltering?.customIgnoreFiles; + const fsFactory = runtime.resolveBridgeFsFactory({ + boundWorkspace, + injected: deps.fsFactory, + trusted: trustedWorkspace, + emit: deps.fsAuditEmit, + ...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}), + }); + const channelFactory = runtime.createSpawnChannelFactory({ + onDiagnosticLine: diagnosticSink, + ...(opts.experimentalLsp === true + ? { extraArgs: ['--experimental-lsp'] } + : {}), + }); + const statusProvider = runtime.createDaemonStatusProvider(); + const workspaceProvidersStatusProvider = + runtime.createWorkspaceProvidersStatusProvider(); + const persistDisabledToolsFn = ( + workspace: string, + toolName: string, + enabled: boolean, + ): Promise => withSettingsLock(workspace, async () => { - const fresh = loadSettings(workspace); - fresh.setValue(scope, key, value); - return fresh; - }), - installAuthProvider: (req) => - withSettingsLock( + const fresh = settingsRuntime.settings.loadSettings(workspace); + const wsScope = fresh.forScope(WORKSPACE_SETTING_SCOPE).settings; + const wsDisabled = wsScope.tools?.disabled; + const current = Array.isArray(wsDisabled) + ? wsDisabled.filter((v): v is string => typeof v === 'string') + : []; + const next = new Set(current); + if (enabled) next.delete(toolName); + else next.add(toolName); + fresh.setValue( + WORKSPACE_SETTING_SCOPE, + 'tools.disabled', + [...next].sort(), + ); + }); + const bridge = + deps.bridge ?? + runtime.createAcpSessionBridge({ + maxSessions: opts.maxSessions, + ...(opts.maxPendingPromptsPerSession !== undefined + ? { maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession } + : {}), + ...(opts.eventRingSize !== undefined + ? { eventRingSize: opts.eventRingSize } + : {}), + ...(opts.channelIdleTimeoutMs !== undefined + ? { channelIdleTimeoutMs: opts.channelIdleTimeoutMs } + : {}), + ...(opts.sessionReapIntervalMs !== undefined + ? { sessionReapIntervalMs: opts.sessionReapIntervalMs } + : {}), + ...(opts.sessionIdleTimeoutMs !== undefined + ? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs } + : {}), + ...(opts.permissionResponseTimeoutMs !== undefined + ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } + : {}), boundWorkspace, - async (): Promise => { - const provider = findProviderById(req.providerId); - if (!provider) { - throw new Error(`Unsupported auth provider: ${req.providerId}`); - } - const inputs = buildProviderSetupInputs(req, provider); - const plan = buildInstallPlan(provider, inputs); - const fresh = loadSettings(boundWorkspace); - await applyProviderInstallPlan(plan, { - settings: createLoadedSettingsAdapter(fresh), - doRefreshAuth: false, - }); - emitDaemonLog('Auth provider installed.', { - 'qwen-code.daemon.auth.provider_id': provider.id, - 'qwen-code.daemon.auth.auth_type': plan.authType, + sessionShellCommandEnabled, + childEnvOverrides, + channelFactory, + onDiagnosticLine: diagnosticSink, + telemetry: daemonTelemetry, + ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), + ...(permissionConsensusQuorum !== undefined + ? { permissionConsensusQuorum } + : {}), + permissionAudit: permissionAuditPublisher, + statusProvider, + fileSystem: createBridgeFileSystemAdapter(fsFactory), + persistApprovalMode: (workspace, mode) => + withSettingsLock(workspace, async () => { + const fresh = settingsRuntime.settings.loadSettings(workspace); + fresh.setValue(WORKSPACE_SETTING_SCOPE, 'tools.approvalMode', mode); + }), + }); + if (!deps.bridge) { + bridgeRef = bridge; + } + const workspaceService = runtime.createDaemonWorkspaceService({ + boundWorkspace, + contextFilename: contextFilenameForInit ?? 'QWEN.md', + statusProvider, + workspaceProvidersStatusProvider, + isChannelLive: () => bridge.isChannelLive(), + persistDisabledTools: persistDisabledToolsFn, + reloadDaemonEnv: (workspace) => + withSettingsLock(workspace, async () => { + const fresh = settingsRuntime.settings.loadSettings(workspace, { + skipLoadEnvironment: true, }); - return { - v: 1, - providerId: provider.id, - providerLabel: provider.label, - authType: plan.authType, - ...(plan.modelSelection?.modelId - ? { modelId: plan.modelSelection.modelId } - : {}), - ...(inputs.baseUrl ? { baseUrl: inputs.baseUrl } : {}), - message: `Successfully configured ${provider.label}. Use /model to switch models.`, - }; - }, - ), + return settingsRuntime.settings.reloadEnvironment( + fresh.merged, + workspace, + ); + }), + queryWorkspaceStatus: (method, idle) => + bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, invokeOpts) => + bridge.invokeWorkspaceCommand(method, params, invokeOpts), + refreshExtensionsForAllSessions: () => + bridge.refreshExtensionsForAllSessions(), + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + }); + + core.registerDaemonGaugeCallbacks({ + sessionCount: () => bridge.sessionCount, + sseCount: () => runtime.getActiveSseCount(), + heapUsed: () => process.memoryUsage().heapUsed, + }); + + const app = runtime.createServeApp(opts, () => actualPort, { + bridge, + webShellDir, + boundWorkspace, + qwenCodeVersion: cliVersion, + startup, + fsFactory, + daemonLog, + workspace: workspaceService, + persistDisabledTools: persistDisabledToolsFn, + persistSetting: (workspace, scope, key, value) => + withSettingsLock(workspace, async () => { + const fresh = settingsRuntime.settings.loadSettings(workspace); + fresh.setValue(scope, key, value); + }), + installAuthProvider: (req) => + withSettingsLock( + boundWorkspace, + async (): Promise => { + const provider = core.findProviderById(req.providerId); + if (!provider) { + throw new Error(`Unsupported auth provider: ${req.providerId}`); + } + const inputs = buildProviderSetupInputs(req, provider, { + getDefaultModelIds: core.getDefaultModelIds, + resolveBaseUrl: core.resolveBaseUrl, + }); + const plan = core.buildInstallPlan(provider, inputs); + const fresh = settingsRuntime.settings.loadSettings(boundWorkspace); + await core.applyProviderInstallPlan(plan, { + settings: + settingsRuntime.loadedSettingsAdapter.createLoadedSettingsAdapter( + fresh, + ), + doRefreshAuth: false, + }); + core.emitDaemonLog('Auth provider installed.', { + 'qwen-code.daemon.auth.provider_id': provider.id, + 'qwen-code.daemon.auth.auth_type': plan.authType, + }); + return { + v: 1, + providerId: provider.id, + providerLabel: provider.label, + authType: plan.authType, + ...(plan.modelSelection?.modelId + ? { modelId: plan.modelSelection.modelId } + : {}), + ...(inputs.baseUrl ? { baseUrl: inputs.baseUrl } : {}), + message: `Successfully configured ${provider.label}. Use /model to switch models.`, + }; + }, + ), + }); + return { app, bridge }; + }; + + if (deps.bridge) { + const runtime = await buildRuntime(); + runtimeAppForCleanup = runtime.app; + runtimeApp = runtime.app; + bridgeRef = runtime.bridge; + runtimeStartupSettled = true; + markRuntimeReady(); + } + + const bootstrapApp = createBootstrapServeApp({ + opts, + getPort: () => actualPort, + boundWorkspace, + startup, + daemonLog, + qwenCodeVersion: cliVersion, + sessionShellCommandEnabled, + permissionPolicy, + getRuntimeError: () => runtimeStartupError, }); - // Pull the device-flow registry back out so the close hook can - // dispose it before `bridge.shutdown()`, ensuring polling timers + - // cancel controllers are torn down BEFORE we tell agent children - // to exit. - const deviceFlowRegistry = getDeviceFlowRegistry(app); + const app = + runtimeApp ?? createDelegatingServeApp(bootstrapApp, () => runtimeApp); // Node's `app.listen()` wants the unbracketed IPv6 literal (`::1`) but // operators conventionally type `[::1]` (or copy/paste from URLs that @@ -1181,6 +1790,14 @@ export async function runQwenServe( return await new Promise((resolve, reject) => { const server = app.listen(opts.port, listenHostname, () => { + startup.listenerReadyAt = new Date().toISOString(); + startup.processToListenMs = Math.round(process.uptime() * 1000); + startup.runQwenServeToListenMs = Math.round( + performance.now() - runStartedAt, + ); + profileCheckpoint('serve_listener_ready'); + finalizeStartupProfile(daemonLog.getDaemonId() || 'serve'); + // Listener-level connection cap, set inside the listen callback // because Node only exposes the underlying `Server` after // `app.listen()` returns. Each session's `EventBus` already @@ -1228,6 +1845,10 @@ export async function runQwenServe( writeStderrLine( `qwen serve: bound to workspace ${JSON.stringify(boundWorkspace)}`, ); + writeStderrLine( + `qwen serve: startup timing: processToListenMs=${startup.processToListenMs} ` + + `runQwenServeToListenMs=${startup.runQwenServeToListenMs}`, + ); if (!token) { writeStderrLine( `qwen serve: bearer auth disabled (loopback default). Set ${QWEN_SERVER_TOKEN_ENV} to enable.`, @@ -1247,6 +1868,121 @@ export async function runQwenServe( let shuttingDown = false; let closePromise: Promise | undefined; + let runtimeStartupTimer: NodeJS.Timeout | undefined; + const runtimeStartupTimeoutMs = resolveRuntimeStartupTimeoutMs( + deps.runtimeStartupTimeoutMs, + ); + const clearRuntimeStartupTimer = (): void => { + if (!runtimeStartupTimer) return; + clearTimeout(runtimeStartupTimer); + runtimeStartupTimer = undefined; + }; + const shutdownBridgeAfterFailedStartup = async ( + bridge: AcpSessionBridge | undefined, + ): Promise => { + if (!bridge || deps.bridge) return; + try { + await bridge.shutdown(); + } catch (shutdownErr) { + daemonLog.error( + 'bridge shutdown after runtime startup error failed', + shutdownErr instanceof Error ? shutdownErr : null, + ); + } finally { + if (bridgeRef === bridge) { + bridgeRef = undefined; + } + } + }; + const failRuntimeStartup = async ( + err: unknown, + bridgeForCleanup?: AcpSessionBridge, + ): Promise => { + const error = err instanceof Error ? err : new Error(String(err)); + if (runtimeStartupSettled) { + await shutdownBridgeAfterFailedStartup(bridgeForCleanup); + return; + } + runtimeStartupSettled = true; + clearRuntimeStartupTimer(); + const message = error.message; + runtimeStartupError = message; + if ( + startup.preheat.status === 'scheduled' || + startup.preheat.status === 'running' + ) { + startup.preheat.status = 'failed'; + startup.preheat.error = message; + } + writeStderrLine(`qwen serve: runtime startup failed: ${message}`); + daemonLog.error('runtime startup failed', error); + markRuntimeFailed(error); + await shutdownBridgeAfterFailedStartup(bridgeForCleanup ?? bridgeRef); + }; + const startBridgePreheat = (bridge: AcpSessionBridge): void => { + startup.preheat.status = 'running'; + const preheatStartedAt = performance.now(); + bridge + .preheat() + .then(() => { + startup.preheat.status = 'succeeded'; + startup.preheat.durationMs = Math.round( + performance.now() - preheatStartedAt, + ); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + startup.preheat.status = 'failed'; + startup.preheat.durationMs = Math.round( + performance.now() - preheatStartedAt, + ); + startup.preheat.error = message; + writeStderrLine( + `qwen serve: ACP preheat failed, will retry on first session: ${message}`, + ); + }); + }; + const startRuntime = (): void => { + if (runtimeStarting) return; + runtimeStarting = buildRuntime() + .then(async (runtime) => { + if (runtimeStartupSettled) { + await shutdownBridgeAfterFailedStartup(runtime.bridge); + return; + } + bridgeRef = runtime.bridge; + runtimeAppForCleanup = runtime.app; + if (shuttingDown) { + await failRuntimeStartup( + new Error('Daemon runtime stopped before mounting.'), + runtime.bridge, + ); + return; + } + runtimeApp = runtime.app; + const acpHandle = runtime.app.locals?.['acpHandle'] as + | AcpHttpHandle + | undefined; + acpHandle?.attachServer?.(server); + if (shouldPreheat) { + startBridgePreheat(runtime.bridge); + } + runtimeStartupSettled = true; + clearRuntimeStartupTimer(); + markRuntimeReady(); + }) + .catch((err) => failRuntimeStartup(err)); + if (runtimeStartupTimeoutMs > 0) { + runtimeStartupTimer = setTimeout(() => { + void failRuntimeStartup( + new Error( + `Daemon runtime startup timed out after ${runtimeStartupTimeoutMs}ms.`, + ), + ); + }, runtimeStartupTimeoutMs); + runtimeStartupTimer.unref(); + } + }; // Forward declaration so handle.close can detach the listener after // drain completes. The handler is registered just before `resolve()`. @@ -1265,7 +2001,7 @@ export async function runQwenServe( // `qwen` processes in the operator's `ps` output. daemonLog.warn(`received ${signal} during drain — forcing exit`); try { - bridge.killAllSync(); + bridgeRef?.killAllSync(); } catch (err) { daemonLog.error( 'force-kill error', @@ -1291,9 +2027,10 @@ export async function runQwenServe( const handle: RunHandle = { server, url, - bridge, + bridge: handleBridge, webShellMounted, resolvedToken: token, + runtimeReady, close: () => { // Idempotent: cache the in-flight (or settled) close promise so // overlapping calls (e.g. test harness + signal handler firing @@ -1342,7 +2079,11 @@ export async function runQwenServe( settled = true; process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); - void shutdownTelemetry() + void ( + coreRuntimePromise + ? coreRuntimePromise.then((core) => core.shutdownTelemetry()) + : Promise.resolve() + ) .catch((telemetryErr) => { writeStderrLine( `qwen serve: telemetry shutdown error: ${ @@ -1352,6 +2093,7 @@ export async function runQwenServe( }`, ); }) + .finally(() => daemonLog.flush().catch(() => {})) .finally(() => { // Server.close error takes precedence (operator-visible // listener problem); fall back to the bridge error @@ -1362,107 +2104,122 @@ export async function runQwenServe( }); }; - // Dispose the device-flow registry FIRST so any - // in-flight IdP poll is cancelled and timers are cleared - // before the bridge tear-down (which would otherwise race - // with the still-polling registry on shared HTTP agents). - if (deviceFlowRegistry) { - try { - deviceFlowRegistry.dispose(); - } catch (err) { - daemonLog.warn( - `device-flow registry dispose error: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - // Dispose ACP handle (close WebSocketServer + send close frames). - const acpHandle = app.locals?.['acpHandle'] as - | AcpHttpHandle - | undefined; - if (acpHandle?.dispose) { - try { - acpHandle.dispose(); - } catch (err) { - daemonLog.warn( - `ACP handle dispose error: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - // Dispose rate limiter (clear GC timer + buckets). - const rl = getRateLimiter(app); - if (rl) { - rl.setDraining(true); - rl.dispose(); - } - forceFlushMetrics() - .catch((flushErr) => { - daemonLog.warn( - `pre-shutdown metrics flush failed: ${ - flushErr instanceof Error - ? flushErr.message - : String(flushErr) - }`, + void (coreRuntimePromise + ? coreRuntimePromise.then((core) => core.forceFlushMetrics()) + : Promise.resolve() + ).catch((flushErr) => { + daemonLog.warn( + `pre-shutdown metrics flush failed: ${ + flushErr instanceof Error + ? flushErr.message + : String(flushErr) + }`, + ); + }); + + Promise.resolve() + .then(async () => { + await waitForRuntimeStartingForShutdown( + runtimeStarting, + daemonLog, ); - }) - .then(() => { - bridge - .shutdown() - .catch((err) => { + const appForCleanup = runtimeApp ?? runtimeAppForCleanup; + // Dispose the device-flow registry FIRST so any + // in-flight IdP poll is cancelled and timers are cleared + // before the bridge tear-down (which would otherwise race + // with the still-polling registry on shared HTTP agents). + const deviceFlowRegistry = appForCleanup + ? getDeviceFlowRegistry(appForCleanup) + : undefined; + if (deviceFlowRegistry) { + try { + deviceFlowRegistry.dispose(); + } catch (err) { + daemonLog.warn( + `device-flow registry dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + // Dispose ACP handle (close WebSocketServer + send close frames). + const acpHandle = appForCleanup?.locals?.['acpHandle'] as + | AcpHttpHandle + | undefined; + if (acpHandle?.dispose) { + try { + acpHandle.dispose(); + } catch (err) { + daemonLog.warn( + `ACP handle dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + // Dispose rate limiter (clear GC timer + buckets). + const rl = appForCleanup + ? getRateLimiter(appForCleanup) + : undefined; + if (rl) { + rl.setDraining(true); + rl.dispose(); + } + const bridgeForShutdown = bridgeRef; + if (bridgeForShutdown) { + await bridgeForShutdown.shutdown().catch((err) => { daemonLog.error( 'bridge shutdown error', err instanceof Error ? err : null, ); bridgeShutdownError = err instanceof Error ? err : new Error(String(err)); - }) - .finally(() => { - // Phase 2: arm the force timer NOW so it only races - // server.close, not the bridge tear-down above. - // `RunHandle.close()` contract says "fully - // closed and bridge drained" — the previous code - // resolved on a 100ms shortcut AFTER - // `closeAllConnections()` without waiting for - // `server.close`'s callback, so embedders/tests - // could observe a "closed" handle while the server - // was still finalizing. Now: force-close just - // accelerates `server.close` by killing the - // sockets, but we still wait for `server.close`'s - // callback to fire. A secondary deadline catches - // the pathological case where `server.close` never - // resolves at all (kernel-stuck socket etc.) so - // shutdown is still bounded. - const SECONDARY_DEADLINE_MS = 2_000; - let secondaryTimer: NodeJS.Timeout | undefined; - const forceTimer = setTimeout(() => { - daemonLog.warn( - `${SHUTDOWN_FORCE_CLOSE_MS}ms listener-drain timeout reached; force-closing remaining connections`, - ); - server.closeAllConnections(); - // After force-close, server.close's callback - // SHOULD fire promptly. Give it `SECONDARY_DEADLINE_MS` - // before we resolve anyway with a warning — much - // longer than the previous 100ms shortcut, and - // logged so the operator knows the contract was - // bent. - secondaryTimer = setTimeout(() => { - daemonLog.warn( - `server.close did not fire ${SECONDARY_DEADLINE_MS}ms after force-close; resolving anyway`, - ); - finish(); - }, SECONDARY_DEADLINE_MS); - secondaryTimer.unref(); - }, SHUTDOWN_FORCE_CLOSE_MS); - forceTimer.unref(); - server.close((err) => { - clearTimeout(forceTimer); - if (secondaryTimer) clearTimeout(secondaryTimer); - finish(err); - }); }); + } + }) + .finally(() => { + // Phase 2: arm the force timer NOW so it only races + // server.close, not the bridge tear-down above. + // `RunHandle.close()` contract says "fully + // closed and bridge drained" — the previous code + // resolved on a 100ms shortcut AFTER + // `closeAllConnections()` without waiting for + // `server.close`'s callback, so embedders/tests + // could observe a "closed" handle while the server + // was still finalizing. Now: force-close just + // accelerates `server.close` by killing the + // sockets, but we still wait for `server.close`'s + // callback to fire. A secondary deadline catches + // the pathological case where `server.close` never + // resolves at all (kernel-stuck socket etc.) so + // shutdown is still bounded. + const SECONDARY_DEADLINE_MS = 2_000; + let secondaryTimer: NodeJS.Timeout | undefined; + const forceTimer = setTimeout(() => { + daemonLog.warn( + `${SHUTDOWN_FORCE_CLOSE_MS}ms listener-drain timeout reached; force-closing remaining connections`, + ); + server.closeAllConnections(); + // After force-close, server.close's callback + // SHOULD fire promptly. Give it `SECONDARY_DEADLINE_MS` + // before we resolve anyway with a warning — much + // longer than the previous 100ms shortcut, and + // logged so the operator knows the contract was + // bent. + secondaryTimer = setTimeout(() => { + daemonLog.warn( + `server.close did not fire ${SECONDARY_DEADLINE_MS}ms after force-close; resolving anyway`, + ); + finish(); + }, SECONDARY_DEADLINE_MS); + secondaryTimer.unref(); + }, SHUTDOWN_FORCE_CLOSE_MS); + forceTimer.unref(); + server.close((err) => { + clearTimeout(forceTimer); + if (secondaryTimer) clearTimeout(secondaryTimer); + finish(err); + }); }); }); return closePromise; @@ -1482,21 +2239,38 @@ export async function runQwenServe( server.on('error', (err) => { daemonLog.error('server error', err instanceof Error ? err : null); }); - if (!deps.bridge && shouldPreheatBridge(deps)) { - bridge.preheat().catch((err) => { - writeStderrLine( - `qwen serve: ACP preheat failed, will retry on first session: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }); + if (runtimeApp && bridgeRef) { + const acpHandle = runtimeApp.locals?.['acpHandle'] as + | AcpHttpHandle + | undefined; + acpHandle?.attachServer?.(server); + if (shouldPreheat) { + startBridgePreheat(bridgeRef); + } + } else { + startRuntime(); } - // Enable WebSocket transport now that http.Server is available. - const acpHandle = app.locals?.['acpHandle'] as AcpHttpHandle | undefined; - acpHandle?.attachServer?.(server); - - resolve(handle); + if (deps.resolveOnListen) { + resolve(handle); + } else { + void runtimeReady.then( + () => resolve(handle), + (err) => { + void handle + .close() + .catch((closeErr) => { + daemonLog.error( + 'shutdown after runtime startup error failed', + closeErr instanceof Error ? closeErr : null, + ); + }) + .finally(() => { + reject(err instanceof Error ? err : new Error(String(err))); + }); + }, + ); + } }); server.once('error', reject); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 50a6c0e0bb0..b3f5a6fb60f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -70,6 +70,7 @@ import { mountAcpHttp, type AcpHttpHandle } from './acp-http/index.js'; import { createVoiceWsConnectionHandler } from './voice/voice-ws.js'; import { buildDaemonStatusResponse, + type DaemonStartupSnapshot, parseDaemonStatusDetail, } from './daemon-status.js'; import { @@ -766,6 +767,7 @@ export interface ServeAppDeps { * stderr-only behavior. */ daemonLog?: DaemonLogger; + startup?: DaemonStartupSnapshot; workspace?: DaemonWorkspaceService; persistDisabledTools?: ( workspace: string, @@ -1292,9 +1294,12 @@ export function createServeApp( const createExtensionManager = () => new ExtensionManager({ workspaceDir: boundWorkspace, - isWorkspaceTrusted: !!isWorkspaceTrusted( - loadSettings(boundWorkspace).merged, - ), + isWorkspaceTrusted: + isWorkspaceTrusted( + loadSettings(boundWorkspace).merged, + undefined, + boundWorkspace, + ).isTrusted ?? true, requestConsent: () => Promise.resolve(), requestSetting: async (setting: ExtensionSetting) => { throw new Error( @@ -2029,6 +2034,7 @@ export function createServeApp( bridge, workspace, daemonLog, + startup: deps.startup, qwenCodeVersion: deps.qwenCodeVersion, acpHandle: acpHandleRef.current, rateLimiter, diff --git a/packages/cli/src/serve/web-shell-resolver.ts b/packages/cli/src/serve/web-shell-resolver.ts new file mode 100644 index 00000000000..6cae0734e08 --- /dev/null +++ b/packages/cli/src/serve/web-shell-resolver.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const BUNDLE_CHUNK_DIR = 'chunks'; + +function resolveBundleDirFastPath(importMetaUrl: string): string { + const moduleDir = path.dirname(fileURLToPath(importMetaUrl)); + return path.basename(moduleDir) === BUNDLE_CHUNK_DIR + ? path.dirname(moduleDir) + : moduleDir; +} + +/** + * Locate the built Web Shell assets directory (the one containing + * `index.html` + `assets/`). Returns `undefined` when the assets are not + * present so serve can degrade to API-only instead of crashing. + */ +export function resolveWebShellDir(): string | undefined { + const selfDir = path.dirname(fileURLToPath(import.meta.url)); + const hasShell = (dir: string): boolean => + existsSync(path.join(dir, 'index.html')) && + existsSync(path.join(dir, 'assets')); + + const bundled = path.join( + resolveBundleDirFastPath(import.meta.url), + 'web-shell', + ); + if (hasShell(bundled)) return bundled; + + let dir = selfDir; + for (let i = 0; i < 10; i++) { + const candidate = path.join(dir, 'packages', 'web-shell', 'dist'); + if (hasShell(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} diff --git a/packages/cli/src/serve/web-shell-static.ts b/packages/cli/src/serve/web-shell-static.ts index 3f431bcf40a..953af6483d4 100644 --- a/packages/cli/src/serve/web-shell-static.ts +++ b/packages/cli/src/serve/web-shell-static.ts @@ -4,14 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { existsSync } from 'node:fs'; import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; import express from 'express'; import type { Application, NextFunction, Request, Response } from 'express'; -import { resolveBundleDir } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { isServeDebugMode } from './debug-mode.js'; +export { resolveWebShellDir } from './web-shell-resolver.js'; /** * Content-Security-Policy for the Web Shell HTML shell. @@ -40,46 +38,6 @@ export const WEB_SHELL_CSP = [ "frame-ancestors 'none'", ].join('; '); -/** - * Locate the built Web Shell assets directory (the one containing - * `index.html` + `assets/`). Returns `undefined` when the assets are not - * present — e.g. a `--cli-only` build, or running before `npm run build` - * produced `packages/web-shell/dist` — so the caller can degrade to - * API-only instead of crashing. - */ -export function resolveWebShellDir(): string | undefined { - const selfDir = path.dirname(fileURLToPath(import.meta.url)); - // Require BOTH index.html and assets/: a partial build (index.html without - // its hashed chunks) would otherwise pass and serve a shell whose every - // script/style 404s. copy_bundle_assets.js applies the same two-part check. - const hasShell = (dir: string): boolean => - existsSync(path.join(dir, 'index.html')) && - existsSync(path.join(dir, 'assets')); - - // esbuild bundle: this module is hoisted into dist/cli.js (or a - // dist/chunks/*.js shared chunk). `resolveBundleDir` strips the `chunks/` - // segment so we land on dist/, where copy_bundle_assets.js drops the UI as - // dist/web-shell/. - const bundled = path.join(resolveBundleDir(import.meta.url), 'web-shell'); - if (hasShell(bundled)) return bundled; - - // Non-bundled runs sit at different depths under the repo: tsx on source - // (packages/cli/src/serve/), per-package `tsc` output - // (packages/cli/dist/src/serve/), and the integration daemon harness - // (packages/cli/dist/index.js). Walk up from this module to find a sibling - // packages/web-shell/dist instead of hard-coding one `..` depth — which - // only matched the tsx case and left the transpiled layouts serving no UI. - let dir = selfDir; - for (let i = 0; i < 10; i++) { - const candidate = path.join(dir, 'packages', 'web-shell', 'dist'); - if (hasShell(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return undefined; -} - /** * True when the request is a top-level document navigation (address-bar * load, link click, or refresh) rather than a programmatic fetch/XHR. diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx new file mode 100644 index 00000000000..71662dcae18 --- /dev/null +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { basename } from 'node:path'; +import { render } from 'ink'; +import React from 'react'; +import { + createDebugLogger, + type Config, + writeRuntimeStatus, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import type { InitializationResult } from '../core/initializer.js'; +import { DualOutputBridge } from '../dualOutput/DualOutputBridge.js'; +import { DualOutputContext } from '../dualOutput/DualOutputContext.js'; +import { RemoteInputWatcher } from '../remoteInput/RemoteInputWatcher.js'; +import { RemoteInputContext } from '../remoteInput/RemoteInputContext.js'; +import { AppContainer } from './AppContainer.js'; +import { KeypressProvider } from './contexts/KeypressContext.js'; +import { SessionStatsProvider } from './contexts/SessionContext.js'; +import { SettingsContext } from './contexts/SettingsContext.js'; +import { VimModeProvider } from './contexts/VimModeContext.js'; +import { AgentViewProvider } from './contexts/AgentViewContext.js'; +import { BackgroundTaskViewProvider } from './contexts/BackgroundTaskViewContext.js'; +import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js'; +import { checkForUpdates } from './utils/updateCheck.js'; +import { disableKittyProtocol } from './utils/kittyProtocolDetector.js'; +import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; +import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; +import { handleAutoUpdate } from '../utils/handleAutoUpdate.js'; +import { registerCleanup } from '../utils/cleanup.js'; +import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js'; +import { profileCheckpoint } from '../utils/startupProfiler.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { + computeWindowTitle, + writeTerminalTitle, +} from '../utils/windowTitle.js'; +import { getCliVersion } from '../utils/version.js'; + +const debugLogger = createDebugLogger('STARTUP'); + +export async function startInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: string[], + workspaceRoot: string = process.cwd(), + initializationResult: InitializationResult, +) { + const version = await getCliVersion(); + setWindowTitle(settings, basename(workspaceRoot)); + + // Write a small runtime.json sidecar next to the chat log so external + // tools (terminal multiplexers, IDE integrations, status daemons) can + // map the running PID back to its session id and work directory. + // Best-effort: a read-only filesystem must not prevent the UI from + // starting up. Marking the runtime status as enabled is what arms the + // session-swap refresh in `Config.refreshSessionId()` — without this + // call, the sidecar would never update on `/clear` or `/resume`. + try { + const sessionId = config.getSessionId(); + const runtimeStatusPath = config.storage.getRuntimeStatusPath(sessionId); + await writeRuntimeStatus(runtimeStatusPath, { + sessionId, + workDir: config.getTargetDir(), + qwenVersion: version, + }); + config.markRuntimeStatusEnabled(); + } catch { + // ignored: best-effort, never block UI startup. + } + + const restoreTerminalRedrawOptimizer = + process.stdout.isTTY && !config.getScreenReader() + ? installTerminalRedrawOptimizer(process.stdout) + : () => {}; + const restoreSynchronizedOutput = + process.stdout.isTTY && !config.getScreenReader() + ? installSynchronizedOutput(process.stdout) + : () => {}; + + // Create dual output bridge if --json-fd or --json-file is specified. + // Errors are caught so a bad fd/path degrades gracefully instead of + // preventing the TUI from launching. + let dualOutputBridge: DualOutputBridge | null = null; + const jsonFd = config.getJsonFd?.(); + const jsonFile = config.getJsonFile?.(); + try { + if (jsonFd != null) { + dualOutputBridge = new DualOutputBridge( + config, + { fd: jsonFd }, + { version }, + ); + } else if (jsonFile != null) { + dualOutputBridge = new DualOutputBridge( + config, + { filePath: jsonFile }, + { version }, + ); + } + } catch (err) { + debugLogger.error('Failed to initialize dual output bridge:', err); + writeStderrLine( + `Warning: dual output disabled — ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Create remote input watcher if --input-file is specified. + // This enables bidirectional sync: an external process writes JSONL + // commands to this file, and the TUI processes them as user messages. + let remoteInputWatcher: RemoteInputWatcher | null = null; + const inputFile = config.getInputFile?.(); + if (inputFile) { + try { + remoteInputWatcher = new RemoteInputWatcher(inputFile); + } catch (err) { + debugLogger.error('Failed to initialize remote input watcher:', err); + writeStderrLine( + `Warning: remote input disabled — ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + // Drain the early-captured input exactly once, before any React rendering. + // Must be outside any component/effect so StrictMode's mount/cleanup/remount + // always reads from the same stable prop rather than the (now empty) module buffer. + const initialCapturedInput = stopAndGetCapturedInput(); + + // Create wrapper component to use hooks inside render + const AppWrapper = () => { + const kittyProtocolStatus = useKittyKeyboardProtocol(); + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + return ( + + + + + + + + + + + + + + + + + + ); + }; + + const useVP = settings.merged.ui?.useTerminalBuffer ?? false; + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + alternateScreen: useVP, + }, + ); + // Records the moment Ink's `render()` call has returned, which is + // synchronous and happens before React reconciliation actually pushes + // bytes to the terminal. We intentionally keep the legacy name + // `first_paint` for backward compatibility with previously-collected + // profile files; the value is best read as "render call returned" + // rather than literal pixel paint. AppContainer's mount effect runs + // after this — it carries the `config_initialize_*` and + // `input_enabled` checkpoints that complete the first-screen picture. + profileCheckpoint('first_paint'); + + // Check for updates only if enableAutoUpdate is not explicitly disabled. + // Using !== false ensures updates are enabled by default when undefined. + if (settings.merged.general?.enableAutoUpdate !== false) { + checkForUpdates() + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + debugLogger.warn(`Update check failed: ${err}`); + }); + } + + registerCleanup(async () => { + remoteInputWatcher?.shutdown(); + await dualOutputBridge?.shutdown(); + // Explicitly disable the Kitty keyboard protocol before unmounting Ink so + // that the disable escape sequence is written while stdout is still fully + // operational, preventing garbled terminal output after the app exits. + disableKittyProtocol(); + instance.unmount(); + restoreSynchronizedOutput(); + restoreTerminalRedrawOptimizer(); + }); +} + +function setWindowTitle(settings: LoadedSettings, folderName?: string) { + if ( + settings.merged.ui?.hideWindowTitle || + settings.merged.ui?.showStatusInTitle === false + ) { + return; + } + const windowTitle = computeWindowTitle(folderName); + writeTerminalTitle((value) => process.stdout.write(value), windowTitle); + + process.on('exit', () => { + try { + writeTerminalTitle((value) => process.stdout.write(value), ''); + } catch { + // Best-effort: clearing the title during exit must not produce + // a visible error (e.g. EPIPE if stdout is already closed). + } + }); +} diff --git a/packages/cli/src/ui/themes/default-light.ts b/packages/cli/src/ui/themes/default-light.ts index 1803e7fae04..ccddbcdfc48 100644 --- a/packages/cli/src/ui/themes/default-light.ts +++ b/packages/cli/src/ui/themes/default-light.ts @@ -5,9 +5,10 @@ */ import { lightTheme, Theme } from './theme.js'; +import { DEFAULT_LIGHT_THEME_NAME } from '../../config/default-theme-names.js'; export const DefaultLight: Theme = new Theme( - 'Default Light', + DEFAULT_LIGHT_THEME_NAME, 'light', { hljs: { diff --git a/packages/cli/src/ui/themes/default.ts b/packages/cli/src/ui/themes/default.ts index e1d0247c016..b7884302cb0 100644 --- a/packages/cli/src/ui/themes/default.ts +++ b/packages/cli/src/ui/themes/default.ts @@ -5,9 +5,10 @@ */ import { darkTheme, Theme } from './theme.js'; +import { DEFAULT_DARK_THEME_NAME } from '../../config/default-theme-names.js'; export const DefaultDark: Theme = new Theme( - 'Default', + DEFAULT_DARK_THEME_NAME, 'dark', { hljs: { diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index b64f87c2c2d..cef5657a465 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -6,7 +6,6 @@ import { promises as fs } from 'node:fs'; import { join } from 'node:path'; -import { Storage } from '@qwen-code/qwen-code-core'; const cleanupFunctions: Array<(() => void) | (() => Promise)> = []; @@ -104,6 +103,7 @@ export function _resetCleanupFunctionsForTest(): void { } export async function cleanupCheckpoints() { + const { Storage } = await import('@qwen-code/qwen-code-core'); const storage = new Storage(process.cwd()); const tempDir = storage.getProjectTempDir(); const checkpointsDir = join(tempDir, 'checkpoints'); diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index e90376b69bf..388cf9123d0 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - readPackageUp, - type PackageJson as BasePackageJson, -} from 'read-package-up'; +import type { PackageJson as BasePackageJson } from 'read-package-up'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -27,6 +24,7 @@ export async function getPackageJson(): Promise { return packageJson; } + const { readPackageUp } = await import('read-package-up'); const result = await readPackageUp({ cwd: __dirname }); if (!result) { // TODO: Maybe bubble this up as an error. diff --git a/packages/cli/src/utils/startupProfiler.test.ts b/packages/cli/src/utils/startupProfiler.test.ts index 64eb28d7765..ea63d8d46e4 100644 --- a/packages/cli/src/utils/startupProfiler.test.ts +++ b/packages/cli/src/utils/startupProfiler.test.ts @@ -14,6 +14,7 @@ vi.mock('node:fs'); describe('startupProfiler', () => { const savedEnv: Record = {}; + const savedArgv = process.argv; function saveEnv(...keys: string[]) { for (const k of keys) { @@ -48,6 +49,7 @@ describe('startupProfiler', () => { afterEach(() => { restoreEnv(); + process.argv = savedArgv; }); function enableProfiler() { @@ -374,5 +376,33 @@ describe('startupProfiler', () => { // outer-prefixed filename keeps it distinct from sandbox-child reports. expect(writtenPath).toMatch(/[\\/]outer-/); }); + + it('collects outside sandbox for qwen serve with the primary startup flag', () => { + process.env['QWEN_CODE_PROFILE_STARTUP'] = '1'; + delete process.env['SANDBOX']; + process.argv = ['node', 'qwen', 'serve']; + + initStartupProfiler(); + profileCheckpoint('serve_listener_ready'); + + const report = getStartupReport(); + expect(report).not.toBeNull(); + expect(report!.outerProcess).toBe(false); + expect(report!.phases[0]!.name).toBe('serve_listener_ready'); + }); + + it('collects outside sandbox for bundled qwen serve argv shape', () => { + process.env['QWEN_CODE_PROFILE_STARTUP'] = '1'; + delete process.env['SANDBOX']; + process.argv = ['node', 'qwen', '/repo/dist/cli.js', 'serve']; + + initStartupProfiler(); + profileCheckpoint('serve_listener_ready'); + + const report = getStartupReport(); + expect(report).not.toBeNull(); + expect(report!.outerProcess).toBe(false); + expect(report!.phases[0]!.name).toBe('serve_listener_ready'); + }); }); }); diff --git a/packages/cli/src/utils/startupProfiler.ts b/packages/cli/src/utils/startupProfiler.ts index 6b0cf5a7309..f4082b1d2d7 100644 --- a/packages/cli/src/utils/startupProfiler.ts +++ b/packages/cli/src/utils/startupProfiler.ts @@ -17,10 +17,11 @@ * recordStartupEvent('name', attrs?) — record a discrete event (multi-fire allowed) * finalizeStartupProfile(id) — call after last checkpoint to write report * - * By default profiles only inside the sandbox child process to avoid duplicate - * reports. Set QWEN_CODE_PROFILE_STARTUP_OUTER=1 to also profile the outer - * (pre-sandbox) process; outer reports are written with an `outer-` filename - * prefix to keep them separate from sandbox-child reports. + * By default profiles inside the sandbox child process to avoid duplicate + * reports. `qwen serve` has no sandbox child, so it is profiled directly. + * Set QWEN_CODE_PROFILE_STARTUP_OUTER=1 to also profile the outer + * (pre-sandbox) process for non-serve runs; outer reports are written with an + * `outer-` filename prefix to keep them separate from sandbox-child reports. * * Zero overhead when disabled (single env var check). */ @@ -30,6 +31,7 @@ import * as path from 'node:path'; import { performance } from 'node:perf_hooks'; import type { StartupEventAttrs } from '@qwen-code/qwen-code-core'; +import { isServeFastPathArgv } from '../serve/fast-path-argv.js'; interface Checkpoint { name: string; @@ -141,16 +143,17 @@ export function initStartupProfiler(): void { const inSandboxChild = !!process.env['SANDBOX']; const outerOptIn = process.env['QWEN_CODE_PROFILE_STARTUP_OUTER'] === '1'; + const serveCommand = isServeFastPathArgv(process.argv.slice(2)); - // Default behavior is unchanged: only the sandbox child collects. - // Outer (pre-sandbox) collection requires an explicit opt-in to avoid - // accidentally producing duplicate reports. - if (!inSandboxChild && !outerOptIn) { + // Non-serve outer (pre-sandbox) collection requires an explicit opt-in to + // avoid accidentally producing duplicate reports. Serve has no sandbox child, + // so the primary startup flag should collect in the current process. + if (!inSandboxChild && !outerOptIn && !serveCommand) { return; } enabled = true; - outerProcess = !inSandboxChild; + outerProcess = !inSandboxChild && !serveCommand; // Default to capturing heap snapshots at every checkpoint. // Disable with QWEN_CODE_PROFILE_STARTUP_NO_HEAP=1 when measuring the // Heisenberg overhead of the heap call itself.