mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 16:14:31 +00:00
refactor(config): simplify env snapshot write context
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
328
src/config/io.ts
328
src/config/io.ts
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user