diff --git a/packages/core/src/config/config-session-env.test.ts b/packages/core/src/config/config-session-env.test.ts new file mode 100644 index 00000000000..8f300eacb27 --- /dev/null +++ b/packages/core/src/config/config-session-env.test.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Tests for the module-level `sessionEnvClaimed` guard in Config. + * + * The guard ensures that only the first Config instance in a process sets + * `process.env['QWEN_CODE_SESSION_ID']`, preventing throwaway instances + * (e.g. telemetry-only) from overwriting the real session's ID. + * + * We use `vi.isolateModules` to get a fresh module scope (resetting the + * module-level flag) for each test. + */ + +// Shared mocks needed by Config constructor +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('../telemetry/index.js', () => ({ + QwenLogger: vi.fn().mockImplementation(() => ({ + logStartSessionEvent: vi.fn().mockResolvedValue(undefined), + logEndSessionEvent: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + DEFAULT_TELEMETRY_TARGET: 'none', + DEFAULT_OTLP_ENDPOINT: '', + isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), + shutdownTelemetry: vi.fn().mockResolvedValue(undefined), + refreshSessionContext: vi.fn(), +})); +vi.mock('../core/contentGenerator.js', () => ({ + resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({ + config: { model: 'test-model', apiKey: 'test-key' }, + sources: {}, + }), + createContentGeneratorConfig: vi.fn().mockReturnValue({}), + createContentGenerator: vi.fn().mockReturnValue({}), + AuthType: { API_KEY: 'apiKey' }, +})); +vi.mock('../core/baseLlmClient.js'); +vi.mock('../core/toolHookTriggers.js', () => ({ + fireNotificationHook: vi.fn().mockResolvedValue({}), +})); +vi.mock('../services/skillManager.js', () => { + const SkillManagerMock = vi.fn(); + SkillManagerMock.prototype.startWatching = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.refreshCache = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.stopWatching = vi.fn(); + SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]); + SkillManagerMock.prototype.addChangeListener = vi.fn(); + SkillManagerMock.prototype.removeChangeListener = vi.fn(); + SkillManagerMock.prototype.matchAndActivateByPath = vi + .fn() + .mockResolvedValue([]); + SkillManagerMock.prototype.matchAndActivateByPaths = vi + .fn() + .mockResolvedValue([]); + return { SkillManager: SkillManagerMock }; +}); +vi.mock('../subagents/subagent-manager.js', () => { + const SubagentManagerMock = vi.fn(); + SubagentManagerMock.prototype.loadSessionSubagents = vi.fn(); + SubagentManagerMock.prototype.addChangeListener = vi + .fn() + .mockReturnValue(() => {}); + SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]); + return { SubagentManager: SubagentManagerMock }; +}); +vi.mock('../ide/ide-client.js', () => ({ + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, +})); +vi.mock('../memory/const.js', () => ({ + setGeminiMdFilename: vi.fn(), +})); + +import * as fs from 'node:fs'; +import type { Mock } from 'vitest'; +import type { ConfigParameters } from './config.js'; + +const baseParams: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp', + debugMode: false, + model: 'test-model', + telemetry: { enabled: false }, + usageStatisticsEnabled: false, + overrideExtensions: [], +}; + +describe('Config sessionEnvClaimed guard', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env['QWEN_CODE_SESSION_ID']; + delete process.env['QWEN_CODE_SESSION_ID']; + + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readdirSync as Mock).mockReturnValue([]); + (fs.statSync as Mock).mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => String(p)); + (fs.mkdirSync as Mock).mockImplementation(() => undefined); + (fs.writeFileSync as Mock).mockImplementation(() => undefined); + (fs.renameSync as Mock).mockImplementation(() => undefined); + (fs.copyFileSync as Mock).mockImplementation(() => undefined); + (fs.unlinkSync as Mock).mockImplementation(() => undefined); + (fs.readFileSync as Mock).mockImplementation(() => undefined); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['QWEN_CODE_SESSION_ID'] = originalEnv; + } else { + delete process.env['QWEN_CODE_SESSION_ID']; + } + vi.resetModules(); + }); + + it('first Config sets process.env QWEN_CODE_SESSION_ID to its sessionId', async () => { + const { Config } = await import('./config.js'); + const config = new Config({ ...baseParams }); + + expect(process.env['QWEN_CODE_SESSION_ID']).toBe(config.getSessionId()); + }); + + it('subsequent Config does not overwrite the env var set by the first', async () => { + const { Config } = await import('./config.js'); + const firstConfig = new Config({ ...baseParams }); + const firstSessionId = firstConfig.getSessionId(); + + // Second Config (e.g. telemetry-only throwaway instance) + const secondConfig = new Config({ + ...baseParams, + sessionId: 'throwaway-session-id', + }); + + // The env var should still be the first config's session ID + expect(process.env['QWEN_CODE_SESSION_ID']).toBe(firstSessionId); + expect(process.env['QWEN_CODE_SESSION_ID']).not.toBe( + secondConfig.getSessionId(), + ); + }); + + it('startNewSession updates env var to the new session ID', async () => { + const { Config } = await import('./config.js'); + const config = new Config({ ...baseParams }); + const originalSessionId = config.getSessionId(); + + expect(process.env['QWEN_CODE_SESSION_ID']).toBe(originalSessionId); + + // Simulate /clear or session switch + config.startNewSession('new-session-uuid-123'); + + expect(process.env['QWEN_CODE_SESSION_ID']).toBe('new-session-uuid-123'); + expect(process.env['QWEN_CODE_SESSION_ID']).not.toBe(originalSessionId); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5aade5d0e0e..218b36097ca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -872,6 +872,12 @@ const DEFAULT_BARE_CORE_TOOLS = [ ToolNames.SHELL, ]; +// Tracks whether the first Config in this process has claimed the global +// QWEN_CODE_SESSION_ID env var. Prevents throwaway Config instances from +// overwriting the real session's ID while still allowing nested qwen-code +// processes to claim their own (they start with a fresh module scope). +let sessionEnvClaimed = false; + export class Config { private sessionId: string; private sessionData?: ResumedSessionData; @@ -1074,6 +1080,16 @@ export class Config { constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); + // Only set the global env marker once per process lifetime, so + // throwaway Config instances (e.g. telemetry-only) don't clobber + // the real interactive session's ID. Uses a module-level flag + // rather than checking env existence — otherwise a nested qwen-code + // launched from within a session would inherit the parent's ID and + // never claim its own. + if (!sessionEnvClaimed && process.env) { + process.env['QWEN_CODE_SESSION_ID'] = this.sessionId; + sessionEnvClaimed = true; + } this.sessionData = params.sessionData; setDebugLogSession(this); this.debugLogger = createDebugLogger(); @@ -1986,6 +2002,12 @@ export class Config { const previousSessionId = this.sessionId; this.sessionId = sessionId ?? randomUUID(); + // Unconditional: startNewSession is only called on the canonical Config + // instance (the one that already claimed via sessionEnvClaimed), so this + // correctly updates the env var to reflect the new active session. + if (process.env) { + process.env['QWEN_CODE_SESSION_ID'] = this.sessionId; + } this.sessionData = sessionData; setDebugLogSession(this); this.debugLogger = createDebugLogger(); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 6f664267d27..630cfcb6693 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -29,6 +29,7 @@ import { FunctionHookRunner } from './functionHookRunner.js'; import { PromptHookRunner } from './promptHookRunner.js'; import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js'; import type { Config } from '../config/config.js'; +import { getShellContextEnvVars } from '../utils/shellContextEnv.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -573,6 +574,7 @@ export class HookRunner { GEMINI_PROJECT_DIR: input.cwd, CLAUDE_PROJECT_DIR: input.cwd, // For compatibility QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility + ...getShellContextEnvVars(), ...hookConfig.env, }; diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 2fe8d491fb2..d6be9185dc1 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -21,6 +21,7 @@ import { type AnsiOutput, } from '../utils/terminalSerializer.js'; import { normalizePathEnvForWindows } from '../utils/windowsPath.js'; +import { getShellContextEnvVars } from '../utils/shellContextEnv.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const { Terminal } = pkg; @@ -550,6 +551,7 @@ export class ShellExecutionService { QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: 'cat', + ...getShellContextEnvVars(), }, }); @@ -1152,6 +1154,7 @@ export class ShellExecutionService { TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + ...getShellContextEnvVars(), }, handleFlowControl: true, }); diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index e441df10e2a..d0b6ab4d577 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -53,6 +53,7 @@ import { isShellCommandReadOnlyAST, } from '../utils/shellAstParser.js'; import { getCurrentAgentId } from '../agents/runtime/agent-context.js'; +import { getShellContextEnvVars } from '../utils/shellContextEnv.js'; const debugLogger = createDebugLogger('MONITOR'); @@ -366,6 +367,7 @@ class MonitorToolInvocation extends BaseToolInvocation< QWEN_CODE: '1', TERM: 'dumb', // no color codes for streaming PAGER: 'cat', + ...getShellContextEnvVars(), }, }); } catch (err) { diff --git a/packages/core/src/utils/shellContextEnv.test.ts b/packages/core/src/utils/shellContextEnv.test.ts new file mode 100644 index 00000000000..17680915eb6 --- /dev/null +++ b/packages/core/src/utils/shellContextEnv.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { getShellContextEnvVars } from './shellContextEnv.js'; +import { runWithAgentContext } from '../agents/runtime/agent-context.js'; +import { promptIdContext } from './promptIdContext.js'; + +describe('getShellContextEnvVars', () => { + let originalSessionId: string | undefined; + + beforeEach(() => { + originalSessionId = process.env['QWEN_CODE_SESSION_ID']; + delete process.env['QWEN_CODE_SESSION_ID']; + }); + + afterEach(() => { + if (originalSessionId !== undefined) { + process.env['QWEN_CODE_SESSION_ID'] = originalSessionId; + } else { + delete process.env['QWEN_CODE_SESSION_ID']; + } + }); + + it('returns empty strings for agent/prompt when no context is available', () => { + const env = getShellContextEnvVars(); + expect(env).toEqual({ + QWEN_CODE_AGENT_ID: '', + QWEN_CODE_PROMPT_ID: '', + }); + }); + + it('returns QWEN_CODE_SESSION_ID when set in process.env', () => { + process.env['QWEN_CODE_SESSION_ID'] = 'test-session-123'; + const env = getShellContextEnvVars(); + expect(env['QWEN_CODE_SESSION_ID']).toBe('test-session-123'); + }); + + it('returns QWEN_CODE_AGENT_ID when called within agent context', async () => { + const env = await runWithAgentContext('my-agent-42', async () => + getShellContextEnvVars(), + ); + expect(env['QWEN_CODE_AGENT_ID']).toBe('my-agent-42'); + }); + + it('returns QWEN_CODE_PROMPT_ID when called within prompt context', () => { + const env = promptIdContext.run('prompt-abc', () => + getShellContextEnvVars(), + ); + expect(env['QWEN_CODE_PROMPT_ID']).toBe('prompt-abc'); + }); + + it('returns all vars when all contexts are active', async () => { + process.env['QWEN_CODE_SESSION_ID'] = 'sess-uuid'; + const env = await runWithAgentContext('agent-xyz', async () => + promptIdContext.run('prompt-456', () => getShellContextEnvVars()), + ); + expect(env).toEqual({ + QWEN_CODE_SESSION_ID: 'sess-uuid', + QWEN_CODE_AGENT_ID: 'agent-xyz', + QWEN_CODE_PROMPT_ID: 'prompt-456', + }); + }); + + it('sets empty string for agent/prompt to override inherited env', () => { + // Simulates a nested qwen-code process where parent injected these + const env = getShellContextEnvVars(); + expect(env['QWEN_CODE_AGENT_ID']).toBe(''); + expect(env['QWEN_CODE_PROMPT_ID']).toBe(''); + // Empty strings will overwrite any stale inherited values in process.env + }); +}); diff --git a/packages/core/src/utils/shellContextEnv.ts b/packages/core/src/utils/shellContextEnv.ts new file mode 100644 index 00000000000..7956eecb7dc --- /dev/null +++ b/packages/core/src/utils/shellContextEnv.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Returns context environment variables to inject into shell subprocesses. + * + * Reads dynamic context (agent ID, prompt ID) from AsyncLocalStorage at + * call time, and session ID from process.env (set by Config at session + * start). This enables downstream scripts to identify which session, + * agent, and prompt triggered their execution — useful for tracing, + * audit logging, and business context correlation. + * + * Must be called at spawn time within the executing async context to + * capture the correct agent/prompt frame. + */ + +import { getCurrentAgentId } from '../agents/runtime/agent-context.js'; +import { promptIdContext } from './promptIdContext.js'; + +export function getShellContextEnvVars(): Record { + const env: Record = {}; + + const sessionId = process.env['QWEN_CODE_SESSION_ID']; + if (sessionId) { + env['QWEN_CODE_SESSION_ID'] = sessionId; + } + + // For agent/prompt IDs: explicitly set empty string when no ALS context + // exists, so that stale values inherited from a parent qwen-code process + // (via process.env spread) are overwritten rather than leaked. + const agentId = getCurrentAgentId(); + env['QWEN_CODE_AGENT_ID'] = agentId ?? ''; + + const promptId = promptIdContext.getStore(); + env['QWEN_CODE_PROMPT_ID'] = promptId ?? ''; + + return env; +}