fix(config): preserve source config during recovery

This commit is contained in:
Peter Steinberger
2026-04-22 18:42:07 +01:00
parent 557f4fc689
commit 860cc1b3fe
4 changed files with 113 additions and 5 deletions

View File

@@ -714,6 +714,10 @@ config.channels = {
botToken: "xoxb-bundled-channel-update-token",
appToken: "xapp-bundled-channel-update-token",
},
feishu: {
...(config.channels?.feishu || {}),
enabled: mode === "feishu",
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -841,6 +845,7 @@ npm install -g "openclaw@$BASELINE_VERSION" --omit=optional --no-fund --no-audit
command -v openclaw >/dev/null
baseline_root="$(package_root)"
test -d "$baseline_root/dist/extensions/telegram"
test -d "$baseline_root/dist/extensions/feishu"
echo "Replicating configured Telegram missing-runtime state..."
write_config telegram
@@ -889,6 +894,15 @@ cat /tmp/openclaw-update-slack.json
assert_update_ok /tmp/openclaw-update-slack.json "$candidate_version"
assert_dep_available slack @slack/web-api
echo "Mutating config to Feishu and rerunning same-version update path..."
write_config feishu
remove_runtime_dep feishu @larksuiteoapi/node-sdk
assert_no_dep_available feishu @larksuiteoapi/node-sdk
run_update_and_capture feishu /tmp/openclaw-update-feishu.json
cat /tmp/openclaw-update-feishu.json
assert_update_ok /tmp/openclaw-update-feishu.json "$candidate_version"
assert_dep_available feishu @larksuiteoapi/node-sdk
echo "bundled channel runtime deps Docker update E2E passed"
EOF
then

View File

@@ -219,6 +219,55 @@ describe("doctor bundled plugin runtime deps", () => {
]);
});
it("repairs Feishu runtime deps from preserved source config", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
writeBundledChannelPlugin(root, "feishu", { "@larksuiteoapi/node-sdk": "^1.61.0" });
const installed: Array<{
installRoot: string;
missingSpecs: string[];
installSpecs: string[];
}> = [];
const prompter = {
shouldRepair: false,
shouldForce: false,
repairMode: {
shouldRepair: false,
shouldForce: false,
nonInteractive: true,
canPrompt: false,
updateInProgress: true,
},
confirm: async () => false,
confirmAutoFix: async () => false,
confirmAggressiveAutoFix: async () => false,
confirmRuntimeRepair: async () => false,
select: async (_params: unknown, fallback: unknown) => fallback,
} as DoctorPrompter;
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
prompter,
packageRoot: root,
includeConfiguredChannels: true,
config: {
plugins: { enabled: true },
channels: { feishu: { enabled: true } },
},
installDeps: (params) => {
installed.push(params);
},
});
expect(installed).toEqual([
{
installRoot: root,
missingSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"],
installSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"],
},
]);
});
it("repairs missing deps into an external stage dir when configured", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-stage-"));

View File

@@ -1257,9 +1257,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
});
}
let fallbackRaw: string | null = null;
let fallbackParsed: unknown = {};
let fallbackSourceConfig: OpenClawConfig = {};
let fallbackHash = hashConfigRaw(null);
try {
const raw = deps.fs.readFileSync(configPath, "utf-8");
const rawHash = hashConfigRaw(raw);
fallbackRaw = raw;
fallbackHash = rawHash;
const parsedRes = parseConfigJson5(raw, deps.json5);
if (!parsedRes.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
@@ -1278,6 +1285,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}),
});
}
fallbackParsed = parsedRes.parsed;
fallbackSourceConfig = coerceConfig(parsedRes.parsed);
// Resolve $include directives
const recovered = await maybeRecoverSuspiciousConfigRead({
@@ -1289,6 +1298,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const effectiveRaw = recovered.raw;
const effectiveParsed = recovered.parsed;
const hash = hashConfigRaw(effectiveRaw);
fallbackRaw = effectiveRaw;
fallbackParsed = effectiveParsed;
fallbackSourceConfig = coerceConfig(effectiveParsed);
fallbackHash = hash;
let resolved: unknown;
try {
@@ -1329,6 +1342,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, effectiveParsed);
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
fallbackSourceConfig = coerceConfig(effectiveConfigRaw);
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
if (!validated.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
@@ -1392,12 +1406,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw: null,
parsed: {},
sourceConfig: {},
raw: fallbackRaw,
parsed: fallbackParsed,
sourceConfig: fallbackSourceConfig,
valid: false,
runtimeConfig: {},
hash: hashConfigRaw(null),
runtimeConfig: fallbackSourceConfig,
hash: fallbackHash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],

View File

@@ -286,6 +286,37 @@ describe("config io write", () => {
});
});
it("preserves parsed source config when snapshot validation throws", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
const original = {
gateway: { mode: "local" },
channels: { feishu: { enabled: true } },
};
const originalRaw = `${JSON.stringify(original, null, 2)}\n`;
await fs.writeFile(configPath, originalRaw, "utf-8");
mockLoadPluginManifestRegistry.mockImplementationOnce(() => {
throw new Error("manifest registry unavailable");
});
const io = createConfigIO({
env: { VITEST: "true" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(false);
expect(snapshot.raw).toBe(originalRaw);
expect(snapshot.parsed).toEqual(original);
expect(snapshot.sourceConfig).toEqual(original);
expect(snapshot.config).toEqual(original);
expect(snapshot.issues[0]?.message).toContain("manifest registry unavailable");
});
});
it("does not inject include-only $schema into the root config during partial writes", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");