From 860cc1b3fe3f4ce16b44ec4dba059c7c8107bd8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 18:42:07 +0100 Subject: [PATCH] fix(config): preserve source config during recovery --- .../bundled-channel-runtime-deps-docker.sh | 14 ++++++ ...doctor-bundled-plugin-runtime-deps.test.ts | 49 +++++++++++++++++++ src/config/io.ts | 24 +++++++-- src/config/io.write-config.test.ts | 31 ++++++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 5353c7be081..0ae5c8b4928 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -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 diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 9201b27f339..abcb8cff340 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -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-")); diff --git a/src/config/io.ts b/src/config/io.ts index 9c8647dff04..3548954a6e9 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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: [], diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index bb7b0c943b5..1783beacd1f 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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");