mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 08:02:12 -06:00
fix(config): migrate and validate recovery snapshots before restore (#120475)
This commit is contained in:
committed by
GitHub
parent
947bca5608
commit
267268f646
@@ -0,0 +1,65 @@
|
||||
// Verifies Doctor persists legacy gateway bind repairs through the real config writer.
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js";
|
||||
import { runInitialConfigWriteHealth } from "../flows/doctor-health-contribution-runners.config.js";
|
||||
import type { DoctorHealthFlowContext } from "../flows/doctor-health-contribution-types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||
|
||||
describe("Doctor gateway bind persistence", () => {
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["localhost", "loopback"],
|
||||
["0.0.0.0", "lan"],
|
||||
] as const)("persists gateway bind %s as %s", async (legacyBind, canonicalBind) => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = await writeOpenClawConfig(home, {
|
||||
gateway: { mode: "local", bind: legacyBind },
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
log: vi.fn(),
|
||||
};
|
||||
const options: DoctorOptions = { nonInteractive: true, repair: true };
|
||||
const prompter = createDoctorPrompter({ runtime, options });
|
||||
const configResult = await loadAndMaybeMigrateDoctorConfig({
|
||||
options,
|
||||
confirm: (params) => prompter.confirm(params),
|
||||
runtime,
|
||||
prompter,
|
||||
});
|
||||
const ctx: DoctorHealthFlowContext = {
|
||||
runtime,
|
||||
options,
|
||||
prompter,
|
||||
configResult,
|
||||
cfg: configResult.cfg,
|
||||
cfgForPersistence: structuredClone(configResult.cfg),
|
||||
sourceConfigValid: configResult.sourceConfigValid ?? true,
|
||||
configPath,
|
||||
stateDirExistedAtStart: true,
|
||||
...(configResult.runWithPluginMetadataSnapshot
|
||||
? { runWithPluginMetadataSnapshot: configResult.runWithPluginMetadataSnapshot }
|
||||
: {}),
|
||||
...(configResult.invalidatePluginMetadataSnapshot
|
||||
? { invalidatePluginMetadataSnapshot: configResult.invalidatePluginMetadataSnapshot }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await runInitialConfigWriteHealth(ctx);
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
expect(snapshot.valid).toBe(true);
|
||||
expect(snapshot.config.gateway?.bind).toBe(canonicalBind);
|
||||
expect(await fs.readFile(configPath, "utf-8")).not.toContain(`"bind": "${legacyBind}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyCliProfileEnv } from "../cli/profile.js";
|
||||
import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js";
|
||||
import { writeConfigHealthStateToStore } from "../config/io.health-state.js";
|
||||
import { createConfigHealthFingerprint } from "../config/io.observe-state.js";
|
||||
import { withEnvOverride, withTempHome, writeOpenClawConfig } from "../config/test-helpers.js";
|
||||
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
@@ -37,6 +39,36 @@ async function writeLegacyConfig(home: string): Promise<string> {
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
async function seedLastKnownGood(
|
||||
home: string,
|
||||
configPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
||||
const lastGoodPath = `${configPath}.last-good`;
|
||||
await fs.writeFile(lastGoodPath, raw, "utf-8");
|
||||
const fingerprint = createConfigHealthFingerprint({
|
||||
raw,
|
||||
parsed: config,
|
||||
stat: await fs.stat(lastGoodPath),
|
||||
});
|
||||
writeConfigHealthStateToStore(
|
||||
{
|
||||
env: { ...process.env, HOME: home },
|
||||
homedir: () => home,
|
||||
logger: { warn: () => {} },
|
||||
},
|
||||
{
|
||||
entries: {
|
||||
[configPath]: {
|
||||
lastKnownGood: fingerprint,
|
||||
lastPromotedGood: fingerprint,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("runDoctorConfigPreflight", () => {
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
@@ -262,6 +294,66 @@ describe("runDoctorConfigPreflight", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["localhost", "loopback"],
|
||||
["0.0.0.0", "lan"],
|
||||
] as const)(
|
||||
"migrates last-known-good gateway bind %s to %s before restoring",
|
||||
async (legacyBind, canonicalBind) => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = await writeOpenClawConfig(home, {
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
await seedLastKnownGood(home, configPath, {
|
||||
gateway: { mode: "local", bind: legacyBind },
|
||||
});
|
||||
const brokenRaw = '{ "gateway": { "mode": "local" },';
|
||||
await fs.writeFile(configPath, brokenRaw, "utf-8");
|
||||
|
||||
const repaired = await runDoctorConfigPreflight({
|
||||
migrateState: false,
|
||||
migrateLegacyConfig: false,
|
||||
repairPrefixedConfig: true,
|
||||
invalidConfigNote: false,
|
||||
});
|
||||
|
||||
expect(repaired.snapshot.valid).toBe(true);
|
||||
expect(repaired.snapshot.config.gateway?.bind).toBe(canonicalBind);
|
||||
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
|
||||
gateway?: { bind?: string };
|
||||
};
|
||||
expect(persisted.gateway?.bind).toBe(canonicalBind);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the active config when last-known-good cannot converge", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = await writeOpenClawConfig(home, {
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
await seedLastKnownGood(home, configPath, {
|
||||
gateway: { mode: "local", bind: "not-a-bind-mode" },
|
||||
});
|
||||
const brokenRaw = '{ "gateway": { "mode": "local" },';
|
||||
await fs.writeFile(configPath, brokenRaw, "utf-8");
|
||||
|
||||
const failure = await runDoctorConfigPreflight({
|
||||
migrateState: false,
|
||||
migrateLegacyConfig: false,
|
||||
repairPrefixedConfig: true,
|
||||
invalidConfigNote: false,
|
||||
}).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect((failure as Error).message).toContain("Config could not be parsed or recovered");
|
||||
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(brokenRaw);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves and rejects unparseable config without last-known-good during repair preflight", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -5,13 +5,11 @@ import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js";
|
||||
import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js";
|
||||
import type { DoctorConfigMutationState } from "./config-mutation-state.js";
|
||||
import {
|
||||
classifyConfigPathMigrationOwnership,
|
||||
classifyOtelGrpcMigrationOwnership,
|
||||
containsAuthoredInclude,
|
||||
} from "./include-migration-ownership.js";
|
||||
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
|
||||
|
||||
const OTEL_GRPC_PROTOCOL_PATH = "diagnostics.otel.protocol";
|
||||
|
||||
/** Apply legacy config migrations and update preview/fix state for doctor config flow. */
|
||||
export function applyLegacyCompatibilityStep(params: {
|
||||
snapshot: DoctorConfigPreflightResult["snapshot"];
|
||||
@@ -34,21 +32,24 @@ export function applyLegacyCompatibilityStep(params: {
|
||||
}
|
||||
|
||||
const issueLines = formatConfigIssueLines(params.snapshot.legacyIssues, "-");
|
||||
if (params.snapshot.legacyIssues.some((issue) => issue.path === OTEL_GRPC_PROTOCOL_PATH)) {
|
||||
const ownership = classifyConfigPathMigrationOwnership({
|
||||
snapshot: params.snapshot,
|
||||
configPath: ["diagnostics", "otel", "protocol"],
|
||||
});
|
||||
const otelOwnership = classifyOtelGrpcMigrationOwnership({
|
||||
snapshot: params.snapshot,
|
||||
authoredConfig: params.snapshot.parsed,
|
||||
resolvedConfig: params.snapshot.sourceConfig,
|
||||
});
|
||||
if (otelOwnership) {
|
||||
const ownership = otelOwnership;
|
||||
if (ownership.kind === "manual") {
|
||||
const otelPath = "diagnostics.otel.protocol";
|
||||
const targets =
|
||||
ownership.targetPaths.length > 0
|
||||
? ` Inspect these candidate source files and remove or replace ${OTEL_GRPC_PROTOCOL_PATH} = "grpc" from every definition: ${ownership.targetPaths.join(", ")}.`
|
||||
: ` Remove or replace ${OTEL_GRPC_PROTOCOL_PATH} = "grpc" in the owning $include directive or included file.`;
|
||||
? ` Inspect these candidate source files and remove or replace ${otelPath} = "grpc" from every definition: ${ownership.targetPaths.join(", ")}.`
|
||||
: ` Remove or replace ${otelPath} = "grpc" in the owning $include directive or included file.`;
|
||||
return {
|
||||
state: params.state,
|
||||
issueLines: [
|
||||
...issueLines,
|
||||
`- ${OTEL_GRPC_PROTOCOL_PATH}: Doctor cannot safely rewrite this $include ownership.${targets} No config files were changed.`,
|
||||
`- ${otelPath}: Doctor cannot safely rewrite this $include ownership.${targets} No config files were changed.`,
|
||||
],
|
||||
changeLines: [],
|
||||
blocksWrite: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import {
|
||||
classifyConfigPathMigrationOwnership,
|
||||
classifyOtelGrpcMigrationOwnership,
|
||||
isSingleTopLevelIncludeMigration,
|
||||
} from "./include-migration-ownership.js";
|
||||
|
||||
@@ -17,42 +17,40 @@ describe("include migration ownership", () => {
|
||||
const configDir = path.resolve("/tmp/openclaw-config");
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
const diagnosticsPath = path.join(configDir, "diagnostics.json5");
|
||||
const classifyOtelOwnership = (
|
||||
includeProvenance: NonNullable<
|
||||
Parameters<typeof classifyOtelGrpcMigrationOwnership>[0]["snapshot"]["includeProvenance"]
|
||||
>,
|
||||
) =>
|
||||
classifyOtelGrpcMigrationOwnership({
|
||||
snapshot: { path: configPath, includeProvenance },
|
||||
authoredConfig: { diagnostics: { otel: { protocol: "grpc" } } },
|
||||
resolvedConfig: { diagnostics: { otel: { protocol: "grpc" } } },
|
||||
});
|
||||
|
||||
it("classifies direct config even when an unrelated include exists", () => {
|
||||
expect(
|
||||
classifyConfigPathMigrationOwnership({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
includeProvenance: [
|
||||
{
|
||||
path: ["agents"],
|
||||
kind: "single",
|
||||
hasSiblingOverrides: false,
|
||||
targetPath: path.join(configDir, "agents.json5"),
|
||||
},
|
||||
],
|
||||
classifyOtelOwnership([
|
||||
{
|
||||
path: ["agents"],
|
||||
kind: "single",
|
||||
hasSiblingOverrides: false,
|
||||
targetPath: path.join(configDir, "agents.json5"),
|
||||
},
|
||||
configPath: ["diagnostics", "otel", "protocol"],
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: "direct" });
|
||||
});
|
||||
|
||||
it("allows one internal top-level include that solely owns diagnostics", () => {
|
||||
expect(
|
||||
classifyConfigPathMigrationOwnership({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
includeProvenance: [
|
||||
{
|
||||
path: ["diagnostics"],
|
||||
kind: "single",
|
||||
hasSiblingOverrides: false,
|
||||
targetPath: diagnosticsPath,
|
||||
},
|
||||
],
|
||||
classifyOtelOwnership([
|
||||
{
|
||||
path: ["diagnostics"],
|
||||
kind: "single",
|
||||
hasSiblingOverrides: false,
|
||||
targetPath: diagnosticsPath,
|
||||
},
|
||||
configPath: ["diagnostics", "otel", "protocol"],
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: "single-top-level-include", targetPath: diagnosticsPath });
|
||||
});
|
||||
|
||||
@@ -124,12 +122,7 @@ describe("include migration ownership", () => {
|
||||
targetPaths: [path.resolve(configDir, "..", "external-diagnostics.json5")],
|
||||
},
|
||||
])("requires manual repair for $name ownership", ({ includeProvenance, targetPaths }) => {
|
||||
expect(
|
||||
classifyConfigPathMigrationOwnership({
|
||||
snapshot: { path: configPath, includeProvenance },
|
||||
configPath: ["diagnostics", "otel", "protocol"],
|
||||
}),
|
||||
).toEqual({ kind: "manual", targetPaths });
|
||||
expect(classifyOtelOwnership(includeProvenance)).toEqual({ kind: "manual", targetPaths });
|
||||
});
|
||||
|
||||
it("allows one isolated direct top-level string include", () => {
|
||||
|
||||
@@ -21,8 +21,10 @@ type ConfigPathMigrationOwnership =
|
||||
| { kind: "single-top-level-include"; targetPath: string }
|
||||
| { kind: "manual"; targetPaths: string[] };
|
||||
|
||||
type OtelGrpcMigrationOwnership = ConfigPathMigrationOwnership | { kind: "resolved-only" };
|
||||
|
||||
/** Classify whether Doctor can safely persist a migration at one resolved config path. */
|
||||
export function classifyConfigPathMigrationOwnership(params: {
|
||||
function classifyConfigPathMigrationOwnership(params: {
|
||||
snapshot: Pick<ConfigFileSnapshot, "path" | "includeProvenance">;
|
||||
configPath: readonly string[];
|
||||
}): ConfigPathMigrationOwnership {
|
||||
@@ -57,6 +59,32 @@ export function classifyConfigPathMigrationOwnership(params: {
|
||||
return { kind: "manual", targetPaths };
|
||||
}
|
||||
|
||||
function readOtelProtocol(config: unknown): unknown {
|
||||
const root = isRecord(config) ? config : null;
|
||||
const diagnostics = isRecord(root?.diagnostics) ? root.diagnostics : null;
|
||||
const otel = isRecord(diagnostics?.otel) ? diagnostics.otel : null;
|
||||
return otel?.protocol;
|
||||
}
|
||||
|
||||
/** Classify ownership for the sole legacy migration that consults resolved config values. */
|
||||
export function classifyOtelGrpcMigrationOwnership(params: {
|
||||
snapshot: Pick<ConfigFileSnapshot, "path" | "includeProvenance">;
|
||||
authoredConfig: unknown;
|
||||
resolvedConfig: unknown;
|
||||
}): OtelGrpcMigrationOwnership | null {
|
||||
if (readOtelProtocol(params.resolvedConfig) !== "grpc") {
|
||||
return null;
|
||||
}
|
||||
const ownership = classifyConfigPathMigrationOwnership({
|
||||
snapshot: params.snapshot,
|
||||
configPath: ["diagnostics", "otel", "protocol"],
|
||||
});
|
||||
if (ownership.kind !== "direct") {
|
||||
return ownership;
|
||||
}
|
||||
return readOtelProtocol(params.authoredConfig) === "grpc" ? ownership : { kind: "resolved-only" };
|
||||
}
|
||||
|
||||
export function isSingleTopLevelIncludeMigration(params: {
|
||||
parsed: unknown;
|
||||
sourceConfig: OpenClawConfig;
|
||||
|
||||
@@ -86,6 +86,33 @@ describe("config io paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["lan", "loopback", "tailnet", "auto", "custom", undefined] as const)(
|
||||
"keeps canonical gateway bind %s byte-identical during load",
|
||||
async (bind) => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const gateway = {
|
||||
mode: "local" as const,
|
||||
...(bind ? { bind } : {}),
|
||||
...(bind === "custom" ? { customBindHost: "127.0.0.1" } : {}),
|
||||
};
|
||||
const raw = `${JSON.stringify({ gateway }, null, 2)}\n`;
|
||||
await fs.writeFile(configPath, raw, "utf-8");
|
||||
const io = createConfigIO({
|
||||
configPath,
|
||||
env: { HOME: home } as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
});
|
||||
|
||||
const config = io.loadConfig();
|
||||
|
||||
expect(config.gateway?.bind).toBe(bind);
|
||||
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(raw);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("logs validation warnings with real line breaks", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
|
||||
import { collectManifestModelIdNormalizationPolicies } from "@openclaw/model-catalog-core/provider-model-id-normalization";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
||||
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import { classifyOtelGrpcMigrationOwnership } from "../commands/doctor/shared/include-migration-ownership.js";
|
||||
import { applyLegacyDoctorMigrations } from "../commands/doctor/shared/legacy-config-compat.js";
|
||||
import {
|
||||
loadShellEnvFallback,
|
||||
resolveShellEnvFallbackTimeoutMs,
|
||||
@@ -25,7 +27,13 @@ import {
|
||||
resolveConfigPathForDeps,
|
||||
} from "./io.read-helpers.js";
|
||||
import { autoOwnerDisplaySecretByPath } from "./io.state.js";
|
||||
import type { ConfigIoFactoryOptions, NormalizedConfigIoDeps } from "./io.types.js";
|
||||
import type {
|
||||
ConfigIoFactoryOptions,
|
||||
ConfigRecoveryCandidate,
|
||||
ConfigRecoveryCandidatePreparation,
|
||||
NormalizedConfigIoDeps,
|
||||
} from "./io.types.js";
|
||||
import { formatConfigIssueSummary } from "./issue-format.js";
|
||||
import { migratePersistedImplicitMainRoster } from "./legacy.roster.js";
|
||||
import { materializeRuntimeConfig } from "./materialize.js";
|
||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||
@@ -50,7 +58,9 @@ export type ConfigIoContext = {
|
||||
allowCurrentPluginMetadata?: boolean;
|
||||
}) => ValidationPluginMetadataSnapshotLoader;
|
||||
resolveRuntimePreflightSourceConfig: (candidate: OpenClawConfig) => OpenClawConfig;
|
||||
resolveSuspiciousRecoveryBackupCandidate: (parsed: unknown) => OpenClawConfig | null;
|
||||
prepareRecoveryBackupCandidate: (
|
||||
candidate: ConfigRecoveryCandidate,
|
||||
) => ConfigRecoveryCandidatePreparation;
|
||||
};
|
||||
|
||||
export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): ConfigIoContext {
|
||||
@@ -132,10 +142,52 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con
|
||||
return coerceConfig(migratePersistedImplicitMainRoster(resolution.resolvedConfigRaw).config);
|
||||
}
|
||||
|
||||
function resolveSuspiciousRecoveryBackupCandidate(parsed: unknown): OpenClawConfig | null {
|
||||
function prepareRecoveryBackupCandidate(
|
||||
candidate: ConfigRecoveryCandidate,
|
||||
): ConfigRecoveryCandidatePreparation {
|
||||
try {
|
||||
const originalEnv = cloneEnvWithPlatformSemantics(deps.env);
|
||||
const includeProvenance: NonNullable<ConfigFileSnapshot["includeProvenance"]>[number][] = [];
|
||||
const originalResolvedIncludes = resolveConfigIncludesForRead(
|
||||
candidate.parsed,
|
||||
configPath,
|
||||
{ ...deps, env: originalEnv },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(event) => {
|
||||
const { value: _value, ...ownership } = event;
|
||||
includeProvenance.push(ownership);
|
||||
},
|
||||
);
|
||||
const originalResolution = resolveConfigForRead(
|
||||
originalResolvedIncludes,
|
||||
originalEnv,
|
||||
deps.lowerPrecedenceEnv,
|
||||
);
|
||||
const otelOwnership = classifyOtelGrpcMigrationOwnership({
|
||||
snapshot: { path: configPath, includeProvenance },
|
||||
authoredConfig: candidate.parsed,
|
||||
resolvedConfig: originalResolution.resolvedConfigRaw,
|
||||
});
|
||||
if (otelOwnership && otelOwnership.kind !== "direct") {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
otelOwnership.kind === "resolved-only"
|
||||
? "candidate migration cannot persist an env-resolved diagnostics.otel.protocol repair"
|
||||
: "candidate migration requires an include-owned diagnostics.otel.protocol repair",
|
||||
};
|
||||
}
|
||||
// Recovery is a migration boundary, not runtime compatibility: the canonical Doctor
|
||||
// registry owns historical shapes before current-schema validation and any disk write.
|
||||
const migrated = applyLegacyDoctorMigrations(candidate.parsed, {
|
||||
authoredRaw: candidate.parsed,
|
||||
resolvedRaw: originalResolution.resolvedConfigRaw,
|
||||
});
|
||||
const authoredCandidate = migrated.next ?? candidate.parsed;
|
||||
const candidateEnv = cloneEnvWithPlatformSemantics(deps.env);
|
||||
const resolved = resolveConfigIncludesForRead(parsed, configPath, {
|
||||
const resolved = resolveConfigIncludesForRead(authoredCandidate, configPath, {
|
||||
...deps,
|
||||
env: candidateEnv,
|
||||
});
|
||||
@@ -149,12 +201,30 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con
|
||||
env: candidateEnv,
|
||||
pluginValidation: options.pluginValidation,
|
||||
loadPluginMetadataSnapshot: pluginMetadata.load,
|
||||
sourceRaw: parsed,
|
||||
sourceRaw: authoredCandidate,
|
||||
preservedLegacyRootKeys: options.preservedLegacyRootKeys,
|
||||
});
|
||||
return validated.ok ? coerceConfig(effectiveConfigRaw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
if (!validated.ok) {
|
||||
const issueSummary = formatConfigIssueSummary(validated.issues.slice(0, 3)) ?? "";
|
||||
const detail = issueSummary.length > 800 ? `${issueSummary.slice(0, 799)}…` : issueSummary;
|
||||
return {
|
||||
ok: false,
|
||||
reason: `candidate remains invalid after legacy migration${detail ? `: ${detail}` : ""}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
candidate: {
|
||||
config: validated.config,
|
||||
parsed: authoredCandidate,
|
||||
raw: migrated.next
|
||||
? JSON.stringify(authoredCandidate, null, 2).trimEnd().concat("\n")
|
||||
: candidate.raw,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, reason: `candidate preparation failed: ${detail}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +236,7 @@ export function createConfigIoContext(options: ConfigIoFactoryOptions = {}): Con
|
||||
finalizeLoadedRuntimeConfig,
|
||||
createValidationPluginMetadataSnapshotLoader,
|
||||
resolveRuntimePreflightSourceConfig,
|
||||
resolveSuspiciousRecoveryBackupCandidate,
|
||||
prepareRecoveryBackupCandidate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createConfigIO(options: ConfigIoFactoryOptions = {}) {
|
||||
deps: context.deps,
|
||||
snapshot: params.snapshot,
|
||||
reason: params.reason,
|
||||
prepareCandidate: context.prepareRecoveryBackupCandidate,
|
||||
}),
|
||||
preserveConfigSnapshotAsClobbered: (snapshot: ConfigFileSnapshot) =>
|
||||
preserveConfigSnapshotAsClobbered({ deps: context.deps, snapshot }),
|
||||
|
||||
@@ -155,8 +155,7 @@ export function loadConfigFromContext(
|
||||
configPath,
|
||||
raw,
|
||||
parsed,
|
||||
validateBackupSync: (backup) =>
|
||||
context.resolveSuspiciousRecoveryBackupCandidate(backup.parsed) !== null,
|
||||
prepareBackup: context.prepareRecoveryBackupCandidate,
|
||||
});
|
||||
if (recovery.raw !== raw) {
|
||||
restoreEnvChangesIfUnchanged({
|
||||
|
||||
@@ -24,6 +24,10 @@ import type { ConfigFileSnapshot } from "./types.js";
|
||||
const CONFIG_CLOBBER_SNAPSHOT_LIMIT = 32;
|
||||
type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">;
|
||||
type ObserveRecoveryDeps = Parameters<typeof maybeRecoverSuspiciousConfigRead>[0]["deps"];
|
||||
const approveRecoveryCandidate = <T extends { raw: string; parsed: unknown }>(candidate: T) => ({
|
||||
ok: true as const,
|
||||
candidate,
|
||||
});
|
||||
|
||||
function resolveLastKnownGoodConfigPath(configPath: string): string {
|
||||
return `${configPath}.last-good`;
|
||||
@@ -197,6 +201,7 @@ describe("config observe recovery", () => {
|
||||
configPath: params.configPath,
|
||||
raw: clobberedUpdateChannelRaw,
|
||||
parsed: clobberedUpdateChannelConfig,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +216,7 @@ describe("config observe recovery", () => {
|
||||
configPath: params.configPath,
|
||||
raw: params.raw,
|
||||
parsed: params.parsed,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +229,7 @@ describe("config observe recovery", () => {
|
||||
configPath: params.configPath,
|
||||
raw: clobberedUpdateChannelRaw,
|
||||
parsed: clobberedUpdateChannelConfig,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,6 +409,7 @@ describe("config observe recovery", () => {
|
||||
configPath,
|
||||
raw: clobbered.raw,
|
||||
parsed: clobbered.parsed,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
});
|
||||
|
||||
expect((recovered.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
|
||||
@@ -609,6 +617,133 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["localhost", "loopback"],
|
||||
["0.0.0.0", "lan"],
|
||||
] as const)(
|
||||
"migrates backup gateway bind %s to %s before recovery",
|
||||
async (bind, canonicalBind) => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { io, configPath, warn } = createTestConfigIO(home);
|
||||
await seedConfigBackup(configPath, {
|
||||
gateway: { mode: "local", bind },
|
||||
});
|
||||
await fsp.writeFile(
|
||||
`${configPath}.bak`,
|
||||
`{\n // historical bind alias\n gateway: { mode: "local", bind: "${bind}" }\n}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const clobbered = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
const snapshot = await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
expect(snapshot.valid).toBe(true);
|
||||
expect(snapshot.config.gateway?.mode).toBe("local");
|
||||
expect(snapshot.config.gateway?.bind).toBe(canonicalBind);
|
||||
expect(JSON5.parse(await fsp.readFile(configPath, "utf-8"))).toMatchObject({
|
||||
gateway: { mode: "local", bind: canonicalBind },
|
||||
});
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.not.toBe(clobbered.raw);
|
||||
await expect(listClobberFiles(configPath)).resolves.toHaveLength(1);
|
||||
expectWarnContaining(warn, `Config write will strip JSON5 comments from ${configPath}.`);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not flatten env- or include-owned bind aliases during backup recovery", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const env = { RECOVERY_BIND: "localhost" } as NodeJS.ProcessEnv;
|
||||
const { io, configPath } = createTestConfigIO(home, vi.fn(), { env });
|
||||
await seedConfigBackup(configPath, {
|
||||
gateway: { mode: "local", bind: "${RECOVERY_BIND}" },
|
||||
});
|
||||
const envOwnedCurrent = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(envOwnedCurrent.raw);
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(path.dirname(configPath), "legacy-bind.json5"),
|
||||
'{ gateway: { mode: "local", bind: "localhost" } }\n',
|
||||
"utf-8",
|
||||
);
|
||||
await seedConfigBackup(configPath, { $include: "./legacy-bind.json5" });
|
||||
const includeOwnedCurrent = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(includeOwnedCurrent.raw);
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates directly authored OTel grpc config before backup recovery", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { io, configPath } = createTestConfigIO(home);
|
||||
await seedConfigBackup(configPath, {
|
||||
gateway: { mode: "local" },
|
||||
diagnostics: { otel: { enabled: true, protocol: "grpc", traces: true } },
|
||||
});
|
||||
await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
const snapshot = await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
expect(snapshot.config.diagnostics?.otel?.protocol).toBeUndefined();
|
||||
expect(snapshot.config.diagnostics?.otel?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist env-resolved OTel grpc config during backup recovery", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const env = { OTEL_PROTOCOL: "grpc" } as NodeJS.ProcessEnv;
|
||||
const { io, configPath } = createTestConfigIO(home, vi.fn(), { env });
|
||||
await seedConfigBackup(configPath, {
|
||||
gateway: { mode: "local" },
|
||||
diagnostics: { otel: { enabled: true, protocol: "${OTEL_PROTOCOL}", traces: true } },
|
||||
});
|
||||
const active = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(active.raw);
|
||||
await expect(listClobberFiles(configPath)).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flatten include-owned OTel settings during backup recovery", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { io, configPath } = createTestConfigIO(home);
|
||||
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(path.dirname(configPath), "legacy-otel.json5"),
|
||||
'{ enabled: true, protocol: "grpc", traces: true }\n',
|
||||
"utf-8",
|
||||
);
|
||||
await seedConfigBackup(configPath, {
|
||||
gateway: { mode: "local" },
|
||||
diagnostics: { otel: { $include: "./legacy-otel.json5" } },
|
||||
});
|
||||
const active = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedVersion: "2026.5.28" },
|
||||
});
|
||||
|
||||
await io.readConfigFileSnapshot({ recoverSuspicious: true });
|
||||
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(active.raw);
|
||||
await expect(listClobberFiles(configPath)).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-restore backup candidates rejected by the caller", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { io, configPath } = createTestConfigIO(home);
|
||||
@@ -740,7 +875,7 @@ describe("config observe recovery", () => {
|
||||
"utf-8",
|
||||
);
|
||||
const clobbered = await writeClobberedUpdateChannel(configPath);
|
||||
const input = { deps, configPath, ...clobbered };
|
||||
const input = { deps, configPath, ...clobbered, prepareBackup: approveRecoveryCandidate };
|
||||
|
||||
const recovered =
|
||||
mode === "async"
|
||||
@@ -762,7 +897,7 @@ describe("config observe recovery", () => {
|
||||
const backup = { meta: { authoredBy: "operator" }, gateway: { mode: "local" } };
|
||||
await seedConfigBackup(configPath, backup);
|
||||
const clobbered = await writeConfigRaw(configPath, { gateway: { mode: "local" } });
|
||||
const input = { deps, configPath, ...clobbered };
|
||||
const input = { deps, configPath, ...clobbered, prepareBackup: approveRecoveryCandidate };
|
||||
|
||||
const recovered =
|
||||
mode === "async"
|
||||
@@ -801,7 +936,12 @@ describe("config observe recovery", () => {
|
||||
}) as typeof fs.statSync,
|
||||
},
|
||||
};
|
||||
const input = { deps: statDeps, configPath, ...clobbered };
|
||||
const input = {
|
||||
deps: statDeps,
|
||||
configPath,
|
||||
...clobbered,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
};
|
||||
|
||||
const recovered =
|
||||
mode === "async"
|
||||
@@ -815,22 +955,22 @@ describe("config observe recovery", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "records writeFile failure instead of falsely claiming restore succeeded",
|
||||
name: "records atomic replace failure instead of falsely claiming restore succeeded",
|
||||
mode: "async",
|
||||
retry: false,
|
||||
},
|
||||
{
|
||||
name: "sync recovery records writeFileSync failure instead of falsely claiming restore succeeded",
|
||||
name: "sync recovery records atomic replace failure instead of falsely claiming restore succeeded",
|
||||
mode: "sync",
|
||||
retry: false,
|
||||
},
|
||||
{
|
||||
name: "retries recovery on next launch after a failed writeFile restore",
|
||||
name: "retries recovery on next launch after a failed atomic replace",
|
||||
mode: "async",
|
||||
retry: true,
|
||||
},
|
||||
{
|
||||
name: "sync recovery retries on next launch after a failed writeFileSync restore",
|
||||
name: "sync recovery retries on next launch after a failed atomic replace",
|
||||
mode: "sync",
|
||||
retry: true,
|
||||
},
|
||||
@@ -846,23 +986,28 @@ describe("config observe recovery", () => {
|
||||
...deps.fs,
|
||||
promises: {
|
||||
...deps.fs.promises,
|
||||
writeFile: (target, data, options) =>
|
||||
rename: (source, target) =>
|
||||
target === configPath
|
||||
? Promise.reject(copyError)
|
||||
: deps.fs.promises.writeFile(target, data, options),
|
||||
: deps.fs.promises.rename(source, target),
|
||||
},
|
||||
}
|
||||
: {
|
||||
...deps.fs,
|
||||
writeFileSync: (target, data, options) => {
|
||||
renameSync: (source, target) => {
|
||||
if (target === configPath) {
|
||||
throw copyError;
|
||||
}
|
||||
return deps.fs.writeFileSync(target, data, options);
|
||||
return deps.fs.renameSync(source, target);
|
||||
},
|
||||
};
|
||||
const recover = (recoveryDeps: ObserveRecoveryDeps) => {
|
||||
const input = { deps: recoveryDeps, configPath, ...clobbered };
|
||||
const input = {
|
||||
deps: recoveryDeps,
|
||||
configPath,
|
||||
...clobbered,
|
||||
prepareBackup: approveRecoveryCandidate,
|
||||
};
|
||||
return mode === "async"
|
||||
? maybeRecoverSuspiciousConfigRead(input)
|
||||
: maybeRecoverSuspiciousConfigReadSync(input);
|
||||
@@ -897,8 +1042,8 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "restores the exact async backup bytes approved by validation", mode: "async" },
|
||||
{ name: "restores the exact sync backup bytes approved by validation", mode: "sync" },
|
||||
{ name: "restores the exact async backup bytes approved by preparation", mode: "async" },
|
||||
{ name: "restores the exact sync backup bytes approved by preparation", mode: "sync" },
|
||||
] as const)("$name", async ({ mode }) => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath } = makeDeps(home);
|
||||
@@ -907,22 +1052,22 @@ describe("config observe recovery", () => {
|
||||
const approvedRaw = await fsp.readFile(backupPath, "utf-8");
|
||||
const replacementRaw = `${JSON.stringify({ gateway: { mode: "remote" } }, null, 2)}\n`;
|
||||
const clobbered = await writeClobberedUpdateChannel(configPath);
|
||||
const input = { deps, configPath, ...clobbered };
|
||||
const input = { deps, configPath, ...clobbered, prepareBackup: approveRecoveryCandidate };
|
||||
|
||||
if (mode === "async") {
|
||||
await maybeRecoverSuspiciousConfigRead({
|
||||
...input,
|
||||
validateBackup: async () => {
|
||||
await fsp.writeFile(backupPath, replacementRaw, "utf-8");
|
||||
return true;
|
||||
prepareBackup: (candidate) => {
|
||||
fs.writeFileSync(backupPath, replacementRaw, "utf-8");
|
||||
return { ok: true, candidate };
|
||||
},
|
||||
});
|
||||
} else {
|
||||
maybeRecoverSuspiciousConfigReadSync({
|
||||
...input,
|
||||
validateBackupSync: () => {
|
||||
prepareBackup: (candidate) => {
|
||||
fs.writeFileSync(backupPath, replacementRaw, "utf-8");
|
||||
return true;
|
||||
return { ok: true, candidate };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1091,6 +1236,7 @@ describe("config observe recovery", () => {
|
||||
issues: [{ path: "gateway.mode", message: "Expected string" }],
|
||||
},
|
||||
reason: "test-invalid-config",
|
||||
prepareCandidate: approveRecoveryCandidate,
|
||||
});
|
||||
|
||||
expect(restored).toBe(true);
|
||||
@@ -1103,6 +1249,41 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the active config untouched when its owner rejects last-known-good recovery", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, warn } = makeDeps(home);
|
||||
const snapshot = await makeSnapshot(configPath, {
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
await expect(
|
||||
promoteConfigSnapshotToLastKnownGood({ deps, snapshot, logger: deps.logger }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const brokenRaw = "{ gateway: { mode: 123 } }\n";
|
||||
await fsp.writeFile(configPath, brokenRaw, "utf-8");
|
||||
const restored = await recoverConfigFromLastKnownGood({
|
||||
deps,
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
raw: brokenRaw,
|
||||
parsed: { gateway: { mode: 123 } },
|
||||
valid: false,
|
||||
issues: [{ path: "gateway.mode", message: "Expected string" }],
|
||||
},
|
||||
reason: "test-invalid-config",
|
||||
prepareCandidate: () => ({
|
||||
ok: false,
|
||||
reason: "candidate cannot converge under the current schema",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(restored).toBe(false);
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(brokenRaw);
|
||||
await expect(listClobberFiles(configPath)).resolves.toHaveLength(0);
|
||||
expectWarnContaining(warn, "candidate cannot converge under the current schema");
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when last-known-good promotion cannot tighten snapshot permissions", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, warn } = makeDeps(home);
|
||||
@@ -1148,6 +1329,7 @@ describe("config observe recovery", () => {
|
||||
issues: [{ path: "gateway.mode", message: "Expected string" }],
|
||||
},
|
||||
reason: "test-invalid-config",
|
||||
prepareCandidate: approveRecoveryCandidate,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
@@ -1226,6 +1408,7 @@ describe("config observe recovery", () => {
|
||||
deps,
|
||||
snapshot: { ...staleSnapshot, ...active, valid: false, issues: [issue] },
|
||||
reason: "reload-invalid-config",
|
||||
prepareCandidate: approveRecoveryCandidate,
|
||||
});
|
||||
|
||||
expect(restored).toBe(false);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Observes and recovers config files that appear missing, corrupt, or clobbered.
|
||||
import type fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { replaceFileAtomic, replaceFileAtomicSync } from "../infra/replace-file.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { appendConfigAuditRecord, appendConfigAuditRecordSync } from "./io.audit.js";
|
||||
import {
|
||||
@@ -22,8 +24,13 @@ import {
|
||||
} from "./io.observe-state.js";
|
||||
import { resolveConfigObserveSuspiciousReasons } from "./io.observe-suspicious.js";
|
||||
import { hashConfigRaw, resolveConfigSnapshotHash } from "./io.read-helpers.js";
|
||||
import type { NormalizedConfigIoDeps } from "./io.types.js";
|
||||
import type {
|
||||
ConfigRecoveryCandidatePreparation,
|
||||
NormalizedConfigIoDeps,
|
||||
PrepareConfigRecoveryCandidate,
|
||||
} from "./io.types.js";
|
||||
import { formatConfigIssueSummary } from "./issue-format.js";
|
||||
import { warnIfJSON5CommentsWillBeStripped } from "./json5-comments.js";
|
||||
import {
|
||||
isPluginLocalInvalidConfigSnapshot,
|
||||
shouldAttemptLastKnownGoodRecovery,
|
||||
@@ -83,8 +90,7 @@ type ConfigReadRecoveryParams = {
|
||||
configPath: string;
|
||||
raw: string;
|
||||
parsed: unknown;
|
||||
validateBackup?: (backup: { raw: string; parsed: unknown }) => Promise<boolean>;
|
||||
validateBackupSync?: (backup: { raw: string; parsed: unknown }) => boolean;
|
||||
prepareBackup: PrepareConfigRecoveryCandidate;
|
||||
allowBackupRecovery?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -93,6 +99,29 @@ type ConfigReadRecoveryResult = {
|
||||
parsed: unknown;
|
||||
};
|
||||
|
||||
function createRecoveryCommitEffect(params: {
|
||||
deps: ObserveRecoveryDeps;
|
||||
configPath: string;
|
||||
raw: string;
|
||||
}): ConfigRecoveryEffect<void> {
|
||||
const options = {
|
||||
filePath: params.configPath,
|
||||
content: params.raw,
|
||||
dirMode: 0o700,
|
||||
mode: 0o600,
|
||||
tempPrefix: path.basename(params.configPath),
|
||||
fileSystem: params.deps.fs,
|
||||
};
|
||||
return {
|
||||
sync: () => {
|
||||
replaceFileAtomicSync(options);
|
||||
},
|
||||
async: async () => {
|
||||
await replaceFileAtomic(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ConfigObserveAuditRecordParams = Parameters<typeof createConfigObserveAuditRecord>[0];
|
||||
|
||||
function createConfigObserveAuditAppendParams(
|
||||
@@ -374,13 +403,14 @@ function* recoverSuspiciousConfigRead(
|
||||
return returnOriginalConfigRead(params);
|
||||
}
|
||||
const backupCandidate = { raw: backupRaw, parsed: backupParse.parsed };
|
||||
const validBackup = (yield {
|
||||
sync: () => params.validateBackupSync?.(backupCandidate) ?? true,
|
||||
async: () => params.validateBackup?.(backupCandidate) ?? true,
|
||||
}) as boolean;
|
||||
if (!validBackup) {
|
||||
const prepared = (yield {
|
||||
sync: () => params.prepareBackup(backupCandidate),
|
||||
async: () => params.prepareBackup(backupCandidate),
|
||||
}) as ConfigRecoveryCandidatePreparation;
|
||||
if (!prepared.ok) {
|
||||
return returnOriginalConfigRead(params);
|
||||
}
|
||||
const preparedCandidate = prepared.candidate;
|
||||
// Eligibility must describe the approved backup bytes, never an older healthy config.
|
||||
const backupStat = (yield createConfigRecoveryStatEffect(deps, backupPath)) as fs.Stats | null;
|
||||
const backup = createConfigHealthFingerprint({
|
||||
@@ -413,11 +443,14 @@ function* recoverSuspiciousConfigRead(
|
||||
let restoredFromBackup = false;
|
||||
let restoreError: unknown;
|
||||
try {
|
||||
const options = { encoding: "utf-8" as const, mode: 0o600 };
|
||||
yield {
|
||||
sync: () => deps.fs.writeFileSync(configPath, backupRaw, options),
|
||||
async: () => deps.fs.promises.writeFile(configPath, backupRaw, options),
|
||||
};
|
||||
if (preparedCandidate.raw !== backupRaw) {
|
||||
warnIfJSON5CommentsWillBeStripped({
|
||||
raw: backupRaw,
|
||||
filePath: configPath,
|
||||
warn: (message) => deps.logger.warn(message),
|
||||
});
|
||||
}
|
||||
yield createRecoveryCommitEffect({ deps, configPath, raw: preparedCandidate.raw });
|
||||
const chmodParams = { deps, configPath, context: "backup restore" };
|
||||
yield {
|
||||
sync: () => chmodConfigBestEffortSync(chmodParams),
|
||||
@@ -462,7 +495,7 @@ function* recoverSuspiciousConfigRead(
|
||||
}),
|
||||
);
|
||||
}
|
||||
return backupCandidate;
|
||||
return preparedCandidate;
|
||||
}
|
||||
|
||||
export async function promoteConfigSnapshotToLastKnownGood(params: {
|
||||
@@ -519,6 +552,7 @@ export async function recoverConfigFromLastKnownGood(params: {
|
||||
deps: ObserveRecoveryDeps;
|
||||
snapshot: ConfigFileSnapshot;
|
||||
reason: string;
|
||||
prepareCandidate: PrepareConfigRecoveryCandidate;
|
||||
}): Promise<boolean> {
|
||||
const { deps, snapshot } = params;
|
||||
if (!snapshot.exists || typeof snapshot.raw !== "string") {
|
||||
@@ -549,7 +583,18 @@ export async function recoverConfigFromLastKnownGood(params: {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const polluted = collectPollutedSecretPlaceholders(backupParsed);
|
||||
// Historical bytes become live config only after their owner has migrated and validated them.
|
||||
// This prevents Doctor recovery from exposing a schema-invalid intermediate file.
|
||||
const originalCandidate = { raw: backupRaw, parsed: backupParsed };
|
||||
const prepared = params.prepareCandidate(originalCandidate);
|
||||
if (!prepared.ok) {
|
||||
deps.logger.warn(
|
||||
`Config last-known-good recovery skipped: ${prepared.reason} (${params.reason})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const recoveryCandidate = prepared.candidate;
|
||||
const polluted = collectPollutedSecretPlaceholders(recoveryCandidate.parsed);
|
||||
if (polluted.length > 0) {
|
||||
deps.logger.warn(
|
||||
`Config last-known-good recovery skipped: redacted secret placeholder at ${polluted[0]}`,
|
||||
@@ -571,10 +616,18 @@ export async function recoverConfigFromLastKnownGood(params: {
|
||||
snapshot,
|
||||
observedAt: now,
|
||||
});
|
||||
await deps.fs.promises.writeFile(snapshot.path, backupRaw, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
if (recoveryCandidate.raw !== backupRaw) {
|
||||
warnIfJSON5CommentsWillBeStripped({
|
||||
raw: backupRaw,
|
||||
filePath: snapshot.path,
|
||||
warn: (message) => deps.logger.warn(message),
|
||||
});
|
||||
}
|
||||
await createRecoveryCommitEffect({
|
||||
deps,
|
||||
configPath: snapshot.path,
|
||||
raw: recoveryCandidate.raw,
|
||||
}).async();
|
||||
await chmodConfigBestEffort({
|
||||
deps,
|
||||
configPath: snapshot.path,
|
||||
|
||||
@@ -244,9 +244,10 @@ export async function readConfigFileSnapshotInternal(
|
||||
configPath,
|
||||
raw,
|
||||
parsed: effectiveParsed,
|
||||
validateBackup: async (backup) => {
|
||||
recoveryCandidate = context.resolveSuspiciousRecoveryBackupCandidate(backup.parsed);
|
||||
return recoveryCandidate !== null;
|
||||
prepareBackup: (backup) => {
|
||||
const prepared = context.prepareRecoveryBackupCandidate(backup);
|
||||
recoveryCandidate = prepared.ok ? (prepared.candidate.config ?? null) : null;
|
||||
return prepared;
|
||||
},
|
||||
...(allowSuspiciousRecovery
|
||||
? {
|
||||
|
||||
@@ -148,3 +148,17 @@ export type BestEffortConfigSnapshot = {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type ConfigRecoveryCandidate = {
|
||||
raw: string;
|
||||
parsed: unknown;
|
||||
config?: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type ConfigRecoveryCandidatePreparation =
|
||||
| { ok: true; candidate: ConfigRecoveryCandidate }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export type PrepareConfigRecoveryCandidate = (
|
||||
candidate: ConfigRecoveryCandidate,
|
||||
) => ConfigRecoveryCandidatePreparation;
|
||||
|
||||
@@ -116,4 +116,54 @@ describe("gateway network runtime", () => {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["lan", "loopback", "tailnet", "auto", "custom", undefined] as const)(
|
||||
"starts with persisted canonical bind %s without rewriting config",
|
||||
async (bind) => {
|
||||
const envSnapshot = captureEnv([...NETWORK_GATEWAY_ENV_KEYS]);
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-bind-home-"));
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
|
||||
try {
|
||||
for (const key of NETWORK_GATEWAY_ENV_KEYS) {
|
||||
deleteTestEnvValue(key);
|
||||
}
|
||||
setTestEnvValue("HOME", tempHome);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempHome, ".openclaw"));
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(tempHome, "empty-bundled-plugins");
|
||||
await fs.mkdir(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, { recursive: true });
|
||||
|
||||
const token = `bind-token-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}`;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
const configPath = path.join(tempHome, ".openclaw", "openclaw.json");
|
||||
const gateway = {
|
||||
mode: "local" as const,
|
||||
auth: { mode: "token" as const, token },
|
||||
...(bind ? { bind } : {}),
|
||||
...(bind === "custom" ? { customBindHost: "127.0.0.1" } : {}),
|
||||
};
|
||||
const raw = `${JSON.stringify({ gateway }, null, 2)}\n`;
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, raw, { mode: 0o600 });
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
|
||||
server = await startGatewayServer(await getFreeGatewayPort(), {
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(raw);
|
||||
} finally {
|
||||
await server?.close({ reason: "gateway bind persistence test complete" });
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
envSnapshot.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user