diff --git a/sdk/typescript/_bundled_plugin/references/config-preflight.md b/sdk/typescript/_bundled_plugin/references/config-preflight.md index 77d8cc087..d93938581 100644 --- a/sdk/typescript/_bundled_plugin/references/config-preflight.md +++ b/sdk/typescript/_bundled_plugin/references/config-preflight.md @@ -16,7 +16,7 @@ For standard and diff scans, a passed `delegated_workers` check means the runtim When `CODEX_SECURITY_CONFIG_PATH` is set, add `--config "$CODEX_SECURITY_CONFIG_PATH"` in POSIX shells or `--config "$env:CODEX_SECURITY_CONFIG_PATH"` in PowerShell. The CLI provides this sanitized, shell-readable copy of the active worker configuration because the credential-bearing `CODEX_HOME` is intentionally inaccessible to repository-influenced commands. Do not substitute an ambient Codex home in that case. -Otherwise, the helper discovers Codex config paths itself from `--cwd`, which defaults to the current working directory. It reads `/etc/codex/config.toml`, then `$CODEX_HOME/config.toml`, resolves `project_root_markers`, checks the matching `[projects.""].trust_level`, and loads trusted project `.codex/config.toml` layers from the project root down to `--cwd`. It does not load project layers unless the user config marks that project root as `trusted`. +Otherwise, the helper discovers Codex config paths itself from `--cwd`, which defaults to the current working directory. It reads `/etc/codex/config.toml` on Unix-like hosts or `%ProgramData%\OpenAI\Codex\config.toml` on Windows, then `$CODEX_HOME/config.toml`, resolves `project_root_markers`, checks the matching `[projects.""].trust_level`, and loads trusted project `.codex/config.toml` layers from the project root down to `--cwd`. It does not load project layers unless the user config marks that project root as `trusted`. When the current Codex CLI session selected `-p/--profile `, pass `--codex-config-profile `. Current Codex loads `$CODEX_HOME/.config.toml` above the base user config and below trusted project config, so the helper uses that layer for project-root markers, trust, and capability values before it discovers project config. A missing profile file is an empty layer, matching the CLI. Embedded `[profiles.]` lookup remains only for older Codex configs that select `profile` without the CLI flag. Project-local `profile` and `profiles` values are ignored. For session-only CLI overrides or other effective config values that cannot be recovered from config paths, pass `--effective-config =`. diff --git a/sdk/typescript/_bundled_plugin/scripts/config_preflight.py b/sdk/typescript/_bundled_plugin/scripts/config_preflight.py index ee4f1ab01..cb04e3cb0 100644 --- a/sdk/typescript/_bundled_plugin/scripts/config_preflight.py +++ b/sdk/typescript/_bundled_plugin/scripts/config_preflight.py @@ -20,7 +20,16 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[1] DEFAULT_REGISTRY = PLUGIN_ROOT / "preflight" / "capability-profiles.toml" DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() -SYSTEM_CONFIG = Path("/etc/codex/config.toml") + + +def default_system_config() -> Path: + if os.name == "nt": + program_data = os.environ.get("ProgramData", r"C:\ProgramData") + return Path(program_data) / "OpenAI" / "Codex" / "config.toml" + return Path("/etc/codex/config.toml") + + +SYSTEM_CONFIG = default_system_config() DEFAULT_CONFIG = DEFAULT_CODEX_HOME / "config.toml" VALID_SEVERITIES = {"block", "warn", "suggest"} VALID_MULTI_AGENT_OWNERS = {"native", "codex-bridge"} @@ -244,6 +253,16 @@ def project_trust_level( if not isinstance(projects, dict): continue project = projects.get(str(project_root)) + if not isinstance(project, dict) and os.name == "nt": + project_key = os.path.normcase(os.path.realpath(project_root)) + project = next( + ( + value + for path, value in projects.items() + if os.path.normcase(os.path.realpath(path)) == project_key + ), + None, + ) if not isinstance(project, dict): continue trust_level = project.get("trust_level") diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 85d1b6577..8497fcc97 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -100,6 +100,7 @@ import { codexSecurityHasStoredFileCredentials, codexSecurityStateDirectory, createIsolatedHome, + expandHome, importAmbientAuth, prepareCodexSecurityCredentialHome, preserveCodexSecurityPluginRegistration, @@ -2101,8 +2102,10 @@ async function prepareDeepScanConfig( options: DeepScanOptions, signal: AbortSignal, ): Promise { - const ambientHome = - environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"); + const ambientHome = expandHome( + environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"), + environment, + ); const source = join(ambientHome, "codex-security", "config.toml"); let configured: TomlTable = {}; try { @@ -2128,14 +2131,15 @@ async function prepareDeepScanConfig( const value = options[name]; if (value !== undefined) overrides[key] = value; } + const sharedConfig = await sameExistingPath(source, destination); const hasOverrides = Object.keys(overrides).length > 0; if (existing === undefined && !hasOverrides) { - if (destination !== source) { + if (!sharedConfig) { await rm(destination, { force: true }); } return; } - if (destination === source && !hasOverrides) return; + if (sharedConfig && !hasOverrides) return; await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); await writeFile( destination, @@ -2147,6 +2151,15 @@ async function prepareDeepScanConfig( ); } +async function sameExistingPath(left: string, right: string): Promise { + if (left === right) return true; + const [canonicalLeft, canonicalRight] = await Promise.all([ + realpath(left).catch(() => null), + realpath(right).catch(() => null), + ]); + return canonicalLeft !== null && canonicalLeft === canonicalRight; +} + export function createSecurity( config: CodexSecurityConfig = {}, ): CodexSecurity { diff --git a/sdk/typescript/src/bulk-scan-discovery.ts b/sdk/typescript/src/bulk-scan-discovery.ts index c5c42410a..23a7b8722 100644 --- a/sdk/typescript/src/bulk-scan-discovery.ts +++ b/sdk/typescript/src/bulk-scan-discovery.ts @@ -8,6 +8,7 @@ import { promisify } from "node:util"; import { confirm, input, search } from "@inquirer/prompts"; import { Octokit } from "@octokit/core"; import Papa from "papaparse"; +import { expandHome } from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -171,9 +172,11 @@ export async function runBulkScanWizard( const outputDir = resolve( dependencies.currentDirectory(), - await prompt.input( - "Where should scan results be saved?", - "./security-scans", + expandHome( + await prompt.input( + "Where should scan results be saved?", + "./security-scans", + ), ), ); const inputPath = join(outputDir, "repositories.csv"); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 5f7c03fd8..c12d0bbc6 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -586,11 +586,14 @@ async function readPromptFiles( const [scanPrompt, postScanPrompt] = await Promise.all([ scanPromptFile === undefined ? undefined - : readRegularInputFile(resolve(directory, scanPromptFile), repository), + : readRegularInputFile( + resolveCliPath(directory, scanPromptFile), + repository, + ), postScanPromptFile === undefined ? undefined : readRegularInputFile( - resolve(directory, postScanPromptFile), + resolveCliPath(directory, postScanPromptFile), repository, ), ]); @@ -646,6 +649,10 @@ async function readRegularInputFile( } } +export function resolveCliPath(directory: string, value: string): string { + return resolve(directory, expandHome(value)); +} + interface ScanArguments extends DeepScanOptions { auth?: ScanAuthMode; verbose?: boolean; @@ -901,7 +908,9 @@ export async function runCodexSkillCommand( if (name.toUpperCase() === "CODEX_HOME") delete environment[name]; } if (configuredHome?.trim()) { - environment["CODEX_HOME"] = resolve(expandHome(configuredHome)); + environment["CODEX_HOME"] = resolve( + expandHome(configuredHome, processEnvironment), + ); } const invocation = spawn(command.command, [...args], { env: environment, @@ -1281,7 +1290,7 @@ export async function main( }), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format }) { - const repository = resolve( + const repository = resolveCliPath( dependencies.currentDirectory(), args.repository ?? ".", ); @@ -1330,26 +1339,25 @@ export async function main( output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { const directory = dependencies.currentDirectory(); + const scanRoot = + options.scanRoot === undefined + ? undefined + : resolveCliPath(directory, options.scanRoot); const repository = - options.scanRoot !== undefined && args.repository === undefined + scanRoot !== undefined && args.repository === undefined ? undefined - : resolve(directory, args.repository ?? directory); + : resolveCliPath(directory, args.repository ?? directory); return presentHistory( await history([ "list-scans", ...(repository === undefined ? [] : ["--repository", repository]), - ...(options.scanRoot === undefined - ? [] - : ["--scan-root", resolve(directory, options.scanRoot)]), + ...(scanRoot === undefined ? [] : ["--scan-root", scanRoot]), ]), "list", format, { repository, - scanRoot: - options.scanRoot === undefined - ? undefined - : resolve(directory, options.scanRoot), + scanRoot, }, ); }, @@ -2168,7 +2176,10 @@ export async function main( "git", [ "-C", - resolve(dependencies.currentDirectory(), args.repository ?? "."), + resolveCliPath( + dependencies.currentDirectory(), + args.repository ?? ".", + ), "rev-parse", "--path-format=absolute", "--git-path", @@ -2346,8 +2357,8 @@ export async function main( "--output-dir is required with a repository CSV.", ); } - inputPath = resolve(currentDirectory, args.input); - outputDir = resolve(currentDirectory, options.outputDir); + inputPath = resolveCliPath(currentDirectory, args.input); + outputDir = resolveCliPath(currentDirectory, options.outputDir); } const result = await runMultiscan({ inputPath, @@ -2443,12 +2454,12 @@ export async function main( if (scanDir === undefined) return; exitCode = await runExport( { - scanDir: resolve(currentDirectory, scanDir), + scanDir: resolveCliPath(currentDirectory, scanDir), format: options.exportFormat, output: options.output === "-" ? "-" - : resolve( + : resolveCliPath( currentDirectory, options.output ?? EXPORT_DEFAULT_OUTPUTS[options.exportFormat], @@ -2456,7 +2467,7 @@ export async function main( sourceRoot: options.sourceRoot === undefined ? undefined - : resolve(currentDirectory, options.sourceRoot), + : resolveCliPath(currentDirectory, options.sourceRoot), pythonPath: options.python, }, output, @@ -3272,7 +3283,7 @@ async function runSkill( !staysWithinWindowsDeviceRoot(input, rawDeviceRoot) || localDeviceRoot !== normalizedDeviceRoot); if (!windowsNetworkPath) { - const path = resolve(directory, input); + const path = resolveCliPath(directory, input); const metadata = await lstat(path, { bigint: true }).catch( (error: unknown) => { if ( diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 81b2a2379..5c94defb4 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -124,10 +124,17 @@ export function codexSecurityStateDirectory( environment: ProcessEnvironment = process.env, ): string { const configured = environmentValue(environment, "CODEX_SECURITY_STATE_DIR"); - if (configured !== undefined) return resolve(expandHome(configured)); + if (configured !== undefined) { + return resolve(expandHome(configured, environment)); + } const codexHome = environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"); - return resolve(expandHome(codexHome), "state", "plugins", "codex-security"); + return resolve( + expandHome(codexHome, environment), + "state", + "plugins", + "codex-security", + ); } export function codexSecurityCredentialHome( @@ -2023,11 +2030,13 @@ export function resolveCodexCommand( environment: ProcessEnvironment = process.env, ): CodexCommand { const configured = environmentValue(environment, "CODEX_CLI_PATH"); + const expanded = + configured === undefined ? undefined : expandHome(configured, environment); if ( - configured && - (process.platform !== "win32" || /\.(?:exe|com)$/iu.test(configured)) + expanded && + (process.platform !== "win32" || /\.(?:exe|com)$/iu.test(expanded)) ) { - return { command: resolve(configured) }; + return { command: resolve(expanded) }; } const platform = process.platform === "android" ? "linux" : process.platform; @@ -2482,7 +2491,9 @@ async function usablePython( signal?: AbortSignal, ): Promise { const command = await resolveTrustedExecutable( - isPythonPathCandidate(candidate) ? expandHome(candidate) : candidate, + isPythonPathCandidate(candidate) + ? expandHome(candidate, environment) + : candidate, environment, protectedRoot, ); @@ -2548,10 +2559,20 @@ async function sameFile(left: string, right: string): Promise { } } -export function expandHome(value: string): string { - if (value === "~") return homedir(); - if (value.startsWith("~/") || value.startsWith("~\\")) { - return join(homedir(), value.slice(2)); +export function expandHome( + value: string, + environment: ProcessEnvironment = process.env, +): string { + const home = + (process.platform === "win32" + ? environmentValue(environment, "USERPROFILE") ?? + environmentValue(environment, "HOME") + : environmentValue(environment, "HOME") ?? + environmentValue(environment, "USERPROFILE")) ?? homedir(); + if (value === "~") return home; + if (value.startsWith("~/")) return join(home, value.slice(2)); + if (value.startsWith("~\\")) { + return join(home, ...value.slice(2).split("\\")); } return value; } diff --git a/sdk/typescript/src/scan-comparison.ts b/sdk/typescript/src/scan-comparison.ts index 520f38e27..20614c39d 100644 --- a/sdk/typescript/src/scan-comparison.ts +++ b/sdk/typescript/src/scan-comparison.ts @@ -13,6 +13,7 @@ import { accountStatus } from "./auth.js"; import { CodexSecurityError } from "./errors.js"; import { codexSecurityCredentialHome, + expandHome, prepareCodexSecurityCredentialHome, resolveCodexCommand, } from "./runtime.js"; @@ -287,6 +288,7 @@ export async function comparisonEnvironment( source: NodeJS.ProcessEnv = process.env, nativeAccountStatus: typeof accountStatus = accountStatus, signal?: AbortSignal, + prepareCredentialHome: typeof prepareCodexSecurityCredentialHome = prepareCodexSecurityCredentialHome, ): Promise> { signal?.throwIfAborted(); const environment = Object.fromEntries( @@ -294,7 +296,9 @@ export async function comparisonEnvironment( (entry): entry is [string, string] => entry[1] !== undefined, ), ); - if (environment["CODEX_SECURITY_SCAN_ID"] !== undefined) return environment; + if (environmentEntry(environment, "CODEX_SECURITY_SCAN_ID") !== undefined) { + return environment; + } if ( Object.entries(environment).some( ([name, value]) => @@ -306,18 +310,19 @@ export async function comparisonEnvironment( } const credentialHome = codexSecurityCredentialHome(source); if (existsSync(credentialHome)) { - const canonicalCredentialHome = - await prepareCodexSecurityCredentialHome(source); + const canonicalCredentialHome = await prepareCredentialHome(source); signal?.throwIfAborted(); - const storedEnvironment: Record = { - ...environment, - CODEX_HOME: canonicalCredentialHome, - }; + const storedEnvironment: Record = { ...environment }; for (const key of Object.keys(storedEnvironment)) { - if (["OPENAI_API_KEY", "CODEX_API_KEY"].includes(key.toUpperCase())) { + if ( + ["CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY"].includes( + key.toUpperCase(), + ) + ) { delete storedEnvironment[key]; } } + storedEnvironment["CODEX_HOME"] = canonicalCredentialHome; const status = await nativeAccountStatus( resolveCodexCommand(source), storedEnvironment, @@ -325,13 +330,9 @@ export async function comparisonEnvironment( ); if (status.authenticated) return storedEnvironment; } - const configuredHome = environment["CODEX_HOME"]?.trim(); + const configuredHome = environmentEntry(environment, "CODEX_HOME")?.trim(); const codexHome = configuredHome - ? configuredHome === "~" - ? homedir() - : configuredHome.startsWith("~/") - ? join(homedir(), configuredHome.slice(2)) - : configuredHome + ? expandHome(configuredHome, environment) : join(homedir(), ".codex"); if (existsSync(join(codexHome, "auth.json"))) { for (const key of Object.keys(environment)) { @@ -343,6 +344,18 @@ export async function comparisonEnvironment( return environment; } +function environmentEntry( + environment: Record, + requested: string, +): string | undefined { + const exact = environment[requested]; + if (exact !== undefined || process.platform !== "win32") return exact; + const upper = requested.toUpperCase(); + return Object.entries(environment).find( + ([name]) => name.toUpperCase() === upper, + )?.[1]; +} + function validateComparison( input: ScanComparisonInput, response: unknown, diff --git a/sdk/typescript/tests-ts/api-preflight-config.test.ts b/sdk/typescript/tests-ts/api-preflight-config.test.ts index c4cc0bbfd..07c741a38 100644 --- a/sdk/typescript/tests-ts/api-preflight-config.test.ts +++ b/sdk/typescript/tests-ts/api-preflight-config.test.ts @@ -87,6 +87,55 @@ function runPreflight( } describe("CodexSecurity preflight configuration", () => { + test.skipIf(process.platform !== "win32")( + "loads trusted project config through a Windows path alias", + async () => { + const root = await temporaryDirectory(); + const codexHome = join(root, "codex-home"); + const repository = join(root, "Repository"); + const projectConfig = join(repository, ".codex", "config.toml"); + await mkdir(join(repository, ".git"), { recursive: true }); + await mkdir(join(repository, ".codex"), { recursive: true }); + await mkdir(codexHome); + await writeFile(projectConfig, "[features]\ngoals = true\n"); + await writeCodexConfig(join(codexHome, "config.toml"), { + projects: { + [repository.toUpperCase()]: { trust_level: "trusted" }, + }, + }); + + const interpreter = + process.env["PYTHON"] ?? + Bun.which("python3") ?? + Bun.which("python") ?? + Bun.which("py"); + expect(interpreter).not.toBeNull(); + const result = spawnSync( + interpreter!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "config_preflight.py"), + "--profile", + "security_scan", + "--cwd", + repository, + ], + { + encoding: "utf8", + env: { PATH: process.env["PATH"], CODEX_HOME: codexHome }, + }, + ); + expect(result.error).toBeUndefined(); + const payload = JSON.parse(result.stdout) as Record; + expect(payload["config_resolution"]).toBe("cwd-discovery"); + expect(payload["config_discovery"]).toMatchObject({ + project_layers_loaded: true, + }); + expect(payload["config_paths"]).toContain(projectConfig); + }, + ); + test("ignores unrelated runtime settings for profiles without parent-runtime requirements", async () => { const root = await temporaryDirectory(); const config = join(root, "empty.toml"); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e1db152a6..ead5d09c2 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2121,6 +2121,57 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test.skipIf(process.platform !== "win32")( + "loads deep scan settings from a backslash home-relative CODEX_HOME", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-home"); + const codexHome = join(root, "runtime-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(join(ambientHome, "codex-security"), { recursive: true }); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile( + join(ambientHome, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 5\n", + ); + const client = new TestClient( + {}, + { + environment: { + CODEX_HOME: "~\\ambient-home", + USERPROFILE: root, + }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), + }), + }, + ); + + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "deep scan settings captured", + ); + expect( + await readFile( + join(codexHome, "codex-security", "config.toml"), + "utf8", + ), + ).toContain("workers = 5"); + await client.close(); + }, + ); + test.each([ "removed", "without deep settings", @@ -2246,6 +2297,47 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test.skipIf(process.platform !== "win32")( + "preserves ambient configuration when the same Windows home uses different casing", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const configPath = join(codexHome, "codex-security", "config.toml"); + const originalConfiguration = "[other]\nenabled = true\n"; + await mkdir(repository); + await mkdir(join(codexHome, "codex-security"), { recursive: true }); + await writeFile(configPath, originalConfiguration); + await mkdir(scanDir, { mode: 0o700 }); + + const client = new TestClient( + {}, + { + environment: { CODEX_HOME: codexHome.toUpperCase() }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), + }), + }, + ); + + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "deep scan settings captured", + ); + expect(await readFile(configPath, "utf8")).toBe(originalConfiguration); + await client.close(); + }, + ); + test("rejects a scan registration without an authoritative target contract", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts b/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts index 718f9be93..5aae6f7f6 100644 --- a/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts +++ b/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts @@ -293,6 +293,31 @@ describe("bulk scan repository discovery", () => { expect(csv).not.toContain("unrelated"); }); + test.skipIf(process.platform !== "win32")( + "expands a backslash home-relative output directory", + async () => { + const home = await temporaryDirectory(); + const currentDirectory = join(home, "current"); + await mkdir(currentDirectory); + const { dependencies, prompt } = discoveryDependencies(currentDirectory); + prompt.confirms = [true]; + prompt.inputs = ["~\\bulk-results"]; + const previousUserProfile = process.env["USERPROFILE"]; + process.env["USERPROFILE"] = home; + try { + const result = await runBulkScanWizard(dependencies); + + expect(result?.outputDir).toBe(join(home, "bulk-results")); + } finally { + if (previousUserProfile === undefined) { + delete process.env["USERPROFILE"]; + } else { + process.env["USERPROFILE"] = previousUserProfile; + } + } + }, + ); + test("includes public repositories and excludes archived, forked, and empty repositories", async () => { const root = await temporaryDirectory(); const { dependencies, prompt } = discoveryDependencies(root, { diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index 923e247c0..3ea2f66af 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -377,6 +377,64 @@ describe("CLI", () => { } }); + test("expands home-relative export paths", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-export-home-")); + const home = join(root, "home"); + const currentDirectory = join(root, "current"); + const previousHome = process.env["HOME"]; + const previousUserProfile = process.env["USERPROFILE"]; + try { + await mkdir(home); + await mkdir(currentDirectory); + const scan = await copyCompletedScan(home); + const sourceRoot = join(home, "source"); + await mkdir(sourceRoot); + process.env["HOME"] = home; + process.env["USERPROFILE"] = home; + + const exports: Array<{ + scanDir: string; + output: string; + sourceRoot?: string; + }> = []; + const deps = dependencies({ currentDirectory }); + deps.exportFindings = async (arguments_) => { + exports.push(arguments_); + return undefined; + }; + expect( + await main( + [ + "export", + "~/scan", + "--export-format", + "sarif", + "--output", + "~/findings.sarif", + "--source-root", + "~/source", + ], + capture().stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(exports).toEqual([ + expect.objectContaining({ + scanDir: await realpath(scan), + output: join(await realpath(home), "findings.sarif"), + sourceRoot, + }), + ]); + } finally { + if (previousHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = previousHome; + if (previousUserProfile === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = previousUserProfile; + await rm(root, { recursive: true, force: true }); + } + }); + test("explains a missing export-output directory", async () => { const root = await mkdtemp( join(tmpdir(), "codex-security-export-missing-"), diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 8d891cefb..7d52e5dce 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -33,7 +33,12 @@ import { ScanInterruptedError, VERSION, } from "../src/index.js"; -import { main, parseCodexOverrides, Progress } from "../src/cli.js"; +import { + main, + parseCodexOverrides, + Progress, + resolveCliPath, +} from "../src/cli.js"; import { scanPreflightCodexConfig } from "../src/api.js"; import { CODEX_EXECUTABLE_VERSION, CODEX_SDK_VERSION } from "../src/version.js"; import { @@ -781,6 +786,55 @@ describe("CLI", () => { } }); + test("expands home-relative bulk scan paths", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-cli-home-")); + const home = join(root, "home"); + const currentDirectory = join(root, "current"); + const previousHome = process.env["HOME"]; + const previousUserProfile = process.env["USERPROFILE"]; + try { + await mkdir(home); + await mkdir(currentDirectory); + await multiscanInventory(home); + process.env["HOME"] = home; + process.env["USERPROFILE"] = home; + + expect(resolveCliPath(currentDirectory, "~/repositories.csv")).toBe( + join(home, "repositories.csv"), + ); + expect(resolveCliPath(currentDirectory, "~person/repositories.csv")).toBe( + join(currentDirectory, "~person", "repositories.csv"), + ); + + const stdout = capture(); + expect( + await main( + [ + "bulk-scan", + "~/repositories.csv", + "--output-dir", + "~/results", + "--json", + ], + stdout.stream, + capture().stream, + dependencies({ currentDirectory }), + ), + ).toBe(0); + expect(JSON.parse(stdout.text())).toMatchObject({ + completed: 1, + failed: 0, + resultsPath: join(home, "results", "results.jsonl"), + }); + } finally { + if (previousHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = previousHome; + if (previousUserProfile === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = previousUserProfile; + await rm(root, { recursive: true, force: true }); + } + }); + test.each([ [ "OpenRouter", diff --git a/sdk/typescript/tests-ts/knowledge-base.test.ts b/sdk/typescript/tests-ts/knowledge-base.test.ts index ffe54734b..206483f35 100644 --- a/sdk/typescript/tests-ts/knowledge-base.test.ts +++ b/sdk/typescript/tests-ts/knowledge-base.test.ts @@ -200,8 +200,10 @@ describe("scan knowledge bases", () => { const documents = join(home, "docs"); await mkdir(documents, { recursive: true }); await writeFile(join(documents, "scope.md"), "Review the payment service."); - const realHomeDirectory = os.homedir(); - const homeSpy = spyOn(os, "homedir").mockImplementation(() => home); + const previousHome = process.env["HOME"]; + const previousUserProfile = process.env["USERPROFILE"]; + process.env["HOME"] = home; + process.env["USERPROFILE"] = home; try { const expanded = await prepareKnowledgeBase(["~/docs"]); temporaryDirectories.push(expanded.path); @@ -217,9 +219,11 @@ describe("scan knowledge bases", () => { expect(expandHome("~other/docs")).toBe("~other/docs"); } finally { - homeSpy.mockRestore(); + if (previousHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = previousHome; + if (previousUserProfile === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = previousUserProfile; } - expect(os.homedir()).toBe(realHomeDirectory); }); test("extracts searchable text from PDFs and DOCX documents", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 5f6505e08..0031e323a 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -17,7 +17,7 @@ import { writeFile, } from "node:fs/promises"; import * as fsPromises from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, @@ -1806,6 +1806,16 @@ describe("plugin runtime preparation", () => { expect( resolveCodexCommand({ CODEX_CLI_PATH: `./bin/${executable}` }), ).toEqual({ command: join(process.cwd(), "bin", executable) }); + expect( + resolveCodexCommand({ CODEX_CLI_PATH: `~/bin/${executable}` }), + ).toEqual({ + command: join(homedir(), "bin", executable), + }); + expect( + resolveCodexCommand({ CODEX_CLI_PATH: `~\\bin\\${executable}` }), + ).toEqual({ + command: join(homedir(), "bin", executable), + }); expect(resolveCodexCommand({ Codex_Cli_Path: configured })).toEqual({ command: configured, }); @@ -3477,7 +3487,14 @@ describe("runtime directories and plugin Python boundary", () => { }; expect(payload.user_config_path).toBe(configPath); expect(payload.config_paths).toEqual([ - join("/", "etc", "codex", "config.toml"), + process.platform === "win32" + ? join( + process.env["ProgramData"] ?? "C:\\ProgramData", + "OpenAI", + "Codex", + "config.toml", + ) + : join("/", "etc", "codex", "config.toml"), configPath, ]); expect( @@ -3487,6 +3504,69 @@ describe("runtime directories and plugin Python boundary", () => { ).toMatchObject({ actual: 8, source: configPath }); }); + test.skipIf(process.platform !== "win32")( + "loads machine-wide Windows settings during preflight discovery", + async () => { + const root = await temporaryDirectory(); + const codexHome = join(root, "codex-home"); + const programData = join(root, "ProgramData"); + const systemConfig = join(programData, "OpenAI", "Codex", "config.toml"); + const repository = join(root, "repository"); + await mkdir(dirname(systemConfig), { recursive: true }); + await mkdir(codexHome); + await mkdir(repository); + await writeFile(systemConfig, "[agents]\nmax_threads = 8\n"); + + const python = + process.env["PYTHON"] ?? Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const result = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "config_preflight.py"), + "--profile", + "security_scan", + "--cwd", + repository, + "--runtime-check", + "delegation_available=true", + "--multi-agent-runtime-owner", + "native", + "--multi-agent-runtime-version", + "v1", + "--multi-agent-runtime-provenance", + "app-server", + ], + { + encoding: "utf8", + env: { + ...process.env, + CODEX_HOME: codexHome, + ProgramData: programData, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout) as { + config_paths: string[]; + results: { capability: string; actual: number; source: string }[]; + }; + expect(payload.config_paths).toEqual([ + systemConfig, + join(codexHome, "config.toml"), + ]); + expect( + payload.results.find( + (result) => result.capability === "usable_worker_slots_6", + ), + ).toMatchObject({ actual: 8, source: systemConfig }); + }, + ); + test("continues when optional preflight capabilities are unknown", async () => { const root = await temporaryDirectory(); const config = join(root, "config.toml"); diff --git a/sdk/typescript/tests-ts/scan-comparison.test.ts b/sdk/typescript/tests-ts/scan-comparison.test.ts index 3300242ae..29f11bef5 100644 --- a/sdk/typescript/tests-ts/scan-comparison.test.ts +++ b/sdk/typescript/tests-ts/scan-comparison.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ThreadOptions, TurnOptions } from "@openai/codex-sdk"; @@ -92,6 +99,62 @@ describe("semantic scan comparison", () => { expect(statusProbed).toBe(false); }); + test.skipIf(process.platform !== "win32")( + "recognizes provider scan variables regardless of Windows casing", + async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-comparison-")); + temporaryDirectories.push(root); + const stateDirectory = join(root, "state"); + const providerHome = join(root, "provider-home"); + await mkdir(join(stateDirectory, "codex-home"), { + recursive: true, + mode: 0o700, + }); + let statusProbed = false; + const provider = { + codex_security_scan_id: "scan", + CODEX_SECURITY_STATE_DIR: stateDirectory, + codex_home: providerHome, + FIREWORKS_API_KEY: "synthetic-provider-key", + }; + + const environment = await comparisonEnvironment(provider, async () => { + statusProbed = true; + return { authenticated: true, details: "Logged in using ChatGPT" }; + }); + + expect(environment).toEqual(provider); + expect(statusProbed).toBe(false); + }, + ); + + test.skipIf(process.platform !== "win32")( + "replaces differently cased Windows CODEX_HOME variables", + async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-comparison-")); + temporaryDirectories.push(root); + const stateDirectory = join(root, "state"); + const credentialHome = join(stateDirectory, "codex-home"); + await mkdir(credentialHome, { recursive: true, mode: 0o700 }); + + const environment = await comparisonEnvironment( + { + CODEX_SECURITY_STATE_DIR: stateDirectory, + codex_home: join(root, "ambient-home"), + }, + async () => ({ + authenticated: true, + details: "Logged in using ChatGPT", + }), + undefined, + async () => await realpath(credentialHome), + ); + + expect(environment["CODEX_HOME"]).toBe(await realpath(credentialHome)); + expect(environment["codex_home"]).toBeUndefined(); + }, + ); + test("reuses managed keyring credentials when no environment key is present", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-comparison-")); temporaryDirectories.push(root); @@ -196,6 +259,25 @@ describe("semantic scan comparison", () => { expect(environment["CODEX_HOME"]).toBe(ambientHome); }); + test.skipIf(process.platform !== "win32")( + "recognizes stored credentials under a backslash home-relative path", + async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-comparison-")); + temporaryDirectories.push(root); + const ambientHome = join(root, "ambient-codex-home"); + await mkdir(ambientHome); + await writeFile(join(ambientHome, "auth.json"), "{}"); + const environment = await comparisonEnvironment({ + CODEX_HOME: "~\\ambient-codex-home", + CODEX_SECURITY_STATE_DIR: join(root, "state"), + OPENAI_API_KEY: "", + USERPROFILE: root, + }); + + expect(environment["OPENAI_API_KEY"]).toBeUndefined(); + }, + ); + test("compares all findings with one restricted structured-output turn", async () => { const input: ScanComparisonInput = { before: [finding("before-1"), finding("before-2")],