Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions packages/core/src/config/config-session-env.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Comment thread
yiliang114 marked this conversation as resolved.
process.env['QWEN_CODE_SESSION_ID'] = this.sessionId;
sessionEnvClaimed = true;
}
this.sessionData = params.sessionData;
setDebugLogSession(this);
this.debugLogger = createDebugLogger();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/hooks/hookRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
};

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -550,6 +551,7 @@ export class ShellExecutionService {
QWEN_CODE: '1',
TERM: 'xterm-256color',
PAGER: 'cat',
...getShellContextEnvVars(),
},
});

Expand Down Expand Up @@ -1152,6 +1154,7 @@ export class ShellExecutionService {
TERM: 'xterm-256color',
PAGER: shellExecutionConfig.pager ?? 'cat',
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
...getShellContextEnvVars(),
},
handleFlowControl: true,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tools/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -366,6 +367,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
QWEN_CODE: '1',
TERM: 'dumb', // no color codes for streaming
PAGER: 'cat',
...getShellContextEnvVars(),
},
});
} catch (err) {
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/utils/shellContextEnv.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
41 changes: 41 additions & 0 deletions packages/core/src/utils/shellContextEnv.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const env: Record<string, string> = {};

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 ?? '';
Comment thread
yiliang114 marked this conversation as resolved.

const promptId = promptIdContext.getStore();
env['QWEN_CODE_PROMPT_ID'] = promptId ?? '';
Comment thread
yiliang114 marked this conversation as resolved.

return env;
}
Loading