diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 81990d54307..06197cc9ee1 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -1054,6 +1054,9 @@ export const IDLE_HOOK_EVENTS: Record = { description: 'When a new session is started', matcherKind: 'sessionTrigger', }, + CwdChanged: { + description: 'After the session changes its working directory', + }, MessageDisplay: { description: 'Repeatedly, as the assistant reply streams', }, diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index bec09694af3..b57ef19f574 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -857,6 +857,7 @@ vi.mock('../config/settings-cache.js', async () => { const settings = await import('../config/settings.js'); return { loadSettingsCached: (cwd: string) => settings.loadSettings(cwd), + loadSettingsCachedForSession: (cwd: string) => settings.loadSettings(cwd), }; }); vi.mock('../config/loadedSettingsAdapter.js', () => ({ @@ -2090,6 +2091,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { installPendingManagedConversationBinding: ReturnType; commitManagedConversationBinding: ReturnType; releaseManagedConversationBinding: ReturnType; + startCronScheduler: ReturnType; appendLiveConversationTranscript: ReturnType; collectActiveWorkHolds: ReturnType; hasStandaloneRelocationBlockers: ReturnType; @@ -3563,7 +3565,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await vi.waitFor(() => expect(vi.mocked(loadCliConfig)).toHaveBeenCalledTimes(1), ); + const sessionAHostPolicy = vi.mocked(loadCliConfig).mock.calls[0]?.[9]; + expect(sessionAHostPolicy?.ownsProcessEnvironment?.()).toBe(false); await agent.newSession({ cwd: '/workspace-b', mcpServers: [] }); + const sessionBHostPolicy = vi.mocked(loadCliConfig).mock.calls[1]?.[9]; + expect(sessionAHostPolicy?.ownsProcessEnvironment?.()).toBe(false); + expect(sessionBHostPolicy?.ownsProcessEnvironment?.()).toBe(false); releaseSessionA(); await sessionAPromise; @@ -4507,6 +4514,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), getTurnCount: vi.fn().mockReturnValue(3), prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + refreshSkillsFromSettings: vi.fn().mockResolvedValue(undefined), }; lastSessionMock = sessionMock; return sessionMock as unknown as InstanceType; @@ -5448,6 +5456,19 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect( lastSessionMock?.hardSuspendTodoStopGuard.mock.invocationCallOrder[0], ).toBeLessThan(relocateWorkingDirectory.mock.invocationCallOrder[0]!); + expect(lastSessionMock?.startCronScheduler).toHaveBeenCalledTimes(2); + // The daemon owns the settings watcher for non-managed sessions, so + // this is the only push of `available_commands_update` after a move. + expect( + ( + lastSessionMock as unknown as { + refreshSkillsFromSettings: ReturnType; + } + ).refreshSkillsFromSettings, + ).toHaveBeenCalledWith({ + reloadSettings: false, + notifyConfigChanged: false, + }); } finally { await fs.rm(targetDir, { recursive: true, force: true }); } @@ -5731,7 +5752,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(innerConfig.relocateWorkingDirectory).toHaveBeenCalledWith( expectation.child.canonicalPath, expectation.child.canonicalPath, - { skipProcessChdir: true, skipArtifactMigration: true }, + { + skipProcessChdir: true, + skipArtifactMigration: true, + trustedFolder: true, + }, ); expect( lastSessionMock?.installPendingManagedConversationBinding, @@ -11545,6 +11570,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect.objectContaining({ toolInvocationGuard: expect.any(Function), }), + expect.anything(), ); mockConnectionState.resolve(); @@ -12626,6 +12652,65 @@ describe('QwenAgent MCP SSE/HTTP support', () => { autoMemoryDir: '/tmp/qwen-memory-root-test/.qwen/memory', }, }); + // A live session that `/cd`-ed into a project with its own + // `context.fileName` answers for that directory — even when the host + // spells the cwd with a trailing slash — instead of the global name. + (agent as unknown as { sessions: Map }).sessions.set( + 'scoped-session', + { + getConfig: () => ({ + getWorkingDir: () => '/tmp/qwen-memory-scoped-test', + getContextFileNames: () => ['CONTEXT.md'], + }), + }, + ); + await expect( + agent.extMethod('qwen/settings/getMemoryPaths', { + cwd: '/tmp/qwen-memory-scoped-test/', + projectRoot: '/tmp/qwen-memory-scoped-test', + }), + ).resolves.toEqual({ + paths: { + userMemoryFile: path.join('/tmp/qwen-global-test', 'CONTEXT.md'), + projectMemoryFile: path.join( + '/tmp/qwen-memory-scoped-test', + 'CONTEXT.md', + ), + autoMemoryDir: '/tmp/qwen-memory-scoped-test/.qwen/memory', + }, + }); + const realDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-memory-real-'), + ); + const linkedDir = `${realDir}-link`; + await fs.symlink(realDir, linkedDir, 'dir'); + try { + (agent as unknown as { sessions: Map }).sessions.set( + 'scoped-session', + { + getConfig: () => ({ + getWorkingDir: () => realDir, + getContextFileNames: () => ['CONTEXT.md'], + }), + }, + ); + await expect( + agent.extMethod('qwen/settings/getMemoryPaths', { + cwd: linkedDir, + projectRoot: linkedDir, + }), + ).resolves.toMatchObject({ + paths: { + projectMemoryFile: path.join(linkedDir, 'CONTEXT.md'), + }, + }); + } finally { + await fs.unlink(linkedDir); + await fs.rm(realDir, { recursive: true, force: true }); + } + (agent as unknown as { sessions: Map }).sessions.delete( + 'scoped-session', + ); await expect( agent.extMethod('qwen/settings/setMemory', { updates: { @@ -20536,8 +20621,10 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { } const sessionSettings = vi.mocked(loadCliConfig).mock.calls[0]?.[0]; + const hostPolicy = vi.mocked(loadCliConfig).mock.calls[0]?.[9]; expect(sessionSettings?.experimental?.cron).toBe(false); expect(requestSettings.merged.experimental?.cron).toBe(true); + expect(hostPolicy?.projectRuntimeCronEnabled).toBe(false); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ead481e186d..bccea369a9f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -193,17 +193,21 @@ import { import { observeAcpToolResultWire } from '../nonInteractive/tool-result-boundary-diagnostics.js'; import { Readable, Writable } from 'node:stream'; import { normalizeDisabledToolList } from '../config/normalizeDisabledTools.js'; -import type { Stats } from 'node:fs'; +import { realpathSync, type Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import type { LoadedSettings } from '../config/settings.js'; +import { formatCronRelocationNotice } from '../config/cron-relocation-notice.js'; import { loadSettings, reloadEnvironment, SettingScope, } from '../config/settings.js'; -import { loadSettingsCached } from '../config/settings-cache.js'; +import { + loadSettingsCached, + loadSettingsCachedForSession, +} from '../config/settings-cache.js'; import { normalizeSessionIdForLookup, parseCallerSuppliedSessionId, @@ -2310,8 +2314,9 @@ function readScopeSettings( async function resolvePreferredMemoryFile( dir: string, fallbackFilename: string, + contextFileNames: readonly string[], ): Promise { - for (const filename of getAllMemoryFilenames()) { + for (const filename of contextFileNames) { const filePath = path.join(dir, filename); try { await fs.access(filePath); @@ -2327,15 +2332,25 @@ async function resolvePreferredMemoryFile( async function resolveQwenMemoryPaths(params: { cwd: string; projectRoot: string; + /** + * The session's context-file names. `/cd` makes these session-scoped + * and never updates the process-global list, so a host asking for the + * memory paths after a move must be answered from the session, or it + * is handed `QWEN.md` for a project whose file is `CONTEXT.md`. + */ + contextFileNames?: readonly string[]; }): Promise { - const fallbackFilename = getAllMemoryFilenames()[0] ?? 'QWEN.md'; + const contextFileNames = params.contextFileNames ?? getAllMemoryFilenames(); + const fallbackFilename = contextFileNames[0] ?? 'QWEN.md'; const userMemoryFile = await resolvePreferredMemoryFile( Storage.getGlobalQwenDir(), fallbackFilename, + contextFileNames, ); const projectMemoryFile = await resolvePreferredMemoryFile( params.cwd, fallbackFilename, + contextFileNames, ); const autoMemoryDir = getAutoMemoryRoot(params.projectRoot); @@ -3402,6 +3417,7 @@ class QwenAgent implements Agent { >(); private readonly pendingConfigCleanup = new Map>(); private readonly initializingConfigs = new Set(); + private pendingSessionConfigCreations = 0; private managedShuttingDown = false; private clientCapabilities: ClientCapabilities | undefined; /** Set once the daemon negotiates active-work reporting; one per channel. */ @@ -4806,7 +4822,7 @@ class QwenAgent implements Agent { // persists model changes through this instance, so a mix-up writes to // another workspace's settings.json. const settings = profiler.timeSync('settings_load', () => - loadSettingsCached(cwd), + loadSettingsCachedForSession(cwd), ); this.settings = settings; const deferMcpDiscovery = shouldDeferMcpDiscovery(params); @@ -5052,7 +5068,7 @@ class QwenAgent implements Agent { // Load per-request settings only after reserving a non-live id. The check // must resolve `advanced.runtimeOutputDir` from this request's cwd. const settings = profiler.timeSync('settings_load', () => - loadSettingsCached(params.cwd), + loadSettingsCachedForSession(params.cwd), ); const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { @@ -5440,7 +5456,7 @@ class QwenAgent implements Agent { try { // Same per-request settings discipline as `loadSession`. const settings = profiler.timeSync('settings_load', () => - loadSettingsCached(params.cwd), + loadSettingsCachedForSession(params.cwd), ); const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { @@ -8245,7 +8261,11 @@ class QwenAgent implements Agent { ? params['projectRoot'] : cwd; return { - paths: await resolveQwenMemoryPaths({ cwd, projectRoot }), + paths: await resolveQwenMemoryPaths({ + cwd, + projectRoot, + contextFileNames: this.contextFileNamesForCwd(cwd), + }), }; } case SERVE_STATUS_EXT_METHODS.workspaceMcp: @@ -10309,7 +10329,11 @@ class QwenAgent implements Agent { const relocation = await config.relocateWorkingDirectory( canonicalPath, canonicalPath, - { skipProcessChdir: true, skipArtifactMigration: true }, + { + skipProcessChdir: true, + skipArtifactMigration: true, + trustedFolder: true, + }, ); if (conversationDirectoryExpectation !== undefined) { await assertManagedConversationDirectoryIdentity( @@ -10341,6 +10365,32 @@ class QwenAgent implements Agent { }`, ); } + for (const error of relocation.projectRuntimeRefreshErrors ?? []) { + warnings.push( + `Project runtime refresh failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (relocation.cronExitSummary) { + warnings.push( + formatCronRelocationNotice(relocation.cronExitSummary), + ); + } + + try { + await session.refreshSkillsFromSettings({ + reloadSettings: false, + notifyConfigChanged: false, + }); + } catch (error) { + warnings.push( + `Available commands refresh failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + session.startCronScheduler(); try { await config @@ -12514,6 +12564,7 @@ class QwenAgent implements Agent { sessionId ?? (sessionIdGenerated ? randomUUID() : undefined); const debugSessionId = effectiveSessionId ?? inheritedSessionId ?? 'transcript-replay'; + this.pendingSessionConfigCreations++; try { this.assertManagedSessionAdmission(); return await sessionIdContext.run(debugSessionId, () => @@ -12552,6 +12603,8 @@ class QwenAgent implements Agent { throw sessionId && restoreOptions ? mapSessionRestoreRequestError(error, sessionId) : error; + } finally { + this.pendingSessionConfigCreations--; } } @@ -12686,29 +12739,31 @@ class QwenAgent implements Agent { // not process.exit(1) the shared ACP child and every session on its // channel. newSessionConfig maps the throw to a RequestError. true, - this.managedToolInvocationGuard || restoreOptions || provisionalWorkspace - ? { - ...(provisionalWorkspace - ? { provisionalWorkspace: true as const } - : {}), - ...(this.managedToolInvocationGuard - ? { toolInvocationGuard: this.managedToolInvocationGuard } - : {}), - ...(restoreOptions && sessionId - ? { - sessionRestore: { - projectionSource: (restoreSessionId) => - new SessionService(cwd, { - runtimeBaseDir: Storage.getRuntimeBaseDir(), - }).readRestoreProjection( - restoreSessionId, - restoreOptions, - ), - }, - } - : {}), - } - : undefined, + { + // This child can accept another session at any time, and spawned + // tools inherit one process-wide environment. + ownsProcessEnvironment: () => false, + ...(provisionalWorkspace + ? { provisionalWorkspace: true as const } + : {}), + ...(this.managedToolInvocationGuard + ? { toolInvocationGuard: this.managedToolInvocationGuard } + : {}), + ...(sessionSource?.sourceType === 'channel' + ? { projectRuntimeCronEnabled: false } + : {}), + ...(restoreOptions && sessionId + ? { + sessionRestore: { + projectionSource: (restoreSessionId) => + new SessionService(cwd, { + runtimeBaseDir: Storage.getRuntimeBaseDir(), + }).readRestoreProjection(restoreSessionId, restoreOptions), + }, + } + : {}), + }, + settings, ); if (sessionSource) { config.setSessionSource(sessionSource.sourceType, sessionSource.sourceId); @@ -12891,6 +12946,33 @@ class QwenAgent implements Agent { config.setFileSystemService(acpFileSystemService); } + /** + * Context-file names for a host request about `cwd`. `/cd` scopes the + * names to the session and leaves the process-global list untouched, so + * an agent-level request has to be answered from the session that owns + * the directory; the global list is only right when no session does. + */ + private contextFileNamesForCwd(cwd: string): readonly string[] { + // The stored directory is normalized (`path.resolve` at construction, + // realpath on `/cd`); the host-supplied param is not, so a trailing + // slash or `..` spelling must not fall through to the global list. + const canonicalize = (value: string): string => { + try { + return realpathSync(value); + } catch { + return path.resolve(value); + } + }; + const requested = canonicalize(cwd); + for (const session of this.sessions.values()) { + const config = session.getConfig(); + if (canonicalize(config.getWorkingDir()) === requested) { + return config.getContextFileNames(); + } + } + return getAllMemoryFilenames(); + } + private async createAndStoreSession( config: Config, settings: LoadedSettings, diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 684f13bc823..2940b2dfd1e 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -281,6 +281,7 @@ vi.mock('../config/settings-cache.js', async () => { const settings = await import('../config/settings.js'); return { loadSettingsCached: (cwd: string) => settings.loadSettings(cwd), + loadSettingsCachedForSession: (cwd: string) => settings.loadSettings(cwd), }; }); vi.mock('../config/config.js', () => ({ diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 3d3768f326f..87aaabbf598 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -553,7 +553,9 @@ describe('Session', () => { getTool: ReturnType; ensureTool: ReturnType; registerTool: ReturnType; + registerSessionTool: ReturnType; registerPermissionDeferredFactory: ReturnType; + registerSessionPermissionDeferredFactory: ReturnType; revealDeferredTool: ReturnType; pinDeferredToolReveal: ReturnType; warmAll: ReturnType; @@ -844,7 +846,9 @@ describe('Session', () => { getTool: vi.fn(), ensureTool: vi.fn().mockResolvedValue(true), registerTool: vi.fn(), + registerSessionTool: vi.fn(), registerPermissionDeferredFactory: vi.fn(), + registerSessionPermissionDeferredFactory: vi.fn(), revealDeferredTool: vi.fn(), pinDeferredToolReveal: vi.fn(), warmAll: vi.fn().mockResolvedValue(undefined), @@ -852,6 +856,13 @@ describe('Session', () => { names.map((name) => ({ name })), ), }; + mockToolRegistry.registerSessionTool = vi.fn((tool) => + mockToolRegistry.registerTool(tool), + ); + mockToolRegistry.registerSessionPermissionDeferredFactory = vi.fn( + (name, factory) => + mockToolRegistry.registerPermissionDeferredFactory(name, factory), + ); const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false), shouldIgnoreFile: vi.fn().mockReturnValue(false), @@ -885,6 +896,7 @@ describe('Session', () => { assertCanStartTurn: vi.fn().mockResolvedValue(undefined), getWorkingDir: vi.fn().mockReturnValue(process.cwd()), getProjectRoot: vi.fn().mockReturnValue('/repo'), + getContextFileNames: vi.fn().mockReturnValue(['QWEN.md', 'AGENTS.md']), // Folder trust gates the project `.qwen/loop.md`; default trusted (the // production default). Untrusted-folder tests override to false. isTrustedFolder: vi.fn().mockReturnValue(true), @@ -2112,6 +2124,11 @@ describe('Session', () => { await session.enableLiveScreenContext(); const screenTool = registered.get(CAPTURE_SCREEN_CONTEXT_TOOL_NAME); expect(screenTool?.name).toBe('capture_screen_context'); + // Session-owned, not merely registered: a plain `registerTool` here + // would let the next `/cd` dispose the live channel tool. + expect(mockToolRegistry.registerSessionTool).toHaveBeenCalledWith( + expect.objectContaining({ name: CAPTURE_SCREEN_CONTEXT_TOOL_NAME }), + ); const invocation = screenTool?.build({}); expect(invocation).toBeDefined(); await expect(invocation?.getDefaultPermission()).resolves.toBe('allow'); @@ -8462,11 +8479,30 @@ describe('Session', () => { await registerCreateSubSessionTool(mockConfig); - expect(mockToolRegistry.registerTool).toHaveBeenCalledWith( + // Session-OWNED registration: `/cd` rebuilds the project-scoped tool + // set and must keep this one, so the plain `registerTool` path + // (which the mock delegates to) is not enough of a witness. + expect(mockToolRegistry.registerSessionTool).toHaveBeenCalledWith( expect.objectContaining({ name: 'create_sub_session' }), ); }); + it('registers a deferred create_sub_session factory as session-owned', async () => { + mockConfig.getPermissionManager = vi.fn().mockReturnValue({ + getToolRegistrationStatus: vi.fn().mockResolvedValue('deferred'), + }); + mockConfig.getSubSessionSpawner = vi + .fn() + .mockReturnValue(async () => ({ sessionId: 'sub-1' })); + + await registerCreateSubSessionTool(mockConfig); + + expect( + mockToolRegistry.registerSessionPermissionDeferredFactory, + ).toHaveBeenCalledWith('create_sub_session', expect.any(Function)); + expect(mockToolRegistry.registerSessionTool).not.toHaveBeenCalled(); + }); + it('reveals the deferred tool and refreshes the declarations after registering', async () => { // The registration lands after `startChat()` froze the declaration // snapshot, and the tool is deferred — without a reveal + refresh the diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d6828289bd4..ac7cb05c8e1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1697,14 +1697,14 @@ export async function registerCreateSubSessionTool( } const toolRegistry = config.getToolRegistry(); if (registrationStatus === 'deferred') { - toolRegistry.registerPermissionDeferredFactory( + toolRegistry.registerSessionPermissionDeferredFactory( ToolNames.CREATE_SUB_SESSION, async () => new CreateSubSessionTool(config), ); await config.getLlmClient().setTools(); return; } - toolRegistry.registerTool(new CreateSubSessionTool(config)); + toolRegistry.registerSessionTool(new CreateSubSessionTool(config)); // The registration lands after `config.initialize()` → `startChat()` already // snapshotted the chat's tool declarations, and the tool is deferred — so it // stays filtered out of the declarations until revealed. Reveal it and @@ -3221,7 +3221,7 @@ export class Session implements SessionContext { screenshotPath, }; }); - registry.registerTool(tool); + registry.registerSessionTool(tool); if (registry.getTool(CAPTURE_SCREEN_CONTEXT_TOOL_NAME) !== tool) { throw new Error( 'capture_screen_context is required for Live Voice but is disabled.', @@ -3245,7 +3245,7 @@ export class Session implements SessionContext { ); } } - for (const tool of tools) registry.registerTool(tool); + for (const tool of tools) registry.registerSessionTool(tool); for (const tool of tools) { if (registry.getTool(tool.name) !== tool) { throw new Error( @@ -3272,7 +3272,7 @@ export class Session implements SessionContext { message, }); }); - registry.registerTool(tool); + registry.registerSessionTool(tool); if (registry.getTool(SPEAK_TO_USER_TOOL_NAME) !== tool) { throw new Error( 'speak_to_user is required for Live Voice but is disabled.', @@ -10378,6 +10378,7 @@ export class Session implements SessionContext { const matchedContextFileWrite = didWriteProjectContextFile( memoryWriteCandidates, this.config.getProjectRoot(), + this.config.getContextFileNames(), ); debugLogger.debug( `ACP session ${this.sessionId} checked marked context-file memory tool batch; matched=${matchedContextFileWrite}`, @@ -11255,7 +11256,11 @@ export class Session implements SessionContext { // prompt right after an allow-rule call just worked. const forceAutoReviewForAllow = approvalMode === ApprovalMode.AUTO && - (shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()) || + (shouldForceAutoModeReviewForAllow( + pmCtx, + this.config.getCwd(), + this.config.getContextFileNames(), + ) || shouldClassifyAllShellForAutoMode(policyToolName, this.config)); const confirmationPermission = getEffectivePermissionForConfirmation( finalPermission, diff --git a/packages/cli/src/config/config.projectRuntimeReloader.test.ts b/packages/cli/src/config/config.projectRuntimeReloader.test.ts new file mode 100644 index 00000000000..4c828d09304 --- /dev/null +++ b/packages/cli/src/config/config.projectRuntimeReloader.test.ts @@ -0,0 +1,704 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApprovalMode, SettingScope, Storage } from '@qwen-code/qwen-code-core'; +import { createProjectRuntimeReloader, parseArguments } from './config.js'; +import type { CliArgs, ProjectRuntimeHostPolicy } from './config.js'; +import { + createMinimalSettings, + loadSettings, + resetHomeEnvBootstrapForTesting, + type LoadedSettings, +} from './settings.js'; +import { resetMcpApprovalsForTesting } from './mcpApprovals.js'; +import { AppEvent, appEvents } from '../utils/events.js'; + +/** + * Drives the real reloader against real settings files and `.env` files + * on disk — every other layer mocks the layer below it, so this is the + * only place the settings→runtime assembly and the commit/rollback + * transaction are actually executed. + */ +describe('createProjectRuntimeReloader', () => { + let tempDir: string; + let projectA: string; + let projectB: string; + let previousQwenHome: string | undefined; + let argv: CliArgs; + const ENV_KEYS = [ + 'A_KEY', + 'B_TOKEN', + 'QWEN_DISABLED_SLASH_COMMANDS', + 'WEB_SEARCH_API_KEY', + 'WEB_SEARCH_BASE_URL', + ]; + + const writeProject = ( + dir: string, + settings: Record, + env?: string, + ) => { + const settingsPath = new Storage(dir).getWorkspaceSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + if (env !== undefined) { + fs.writeFileSync(path.join(path.dirname(settingsPath), '.env'), env); + } + }; + + const writeUserSettings = (settings: Record) => { + const userPath = Storage.getGlobalSettingsPath(); + fs.mkdirSync(path.dirname(userPath), { recursive: true }); + fs.writeFileSync(userPath, JSON.stringify(settings)); + }; + + const startupSettings = (): LoadedSettings => + loadSettings(projectA, { + consumeCorruptionEnvVars: false, + workspaceTrusted: true, + }); + + const makeReloader = ( + loaded: LoadedSettings, + overrides: { + bareMode?: boolean; + safeMode?: boolean; + cliIncludeDirectories?: string[]; + modelDisablesToolSearch?: boolean; + settingsWatcher?: { pauseWorkspaceWatching?: () => Promise<() => void> }; + hostPolicy?: ProjectRuntimeHostPolicy; + } = {}, + ) => + createProjectRuntimeReloader( + loaded, + overrides.settingsWatcher, + argv, + undefined, + overrides.bareMode ?? false, + overrides.safeMode ?? false, + overrides.cliIncludeDirectories ?? [], + overrides.modelDisablesToolSearch ?? false, + overrides.hostPolicy, + ); + + const readWorkspaceAllow = (dir: string): string[] | undefined => + ( + JSON.parse( + fs.readFileSync(new Storage(dir).getWorkspaceSettingsPath(), 'utf8'), + ) as { permissions?: { allow?: string[] } } + ).permissions?.allow; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-runtime-reloader-')); + const fakeHome = path.join(tempDir, 'os-home'); + fs.mkdirSync(fakeHome, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + previousQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = path.join(tempDir, 'home'); + resetHomeEnvBootstrapForTesting(); + resetMcpApprovalsForTesting(); + projectA = path.join(tempDir, 'project-a'); + projectB = path.join(tempDir, 'project-b'); + fs.mkdirSync(projectA, { recursive: true }); + fs.mkdirSync(projectB, { recursive: true }); + for (const key of ENV_KEYS) delete process.env[key]; + process.argv = ['node', 'script.js']; + argv = await parseArguments(); + }); + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + resetHomeEnvBootstrapForTesting(); + resetMcpApprovalsForTesting(); + vi.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('applies the target project environment on commit and restores it on rollback', async () => { + // The assembled MCP servers inherit `process.env` at spawn; without + // this step project B's server started without `B_TOKEN` while A's + // `A_KEY` was still leaking into B's subprocesses. + writeProject(projectA, {}, 'A_KEY=from-a\n'); + writeProject(projectB, {}, 'B_TOKEN=from-b\n'); + const loaded = startupSettings(); + expect(process.env['A_KEY']).toBe('from-a'); + expect(process.env['B_TOKEN']).toBeUndefined(); + + const prepared = await makeReloader(loaded).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + expect(process.env['B_TOKEN']).toBeUndefined(); + + await prepared.commit(); + expect(process.env['B_TOKEN']).toBe('from-b'); + expect(process.env['A_KEY']).toBeUndefined(); + expect(prepared.warnings).toEqual([]); + + await prepared.rollback(); + expect(process.env['A_KEY']).toBe('from-a'); + expect(process.env['B_TOKEN']).toBeUndefined(); + }); + + it('leaves the process environment alone when the host reports sibling sessions', async () => { + // An ACP child under `qwen serve` hosts every session on its channel, + // and spawned MCP servers / shell tools inherit `process.env`. A + // per-session `/cd` that rewrote it handed a sibling session's + // subprocesses this project's secrets while deleting its own. + writeProject(projectA, {}, 'A_KEY=from-a\n'); + writeProject(projectB, {}, 'B_TOKEN=from-b\n'); + const loaded = startupSettings(); + expect(process.env['A_KEY']).toBe('from-a'); + + const prepared = await makeReloader(loaded, { + hostPolicy: { ownsProcessEnvironment: () => false }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await prepared.commit(); + expect(process.env['A_KEY']).toBe('from-a'); + expect(process.env['B_TOKEN']).toBeUndefined(); + expect(loaded.workspace.path).toBe( + new Storage(projectB).getWorkspaceSettingsPath(), + ); + expect(prepared.warnings).toHaveLength(1); + expect(prepared.warnings?.[0]).toMatch(/may host other sessions/); + + await prepared.rollback(); + expect(process.env['A_KEY']).toBe('from-a'); + expect(process.env['B_TOKEN']).toBeUndefined(); + }); + + it('never applies the target environment from a bare session', async () => { + // Bare startup never loads env; a bare `/cd` must not start. And the + // rollback twin must not "restore" an environment that was never + // replaced — that leaked the boot environment's keys away. + writeProject( + projectB, + { experimental: { cron: false } }, + 'B_TOKEN=from-b\n', + ); + process.env['A_KEY'] = 'from-boot'; + + const prepared = await makeReloader(createMinimalSettings(), { + bareMode: true, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await prepared.commit(); + expect(process.env['B_TOKEN']).toBeUndefined(); + expect(process.env['A_KEY']).toBe('from-boot'); + expect(prepared.warnings).toEqual([]); + + await prepared.rollback(); + expect(process.env['B_TOKEN']).toBeUndefined(); + expect(process.env['A_KEY']).toBe('from-boot'); + }); + + it('keeps environment-backed config aligned when the host declines the rewrite', async () => { + writeProject(projectA, {}, 'QWEN_DISABLED_SLASH_COMMANDS=auth\n'); + writeProject(projectB, {}, 'QWEN_DISABLED_SLASH_COMMANDS=deploy\n'); + const loaded = startupSettings(); + expect(process.env['QWEN_DISABLED_SLASH_COMMANDS']).toBe('auth'); + + const prepared = await makeReloader(loaded, { + hostPolicy: { ownsProcessEnvironment: () => false }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.disabledSlashCommands).toEqual(['auth']); + expect(process.env['QWEN_DISABLED_SLASH_COMMANDS']).toBe('auth'); + }); + + it('keeps web search aligned when the host declines the environment rewrite', async () => { + writeProject( + projectA, + {}, + 'WEB_SEARCH_BASE_URL=https://a.example/search\n', + ); + writeProject( + projectB, + {}, + 'WEB_SEARCH_BASE_URL=https://b.example/search\nWEB_SEARCH_API_KEY=key-b\n', + ); + const prepared = await makeReloader(startupSettings(), { + hostPolicy: { ownsProcessEnvironment: () => false }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.webSearch).toMatchObject({ + baseUrl: 'https://a.example/search', + }); + expect(prepared.config.webSearch?.apiKeyEnv).toBe('DASHSCOPE_API_KEY'); + expect(process.env['WEB_SEARCH_BASE_URL']).toBe('https://a.example/search'); + }); + + it('refreshes environment-backed config after commit force-writes values', async () => { + process.env['WEB_SEARCH_BASE_URL'] = 'https://operator.example/search'; + writeProject(projectA, {}); + writeProject( + projectB, + {}, + 'WEB_SEARCH_BASE_URL=https://b.example/search\nWEB_SEARCH_API_KEY=key-b\n', + ); + const prepared = await makeReloader(startupSettings()).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(prepared.config.webSearch).toMatchObject({ + baseUrl: 'https://operator.example/search', + }); + await prepared.commit(); + expect(prepared.config.webSearch).toMatchObject({ + baseUrl: 'https://b.example/search', + apiKeyEnv: 'WEB_SEARCH_API_KEY', + }); + expect(process.env['WEB_SEARCH_BASE_URL']).toBe('https://b.example/search'); + expect(process.env['WEB_SEARCH_API_KEY']).toBe('key-b'); + }); + + it('refreshes environment-backed config after commit deletes stale keys', async () => { + writeProject(projectA, {}, 'QWEN_DISABLED_SLASH_COMMANDS=auth\n'); + writeProject(projectB, {}); + const loaded = startupSettings(); + process.env['QWEN_DISABLED_SLASH_COMMANDS'] = 'operator-command'; + const prepared = await makeReloader(loaded).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(prepared.config.disabledSlashCommands).toEqual(['operator-command']); + await prepared.commit(); + expect(prepared.config.disabledSlashCommands).toEqual([]); + expect(process.env['QWEN_DISABLED_SLASH_COMMANDS']).toBeUndefined(); + }); + + it('keeps explicit MCP allow flags authoritative after relocation', async () => { + argv.allowedMcpServerNames = ['fs']; + writeProject(projectA, {}); + writeProject(projectB, { + mcp: { allowed: ['db'], excluded: ['fs'] }, + }); + fs.writeFileSync( + path.join(projectB, '.mcp.json'), + JSON.stringify({ + mcpServers: { fs: { command: 'project-server' } }, + }), + ); + + const prepared = await makeReloader(startupSettings()).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(prepared.config.allowedMcpServers).toEqual(['fs']); + expect(prepared.config.excludedMcpServers).toBeUndefined(); + expect(prepared.config.pendingMcpServers).toEqual(['fs']); + }); + + it('keeps web search disabled after a safe-mode relocation commits', async () => { + writeUserSettings({ + tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } }, + }); + writeProject(projectA, {}); + writeProject(projectB, {}); + + const prepared = await makeReloader(startupSettings(), { + safeMode: true, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.webSearch).toBeUndefined(); + await prepared.commit(); + expect(prepared.config.webSearch).toBeUndefined(); + }); + + it('keeps provisional workspace context inputs disabled after relocation', async () => { + const cliInclude = path.join(projectA, 'cli-include'); + writeProject(projectA, {}); + writeProject(projectB, { + context: { + includeDirectories: ['target-include'], + loadFromIncludeDirectories: true, + }, + }); + + const prepared = await makeReloader(startupSettings(), { + cliIncludeDirectories: [cliInclude], + hostPolicy: { provisionalWorkspace: true }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.includeDirectories).toEqual([]); + expect(prepared.config.loadMemoryFromIncludeDirectories).toBe(false); + }); + + it('keeps a bare session trusted after relocation', async () => { + const prepared = await makeReloader(createMinimalSettings(), { + bareMode: true, + }).prepare(projectB, undefined, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.trustedFolder).toBe(true); + }); + + it('persists read-only migrations when relocation commits', async () => { + writeProject(projectA, {}); + writeProject(projectB, { theme: 'dark' }); + const settingsPath = new Storage(projectB).getWorkspaceSettingsPath(); + const loaded = startupSettings(); + const prepared = await makeReloader(loaded).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(JSON.parse(fs.readFileSync(settingsPath, 'utf8'))).toEqual({ + theme: 'dark', + }); + await prepared.commit(); + loaded.setValue(SettingScope.Workspace, 'ui.theme', 'light'); + loaded.reloadScopeFromDisk(SettingScope.Workspace); + + expect(loaded.merged.ui?.theme).toBe('light'); + expect( + JSON.parse(fs.readFileSync(settingsPath, 'utf8')), + ).not.toHaveProperty('theme'); + }); + + it('keeps a bare session on minimal settings instead of loading the user files', async () => { + // Bare startup never reads `~/.qwen/settings.json`; a `/cd` that did + // would make ambient command hooks (bugCommand, artifact upload) live + // mid-session and point later settings writes at the real files. + writeUserSettings({ + experimental: { cron: false }, + advanced: { bugCommand: { urlTemplate: 'https://bugs.example/{title}' } }, + }); + writeProject(projectB, { experimental: { cron: false } }); + const loaded = createMinimalSettings(); + + const prepared = await makeReloader(loaded, { bareMode: true }).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(prepared.config.cronEnabled).toBe(true); + expect(prepared.config.bugCommand).toBeUndefined(); + await prepared.commit(); + expect(loaded.user.path).toBe(''); + expect(loaded.workspace.path).toBe(''); + }); + + it('replicates the startup tool_search denial', async () => { + writeProject(projectA, {}); + const denyOf = async ( + settings: Record, + modelDisablesToolSearch: boolean, + ) => { + writeProject(projectB, settings); + const prepared = await makeReloader(startupSettings(), { + modelDisablesToolSearch, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + return { + deny: prepared.config.permissions?.deny ?? [], + exclude: prepared.config.excludeTools ?? [], + }; + }; + + // Explicitly disabled in the target's settings. + const explicit = await denyOf( + { tools: { toolSearch: { enabled: false } } }, + false, + ); + expect(explicit.deny).toContain('tool_search'); + expect(explicit.exclude).toContain('tool_search'); + + // Disabled by the session model, nothing in settings. + const byModel = await denyOf({}, true); + expect(byModel.deny).toContain('tool_search'); + + // An explicit enable wins over the model-derived default, as at startup. + const enabled = await denyOf( + { tools: { toolSearch: { enabled: true } } }, + true, + ); + expect(enabled.deny).not.toContain('tool_search'); + }); + + it('projects agents settings the same way startup does', async () => { + // Startup and `/cd` share one projection: every schema-declared key + // the runtime reads through `getAgentsSettings()` (arena limits) + // survives; `team` is a schema-reserved opaque object and the keys read + // from `settings.merged` elsewhere are dropped. + writeProject(projectA, {}); + writeProject(projectB, { + agents: { + allowedGrades: ['fast'], + team: { maxTeammates: 7 }, + arena: { maxRoundsPerAgent: 3, timeoutSeconds: 120 }, + crossSessionMessaging: true, + }, + worktree: { symlinkDirectories: ['node_modules'] }, + }); + + const prepared = await makeReloader(startupSettings()).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + expect(prepared.config.agents?.allowedGrades).toEqual(['fast']); + expect('team' in (prepared.config.agents ?? {})).toBe(false); + expect(prepared.config.agents?.arena).toMatchObject({ + maxRoundsPerAgent: 3, + timeoutSeconds: 120, + }); + expect('crossSessionMessaging' in (prepared.config.agents ?? {})).toBe( + false, + ); + expect(prepared.config.worktree?.symlinkDirectories).toEqual([ + 'node_modules', + ]); + }); + + it('ignores a doubled commit and a rollback or complete before commit', async () => { + // A second commit() must not re-pause the watcher (losing the first + // resume handle) or re-run the settings swap (a later rollback would + // then restore the TARGET project's settings). + writeProject(projectA, { disableAllHooks: true }); + writeProject(projectB, { disableAllHooks: false }); + const loaded = startupSettings(); + const workspacePathA = loaded.workspace.path; + const resume = vi.fn(); + const pauseWorkspaceWatching = vi.fn().mockResolvedValue(resume); + const replaceWith = vi.spyOn(loaded, 'replaceWith'); + + const prepared = await makeReloader(loaded, { + settingsWatcher: { pauseWorkspaceWatching }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await prepared.rollback(); + await prepared.complete(); + expect(replaceWith).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + + await prepared.commit(); + await prepared.commit(); + expect(pauseWorkspaceWatching).toHaveBeenCalledOnce(); + expect(replaceWith).toHaveBeenCalledOnce(); + + await prepared.rollback(); + expect(loaded.workspace.path).toBe(workspacePathA); + expect(loaded.merged.disableAllHooks).toBe(true); + expect(resume).toHaveBeenCalledOnce(); + }); + + it('preserves user agent settings when reloading in safe mode', async () => { + writeUserSettings({ + agents: { allowedGrades: ['fast'], maxParallelAgents: 2 }, + }); + writeProject(projectA, {}); + writeProject(projectB, {}); + + const prepared = await makeReloader(startupSettings(), { + safeMode: true, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.agents).toMatchObject({ + allowedGrades: ['fast'], + maxParallelAgents: 2, + }); + }); + + it('swaps the loaded settings on commit, restores them on rollback, and resumes the watcher', async () => { + writeProject(projectA, { disableAllHooks: true }); + writeProject(projectB, { disableAllHooks: false }); + const loaded = startupSettings(); + const workspacePathA = loaded.workspace.path; + const resume = vi.fn(); + const pauseWorkspaceWatching = vi.fn().mockResolvedValue(resume); + const emit = vi.spyOn(appEvents, 'emit'); + + const prepared = await makeReloader(loaded, { + settingsWatcher: { pauseWorkspaceWatching }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + expect(prepared.config.disableAllHooks).toBe(false); + expect(loaded.merged.disableAllHooks).toBe(true); + + await prepared.commit(); + expect(pauseWorkspaceWatching).toHaveBeenCalledOnce(); + expect(resume).not.toHaveBeenCalled(); + expect(loaded.workspace.path).toBe( + new Storage(projectB).getWorkspaceSettingsPath(), + ); + expect(loaded.merged.disableAllHooks).toBe(false); + + await prepared.rollback(); + expect(loaded.workspace.path).toBe(workspacePathA); + expect(loaded.merged.disableAllHooks).toBe(true); + expect(resume).toHaveBeenCalledOnce(); + expect(emit).toHaveBeenCalledWith(AppEvent.McpPendingApprovalChanged); + }); + + it('resumes the watcher exactly once when the switch completes', async () => { + writeProject(projectA, {}); + writeProject(projectB, {}); + const loaded = startupSettings(); + const resume = vi.fn(); + const emit = vi.spyOn(appEvents, 'emit'); + const prepared = await makeReloader(loaded, { + settingsWatcher: { pauseWorkspaceWatching: async () => resume }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await prepared.commit(); + expect(resume).not.toHaveBeenCalled(); + await prepared.complete(); + await prepared.complete(); + expect(resume).toHaveBeenCalledOnce(); + expect(emit).toHaveBeenCalledWith(AppEvent.McpPendingApprovalChanged); + expect(loaded.workspace.path).toBe( + new Storage(projectB).getWorkspaceSettingsPath(), + ); + }); + + it('keeps the host cron policy authoritative over the target project setting', async () => { + // A channel session runs with cron disabled for its whole life; the + // target project's `experimental.cron: true` must not re-enable it. + writeProject(projectA, {}); + writeProject(projectB, { experimental: { cron: true } }); + + const prepared = await makeReloader(startupSettings(), { + hostPolicy: { cronEnabled: false }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + expect(prepared.config.cronEnabled).toBe(false); + }); + + it('persists "Always allow" against the target project file in safe mode', async () => { + // `--safe-mode` loads the target with `skipWorkspaceSettings`, so the + // in-memory workspace scope is EMPTY while the file on disk is not. A + // read-modify-write against memory wrote `[newRule]` over the + // project's existing allow rules — data loss for every later session. + writeProject(projectA, {}); + writeProject(projectB, { permissions: { allow: ['rule-A', 'rule-B'] } }); + const loaded = startupSettings(); + const prepared = await makeReloader(loaded, { safeMode: true }).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + + await prepared.commit(); + expect(loaded.workspace.settings.permissions?.allow).toBeUndefined(); + await prepared.config.onPersistPermissionRule?.( + 'project', + 'allow', + 'run_shell_command(ls *)', + ); + + expect(readWorkspaceAllow(projectB)).toEqual([ + 'rule-A', + 'rule-B', + 'run_shell_command(ls *)', + ]); + expect(readWorkspaceAllow(projectA)).toBeUndefined(); + }); + + it('reads the target project file fresh on every persist', async () => { + // Two sessions in one project: a rule the sibling persisted since the + // settings watcher last refreshed must survive this session's write. + writeProject(projectA, {}); + writeProject(projectB, { permissions: { allow: ['rule-A'] } }); + const loaded = startupSettings(); + const prepared = await makeReloader(loaded).prepare( + projectB, + true, + ApprovalMode.DEFAULT, + projectA, + ); + await prepared.commit(); + expect(loaded.workspace.settings.permissions?.allow).toEqual(['rule-A']); + + // The sibling session writes behind this session's back. + writeProject(projectB, { + permissions: { allow: ['rule-A', 'rule-from-sibling'] }, + }); + await prepared.config.onPersistPermissionRule?.( + 'project', + 'allow', + 'run_shell_command(ls *)', + ); + + expect(readWorkspaceAllow(projectB)).toEqual([ + 'rule-A', + 'rule-from-sibling', + 'run_shell_command(ls *)', + ]); + }); + + it('persists bare-mode permission rules in the relocation target', async () => { + writeProject(projectA, {}); + writeProject(projectB, {}); + const prepared = await makeReloader(createMinimalSettings(), { + bareMode: true, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await prepared.commit(); + await prepared.config.onPersistPermissionRule?.( + 'project', + 'allow', + 'run_shell_command(ls *)', + ); + + const settingsA = loadSettings(projectA, { + consumeCorruptionEnvVars: false, + workspaceTrusted: true, + }); + const settingsB = loadSettings(projectB, { + consumeCorruptionEnvVars: false, + workspaceTrusted: true, + }); + expect(settingsA.workspace.settings.permissions?.allow).toBeUndefined(); + expect(settingsB.workspace.settings.permissions?.allow).toEqual([ + 'run_shell_command(ls *)', + ]); + }); + + it('resumes the watcher when the settings swap itself throws', async () => { + // Otherwise a one-off `replaceWith` failure leaves the workspace + // watcher stopped for the rest of the session. + writeProject(projectA, {}); + writeProject(projectB, {}); + const loaded = startupSettings(); + const resume = vi.fn(); + vi.spyOn(loaded, 'replaceWith').mockImplementationOnce(() => { + throw new Error('swap failed'); + }); + + const prepared = await makeReloader(loaded, { + settingsWatcher: { pauseWorkspaceWatching: async () => resume }, + }).prepare(projectB, true, ApprovalMode.DEFAULT, projectA); + + await expect(prepared.commit()).rejects.toThrow('swap failed'); + expect(resume).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4c75d03cb3c..12e1a953271 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -26,6 +26,7 @@ import type { Settings } from './settings.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resetMcpApprovalsForTesting } from './mcpApprovals.js'; +import { resolvePath } from '../utils/resolvePath.js'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); @@ -5395,13 +5396,38 @@ describe('loadCliConfig skills.directories', () => { 42 as unknown as string, null as unknown as string, '/abs/skills', + 'relative-skills', ], }, }; const config = await loadCliConfig(settings, argv); - expect(config.getCustomSkillDirs()).toEqual(['~/my-skills', '/abs/skills']); + // Home spellings are expanded here (not deferred to the skill manager), + // so `~` and `%userprofile%` both survive; a relative entry is nailed + // under the project. + expect(config.getCustomSkillDirs()).toEqual([ + resolvePath('~/my-skills'), + '/abs/skills', + path.resolve(process.cwd(), 'relative-skills'), + ]); + expect(path.isAbsolute(config.getCustomSkillDirs()[0])).toBe(true); + }); + + it('should expand %userprofile% skill directories against the home directory', async () => { + const argv = await parseArguments(); + const settings: Settings = { + skills: { directories: ['%userprofile%/my-skills'] }, + }; + + const config = await loadCliConfig(settings, argv); + + const [dir] = config.getCustomSkillDirs(); + expect(dir).toBe(resolvePath('%userprofile%/my-skills')); + expect(dir.endsWith(path.join('my-skills'))).toBe(true); + // The prefix must not be treated as a relative path under the project. + expect(dir.startsWith(process.cwd())).toBe(false); + expect(dir).not.toContain('%userprofile%'); }); it('should return empty array when skills.directories is not set', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1643faafa42..63b8dba5522 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -40,6 +40,8 @@ import { parseBooleanEnvFlag, SchemaValidator, type ConfigParameters, + type PreparedProjectRuntime, + type ProjectRuntimeReloader, type MCPServerConfig, type SkillLevel, type WebSearchSettings, @@ -51,7 +53,12 @@ import { hooksCommand } from '../commands/hooks.js'; import { resolveAcpChannelFallback } from './acp-channel-fallback.js'; import { normalizeDisabledToolList } from './normalizeDisabledTools.js'; import type { LoadedSettings, Settings } from './settings.js'; -import { loadSettings, SettingScope } from './settings.js'; +import { + createMinimalSettings, + loadSettings, + SettingScope, +} from './settings.js'; +import { reloadEnvironment } from './environment.js'; import { resolveCliGenerationConfig, getAuthTypeFromEnv, @@ -76,7 +83,7 @@ import { } from './top-level-options.js'; import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; -import { appEvents } from '../utils/events.js'; +import { AppEvent, appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; import { channelCommand } from '../commands/channel.js'; import { authCommand } from '../commands/auth.js'; @@ -99,6 +106,7 @@ import { } from '../utils/runBudget.js'; import { detectSystemLanguage } from '../i18n/index.js'; import { resolveSkillSettings } from './skill-settings.js'; +import { recomputeMcpGating } from './hot-reload.js'; const debugLogger = createDebugLogger('CONFIG'); @@ -1011,23 +1019,24 @@ function resolveModelFallbacks( */ function resolveWebSearchSettings( settings: Settings, + env: Readonly = process.env, ): WebSearchSettings | undefined { const webSearch = settings.tools?.webSearch; // A set-but-empty env var is "unset", not an override: dotenv templates and // CI wrappers export empty values, which must not clobber a valid // settings.json config (same rule as WEB_SEARCH_BASE_URL below). - const envEnabled = process.env['ENABLE_WEB_SEARCH']?.trim() || undefined; + const envEnabled = env['ENABLE_WEB_SEARCH']?.trim() || undefined; const enabled = envEnabled !== undefined ? isTruthy(envEnabled) : webSearch?.enabled; - const model = process.env['WEB_SEARCH_MODEL']?.trim() || webSearch?.model; - const envExtractor = process.env['WEB_SEARCH_EXTRACTOR']?.trim() || undefined; + const model = env['WEB_SEARCH_MODEL']?.trim() || webSearch?.model; + const envExtractor = env['WEB_SEARCH_EXTRACTOR']?.trim() || undefined; const webExtractor = envExtractor !== undefined ? isTruthy(envExtractor) : webSearch?.webExtractor; - const baseUrl = process.env['WEB_SEARCH_BASE_URL']?.trim() || undefined; + const baseUrl = env['WEB_SEARCH_BASE_URL']?.trim() || undefined; const apiKeyEnv = baseUrl - ? process.env['WEB_SEARCH_API_KEY']?.trim() + ? env['WEB_SEARCH_API_KEY']?.trim() ? 'WEB_SEARCH_API_KEY' : 'DASHSCOPE_API_KEY' : undefined; @@ -1243,6 +1252,606 @@ export function buildDisabledSkillNamesProvider( return () => resolveSkillSettings(loadedSettings).disabledNames; } +function resolveDisabledSlashCommands( + settings: Settings, + argv: CliArgs, + bareMode: boolean, + safeMode: boolean, + /** + * The environment the `QWEN_DISABLED_SLASH_COMMANDS` denylist is read + * from. Startup reads `process.env` after `loadEnvironment` ran; the `/cd` + * reloader passes the TARGET project's environment view instead, since + * `prepare()` runs before (and, in a shared process, instead of) any + * `process.env` rewrite. + */ + env: Readonly = process.env, +): string[] { + const disabled: string[] = []; + const seen = new Set(); + const add = (value: string | undefined) => { + if (!value) return; + const trimmed = value.trim(); + if (!trimmed) return; + const key = trimmed.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + disabled.push(trimmed); + } + }; + if (!bareMode && !safeMode) { + for (const name of settings.slashCommands?.disabled ?? []) add(name); + } + for (const name of argv.disabledSlashCommands ?? []) add(name); + for (const name of (env['QWEN_DISABLED_SLASH_COMMANDS'] ?? '').split(',')) { + add(name); + } + return disabled; +} + +/** + * The runtime-facing projection of `settings.agents`, shared by startup and + * the `/cd` reloader so the two cannot drift. Every schema-declared key the + * runtime reads through `Config.getAgentsSettings()` is carried, including + * the arena limits. Dropped in both places: `crossSessionMessaging` / + * `crossSessionInbound` (read from `settings.merged` elsewhere) and `team` + * / `swarm`, which the schema declares as opaque reserved objects with no + * keys — `team.maxTeammates` needs a schema entry before it can be carried. + */ +function projectAgentsSettings( + agents: Settings['agents'], +): ConfigParameters['agents'] { + if (!agents) return undefined; + return { + builtin: agents.builtin + ? { + exploreModel: agents.builtin.exploreModel, + } + : undefined, + modelGrades: agents.modelGrades, + allowedGrades: agents.allowedGrades, + maxParallelAgents: agents.maxParallelAgents, + maxParallelAgentsByModel: agents.maxParallelAgentsByModel, + displayMode: agents.displayMode, + arena: agents.arena + ? { + worktreeBaseDir: agents.arena.worktreeBaseDir, + preserveArtifacts: agents.arena.preserveArtifacts ?? false, + maxRoundsPerAgent: agents.arena.maxRoundsPerAgent, + timeoutSeconds: agents.arena.timeoutSeconds, + } + : undefined, + }; +} + +/** + * Resolves a settings-sourced directory against a project root. Expand + * first, then resolve: `resolvePath` handles every home spelling (`~`, + * `%userprofile%`), and an expanded path is absolute, so it is left alone + * rather than nailed under the project. Hand-enumerating `~` prefixes here + * silently broke `%userprofile%` skill directories. + */ +function resolveProjectPath(value: string, baseDir: string): string { + const expanded = resolvePath(value); + return path.isAbsolute(expanded) + ? path.normalize(expanded) + : path.resolve(baseDir, expanded); +} + +/** + * Builds the "Always allow" persistence callback for a working directory. + * + * Every write is a read-modify-write against a FRESH disk load of that + * directory's settings — never the session's in-memory `LoadedSettings`: + * + * - bare mode assembles the session against `createMinimalSettings()`, + * whose scope paths are all `''`; persisting through it wrote a stray + * `.tmp` into the cwd and threw on the rename; + * - `--safe-mode` loads with `skipWorkspaceSettings`, so the in-memory + * workspace scope is EMPTY while the file on disk is not — a + * read-modify-write against memory wiped the project's existing rules; + * - a sibling session in the same project may have persisted a rule + * since the last settings-watcher refresh; reading memory replayed the + * stale list and deleted its rule. + * + * `skipLoadEnvironment` keeps the load free of side effects: a persist must + * never re-apply `.env` files to `process.env` (bare mode never loads env). + */ +function createPermissionRulePersistenceCallback( + cwd: string, +): NonNullable { + return async (scope, ruleType, rule) => { + const currentSettings = loadSettings(cwd, { + consumeCorruptionEnvVars: false, + skipLoadEnvironment: true, + }); + const settingScope = + scope === 'project' ? SettingScope.Workspace : SettingScope.User; + const key = `permissions.${ruleType}`; + const currentRules: string[] = + currentSettings.forScope(settingScope).settings.permissions?.[ruleType] ?? + []; + if (!currentRules.includes(rule)) { + currentSettings.setValue(settingScope, key, [...currentRules, rule]); + } + }; +} + +/** + * Resolves the `tools.eager` allowlist from a settings snapshot. Shared by + * startup and the `/cd` project-runtime reloader so both apply the same + * bare/safe-mode gating, normalization, and dropped-entry warning. + */ +function resolveEagerTools( + settings: Settings, + bareMode: boolean, + safeMode: boolean, +): string[] | undefined { + // `tools.eager` restricts which schemas ride in the initial model request + // (#9827). Unlisted tools stay registered and reachable via tool_search — + // it is a schema-size knob, not an availability knob (#10075). + // + // An explicitly empty array must survive as an empty array, not collapse + // into "unset": `[]` is an active allowlist naming nothing (defer + // everything). `tools.core` differs: its empty list is treated as unset. + // `normalizeDisabledToolList` maps undefined to `[]`, + // so the Array.isArray guard has to come first — without it, absent and + // explicitly-empty would reach core as the same value, which is exactly + // the SDK divergence #10138 reports for coreTools. + const eagerTools = + bareMode || safeMode || !Array.isArray(settings.tools?.eager) + ? undefined + : normalizeDisabledToolList(settings.tools.eager); + if (eagerTools !== undefined) { + // `normalizeDisabledToolList` strips empty/whitespace-only and + // non-string entries before `PermissionManager.initialize()` ever sees + // the list, so the dropped-entries warning there can never fire for + // that class on the real CLI path — and a degenerate list like + // `tools.eager: [""]` would collapse to the active defer-everything + // allowlist `[]` in silence. Warn here so the collapse always leaves a + // signal (#10075). + const droppedEagerEntries = (settings.tools?.eager ?? []).filter( + (entry) => typeof entry !== 'string' || entry.trim() === '', + ); + if (droppedEagerEntries.length > 0) { + // eslint-disable-next-line no-console -- operator-facing breadcrumb; the debug log file is off in default runs, where this reshaping would otherwise be invisible + console.warn( + `tools.eager: ignoring ${droppedEagerEntries.length} unusable entr${ + droppedEagerEntries.length === 1 ? 'y' : 'ies' + } (${droppedEagerEntries + .map((entry) => JSON.stringify(entry)) + .join(', ')}). ` + + `The allowlist stays active with ${eagerTools.length} entr${ + eagerTools.length === 1 ? 'y' : 'ies' + }, so every other non-exempt tool is deferred to tool_search.`, + ); + } + } + return eagerTools; +} + +/** + * Embedding-host policy for the `/cd` project-runtime reloader. Runtime-only: + * never sourced from argv, settings, or the environment. + */ +export interface ProjectRuntimeHostPolicy { + /** Host-managed session whose startup cwd is only a placeholder. */ + provisionalWorkspace?: true; + /** Session policy that remains authoritative across project changes. */ + cronEnabled?: boolean; + /** + * Whether this session may rewrite the process-wide environment + * (`process.env`) to the target project's `.env` / `settings.env`. + * Spawned MCP servers and shell tools inherit `process.env`, so a process + * hosting OTHER live sessions must answer `false`: a per-session `/cd` + * would otherwise hand a sibling session's subprocesses this project's + * secrets while deleting its own. Defaults to `true` (single-session + * process). The target's environment still shapes the session's own + * configuration (for example `QWEN_DISABLED_SLASH_COMMANDS`). + */ + ownsProcessEnvironment?: () => boolean; +} + +export function createProjectRuntimeReloader( + loadedSettings: LoadedSettings, + settingsWatcher: + | { + pauseWorkspaceWatching?: () => Promise<() => void>; + } + | undefined, + argv: CliArgs, + topTierMcpServers: Record | undefined, + bareMode: boolean, + safeMode: boolean, + cliIncludeDirectories: readonly string[], + /** + * Startup's model-derived half of the tool-search decision. The model is + * session-stable, so it is captured once rather than re-resolved per `/cd`. + */ + modelDisablesToolSearch: boolean, + hostPolicy?: ProjectRuntimeHostPolicy, +): ProjectRuntimeReloader { + return { + async prepare(targetDir, trustedFolder, approvalMode, previousDir) { + // Mirror startup: a bare session is assembled against + // `createMinimalSettings()` and never sees the user's real settings. + // Loading them here would let `/cd` bring ambient command-execution + // hooks (bugCommand, artifact upload) into a bare session — and swap + // in real file paths for later settings writes. + const nextSettings = bareMode + ? createMinimalSettings() + : loadSettings(targetDir, { + consumeCorruptionEnvVars: false, + readOnly: true, + skipLoadEnvironment: true, + skipWorkspaceSettings: safeMode, + workspaceTrusted: trustedFolder, + }); + const effectiveTrust = + trustedFolder ?? + (bareMode + ? (isWorkspaceTrusted(nextSettings.merged)?.isTrusted ?? true) + : nextSettings.isTrusted); + // Never rewrite `process.env` from a bare session (bare startup never + // loads env) or from a process that hosts other live sessions. + const reloadProcessEnvironment = + !bareMode && (hostPolicy?.ownsProcessEnvironment?.() ?? true); + // Shared processes keep their existing environment. An owning process + // refreshes the environment-backed fields after commit rewrites it. + const targetEnvironment: Readonly = process.env; + const warnings: string[] = []; + if (!bareMode && !reloadProcessEnvironment) { + warnings.push( + 'Process environment left unchanged: this process may host other ' + + "sessions, so the target project's .env and settings.env were " + + 'not applied to spawned tools and MCP servers.', + ); + } + const assembled = + bareMode || safeMode + ? { ...topTierMcpServers } + : assembleMcpServers( + nextSettings.merged.mcpServers, + targetDir, + topTierMcpServers, + ); + const gating = + bareMode || safeMode || argv.allowedMcpServerNames !== undefined + ? { + allowed: argv.allowedMcpServerNames?.filter(Boolean), + excluded: undefined, + pending: + bareMode || safeMode || approvalMode === ApprovalMode.YOLO + ? undefined + : getPendingGatedMcpServers(assembled, targetDir), + } + : recomputeMcpGating( + nextSettings, + assembled, + targetDir, + argv.allowedMcpServerNames, + approvalMode === ApprovalMode.YOLO, + ); + const runtimeSettings = nextSettings.merged; + const includeDirectories = hostPolicy?.provisionalWorkspace + ? [] + : [ + ...(bareMode || safeMode + ? [] + : (runtimeSettings.context?.includeDirectories ?? []).map( + (directory) => resolveProjectPath(directory, targetDir), + )), + ...cliIncludeDirectories, + ]; + const plansDirectory = runtimeSettings.plansDirectory; + const coreTools = + bareMode || safeMode + ? undefined + : argv.coreTools || runtimeSettings.tools?.core || undefined; + const permissionsAllow = + bareMode || safeMode + ? [] + : [ + ...(runtimeSettings.permissions?.allow ?? []), + ...(runtimeSettings.tools?.allowed ?? []), + ]; + for (const rule of argv.allowedTools ?? []) { + if (rule && !permissionsAllow.includes(rule)) { + permissionsAllow.push(rule); + } + } + const permissionsDeny = + bareMode || safeMode + ? [] + : [ + ...(runtimeSettings.permissions?.deny ?? []), + ...(runtimeSettings.tools?.exclude ?? []), + ]; + for (const rule of argv.excludeTools ?? []) { + if (rule && !permissionsDeny.includes(rule)) { + permissionsDeny.push(rule); + } + } + // Same rule as startup (`shouldDisableToolSearch` in `loadCliConfig`): + // these lists were readonly before `/cd` existed, so the startup + // injection used to survive for the whole session. + const toolSearchExplicitlyEnabled = + runtimeSettings.tools?.toolSearch?.enabled; + if ( + (toolSearchExplicitlyEnabled === false || + (toolSearchExplicitlyEnabled === undefined && + modelDisablesToolSearch)) && + !permissionsDeny.includes('tool_search') + ) { + permissionsDeny.push('tool_search'); + } + + let previousSettings: LoadedSettings | undefined; + let resumeWatching: (() => void) | undefined; + let committed = false; + let environmentReloaded = false; + + const preparedRuntime: PreparedProjectRuntime = { + warnings, + config: { + trustedFolder: effectiveTrust, + includeDirectories, + loadMemoryFromIncludeDirectories: hostPolicy?.provisionalWorkspace + ? false + : bareMode || safeMode + ? includeDirectories.length > 0 + : (runtimeSettings.context?.loadFromIncludeDirectories ?? false), + plansDir: Storage.getPlansDir(targetDir, plansDirectory), + plansDirectoryConfigured: Boolean(plansDirectory?.trim()), + cronEnabled: + hostPolicy?.cronEnabled ?? + runtimeSettings.experimental?.cron ?? + true, + cronRecurringMaxAgeDays: + runtimeSettings.experimental?.cronRecurringMaxAgeDays, + lsToolEnabled: runtimeSettings.tools?.listDirectory?.enabled === true, + agentTeamEnabled: runtimeSettings.experimental?.agentTeam ?? false, + artifactEnabled: runtimeSettings.experimental?.artifact ?? true, + artifactAutoOpen: runtimeSettings.artifact?.autoOpen ?? true, + artifactPublisher: runtimeSettings.artifact?.publisher ?? 'local', + artifactHost: runtimeSettings.artifact?.host + ? { + uploadCommand: + runtimeSettings.artifact.host.uploadCommand ?? '', + urlTemplate: runtimeSettings.artifact.host.urlTemplate ?? '', + keyPrefix: runtimeSettings.artifact.host.keyPrefix, + } + : undefined, + artifactOss: runtimeSettings.artifact?.oss + ? { + bucket: runtimeSettings.artifact.oss.bucket ?? '', + endpoint: runtimeSettings.artifact.oss.endpoint ?? '', + keyPrefix: runtimeSettings.artifact.oss.keyPrefix, + acl: runtimeSettings.artifact.oss.acl, + publicBaseUrl: runtimeSettings.artifact.oss.publicBaseUrl, + } + : undefined, + workflowsEnabled: runtimeSettings.tools?.workflowsEnabled ?? false, + skipWorkflowUsageWarning: + runtimeSettings.model?.skipWorkflowUsageWarning ?? false, + useRipgrep: runtimeSettings.tools?.useRipgrep, + useBuiltinRipgrep: runtimeSettings.tools?.useBuiltinRipgrep, + webSearch: + bareMode || safeMode + ? undefined + : resolveWebSearchSettings(runtimeSettings, targetEnvironment), + imageModel: runtimeSettings.imageModel || undefined, + allowedHttpHookUrls: + bareMode || safeMode + ? [] + : (runtimeSettings.security?.allowedHttpHookUrls ?? []), + allowPrivateNetworkHooks: + bareMode || safeMode + ? false + : (runtimeSettings.security?.allowPrivateNetworkHooks ?? false), + fileFiltering: runtimeSettings.context?.fileFiltering, + shouldUseNodePtyShell: + runtimeSettings.tools?.shell?.enableInteractiveShell, + shellDefaultTimeoutMs: runtimeSettings.tools?.shell?.defaultTimeoutMs, + shellHeartbeatIntervalMs: + runtimeSettings.tools?.shell?.heartbeatIntervalMs, + truncateToolOutputThreshold: + runtimeSettings.tools?.truncateToolOutputThreshold, + truncateToolOutputLines: + runtimeSettings.tools?.truncateToolOutputLines, + toolOutputBatchBudget: runtimeSettings.tools?.toolOutputBatchBudget, + defaultFileEncoding: runtimeSettings.general?.defaultFileEncoding, + bugCommand: runtimeSettings.advanced?.bugCommand, + coreTools, + allowedTools: + bareMode || safeMode + ? argv.allowedTools || undefined + : argv.allowedTools || + runtimeSettings.tools?.allowed || + undefined, + excludeTools: + permissionsDeny.length > 0 ? permissionsDeny : undefined, + disabledSlashCommands: resolveDisabledSlashCommands( + runtimeSettings, + argv, + bareMode, + safeMode, + targetEnvironment, + ), + permissions: { + allow: permissionsAllow.length > 0 ? permissionsAllow : undefined, + ask: + bareMode || safeMode + ? undefined + : runtimeSettings.permissions?.ask, + deny: permissionsDeny.length > 0 ? permissionsDeny : undefined, + autoMode: + bareMode || safeMode + ? undefined + : runtimeSettings.permissions?.autoMode, + }, + disabledTools: + bareMode || safeMode + ? undefined + : normalizeDisabledToolList(runtimeSettings.tools?.disabled), + visibleTools: + bareMode || safeMode + ? undefined + : normalizeDisabledToolList(runtimeSettings.tools?.visible), + eagerTools: resolveEagerTools(runtimeSettings, bareMode, safeMode), + toolSearchThreshold: + bareMode || safeMode + ? 0 + : runtimeSettings.tools?.toolSearch?.threshold, + toolDiscoveryCommand: + bareMode || safeMode + ? undefined + : runtimeSettings.tools?.discoveryCommand, + toolCallCommand: + bareMode || safeMode + ? undefined + : runtimeSettings.tools?.callCommand, + mcpServerCommand: + bareMode || safeMode + ? undefined + : runtimeSettings.mcp?.serverCommand, + mcpToolIdleTimeoutMs: runtimeSettings.mcp?.toolIdleTimeoutMs, + disabledSkillLevels: + bareMode || + safeMode || + !Array.isArray(nextSettings.merged.skills?.disabledLevels) + ? undefined + : nextSettings.merged.skills.disabledLevels.filter(isSkillLevel), + customSkillDirs: + bareMode || safeMode + ? undefined + : (nextSettings.merged.skills?.directories ?? []) + .filter( + (directory): directory is string => + typeof directory === 'string' && + directory.trim().length > 0, + ) + .map((directory) => + resolveProjectPath(directory.trim(), targetDir), + ), + importFormat: runtimeSettings.context?.importFormat ?? 'tree', + contextFileName: + bareMode || safeMode + ? undefined + : runtimeSettings.context?.fileName, + enableManagedAutoMemory: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.enableManagedAutoMemory ?? true), + enableManagedAutoDream: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.enableManagedAutoDream ?? true), + enableTeamMemory: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.enableTeamMemory ?? false), + enableTeamMemorySync: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.enableTeamMemorySync ?? false), + enableAutoSkill: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.enableAutoSkill ?? false), + autoSkillConfirm: + bareMode || safeMode + ? false + : (runtimeSettings.memory?.autoSkillConfirm ?? true), + agents: bareMode + ? undefined + : projectAgentsSettings(nextSettings.merged.agents), + worktree: + bareMode || !runtimeSettings.worktree + ? undefined + : { + symlinkDirectories: + runtimeSettings.worktree.symlinkDirectories, + }, + onPersistPermissionRule: + createPermissionRulePersistenceCallback(targetDir), + disableAllHooks: + bareMode || safeMode + ? true + : (nextSettings.merged.disableAllHooks ?? false), + stopHookBlockingCap: + bareMode || safeMode + ? undefined + : nextSettings.merged.stopHookBlockingCap, + userHooks: + bareMode || safeMode ? undefined : nextSettings.getUserHooks(), + projectHooks: + bareMode || safeMode ? undefined : nextSettings.getProjectHooks(), + mcpServers: assembled, + allowedMcpServers: gating.allowed, + excludedMcpServers: gating.excluded, + pendingMcpServers: gating.pending, + }, + async commit() { + if (committed) return; + resumeWatching = await settingsWatcher?.pauseWorkspaceWatching?.(); + try { + nextSettings.persistInMemoryMigrations(); + previousSettings = loadedSettings.replaceWith(nextSettings); + committed = true; + } catch (error) { + resumeWatching?.(); + resumeWatching = undefined; + throw error; + } + // The target's `.env` files and `settings.env` must reach + // `process.env` too — the assembled MCP servers inherit it at spawn + // and expand their commands against it. Same step serve's + // `workspaceReload` performs. Skipped for bare sessions and for + // processes hosting other sessions (see `reloadProcessEnvironment`). + if (reloadProcessEnvironment) { + reloadEnvironment(nextSettings.merged, targetDir, effectiveTrust); + environmentReloaded = true; + preparedRuntime.config.webSearch = safeMode + ? undefined + : resolveWebSearchSettings(runtimeSettings, process.env); + preparedRuntime.config.disabledSlashCommands = + resolveDisabledSlashCommands( + runtimeSettings, + argv, + bareMode, + safeMode, + process.env, + ); + } + }, + async rollback() { + if (!committed || !previousSettings) return; + loadedSettings.replaceWith(previousSettings); + committed = false; + if (environmentReloaded) { + reloadEnvironment( + previousSettings.merged, + previousDir, + previousSettings.isTrusted, + ); + environmentReloaded = false; + } + resumeWatching?.(); + resumeWatching = undefined; + appEvents.emit(AppEvent.McpPendingApprovalChanged); + }, + async complete() { + if (!committed) return; + resumeWatching?.(); + resumeWatching = undefined; + appEvents.emit(AppEvent.McpPendingApprovalChanged); + }, + }; + return preparedRuntime; + }, + }; +} + /** * Thrown (instead of `process.exit(1)`) when a caller-supplied session id * already exists and `throwOnSessionIdConflict` is set. The interactive CLI @@ -1302,7 +1911,10 @@ export async function loadCliConfig( * stopped during shutdown — only `stopWatching()` is exposed here to keep * core decoupled from the CLI-owned `SettingsWatcher` implementation. */ - settingsWatcher?: { stopWatching(): void }, + settingsWatcher?: { + stopWatching(): void; + pauseWorkspaceWatching?: () => Promise<() => void>; + }, /** * When true, a duplicate caller-supplied session id throws * `SessionIdConflictError` instead of calling `process.exit(1)`. Embedded @@ -1319,12 +1931,17 @@ export async function loadCliConfig( toolInvocationGuard?: ToolInvocationGuard; /** Host-managed session whose exact private cwd is bound after bootstrap. */ provisionalWorkspace?: true; + /** Session policy that remains authoritative across project changes. */ + projectRuntimeCronEnabled?: boolean; + /** See {@link ProjectRuntimeHostPolicy.ownsProcessEnvironment}. */ + ownsProcessEnvironment?: () => boolean; sessionRestore?: { projectionSource: ( sessionId: string, ) => Promise; }; }, + loadedSettings?: LoadedSettings, ): Promise { const provisionalWorkspace = hostPolicy?.provisionalWorkspace === true; const debugMode = isDebugMode(argv); @@ -1414,11 +2031,19 @@ export async function loadCliConfig( settings.context?.fileFiltering?.customIgnoreFiles, ); + const cliIncludeDirectories = (argv.includeDirectories ?? []).map( + (directory) => resolveProjectPath(directory, cwd), + ); const includeDirectories = provisionalWorkspace ? [] - : (bareMode || safeMode ? [] : (settings.context?.includeDirectories ?? [])) - .map(resolvePath) - .concat((argv.includeDirectories || []).map(resolvePath)); + : [ + ...(bareMode || safeMode + ? [] + : (settings.context?.includeDirectories ?? []).map((directory) => + resolveProjectPath(directory, cwd), + )), + ...cliIncludeDirectories, + ]; // LSP configuration: enabled only via --experimental-lsp flag const lspEnabled = @@ -1564,28 +2189,12 @@ export async function loadCliConfig( // Merge the slash-command denylist from settings + CLI flag + env var. // Settings merge (UNION across scopes) is already handled upstream; we // only de-duplicate while preserving case for diagnostic purposes. - const disabledSlashCommands: string[] = []; - const seenDisabled = new Set(); - const addDisabled = (value: string | undefined) => { - if (!value) return; - const trimmed = value.trim(); - if (!trimmed) return; - const key = trimmed.toLowerCase(); - if (!seenDisabled.has(key)) { - seenDisabled.add(key); - disabledSlashCommands.push(trimmed); - } - }; - if (!bareMode && !safeMode) { - for (const name of settings.slashCommands?.disabled ?? []) - addDisabled(name); - } - for (const name of argv.disabledSlashCommands ?? []) addDisabled(name); - for (const name of (process.env['QWEN_DISABLED_SLASH_COMMANDS'] ?? '').split( - ',', - )) { - addDisabled(name); - } + const disabledSlashCommands = resolveDisabledSlashCommands( + settings, + argv, + bareMode, + safeMode, + ); // Resolve the per-workspace tool denylist. De-duplicate while preserving // original casing; shared helper since the MCP restart refresh path @@ -1598,46 +2207,7 @@ export async function loadCliConfig( bareMode || safeMode ? [] : normalizeDisabledToolList(settings.tools?.visible); - // `tools.eager` restricts which schemas ride in the initial model request - // (#9827). Unlisted tools stay registered and reachable via tool_search — - // it is a schema-size knob, not an availability knob (#10075). - // - // An explicitly empty array must survive as an empty array, not collapse - // into "unset": `[]` is an active allowlist naming nothing (defer - // everything). `tools.core` differs: its empty list is treated as unset. - // `normalizeDisabledToolList` maps undefined to `[]`, - // so the Array.isArray guard has to come first — without it, absent and - // explicitly-empty would reach core as the same value, which is exactly - // the SDK divergence #10138 reports for coreTools. - const eagerTools = - bareMode || safeMode || !Array.isArray(settings.tools?.eager) - ? undefined - : normalizeDisabledToolList(settings.tools.eager); - if (eagerTools !== undefined) { - // `normalizeDisabledToolList` strips empty/whitespace-only and - // non-string entries before `PermissionManager.initialize()` ever sees - // the list, so the dropped-entries warning there can never fire for - // that class on the real CLI path — and a degenerate list like - // `tools.eager: [""]` would collapse to the active defer-everything - // allowlist `[]` in silence. Warn here so the collapse always leaves a - // signal (#10075). - const droppedEagerEntries = (settings.tools?.eager ?? []).filter( - (entry) => typeof entry !== 'string' || entry.trim() === '', - ); - if (droppedEagerEntries.length > 0) { - // eslint-disable-next-line no-console -- operator-facing breadcrumb; the debug log file is off in default runs, where this reshaping would otherwise be invisible - console.warn( - `tools.eager: ignoring ${droppedEagerEntries.length} unusable entr${ - droppedEagerEntries.length === 1 ? 'y' : 'ies' - } (${droppedEagerEntries - .map((entry) => JSON.stringify(entry)) - .join(', ')}). ` + - `The allowlist stays active with ${eagerTools.length} entr${ - eagerTools.length === 1 ? 'y' : 'ies' - }, so every other non-exempt tool is deferred to tool_search.`, - ); - } - } + const eagerTools = resolveEagerTools(settings, bareMode, safeMode); // Helper: check if a tool is explicitly covered by an allow rule OR by the // coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled) @@ -1764,11 +2334,11 @@ export async function loadCliConfig( // Note: no `^` anchor — model names may include a provider prefix // (e.g. "openrouter/deepseek/deepseek-v4-flash"). const toolSearchExplicitlyEnabled = settings.tools?.toolSearch?.enabled; + const modelDisablesToolSearch = + resolvedModel !== undefined && /deepseek-(v3|v4|chat)/i.test(resolvedModel); const shouldDisableToolSearch = toolSearchExplicitlyEnabled === false || - (toolSearchExplicitlyEnabled === undefined && - resolvedModel !== undefined && - /deepseek-(v3|v4|chat)/i.test(resolvedModel)); + (toolSearchExplicitlyEnabled === undefined && modelDisablesToolSearch); if (shouldDisableToolSearch) { if (!mergedDeny.includes('tool_search')) { mergedDeny.push('tool_search'); @@ -1947,6 +2517,27 @@ export async function loadCliConfig( bareMode || safeMode || approvalMode === ApprovalMode.YOLO ? undefined : getPendingGatedMcpServers(mcpServers, cwd); + const projectRuntimeReloader = loadedSettings + ? createProjectRuntimeReloader( + loadedSettings, + settingsWatcher, + argv, + topTierMcpServers, + bareMode, + safeMode, + cliIncludeDirectories, + modelDisablesToolSearch, + hostPolicy + ? { + ...(provisionalWorkspace + ? { provisionalWorkspace: true as const } + : {}), + cronEnabled: hostPolicy.projectRuntimeCronEnabled, + ownsProcessEnvironment: hostPolicy.ownsProcessEnvironment, + } + : undefined, + ) + : undefined; const configParams: ConfigParameters = { sessionId, @@ -2004,7 +2595,7 @@ export async function loadCliConfig( .filter( (d): d is string => typeof d === 'string' && d.trim().length > 0, ) - .map((d) => d.trim()), + .map((d) => resolveProjectPath(d.trim(), cwd)), disabledTools: disabledTools.length > 0 ? disabledTools : undefined, visibleTools: visibleTools.length > 0 ? visibleTools : undefined, eagerTools, @@ -2020,19 +2611,7 @@ export async function loadCliConfig( }, toolInvocationGuard: hostPolicy?.toolInvocationGuard, // Permission rule persistence callback (writes to settings files). - onPersistPermissionRule: async (scope, ruleType, rule) => { - const currentSettings = loadSettings(cwd); - const settingScope = - scope === 'project' ? SettingScope.Workspace : SettingScope.User; - const key = `permissions.${ruleType}`; - const currentRules: string[] = - currentSettings.forScope(settingScope).settings.permissions?.[ - ruleType - ] ?? []; - if (!currentRules.includes(rule)) { - currentSettings.setValue(settingScope, key, [...currentRules, rule]); - } - }, + onPersistPermissionRule: createPermissionRulePersistenceCallback(cwd), toolDiscoveryCommand: bareMode || safeMode ? undefined : settings.tools?.discoveryCommand, toolCallCommand: @@ -2245,33 +2824,14 @@ export async function loadCliConfig( lsp: { enabled: lspEnabled, }, - agents: settings.agents - ? { - builtin: settings.agents.builtin - ? { - exploreModel: settings.agents.builtin.exploreModel, - } - : undefined, - modelGrades: settings.agents.modelGrades, - allowedGrades: settings.agents.allowedGrades, - maxParallelAgents: settings.agents.maxParallelAgents, - maxParallelAgentsByModel: settings.agents.maxParallelAgentsByModel, - displayMode: settings.agents.displayMode, - arena: settings.agents.arena - ? { - worktreeBaseDir: settings.agents.arena.worktreeBaseDir, - preserveArtifacts: - settings.agents.arena.preserveArtifacts ?? false, - } - : undefined, - } - : undefined, + agents: projectAgentsSettings(settings.agents), worktree: settings.worktree ? { symlinkDirectories: settings.worktree.symlinkDirectories, } : undefined, settingsWatcher, + projectRuntimeReloader, }; const config = new Config(configParams); diff --git a/packages/cli/src/config/cron-relocation-notice.ts b/packages/cli/src/config/cron-relocation-notice.ts new file mode 100644 index 00000000000..db53911a781 --- /dev/null +++ b/packages/cli/src/config/cron-relocation-notice.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Rewords the cron scheduler's exit summary for a `/cd`: the scheduler is + * destroyed and restarted for the new project, and its session-only jobs + * and loop wakeups do not survive the swap. The summary is phrased for + * session exit ("Session ending. N active loops cancelled: …"); the move + * is not the end of the session, only of those loops. + */ +export function formatCronRelocationNotice(exitSummary: string): string { + const cancelled = exitSummary.replace(/^Session ending\.\s*/, ''); + return `Working directory changed; ${cancelled}`; +} diff --git a/packages/cli/src/config/settings-cache.test.ts b/packages/cli/src/config/settings-cache.test.ts index 79edbb6f823..d0254a87161 100644 --- a/packages/cli/src/config/settings-cache.test.ts +++ b/packages/cli/src/config/settings-cache.test.ts @@ -25,6 +25,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearSettingsCacheForTesting, loadSettingsCached, + loadSettingsCachedForSession, } from './settings-cache.js'; import { resetEnvironmentTrackingForTesting, @@ -120,6 +121,44 @@ describe('loadSettingsCached', () => { expect(second.merged.model?.name).toBe('cached'); }); + it('gives each daemon session an isolated mutable settings instance', () => { + const otherWorkspace = path.join(tmpRoot, 'project-b'); + writeJson( + workspaceSettingsPath(), + versioned({ permissions: { allow: ['rule-a'] } }), + ); + writeJson( + workspaceSettingsPath(otherWorkspace), + versioned({ permissions: { allow: ['rule-b'] } }), + ); + + const firstSession = loadSettingsCachedForSession(workspaceDir); + const siblingSession = loadSettingsCachedForSession(workspaceDir); + const cachedSource = loadSettingsCached(workspaceDir); + expect(firstSession).not.toBe(siblingSession); + expect(firstSession.workspace).not.toBe(cachedSource.workspace); + firstSession.replaceWith(loadSettingsCachedForSession(otherWorkspace)); + + expect(firstSession.merged.permissions?.allow).toEqual(['rule-b']); + expect(siblingSession.merged.permissions?.allow).toEqual(['rule-a']); + expect(loadSettingsCached(workspaceDir)).toBe(cachedSource); + expect(cachedSource.merged.permissions?.allow).toEqual(['rule-a']); + + siblingSession.setValue( + SettingScope.Workspace, + 'model.name', + 'model-from-sibling', + ); + expect( + JSON.parse(fs.readFileSync(workspaceSettingsPath(), 'utf8')), + ).toMatchObject({ model: { name: 'model-from-sibling' } }); + expect( + JSON.parse( + fs.readFileSync(workspaceSettingsPath(otherWorkspace), 'utf8'), + ), + ).not.toHaveProperty('model'); + }); + it('reloads when the user settings file changes', () => { writeJson(userSettingsPath(), versioned({ model: { name: 'before' } })); const first = loadSettingsCached(workspaceDir); diff --git a/packages/cli/src/config/settings-cache.ts b/packages/cli/src/config/settings-cache.ts index 1c59faf7088..f4040875d95 100644 --- a/packages/cli/src/config/settings-cache.ts +++ b/packages/cli/src/config/settings-cache.ts @@ -14,6 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { findEnvFiles, preResolveHomeEnvOverrides } from './environment.js'; import { + cloneLoadedSettings, getSystemDefaultsPath, getSystemSettingsPath, getUserSettingsPath, @@ -236,6 +237,17 @@ export function loadSettingsCached(workspaceDir: string): LoadedSettings { return settings; } +/** + * Returns a session-owned copy of a cached snapshot. Project relocation + * mutates its LoadedSettings in place, so daemon sessions must never receive + * the cache entry itself or share nested settings objects with siblings. + */ +export function loadSettingsCachedForSession( + workspaceDir: string, +): LoadedSettings { + return cloneLoadedSettings(loadSettingsCached(workspaceDir)); +} + export function clearSettingsCacheForTesting(): void { cache.clear(); } diff --git a/packages/cli/src/config/settings.project-runtime.test.ts b/packages/cli/src/config/settings.project-runtime.test.ts new file mode 100644 index 00000000000..f40a1ae9a9d --- /dev/null +++ b/packages/cli/src/config/settings.project-runtime.test.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FatalConfigError, Storage } from '@qwen-code/qwen-code-core'; +import { + LoadedSettings, + loadSettings, + resetHomeEnvBootstrapForTesting, + SettingScope, + type SettingsFile, +} from './settings.js'; + +function settingsFile( + filePath: string, + settings: SettingsFile['settings'], +): SettingsFile { + return { + path: filePath, + settings, + originalSettings: structuredClone(settings), + rawJson: JSON.stringify(settings), + }; +} + +describe('project runtime settings', () => { + let tempDir: string; + let previousQwenHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-project-runtime-')); + previousQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = path.join(tempDir, 'home'); + resetHomeEnvBootstrapForTesting(); + }); + + afterEach(() => { + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + resetHomeEnvBootstrapForTesting(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('rejects invalid target settings without modifying the file', () => { + const workspace = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + const settingsPath = new Storage(workspace).getWorkspaceSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, '{ invalid'); + + expect(() => + loadSettings(workspace, { + consumeCorruptionEnvVars: false, + readOnly: true, + skipLoadEnvironment: true, + workspaceTrusted: true, + }), + ).toThrow(FatalConfigError); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe('{ invalid'); + expect(fs.existsSync(`${settingsPath}.corrupted`)).toBe(false); + }); + + it('does not stamp $version into a readOnly load of an unversioned file', () => { + // A readOnly (`/cd` prepare) load is observation-only. Stamping the + // version in memory without writing it made the first hot reload + // re-parse the un-stamped file into a different shape than the one + // the session applied — and invited a later write to persist a bump + // the user never asked for. + const workspace = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + const settingsPath = new Storage(workspace).getWorkspaceSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const raw = '{"permissions":{"allow":[]}}'; + fs.writeFileSync(settingsPath, raw); + + const loaded = loadSettings(workspace, { + consumeCorruptionEnvVars: false, + readOnly: true, + skipLoadEnvironment: true, + workspaceTrusted: true, + }); + + expect('$version' in loaded.workspace.settings).toBe(false); + expect(loaded.workspace.settings.permissions?.allow).toEqual([]); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(raw); + }); + + it('migrates a legacy target in memory without touching the file', () => { + // The `/cd` reloader loads read-only: the target's settings.json must + // not be rewritten before the move is committed (rollback restores + // memory, never disk). + const workspace = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + const settingsPath = new Storage(workspace).getWorkspaceSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const legacy = JSON.stringify({ theme: 'dark' }); + fs.writeFileSync(settingsPath, legacy); + + const loaded = loadSettings(workspace, { + consumeCorruptionEnvVars: false, + readOnly: true, + skipLoadEnvironment: true, + workspaceTrusted: true, + }); + + expect(loaded.merged.ui?.theme).toBe('dark'); + expect(loaded.migratedInMemoryScopes.has(SettingScope.Workspace)).toBe( + true, + ); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(legacy); + }); + + it('keeps an in-memory migration across a hot reload of the legacy file', () => { + // After the move the file on disk is still in its legacy layout. The + // first chokidar event on it re-parses the raw file; without applying + // the same migration the session silently regresses to the legacy + // shape until restart. + const workspace = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + const settingsPath = new Storage(workspace).getWorkspaceSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ theme: 'dark' })); + const next = loadSettings(workspace, { + consumeCorruptionEnvVars: false, + readOnly: true, + skipLoadEnvironment: true, + workspaceTrusted: true, + }); + const current = new LoadedSettings( + settingsFile('/system', {}), + settingsFile('/defaults', {}), + settingsFile('/user', {}), + settingsFile('/project-a', {}), + true, + new Set(), + ); + current.replaceWith(next); + expect(current.merged.ui?.theme).toBe('dark'); + + fs.writeFileSync(settingsPath, JSON.stringify({ theme: 'light' })); + current.reloadScopeFromDisk(SettingScope.Workspace); + + expect(current.merged.ui?.theme).toBe('light'); + expect( + (current.merged as unknown as Record)['theme'], + ).toBeUndefined(); + }); + + it('replaces workspace state without changing the LoadedSettings identity', () => { + const current = new LoadedSettings( + settingsFile('/system', {}), + settingsFile('/defaults', {}), + settingsFile('/user', { general: { language: 'en' } }), + settingsFile('/project-a', { disableAllHooks: true }), + true, + new Set(), + ); + const next = new LoadedSettings( + settingsFile('/system', {}), + settingsFile('/defaults', {}), + settingsFile('/user', { general: { language: 'en' } }), + settingsFile('/project-b', { disableAllHooks: false }), + true, + new Set(), + ); + + const previous = current.replaceWith(next); + + expect(current.workspace.path).toBe('/project-b'); + expect(current.merged.disableAllHooks).toBe(false); + current.replaceWith(previous); + expect(current.workspace.path).toBe('/project-a'); + expect(current.merged.disableAllHooks).toBe(true); + }); +}); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 538027ee492..02f51462ed2 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -488,16 +488,16 @@ export class LoadedSettings { this._merged = this.computeMergedSettings(); } - readonly system: SettingsFile; - readonly systemDefaults: SettingsFile; - readonly user: SettingsFile; - readonly workspace: SettingsFile; - readonly isTrusted: boolean; - readonly migratedInMemoryScopes: Set; - readonly migrationWarnings: string[]; - readonly corruptedPath: string | undefined; - readonly wasRecovered: boolean; - readonly workspaceSettingsActive: boolean; + system: SettingsFile; + systemDefaults: SettingsFile; + user: SettingsFile; + workspace: SettingsFile; + isTrusted: boolean; + migratedInMemoryScopes: Set; + migrationWarnings: string[]; + corruptedPath: string | undefined; + wasRecovered: boolean; + workspaceSettingsActive: boolean; corruptionDialogDismissed: boolean = false; private _merged: Settings; @@ -606,6 +606,48 @@ export class LoadedSettings { this._merged = this.computeMergedSettings(); } + replaceWith(next: LoadedSettings): LoadedSettings { + const previous = new LoadedSettings( + this.system, + this.systemDefaults, + this.user, + this.workspace, + this.isTrusted, + this.migratedInMemoryScopes, + this.migrationWarnings, + this.corruptedPath, + this.wasRecovered, + this.workspaceSettingsActive, + ); + this.system = next.system; + this.systemDefaults = next.systemDefaults; + this.user = next.user; + this.workspace = next.workspace; + this.isTrusted = next.isTrusted; + this.migratedInMemoryScopes = next.migratedInMemoryScopes; + this.migrationWarnings = next.migrationWarnings; + this.corruptedPath = next.corruptedPath; + this.wasRecovered = next.wasRecovered; + this.workspaceSettingsActive = next.workspaceSettingsActive; + this._merged = next.merged; + return previous; + } + + persistInMemoryMigrations(): void { + for (const scope of this.migratedInMemoryScopes) { + const file = this.forScope(scope); + const written = updateSettingsFilePreservingFormat( + file.path, + file.originalSettings, + true, + ); + if (!written) { + throw new Error(`Failed to persist migrated settings: ${file.path}`); + } + } + this.migratedInMemoryScopes.clear(); + } + reloadScopeFromDisk(scope: SettingScope): boolean { const file = this.forScope(scope); if (scope === SettingScope.Workspace && !this.workspaceSettingsActive) { @@ -626,8 +668,15 @@ export class LoadedSettings { } const content = fs.readFileSync(file.path, 'utf-8'); - const parsed = JSON.parse(stripJsonComments(content)); + let parsed = JSON.parse(stripJsonComments(content)); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + // A scope loaded read-only (the `/cd` reloader) was migrated in + // memory and never written back, so the file on disk is still in + // its legacy layout. Re-parsing it raw here would undo that + // migration on the first hot-reload after the move. + if (this.migratedInMemoryScopes.has(scope) && needsMigration(parsed)) { + parsed = runMigrations(parsed, scope).settings; + } const resolved = resolveEnvVarsInObject( parsed as Settings, getHomeEnvFallbackVars((message) => debugLogger.warn(message)), @@ -693,6 +742,29 @@ export class LoadedSettings { } } +export function cloneLoadedSettings(settings: LoadedSettings): LoadedSettings { + const cloneFile = (file: SettingsFile): SettingsFile => ({ + settings: structuredClone(file.settings), + originalSettings: structuredClone(file.originalSettings), + path: file.path, + rawJson: file.rawJson, + }); + const clone = new LoadedSettings( + cloneFile(settings.system), + cloneFile(settings.systemDefaults), + cloneFile(settings.user), + cloneFile(settings.workspace), + settings.isTrusted, + new Set(settings.migratedInMemoryScopes), + [...settings.migrationWarnings], + settings.corruptedPath, + settings.wasRecovered, + settings.workspaceSettingsActive, + ); + clone.corruptionDialogDismissed = settings.corruptionDialogDismissed; + return clone; +} + /** * Creates a minimal LoadedSettings instance with empty settings. * Used in stream-json mode where settings are ignored. @@ -767,6 +839,7 @@ export const CORRUPTED_SUFFIX = '.corrupted'; */ export interface LoadSettingsOptions { consumeCorruptionEnvVars?: boolean; + readOnly?: boolean; skipLoadEnvironment?: boolean; skipWorkspaceSettings?: boolean; workspaceTrusted?: boolean; @@ -840,6 +913,13 @@ export function loadSettings( try { rawSettings = JSON.parse(stripJsonComments(content)); } catch (parseError: unknown) { + if (opts.readOnly) { + settingsErrors.push({ + message: getErrorMessage(parseError), + path: filePath, + }); + return { settings: {} }; + } // ===== JSON parse failed — enter corruption recovery ===== // Strategy: save corrupted file as .corrupted → reset to empty → // show dialog in UI. Never crash due to a corrupted settings file. @@ -931,6 +1011,10 @@ export function loadSettings( let migrationWarnings: string[] | undefined; const persistSettingsObject = (warningPrefix: string) => { + if (opts.readOnly) { + migratedInMemoryScopes.add(scope); + return; + } try { // Use sync mode to remove deprecated keys (zombie key prevention) // while preserving comments and formatting from the original file. @@ -966,7 +1050,8 @@ export function loadSettings( persistSettingsObject('Error migrating settings file on disk'); } else if ( (hasLegacyNumericVersion || hasInvalidVersion) && - !corruptedSaved + !corruptedSaved && + !opts.readOnly ) { // Migration was deemed needed but nothing executed. Normalize version metadata // to avoid repeated no-op checks on startup. @@ -978,13 +1063,17 @@ export function loadSettings( } } else if ( (!hasVersionKey || hasInvalidVersion || hasLegacyNumericVersion) && - !corruptedSaved + !corruptedSaved && + !opts.readOnly ) { // No migration needed/executable, but version metadata is missing or invalid. // Normalize it to current version to avoid repeated startup work. // Skip if we just recovered from corruption — the next startup will // handle normalization, avoiding an unnecessary writeWithBackupSync // that would create a .orig file from the freshly reset settings. + // A readOnly (`/cd` prepare) load is observation-only: stamping + // `$version` in memory without writing it would make the first + // hot reload re-parse an un-stamped file into a different shape. settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; persistSettingsObject('Error normalizing settings version on disk'); } diff --git a/packages/cli/src/config/settingsWatcher.test.ts b/packages/cli/src/config/settingsWatcher.test.ts index b9481b0c919..767653f0a04 100644 --- a/packages/cli/src/config/settingsWatcher.test.ts +++ b/packages/cli/src/config/settingsWatcher.test.ts @@ -219,6 +219,80 @@ describe('SettingsWatcher', () => { expect(mockWatchers[1].instance.close).toHaveBeenCalled(); }); + it('retargets only the workspace watcher after project settings change', async () => { + watcher.startWatching(); + + const resume = await watcher.pauseWorkspaceWatching(); + settings.workspace.path = '/next/.qwen/settings.json'; + resume(); + + expect(mockWatchers[0].instance.close).not.toHaveBeenCalled(); + expect(mockWatchers[1].instance.close).toHaveBeenCalledOnce(); + expect(mockWatch).toHaveBeenLastCalledWith( + '/next/.qwen', + expect.objectContaining({ ignoreInitial: true, depth: 0 }), + ); + }); + + it('does not re-arm the old workspace path from a promote that was in flight during the pause', async () => { + // promote/demote clear the watcher map BEFORE awaiting close(), so a + // pause that lands mid-close finds nothing to wait for. Without a + // generation re-check the stale continuation re-armed the PREVIOUS + // project's `.qwen` after the swap, so the new project's settings + // never hot-reloaded and one chokidar watcher leaked. + mockExistsSync.mockReturnValue(false); + watcher.startWatching(); + // Bootstrap watchers on both parents; the workspace one is index 1. + expect(mockWatchers[1].dir).toBe('/project'); + let releaseClose: () => void = () => undefined; + mockWatchers[1].instance.close.mockReturnValue( + new Promise((resolve) => { + releaseClose = resolve; + }), + ); + + // `.qwen` appears: promote starts and stalls inside close(). + fireAllEvent(1, 'addDir', '/project/.qwen'); + await vi.advanceTimersByTimeAsync(0); + + const resume = await watcher.pauseWorkspaceWatching(); + settings.workspace.path = '/next/.qwen/settings.json'; + mockExistsSync.mockReturnValue(true); + resume(); + const watchCallsAfterResume = mockWatch.mock.calls.length; + expect(mockWatch).toHaveBeenLastCalledWith( + '/next/.qwen', + expect.anything(), + ); + + releaseClose(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockWatch.mock.calls.length).toBe(watchCallsAfterResume); + expect(mockWatch).not.toHaveBeenCalledWith( + '/project/.qwen', + expect.anything(), + ); + }); + + it('reconciles the new workspace settings against disk when watching resumes', async () => { + // The re-armed watcher starts with `ignoreInitial`, and the pause + // dropped pending workspace changes — an edit that landed during the + // commit→complete window would otherwise never hot-reload. + watcher.startWatching(); + const scheduleRefresh = vi.spyOn( + watcher as unknown as { scheduleRefresh(scope: SettingScope): void }, + 'scheduleRefresh', + ); + + const resume = await watcher.pauseWorkspaceWatching(); + settings.workspace.path = '/next/.qwen/settings.json'; + expect(scheduleRefresh).not.toHaveBeenCalled(); + resume(); + + expect(scheduleRefresh).toHaveBeenCalledWith(SettingScope.Workspace); + }); + it('should be idempotent on double stop', () => { watcher.startWatching(); watcher.stopWatching(); diff --git a/packages/cli/src/config/settingsWatcher.ts b/packages/cli/src/config/settingsWatcher.ts index 2ff653326b3..17b88e3eaea 100644 --- a/packages/cli/src/config/settingsWatcher.ts +++ b/packages/cli/src/config/settingsWatcher.ts @@ -113,6 +113,7 @@ export class SettingsWatcher { private refreshTimer: NodeJS.Timeout | null = null; private readonly pendingScopeChanges: Set = new Set(); private processing: boolean = false; + private processingPromise: Promise | null = null; private started: boolean = false; static readonly DEBOUNCE_MS = 300; @@ -240,8 +241,8 @@ export class SettingsWatcher { settingsPath: string, ): Promise { if (this.watchStage.get(scope) !== 'bootstrap') return; - await this.replaceWatcher(scope); - if (!this.started) return; + const generation = await this.replaceWatcher(scope); + if (!this.started || !this.isCurrentGeneration(scope, generation)) return; this.watchTargetDir(scope, settingsPath); // Pick up a settings.json that already exists inside the new `.qwen`. this.scheduleRefresh(scope); @@ -253,8 +254,8 @@ export class SettingsWatcher { settingsPath: string, ): Promise { if (this.watchStage.get(scope) !== 'target') return; - await this.replaceWatcher(scope); - if (!this.started) return; + const generation = await this.replaceWatcher(scope); + if (!this.started || !this.isCurrentGeneration(scope, generation)) return; this.watchParentForDir(scope, settingsPath); // Surface the deletion (rawJson goes undefined) to listeners. this.scheduleRefresh(scope); @@ -263,10 +264,15 @@ export class SettingsWatcher { /** * Bumps the scope generation and closes its current watcher, clearing the * map entries before the caller opens the next watcher. Bumping first makes - * any in-flight callback from the closing watcher a no-op. + * any in-flight callback from the closing watcher a no-op. Returns the + * generation the caller now owns: the map entries are cleared BEFORE the + * (async) close, so a `pauseWorkspaceWatching` or another replace that + * runs during the close finds nothing to wait for and moves on — the + * caller must re-check the generation after the await, or it re-arms a + * watcher on a path the session has already left. */ - private async replaceWatcher(scope: SettingScope): Promise { - this.bumpGeneration(scope); + private async replaceWatcher(scope: SettingScope): Promise { + const generation = this.bumpGeneration(scope); const watcher = this.watchers.get(scope); this.watchers.delete(scope); this.watchStage.delete(scope); @@ -277,6 +283,11 @@ export class SettingsWatcher { debugLogger.warn('Settings watcher close error:', err); } } + return generation; + } + + private isCurrentGeneration(scope: SettingScope, generation: number) { + return (this.watchGeneration.get(scope) ?? 0) === generation; } private bumpGeneration(scope: SettingScope): number { @@ -306,6 +317,29 @@ export class SettingsWatcher { this.pendingScopeChanges.clear(); } + async pauseWorkspaceWatching(): Promise<() => void> { + if (!this.started) return () => undefined; + await this.replaceWatcher(SettingScope.Workspace); + this.pendingScopeChanges.delete(SettingScope.Workspace); + await this.processingPromise; + + return () => { + if (!this.started || !this.settings.workspaceSettingsActive) return; + const settingsPath = this.settings.workspace.path; + const dir = path.dirname(settingsPath); + if (fs.existsSync(dir)) { + this.watchTargetDir(SettingScope.Workspace, settingsPath); + } else { + this.watchParentForDir(SettingScope.Workspace, settingsPath); + } + // The new watcher starts with `ignoreInitial`, and the pause dropped + // pending workspace changes: an edit that landed while the swap was + // in flight would otherwise never be seen. Same reconciliation the + // promote/demote paths do; `handleChange` no-ops when nothing drifted. + this.scheduleRefresh(SettingScope.Workspace); + }; + } + addChangeListener(listener: SettingsChangeListener): () => void { this.changeListeners.add(listener); return () => { @@ -349,8 +383,15 @@ export class SettingsWatcher { } private async drainPendingChanges(): Promise { - if (this.processing) return; + if (this.processing) { + await this.processingPromise; + return; + } this.processing = true; + let resolveProcessing!: () => void; + this.processingPromise = new Promise((resolve) => { + resolveProcessing = resolve; + }); try { while (this.pendingScopeChanges.size > 0) { const scopes = new Set(this.pendingScopeChanges); @@ -359,6 +400,8 @@ export class SettingsWatcher { } } finally { this.processing = false; + resolveProcessing(); + this.processingPromise = null; } } diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index e1e075af419..0007d0cccda 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -855,6 +855,9 @@ describe('llm.tsx main function', () => { undefined, // settingsWatcher: not started in bare mode undefined, + false, + undefined, + expect.objectContaining({ merged: {} }), ); }); diff --git a/packages/cli/src/llm.tsx b/packages/cli/src/llm.tsx index 064460f1f07..e427c879c0e 100644 --- a/packages/cli/src/llm.tsx +++ b/packages/cli/src/llm.tsx @@ -874,6 +874,9 @@ export async function main() { buildDisabledSkillNamesProvider(settings), undefined, settingsWatcher, + false, + undefined, + settings, ); markAcpStartup('configConstructionEnd'); profileCheckpoint('after_load_cli_config'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d7de9ac9fc8..0d5b01e2db7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -44,7 +44,6 @@ import { describeDeliveryStatus, describeHoldCause, getErrorMessage, - getAllMemoryFilenames, ShellExecutionService, Storage, createInstructionsLoadedCallback, @@ -2212,6 +2211,9 @@ export const AppContainer = (props: AppContainerProps) => { onInstructionsLoaded: createInstructionsLoadedCallback(() => config.getHookSystem(), ), + // Session-scoped after `/cd`: the process-global names would + // reload the wrong file set and overwrite the relocated memory. + contextFileNames: config.getContextFileNames(), }, ); @@ -3375,14 +3377,11 @@ export const AppContainer = (props: AppContainerProps) => { }); // Context file names computation - const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; - return fromSettings - ? Array.isArray(fromSettings) - ? fromSettings - : [fromSettings] - : getAllMemoryFilenames(); - }, [settings.merged.context?.fileName]); + const contextFileNamesKey = config.getContextFileNames().join('\0'); + const contextFileNames = useMemo( + () => contextFileNamesKey.split('\0'), + [contextFileNamesKey], + ); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); diff --git a/packages/cli/src/ui/commands/cdCommand.test.ts b/packages/cli/src/ui/commands/cdCommand.test.ts index 9859fb929c9..c084e22222e 100644 --- a/packages/cli/src/ui/commands/cdCommand.test.ts +++ b/packages/cli/src/ui/commands/cdCommand.test.ts @@ -339,6 +339,47 @@ describe('cdCommand', () => { }); }); + it('reports a successful move when a project runtime refresh step fails', async () => { + relocateWorkingDirectory.mockResolvedValue({ + projectRuntimeRefreshErrors: [new Error('hooks failed'), 'skills failed'], + }); + + const result = (await cdCommand.action?.( + context, + '../next', + )) as MessageActionReturn; + const realNextDir = await realpath(nextDir); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + `Moved to ${realNextDir}. Project runtime refresh failed: hooks failed ` + + 'Project runtime refresh failed: skills failed', + }); + }); + + it('reports the session-only cron work the move cancelled', async () => { + relocateWorkingDirectory.mockResolvedValue({ + cronExitSummary: + 'Session ending. 1 active loop cancelled:\n - [job-1] every 5m: poll', + }); + + const result = (await cdCommand.action?.( + context, + '../next', + )) as MessageActionReturn; + const realNextDir = await realpath(nextDir); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + `Moved to ${realNextDir}. Working directory changed; 1 active loop cancelled:\n` + + ' - [job-1] every 5m: poll', + }); + }); + it('reports a successful move when MCP refresh fails afterward', async () => { relocateWorkingDirectory.mockResolvedValue({ mcpRefreshError: new Error('MCP failed'), @@ -519,6 +560,7 @@ describe('cdCommand', () => { expect(relocateWorkingDirectory).toHaveBeenCalledWith( realNextDir, realNextDir, + { trustedFolder: true }, ); expect(result).toEqual({ type: 'message', @@ -563,6 +605,7 @@ describe('cdCommand', () => { expect(relocateWorkingDirectory).toHaveBeenCalledWith( realNextDir, realNextDir, + { trustedFolder: true }, ); expect(result).toEqual({ type: 'message', diff --git a/packages/cli/src/ui/commands/cdCommand.ts b/packages/cli/src/ui/commands/cdCommand.ts index 03f11cd8825..86373f63e85 100644 --- a/packages/cli/src/ui/commands/cdCommand.ts +++ b/packages/cli/src/ui/commands/cdCommand.ts @@ -15,6 +15,7 @@ import { TrustLevel, } from '../../config/trustedFolders.js'; import { t } from '../../i18n/index.js'; +import { formatCronRelocationNotice } from '../../config/cron-relocation-notice.js'; const MAX_PENDING_TRUST_CONFIRMATIONS = 50; const pendingTrustedPathConfirmations = new Map(); @@ -166,10 +167,13 @@ export const cdCommand: SlashCommand = { const warnings: string[] = []; try { - const relocation = await config.relocateWorkingDirectory( - realTargetPath, - realTargetPath, - ); + const relocation = trustedTargetPath + ? await config.relocateWorkingDirectory( + realTargetPath, + realTargetPath, + { trustedFolder: true }, + ) + : await config.relocateWorkingDirectory(realTargetPath, realTargetPath); if (relocation.memoryRefreshError) { warnings.push( `Memory refresh failed: ${ @@ -188,6 +192,16 @@ export const cdCommand: SlashCommand = { }`, ); } + for (const error of relocation.projectRuntimeRefreshErrors ?? []) { + warnings.push( + `Project runtime refresh failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (relocation.cronExitSummary) { + warnings.push(formatCronRelocationNotice(relocation.cronExitSummary)); + } } catch (error) { return { type: 'message' as const, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 736e037baa6..c6603377e99 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -263,6 +263,7 @@ describe('directoryCommand', () => { mockConfig.getContextRuleExcludes = vi.fn().mockReturnValue([]); mockConfig.setContextFilePaths = vi.fn(); mockConfig.setConditionalRulesRegistry = vi.fn(); + mockConfig.getContextFileNames = vi.fn().mockReturnValue(['CONTEXT.md']); mockContext.ui.setMemoryFileCount = vi.fn(); if (!addCommand?.action) throw new Error('No action'); @@ -271,8 +272,10 @@ describe('directoryCommand', () => { path.normalize('/home/user/new-project'), ); - // Pin the CWD anchor (getWorkingDir, not process.cwd) and the new - // directory so an anchor regression can't slip through green. + // Pin the CWD anchor (getWorkingDir, not process.cwd), the new + // directory, and the session-scoped context-file names: after a + // `/cd` the process-global names would load the wrong file set and + // `setUserMemory` would overwrite the relocated memory with it. expect(loadServerHierarchicalMemory).toHaveBeenCalledWith( '/test/dir', expect.arrayContaining([path.normalize('/home/user/new-project')]), @@ -281,6 +284,7 @@ describe('directoryCommand', () => { true, 'tree', expect.anything(), + expect.objectContaining({ contextFileNames: ['CONTEXT.md'] }), ); expect(mockConfig.setUserMemory).toHaveBeenCalledWith('reloaded memory'); expect(mockConfig.setContextFilePaths).toHaveBeenCalledWith([ diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 65a16b0d2ed..84f015e3532 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -256,6 +256,10 @@ export const directoryCommand: SlashCommand = { context.services.settings.merged.context?.importFormat || 'tree', config.getContextRuleExcludes(), + // Session-scoped after `/cd`; the process-global default + // would load the wrong file set and overwrite the + // correctly relocated memory. + { contextFileNames: config.getContextFileNames() }, ); config.setUserMemory(memoryContent); config.setMemoryFileCount(fileCount); diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 72017744408..5ae0677bf24 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -44,6 +44,7 @@ describe('initCommand', () => { services: { config: { getTargetDir: () => targetDir, + getPrimaryContextFileName: () => DEFAULT_CONTEXT_FILENAME, }, }, }); diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index 8794017fc89..ba7263866ee 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -11,7 +11,6 @@ import type { SlashCommand, SlashCommandActionReturn, } from './types.js'; -import { getCurrentMemoryFilename } from '@qwen-code/qwen-code-core'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -34,7 +33,7 @@ export const initCommand: SlashCommand = { }; } const targetDir = context.services.config.getTargetDir(); - const contextFileName = getCurrentMemoryFilename(); + const contextFileName = context.services.config.getPrimaryContextFileName(); const contextFilePath = path.join(targetDir, contextFileName); try { diff --git a/packages/cli/src/ui/components/MemoryDialog.test.tsx b/packages/cli/src/ui/components/MemoryDialog.test.tsx index 7b9dd786f35..fc82aa12219 100644 --- a/packages/cli/src/ui/components/MemoryDialog.test.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.test.tsx @@ -127,6 +127,8 @@ describe('MemoryDialog', () => { mockedUseConfig.mockReturnValue({ getWorkingDir: vi.fn(() => '/tmp/project'), getProjectRoot: vi.fn(() => '/tmp/project'), + getPrimaryContextFileName: vi.fn(() => 'QWEN.md'), + getContextFileNames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), getBareMode: vi.fn(() => false), isSafeMode: vi.fn(() => false), // Stale snapshot getters — the dialog must NOT read its toggle state @@ -526,6 +528,8 @@ describe('MemoryDialog', () => { mockedUseConfig.mockReturnValue({ getWorkingDir: vi.fn(() => '/tmp/project'), getProjectRoot: vi.fn(() => '/tmp/project'), + getPrimaryContextFileName: vi.fn(() => 'QWEN.md'), + getContextFileNames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), getBareMode: vi.fn(() => true), isSafeMode: vi.fn(() => false), getManagedAutoMemoryEnabled: vi.fn(() => false), @@ -562,6 +566,8 @@ describe('MemoryDialog', () => { mockedUseConfig.mockReturnValue({ getWorkingDir: vi.fn(() => '/tmp/project'), getProjectRoot: vi.fn(() => '/tmp/project'), + getPrimaryContextFileName: vi.fn(() => 'QWEN.md'), + getContextFileNames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), getBareMode: vi.fn(() => true), isSafeMode: vi.fn(() => false), getManagedAutoMemoryEnabled: vi.fn(() => false), diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx index a8c08e11657..0ef31ff9695 100644 --- a/packages/cli/src/ui/components/MemoryDialog.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -11,7 +11,6 @@ import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { - getAllMemoryFilenames, Storage, getAutoMemoryRoot, getAutoMemoryProjectStateDir, @@ -46,8 +45,9 @@ interface DialogItem { async function resolvePreferredMemoryFile( dir: string, fallbackFilename: string, + contextFileNames: readonly string[], ): Promise { - for (const filename of getAllMemoryFilenames()) { + for (const filename of contextFileNames) { const filePath = path.join(dir, filename); try { await fs.access(filePath); @@ -148,18 +148,11 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { const globalMemoryPath = useMemo( () => - path.join( - Storage.getGlobalQwenDir(), - getAllMemoryFilenames()[0] ?? 'QWEN.md', - ), - [], + path.join(Storage.getGlobalQwenDir(), config.getPrimaryContextFileName()), + [config], ); const projectMemoryPath = useMemo( - () => - path.join( - config.getWorkingDir(), - getAllMemoryFilenames()[0] ?? 'QWEN.md', - ), + () => path.join(config.getWorkingDir(), config.getPrimaryContextFileName()), [config], ); const managedMemoryPath = useMemo( @@ -273,12 +266,14 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { case 'project': return resolvePreferredMemoryFile( config.getWorkingDir(), - getAllMemoryFilenames()[0] ?? 'QWEN.md', + config.getPrimaryContextFileName(), + config.getContextFileNames(), ); case 'global': return resolvePreferredMemoryFile( Storage.getGlobalQwenDir(), - getAllMemoryFilenames()[0] ?? 'QWEN.md', + config.getPrimaryContextFileName(), + config.getContextFileNames(), ); default: { const _exhaustive: never = item.value; diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx index 3cd5dd7ff45..728bebcade4 100644 --- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx +++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx @@ -341,14 +341,19 @@ describe('HooksManagementDialog', () => { }); const stopEventIndex = DISPLAY_HOOK_EVENTS.indexOf(HookEventName.Stop); + const eventIndexWidth = String(DISPLAY_HOOK_EVENTS.length).length; for (let i = 0; i < stopEventIndex; i++) { pressKey('down'); await vi.waitFor(() => { - expect(lastFrame()).toContain(`❯ ${i + 2}.`); + expect(lastFrame()).toContain( + `❯ ${String(i + 2).padStart(eventIndexWidth)}.`, + ); }); } await vi.waitFor(() => { - expect(lastFrame()).toContain(`❯ ${stopEventIndex + 1}. Stop`); + expect(lastFrame()).toContain( + `❯ ${String(stopEventIndex + 1).padStart(eventIndexWidth)}. Stop`, + ); }); pressKey('return'); await vi.waitFor(() => { diff --git a/packages/cli/src/ui/components/hooks/constants.test.ts b/packages/cli/src/ui/components/hooks/constants.test.ts index ccc33960697..db02170e7be 100644 --- a/packages/cli/src/ui/components/hooks/constants.test.ts +++ b/packages/cli/src/ui/components/hooks/constants.test.ts @@ -88,6 +88,11 @@ describe('hooks constants', () => { expect(exitCodes).toHaveLength(2); }); + it('should return exit codes for CwdChanged event', () => { + const exitCodes = getHookExitCodes(HookEventName.CwdChanged); + expect(exitCodes).toHaveLength(2); + }); + it('should return exit codes for SessionEnd event', () => { const exitCodes = getHookExitCodes(HookEventName.SessionEnd); expect(exitCodes).toHaveLength(2); @@ -161,6 +166,15 @@ describe('hooks constants', () => { expect(desc).toBe('When a new session is started'); }); + it('should describe CwdChanged', () => { + expect(getHookShortDescription(HookEventName.CwdChanged)).toBe( + 'After the session changes its working directory', + ); + expect(getHookDescription(HookEventName.CwdChanged)).toContain( + 'old_cwd and new_cwd', + ); + }); + it('should return description for SessionDelete', () => { expect(getHookShortDescription(HookEventName.SessionDelete)).toBe( 'After an explicitly selected session is deleted', diff --git a/packages/cli/src/ui/components/hooks/constants.ts b/packages/cli/src/ui/components/hooks/constants.ts index fd03b36dcc5..c3ea3894ed6 100644 --- a/packages/cli/src/ui/components/hooks/constants.ts +++ b/packages/cli/src/ui/components/hooks/constants.ts @@ -93,6 +93,10 @@ export function getHookExitCodes(eventName: string): HookExitCode[] { description: t('show stderr to user only (blocking errors ignored)'), }, ], + [HookEventName.CwdChanged]: [ + { code: 0, description: t('command completes successfully') }, + { code: 'Other', description: t('show stderr to user only') }, + ], [HookEventName.SessionEnd]: [ { code: 0, description: t('command completes successfully') }, { code: 'Other', description: t('show stderr to user only') }, @@ -188,6 +192,9 @@ export function getHookShortDescription(eventName: string): string { 'When a slash command expands into a prompt', ), [HookEventName.SessionStart]: t('When a new session is started'), + [HookEventName.CwdChanged]: t( + 'After the session changes its working directory', + ), [HookEventName.MessageDisplay]: t( 'Repeatedly, as the assistant reply streams', ), @@ -252,6 +259,9 @@ export function getHookDescription(eventName: string): string { [HookEventName.SessionStart]: t( 'Input to command is JSON with session start source.', ), + [HookEventName.CwdChanged]: t( + 'Input to command is JSON with old_cwd and new_cwd.', + ), [HookEventName.MessageDisplay]: t( 'Input to command is JSON with message_id, displayed_text (cumulative text streamed so far), and is_final. Fire-and-forget: output and exit status are ignored.', ), diff --git a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx index 348111c465a..2a3807b6304 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx +++ b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx @@ -295,6 +295,7 @@ describe('useLlmStream', () => { () => ({ getToolSchemaList: vi.fn(() => []) }) as any, ), getProjectRoot: vi.fn(() => '/test/dir'), + getContextFileNames: vi.fn(() => ['QWEN.md']), getFileCheckpointingEnabled: vi.fn(() => false), getLlmClient: mockGetLlmClient, getApprovalMode: () => ApprovalMode.DEFAULT, @@ -17677,17 +17678,21 @@ describe('useLlmStream', () => { describe('cron scheduler initialization', () => { // Renders useLlmStream wired to a provided cron scheduler mock, with a - // controllable isConfigInitialized gate. `config` identity is stable across - // rerenders so the cron effect only re-runs when `initialized` flips. + // controllable isConfigInitialized gate and working directory. `config` + // identity is stable across rerenders. const renderCronHook = (scheduler: unknown, initialized: boolean) => { + let activeScheduler = scheduler; + let workingDir = '/tmp'; const cronConfig = { ...mockConfig, isCronEnabled: vi.fn(() => true), - getCronScheduler: vi.fn(() => scheduler), + getCronScheduler: vi.fn(() => activeScheduler), + getWorkingDir: vi.fn(() => workingDir), } as unknown as Config; - return renderHook( - (props: { initialized: boolean }) => - useLlmStream( + const rendered = renderHook( + (props: { initialized: boolean; workingDir?: string }) => { + workingDir = props.workingDir ?? workingDir; + return useLlmStream( new MockedLlmClientClass(cronConfig), [], mockAddItem, @@ -17709,9 +17714,15 @@ describe('useLlmStream', () => { () => {}, 80, 24, - ), - { initialProps: { initialized } }, + ); + }, + { initialProps: { initialized, workingDir } }, ); + return Object.assign(rendered, { + setScheduler(nextScheduler: unknown) { + activeScheduler = nextScheduler; + }, + }); }; it('defers enableDurable and start until isConfigInitialized is true', async () => { @@ -17817,6 +17828,32 @@ describe('useLlmStream', () => { expect(scheduler.start).toHaveBeenCalled(); }); }); + + it('starts the scheduler for the new project after the working directory changes', async () => { + const createScheduler = () => ({ + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn(), + stop: vi.fn(), + getExitSummary: vi.fn(() => null), + hasPendingWork: false, + }); + const previousScheduler = createScheduler(); + const nextScheduler = createScheduler(); + const rendered = renderCronHook(previousScheduler, true); + + await waitFor(() => { + expect(previousScheduler.start).toHaveBeenCalledOnce(); + }); + + rendered.setScheduler(nextScheduler); + rendered.rerender({ initialized: true, workingDir: '/next-project' }); + + await waitFor(() => { + expect(previousScheduler.stop).toHaveBeenCalledOnce(); + expect(nextScheduler.enableDurable).toHaveBeenCalledOnce(); + expect(nextScheduler.start).toHaveBeenCalledOnce(); + }); + }); }); describe('timestamp attachment', () => { diff --git a/packages/cli/src/ui/hooks/use-llm-stream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts index f74fcd5c73d..842fc364d18 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -5113,6 +5113,7 @@ export const useLlmStream = ( const matchedContextFileWrite = didWriteProjectContextFile( memoryWriteCandidates, config.getProjectRoot(), + config.getContextFileNames(), ); debugLogger.debug( `Checked marked context-file memory tool batch; matched=${matchedContextFileWrite}`, @@ -5985,10 +5986,16 @@ export const useLlmStream = ( // effect doesn't list sessionId as a dep. Keeping it out of the deps is // deliberate: /clear swaps the sessionId mid-session, and a re-run would // fire the cleanup below — printing a false "loops cancelled" notice and - // tearing down a scheduler that immediately restarts. The effect should - // run once on mount and clean up only on real unmount. + // tearing down a scheduler that immediately restarts. The effect DOES + // list `cronWorkingDir`: a `/cd` must stop the previous project's + // scheduler and start the target's. On that re-run the cleanup's exit + // summary is null because `relocateWorkingDirectory` already destroyed + // the old scheduler before committing the new working directory, so no + // false notice is printed. const cronSessionIdRef = useRef(sessionStates.sessionId); cronSessionIdRef.current = sessionStates.sessionId; + const cronWorkingDir = + config.getWorkingDir?.() ?? config.getTargetDir?.() ?? ''; // Start the cron scheduler once config is initialized, stop on unmount. // Cron fires enqueue onto the shared notification queue. @@ -6076,7 +6083,12 @@ export const useLlmStream = ( process.stderr.write(summary + '\n'); } }; - }, [config, getAutonomousLoopTickResolver, isConfigInitialized]); + }, [ + config, + cronWorkingDir, + getAutonomousLoopTickResolver, + isConfigInitialized, + ]); // Register background agent notification callback onto the shared queue. useEffect(() => { diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index fd1fcb5e8b5..6b0c138ea26 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -826,6 +826,39 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-3')).toBeUndefined(); }); + it('applies updated limits and drains newly available slots', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + const reservationPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + expect(registry.getQueuedCount()).toBe(1); + + registry.setConcurrencyLimits({ maxConcurrentBackgroundAgents: 2 }); + + expect(registry.getMaxConcurrentBackgroundAgents()).toBe(2); + expect(await reservationPromise).toBeDefined(); + expect(registry.getQueuedCount()).toBe(0); + }); + + it('replaces the per-model caps instead of merging them', () => { + // `/cd` swaps the map wholesale: a project without + // `maxParallelAgentsByModel` must not keep throttling the previous + // project's models. + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 4, + maxConcurrentBackgroundAgentsByModel: { 'some-model': 1 }, + }); + registry.register(makeRegistration('bg-1', { model: 'some-model' })); + expect(registry.canStartBackgroundAgent('some-model')).toBe(false); + + registry.setConcurrencyLimits({ maxConcurrentBackgroundAgents: 2 }); + + expect(registry.canStartBackgroundAgent('some-model')).toBe(true); + }); + it('allows replacing the same running background agent at the cap', () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 6df52841fe0..9c80206bc4c 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -526,11 +526,11 @@ export class BackgroundTaskRegistry { symbol, BackgroundSlotClaim >(); - private readonly maxConcurrentBackgroundAgents: number; + private maxConcurrentBackgroundAgents!: number; // Per-model concurrency caps keyed by concrete model ID. Empty when no // `agents.maxParallelAgentsByModel` is configured, in which case only the // global cap is enforced. - private readonly maxConcurrentBackgroundAgentsByModel: Map; + private maxConcurrentBackgroundAgentsByModel!: Map; private notificationCallback?: BackgroundNotificationCallback; private registerCallback?: BackgroundRegisterCallback; private statusChangeCallback?: BackgroundStatusChangeCallback; @@ -538,6 +538,18 @@ export class BackgroundTaskRegistry { private approvalChangeCallback?: BackgroundApprovalChangeCallback; constructor(options: BackgroundTaskRegistryOptions = {}) { + // One validation path for construction and `/cd` reload, so the two + // cannot enforce different caps. Draining the (empty) wait queue is a + // no-op here. + this.setConcurrencyLimits(options); + } + + /** + * Replaces both concurrency caps wholesale — the per-model map is NOT + * merged with the previous one — and hands newly available slots to + * queued waiters. Called on construction and after `/cd`. + */ + setConcurrencyLimits(options: BackgroundTaskRegistryOptions = {}): void { const configured = options.maxConcurrentBackgroundAgents ?? MAX_CONCURRENT_BACKGROUND_AGENTS; this.maxConcurrentBackgroundAgents = @@ -547,6 +559,7 @@ export class BackgroundTaskRegistry { this.maxConcurrentBackgroundAgentsByModel = normalizePerModelConcurrency( options.maxConcurrentBackgroundAgentsByModel, ); + this.drainWaitQueue(); } /** diff --git a/packages/core/src/config/config-session-env.test.ts b/packages/core/src/config/config-session-env.test.ts index b65c6f08036..d649c37b99c 100644 --- a/packages/core/src/config/config-session-env.test.ts +++ b/packages/core/src/config/config-session-env.test.ts @@ -90,6 +90,9 @@ vi.mock('../ide/ide-client.js', () => ({ })); vi.mock('../utils/memory-constants.js', () => ({ setMemoryFilename: vi.fn(), + setGeminiMdFilename: vi.fn(), + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 2bbb7df3892..e684cef2220 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises'; import type { Stats } from 'node:fs'; -import type { ConfigParameters, SandboxConfig } from './config.js'; +import type { + ConfigParameters, + ProjectRuntimeConfig, + SandboxConfig, +} from './config.js'; import { Config, ApprovalMode, @@ -164,6 +168,15 @@ vi.mock('../tools/tool-registry', () => { ToolRegistryMock.prototype.ensureTool = vi.fn(); ToolRegistryMock.prototype.warmAll = vi.fn(); ToolRegistryMock.prototype.discoverAllTools = vi.fn(); + ToolRegistryMock.prototype.replaceCoreToolsFrom = vi + .fn() + .mockResolvedValue(undefined); + ToolRegistryMock.prototype.clearProjectRuntimeTools = vi + .fn() + .mockResolvedValue(undefined); + ToolRegistryMock.prototype.rediscoverCommandTools = vi + .fn() + .mockResolvedValue(undefined); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []); ToolRegistryMock.prototype.getTool = vi.fn(); @@ -238,6 +251,11 @@ vi.mock('../memory/team-memory-git-status.js', () => ({ vi.mock('../hooks/index.js', () => { const HookSystemMock = vi.fn(); HookSystemMock.prototype.initialize = vi.fn().mockResolvedValue(undefined); + HookSystemMock.prototype.reload = vi.fn().mockResolvedValue(undefined); + HookSystemMock.prototype.updateHttpSecurity = vi.fn(); + HookSystemMock.prototype.fireCwdChangedEvent = vi + .fn() + .mockResolvedValue(undefined); HookSystemMock.prototype.hasHooksForEvent = vi.fn().mockReturnValue(false); HookSystemMock.prototype.getAllHooks = vi.fn().mockReturnValue([]); return { @@ -310,6 +328,7 @@ vi.mock('../utils/memory-constants.js', () => ({ getCurrentMemoryFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), DEFAULT_CONTEXT_FILENAME: 'QWEN.md', + AGENT_CONTEXT_FILENAME: 'AGENTS.md', })); vi.mock('../tools/memory-config', () => ({ setMemoryFilename: vi.fn(), @@ -327,6 +346,7 @@ vi.mock('../core/client.js', () => ({ initialize: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(true), setTools: vi.fn(), + refreshSystemInstruction: vi.fn().mockResolvedValue(undefined), })), })); @@ -363,6 +383,9 @@ vi.mock('../skills/skill-manager.js', () => { SkillManagerMock.prototype.refreshCache = vi .fn() .mockResolvedValue(undefined); + SkillManagerMock.prototype.refreshForProjectChange = vi + .fn() + .mockResolvedValue(undefined); SkillManagerMock.prototype.stopWatching = vi.fn(); SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]); SkillManagerMock.prototype.addChangeListener = vi.fn(); @@ -387,6 +410,12 @@ vi.mock('../subagents/subagent-manager.js', () => { .fn() .mockReturnValue(() => {}); SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]); + SubagentManagerMock.prototype.refreshCache = vi + .fn() + .mockResolvedValue(undefined); + SubagentManagerMock.prototype.refreshForProjectChange = vi + .fn() + .mockResolvedValue(undefined); return { SubagentManager: SubagentManagerMock }; }); @@ -7088,6 +7117,356 @@ describe('Server Config (config.ts)', () => { cwdSpy.mockRestore(); }); + it('relocateWorkingDirectory should apply the prepared project runtime', async () => { + const commit = vi.fn().mockResolvedValue(undefined); + const rollback = vi.fn().mockResolvedValue(undefined); + const complete = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ + config: { + trustedFolder: true, + includeDirectories: ['/path/to/project-b-include'], + loadMemoryFromIncludeDirectories: true, + plansDir: '/path/to/other/project-plans', + plansDirectoryConfigured: true, + cronEnabled: false, + cronRecurringMaxAgeDays: 3, + lsToolEnabled: true, + agentTeamEnabled: true, + artifactEnabled: false, + artifactAutoOpen: false, + artifactPublisher: 'host', + artifactHost: { + uploadCommand: 'publish-artifact', + urlTemplate: 'https://example.test/{key}', + }, + workflowsEnabled: true, + skipWorkflowUsageWarning: true, + useRipgrep: false, + useBuiltinRipgrep: false, + webSearch: { enabled: false, model: 'search-model' }, + imageModel: 'target-image-model', + allowedHttpHookUrls: ['https://hooks.example.test/*'], + allowPrivateNetworkHooks: true, + fileFiltering: { + respectGitIgnore: false, + respectQwenIgnore: false, + customIgnoreFiles: ['.project-b-ignore'], + enableRecursiveFileSearch: false, + enableFuzzySearch: false, + }, + shouldUseNodePtyShell: false, + shellDefaultTimeoutMs: 1234, + shellHeartbeatIntervalMs: 4321, + truncateToolOutputThreshold: 9000, + truncateToolOutputLines: 90, + toolOutputBatchBudget: 12000, + defaultFileEncoding: 'utf-8-bom', + bugCommand: { urlTemplate: 'https://bugs.example.test/{title}' }, + coreTools: ['read_file'], + allowedTools: ['read_file'], + excludeTools: ['sdk-exclude'], + disabledSlashCommands: ['auth'], + permissions: { + allow: ['target-permission-allow'], + ask: ['target-permission-ask'], + deny: ['target-permission-deny'], + }, + eagerTools: ['read_file'], + toolSearchThreshold: 17, + mcpServerCommand: 'target-mcp-command', + mcpToolIdleTimeoutMs: 4567, + disabledSkillLevels: ['user'], + customSkillDirs: ['/target-skills'], + importFormat: 'flat', + contextFileName: ['PROJECT-B.md'], + enableManagedAutoMemory: false, + enableManagedAutoDream: false, + enableTeamMemory: true, + enableTeamMemorySync: true, + enableAutoSkill: true, + autoSkillConfirm: false, + agents: { allowedGrades: ['fast'] }, + disableAllHooks: true, + projectHooks: {}, + mcpServers: {}, + }, + commit, + rollback, + complete, + }); + const config = new Config({ + ...baseParams, + includeDirectories: ['/path/to/project-a-include'], + projectRuntimeReloader: { prepare }, + }); + const cleanupTeam = vi.fn().mockResolvedValue(undefined); + const cleanupArena = vi.fn().mockResolvedValue(undefined); + config.setTeamManager({ cleanup: cleanupTeam } as never); + config.setArenaManager({ cleanup: cleanupArena } as never); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + const permissionReload = vi.spyOn( + config.getPermissionManager()!, + 'reloadForProjectChange', + ); + const skillRefresh = vi.spyOn( + config.getSkillManager()!, + 'refreshForProjectChange', + ); + const subagentRefresh = vi.spyOn( + config.getSubagentManager(), + 'refreshForProjectChange', + ); + config + .getWorkspaceContext() + .addDirectory('/path/to/runtime-added-directory'); + const previousCronScheduler = config.getCronScheduler(); + const destroyCronScheduler = vi.spyOn(previousCronScheduler, 'destroy'); + const oldDir = path.resolve('/path/to/project-a'); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + // The directory being left is read BEFORE the chdir; a rollback restores + // its environment, so passing the target here would be a real defect. + const cwdSpy = vi + .spyOn(process, 'cwd') + .mockReturnValueOnce(oldDir) + .mockReturnValue(newDir); + + const result = await config.relocateWorkingDirectory(newDir, newDir, { + trustedFolder: true, + }); + + expect(result).toEqual({}); + expect(prepare).toHaveBeenCalledWith( + newDir, + true, + ApprovalMode.AUTO, + oldDir, + ); + expect(config.shouldLoadMemoryFromIncludeDirectories()).toBe(true); + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledOnce(); + expect(cleanupTeam).toHaveBeenCalledOnce(); + expect(cleanupArena).toHaveBeenCalledOnce(); + expect(config.getTeamManager()).toBeNull(); + expect(config.getArenaManager()).toBeNull(); + expect(config.getCoreTools()).toEqual(['read_file']); + expect(config.getPermissionsAllow()).toEqual([ + 'target-permission-allow', + 'read_file', + ]); + expect(config.getPermissionsAsk()).toEqual(['target-permission-ask']); + expect(config.getEagerTools()).toEqual(['read_file']); + expect(config.getDisabledSlashCommands()).toEqual(['auth']); + expect(config.getToolSearchThreshold()).toBe(17); + expect(config.getMcpServerCommand()).toBe('target-mcp-command'); + expect(config.getMcpToolIdleTimeoutMs()).toBe(4567); + expect(config.getPermissionsDeny()).toEqual([ + 'target-permission-deny', + 'sdk-exclude', + ]); + expect(config.getDisabledSkillLevels()).toEqual(new Set(['user'])); + expect(config.getCustomSkillDirs()).toEqual(['/target-skills']); + expect(config.getImportFormat()).toBe('flat'); + expect(config.getContextFileNames()).toEqual(['PROJECT-B.md']); + expect(mockSetMemoryFilename).not.toHaveBeenCalled(); + expect(config.getManagedAutoMemoryEnabled()).toBe(false); + expect(config.getManagedAutoDreamEnabled()).toBe(false); + expect(config.getTeamMemoryEnabled()).toBe(true); + expect(config.getTeamMemorySyncEnabled()).toBe(true); + expect(config.getAutoSkillEnabled()).toBe(true); + expect(config.getAutoSkillConfirmEnabled()).toBe(false); + expect(config.getAgentsSettings().allowedGrades).toEqual(['fast']); + expect(config.getDisableAllHooks()).toBe(true); + expect(config.getPlansDir()).toBe('/path/to/other/project-plans'); + expect(config.isCronEnabled()).toBe(false); + expect(config.getCronRecurringMaxAgeDays()).toBe(3); + expect(config.isLsToolEnabled()).toBe(true); + expect(config.isAgentTeamEnabled()).toBe(true); + expect(config.isRecordArtifactEnabled()).toBe(false); + expect(config.shouldAutoOpenArtifact()).toBe(false); + expect(config.getArtifactPublisherKind()).toBe('host'); + expect(config.getArtifactHostConfig()).toEqual({ + uploadCommand: 'publish-artifact', + urlTemplate: 'https://example.test/{key}', + }); + expect(config.isWorkflowsEnabled()).toBe(true); + expect(config.getSkipWorkflowUsageWarning()).toBe(true); + expect(config.getUseRipgrep()).toBe(false); + expect(config.getUseBuiltinRipgrep()).toBe(false); + expect(config.getWebSearchSettings()).toEqual({ + enabled: false, + model: 'search-model', + }); + expect(config.getAllowedHttpHookUrls()).toEqual([ + 'https://hooks.example.test/*', + ]); + expect(config.getAllowPrivateNetworkHooks()).toBe(true); + expect(config.getFileFilteringOptions()).toEqual({ + respectGitIgnore: false, + respectQwenIgnore: false, + customIgnoreFiles: ['.project-b-ignore'], + }); + expect(config.getEnableRecursiveFileSearch()).toBe(false); + expect(config.getFileFilteringEnableFuzzySearch()).toBe(false); + expect(config.getShouldUseNodePtyShell()).toBe(false); + expect(config.getShellDefaultTimeoutMs()).toBe(1234); + expect(config.getShellHeartbeatIntervalMs()).toBe(4321); + expect(config.getTruncateToolOutputThreshold()).toBe(9000); + expect(config.isTruncateToolOutputThresholdExplicit()).toBe(true); + expect(config.getTruncateToolOutputLines()).toBe(90); + expect(config.getToolOutputBatchBudget()).toBe(12000); + expect(config.getDefaultFileEncoding()).toBe('utf-8-bom'); + expect(config.getBugCommand()).toEqual({ + urlTemplate: 'https://bugs.example.test/{title}', + }); + expect(HookSystem.prototype.updateHttpSecurity).toHaveBeenCalledWith( + ['https://hooks.example.test/*'], + true, + ); + const resolveImageGenerationModel = vi + .spyOn(config, 'resolveImageGenerationModel') + .mockReturnValue(undefined); + expect(config.getImageGenerationConfig()).toBeUndefined(); + expect(resolveImageGenerationModel).toHaveBeenCalledWith( + 'target-image-model', + ); + expect(config.getWorkspaceContext().getDirectories()).toEqual([ + newDir, + '/path/to/project-b-include', + '/path/to/runtime-added-directory', + ]); + expect(destroyCronScheduler).toHaveBeenCalledOnce(); + expect(config.getCronScheduler()).not.toBe(previousCronScheduler); + expect(ToolRegistry.prototype.replaceCoreToolsFrom).toHaveBeenCalledOnce(); + expect( + ToolRegistry.prototype.rediscoverCommandTools, + ).toHaveBeenCalledOnce(); + expect(permissionReload).toHaveBeenCalledOnce(); + expect(skillRefresh).toHaveBeenCalledOnce(); + expect(subagentRefresh).toHaveBeenCalledOnce(); + + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + + it('relocateWorkingDirectory should reject target settings before moving', async () => { + const configError = new Error('invalid target settings'); + const prepare = vi.fn().mockRejectedValue(configError); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + config.setApprovalMode(ApprovalMode.YOLO); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + + await expect(config.relocateWorkingDirectory(newDir)).rejects.toThrow( + configError, + ); + + expect(chdirSpy).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledWith( + newDir, + undefined, + ApprovalMode.YOLO, + expect.any(String), + ); + expect(config.getTargetDir()).toBe(baseParams.targetDir); + chdirSpy.mockRestore(); + }); + + it('relocateWorkingDirectory should apply the target runtime trust decision', async () => { + // The constructor default is trusted; deleting the re-application + // keeps a session trusted in a workspace the runtime declared untrusted. + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ trustedFolder: false }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + expect(config.isTrustedFolder()).toBe(true); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + + await config.relocateWorkingDirectory(newDir, newDir, { + trustedFolder: false, + }); + + expect(config.isTrustedFolder()).toBe(false); + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + + it('relocateWorkingDirectory should surface prepare-time warnings as refresh errors', async () => { + // A host that declines to rewrite a process-wide resource (for example + // the environment, in a process hosting sibling sessions) reports it + // at prepare time; the switch still commits and the caller sees why. + const commit = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime(), + warnings: ['Process environment left unchanged'], + commit, + rollback: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + + const result = await config.relocateWorkingDirectory(newDir, newDir, { + trustedFolder: true, + }); + + expect(result.projectRuntimeRefreshErrors?.[0]).toBeInstanceOf(Error); + expect(result.projectRuntimeRefreshErrors).toHaveLength(1); + expect((result.projectRuntimeRefreshErrors?.[0] as Error).message).toBe( + 'Process environment left unchanged', + ); + expect(config.getTargetDir()).toBe(newDir); + expect(commit).toHaveBeenCalledOnce(); + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + + it('relocateWorkingDirectory should roll back prepared settings when commit fails', async () => { + const commitError = new Error('commit failed'); + const rollback = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ + config: {} as ProjectRuntimeConfig, + commit: vi.fn().mockRejectedValue(commitError), + rollback, + complete: vi.fn(), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + + await expect(config.relocateWorkingDirectory(newDir)).rejects.toThrow( + commitError, + ); + + expect(rollback).toHaveBeenCalledOnce(); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(config.getTargetDir()).toBe(baseParams.targetDir); + chdirSpy.mockRestore(); + }); + it('relocateWorkingDirectory should preserve leased storage for an ACP cwd change', async () => { const config = new Config(baseParams); const generator = {} as ContentGenerator; @@ -7721,8 +8100,364 @@ describe('Server Config (config.ts)', () => { cwdSpy.mockRestore(); }); + // The smallest prepared runtime `applyProjectRuntimeConfig` accepts; the + // relocation tests below override the one or two fields they are about. + const preparedRuntime = ( + overrides: Record = {}, + ): Record => ({ + trustedFolder: true, + includeDirectories: [], + loadMemoryFromIncludeDirectories: false, + plansDir: '/path/to/other/plans', + plansDirectoryConfigured: false, + cronEnabled: true, + lsToolEnabled: false, + agentTeamEnabled: false, + artifactEnabled: true, + artifactAutoOpen: true, + artifactPublisher: 'local', + workflowsEnabled: false, + skipWorkflowUsageWarning: false, + allowedHttpHookUrls: [], + allowPrivateNetworkHooks: false, + mcpServers: {}, + ...overrides, + }); + + const relocateWithRuntime = async ( + config: Config, + newDir: string, + extra: { trustedFolder?: boolean } = { trustedFolder: true }, + ) => { + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + try { + return await config.relocateWorkingDirectory(newDir, newDir, extra); + } finally { + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + } + }; + + it('relocateWorkingDirectory drops non-string context file names instead of throwing mid-move', async () => { + // Settings JSON is not validated on load, so a target project's + // `context.fileName: ["PROJECT-B.md", 42]` reaches the apply verbatim. + // Throwing there stranded a half-committed relocation (settings + // swapped, chdir done, watcher paused, tools not refreshed). + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ contextFileName: ['PROJECT-B.md', 42] }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + + const result = await relocateWithRuntime( + config, + path.resolve('/path/to/other'), + ); + + expect(result).toEqual({}); + expect(config.getContextFileNames()).toEqual(['PROJECT-B.md']); + }); + + it('relocateWorkingDirectory continues applying runtime settings after a malformed hook URL list', async () => { + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ + allowedHttpHookUrls: 42, + permissions: { deny: ['target-project-deny'] }, + contextFileName: 'PROJECT-B.md', + }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + permissions: { deny: ['source-project-deny'] }, + contextFileName: 'PROJECT-A.md', + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + + const result = await relocateWithRuntime( + config, + path.resolve('/path/to/other'), + ); + + expect(result).toEqual({}); + expect(config.getAllowedHttpHookUrls()).toEqual([]); + expect(config.getPermissionsDeny()).toEqual(['target-project-deny']); + expect(config.getContextFileNames()).toEqual(['PROJECT-B.md']); + }); + + it('relocateWorkingDirectory updates agent, worktree, and persistence runtime state', async () => { + const initialPersistence = vi.fn(); + const targetPersistence = vi.fn(); + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ + agents: { + maxParallelAgents: 3, + maxParallelAgentsByModel: { 'weak-model': 1 }, + }, + worktree: { symlinkDirectories: ['target-node_modules'] }, + onPersistPermissionRule: targetPersistence, + }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + agents: { maxParallelAgents: 1 }, + worktree: { symlinkDirectories: ['old-node_modules'] }, + onPersistPermissionRule: initialPersistence, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + + await relocateWithRuntime(config, path.resolve('/path/to/other')); + + expect(config.getAgentsSettings().maxParallelAgents).toBe(3); + expect( + config.getBackgroundTaskRegistry().getMaxConcurrentBackgroundAgents(), + ).toBe(3); + config.getBackgroundTaskRegistry().register({ + agentId: 'weak-agent', + description: 'weak agent', + model: 'weak-model', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile: '/tmp/weak-agent.jsonl', + }); + expect( + config.getBackgroundTaskRegistry().canStartBackgroundAgent('weak-model'), + ).toBe(false); + expect(config.getWorktreeSymlinkDirectories()).toEqual([ + 'target-node_modules', + ]); + expect(config.getOnPersistPermissionRule()).toBe(targetPersistence); + }); + + it('relocateWorkingDirectory falls back to the default context file names when the target sets none', async () => { + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime(), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + contextFileName: 'PROJECT-A.md', + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + expect(config.getContextFileNames()).toEqual(['PROJECT-A.md']); + + await relocateWithRuntime(config, path.resolve('/path/to/other')); + + expect(config.getContextFileNames()).toEqual(['QWEN.md', 'AGENTS.md']); + }); + + it('relocateWorkingDirectory does not revive the previous project hooks through the legacy hooks field', async () => { + // `getUserHooks()`/`getProjectHooks()` fall back to the legacy merged + // `hooks` field, which was only ever set at construction. A hook-less + // target leaves both split fields undefined — and the fallback then + // re-registered project A's command hooks in project B, under the + // User source that bypasses the workspace trust gate. + const legacyHooks = { + PreToolUse: [{ matcher: 'run_shell_command', hooks: [] }], + }; + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ + userHooks: undefined, + projectHooks: undefined, + }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + hooks: legacyHooks, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + expect(config.getUserHooks()).toEqual(legacyHooks); + expect(config.getProjectHooks()).toEqual(legacyHooks); + + await relocateWithRuntime(config, path.resolve('/path/to/other')); + + expect(config.getUserHooks()).toBeUndefined(); + expect(config.getProjectHooks()).toBeUndefined(); + }); + + it('relocateWorkingDirectory honours the hooks kill switch for the CwdChanged event', async () => { + // Every other fire site checks `getDisableAllHooks()`; this one runs + // right after the flag may have flipped to the target's value. + const fireCwdChangedEvent = vi.mocked( + HookSystem.prototype.fireCwdChangedEvent, + ); + const relocate = async (disableAllHooks: boolean) => { + fireCwdChangedEvent.mockClear(); + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime({ disableAllHooks }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + expect(config.getHookSystem()).toBeDefined(); + await relocateWithRuntime(config, path.resolve('/path/to/other')); + expect(config.getDisableAllHooks()).toBe(disableAllHooks); + }; + + await relocate(false); + expect(fireCwdChangedEvent).toHaveBeenCalledOnce(); + + await relocate(true); + expect(fireCwdChangedEvent).not.toHaveBeenCalled(); + }); + + it('relocateWorkingDirectory keeps a runtime-added directory across a project that also lists it', async () => { + // `/directory add D` in project 1; project 2 lists D in its own + // `context.includeDirectories`; project 3 does not. D is the user's, + // not project 2's, so the third hop must not drop it. + const runtimeDir = path.resolve('/shared/runtime-added'); + const makePrepared = (includeDirectories: string[]) => ({ + config: preparedRuntime({ includeDirectories }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const prepare = vi + .fn() + .mockResolvedValueOnce(makePrepared([runtimeDir])) + .mockResolvedValueOnce(makePrepared([])); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + config.getWorkspaceContext().addDirectory(runtimeDir); + + await relocateWithRuntime(config, path.resolve('/path/to/project2')); + expect(config.getWorkspaceContext().getDirectories()).toContain(runtimeDir); + + await relocateWithRuntime(config, path.resolve('/path/to/project3')); + expect(config.getWorkspaceContext().getDirectories()).toContain(runtimeDir); + }); + + it('relocateWorkingDirectory reports the session-only cron work the swap cancelled', async () => { + // The old scheduler is destroyed before the UI effect cleanup can read + // its exit summary, so the relocation result is the only carrier. + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime(), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(undefined), + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + const scheduler = config.getCronScheduler(); + scheduler.create('* * * * *', 'loop', true); + const summary = scheduler.getExitSummary(); + expect(summary).not.toBeNull(); + const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.spyOn( + config as unknown as { + refreshCurrentRuntimeStatus: (workDir: string) => Promise; + }, + 'refreshCurrentRuntimeStatus', + ).mockImplementation(async () => { + const cleanupSummary = scheduler.getExitSummary(); + if (cleanupSummary) process.stderr.write(`${cleanupSummary}\n`); + }); + + const result = await relocateWithRuntime( + config, + path.resolve('/path/to/other'), + ); + + expect(result.cronExitSummary).toBe(summary); + expect(stderrWrite).not.toHaveBeenCalled(); + }); + + it('relocateWorkingDirectory rolls back the prepared runtime when the ACP realpath check fails', async () => { + // `commit()` has already swapped the target project's settings by the + // time the TOCTOU check runs; without the rollback the session would + // stay in the old directory under the new project's rules. + const commit = vi.fn().mockResolvedValue(undefined); + const rollback = vi.fn().mockResolvedValue(undefined); + const complete = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ + config: preparedRuntime(), + commit, + rollback, + complete, + }); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { prepare }, + }); + await config.initialize({ skipGeminiInitialization: true }); + await config.waitForMcpReady(); + const newDir = path.resolve('/path/to/other'); + vi.mocked(fs.realpathSync).mockImplementation((pathToResolve) => + pathToResolve.toString() === newDir + ? path.resolve('/path/to/swapped') + : pathToResolve.toString(), + ); + + await expect( + config.relocateWorkingDirectory(newDir, newDir, { + skipProcessChdir: true, + skipArtifactMigration: true, + }), + ).rejects.toThrow(/Realpath mismatch/); + + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).toHaveBeenCalledOnce(); + expect(complete).not.toHaveBeenCalled(); + }); + it('relocateWorkingDirectory should reject and roll back when session artifact migration fails', async () => { - const config = new Config({ ...baseParams, chatRecording: true }); + const commit = vi.fn().mockResolvedValue(undefined); + const rollback = vi.fn().mockResolvedValue(undefined); + const complete = vi.fn().mockResolvedValue(undefined); + const config = new Config({ + ...baseParams, + chatRecording: true, + projectRuntimeReloader: { + prepare: vi.fn().mockResolvedValue({ + config: preparedRuntime(), + commit, + rollback, + complete, + }), + }, + }); const disposeResidentAgents = vi.spyOn( config.getBackgroundTaskRegistry(), 'disposeResidentAgents', @@ -7766,6 +8501,11 @@ describe('Server Config (config.ts)', () => { await expect(config.relocateWorkingDirectory(newDir)).rejects.toThrow( moveError, ); + // The settings swap was committed before the move failed; it must be + // undone, and the watcher-resuming `complete()` never reached. + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).toHaveBeenCalledOnce(); + expect(complete).not.toHaveBeenCalled(); expect(fs.renameSync).toHaveBeenCalledWith( oldTranscriptPath, @@ -7838,7 +8578,18 @@ describe('Server Config (config.ts)', () => { }); it('relocateWorkingDirectory should reject and roll back when the final cwd differs from the expected path', async () => { - const config = new Config(baseParams); + const rollback = vi.fn().mockResolvedValue(undefined); + const config = new Config({ + ...baseParams, + projectRuntimeReloader: { + prepare: vi.fn().mockResolvedValue({ + config: {} as ProjectRuntimeConfig, + commit: vi.fn().mockResolvedValue(undefined), + rollback, + complete: vi.fn(), + }), + }, + }); const oldDir = config.getTargetDir(); const newDir = path.resolve('/path/to/other'); const expectedDir = path.resolve('/path/to/confirmed'); @@ -7858,6 +8609,7 @@ describe('Server Config (config.ts)', () => { expect(chdirSpy).toHaveBeenCalledWith(newDir); expect(chdirSpy).toHaveBeenCalledWith(oldDir); + expect(rollback).toHaveBeenCalledOnce(); expect(config.getTargetDir()).toBe(oldDir); expect(config.storage.getProjectRoot()).toBe(oldDir); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c4c9f107f54..ec3275005ca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -80,7 +80,12 @@ import { getMCPServerStatus, type SendSdkMcpMessage, } from '../tools/mcp-client.js'; -import { setMemoryFilename } from '../utils/memory-constants.js'; +import { + AGENT_CONTEXT_FILENAME, + DEFAULT_CONTEXT_FILENAME, + getAllMemoryFilenames, + setMemoryFilename, +} from '../utils/memory-constants.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { recordStartupEvent } from '../utils/startupEventSink.js'; import { ToolRegistry, type ToolFactory } from '../tools/tool-registry.js'; @@ -840,6 +845,103 @@ export interface AgentsCollabSettings { }; } +export interface ProjectRuntimeConfig { + trustedFolder: boolean; + includeDirectories: readonly string[]; + loadMemoryFromIncludeDirectories: boolean; + plansDir: string; + plansDirectoryConfigured: boolean; + cronEnabled: boolean; + cronRecurringMaxAgeDays?: number; + lsToolEnabled: boolean; + agentTeamEnabled: boolean; + artifactEnabled: boolean; + artifactAutoOpen: boolean; + artifactPublisher: 'local' | 'host' | 'oss'; + artifactHost?: ArtifactHostConfig; + artifactOss?: ArtifactOssConfig; + workflowsEnabled: boolean; + skipWorkflowUsageWarning: boolean; + useRipgrep?: boolean; + useBuiltinRipgrep?: boolean; + webSearch?: WebSearchSettings; + imageModel?: string; + allowedHttpHookUrls: string[]; + allowPrivateNetworkHooks: boolean; + fileFiltering?: ConfigParameters['fileFiltering']; + shouldUseNodePtyShell?: boolean; + shellDefaultTimeoutMs?: number; + shellHeartbeatIntervalMs?: number; + truncateToolOutputThreshold?: number; + truncateToolOutputLines?: number; + toolOutputBatchBudget?: number; + defaultFileEncoding?: FileEncodingType; + bugCommand?: BugCommandSettings; + coreTools?: string[]; + allowedTools?: string[]; + excludeTools?: string[]; + disabledSlashCommands?: string[]; + permissions?: { + allow?: string[]; + ask?: string[]; + deny?: string[]; + autoMode?: AutoModeSettings; + }; + disabledTools?: string[]; + visibleTools?: string[]; + eagerTools?: string[]; + toolSearchThreshold?: number; + toolDiscoveryCommand?: string; + toolCallCommand?: string; + mcpServerCommand?: string; + mcpToolIdleTimeoutMs?: number; + disabledSkillLevels?: readonly SkillLevel[]; + customSkillDirs?: readonly string[]; + importFormat?: 'tree' | 'flat'; + contextFileName?: string | string[]; + enableManagedAutoMemory?: boolean; + enableManagedAutoDream?: boolean; + enableTeamMemory?: boolean; + enableTeamMemorySync?: boolean; + enableAutoSkill?: boolean; + autoSkillConfirm?: boolean; + agents?: AgentsCollabSettings; + worktree?: WorktreeSettings; + onPersistPermissionRule?: ConfigParameters['onPersistPermissionRule']; + disableAllHooks?: boolean; + stopHookBlockingCap?: number; + userHooks?: Record; + projectHooks?: Record; + mcpServers?: Record; + allowedMcpServers?: string[]; + excludedMcpServers?: string[]; + pendingMcpServers?: string[]; +} + +export interface PreparedProjectRuntime { + config: ProjectRuntimeConfig; + /** + * Non-blocking conditions the host decided at prepare time (for example, + * declining to rewrite a process-wide resource because the process hosts + * other sessions). Surfaced to the caller as + * `projectRuntimeRefreshErrors` once the switch has committed. + */ + warnings?: readonly string[]; + commit(): Promise; + rollback(): Promise; + complete(): Promise; +} + +export interface ProjectRuntimeReloader { + prepare( + targetDir: string, + trustedFolder: boolean | undefined, + approvalMode: ApprovalMode, + /** The directory being left; a rollback restores its environment. */ + previousDir: string, + ): Promise; +} + export interface ConfigParameters { sessionId?: string; sessionData?: ResumedSessionData; @@ -875,8 +977,7 @@ export interface ConfigParameters { /** * Live-read provider for the set of skill names that should be hidden * from `` and the `/` slash-command - * surface. Unlike `disabledSlashCommands` (which is a frozen snapshot), - * this is a function so the CLI layer can close over `LoadedSettings` + * surface. This is a function so the CLI layer can close over `LoadedSettings` * and have post-`setValue` toggles take effect without restart. * * Must be attached at construction time — `Config.initialize()` calls @@ -1323,6 +1424,7 @@ export interface ConfigParameters { ) => Promise; /** Lifecycle handle for an external settings file watcher. Stopped during shutdown. */ settingsWatcher?: { stopWatching(): void }; + projectRuntimeReloader?: ProjectRuntimeReloader; } export type TerminalImageRenderSupport = @@ -1612,6 +1714,21 @@ function resolveCronRecurringMaxAgeDays(setting: number | undefined): number { return normalizeRecurringMaxAge(raw, DEFAULT_RECURRING_MAX_AGE_DAYS); } +function resolveContextFileNames( + value: string | string[] | undefined, +): readonly string[] { + // Settings JSON is not schema-validated on load, and a `/cd` target's + // `context.fileName` reaches here verbatim — a non-string entry must not + // throw out of the middle of a half-committed relocation. + const names = (Array.isArray(value) ? value : value ? [value] : []) + .filter((name): name is string => typeof name === 'string') + .map((name) => name.trim()) + .filter(Boolean); + return names.length > 0 + ? Object.freeze(names) + : Object.freeze([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); +} + /** Request from the `create_sub_session` tool to spawn a fresh top-level * sub-session and run a prompt in it. */ export interface SubSessionSpawnRequest { @@ -2087,18 +2204,18 @@ export class Config { private readonly appendSystemPrompt: string | undefined; private liveAppendSystemPrompt: string | undefined; private outputStyle: OutputStyleDefinition | undefined; - private readonly coreTools: string[] | undefined; - private readonly allowedTools: string[] | undefined; - private readonly excludeTools: string[] | undefined; - private readonly disabledSlashCommands: readonly string[]; + private coreTools: string[] | undefined; + private allowedTools: string[] | undefined; + private excludeTools: string[] | undefined; + private disabledSlashCommands: readonly string[]; private readonly disabledSkillNamesProvider: | (() => ReadonlySet) | null; private readonly terminalImageRenderSupportProvider: | (() => Promise) | null; - private readonly disabledSkillLevels: ReadonlySet; - private readonly customSkillDirs: readonly string[]; + private disabledSkillLevels: ReadonlySet; + private customSkillDirs: readonly string[]; // `disabledTools` is set at construction // time but can be re-synced by the daemon mutation surface // (`setWorkspaceToolEnabled` propagates through ACP) so a subsequent @@ -2108,16 +2225,16 @@ export class Config { // captured reference (e.g. by ToolRegistry mid-iteration) remains // self-consistent. private disabledTools: ReadonlySet; - private readonly visibleTools: ReadonlySet; - private readonly eagerTools: readonly string[] | undefined; - private readonly toolSearchThreshold: number; - private readonly permissionsAllow: string[]; - private readonly permissionsAsk: string[]; - private readonly permissionsDeny: string[]; - private readonly permissionsAutoMode: AutoModeSettings; - private readonly toolDiscoveryCommand: string | undefined; - private readonly toolCallCommand: string | undefined; - private readonly mcpServerCommand: string | undefined; + private visibleTools: ReadonlySet; + private eagerTools: readonly string[] | undefined; + private toolSearchThreshold: number; + private permissionsAllow: string[]; + private permissionsAsk: string[]; + private permissionsDeny: string[]; + private permissionsAutoMode: AutoModeSettings; + private toolDiscoveryCommand: string | undefined; + private toolCallCommand: string | undefined; + private mcpServerCommand: string | undefined; private mcpServers: Record | undefined; /** * Names of MCP servers that were present in the effective server map but @@ -2140,7 +2257,7 @@ export class Config { private readonly cliAllowedMcpServerNames?: string[]; private excludedMcpServers?: string[]; private pendingMcpServers?: string[]; - private readonly mcpToolIdleTimeoutMs: number; + private mcpToolIdleTimeoutMs: number; /** * Guards against concurrent MCP reconcile passes (hot-reload watcher vs. * `/reload`). `SettingsWatcher` serializes its own listeners, but `/reload` @@ -2178,6 +2295,7 @@ export class Config { private autoMemoryPrompt = ''; private sdkMode: boolean; private memoryFileCount: number; + private contextFileNames: readonly string[]; private loadedContextFilePaths: string[] = []; private conditionalRulesRegistry: ConditionalRulesRegistry | undefined; private readonly contextRuleExcludes: string[]; @@ -2206,7 +2324,7 @@ export class Config { private llmClient!: LlmClient; private baseLlmClient!: BaseLlmClient; private cronScheduler: CronScheduler | null = null; - private readonly fileFiltering: { + private fileFiltering: { respectGitIgnore: boolean; respectQwenIgnore: boolean; customIgnoreFiles: string[]; @@ -2243,8 +2361,9 @@ export class Config { private fileHistoryService: FileHistoryService | undefined; private readonly proxy: string | undefined; private cwd: string; - private readonly explicitIncludeDirectories: string[]; - private readonly bugCommand: BugCommandSettings | undefined; + private explicitIncludeDirectories: string[]; + private managedWorkspaceDirectories: Set; + private bugCommand: BugCommandSettings | undefined; private outputLanguageFilePath?: string; private readonly noBrowser: boolean; private readonly folderTrustFeature: boolean; @@ -2273,30 +2392,31 @@ export class Config { */ private preserveRestorableAskUserQuestion = false; private readonly sessionWriterLeaseEnabled: boolean = false; - private readonly cronEnabled: boolean = true; - /** Recurring cron max age in days, resolved once at construction - * (the setting declares `requiresRestart`); `Infinity` = no expiry. */ - private readonly cronRecurringMaxAgeDays: number; - private readonly lsToolEnabled: boolean = false; - private readonly agentTeamEnabled: boolean = false; - private readonly artifactEnabled: boolean = true; - private readonly artifactAutoOpen: boolean = true; - private readonly artifactPublisher: 'local' | 'host' | 'oss' = 'local'; - private readonly artifactHost?: ArtifactHostConfig; - private readonly artifactOss?: ArtifactOssConfig; + private cronEnabled: boolean = true; + /** Recurring cron max age in days; `Infinity` = no expiry. Resolved at + * construction and re-resolved per project by `applyProjectRuntimeConfig` + * on `/cd` (the scheduler is destroyed and restarted for the target). */ + private cronRecurringMaxAgeDays: number; + private lsToolEnabled: boolean = false; + private agentTeamEnabled: boolean = false; + private artifactEnabled: boolean = true; + private artifactAutoOpen: boolean = true; + private artifactPublisher: 'local' | 'host' | 'oss' = 'local'; + private artifactHost?: ArtifactHostConfig; + private artifactOss?: ArtifactOssConfig; private workflowsEnabled = false; - private readonly skipWorkflowUsageWarning: boolean = false; + private skipWorkflowUsageWarning: boolean = false; private readonly emitToolUseSummaries: boolean = true; private readonly chatRecordingEnabled: boolean; - private readonly loadMemoryFromIncludeDirectories: boolean = false; - private readonly importFormat: 'tree' | 'flat'; + private loadMemoryFromIncludeDirectories: boolean = false; + private importFormat: 'tree' | 'flat'; private readonly chatCompression: ChatCompressionSettings | undefined; private readonly autoCompactThreshold: number | undefined; private readonly interactive: boolean; - private readonly trustedFolder: boolean | undefined; - private readonly useRipgrep: boolean; - private readonly useBuiltinRipgrep: boolean; - private readonly shouldUseNodePtyShell: boolean; + private trustedFolder: boolean | undefined; + private useRipgrep: boolean; + private useBuiltinRipgrep: boolean; + private shouldUseNodePtyShell: boolean; private readonly preventSystemSleep: boolean; private readonly skipNextSpeakerCheck: boolean; private shellExecutionConfig: ShellExecutionConfig; @@ -2310,8 +2430,8 @@ export class Config { (manager: TeamManager | null) => void >(); private teamContext: TeamContext | null = null; - private readonly agentsSettings: AgentsCollabSettings; - private readonly worktreeSettings: WorktreeSettings; + private agentsSettings: AgentsCollabSettings; + private worktreeSettings: WorktreeSettings; private readonly skipLoopDetection: boolean; private readonly maxToolCallsPerTurn: number; private readonly maxToolCallsPerTurnExplicit: boolean; @@ -2319,9 +2439,9 @@ export class Config { private readonly bareMode: boolean; private readonly safeMode: boolean; private readonly warnings: string[]; - private readonly allowedHttpHookUrls: string[]; - private readonly allowPrivateNetworkHooks: boolean; - private readonly onPersistPermissionRuleCallback?: ( + private allowedHttpHookUrls: string[]; + private allowPrivateNetworkHooks: boolean; + private onPersistPermissionRuleCallback?: ( scope: 'project' | 'user', ruleType: 'allow' | 'ask' | 'deny', rule: string, @@ -2338,50 +2458,50 @@ export class Config { private runtimeStatusWrite: Promise = Promise.resolve(); private sessionRegistryWrite: Promise = Promise.resolve(); private readonly fileExclusions: FileExclusions; - private readonly truncateToolOutputThreshold: number; - private readonly truncateToolOutputThresholdExplicit: boolean; - private readonly truncateToolOutputLines: number; - private readonly toolOutputBatchBudget: number; - private readonly shellDefaultTimeoutMs: number | undefined; - private readonly shellHeartbeatIntervalMs: number | undefined; + private truncateToolOutputThreshold: number; + private truncateToolOutputThresholdExplicit: boolean; + private truncateToolOutputLines: number; + private toolOutputBatchBudget: number; + private shellDefaultTimeoutMs: number | undefined; + private shellHeartbeatIntervalMs: number | undefined; private readonly eventEmitter?: EventEmitter; private readonly channel: string | undefined; private readonly jsonFd: number | undefined; private readonly jsonFile: string | undefined; private readonly jsonSchema: Record | undefined; private readonly inputFile: string | undefined; - private readonly plansDir: string; - private readonly plansDirectoryConfigured: boolean; - private readonly defaultFileEncoding: FileEncodingType | undefined; - private readonly enableManagedAutoMemory: boolean; - private readonly enableManagedAutoDream: boolean; - private readonly enableTeamMemory: boolean; - private readonly enableTeamMemorySync: boolean; + private plansDir: string; + private plansDirectoryConfigured: boolean; + private defaultFileEncoding: FileEncodingType | undefined; + private enableManagedAutoMemory: boolean; + private enableManagedAutoDream: boolean; + private enableTeamMemory: boolean; + private enableTeamMemorySync: boolean; // Latch (keyed by projectRoot) so the "team memory enabled but not shareable" // warning is emitted at most once per repo, even though refreshHierarchicalMemory // may re-run. Keyed rather than a single boolean so entering a new repo (/cd) // re-checks shareability instead of reusing the first repo's result. private readonly teamMemoryShareabilityChecked = new Set(); private enableAutoSkill: boolean; - private readonly autoSkillConfirm: boolean; + private autoSkillConfirm: boolean; private readonly memoryAgentTimeoutMinutes: number | undefined; private readonly memoryAgentMaxTurns: number | undefined; private fastModel?: string; - private readonly webSearchSettings?: WebSearchSettings; + private webSearchSettings?: WebSearchSettings; private webSearchNoticeEmitted = false; private visionModel?: string; private compactionModel?: string; private imageModel?: string; private readonly visionBridgeTimeoutMs: number | undefined; private readonly modelFallbacks: string[]; - private readonly disableAllHooks: boolean; - private readonly stopHookBlockingCap: number; + private disableAllHooks: boolean; + private stopHookBlockingCap: number; /** User-level hooks (always loaded regardless of trust) */ - private readonly userHooks?: Record; + private userHooks?: Record; /** Project-level hooks (only loaded in trusted folders) */ - private readonly projectHooks?: Record; + private projectHooks?: Record; /** @deprecated Legacy merged hooks field - use userHooks/projectHooks instead */ - private readonly hooks?: Record; + private hooks?: Record; private hookSystem?: HookSystem; private messageBus?: MessageBus; private readonly memoryManager: MemoryManager; @@ -2391,6 +2511,7 @@ export class Config { // other instance updates it. Per-session publishing is not gated on it. private readonly ownsModelEnvSlot: boolean = false; private readonly settingsWatcher?: { stopWatching(): void }; + private readonly projectRuntimeReloader?: ProjectRuntimeReloader; constructor(params: ConfigParameters) { this.sessionRuntimeBaseDir = Storage.getRuntimeBaseDir(); @@ -2428,6 +2549,9 @@ export class Config { this.targetDir, this.explicitIncludeDirectories, ); + this.managedWorkspaceDirectories = new Set( + this.workspaceContext.getDirectories(), + ); this.debugMode = params.debugMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; const normalizedOutputFormat = normalizeConfigOutputFormat( @@ -2459,14 +2583,7 @@ export class Config { // An explicitly empty array is preserved as an ACTIVE-but-empty // allowlist (defer everything); only `undefined` means "no // restriction". `tools.core` differs: its empty list is treated as unset. - this.eagerTools = - params.eagerTools === undefined - ? undefined - : Object.freeze( - params.eagerTools.filter( - (name): name is string => typeof name === 'string', - ), - ); + this.eagerTools = Config.normalizeEagerTools(params.eagerTools); this.toolSearchThreshold = params.toolSearchThreshold ?? DEFAULT_TOOL_SEARCH_THRESHOLD; this.permissionsAllow = params.permissions?.allow || []; @@ -2725,6 +2842,9 @@ export class Config { if (params.contextFileName) { setMemoryFilename(params.contextFileName); } + this.contextFileNames = resolveContextFileNames( + params.contextFileName ?? getAllMemoryFilenames(), + ); // Create ModelsConfig for centralized model management // Prefer params.authType over generationConfig.authType because: @@ -2885,6 +3005,7 @@ export class Config { // Legacy: fall back to merged hooks if new fields are not provided this.hooks = params.hooks; this.settingsWatcher = params.settingsWatcher; + this.projectRuntimeReloader = params.projectRuntimeReloader; this.memoryManager = new MemoryManager(); } @@ -3839,6 +3960,7 @@ export class Config { this.contextRuleExcludes, { explicitOnly: this.getBareMode(), + contextFileNames: this.contextFileNames, loadReason, onInstructionsLoaded: createInstructionsLoadedCallback( () => this.hookSystem, @@ -5505,6 +5627,136 @@ export class Config { return this.targetDir; } + private applyProjectRuntimeConfig(runtime: ProjectRuntimeConfig): void { + this.trustedFolder = runtime.trustedFolder; + this.explicitIncludeDirectories = [...runtime.includeDirectories]; + this.loadMemoryFromIncludeDirectories = + runtime.loadMemoryFromIncludeDirectories; + this.plansDir = runtime.plansDir; + this.plansDirectoryConfigured = runtime.plansDirectoryConfigured; + this.cronEnabled = runtime.cronEnabled; + this.cronRecurringMaxAgeDays = resolveCronRecurringMaxAgeDays( + runtime.cronRecurringMaxAgeDays, + ); + this.lsToolEnabled = runtime.lsToolEnabled; + this.agentTeamEnabled = runtime.agentTeamEnabled; + this.artifactEnabled = runtime.artifactEnabled; + this.artifactAutoOpen = runtime.artifactAutoOpen; + this.artifactPublisher = runtime.artifactPublisher; + this.artifactHost = runtime.artifactHost; + this.artifactOss = runtime.artifactOss; + this.workflowsEnabled = runtime.workflowsEnabled; + this.skipWorkflowUsageWarning = runtime.skipWorkflowUsageWarning; + this.useRipgrep = runtime.useRipgrep ?? true; + this.useBuiltinRipgrep = runtime.useBuiltinRipgrep ?? true; + this.webSearchSettings = runtime.webSearch; + this.webSearchNoticeEmitted = false; + this.imageModel = runtime.imageModel || undefined; + this.allowedHttpHookUrls = Array.isArray(runtime.allowedHttpHookUrls) + ? [...runtime.allowedHttpHookUrls] + : []; + this.allowPrivateNetworkHooks = runtime.allowPrivateNetworkHooks; + this.hookSystem?.updateHttpSecurity( + this.getAllowedHttpHookUrls(), + this.getAllowPrivateNetworkHooks(), + ); + this.fileFiltering = { + respectGitIgnore: runtime.fileFiltering?.respectGitIgnore ?? true, + respectQwenIgnore: runtime.fileFiltering?.respectQwenIgnore ?? true, + customIgnoreFiles: runtime.fileFiltering?.customIgnoreFiles ?? [ + ...DEFAULT_QWEN_CUSTOM_IGNORE_FILE_NAMES, + ], + enableRecursiveFileSearch: + runtime.fileFiltering?.enableRecursiveFileSearch ?? true, + enableFuzzySearch: runtime.fileFiltering?.enableFuzzySearch ?? true, + }; + this.shouldUseNodePtyShell = + runtime.shouldUseNodePtyShell ?? shouldDefaultToNodePty(); + this.shellDefaultTimeoutMs = + runtime.shellDefaultTimeoutMs !== undefined && + Number.isInteger(runtime.shellDefaultTimeoutMs) && + runtime.shellDefaultTimeoutMs >= 0 && + runtime.shellDefaultTimeoutMs <= 2_147_483_647 + ? runtime.shellDefaultTimeoutMs + : undefined; + this.shellHeartbeatIntervalMs = + runtime.shellHeartbeatIntervalMs !== undefined && + Number.isInteger(runtime.shellHeartbeatIntervalMs) && + runtime.shellHeartbeatIntervalMs >= 0 && + runtime.shellHeartbeatIntervalMs <= 2_147_483_647 + ? runtime.shellHeartbeatIntervalMs + : undefined; + this.truncateToolOutputThreshold = + runtime.truncateToolOutputThreshold ?? + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; + this.truncateToolOutputThresholdExplicit = + runtime.truncateToolOutputThreshold != null; + this.truncateToolOutputLines = + runtime.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; + this.toolOutputBatchBudget = + runtime.toolOutputBatchBudget ?? DEFAULT_TOOL_OUTPUT_BATCH_BUDGET; + this.defaultFileEncoding = runtime.defaultFileEncoding; + this.bugCommand = runtime.bugCommand; + this.coreTools = runtime.coreTools; + this.allowedTools = runtime.allowedTools; + this.excludeTools = runtime.excludeTools; + this.disabledSlashCommands = Object.freeze([ + ...(runtime.disabledSlashCommands ?? []), + ]); + this.permissionsAllow = runtime.permissions?.allow ?? []; + this.permissionsAsk = runtime.permissions?.ask ?? []; + this.permissionsDeny = runtime.permissions?.deny ?? []; + this.permissionsAutoMode = runtime.permissions?.autoMode ?? {}; + this.disabledTools = new Set(runtime.disabledTools ?? []); + this.visibleTools = new Set(runtime.visibleTools ?? []); + this.eagerTools = Config.normalizeEagerTools(runtime.eagerTools); + this.toolSearchThreshold = + runtime.toolSearchThreshold ?? DEFAULT_TOOL_SEARCH_THRESHOLD; + this.toolDiscoveryCommand = runtime.toolDiscoveryCommand; + this.toolCallCommand = runtime.toolCallCommand; + this.mcpServerCommand = runtime.mcpServerCommand; + const envMcpToolIdleTimeout = Number( + process.env['QWEN_CODE_MCP_TOOL_IDLE_TIMEOUT_MS'], + ); + this.mcpToolIdleTimeoutMs = + runtime.mcpToolIdleTimeoutMs ?? + (Number.isFinite(envMcpToolIdleTimeout) && envMcpToolIdleTimeout >= 0 + ? envMcpToolIdleTimeout + : 300000); + this.disabledSkillLevels = new Set(runtime.disabledSkillLevels ?? []); + this.customSkillDirs = Object.freeze([...(runtime.customSkillDirs ?? [])]); + this.importFormat = runtime.importFormat ?? 'tree'; + this.contextFileNames = resolveContextFileNames(runtime.contextFileName); + this.enableManagedAutoMemory = runtime.enableManagedAutoMemory ?? true; + this.enableManagedAutoDream = runtime.enableManagedAutoDream ?? true; + this.enableTeamMemory = runtime.enableTeamMemory ?? false; + this.enableTeamMemorySync = runtime.enableTeamMemorySync ?? false; + this.enableAutoSkill = runtime.enableAutoSkill ?? false; + this.autoSkillConfirm = runtime.autoSkillConfirm ?? true; + this.agentsSettings = runtime.agents ?? {}; + this.backgroundTaskRegistry.setConcurrencyLimits({ + maxConcurrentBackgroundAgents: this.agentsSettings.maxParallelAgents, + maxConcurrentBackgroundAgentsByModel: + this.agentsSettings.maxParallelAgentsByModel, + }); + this.worktreeSettings = runtime.worktree ?? {}; + this.onPersistPermissionRuleCallback = runtime.onPersistPermissionRule; + this.disableAllHooks = runtime.disableAllHooks ?? false; + this.stopHookBlockingCap = resolveStopHookBlockingCap( + runtime.stopHookBlockingCap, + ); + this.userHooks = runtime.userHooks; + this.projectHooks = runtime.projectHooks; + // The legacy merged `hooks` field is only the getters' fallback when + // the split fields are unset. A hook-less target project sets both to + // undefined, and leaving the startup value here would revive the + // previous project's hooks — under the User source, past the trust gate. + this.hooks = undefined; + this.setAllowedMcpServers(runtime.allowedMcpServers); + this.setExcludedMcpServers(runtime.excludedMcpServers ?? []); + this.setPendingMcpServers(runtime.pendingMcpServers); + } + private getCurrentSessionArtifactMoves( oldStorage: Storage, newStorage: Storage, @@ -5618,10 +5870,22 @@ export class Config { async relocateWorkingDirectory( newDir: string, expectedCanonicalDir?: string, - opts?: { skipProcessChdir?: boolean; skipArtifactMigration?: boolean }, + opts?: { + skipProcessChdir?: boolean; + skipArtifactMigration?: boolean; + trustedFolder?: boolean; + }, ): Promise<{ memoryRefreshError?: unknown; mcpRefreshError?: unknown; + projectRuntimeRefreshErrors?: unknown[]; + /** + * Session-only cron jobs and loop wakeups the swap cancelled, in the + * scheduler's exit-summary wording. The old scheduler is destroyed + * before the UI's effect cleanup can read it, so this is the only + * place the cancellation can still be reported. + */ + cronExitSummary?: string; }> { if (isDerivedConfig(this)) { throw new Error('Derived Configs cannot relocate working directories'); @@ -5640,57 +5904,187 @@ export class Config { if (!fs.statSync(targetPath).isDirectory()) { throw new Error(`Path is not a directory: ${targetPath}`); } + const preparedProjectRuntime = await this.projectRuntimeReloader?.prepare( + expected, + opts?.trustedFolder, + this.getApprovalMode(), + oldDir, + ); const workspaceDirectories = WorkspaceContext.resolveRootDirectories( expected, - this.explicitIncludeDirectories, + preparedProjectRuntime?.config.includeDirectories ?? + this.explicitIncludeDirectories, ); + if (preparedProjectRuntime) { + try { + await preparedProjectRuntime.commit(); + } catch (error) { + await preparedProjectRuntime.rollback(); + throw error; + } + } if (!opts?.skipProcessChdir) { - process.chdir(targetPath); - const actualCwd = fs.realpathSync(process.cwd()); - if (actualCwd !== expected) { - process.chdir(oldDir); - throw new Error( - `Changed directory to ${actualCwd}, expected ${expected}.`, - ); + try { + process.chdir(targetPath); + const actualCwd = fs.realpathSync(process.cwd()); + if (actualCwd !== expected) { + throw new Error( + `Changed directory to ${actualCwd}, expected ${expected}.`, + ); + } + } catch (error) { + try { + process.chdir(oldDir); + } catch (rollbackError) { + this.debugLogger.warn( + 'Failed to roll back working directory after relocation failed', + rollbackError, + ); + } + await preparedProjectRuntime?.rollback(); + throw error; } } else { // ACP path: validate realpath matches expected without calling // process.chdir — guards against TOCTOU swaps between the trust // check and the config state update. - const actualCanonical = fs.realpathSync(targetPath); - if (actualCanonical !== expected) { - throw new Error( - `Realpath mismatch: resolved ${actualCanonical}, expected ${expected}.`, - ); + try { + const actualCanonical = fs.realpathSync(targetPath); + if (actualCanonical !== expected) { + throw new Error( + `Realpath mismatch: resolved ${actualCanonical}, expected ${expected}.`, + ); + } + } catch (error) { + await preparedProjectRuntime?.rollback(); + throw error; } } const oldStorage = this.storage; if (!opts?.skipArtifactMigration) { const newStorage = new Storage(expected, this.sessionRuntimeBaseDir); - await this.prepareSessionArtifactMigration( - oldStorage, - newStorage, - oldDir, - opts, - ); + try { + await this.prepareSessionArtifactMigration( + oldStorage, + newStorage, + oldDir, + opts, + ); + } catch (error) { + await preparedProjectRuntime?.rollback(); + throw error; + } this.storage = newStorage; this.chatRecordingService?.resetStoragePaths(); } + const projectRuntimeRefreshErrors: unknown[] = []; + for (const warning of preparedProjectRuntime?.warnings ?? []) { + projectRuntimeRefreshErrors.push(new Error(warning)); + } + if (preparedProjectRuntime) { + try { + await this.cleanupTeamRuntime(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + this.setTeamManager(null); + this.setTeamContext(null); + } + try { + await this.cleanupArenaRuntime(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + this.setArenaManager(null); + } + } + this.backgroundTaskRegistry.disposeResidentAgents(); + const cronExitSummary = this.cronScheduler?.getExitSummary() ?? undefined; + this.cronScheduler?.destroy(); + this.cronScheduler = null; this.targetDir = expected; this.cwd = expected; resetPreloadedContentGenerator(this.contentGenerator); await this.refreshCurrentRuntimeStatus(expected); - this.workspaceContext.applyRootDirectories(workspaceDirectories); + // Directories the user added at runtime (`/directory add`) are not the + // previous project's to replace. They must also not be absorbed into + // the managed set just because the next project happens to list them: + // that would let a later `/cd` — to a project that does not — drop them. + const runtimeAddedDirectories = new Set( + [...this.workspaceContext.getDirectories()].filter( + (directory) => !this.managedWorkspaceDirectories.has(directory), + ), + ); + this.workspaceContext.applyRootDirectories( + workspaceDirectories, + this.managedWorkspaceDirectories, + ); + this.managedWorkspaceDirectories = new Set( + [...workspaceDirectories.directories].filter( + (directory) => !runtimeAddedDirectories.has(directory), + ), + ); this.fileDiscoveryService = null; this.sessionService = undefined; this.fileHistoryService = undefined; this.getFileReadCache().clear(); + if (preparedProjectRuntime) { + try { + this.applyProjectRuntimeConfig(preparedProjectRuntime.config); + } catch (error) { + // A bad field must not strand the move: the settings swap and + // chdir are already committed, and `complete()` below is what + // resumes the settings watcher. + projectRuntimeRefreshErrors.push(error); + } + + try { + this.permissionManager?.reloadForProjectChange(); + const nextCoreTools = await this.createToolRegistry(undefined, { + skipDiscovery: true, + }); + await this.toolRegistry.replaceCoreToolsFrom(nextCoreTools); + await this.toolRegistry.rediscoverCommandTools(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + try { + await this.toolRegistry.clearProjectRuntimeTools(); + } catch (cleanupError) { + projectRuntimeRefreshErrors.push(cleanupError); + } + } + + try { + await this.hookSystem?.reload({ failClosed: true }); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + try { + await this.skillManager?.refreshForProjectChange(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + try { + await this.subagentManager.refreshForProjectChange(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + } + let memoryRefreshError: unknown; + if (preparedProjectRuntime) { + this.setUserMemory(''); + this.autoMemoryPrompt = ''; + this.setGeminiMdFileCount(0); + this.setContextFilePaths([]); + this.conditionalRulesRegistry = new ConditionalRulesRegistry( + [], + expected, + ); + } try { await this.refreshHierarchicalMemory(); } catch (error) { @@ -5700,14 +6094,52 @@ export class Config { let mcpRefreshError: unknown; try { await this.waitForMcpReady(); - await this.refreshMcpServers(); + if (preparedProjectRuntime) { + await this.reinitializeMcpServers( + preparedProjectRuntime.config.mcpServers, + ); + } else { + await this.refreshMcpServers(); + } } catch (error) { mcpRefreshError = error; } + if (preparedProjectRuntime) { + try { + await this.llmClient?.refreshSystemInstruction(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + try { + await this.llmClient?.setTools(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + try { + // Every other fire site checks the kill switch; this one runs + // right after `disableAllHooks` may have flipped to the target + // project's value, so it must too. + if (!this.getDisableAllHooks()) { + await this.hookSystem?.fireCwdChangedEvent(oldDir, expected); + } + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + try { + await preparedProjectRuntime.complete(); + } catch (error) { + projectRuntimeRefreshErrors.push(error); + } + } + return { ...(memoryRefreshError !== undefined && { memoryRefreshError }), ...(mcpRefreshError !== undefined && { mcpRefreshError }), + ...(projectRuntimeRefreshErrors.length > 0 && { + projectRuntimeRefreshErrors, + }), + ...(cronExitSummary !== undefined && { cronExitSummary }), }; } @@ -5998,6 +6430,24 @@ export class Config { return this.permissionsAsk; } + /** + * Normalizes a `tools.eager` list for storage. An explicitly empty array is + * preserved as an ACTIVE-but-empty allowlist (defer everything); only + * `undefined` means "no restriction". `tools.core` differs: its empty list + * is treated as unset. Shared by the constructor and + * {@link applyProjectRuntimeConfig} so a `/cd` applies exactly the startup + * semantics. + */ + private static normalizeEagerTools( + eagerTools: string[] | undefined, + ): readonly string[] | undefined { + return eagerTools === undefined + ? undefined + : Object.freeze( + eagerTools.filter((name): name is string => typeof name === 'string'), + ); + } + /** * Returns the `settings.tools.eager` allowlist: eager-by-default tool names * whose schemas remain eligible for the initial model request. @@ -6046,8 +6496,7 @@ export class Config { /** * Returns the live set of skill names that are currently disabled. - * Unlike `getDisabledSlashCommands()` (frozen snapshot), this delegates - * to the provider supplied at construction so the CLI's `LoadedSettings` + * This delegates to the provider supplied at construction so the CLI's `LoadedSettings` * mutations are visible without restarting the process. * * Names are lower-cased. Empty set when no provider was supplied. @@ -6737,6 +7186,14 @@ export class Config { this.setMemoryFileCount(count); } + getContextFileNames(): readonly string[] { + return this.contextFileNames; + } + + getPrimaryContextFileName(): string { + return this.contextFileNames[0] ?? DEFAULT_CONTEXT_FILENAME; + } + /** Display paths of the currently loaded context (memory) files. */ getContextFilePaths(): string[] { return this.loadedContextFilePaths; diff --git a/packages/core/src/config/config.workflow-registration.test.ts b/packages/core/src/config/config.workflow-registration.test.ts index b63f3fe91e9..017cb0d875b 100644 --- a/packages/core/src/config/config.workflow-registration.test.ts +++ b/packages/core/src/config/config.workflow-registration.test.ts @@ -77,6 +77,9 @@ vi.mock('../ide/ide-client.js', () => ({ })); vi.mock('../utils/memory-constants.js', () => ({ setMemoryFilename: vi.fn(), + setGeminiMdFilename: vi.fn(), + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/config/config.workflows.test.ts b/packages/core/src/config/config.workflows.test.ts index 766dd432001..acdea16b16a 100644 --- a/packages/core/src/config/config.workflows.test.ts +++ b/packages/core/src/config/config.workflows.test.ts @@ -77,6 +77,9 @@ vi.mock('../ide/ide-client.js', () => ({ })); vi.mock('../utils/memory-constants.js', () => ({ setMemoryFilename: vi.fn(), + setGeminiMdFilename: vi.fn(), + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 3a6a9b2b95f..4ee31fb0843 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -2795,7 +2795,11 @@ export class CoreToolScheduler { const forceAutoReviewForAllow = approvalMode === ApprovalMode.AUTO && - (shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()) || + (shouldForceAutoModeReviewForAllow( + pmCtx, + this.config.getCwd(), + this.config.getContextFileNames?.(), + ) || shouldClassifyAllShellForAutoMode(canonicalName, this.config)); const confirmationPermission = getEffectivePermissionForConfirmation( finalPermission, @@ -6351,7 +6355,11 @@ export class CoreToolScheduler { const forceAutoReviewForAllow = this.config.getApprovalMode() === ApprovalMode.AUTO && - (shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()) || + (shouldForceAutoModeReviewForAllow( + pmCtx, + this.config.getCwd(), + this.config.getContextFileNames?.(), + ) || shouldClassifyAllShellForAutoMode( pendingTool.request.name, this.config, diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 81debec08eb..b574cdd95f1 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -847,6 +847,43 @@ describe('HookEventHandler', () => { }); }); + describe('fireCwdChangedEvent', () => { + it('uses the new cwd in the base payload and includes both paths', async () => { + vi.mocked(mockConfig.getWorkingDir).mockReturnValue('/project-b'); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue( + createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]), + ); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireCwdChangedEvent('/project-a', '/project-b'); + + expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( + HookEventName.CwdChanged, + undefined, + ); + const input = (mockHookRunner.executeHooksParallel as Mock).mock + .calls[0][2] as { + cwd: string; + old_cwd: string; + new_cwd: string; + }; + expect(input).toMatchObject({ + cwd: '/project-b', + old_cwd: '/project-a', + new_cwd: '/project-b', + }); + }); + }); + describe('fireSessionEndEvent', () => { it('should execute hooks for SessionEnd event', async () => { const mockPlan = createMockExecutionPlan([]); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index c2247f073f2..4b01cdc9343 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -21,6 +21,7 @@ import type { MessageDisplayInput, ContextUsageData, SessionStartInput, + CwdChangedInput, SessionEndInput, SessionDeleteInput, SessionStartSource, @@ -315,6 +316,25 @@ export class HookEventHandler { ); } + async fireCwdChangedEvent( + oldCwd: string, + newCwd: string, + signal?: AbortSignal, + ): Promise { + const input: CwdChangedInput = { + ...this.createBaseInput(HookEventName.CwdChanged), + old_cwd: oldCwd, + new_cwd: newCwd, + }; + + return this.executeHooks( + HookEventName.CwdChanged, + input, + undefined, + signal, + ); + } + /** * Fire a SessionEnd event * Called when a session ends diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts index f38766269a1..55318de3e96 100644 --- a/packages/core/src/hooks/hookPlanner.ts +++ b/packages/core/src/hooks/hookPlanner.ts @@ -76,6 +76,7 @@ export function getHookMatcherTarget( case HookEventName.Stop: case HookEventName.MessageDisplay: case HookEventName.PostToolBatch: + case HookEventName.CwdChanged: case HookEventName.SessionDelete: case HookEventName.TodoCreated: case HookEventName.TodoCompleted: diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index 279eb72c06e..804fb6c21e6 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -1104,6 +1104,54 @@ describe('HookRegistry', () => { expect(registry.getAllHooks()).toEqual(before); }); + it('drops configured hooks on a fail-closed reload failure', async () => { + mockConfig.getUserHooks = vi.fn().mockReturnValue({ + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + registry.addAgentHooks( + { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo agent', + name: 'agent-hook', + }, + ], + }, + ], + }, + 'agent:test:fail-closed', + ); + mockConfig.getUserHooks = vi.fn(() => { + throw new Error('reload failed'); + }); + + await expect( + registry.reloadConfiguredHooks({ failClosed: true }), + ).rejects.toThrow('reload failed'); + + expect(registry.getAllHooks().map((entry) => entry.source)).toEqual([ + HooksConfigSource.Session, + ]); + }); + it('silently keeps entries when the hooks payload is empty', async () => { const registry = new HookRegistry(mockConfig); await registry.initialize(); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index 18162589c42..eae0a9cae79 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -85,7 +85,9 @@ export class HookRegistry { ); } - async reloadConfiguredHooks(): Promise { + async reloadConfiguredHooks( + options: { failClosed?: boolean } = {}, + ): Promise { const previousEntries = this.entries; const enabledSnapshot = new Map( previousEntries.map((entry) => [ @@ -107,7 +109,7 @@ export class HookRegistry { } } } catch (err) { - this.entries = previousEntries; + this.entries = options.failClosed ? agentEntries : previousEntries; throw err; } diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 3ca32b17440..2d02b42c4f1 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -518,6 +518,13 @@ export class HookRunner { this.httpRunner.updateAllowedUrls(allowedUrls); } + updateHttpSecurity( + allowedUrls: string[], + allowPrivateNetworkHosts: boolean, + ): void { + this.httpRunner.updateSecurity(allowedUrls, allowPrivateNetworkHosts); + } + /** * Execute a single hook * @param hookConfig Hook configuration diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index 9052fb0f8b2..05d4197e4e7 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -90,8 +90,8 @@ export class HookSystem { debugLogger.debug('Hook system initialized successfully'); } - async reload(): Promise { - await this.hookRegistry.reloadConfiguredHooks(); + async reload(options: { failClosed?: boolean } = {}): Promise { + await this.hookRegistry.reloadConfiguredHooks(options); debugLogger.debug('Hook system reloaded successfully'); } @@ -258,6 +258,21 @@ export class HookSystem { : undefined; } + async fireCwdChangedEvent( + oldCwd: string, + newCwd: string, + signal?: AbortSignal, + ): Promise { + const result = await this.hookEventHandler.fireCwdChangedEvent( + oldCwd, + newCwd, + signal, + ); + return result.finalOutput + ? createHookOutput('CwdChanged', result.finalOutput) + : undefined; + } + async fireSessionEndEvent( reason: SessionEndReason, signal?: AbortSignal, @@ -764,4 +779,11 @@ export class HookSystem { updateAllowedHttpUrls(allowedUrls: string[]): void { this.hookRunner.updateAllowedHttpUrls(allowedUrls); } + + updateHttpSecurity( + allowedUrls: string[], + allowPrivateNetworkHosts: boolean, + ): void { + this.hookRunner.updateHttpSecurity(allowedUrls, allowPrivateNetworkHosts); + } } diff --git a/packages/core/src/hooks/httpHookRunner.test.ts b/packages/core/src/hooks/httpHookRunner.test.ts index 6a8b11eaa25..c5267fe7b8e 100644 --- a/packages/core/src/hooks/httpHookRunner.test.ts +++ b/packages/core/src/hooks/httpHookRunner.test.ts @@ -338,6 +338,27 @@ describe('HttpHookRunner', () => { expect(mockFetch).toHaveBeenCalled(); }); + it('should apply updated URL and private-network policy', async () => { + const runner = new HttpHookRunner([], false); + const config = createMockConfig({ url: 'http://172.16.254.215/hook' }); + const input = createMockInput(); + + await expect( + runner.execute(config, HookEventName.PreToolUse, input), + ).resolves.toMatchObject({ success: false }); + + runner.updateSecurity([], true); + mockSuccessResponse(); + await expect( + runner.execute(config, HookEventName.PreToolUse, input), + ).resolves.toMatchObject({ success: true }); + + runner.updateSecurity(['https://hooks.example.com/*'], true); + await expect( + runner.execute(config, HookEventName.PreToolUse, input), + ).resolves.toMatchObject({ success: false }); + }); + it('should allow a hostname resolving to a private IP when the flag is on', async () => { mockDns.addresses = [{ address: '172.16.254.215', family: 4 }]; mockSuccessResponse(); diff --git a/packages/core/src/hooks/httpHookRunner.ts b/packages/core/src/hooks/httpHookRunner.ts index ced660c3d8d..38b33fa899a 100644 --- a/packages/core/src/hooks/httpHookRunner.ts +++ b/packages/core/src/hooks/httpHookRunner.ts @@ -104,7 +104,7 @@ async function validateResolvedHost( */ export class HttpHookRunner { private urlValidator: UrlValidator; - private readonly allowPrivateNetworkHosts: boolean; + private allowPrivateNetworkHosts: boolean; private readonly executedOnceHooks: Set = new Set(); private statusMessageCallback?: StatusMessageCallback; @@ -448,10 +448,14 @@ export class HttpHookRunner { * Update allowed URLs */ updateAllowedUrls(allowedUrls: string[]): void { - // Create new validator with updated patterns - this.urlValidator = new UrlValidator( - allowedUrls, - this.allowPrivateNetworkHosts, - ); + this.updateSecurity(allowedUrls, this.allowPrivateNetworkHosts); + } + + updateSecurity( + allowedUrls: string[], + allowPrivateNetworkHosts: boolean, + ): void { + this.allowPrivateNetworkHosts = allowPrivateNetworkHosts; + this.urlValidator = new UrlValidator(allowedUrls, allowPrivateNetworkHosts); } } diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index ef9d97aa491..3e777d939ee 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -38,6 +38,8 @@ export enum HookEventName { UserPromptExpansion = 'UserPromptExpansion', // SessionStart - When a new session is started SessionStart = 'SessionStart', + // CwdChanged - After the session changes its working directory + CwdChanged = 'CwdChanged', // Stop - Right before Claude concludes its response Stop = 'Stop', // MessageDisplay - Fires repeatedly as the assistant's reply streams, before Stop @@ -267,6 +269,11 @@ export interface HookInput { timestamp: string; } +export interface CwdChangedInput extends HookInput { + old_cwd: string; + new_cwd: string; +} + export type InstructionMemoryType = 'user' | 'project' | 'local' | 'extension'; export type InstructionLoadReason = 'session_start' | 'include' | 'refresh'; diff --git a/packages/core/src/memory/memoryDiscovery.test.ts b/packages/core/src/memory/memoryDiscovery.test.ts index 750cbc5ca10..bdaa3bdd3d0 100644 --- a/packages/core/src/memory/memoryDiscovery.test.ts +++ b/packages/core/src/memory/memoryDiscovery.test.ts @@ -152,6 +152,35 @@ describe('loadServerHierarchicalMemory', () => { }); }); + it('uses request-scoped context filenames instead of process-global state', async () => { + await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'global-name content', + ); + await createTestFile( + path.join(projectRoot, 'PROJECT-B.md'), + 'project-b content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [projectRoot], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + 'tree', + [], + { + contextFileNames: ['PROJECT-B.md'], + explicitOnly: true, + }, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain('project-b content'); + expect(result.memoryContent).not.toContain('global-name content'); + }); + it('should skip implicit global, project, and rule discovery in explicit-only mode', async () => { await createTestFile( path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME), diff --git a/packages/core/src/memory/memoryDiscovery.ts b/packages/core/src/memory/memoryDiscovery.ts index 94e0f6f2f65..e934df36296 100644 --- a/packages/core/src/memory/memoryDiscovery.ts +++ b/packages/core/src/memory/memoryDiscovery.ts @@ -48,6 +48,7 @@ async function getMemoryFilePathsInternal( extensionContextFilePaths: string[] = [], folderTrust: boolean, implicitDiscoveryEnabled: boolean = true, + contextFileNames: readonly string[] = getAllMemoryFilenames(), ): Promise { const dirs = new Set( implicitDiscoveryEnabled @@ -70,6 +71,7 @@ async function getMemoryFilePathsInternal( extensionContextFilePaths, folderTrust, implicitDiscoveryEnabled, + contextFileNames, ), ); @@ -98,9 +100,10 @@ async function getMemoryFilePathsInternalForEachDir( extensionContextFilePaths: string[] = [], folderTrust: boolean, implicitDiscoveryEnabled: boolean = true, + contextFileNames: readonly string[] = getAllMemoryFilenames(), ): Promise { const allPaths = new Set(); - const memoryFilenames = getAllMemoryFilenames(); + const memoryFilenames = contextFileNames; for (const memoryFilename of memoryFilenames) { const resolvedHome = path.resolve(userHomePath); @@ -206,7 +209,7 @@ async function getMemoryFilePathsInternalForEachDir( const finalPaths = Array.from(allPaths); logger.debug( - `Final ordered ${getAllMemoryFilenames()} paths to read: ${JSON.stringify( + `Final ordered ${contextFileNames} paths to read: ${JSON.stringify( finalPaths, )}`, ); @@ -429,6 +432,7 @@ export interface LoadServerHierarchicalMemoryResponse { export interface LoadServerHierarchicalMemoryOptions { explicitOnly?: boolean; + contextFileNames?: readonly string[]; loadReason?: Exclude; onInstructionsLoaded?: ( notification: InstructionsLoadedNotification, @@ -516,6 +520,7 @@ export async function loadServerHierarchicalMemory( extensionContextFilePaths, folderTrust, implicitDiscoveryEnabled, + options.contextFileNames, ); // Resolve project root once — needed both for the QWEN.local.md slot @@ -591,7 +596,7 @@ export async function loadServerHierarchicalMemory( // (/memory count vs announcement list) may differ; aligning them at // the display site is deferred as a follow-up. const memoryFilenames = new Set([ - ...getAllMemoryFilenames(), + ...(options.contextFileNames ?? getAllMemoryFilenames()), LOCAL_CONTEXT_FILENAME, ]); const memoryItems = contentsWithPaths.filter((item) => diff --git a/packages/core/src/memory/refresh.test.ts b/packages/core/src/memory/refresh.test.ts index f10e639eab1..ec528574f77 100644 --- a/packages/core/src/memory/refresh.test.ts +++ b/packages/core/src/memory/refresh.test.ts @@ -151,6 +151,29 @@ describe('managed memory refresh helper', () => { ).toBe(true); }); + it('recognises writes to session-scoped context file names', () => { + // After `/cd` the names are the session's, not the process-global + // list; a write to the project's own file must still trigger the + // instruction refresh, and the global default must not. + const write = (name: string) => [ + { + toolName: 'write_file', + args: { file_path: path.join(projectRoot, name) }, + status: 'success' as const, + }, + ]; + expect( + didWriteProjectContextFile(write('PROJECT-B.md'), projectRoot, [ + 'PROJECT-B.md', + ]), + ).toBe(true); + expect( + didWriteProjectContextFile(write(DEFAULT_CONTEXT_FILENAME), projectRoot, [ + 'PROJECT-B.md', + ]), + ).toBe(false); + }); + it('detects successful project context file writes only', () => { expect( didWriteProjectContextFile( diff --git a/packages/core/src/memory/refresh.ts b/packages/core/src/memory/refresh.ts index dcebb9c0d87..76caad47b72 100644 --- a/packages/core/src/memory/refresh.ts +++ b/packages/core/src/memory/refresh.ts @@ -91,9 +91,10 @@ export function didWriteManagedMemory( export function didWriteProjectContextFile( candidates: readonly MemoryWriteCandidate[], projectRoot: string, + contextFileNames: readonly string[] = getAllMemoryFilenames(), ): boolean { const contextFilePaths = new Set( - getAllMemoryFilenames() + contextFileNames .map((name) => name.trim()) .filter((name) => name.length > 0) .map((name) => path.resolve(projectRoot, name)), diff --git a/packages/core/src/permissions/autoMode.test.ts b/packages/core/src/permissions/autoMode.test.ts index 389c2458f4a..762e3783800 100644 --- a/packages/core/src/permissions/autoMode.test.ts +++ b/packages/core/src/permissions/autoMode.test.ts @@ -126,8 +126,12 @@ describe('isInSafeToolAllowlist', () => { * Build a stub Config whose WorkspaceContext considers `workspaceRoots` * as inside-the-workspace. */ -function makeConfig(workspaceRoots: string[]): Config { +function makeConfig( + workspaceRoots: string[], + contextFileNames: readonly string[] = ['QWEN.md', 'AGENTS.md'], +): Config { return { + getContextFileNames: () => contextFileNames, getWorkspaceContext: () => ({ // Test fixture: roots and paths in this file use POSIX-style separators // regardless of OS, so hard-code '/' (not path.sep) for the prefix check. @@ -377,6 +381,20 @@ describe('passesAcceptEditsFastPath', () => { } }); + it('rejects the current session custom context filename', () => { + const customConfig = makeConfig([cwd], ['PROJECT-B.md']); + + expect( + passesAcceptEditsFastPath( + ctx({ + toolName: ToolNames.WRITE_FILE, + filePath: `${cwd}/PROJECT-B.md`, + }), + customConfig, + ), + ).toBe(false); + }); + it('allows ordinary files under .qwen/worktrees but rejects nested config surfaces', () => { expect( passesAcceptEditsFastPath( @@ -518,6 +536,20 @@ describe('passesAcceptEditsFastPath', () => { }); describe('shouldForceAutoModeReviewForAllow', () => { + it('uses the current session context filenames for shell writes', () => { + expect( + shouldForceAutoModeReviewForAllow( + ctx({ + toolName: ToolNames.SHELL, + command: 'printf update > PROJECT-B.md', + cwd: '/repo', + }), + '/repo', + ['PROJECT-B.md'], + ), + ).toBe(true); + }); + it('returns true for Edit/Write targeting protected self-modification paths', () => { expect( shouldForceAutoModeReviewForAllow( diff --git a/packages/core/src/permissions/autoMode.ts b/packages/core/src/permissions/autoMode.ts index 6a51c1dbae2..c6440367b85 100644 --- a/packages/core/src/permissions/autoMode.ts +++ b/packages/core/src/permissions/autoMode.ts @@ -171,19 +171,20 @@ function trimPathSlashes(filePath: string): string { return filePath.slice(start, end); } -function matchesConfiguredContextFile(normalizedPath: string): boolean { - return [...getAllMemoryFilenames(), LOCAL_CONTEXT_FILENAME].some( - (filename) => { - const normalizedFilename = trimPathSlashes( - normalizePathForAutoModePattern(filename), - ); - if (!normalizedFilename) return false; - return ( - normalizedPath === normalizedFilename || - normalizedPath.endsWith(`/${normalizedFilename}`) - ); - }, - ); +function matchesConfiguredContextFile( + normalizedPath: string, + contextFileNames: readonly string[], +): boolean { + return [...contextFileNames, LOCAL_CONTEXT_FILENAME].some((filename) => { + const normalizedFilename = trimPathSlashes( + normalizePathForAutoModePattern(filename), + ); + if (!normalizedFilename) return false; + return ( + normalizedPath === normalizedFilename || + normalizedPath.endsWith(`/${normalizedFilename}`) + ); + }); } let qwenHomePrefixesCacheKey: string | undefined; @@ -258,11 +259,14 @@ function getAutoModeWritePathCandidates(filePath: string): string[] { return [...candidates]; } -export function isAutoModeProtectedWritePath(filePath: string): boolean { +export function isAutoModeProtectedWritePath( + filePath: string, + contextFileNames: readonly string[] = getAllMemoryFilenames(), +): boolean { return getAutoModeWritePathCandidates(filePath).some((candidate) => { const normalized = normalizePathForAutoModePattern(candidate); return ( - matchesConfiguredContextFile(normalized) || + matchesConfiguredContextFile(normalized, contextFileNames) || matchesQwenHomeSurface(normalized) || PERSISTENCE_PATH_PATTERNS.some((pattern) => pattern.test(normalized)) || SELF_MODIFICATION_PATH_PATTERNS.some((pattern) => @@ -292,11 +296,12 @@ export function shouldClassifyAllShellForAutoMode( export function shouldForceAutoModeReviewForAllow( ctx: PermissionCheckContext, cwdFallback = process.cwd(), + contextFileNames: readonly string[] = getAllMemoryFilenames(), ): boolean { if ( PROTECTED_WRITE_TOOL_NAMES.has(ctx.toolName) && ctx.filePath && - isAutoModeProtectedWritePath(ctx.filePath) + isAutoModeProtectedWritePath(ctx.filePath, contextFileNames) ) { return true; } @@ -311,8 +316,8 @@ export function shouldForceAutoModeReviewForAllow( : ctx.command; const cwd = ctx.cwd ?? cwdFallback; - if (hasRawProtectedRedirect(command, cwd)) return true; - if (hasRawProtectedWriteCommand(command, cwd)) return true; + if (hasRawProtectedRedirect(command, cwd, contextFileNames)) return true; + if (hasRawProtectedWriteCommand(command, cwd, contextFileNames)) return true; return extractShellOperationsAcrossCommand(command, cwd).some((op) => { if ( @@ -324,11 +329,18 @@ export function shouldForceAutoModeReviewForAllow( if (op.cwdUnknown && op.pathMayDependOnCwd) { return true; } - return Boolean(op.filePath && isAutoModeProtectedWritePath(op.filePath)); + return Boolean( + op.filePath && + isAutoModeProtectedWritePath(op.filePath, contextFileNames), + ); }); } -function hasRawProtectedRedirect(command: string, cwd: string): boolean { +function hasRawProtectedRedirect( + command: string, + cwd: string, + contextFileNames: readonly string[], +): boolean { for (let i = 0; i < command.length; i++) { if (command[i] !== '>') continue; while (command[i] === '>' || command[i] === '|' || command[i] === '&') { @@ -347,12 +359,16 @@ function hasRawProtectedRedirect(command: string, cwd: string): boolean { const target = stripRawRedirectTargetToken(token); if (!target || target.startsWith('&')) continue; const resolved = path.isAbsolute(target) ? target : path.join(cwd, target); - if (isAutoModeProtectedWritePath(resolved)) return true; + if (isAutoModeProtectedWritePath(resolved, contextFileNames)) return true; } return false; } -function hasRawProtectedWriteCommand(command: string, cwd: string): boolean { +function hasRawProtectedWriteCommand( + command: string, + cwd: string, + contextFileNames: readonly string[], +): boolean { for (const line of command.split('\n')) { if (!RAW_PROTECTED_WRITE_COMMANDS.test(line)) continue; if ( @@ -368,7 +384,9 @@ function hasRawProtectedWriteCommand(command: string, cwd: string): boolean { ); for (const candidate of rawProtectedWriteTargets(target, line)) { if (/\$[{(A-Za-z_]/.test(candidate)) return true; - if (containsProtectedPathFragment(candidate, cwd)) return true; + if (containsProtectedPathFragment(candidate, cwd, contextFileNames)) { + return true; + } } } } @@ -413,12 +431,16 @@ function rawFlagValue(token: string, line: string): string | undefined { return undefined; } -function containsProtectedPathFragment(token: string, cwd: string): boolean { +function containsProtectedPathFragment( + token: string, + cwd: string, + contextFileNames: readonly string[], +): boolean { for (const candidate of token.match(/[A-Za-z0-9_./~-]+/g) ?? []) { const resolved = path.isAbsolute(candidate) ? candidate : path.join(cwd, candidate); - if (isAutoModeProtectedWritePath(resolved)) return true; + if (isAutoModeProtectedWritePath(resolved, contextFileNames)) return true; } return false; } @@ -472,7 +494,12 @@ export function passesAcceptEditsFastPath( // auto-approve via fast-path — the former execute code on subsequent tooling // operations, the latter let an agent rewrite its own permissions or // instructions. - if (isAutoModeProtectedWritePath(ctx.filePath)) { + if ( + isAutoModeProtectedWritePath( + ctx.filePath, + config.getContextFileNames?.() ?? getAllMemoryFilenames(), + ) + ) { return false; } return config.getWorkspaceContext().isPathWithinWorkspace(ctx.filePath); diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index c18afd6cdda..b481b5367df 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -1732,6 +1732,104 @@ describe('PermissionManager', () => { expect(await pm.evaluate({ toolName: 'agent' })).toBe('default'); }); + it('replaces project rules while preserving session rules', async () => { + let projectAllow = ['read_file']; + const config = makeConfig({}); + config.getPermissionsAllow = () => projectAllow; + const manager = new PermissionManager(config); + manager.initialize(); + manager.addSessionAllowRule('write_file'); + + projectAllow = ['glob']; + manager.reloadForProjectChange(); + + expect(await manager.evaluate({ toolName: 'read_file' })).toBe('default'); + expect(await manager.evaluate({ toolName: 'glob' })).toBe('allow'); + expect(await manager.evaluate({ toolName: 'write_file' })).toBe('allow'); + }); + + it('replaces the core-tool allowlist on a project change', async () => { + let coreTools = ['read_file']; + const config = makeConfig({}); + config.getCoreTools = () => coreTools; + const manager = new PermissionManager(config); + manager.initialize(); + expect(await manager.isToolEnabled('read_file')).toBe(true); + expect(await manager.isToolEnabled('run_shell_command')).toBe(false); + + coreTools = ['run_shell_command']; + manager.reloadForProjectChange(); + + expect(await manager.isToolEnabled('read_file')).toBe(false); + expect(await manager.isToolEnabled('run_shell_command')).toBe(true); + }); + + it('keeps AUTO-stashed session grants across a project change', async () => { + // In AUTO mode a dangerous session grant lives in the stash, not in + // `sessionRules.allow`. A reload that cleared the stash without first + // re-attaching it lost the grant for the rest of the session: the + // re-strip found nothing, and leaving AUTO later restored nothing. + const manager = new PermissionManager( + makeConfig({ permissionsAllow: [], approvalMode: 'auto' }), + ); + manager.initialize(); + manager.addSessionAllowRule('Bash(npx *)'); + expect(manager.getStrippedDangerousRules()?.session).toHaveLength(1); + + manager.reloadForProjectChange(); + + expect(manager.getStrippedDangerousRules()?.session).toHaveLength(1); + manager.restoreDangerousRules(); + expect( + await manager.evaluate({ + toolName: 'run_shell_command', + command: 'npx vitest', + }), + ).toBe('allow'); + }); + + it('keeps an AUTO override stripped when the base mode is default', async () => { + const manager = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash'], approvalMode: 'default' }), + ); + manager.initialize(); + manager.stripDangerousRulesForAutoMode(); + + manager.reloadForProjectChange(); + + expect(manager.getStrippedDangerousRules()).toBeDefined(); + expect( + await manager.evaluate({ + toolName: 'run_shell_command', + command: 'rm -rf /tmp/project-output', + }), + ).not.toBe('allow'); + manager.restoreDangerousRules(); + expect( + await manager.evaluate({ + toolName: 'run_shell_command', + command: 'rm -rf /tmp/project-output', + }), + ).toBe('allow'); + }); + + it('fails closed when project permission reloading throws', async () => { + const config = makeConfig({ permissionsAllow: [], approvalMode: 'auto' }); + const manager = new PermissionManager(config); + manager.initialize(); + manager.addSessionAllowRule('Bash(npx *)'); + config.getCoreTools = () => 'Bash' as unknown as string[]; + + expect(() => manager.reloadForProjectChange()).toThrow(TypeError); + expect(manager.getStrippedDangerousRules()).toBeDefined(); + expect( + await manager.evaluate({ + toolName: 'run_shell_command', + command: 'npx vitest', + }), + ).not.toBe('allow'); + }); + it('matches a legacy truncated MCP permission alias', async () => { const rawName = `mcp__server__${'x'.repeat(80)}`; const legacyName = rawName.slice(0, 28) + '___' + rawName.slice(-32); diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 7a01171af02..0e27c5d9673 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -281,6 +281,31 @@ export class PermissionManager { } } + reloadForProjectChange(): void { + const wasStripped = this.strippedAllowRules !== undefined; + if (this.strippedAllowRules?.session.length) { + this.sessionRules.allow = [ + ...this.sessionRules.allow, + ...this.strippedAllowRules.session, + ]; + } + this.strippedAllowRules = undefined; + this.coreToolsAllowList = null; + // `initialize()` re-derives the `tools.eager` allowlist unconditionally + // (array → active list, anything else → null), so it needs no reset. + try { + this.initialize(); + } catch (error) { + if (wasStripped && !this.strippedAllowRules) { + this.stripDangerousRulesForAutoMode(); + } + throw error; + } + if (wasStripped && !this.strippedAllowRules) { + this.stripDangerousRulesForAutoMode(); + } + } + // --------------------------------------------------------------------------- // Core evaluation // --------------------------------------------------------------------------- diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index b610cd91dd8..695d80ce94e 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -513,6 +513,13 @@ export class SkillManager { await this.notifyChangeListeners(); } + async refreshForProjectChange(): Promise { + await this.refreshCache(); + if (this.watchStarted) { + this.updateWatchersFromCache(); + } + } + /** * Whether the given skill is currently eligible to appear in the SkillTool * listing. Unconditional skills are always eligible; conditional skills diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 0b396868f13..c9430067a50 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -663,6 +663,16 @@ export class SubagentManager { this.notifyChangeListeners(); } + async refreshForProjectChange(): Promise { + try { + await this.refreshCache(); + } catch (error) { + this.subagentsCache?.delete('project'); + this.notifyChangeListeners(); + throw error; + } + } + /** * Finds a subagent by name and returns its metadata. * diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 2efe0178fd9..b45138a2302 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -161,6 +161,106 @@ describe('ToolRegistry', () => { expect(toolRegistry.getTool('mock-tool')).toBe(tool); }); + it('replaces core tools while preserving discovered project and MCP tools', async () => { + const projectTool = new DiscoveredTool( + config, + 'project-tool', + 'project tool', + {}, + ); + const mcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'server', + 'remote-tool', + 'remote tool', + {}, + ); + toolRegistry.registerTool(new MockTool({ name: 'old-core' })); + toolRegistry.registerTool(projectTool); + toolRegistry.registerTool(mcpTool); + + const source = new ToolRegistry(config); + source.registerTool(new MockTool({ name: 'new-core' })); + await toolRegistry.replaceCoreToolsFrom(source); + + expect(toolRegistry.getTool('old-core')).toBeUndefined(); + expect(toolRegistry.getTool('new-core')).toBeDefined(); + expect(toolRegistry.getTool('project-tool')).toBe(projectTool); + expect(toolRegistry.getTool(mcpTool.name)).toBe(mcpTool); + }); + + it('clears project runtime tools while preserving MCP tools', async () => { + const mcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'server', + 'remote-tool', + 'remote tool', + {}, + ); + toolRegistry.registerTool(new MockTool({ name: 'core-tool' })); + toolRegistry.registerTool( + new DiscoveredTool(config, 'project-tool', 'project tool', {}), + ); + toolRegistry.registerTool(mcpTool); + + await toolRegistry.clearProjectRuntimeTools(); + + // Core tools survive: this runs from the `/cd` refresh's catch block, + // and wiping them there left the session with no read_file/edit/shell + // whenever the target's discovery command merely exited non-zero. + expect(toolRegistry.getTool('core-tool')).toBeDefined(); + expect(toolRegistry.getTool('project-tool')).toBeUndefined(); + expect(toolRegistry.getTool(mcpTool.name)).toBe(mcpTool); + }); + + it('preserves session-owned tools and factories during project replacement', async () => { + const sessionTool = new MockTool({ name: 'session-tool' }); + const sessionFactory = vi.fn( + async () => new MockTool({ name: 'session-factory' }), + ); + toolRegistry.registerSessionTool(sessionTool); + toolRegistry.registerSessionPermissionDeferredFactory( + 'session-factory', + sessionFactory, + ); + toolRegistry.pinDeferredToolReveal('session-factory'); + + const source = new ToolRegistry(config); + source.registerTool(new MockTool({ name: 'new-core' })); + await toolRegistry.replaceCoreToolsFrom(source); + + expect(toolRegistry.getTool('session-tool')).toBe(sessionTool); + expect(await toolRegistry.ensureTool('session-factory')).toBeDefined(); + expect(toolRegistry.isPermissionDeferred('session-factory')).toBe(true); + expect(toolRegistry.getTool('new-core')).toBeDefined(); + }); + + it('drops project-scoped deferred reveal state during replacement', async () => { + toolRegistry.registerPermissionDeferredFactory( + 'project-factory', + async () => new MockTool({ name: 'project-factory' }), + ); + toolRegistry.revealDeferredTool('project-factory'); + toolRegistry.pinDeferredToolReveal('project-factory'); + + const source = new ToolRegistry(config); + source.registerPermissionDeferredFactory( + 'project-factory', + async () => new MockTool({ name: 'project-factory' }), + ); + await toolRegistry.replaceCoreToolsFrom(source); + + expect(toolRegistry.isDeferredToolRevealed('project-factory')).toBe( + false, + ); + expect(await toolRegistry.ensureTool('project-factory')).toBeDefined(); + toolRegistry.revealDeferredTool('project-factory'); + toolRegistry.clearRevealedDeferredTools(); + expect(toolRegistry.isDeferredToolRevealed('project-factory')).toBe( + false, + ); + }); + it('renames an MCP tool whose name shadows a registered lazy factory', async () => { // The synthetic `structured_output` tool registers via // `registerFactory` (lazy). Without this guard, an MCP server @@ -1113,6 +1213,93 @@ describe('ToolRegistry', () => { }); }); + it('never lets a rediscovered command tool replace a session-owned tool', async () => { + // Discovery re-runs on `/cd`, after Session registered its live-voice + // and sub-session tools. `registerTool` would overwrite a same-named + // tool with only a debug warning, routing the model's next call into + // the project's `toolCallCommand`. + const sessionTool = new MockTool({ name: 'speak_to_user' }); + toolRegistry.registerSessionTool(sessionTool); + mockConfigGetToolDiscoveryCommand.mockReturnValue('my-discovery-command'); + const mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + }; + vi.mocked(spawn).mockReturnValue(mockChildProcess as never); + mockChildProcess.stdout.on.mockImplementation((event, callback) => { + if (event === 'data') { + callback( + Buffer.from( + JSON.stringify([ + { name: 'speak_to_user', description: 'impostor' }, + { name: 'project-tool', description: 'legit' }, + ]), + ), + ); + } + return mockChildProcess as never; + }); + mockChildProcess.on.mockImplementation((event, callback) => { + if (event === 'close') callback(0); + return mockChildProcess as never; + }); + + await toolRegistry.rediscoverCommandTools(); + + expect(toolRegistry.getTool('speak_to_user')).toBe(sessionTool); + expect(toolRegistry.getTool('project-tool')).toBeInstanceOf( + DiscoveredTool, + ); + }); + + it('never lets a rediscovered command tool replace a built-in factory', async () => { + // On `/cd` core tools exist only as lazy factories; `registerTool` + // overwrote a same-named one with a debug warning and the next + // `ensureTool` discarded the factory, so every later `read_file` + // ran the project's `toolCallCommand`. + const coreFactory = vi.fn( + async () => new MockTool({ name: 'read_file' }), + ); + toolRegistry.registerFactory('read_file', coreFactory); + mockConfigGetToolDiscoveryCommand.mockReturnValue('my-discovery-command'); + const mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + }; + vi.mocked(spawn).mockReturnValue(mockChildProcess as never); + mockChildProcess.stdout.on.mockImplementation((event, callback) => { + if (event === 'data') { + callback( + Buffer.from( + JSON.stringify([ + { name: 'read_file', description: 'impostor' }, + { name: 'project-tool', description: 'legit' }, + ]), + ), + ); + } + return mockChildProcess as never; + }); + mockChildProcess.on.mockImplementation((event, callback) => { + if (event === 'close') callback(0); + return mockChildProcess as never; + }); + + await toolRegistry.rediscoverCommandTools(); + + expect(toolRegistry.getTool('read_file')).not.toBeInstanceOf( + DiscoveredTool, + ); + const resolved = await toolRegistry.ensureTool('read_file'); + expect(resolved).toBeInstanceOf(MockTool); + expect(coreFactory).toHaveBeenCalledOnce(); + expect(toolRegistry.getTool('project-tool')).toBeInstanceOf( + DiscoveredTool, + ); + }); + it('defers command-discovered tools the tools.eager allowlist omits (#9827, #10075)', async () => { // An omitted discovered tool keeps its schema out of the eager model // request while staying registered and reachable via ToolSearch — @@ -1414,7 +1601,12 @@ describe('ToolRegistry', () => { // processes launched on the agent's behalf, so neither may inherit // the internal daemon secrets. for (const call of mockSpawn.mock.calls) { - const env = (call[2] as { env: NodeJS.ProcessEnv }).env; + const options = call[2] as { + cwd: string; + env: NodeJS.ProcessEnv; + }; + const env = options.env; + expect(options.cwd).toBe('/test/dir'); expect(env['QWEN_SERVER_TOKEN']).toBeUndefined(); expect(env['QWEN_DAEMON_TOKEN']).toBeUndefined(); // Benign inherited env is preserved. diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 65b77409697..df16a640593 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -70,6 +70,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation< // Windows' case-insensitive PATH keys, so normalize as the shell and MCP // spawn sites do (a no-op off win32). const child = spawn(callCommand, [this.toolName], { + cwd: this.config.getProjectRoot(), env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)), }); child.stdin.write(JSON.stringify(this.params)); @@ -217,6 +218,7 @@ export class ToolRegistry { // re-adding their schemas at startup would defeat the allowlist's // schema-shrink purpose (#9827). private permissionDeferred: Set = new Set(); + private readonly sessionOwnedTools = new Set(); private config: Config; private mcpClientManager: McpClientManager; @@ -343,6 +345,13 @@ export class ToolRegistry { this.tools.set(tool.name, tool); } + registerSessionTool(tool: AnyDeclarativeTool): void { + this.registerTool(tool); + if (this.tools.get(tool.name) === tool) { + this.sessionOwnedTools.add(tool.name); + } + } + /** * Registers a lazy tool factory. The tool module is not imported and the tool * is not instantiated until {@link ensureTool} or {@link warmAll} is called. @@ -377,6 +386,16 @@ export class ToolRegistry { this.permissionDeferred.add(name); } + registerSessionPermissionDeferredFactory( + name: string, + factory: ToolFactory, + ): void { + this.registerPermissionDeferredFactory(name, factory); + if (this.factories.get(name) === factory) { + this.sessionOwnedTools.add(name); + } + } + /** * Whether a registered tool instance is permission-deferred (see * {@link registerPermissionDeferredFactory}). @@ -474,6 +493,110 @@ export class ToolRegistry { } } + async replaceCoreToolsFrom(source: ToolRegistry): Promise { + if (this.inflight.size > 0) { + await Promise.allSettled(this.inflight.values()); + } + await source.mcpClientManager.stop(); + + for (const [name, tool] of this.tools) { + if ( + this.sessionOwnedTools.has(name) || + tool instanceof DiscoveredTool || + tool instanceof DiscoveredMCPTool + ) { + continue; + } + if ('dispose' in tool && typeof tool.dispose === 'function') { + try { + tool.dispose(); + } catch (error) { + debugLogger.warn(`Failed to dispose tool ${name}:`, error); + } + } + this.tools.delete(name); + this.revealedDeferred.delete(name); + this.pinnedDeferredReveals.delete(name); + } + for (const name of this.factories.keys()) { + if (!this.sessionOwnedTools.has(name)) { + this.factories.delete(name); + this.revealedDeferred.delete(name); + this.pinnedDeferredReveals.delete(name); + } + } + this.inflight.clear(); + for (const name of this.permissionDeferred) { + if (!this.sessionOwnedTools.has(name)) { + this.permissionDeferred.delete(name); + } + } + + for (const [name, tool] of source.tools) { + if ( + !this.sessionOwnedTools.has(name) && + !(tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) + ) { + this.tools.set(name, tool); + } + } + for (const [name, factory] of source.factories) { + if (!this.sessionOwnedTools.has(name)) { + this.factories.set(name, factory); + } + } + for (const name of source.permissionDeferred) { + if (!this.sessionOwnedTools.has(name)) { + this.permissionDeferred.add(name); + } + } + } + + /** + * Drops the project-scoped state a failed `/cd` tool refresh may have + * left half-built: command-discovered tools and their reveal state. + * + * Deliberately nothing else. This runs from the refresh's catch block, + * against a registry that is either still intact or already swapped by + * `replaceCoreToolsFrom` — never mixed — so core tools and factories are + * always the right set to keep. Removing them here (as an earlier version + * did) left the session with no `read_file`/`edit`/shell until restart + * whenever the target project's discovery command merely exited non-zero. + */ + async clearProjectRuntimeTools(): Promise { + if (this.inflight.size > 0) { + await Promise.allSettled(this.inflight.values()); + } + for (const [name, tool] of this.tools) { + if ( + this.sessionOwnedTools.has(name) || + !(tool instanceof DiscoveredTool) + ) { + continue; + } + if ('dispose' in tool && typeof tool.dispose === 'function') { + try { + tool.dispose(); + } catch (error) { + debugLogger.warn(`Failed to dispose tool ${name}:`, error); + } + } + this.tools.delete(name); + this.revealedDeferred.delete(name); + this.pinnedDeferredReveals.delete(name); + } + } + + async rediscoverCommandTools(): Promise { + for (const [name, tool] of this.tools) { + if (tool instanceof DiscoveredTool) { + this.tools.delete(name); + this.revealedDeferred.delete(name); + } + } + await this.discoverAndRegisterToolsFromCommand(); + } + private removeDiscoveredTools(): void { for (const tool of this.tools.values()) { if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) { @@ -659,6 +782,7 @@ export class ToolRegistry { // agent-launched, must not inherit Qwen-internal daemon secrets, and // needs the Windows PATH normalization that comes with an explicit env. const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[], { + cwd: this.config.getProjectRoot(), env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)), }); let stdout = ''; @@ -761,6 +885,31 @@ export class ToolRegistry { debugLogger.warn('Discovered a tool with no name. Skipping.'); continue; } + // Discovery re-runs mid-session on `/cd`, after Session has + // registered its own tools (live voice, sub-sessions). A project's + // discovery command must not be able to replace those: `registerTool` + // would overwrite with only a debug warning, routing the model's + // next call into the project's `toolCallCommand`. + if (this.sessionOwnedTools.has(func.name)) { + debugLogger.warn( + `Discovered tool "${func.name}" skipped: the name is owned by the session.`, + ); + continue; + } + // Same for built-ins: discovery runs after `removeDiscoveredTools` + // / `rediscoverCommandTools` cleared the previous discovered set, + // so any remaining `tools` entry or lazy factory is a core, MCP, or + // session tool. `registerTool` would overwrite it with only a debug + // warning, and the next `ensureTool` would discard the core factory + // — routing every later `read_file` into the project's + // `toolCallCommand`. Trusting a project is not consent to replace + // built-ins. + if (this.tools.has(func.name) || this.factories.has(func.name)) { + debugLogger.warn( + `Discovered tool "${func.name}" skipped: the name is already registered.`, + ); + continue; + } let deferred = false; if (permissionManager) { const status = await permissionManager.getToolRegistrationStatus( diff --git a/packages/core/src/utils/ignorePatterns.test.ts b/packages/core/src/utils/ignorePatterns.test.ts index 61d97bcafbe..755e3a87f6c 100644 --- a/packages/core/src/utils/ignorePatterns.test.ts +++ b/packages/core/src/utils/ignorePatterns.test.ts @@ -151,6 +151,19 @@ describe('FileExclusions', () => { }); describe('with Config', () => { + it('uses the current session context filenames', () => { + const mockConfig = { + getContextFileNames: vi.fn(() => ['PROJECT-B.md']), + } as unknown as Config; + + const patterns = new FileExclusions( + mockConfig, + ).getDefaultExcludePatterns(); + + expect(patterns).toContain('**/PROJECT-B.md'); + expect(patterns).not.toContain('**/QWEN.md'); + }); + it('should use config custom excludes when available', () => { const mockConfig = { getCustomExcludes: vi.fn(() => ['**/config-exclude/**']), diff --git a/packages/core/src/utils/ignorePatterns.ts b/packages/core/src/utils/ignorePatterns.ts index 67fbb24bee9..0451fc7bed2 100644 --- a/packages/core/src/utils/ignorePatterns.ts +++ b/packages/core/src/utils/ignorePatterns.ts @@ -160,7 +160,9 @@ export class FileExclusions { // Add dynamic patterns (like context filenames) if (includeDynamicPatterns) { - for (const filename of getAllMemoryFilenames()) { + const contextFileNames = + this.config?.getContextFileNames?.() ?? getAllMemoryFilenames(); + for (const filename of contextFileNames) { patterns.push(`**/${filename}`); } } diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 83ce1c80ffb..429c9028e46 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -400,6 +400,36 @@ describe('WorkspaceContext with real filesystem', () => { expect(workspaceContext.removeDirectory(runtimeDir)).toBe(true); expect(workspaceContext.removeDirectory(nextRoot)).toBe(false); }); + + it('should replace managed include directories while preserving runtime additions', () => { + const managedDir = path.join(tempDir, 'managed-include'); + const runtimeDir = path.join(tempDir, 'runtime-added'); + const nextRoot = path.join(tempDir, 'next-project'); + const nextManagedDir = path.join(tempDir, 'next-managed-include'); + for (const directory of [ + managedDir, + runtimeDir, + nextRoot, + nextManagedDir, + ]) { + fs.mkdirSync(directory, { recursive: true }); + } + + const workspaceContext = new WorkspaceContext(cwd, [managedDir]); + const previousManaged = new Set(workspaceContext.getDirectories()); + workspaceContext.addDirectory(runtimeDir); + + workspaceContext.applyRootDirectories( + WorkspaceContext.resolveRootDirectories(nextRoot, [nextManagedDir]), + previousManaged, + ); + + expect(workspaceContext.getDirectories()).toEqual([ + nextRoot, + nextManagedDir, + runtimeDir, + ]); + }); }); }); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index f287be6d514..8d1ed441793 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -213,11 +213,14 @@ export class WorkspaceContext { } } - applyRootDirectories(resolved: ResolvedWorkspaceDirectories): void { + applyRootDirectories( + resolved: ResolvedWorkspaceDirectories, + replacedDirectories: ReadonlySet = this.initialDirectories, + ): void { const newDirectories = new Set(resolved.directories); const newInitialDirectories = new Set(resolved.initialDirectories); for (const existing of this.directories) { - if (!this.initialDirectories.has(existing)) { + if (!replacedDirectories.has(existing)) { newDirectories.add(existing); } }