refactor(config): simplify env snapshot write context

This commit is contained in:
Peter Steinberger
2026-02-14 02:03:38 +01:00
parent cc2249a431
commit e18f94a347
2 changed files with 249 additions and 241 deletions

View File

@@ -22,6 +22,35 @@ async function withTempConfig(
} }
} }
async function withEnvOverrides(
updates: Record<string, string | undefined>,
run: () => Promise<void>,
): Promise<void> {
const previous = new Map<string, string | undefined>();
for (const key of Object.keys(updates)) {
previous.set(key, process.env[key]);
}
try {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
await run();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("env snapshot TOCTOU via createConfigIO", () => { describe("env snapshot TOCTOU via createConfigIO", () => {
it("restores env refs using read-time env even after env mutation", async () => { it("restores env refs using read-time env even after env mutation", async () => {
const env: Record<string, string> = { const env: Record<string, string> = {
@@ -33,20 +62,17 @@ describe("env snapshot TOCTOU via createConfigIO", () => {
await withTempConfig(configJson, async (configPath) => { await withTempConfig(configJson, async (configPath) => {
// Instance A: read config (captures env snapshot) // Instance A: read config (captures env snapshot)
const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const snapshot = await ioA.readConfigFileSnapshot(); const firstRead = await ioA.readConfigFileSnapshotForWrite();
expect(snapshot.config.gateway?.remote?.token).toBe("original-key-123"); expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
// Mutate env between read and write // Mutate env between read and write
env.MY_API_KEY = "mutated-key-456"; env.MY_API_KEY = "mutated-key-456";
// Instance B: write config, but inject snapshot from A // Instance B: write config using explicit read context from A
const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const envSnapshot = ioA.getEnvSnapshot();
expect(envSnapshot).not.toBeNull();
ioB.setEnvSnapshot(envSnapshot!);
// Write the resolved config back — should restore ${MY_API_KEY} // Write the resolved config back — should restore ${MY_API_KEY}
await ioB.writeConfigFile(snapshot.config); await ioB.writeConfigFile(firstRead.snapshot.config, firstRead.writeOptions);
// Verify the written file still has ${MY_API_KEY}, not the resolved value // Verify the written file still has ${MY_API_KEY}, not the resolved value
const written = await fs.readFile(configPath, "utf-8"); const written = await fs.readFile(configPath, "utf-8");
@@ -72,7 +98,7 @@ describe("env snapshot TOCTOU via createConfigIO", () => {
// Instance B: write WITHOUT snapshot bridging (simulates the old bug) // Instance B: write WITHOUT snapshot bridging (simulates the old bug)
const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv }); const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
// No setEnvSnapshot — ioB uses live env // No explicit writeOptions — ioB uses live env
await ioB.writeConfigFile(snapshot.config); await ioB.writeConfigFile(snapshot.config);
@@ -89,90 +115,58 @@ describe("env snapshot TOCTOU via createConfigIO", () => {
describe("env snapshot TOCTOU via wrapper APIs", () => { describe("env snapshot TOCTOU via wrapper APIs", () => {
it("uses explicit read context even if another read interleaves", async () => { it("uses explicit read context even if another read interleaves", async () => {
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const prevCacheDisabled = process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
const prevToken = process.env.MY_API_KEY;
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2); const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
await withEnvOverrides(
{
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_CONFIG_CACHE: "1",
MY_API_KEY: "original-key-123",
},
async () => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
try { // Interleaving read from another request context with a different env value.
await withTempConfig(configJson, async (configPath) => { process.env.MY_API_KEY = "mutated-key-456";
process.env.OPENCLAW_CONFIG_PATH = configPath; const secondRead = await readConfigFileSnapshotForWrite();
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1"; expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456");
process.env.MY_API_KEY = "original-key-123";
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. // Write using the first read's explicit context.
process.env.MY_API_KEY = "mutated-key-456"; await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions);
const secondRead = await readConfigFileSnapshotForWrite(); const written = await fs.readFile(configPath, "utf-8");
expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456"); const parsed = JSON.parse(written);
expect(parsed.gateway.remote.token).toBe("${MY_API_KEY}");
// Write using the first read's explicit context. },
await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions); );
const written = await fs.readFile(configPath, "utf-8"); });
const parsed = JSON.parse(written);
expect(parsed.gateway.remote.token).toBe("${MY_API_KEY}");
});
} finally {
if (prevConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = prevConfigPath;
}
if (prevCacheDisabled === undefined) {
delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
} else {
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevCacheDisabled;
}
if (prevToken === undefined) {
delete process.env.MY_API_KEY;
} else {
process.env.MY_API_KEY = prevToken;
}
}
}); });
it("ignores read context when expected config path does not match", async () => { it("ignores read context when expected config path does not match", async () => {
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const prevCacheDisabled = process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
const prevToken = process.env.MY_API_KEY;
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2); const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
await withEnvOverrides(
{
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_CONFIG_CACHE: "1",
MY_API_KEY: "original-key-123",
},
async () => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
expect(firstRead.writeOptions.expectedConfigPath).toBe(configPath);
try { process.env.MY_API_KEY = "mutated-key-456";
await withTempConfig(configJson, async (configPath) => { await writeConfigFileViaWrapper(firstRead.snapshot.config, {
process.env.OPENCLAW_CONFIG_PATH = configPath; ...firstRead.writeOptions,
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1"; expectedConfigPath: `${configPath}.different`,
process.env.MY_API_KEY = "original-key-123"; });
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"; const written = await fs.readFile(configPath, "utf-8");
await writeConfigFileViaWrapper(firstRead.snapshot.config, { const parsed = JSON.parse(written);
...firstRead.writeOptions, expect(parsed.gateway.remote.token).toBe("original-key-123");
expectedConfigPath: `${configPath}.different`, },
}); );
});
const written = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(written);
expect(parsed.gateway.remote.token).toBe("original-key-123");
});
} finally {
if (prevConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = prevConfigPath;
}
if (prevCacheDisabled === undefined) {
delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
} else {
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevCacheDisabled;
}
if (prevToken === undefined) {
delete process.env.MY_API_KEY;
} else {
process.env.MY_API_KEY = prevToken;
}
}
}); });
}); });

View File

@@ -408,6 +408,43 @@ export function parseConfigJson5(
} }
} }
type ConfigReadResolution = {
resolvedConfigRaw: unknown;
envSnapshotForRestore: Record<string, string | undefined>;
};
function resolveConfigIncludesForRead(
parsed: unknown,
configPath: string,
deps: Required<ConfigIoDeps>,
): unknown {
return resolveConfigIncludes(parsed, configPath, {
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
parseJson: (raw) => deps.json5.parse(raw),
});
}
function resolveConfigForRead(
resolvedIncludes: unknown,
env: NodeJS.ProcessEnv,
): ConfigReadResolution {
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars.
if (resolvedIncludes && typeof resolvedIncludes === "object" && "env" in resolvedIncludes) {
applyConfigEnv(resolvedIncludes as OpenClawConfig, env);
}
return {
resolvedConfigRaw: resolveConfigEnvVars(resolvedIncludes, env),
// Capture env snapshot after substitution for write-time ${VAR} restoration.
envSnapshotForRestore: { ...env } as Record<string, string | undefined>,
};
}
type ReadConfigFileSnapshotInternalResult = {
snapshot: ConfigFileSnapshot;
envSnapshotForRestore?: Record<string, string | undefined>;
};
export function createConfigIO(overrides: ConfigIoDeps = {}) { export function createConfigIO(overrides: ConfigIoDeps = {}) {
const deps = normalizeDeps(overrides); const deps = normalizeDeps(overrides);
const requestedConfigPath = resolveConfigPathForDeps(deps); const requestedConfigPath = resolveConfigPathForDeps(deps);
@@ -417,11 +454,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const configPath = const configPath =
candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath; candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
// Snapshot of env vars captured after applyConfigEnv + resolveConfigEnvVars.
// Used by writeConfigFile to verify ${VAR} restoration against the env state
// that produced the resolved config, not the (possibly mutated) live env.
let envSnapshotForRestore: Record<string, string | undefined> | null = null;
function loadConfig(): OpenClawConfig { function loadConfig(): OpenClawConfig {
try { try {
maybeLoadDotEnvForConfig(deps.env); maybeLoadDotEnvForConfig(deps.env);
@@ -439,27 +471,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
} }
const raw = deps.fs.readFileSync(configPath, "utf-8"); const raw = deps.fs.readFileSync(configPath, "utf-8");
const parsed = deps.json5.parse(raw); const parsed = deps.json5.parse(raw);
const { resolvedConfigRaw: resolvedConfig } = resolveConfigForRead(
// Resolve $include directives before validation resolveConfigIncludesForRead(parsed, configPath, deps),
const resolved = resolveConfigIncludes(parsed, configPath, { deps.env,
readFile: (p) => deps.fs.readFileSync(p, "utf-8"), );
parseJson: (raw) => deps.json5.parse(raw),
});
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
if (resolved && typeof resolved === "object" && "env" in resolved) {
applyConfigEnv(resolved as OpenClawConfig, deps.env);
}
// Substitute ${VAR} env var references
const substituted = resolveConfigEnvVars(resolved, deps.env);
// Capture env snapshot after substitution for use by writeConfigFile.
// This ensures restoreEnvVarRefs verifies against the env that produced
// the resolved values, not a potentially mutated live env (TOCTOU fix).
envSnapshotForRestore = { ...deps.env } as Record<string, string | undefined>;
const resolvedConfig = substituted;
warnOnConfigMiskeys(resolvedConfig, deps.logger); warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) { if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
return {}; return {};
@@ -539,7 +554,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
} }
} }
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> { async function readConfigFileSnapshotInternal(): Promise<ReadConfigFileSnapshotInternalResult> {
maybeLoadDotEnvForConfig(deps.env); maybeLoadDotEnvForConfig(deps.env);
const exists = deps.fs.existsSync(configPath); const exists = deps.fs.existsSync(configPath);
if (!exists) { if (!exists) {
@@ -555,17 +570,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
); );
const legacyIssues: LegacyConfigIssue[] = []; const legacyIssues: LegacyConfigIssue[] = [];
return { return {
path: configPath, snapshot: {
exists: false, path: configPath,
raw: null, exists: false,
parsed: {}, raw: null,
resolved: {}, parsed: {},
valid: true, resolved: {},
config, valid: true,
hash, config,
issues: [], hash,
warnings: [], issues: [],
legacyIssues, warnings: [],
legacyIssues,
},
}; };
} }
@@ -575,151 +592,165 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const parsedRes = parseConfigJson5(raw, deps.json5); const parsedRes = parseConfigJson5(raw, deps.json5);
if (!parsedRes.ok) { if (!parsedRes.ok) {
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw, exists: true,
parsed: {}, raw,
resolved: {}, parsed: {},
valid: false, resolved: {},
config: {}, valid: false,
hash, config: {},
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], hash,
warnings: [], issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
legacyIssues: [], warnings: [],
legacyIssues: [],
},
}; };
} }
// Resolve $include directives // Resolve $include directives
let resolved: unknown; let resolved: unknown;
try { try {
resolved = resolveConfigIncludes(parsedRes.parsed, configPath, { resolved = resolveConfigIncludesForRead(parsedRes.parsed, configPath, deps);
readFile: (p) => deps.fs.readFileSync(p, "utf-8"),
parseJson: (raw) => deps.json5.parse(raw),
});
} catch (err) { } catch (err) {
const message = const message =
err instanceof ConfigIncludeError err instanceof ConfigIncludeError
? err.message ? err.message
: `Include resolution failed: ${String(err)}`; : `Include resolution failed: ${String(err)}`;
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw, exists: true,
parsed: parsedRes.parsed, raw,
resolved: coerceConfig(parsedRes.parsed), parsed: parsedRes.parsed,
valid: false, resolved: coerceConfig(parsedRes.parsed),
config: coerceConfig(parsedRes.parsed), valid: false,
hash, config: coerceConfig(parsedRes.parsed),
issues: [{ path: "", message }], hash,
warnings: [], issues: [{ path: "", message }],
legacyIssues: [], warnings: [],
legacyIssues: [],
},
}; };
} }
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars let readResolution: ConfigReadResolution;
if (resolved && typeof resolved === "object" && "env" in resolved) {
applyConfigEnv(resolved as OpenClawConfig, deps.env);
}
// Substitute ${VAR} env var references
let substituted: unknown;
try { try {
substituted = resolveConfigEnvVars(resolved, deps.env); readResolution = resolveConfigForRead(resolved, deps.env);
// Capture env snapshot (same as loadConfig — see TOCTOU comment above)
envSnapshotForRestore = { ...deps.env } as Record<string, string | undefined>;
} catch (err) { } catch (err) {
const message = const message =
err instanceof MissingEnvVarError err instanceof MissingEnvVarError
? err.message ? err.message
: `Env var substitution failed: ${String(err)}`; : `Env var substitution failed: ${String(err)}`;
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw, exists: true,
parsed: parsedRes.parsed, raw,
resolved: coerceConfig(resolved), parsed: parsedRes.parsed,
valid: false, resolved: coerceConfig(resolved),
config: coerceConfig(resolved), valid: false,
hash, config: coerceConfig(resolved),
issues: [{ path: "", message }], hash,
warnings: [], issues: [{ path: "", message }],
legacyIssues: [], warnings: [],
legacyIssues: [],
},
}; };
} }
const resolvedConfigRaw = substituted; const resolvedConfigRaw = readResolution.resolvedConfigRaw;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw); const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
if (!validated.ok) { if (!validated.ok) {
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw, exists: true,
parsed: parsedRes.parsed, raw,
resolved: coerceConfig(resolvedConfigRaw), parsed: parsedRes.parsed,
valid: false, resolved: coerceConfig(resolvedConfigRaw),
config: coerceConfig(resolvedConfigRaw), valid: false,
hash, config: coerceConfig(resolvedConfigRaw),
issues: validated.issues, hash,
warnings: validated.warnings, issues: validated.issues,
legacyIssues, warnings: validated.warnings,
legacyIssues,
},
}; };
} }
warnIfConfigFromFuture(validated.config, deps.logger); warnIfConfigFromFuture(validated.config, deps.logger);
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw, exists: true,
parsed: parsedRes.parsed, raw,
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults) parsed: parsedRes.parsed,
// for config set/unset operations (issue #6070) // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
resolved: coerceConfig(resolvedConfigRaw), // for config set/unset operations (issue #6070)
valid: true, resolved: coerceConfig(resolvedConfigRaw),
config: normalizeConfigPaths( valid: true,
applyTalkApiKey( config: normalizeConfigPaths(
applyModelDefaults( applyTalkApiKey(
applyAgentDefaults( applyModelDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), applyAgentDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
),
), ),
), ),
), ),
), hash,
hash, issues: [],
issues: [], warnings: validated.warnings,
warnings: validated.warnings, legacyIssues,
legacyIssues, },
envSnapshotForRestore: readResolution.envSnapshotForRestore,
}; };
} catch (err) { } catch (err) {
return { return {
path: configPath, snapshot: {
exists: true, path: configPath,
raw: null, exists: true,
parsed: {}, raw: null,
resolved: {}, parsed: {},
valid: false, resolved: {},
config: {}, valid: false,
hash: hashConfigRaw(null), config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }], hash: hashConfigRaw(null),
warnings: [], issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [], warnings: [],
legacyIssues: [],
},
}; };
} }
} }
async function writeConfigFile(cfg: OpenClawConfig) { async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const result = await readConfigFileSnapshotInternal();
return result.snapshot;
}
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const result = await readConfigFileSnapshotInternal();
return {
snapshot: result.snapshot,
writeOptions: {
envSnapshotForRestore: result.envSnapshotForRestore,
expectedConfigPath: configPath,
},
};
}
async function writeConfigFile(cfg: OpenClawConfig, options: ConfigWriteOptions = {}) {
clearConfigCache(); clearConfigCache();
let persistCandidate: unknown = cfg; let persistCandidate: unknown = cfg;
// Save the injected env snapshot before readConfigFileSnapshot() overwrites it const { snapshot } = await readConfigFileSnapshotInternal();
// with a new snapshot based on the (possibly mutated) live env.
const savedEnvSnapshot = envSnapshotForRestore;
const snapshot = await readConfigFileSnapshot();
let envRefMap: Map<string, string> | null = null; let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null; let changedPaths: Set<string> | null = null;
if (savedEnvSnapshot) {
envSnapshotForRestore = savedEnvSnapshot;
}
if (snapshot.valid && snapshot.exists) { if (snapshot.valid && snapshot.exists) {
const patch = createMergePatch(snapshot.config, cfg); const patch = createMergePatch(snapshot.config, cfg);
persistCandidate = applyMergePatch(snapshot.resolved, patch); persistCandidate = applyMergePatch(snapshot.resolved, patch);
@@ -771,7 +802,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// Use env snapshot from when config was loaded (if available) to avoid // Use env snapshot from when config was loaded (if available) to avoid
// TOCTOU issues where env changes between load and write. Falls back to // TOCTOU issues where env changes between load and write. Falls back to
// live env if no snapshot exists (e.g., first write before any load). // live env if no snapshot exists (e.g., first write before any load).
const envForRestore = envSnapshotForRestore ?? deps.env; const envForRestore = options.envSnapshotForRestore ?? deps.env;
cfgToWrite = restoreEnvVarRefs( cfgToWrite = restoreEnvVarRefs(
cfgToWrite, cfgToWrite,
parsedRes.parsed, parsedRes.parsed,
@@ -851,15 +882,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
configPath, configPath,
loadConfig, loadConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
writeConfigFile, writeConfigFile,
/** Return the env snapshot captured during the last loadConfig/readConfigFileSnapshot, or null. */
getEnvSnapshot(): Record<string, string | undefined> | null {
return envSnapshotForRestore;
},
/** Inject an env snapshot (e.g. from a prior IO instance) for use by writeConfigFile. */
setEnvSnapshot(snapshot: Record<string, string | undefined>): void {
envSnapshotForRestore = snapshot;
},
}; };
} }
@@ -928,27 +952,17 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
} }
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> { export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const io = createConfigIO(); return await createConfigIO().readConfigFileSnapshotForWrite();
const snapshot = await io.readConfigFileSnapshot();
return {
snapshot,
writeOptions: {
envSnapshotForRestore: io.getEnvSnapshot() ?? undefined,
expectedConfigPath: io.configPath,
},
};
} }
export async function writeConfigFile( export async function writeConfigFile(
cfg: OpenClawConfig, cfg: OpenClawConfig,
options: ConfigWriteOptions = {}, options: ConfigWriteOptions = {},
): Promise<void> { ): Promise<void> {
clearConfigCache();
const io = createConfigIO(); const io = createConfigIO();
const sameConfigPath = const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath; options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
if (sameConfigPath && options.envSnapshotForRestore) { await io.writeConfigFile(cfg, {
io.setEnvSnapshot(options.envSnapshotForRestore); envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
} });
await io.writeConfigFile(cfg);
} }