import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, it, expect } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { createConfigIO, readConfigFileSnapshotForWrite, writeConfigFile as writeConfigFileViaWrapper, } from "./io.js"; async function withTempConfig( configContent: string, run: (configPath: string) => Promise, ): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-env-io-")); const configPath = path.join(dir, "openclaw.json"); await fs.writeFile(configPath, configContent); try { await run(configPath); } finally { await fs.rm(dir, { recursive: true, force: true }); } } async function withWrapperEnvContext(configPath: string, run: () => Promise): Promise { await withEnvAsync( { OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_DISABLE_CONFIG_CACHE: "1", MY_API_KEY: "original-key-123", }, run, ); } function createGatewayTokenConfigJson(): string { return JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2); } function createMutableApiKeyEnv(initialValue = "original-key-123"): Record { return { MY_API_KEY: initialValue }; } async function withGatewayTokenTempConfig( run: (configPath: string) => Promise, ): Promise { await withTempConfig(createGatewayTokenConfigJson(), run); } async function withWrapperGatewayTokenContext( run: (configPath: string) => Promise, ): Promise { await withGatewayTokenTempConfig(async (configPath) => { await withWrapperEnvContext(configPath, async () => run(configPath)); }); } async function readGatewayToken(configPath: string): Promise { const written = await fs.readFile(configPath, "utf-8"); const parsed = JSON.parse(written) as { gateway: { remote: { token: string } } }; return parsed.gateway.remote.token; } describe("env snapshot TOCTOU via createConfigIO", () => { it("restores env refs using read-time env even after env mutation", async () => { const env = createMutableApiKeyEnv(); await withGatewayTokenTempConfig(async (configPath) => { // Instance A: read config (captures env snapshot) const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); const firstRead = await ioA.readConfigFileSnapshotForWrite(); expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123"); // Mutate env between read and write env.MY_API_KEY = "mutated-key-456"; // Instance B: write config using explicit read context from A const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); // Write the resolved config back — should restore ${MY_API_KEY} await ioB.writeConfigFile(firstRead.snapshot.config, firstRead.writeOptions); // Verify the written file still has ${MY_API_KEY}, not the resolved value const written = await fs.readFile(configPath, "utf-8"); const parsed = JSON.parse(written); expect(parsed.gateway.remote.token).toBe("${MY_API_KEY}"); }); }); it("without snapshot bridging, mutated env causes incorrect restoration", async () => { const env = createMutableApiKeyEnv(); await withGatewayTokenTempConfig(async (configPath) => { // Instance A: read config const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); const snapshot = await ioA.readConfigFileSnapshot(); // Mutate env env.MY_API_KEY = "mutated-key-456"; // Instance B: write WITHOUT snapshot bridging (simulates the old bug) const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); // No explicit writeOptions — ioB uses live env await ioB.writeConfigFile(snapshot.config); // The written file should have the raw value because the live env // no longer matches — restoreEnvVarRefs won't find a match const written = await fs.readFile(configPath, "utf-8"); const parsed = JSON.parse(written); // Without snapshot, the resolved value "original-key-123" doesn't match // live env "mutated-key-456", so restoration fails — value is written as-is expect(parsed.gateway.remote.token).toBe("original-key-123"); }); }); }); describe("env snapshot TOCTOU via wrapper APIs", () => { it("uses explicit read context even if another read interleaves", async () => { await withWrapperGatewayTokenContext(async (configPath) => { const firstRead = await readConfigFileSnapshotForWrite(); expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123"); // Interleaving read from another request context with a different env value. process.env.MY_API_KEY = "mutated-key-456"; const secondRead = await readConfigFileSnapshotForWrite(); expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456"); // Write using the first read's explicit context. await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions); expect(await readGatewayToken(configPath)).toBe("${MY_API_KEY}"); }); }); it("ignores read context when expected config path does not match", async () => { await withWrapperGatewayTokenContext(async (configPath) => { const firstRead = await readConfigFileSnapshotForWrite(); expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123"); expect(firstRead.writeOptions.expectedConfigPath).toBe(configPath); process.env.MY_API_KEY = "mutated-key-456"; await writeConfigFileViaWrapper(firstRead.snapshot.config, { ...firstRead.writeOptions, expectedConfigPath: `${configPath}.different`, }); expect(await readGatewayToken(configPath)).toBe("original-key-123"); }); }); });