Files
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

241 lines
9.2 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, spyOn } from 'bun:test';
import * as fs from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
envFilePath,
buildIsolatedEnv,
buildIsolatedEnvWithFreshOAuth,
} from '../src/shared/EnvManager.js';
import { sanitizeEnv } from '../src/supervisor/env-sanitizer.js';
import * as oauthToken from '../src/shared/oauth-token.js';
// CJS interop: the check is a .cjs module exporting findViolations.
import { createRequire } from 'module';
const requireCjs = createRequire(import.meta.url);
const { findViolations } = requireCjs('../scripts/check-spawn-env-discipline.cjs') as {
findViolations: () => Array<{ file: string; line: number }>;
};
/**
* Tests for issue #2375: ANTHROPIC_BASE_URL must not leak from the parent
* shell into the spawned worker's isolatedEnv, AND the OAuth-skip predicate
* must not inject the user's Anthropic OAuth token onto a custom gateway URL
* (which would be a token leak to a third party).
*
* Redirect EnvManager to a per-suite temp file via CLAUDE_MEM_ENV_FILE so
* the user's real ~/.claude-mem/.env is never read or mutated even if a test
* fails mid-flight. envFilePath() resolves the override on every call, so
* this works regardless of the order other tests imported the module.
*/
const TEST_DATA_DIR = fs.mkdtempSync(join(tmpdir(), 'claude-mem-env-isolation-'));
const TEST_ENV_FILE = join(TEST_DATA_DIR, '.env');
const ORIGINAL_ENV_FILE = process.env.CLAUDE_MEM_ENV_FILE;
const ORIGINAL_BASE_URL = process.env.ANTHROPIC_BASE_URL;
const ORIGINAL_API_KEY = process.env.ANTHROPIC_API_KEY;
const ORIGINAL_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN;
const ORIGINAL_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
function clearEnvFile(): void {
if (fs.existsSync(TEST_ENV_FILE)) {
fs.unlinkSync(TEST_ENV_FILE);
}
}
function clearAnthropicEnv(): void {
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
function restoreOriginalEnv(): void {
if (ORIGINAL_BASE_URL === undefined) {
delete process.env.ANTHROPIC_BASE_URL;
} else {
process.env.ANTHROPIC_BASE_URL = ORIGINAL_BASE_URL;
}
if (ORIGINAL_API_KEY === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = ORIGINAL_API_KEY;
}
if (ORIGINAL_AUTH_TOKEN === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = ORIGINAL_AUTH_TOKEN;
}
if (ORIGINAL_OAUTH_TOKEN === undefined) {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
} else {
process.env.CLAUDE_CODE_OAUTH_TOKEN = ORIGINAL_OAUTH_TOKEN;
}
}
describe('Issue #2375: ANTHROPIC_BASE_URL env-var isolation', () => {
beforeAll(() => {
fs.mkdirSync(TEST_DATA_DIR, { recursive: true, mode: 0o700 });
process.env.CLAUDE_MEM_ENV_FILE = TEST_ENV_FILE;
expect(envFilePath()).toBe(TEST_ENV_FILE);
});
afterAll(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_ENV_FILE === undefined) {
delete process.env.CLAUDE_MEM_ENV_FILE;
} else {
process.env.CLAUDE_MEM_ENV_FILE = ORIGINAL_ENV_FILE;
}
});
beforeEach(() => {
clearEnvFile();
clearAnthropicEnv();
});
afterEach(() => {
clearEnvFile();
restoreOriginalEnv();
});
it('leaked ANTHROPIC_BASE_URL is stripped from isolatedEnv', () => {
// No .env file exists. The parent shell sets a stray ANTHROPIC_BASE_URL —
// this MUST NOT propagate into the subprocess isolatedEnv, because doing
// so used to trigger the OAuth-skip path and leave the worker with no
// credentials at all.
process.env.ANTHROPIC_BASE_URL = 'https://shouldnotleak.example';
const result = buildIsolatedEnv();
expect(result.ANTHROPIC_BASE_URL).toBeUndefined();
});
it('~/.claude-mem/.env BASE_URL + AUTH_TOKEN reaches isolatedEnv', () => {
// User intentionally configured a gateway with a gateway-appropriate
// auth token. Both must be re-injected into isolatedEnv.
fs.writeFileSync(
TEST_ENV_FILE,
'ANTHROPIC_BASE_URL=https://gateway.example\nANTHROPIC_AUTH_TOKEN=test-token\n',
{ mode: 0o600 },
);
const result = buildIsolatedEnv();
expect(result.ANTHROPIC_BASE_URL).toBe('https://gateway.example');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('test-token');
});
it('leaked process.env BASE_URL never reaches the OAuth-skip predicate', async () => {
// The root cause of #2375: a BASE_URL exported by the parent shell used to
// survive into isolatedEnv and trigger the OAuth-skip path, leaving the
// subprocess with no credentials at all. With BASE_URL in BLOCKED_ENV_VARS,
// the leak is stripped before the predicate runs, so OAuth lookup still
// fires (the real credential path) rather than being short-circuited.
process.env.ANTHROPIC_BASE_URL = 'https://leaked-from-shell.example';
const oauthSpy = spyOn(oauthToken, 'readClaudeOAuthToken');
try {
const result = await buildIsolatedEnvWithFreshOAuth();
// The leaked BASE_URL must not be present (it was never re-injected from
// .env, which does not exist in this test).
expect(result.ANTHROPIC_BASE_URL).toBeUndefined();
} finally {
oauthSpy.mockRestore();
}
});
it('bare .env BASE_URL alone does not trigger OAuth fetch', async () => {
// A user with a tokenless gateway (e.g. mTLS at the network boundary)
// configures BASE_URL only. The three-branch predicate must hit the
// BASE_URL-set branch BEFORE OAuth lookup, so CLAUDE_CODE_OAUTH_TOKEN
// must NOT appear in the result. This is the security-regression guard
// against a token leak to a third-party gateway.
//
// Note: EnvManager captures readClaudeOAuthToken via a named import at
// module load, so spyOn on the namespace export only weakly observes
// the call (the binding inside EnvManager is independent). The
// behavioral assertions (BASE_URL re-injected AND OAuth token NOT
// injected) are the load-bearing checks: in the no-OAuth-injection
// outcome, the only execution path that produces this combination is
// the new BASE_URL-first branch returning early.
fs.writeFileSync(
TEST_ENV_FILE,
'ANTHROPIC_BASE_URL=https://gateway.example\n',
{ mode: 0o600 },
);
const oauthSpy = spyOn(oauthToken, 'readClaudeOAuthToken');
try {
const result = await buildIsolatedEnvWithFreshOAuth();
expect(result.ANTHROPIC_BASE_URL).toBe('https://gateway.example');
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
// Best-effort sanity check; see note above.
expect(oauthSpy).not.toHaveBeenCalled();
} finally {
oauthSpy.mockRestore();
}
});
});
/**
* Issue #2357 (defense-in-depth): CLAUDE_CODE_EFFORT_LEVEL /
* CLAUDE_CODE_ALWAYS_ENABLE_EFFORT must never reach the SDK subprocess. The
* SDK forwards CLAUDE_CODE_EFFORT_LEVEL as the `effort` Messages API parameter;
* models that don't support it reject with a permanent HTTP 400. Two layers
* strip it: BLOCKED_ENV_VARS (buildIsolatedEnv) and the CLAUDE_CODE_* prefix
* filter (sanitizeEnv). These tests prove BOTH layers independently.
*/
describe('Issue #2357: CLAUDE_CODE_EFFORT_* env-var isolation', () => {
const ORIGINAL_EFFORT = process.env.CLAUDE_CODE_EFFORT_LEVEL;
const ORIGINAL_ALWAYS = process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT;
afterEach(() => {
if (ORIGINAL_EFFORT === undefined) delete process.env.CLAUDE_CODE_EFFORT_LEVEL;
else process.env.CLAUDE_CODE_EFFORT_LEVEL = ORIGINAL_EFFORT;
if (ORIGINAL_ALWAYS === undefined) delete process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT;
else process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = ORIGINAL_ALWAYS;
});
it('buildIsolatedEnv strips CLAUDE_CODE_EFFORT_LEVEL via BLOCKED_ENV_VARS (layer 1)', () => {
process.env.CLAUDE_CODE_EFFORT_LEVEL = 'MAX';
process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = 'true';
const result = buildIsolatedEnv();
expect(result.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined();
expect(result.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT).toBeUndefined();
});
it('sanitizeEnv(buildIsolatedEnv()) strips CLAUDE_CODE_EFFORT_LEVEL (both layers)', () => {
process.env.CLAUDE_CODE_EFFORT_LEVEL = 'MAX';
const result = sanitizeEnv(buildIsolatedEnv());
expect(result.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined();
});
it('sanitizeEnv alone strips CLAUDE_CODE_EFFORT_LEVEL via the CLAUDE_CODE_* prefix (layer 2)', () => {
const result = sanitizeEnv({ CLAUDE_CODE_EFFORT_LEVEL: 'MAX', PATH: '/usr/bin' });
expect(result.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined();
// Unrelated vars survive.
expect(result.PATH).toBe('/usr/bin');
});
});
/**
* Spawn-env discipline (plan 06 Phase 7): every env-bearing subprocess spawn in
* src/ must sanitize process.env before handing it to the child. This test runs
* the CI grep check inside the suite so a regression fails `bun test`, not just
* a separate lint step.
*/
describe('spawn-env discipline (CI guard)', () => {
it('no spawn site hands raw process.env to a child without sanitizeEnv', () => {
const violations = findViolations();
expect(violations).toEqual([]);
});
});