From 767e8280ac7c32cc2207fa42557c19299982fdec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 15 Jun 2026 23:07:29 +0800 Subject: [PATCH] fix(cli): harden official plugin recovery (#93325) * fix(cli): harden official plugin recovery * fix(config): preserve include write context * fix(config): reject external include mutations * fix(config): bind snapshots to config paths * fix(config): preserve write ownership * fix(cli): preflight plugin config mutations * chore(plugin-sdk): refresh api baseline * test(config): prove install env policy mutations * fix(cli): preflight plugin updates * fix(cli): preflight non-npm id migrations * chore(plugin-sdk): refresh api baseline * fix(cli): satisfy plugin recovery checks --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../reply/commands-plugins.install.test.ts | 38 +- src/auto-reply/reply/commands-plugins.ts | 25 +- src/cli/plugin-install-config-policy.ts | 20 +- src/cli/plugins-cli-test-helpers.ts | 59 +- src/cli/plugins-cli.install.test.ts | 644 ++++++++ src/cli/plugins-cli.update.test.ts | 778 ++++++++- src/cli/plugins-install-command.ts | 521 +++--- src/cli/plugins-install-config.test.ts | 894 +++++++---- src/cli/plugins-install-persist.test.ts | 35 +- src/cli/plugins-install-persist.ts | 244 ++- src/cli/plugins-location-bridges.test.ts | 50 +- src/cli/plugins-location-bridges.ts | 27 + src/cli/plugins-update-command.ts | 313 +++- src/commands/configure.wizard.test.ts | 74 +- src/commands/configure.wizard.ts | 36 +- .../doctor/shared/channel-doctor.test.ts | 31 + src/commands/doctor/shared/channel-doctor.ts | 5 +- src/config/env-preserve.test.ts | 653 ++++++++ src/config/env-preserve.ts | 717 ++++++++- src/config/includes.test.ts | 26 + src/config/includes.ts | 54 +- src/config/io.ts | 259 ++- src/config/io.write-config.test.ts | 1018 +++++++++++- src/config/io.write-prepare.test.ts | 332 ++++ src/config/io.write-prepare.ts | 347 +++- src/config/mutate.test.ts | 1408 ++++++++++++++++- src/config/mutate.ts | 625 ++++++-- src/config/mutation-conflict.ts | 12 + src/hooks/install.test.ts | 413 ++++- src/hooks/install.ts | 425 ++++- src/hooks/update.test.ts | 16 +- src/hooks/update.ts | 1 + src/infra/fs-safe.ts | 1 + src/infra/install-package-dir.ts | 5 +- .../installed-plugin-index-records.test.ts | 15 + src/plugins/installed-plugin-index-records.ts | 8 +- src/plugins/update.test.ts | 35 + src/plugins/update.ts | 110 +- 39 files changed, 9380 insertions(+), 898 deletions(-) create mode 100644 src/config/mutation-conflict.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 79f94af2078..8d88762fdb9 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json -61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl +303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json +71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 29d31165f26..519a32b7225 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -53,7 +53,8 @@ vi.mock("../../plugins/git-install.js", async () => { }; }); -vi.mock("../../cli/plugins-install-persist.js", () => ({ +vi.mock("../../cli/plugins-install-persist.js", async (importOriginal) => ({ + ...(await importOriginal()), persistPluginInstall: persistPluginInstallMock, })); @@ -70,6 +71,16 @@ function buildPluginsParams(commandBodyNormalized: string, workspaceDir: string) function expectPersistedInstall(pluginId: string, expectedInstall: Record): void { const persisted = mockFirstObjectArg(persistPluginInstallMock); expect(persisted.pluginId).toBe(pluginId); + const snapshot = persisted.snapshot as Record; + const writeOptions = snapshot.writeOptions as Record; + expectObjectFields(persisted.snapshot, { + writeOptions: expect.objectContaining({ + assertConfigPathForWrite: expect.any(Function), + expectedConfigPath: expect.stringContaining("openclaw.json"), + ownedConfigPathForWrite: expect.stringContaining("openclaw.json"), + }), + }); + expect(writeOptions).not.toHaveProperty("basePluginMetadataSnapshot"); expectObjectFields(persisted.install, expectedInstall); } @@ -190,6 +201,31 @@ describe("handleCommands /plugins install", () => { } }); + it("refuses installs through a root include before package installer side effects", async () => { + await withTempHome("openclaw-command-plugins-home-", async (home) => { + const sharedConfigPath = path.join(home, ".openclaw", "shared.json5"); + await fs.writeFile(sharedConfigPath, `${JSON.stringify({ plugins: {} }, null, 2)}\n`); + await fs.writeFile( + path.join(home, ".openclaw", "openclaw.json"), + `${JSON.stringify({ $include: "./shared.json5" }, null, 2)}\n`, + ); + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install @acme/demo", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain("unsupported $include shape at the root"); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(installPluginFromPathMock).not.toHaveBeenCalled(); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(installPluginFromGitSpecMock).not.toHaveBeenCalled(); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + it("installs from an explicit git: spec", async () => { installPluginFromGitSpecMock.mockResolvedValue({ ok: true, diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index eebfc608489..3226c024694 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -7,10 +7,14 @@ import { createPluginInstallLogger, resolveFileNpmSpecToLocalPath, } from "../../cli/plugins-command-helpers.js"; -import { persistPluginInstall } from "../../cli/plugins-install-persist.js"; +import { + persistPluginInstall, + resolveInstallConfigMutationPreflights, + selectInstallMutationWriteOptions, +} from "../../cli/plugins-install-persist.js"; import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; -import { readConfigFileSnapshot } from "../../config/config.js"; +import { readConfigFileSnapshot, readConfigFileSnapshotForWrite } from "../../config/config.js"; import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; @@ -369,7 +373,8 @@ async function loadPluginCommandConfig(): Promise< | { ok: true; path: string; snapshot: ConfigSnapshotForInstallPersist } | { ok: false; path: string; error: string } > { - const snapshot = await readConfigFileSnapshot(); + const prepared = await readConfigFileSnapshotForWrite(); + const snapshot = prepared.snapshot; if (!snapshot.valid) { return { ok: false, @@ -377,12 +382,26 @@ async function loadPluginCommandConfig(): Promise< error: "Config file is invalid; fix it before using /plugins.", }; } + const writeOptions = selectInstallMutationWriteOptions(prepared.writeOptions); + const { pluginMutation } = resolveInstallConfigMutationPreflights({ + parsed: (snapshot.parsed ?? {}) as Record, + snapshotPath: snapshot.path, + writeOptions, + }); + if (pluginMutation.mode === "blocked") { + return { + ok: false, + path: snapshot.path, + error: pluginMutation.reason, + }; + } return { ok: true, path: snapshot.path, snapshot: { config: structuredClone(snapshot.sourceConfig), baseHash: snapshot.hash, + writeOptions, }, }; } diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index c8726817a11..10e4d27b4cd 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -22,6 +22,7 @@ type PluginInstallInvalidConfigPolicy = "deny" | "allow-plugin-recovery"; export type PluginInstallRequestContext = { rawSpec: string; normalizedSpec: string; + installKind?: "plugin"; resolvedPath?: string; marketplace?: string; bundledPluginId?: string; @@ -77,6 +78,12 @@ function resolveBundledInstallRecoveryMetadata( return direct; } } + if ( + resolveFileNpmSpecToLocalPath(request.rawSpec) !== null || + (request.resolvedPath !== undefined && fs.existsSync(request.resolvedPath)) + ) { + return {}; + } const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec); const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec); for (const value of [ @@ -104,7 +111,7 @@ function resolveBundledInstallRecoveryMetadata( } function resolveOfficialExternalInstallRecoveryMetadata( - request: Pick, + request: Pick, ): { pluginId?: string; allowInvalidConfigRecovery?: boolean; @@ -112,19 +119,24 @@ function resolveOfficialExternalInstallRecoveryMetadata( if (request.marketplace) { return {}; } - if (request.rawSpec.trim().startsWith("file:")) { + if (resolveFileNpmSpecToLocalPath(request.rawSpec) !== null) { return {}; } if (fs.existsSync(resolveUserPath(request.rawSpec))) { return {}; } const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec); + const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec); const values = new Set( normalizeStringEntries([ request.rawSpec, + request.normalizedSpec, rawNpmPrefixSpec ?? "", + normalizedNpmPrefixSpec ?? "", parseRegistryNpmSpec(request.rawSpec)?.name ?? "", + parseRegistryNpmSpec(request.normalizedSpec)?.name ?? "", rawNpmPrefixSpec ? parseRegistryNpmSpec(rawNpmPrefixSpec)?.name : "", + normalizedNpmPrefixSpec ? parseRegistryNpmSpec(normalizedNpmPrefixSpec)?.name : "", ]), ); if (values.size === 0) { @@ -193,6 +205,7 @@ function resolvePluginInstallArgvRequest(commandPath: string[], argv: string[]) export function resolvePluginInstallRequestContext(params: { rawSpec: string; marketplace?: string; + installKind?: "plugin"; }): PluginInstallRequestResolution { if (params.marketplace) { return { @@ -200,6 +213,7 @@ export function resolvePluginInstallRequestContext(params: { request: { rawSpec: params.rawSpec, normalizedSpec: params.rawSpec, + installKind: "plugin", marketplace: params.marketplace, }, }; @@ -220,6 +234,7 @@ export function resolvePluginInstallRequestContext(params: { }); const officialRecovered = resolveOfficialExternalInstallRecoveryMetadata({ rawSpec: params.rawSpec, + normalizedSpec, marketplace: params.marketplace, }); const recovered = @@ -232,6 +247,7 @@ export function resolvePluginInstallRequestContext(params: { rawSpec: params.rawSpec, normalizedSpec, resolvedPath: resolveUserPath(normalizedSpec), + ...(params.installKind === "plugin" || recovered.pluginId ? { installKind: "plugin" } : {}), ...(recovered.pluginId ? { bundledPluginId: recovered.pluginId } : {}), ...(recovered.allowInvalidConfigRecovery !== undefined ? { allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery } diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 7463ddca1af..a2d51293291 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -21,6 +21,10 @@ type ListMarketplacePluginsFn = (typeof import("../plugins/marketplace.js"))["listMarketplacePlugins"]; type ResolveMarketplaceInstallShortcutFn = (typeof import("../plugins/marketplace.js"))["resolveMarketplaceInstallShortcut"]; +type UpdateNpmInstalledPluginsFn = + (typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]; +type UpdateNpmInstalledHookPacksFn = + (typeof import("../hooks/update.js"))["updateNpmInstalledHookPacks"]; type PluginInstallRecordMap = Record; let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {}; @@ -37,6 +41,7 @@ function invokeMock(mock: unknown, ...args: TA export const loadConfig: Mock = vi.fn(() => ({}) as OpenClawConfig); export const readConfigFileSnapshot: AsyncUnknownMock = vi.fn(); +export const readConfigFileSnapshotForWrite: AsyncUnknownMock = vi.fn(); export const writeConfigFile: AsyncUnknownMock = vi.fn(async () => undefined); export const replaceConfigFile: AsyncUnknownMock = vi.fn( async (params: { nextConfig: OpenClawConfig }) => await writeConfigFile(params.nextConfig), @@ -73,8 +78,8 @@ export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); export const planPluginUninstall: UnknownMock = vi.fn(); export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn(); const uninstallPlugin: AsyncUnknownMock = vi.fn(); -export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn(); -export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn(); +export const updateNpmInstalledPlugins: Mock = vi.fn(); +export const updateNpmInstalledHookPacks: Mock = vi.fn(); export const promptYesNo: AsyncUnknownMock = vi.fn(); export class PromptInputClosedError extends Error { constructor() { @@ -191,6 +196,16 @@ vi.mock("../config/config.js", () => ({ readConfigFileSnapshot, ...args, )) as (typeof import("../config/config.js"))["readConfigFileSnapshot"], + readConfigFileSnapshotForWrite: (( + ...args: Parameters<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]> + ) => + invokeMock< + Parameters<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]>, + ReturnType<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]> + >( + readConfigFileSnapshotForWrite, + ...args, + )) as (typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"], writeConfigFile: ((config: OpenClawConfig) => invokeMock< [OpenClawConfig], @@ -481,18 +496,22 @@ vi.mock("../plugins/uninstall.js", async (importOriginal) => { }; }); -vi.mock("../plugins/update.js", () => ({ - updateNpmInstalledPlugins: (( - ...args: Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]> - ) => - invokeMock< - Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>, - ReturnType<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]> - >( - updateNpmInstalledPlugins, - ...args, - )) as (typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"], -})); +vi.mock("../plugins/update.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateNpmInstalledPlugins: (( + ...args: Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>, + ReturnType<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]> + >( + updateNpmInstalledPlugins, + ...args, + )) as (typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"], + }; +}); vi.mock("../hooks/update.js", () => ({ updateNpmInstalledHookPacks: (( @@ -679,6 +698,7 @@ export function resetPluginsCliTestState() { restoreRuntimeCaptureMocks(); loadConfig.mockReset(); readConfigFileSnapshot.mockReset(); + readConfigFileSnapshotForWrite.mockReset(); writeConfigFile.mockReset(); replaceConfigFile.mockReset(); resolveStateDir.mockReset(); @@ -737,6 +757,17 @@ export function resetPluginsCliTestState() { legacyIssues: [], }; }); + readConfigFileSnapshotForWrite.mockImplementation(async () => { + const snapshot = (await readConfigFileSnapshot()) as { path: string }; + return { + snapshot, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: snapshot.path, + ownedConfigPathForWrite: snapshot.path, + }, + }; + }); writeConfigFile.mockResolvedValue(undefined); replaceConfigFile.mockImplementation( (async (params: { nextConfig: OpenClawConfig }) => diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 193a0c2fb33..5845b6b98ee 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { hashConfigIncludeRaw } from "../config/includes.js"; import { listOfficialExternalPluginCatalogEntries, resolveOfficialExternalPluginId, @@ -27,6 +28,7 @@ import { loadConfig, loadPluginManifestRegistry, readConfigFileSnapshot, + readConfigFileSnapshotForWrite, parseClawHubPluginSpec, recordHookInstall, recordPluginInstall, @@ -231,6 +233,7 @@ function createHookPackInstallResult(targetDir: string): { ok: true; hookPackId: string; hooks: string[]; + packageKind: "hook-only"; targetDir: string; version: string; } { @@ -238,6 +241,7 @@ function createHookPackInstallResult(targetDir: string): { ok: true, hookPackId: "demo-hooks", hooks: ["command-audit"], + packageKind: "hook-only", targetDir, version: "1.2.3", }; @@ -310,8 +314,10 @@ type PluginInstallCall = { dangerouslyForceUnsafeInstall?: boolean; dryRun?: boolean; expectedIntegrity?: string; + expectedPackageKind?: "hook-only"; expectedPluginId?: string; extensionsDir?: string; + inspection?: "package-kind"; logger?: { info?: unknown; warn?: unknown; @@ -399,6 +405,154 @@ function runtimeLogsContain(fragment: string): boolean { return runtimeLogs.some((line) => line.includes(fragment)); } +function primeBlockedPluginConfigMutation( + params: { blockHooks?: boolean; config?: OpenClawConfig } = {}, +): void { + const configPath = path.join(process.cwd(), "openclaw.json5"); + const externalPluginsPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + const externalHooksPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "hooks.json5", + ); + const config = params.config ?? ({} as OpenClawConfig); + const parsed = { + plugins: { $include: externalPluginsPath }, + ...(params.blockHooks ? { hooks: { $include: externalHooksPath } } : {}), + }; + loadConfig.mockReturnValue(config); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: configPath, + exists: true, + raw: JSON.stringify(parsed), + parsed, + resolved: config, + sourceConfig: config, + runtimeConfig: config, + valid: true, + config, + hash: "blocked-plugin-config", + issues: [], + warnings: [], + legacyIssues: [], + }, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + includeFileTargetsForWrite: { + [externalPluginsPath]: externalPluginsPath, + ...(params.blockHooks ? { [externalHooksPath]: externalHooksPath } : {}), + }, + }, + }); +} + +function primeNestedPluginConfigMutation(tempRoot: string): void { + const configPath = path.join(tempRoot, "openclaw.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const pluginsRaw = `${JSON.stringify({ entries: { $include: "./entries.json5" } }, null, 2)}\n`; + const config = { plugins: { entries: {} } } as OpenClawConfig; + fs.writeFileSync(pluginsPath, pluginsRaw); + loadConfig.mockReturnValue(config); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: configPath, + exists: true, + raw: JSON.stringify({ plugins: { $include: "./plugins.json5" } }), + parsed: { plugins: { $include: "./plugins.json5" } }, + resolved: config, + sourceConfig: config, + runtimeConfig: config, + valid: true, + config, + hash: "nested-plugin-config", + issues: [], + warnings: [], + legacyIssues: [], + }, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + includeFileHashesForWrite: { + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }, + includeFileTargetsForWrite: { + [pluginsPath]: fs.realpathSync(pluginsPath), + }, + }, + }); +} + +function primeBlockedRootConfigMutation(config = {} as OpenClawConfig): void { + const configPath = path.join(process.cwd(), "openclaw.json5"); + loadConfig.mockReturnValue(config); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: configPath, + exists: true, + raw: JSON.stringify({ $include: "./shared.json5", plugins: {} }), + parsed: { $include: "./shared.json5", plugins: {} }, + resolved: config, + sourceConfig: config, + runtimeConfig: config, + valid: true, + config, + hash: "blocked-root-config", + issues: [], + warnings: [], + legacyIssues: [], + }, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + }, + }); +} + +function primeBlockedHookConfigMutation(config = {} as OpenClawConfig): void { + const configPath = path.join(process.cwd(), "openclaw.json5"); + const externalHooksPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "hooks.json5", + ); + const parsed = { hooks: { $include: externalHooksPath } }; + loadConfig.mockReturnValue(config); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: configPath, + exists: true, + raw: JSON.stringify(parsed), + parsed, + resolved: config, + sourceConfig: config, + runtimeConfig: config, + valid: true, + config, + hash: "blocked-hook-config", + issues: [], + warnings: [], + legacyIssues: [], + }, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + includeFileTargetsForWrite: { + [externalHooksPath]: externalHooksPath, + }, + }, + }); +} + describe("plugins cli install", () => { beforeEach(() => { resetPluginsCliTestState(); @@ -445,6 +599,496 @@ describe("plugins cli install", () => { expect(writeConfigFile).not.toHaveBeenCalled(); }); + it.each(["@acme/demo-plugin", "npm:@acme/demo-plugin"])( + "fails closed before installing blocked ambiguous npm plugin spec %s", + async (spec) => { + primeBlockedPluginConfigMutation(); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + await expect(runPluginsCommand(["plugins", "install", spec])).rejects.toThrow("__exit__:1"); + + expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); + expect(hookNpmInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }, + ); + + it("installs a positively identified npm hook pack without probing plugin installation", async () => { + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.2.3", + }, + }, + }, + }, + } as OpenClawConfig; + primeBlockedPluginConfigMutation(); + installHooksFromNpmSpec.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + packageKind: "hook-only", + targetDir: "/tmp/hooks/demo-hooks", + version: "1.2.3", + npmResolution: { + name: "@acme/demo-hooks", + version: "1.2.3", + resolvedSpec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + recordHookInstall.mockReturnValue(installedCfg); + + await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(2); + expect(hookNpmInstallCall().inspection).toBe("package-kind"); + expect(hookNpmInstallCall(1).expectedIntegrity).toBe("sha256-demo"); + expect(hookNpmInstallCall(1).expectedPackageKind).toBe("hook-only"); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + }); + + it("blocks npm package inspection when plugin and hook config are include-owned", async () => { + primeBlockedPluginConfigMutation({ blockHooks: true }); + installHooksFromNpmSpec.mockResolvedValue({ + ...createHookPackInstallResult("/tmp/hooks/demo-hooks"), + npmResolution: { + name: "@acme/demo-hooks", + version: "1.2.3", + resolvedSpec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + + await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config hooks are stored in an external or unresolved top-level $include", + ); + }); + + it("blocks a proven npm hook pack before plugin installer side effects when only hooks config is include-owned", async () => { + primeBlockedHookConfigMutation(); + installHooksFromNpmSpec.mockResolvedValue({ + ...createHookPackInstallResult("/tmp/hooks/demo-hooks"), + npmResolution: { + name: "@acme/demo-hooks", + version: "1.2.3", + resolvedSpec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + + await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); + expect(hookNpmInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config hooks are stored in an external or unresolved top-level $include", + ); + }); + + it("blocks local package inspection when plugin and hook config are include-owned", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-pack-")); + primeBlockedPluginConfigMutation({ blockHooks: true }); + installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath)); + installPluginFromPath.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.extensions", + code: "missing_openclaw_extensions", + }); + + try { + await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installHooksFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config hooks are stored in an external or unresolved top-level $include", + ); + }); + + it("blocks a proven local hook pack before plugin installer side effects when only hooks config is include-owned", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-pack-")); + primeBlockedHookConfigMutation(); + installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath)); + + try { + await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledTimes(1); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config hooks are stored in an external or unresolved top-level $include", + ); + }); + + it.skipIf(process.platform === "win32")( + "preserves local hook-pack precedence for prefix-shaped paths", + async () => { + const localPath = path.join(process.cwd(), `clawhub:demo-hooks-${process.pid}`); + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "path", + sourcePath: localPath, + }, + }, + }, + }, + } as OpenClawConfig; + fs.mkdirSync(localPath); + primeBlockedPluginConfigMutation(); + parseClawHubPluginSpec.mockReturnValue({ name: "demo-hooks" }); + installPluginFromPath.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.extensions", + code: "missing_openclaw_extensions", + }); + installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath)); + recordHookInstall.mockReturnValue(installedCfg); + + try { + await runPluginsCommand(["plugins", "install", path.basename(localPath)]); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(installHooksFromPath).toHaveBeenCalledTimes(2); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(hookPathInstallCall(1).expectedPackageKind).toBe("hook-only"); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + }, + ); + + it("fails closed for ambiguous npm plugins when the whole config is include-owned", async () => { + primeBlockedRootConfigMutation(); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("unsupported $include shape at the root"); + }); + + it("fails closed for ambiguous local plugins when the whole config is include-owned", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-demo-plugin-")); + primeBlockedRootConfigMutation(); + installHooksFromPath.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + try { + await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installHooksFromPath).not.toHaveBeenCalled(); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("unsupported $include shape at the root"); + }); + + it("fails closed before installing a blocked ambiguous local plugin", async () => { + const archivePath = path.join(os.tmpdir(), `openclaw-plugin-${process.pid}.tgz`); + fs.writeFileSync(archivePath, "not-an-archive"); + primeBlockedPluginConfigMutation(); + installHooksFromPath.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + try { + await expect(runPluginsCommand(["plugins", "install", archivePath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(archivePath, { force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledTimes(1); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("fails closed when an npm hook probe finds a plugin-capable package", async () => { + primeBlockedPluginConfigMutation(); + installHooksFromNpmSpec.mockResolvedValue({ + ...createHookPackInstallResult("/tmp/hooks/demo-hooks"), + packageKind: "plugin-capable", + }); + + await expect(runPluginsCommand(["plugins", "install", "@acme/dual-package"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); + expect(hookNpmInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("fails closed when a local hook probe finds a plugin-capable package", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-dual-package-")); + primeBlockedPluginConfigMutation(); + installHooksFromPath.mockResolvedValue({ + ...createHookPackInstallResult(localPath), + packageKind: "plugin-capable", + }); + + try { + await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledTimes(1); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("fails closed for a local bundle plugin instead of installing its hooks", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundle-plugin-")); + primeBlockedPluginConfigMutation(); + installHooksFromPath.mockResolvedValue({ + ...createHookPackInstallResult(localPath), + packageKind: "plugin-capable", + }); + + try { + await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledTimes(1); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("fails closed when a blocked-config npm hook probe throws", async () => { + primeBlockedPluginConfigMutation(); + installHooksFromNpmSpec.mockRejectedValue(new Error("hook validation exploded")); + + await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); + expect(hookNpmInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("fails closed when a blocked-config local hook probe throws", async () => { + const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-")); + primeBlockedPluginConfigMutation(); + installHooksFromPath.mockRejectedValue(new Error("hook validation exploded")); + + try { + await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(localPluginDir, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledTimes(1); + expect(hookPathInstallCall().inspection).toBe("package-kind"); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it.each([ + { + label: "marketplace", + args: ["plugins", "install", "demo", "--marketplace", "local/repo"], + installer: installPluginFromMarketplace, + setup: () => + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + extensions: ["index.js"], + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "demo", + }), + }, + { + label: "git", + args: ["plugins", "install", "git:github.com/acme/demo"], + installer: installPluginFromGitSpec, + setup: () => installPluginFromGitSpec.mockResolvedValue(createGitPluginInstallResult()), + }, + { + label: "npm-pack", + args: ["plugins", "install", "npm-pack:/tmp/demo.tgz"], + installer: installPluginFromNpmPackArchive, + setup: () => + installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult()), + }, + { + label: "ClawHub", + args: ["plugins", "install", "clawhub:demo"], + installer: installPluginFromClawHub, + setup: () => { + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "stable", + }), + ); + }, + }, + ])( + "blocks explicit $label plugin installs before installer side effects", + async ({ args, installer, setup }) => { + primeBlockedPluginConfigMutation(); + setup(); + + await expect(runPluginsCommand(args)).rejects.toThrow("__exit__:1"); + + expect(installer).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }, + ); + + it("blocks bare official plugins before installer side effects", async () => { + primeBlockedPluginConfigMutation(); + findBundledPluginSourceMock.mockReturnValue(undefined); + + await expect(runPluginsCommand(["plugins", "install", "brave"])).rejects.toThrow("__exit__:1"); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("blocks bare bundled plugin ids before installer side effects", async () => { + const pluginId = "config-required-plugin"; + primeBlockedPluginConfigMutation(); + findBundledPluginSourceMock.mockReturnValue({ + pluginId, + localPath: `/app/dist/extensions/${pluginId}`, + }); + + await expect(runPluginsCommand(["plugins", "install", pluginId])).rejects.toThrow("__exit__:1"); + + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("blocks explicit plugins through nested include config before installer side effects", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-nested-")); + primeNestedPluginConfigMutation(tempRoot); + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + extensions: ["index.js"], + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "demo", + }); + + try { + await expect( + runPluginsCommand(["plugins", "install", "demo", "--marketplace", "local/repo"]), + ).rejects.toThrow("__exit__:1"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("nested $include"); + }); + it("exits when --marketplace is combined with --link", async () => { await expect( runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 0870b2fc941..c62c8a9f3a2 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -1,11 +1,17 @@ // Plugins CLI update tests cover plugin update command behavior and output. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { hashConfigIncludeRaw } from "../config/includes.js"; import { loadConfig, + readConfigFileSnapshotForWrite, refreshPluginRegistry, registerPluginsCli, + replaceConfigFile, resetPluginsCliTestState, runPluginsCommand, runtimeErrors, @@ -55,6 +61,63 @@ function expectSingleCallParams(mockFn: ReturnType) { return params; } +function primeUpdateConfigSnapshot(params: { + config: OpenClawConfig; + configPath?: string; + loadedConfig?: OpenClawConfig; + parsed?: Record; + runtimeConfig?: OpenClawConfig; + sourceConfig?: OpenClawConfig; + valid?: boolean; + includeFileHashesForWrite?: Record; + includeFileTargetsForWrite?: Record; +}): void { + const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5"); + const parsed = params.parsed ?? (params.config as Record); + const sourceConfig = params.sourceConfig ?? params.config; + const runtimeConfig = params.runtimeConfig ?? params.config; + loadConfig.mockReturnValue(params.loadedConfig ?? params.config); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: configPath, + exists: true, + raw: JSON.stringify(parsed), + parsed, + resolved: sourceConfig, + sourceConfig, + runtimeConfig, + valid: params.valid ?? true, + config: runtimeConfig, + hash: "update-config", + issues: [], + warnings: [], + legacyIssues: [], + }, + writeOptions: { + assertConfigPathForWrite: () => {}, + expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + includeFileHashesForWrite: params.includeFileHashesForWrite, + includeFileTargetsForWrite: params.includeFileTargetsForWrite, + }, + }); +} + +function primeBlockedUpdateConfig(section: "hooks" | "plugins", config: OpenClawConfig): void { + const externalPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + `${section}.json5`, + ); + primeUpdateConfigSnapshot({ + config, + parsed: { [section]: { $include: externalPath } }, + includeFileTargetsForWrite: { + [externalPath]: externalPath, + }, + }); +} + describe("plugins cli update", () => { beforeEach(() => { resetPluginsCliTestState(); @@ -131,7 +194,12 @@ describe("plugins cli update", () => { }, } as OpenClawConfig; - loadConfig.mockReturnValue(cfg); + primeUpdateConfigSnapshot({ + config: cfg, + includeFileHashesForWrite: { + "/tmp/hooks.json5": "hooks-start-hash", + }, + }); updateNpmInstalledPlugins.mockResolvedValue({ config: cfg, changed: false, @@ -155,10 +223,682 @@ describe("plugins cli update", () => { expect(hookUpdateParams.config).toBe(cfg); expect(hookUpdateParams.hookIds).toEqual(["demo-hooks"]); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(replaceConfigFile).toHaveBeenCalledWith({ + nextConfig, + baseHash: "update-config", + writeOptions: expect.objectContaining({ + includeFileHashesForWrite: { + "/tmp/hooks.json5": "hooks-start-hash", + }, + }), + }); expect(refreshPluginRegistry).not.toHaveBeenCalled(); expectRestartNoticeLogged(); }); + it("uses the mutation-start snapshot for updater input and hook selection", async () => { + const loadedConfig = { + hooks: { + internal: { + installs: { + "old-hooks": { + source: "npm", + spec: "@acme/old-hooks@1.0.0", + installPath: "/tmp/hooks/old-hooks", + }, + }, + }, + }, + plugins: { + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + const snapshotConfig = { + hooks: { + internal: { + installs: { + "new-hooks": { + source: "npm", + spec: "@acme/new-hooks@1.0.0", + installPath: "~/.openclaw/hooks/new-hooks", + }, + }, + }, + }, + plugins: { + entries: { + alpha: { enabled: false }, + }, + }, + } as OpenClawConfig; + const installRecords = { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + installPath: "/tmp/alpha", + }, + } as const; + primeUpdateConfigSnapshot({ + config: snapshotConfig, + loadedConfig, + runtimeConfig: { + ...snapshotConfig, + hooks: { + internal: { + installs: { + "new-hooks": { + source: "npm", + spec: "@acme/new-hooks@1.0.0", + installPath: "/home/test/.openclaw/hooks/new-hooks", + }, + }, + }, + }, + messages: { + ackReactionScope: "group-mentions", + }, + }, + }); + setInstalledPluginIndexInstallRecords(installRecords); + updateNpmInstalledPlugins.mockImplementation(async (params: { config: OpenClawConfig }) => ({ + config: params.config, + changed: false, + outcomes: [], + })); + updateNpmInstalledHookPacks.mockImplementation(async (params: { config: OpenClawConfig }) => ({ + config: params.config, + changed: false, + outcomes: [], + })); + + await runPluginsCommand(["plugins", "update", "--all"]); + + const pluginUpdateParams = expectSingleCallParams(updateNpmInstalledPlugins); + const hookUpdateParams = expectSingleCallParams(updateNpmInstalledHookPacks); + expect(pluginUpdateParams.config).toEqual({ + ...snapshotConfig, + hooks: { + internal: { + installs: { + "new-hooks": { + source: "npm", + spec: "@acme/new-hooks@1.0.0", + installPath: "/home/test/.openclaw/hooks/new-hooks", + }, + }, + }, + }, + messages: { + ackReactionScope: "group-mentions", + }, + plugins: { + ...snapshotConfig.plugins, + installs: installRecords, + }, + }); + expect(hookUpdateParams.hookIds).toEqual(["new-hooks"]); + }); + + it("uses resolved shipped install records instead of raw env placeholders", async () => { + const cfg = createTrackedPluginConfig({ + pluginId: "alpha", + spec: "@openclaw/alpha@1.0.0", + }); + primeUpdateConfigSnapshot({ + config: cfg, + parsed: { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "${PLUGIN_SPEC}", + installPath: "${PLUGIN_PATH}", + }, + }, + }, + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "alpha"]); + + const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); + expect(updateParams.config).toEqual(cfg); + }); + + it("rejects invalid config snapshots before updater side effects", async () => { + const cfg = createTrackedPluginConfig({ + pluginId: "alpha", + spec: "@openclaw/alpha@1.0.0", + }); + primeUpdateConfigSnapshot({ + config: cfg, + valid: false, + }); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + + await expect(runPluginsCommand(["plugins", "update", "alpha"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toBe( + "Cannot update plugins or hooks while the config is invalid.", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("blocks hook pack updates before updater side effects when hooks config is include-owned", async () => { + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("hooks", cfg); + + await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain( + "Config hooks are stored in an external or unresolved top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("allows index-only legacy id migration when an included plugins section has no references", async () => { + const cfg = { plugins: {} } as OpenClawConfig; + const pluginRecords = createTrackedPluginConfig({ + pluginId: "voice-call", + spec: "@openclaw/voice-call@1.0.0", + }).plugins?.installs; + const nextConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + installs: { + "@openclaw/voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.1.0", + }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords(pluginRecords ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config: nextConfig, + changed: true, + outcomes: [ + { + pluginId: "@openclaw/voice-call", + status: "updated", + message: "Updated @openclaw/voice-call.", + }, + ], + }); + + await runPluginsCommand(["plugins", "update", "--all"]); + + expect(runtimeErrors).toEqual([]); + expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + nextConfig.plugins?.installs, + ); + expect(writeConfigFile).toHaveBeenCalledWith(cfg); + }); + + it("allows scoped non-npm updates beside include-owned plugin config", async () => { + const pluginId = "@acme/demo"; + const cfg = { + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + }, + } as OpenClawConfig; + const pluginRecords = { + [pluginId]: { + source: "git", + spec: "https://github.com/acme/demo.git#v1.0.0", + installPath: "/tmp/demo", + }, + } as const; + const nextConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + installs: pluginRecords, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords(pluginRecords); + updateNpmInstalledPlugins.mockResolvedValue({ + config: nextConfig, + changed: true, + outcomes: [{ pluginId, status: "updated", message: `Updated ${pluginId}.` }], + }); + + await runPluginsCommand(["plugins", "update", pluginId]); + + expect(runtimeErrors).toEqual([]); + expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords); + expect(writeConfigFile).toHaveBeenCalledWith(cfg); + }); + + it("blocks legacy plugin id migration before updater side effects", async () => { + const cfg = { + plugins: { + entries: { + "voice-call": { enabled: true }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords({ + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + }, + }); + + await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "ClawHub", + record: { + source: "clawhub", + spec: "clawhub:@openclaw/voice-call", + clawhubPackage: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + }, + }, + { + label: "git", + record: { + source: "git", + spec: "https://github.com/openclaw/voice-call.git", + installPath: "/tmp/voice-call", + }, + }, + { + label: "marketplace", + record: { + source: "marketplace", + marketplaceSource: "acme", + marketplacePlugin: "voice-call", + installPath: "/tmp/voice-call", + }, + }, + ] as const)( + "blocks possible $label id migration before updater side effects", + async ({ record }) => { + const cfg = { + plugins: { + entries: { + "voice-call": { enabled: true }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords({ + "voice-call": record, + }); + + await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }, + ); + + it("blocks possible legacy id migration when an included plugins section is unresolved", async () => { + const externalPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + const cfg = { plugins: {} } as OpenClawConfig; + primeUpdateConfigSnapshot({ + config: cfg, + parsed: { plugins: { $include: externalPath } }, + sourceConfig: { plugins: { $include: externalPath } } as unknown as OpenClawConfig, + includeFileTargetsForWrite: { + [externalPath]: externalPath, + }, + }); + setInstalledPluginIndexInstallRecords({ + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + }, + }); + + await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("preflights legacy plugin-record cleanup before hook-only updater side effects", async () => { + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + }, + }, + }, + }, + plugins: { + installs: { + legacy: { + source: "npm", + spec: "@openclaw/legacy@1.0.0", + installPath: "/tmp/legacy", + }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + + await expect(runPluginsCommand(["plugins", "update", "demo-hooks"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain( + "Config plugins are stored in an external or unresolved top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("preserves skip behavior for plugin records whose source cannot be updated", async () => { + const cfg = { + plugins: { + installs: { + linked: { + source: "path", + sourcePath: "/tmp/linked", + installPath: "/tmp/linked", + }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [{ pluginId: "linked", status: "skipped", message: "Skipping linked." }], + }); + + await runPluginsCommand(["plugins", "update", "--all"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("preserves skip behavior for ClawHub records missing package metadata", async () => { + const cfg = { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + } as OpenClawConfig; + primeBlockedUpdateConfig("plugins", cfg); + setInstalledPluginIndexInstallRecords({ + demo: { + source: "clawhub", + spec: "clawhub:demo", + installPath: "/tmp/demo", + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + message: 'Skipping "demo" (missing ClawHub package metadata).', + }, + ], + }); + + await runPluginsCommand(["plugins", "update", "demo"]); + + expect(runtimeErrors).toEqual([]); + expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("preserves an include-owned plugins section during legacy-record cleanup", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-")); + const configPath = path.join(tempRoot, "openclaw.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const cfg = createTrackedPluginConfig({ + pluginId: "alpha", + spec: "@openclaw/alpha@1.0.0", + }); + const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`; + const nextConfig = createTrackedPluginConfig({ + pluginId: "alpha", + spec: "@openclaw/alpha@1.1.0", + }); + fs.writeFileSync(pluginsPath, pluginsRaw); + primeUpdateConfigSnapshot({ + config: cfg, + configPath, + parsed: { plugins: { $include: "./plugins.json5" } }, + includeFileHashesForWrite: { + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }, + includeFileTargetsForWrite: { + [pluginsPath]: fs.realpathSync(pluginsPath), + }, + }); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config: nextConfig, + changed: true, + outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }], + }); + + try { + await runPluginsCommand(["plugins", "update", "alpha"]); + + expect(runtimeErrors).toEqual([]); + expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + nextConfig.plugins?.installs, + ); + expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("migrates included legacy install records while updating another indexed plugin", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-")); + const configPath = path.join(tempRoot, "openclaw.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const legacyRecord = { + source: "npm", + spec: "@openclaw/legacy@1.0.0", + installPath: "/tmp/legacy", + } as const; + const indexedRecord = { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + installPath: "/tmp/alpha", + } as const; + const updatedIndexedRecord = { + ...indexedRecord, + spec: "@openclaw/alpha@1.1.0", + } as const; + const cfg = { + plugins: { + installs: { + legacy: legacyRecord, + }, + }, + } as OpenClawConfig; + const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`; + const nextInstallRecords = { + alpha: updatedIndexedRecord, + legacy: legacyRecord, + }; + fs.writeFileSync(pluginsPath, pluginsRaw); + primeUpdateConfigSnapshot({ + config: cfg, + configPath, + parsed: { plugins: { $include: "./plugins.json5" } }, + includeFileHashesForWrite: { + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }, + includeFileTargetsForWrite: { + [pluginsPath]: fs.realpathSync(pluginsPath), + }, + }); + setInstalledPluginIndexInstallRecords({ + alpha: indexedRecord, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + config: { + plugins: { + installs: nextInstallRecords, + }, + } as OpenClawConfig, + changed: true, + outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }], + }); + + try { + await runPluginsCommand(["plugins", "update", "alpha"]); + + expect(runtimeErrors).toEqual([]); + const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); + expect(updateParams.config).toEqual({ + plugins: { + installs: { + alpha: indexedRecord, + legacy: legacyRecord, + }, + }, + }); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + nextInstallRecords, + ); + expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("blocks combined plugin and hook updates when either config section uses an include", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-")); + const configPath = path.join(tempRoot, "openclaw.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const pluginsRaw = "{}\n"; + fs.writeFileSync(pluginsPath, pluginsRaw); + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + }, + }, + }, + }, + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + installPath: "/tmp/alpha", + }, + }, + }, + } as OpenClawConfig; + primeUpdateConfigSnapshot({ + config: cfg, + configPath, + parsed: { + hooks: {}, + plugins: { $include: "./plugins.json5" }, + }, + includeFileHashesForWrite: { + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }, + includeFileTargetsForWrite: { + [pluginsPath]: fs.realpathSync(pluginsPath), + }, + }); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + + try { + await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1"); + expect(runtimeErrors.at(-1)).toContain( + "Config plugins and hooks cannot be updated together while either section uses a top-level $include", + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("exits when update is called without id and without --all", async () => { loadConfig.mockReturnValue({ plugins: { @@ -261,29 +1001,55 @@ describe("plugins cli update", () => { }, }, } as OpenClawConfig; - loadConfig.mockReturnValue(cfg); + const runtimeConfig = { + ...cfg, + messages: { + ackReactionScope: "group-mentions", + }, + } as OpenClawConfig; + const nextRuntimeConfig = { + ...nextConfig, + messages: runtimeConfig.messages, + } as OpenClawConfig; + primeUpdateConfigSnapshot({ + config: cfg, + runtimeConfig, + includeFileHashesForWrite: { + "/tmp/plugins.json5": "plugins-start-hash", + }, + }); setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); updateNpmInstalledPlugins.mockResolvedValue({ - outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], + outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" }], changed: true, - config: nextConfig, + config: nextRuntimeConfig, }); updateNpmInstalledHookPacks.mockResolvedValue({ outcomes: [], changed: false, - config: nextConfig, + config: nextRuntimeConfig, }); await runPluginsCommand(["plugins", "update", "alpha"]); const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); - expect(updateParams.config).toEqual(cfg); + expect(updateParams.config).toEqual(runtimeConfig); expect(updateParams.pluginIds).toEqual(["alpha"]); expect(updateParams.dryRun).toBe(false); expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( nextConfig.plugins?.installs, ); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); expect(writeConfigFile).toHaveBeenCalledWith({}); + expect(replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: {}, + baseHash: "update-config", + writeOptions: expect.objectContaining({ + includeFileHashesForWrite: { + "/tmp/plugins.json5": "plugins-start-hash", + }, + }), + }); expect(refreshPluginRegistry).toHaveBeenCalledWith({ config: {}, installRecords: nextConfig.plugins?.installs, diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 622fdb5456e..b29c4c693f7 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -3,10 +3,16 @@ import fs from "node:fs"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { theme } from "../../packages/terminal-core/src/theme.js"; -import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js"; +import { + assertConfigWriteAllowedInCurrentMode, + readConfigFileSnapshotForWrite, +} from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js"; +import { + installHooksFromNpmSpec, + installHooksFromPath, + type InstallHooksResult, +} from "../hooks/install.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -58,8 +64,21 @@ import { parseNpmPackPrefixPath, parseNpmPrefixSpec, } from "./plugins-command-helpers.js"; -import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; -import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js"; +import { + persistHookPackInstall, + persistPluginInstall, + resolveInstallConfigMutationPreflights, + selectInstallMutationWriteOptions, + supportsInstallConfigSingleTopLevelIncludeShape, + type ConfigMutationPreflight, + type ConfigSnapshotForInstallPersist, +} from "./plugins-install-persist.js"; +import { listPersistedBundledPluginRecoveryLocations } from "./plugins-location-bridges.js"; + +type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & { + hookMutation: ConfigMutationPreflight; + pluginMutation: ConfigMutationPreflight; +}; function resolveInstallMode(force?: boolean): "install" | "update" { return force ? "update" : "install"; @@ -73,6 +92,26 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta }; } +async function probeHookPackFromNpmSpec( + params: Parameters[0], +): Promise { + try { + return await installHooksFromNpmSpec(params); + } catch (error) { + return { ok: false, error: formatErrorMessage(error) }; + } +} + +async function probeHookPackFromPath( + params: Parameters[0], +): Promise { + try { + return await installHooksFromPath(params); + } catch (error) { + return { ok: false, error: formatErrorMessage(error) }; + } +} + const DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING = "--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; @@ -106,6 +145,31 @@ function isEmptyRecord(value: Record): boolean { return Object.keys(value).length === 0; } +function supportsPluginRecoveryIncludeShape(parsed: Record): boolean { + if (Object.hasOwn(parsed, "$include")) { + return false; + } + return supportsInstallConfigSingleTopLevelIncludeShape(parsed.plugins); +} + +function resolveFullyBlockedConfigMutationReason( + snapshot: ConfigSnapshotForInstallExecution, +): string | null { + if (snapshot.pluginMutation.mode !== "blocked" || snapshot.hookMutation.mode !== "blocked") { + return null; + } + if (snapshot.pluginMutation.reason === snapshot.hookMutation.reason) { + return snapshot.pluginMutation.reason; + } + return `Config plugin and hook mutations are both blocked. ${snapshot.pluginMutation.reason} ${snapshot.hookMutation.reason}`; +} + +function assertPluginConfigMutationAllowed(preflight: ConfigMutationPreflight): void { + if (preflight.mode === "blocked") { + throw buildInvalidPluginInstallConfigError(preflight.reason); + } +} + function hasValidBundledPluginConfig(params: { bundledSource: BundledPluginSource; existingEntry: unknown; @@ -147,7 +211,7 @@ function prepareConfigForDisabledBundledInstall( } async function installBundledPluginSource(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; rawSpec: string; bundledSource: BundledPluginSource; warning: string; @@ -168,8 +232,8 @@ async function installBundledPluginSource(params: { : `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`; await persistPluginInstall({ snapshot: { + ...params.snapshot, config: configBase, - baseHash: params.snapshot.baseHash, }, pluginId: params.bundledSource.pluginId, install: { @@ -186,13 +250,17 @@ async function installBundledPluginSource(params: { } async function tryInstallHookPackFromLocalPath(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; resolvedPath: string; installMode: "install" | "update"; safetyOverrides?: InstallSafetyOverrides; link?: boolean; + expectedPackageKind?: "hook-only"; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false; error: string }> { + if (params.snapshot.hookMutation.mode === "blocked") { + return { ok: false, error: params.snapshot.hookMutation.reason }; + } if (params.link) { const stat = fs.statSync(params.resolvedPath); if (!stat.isDirectory()) { @@ -206,6 +274,7 @@ async function tryInstallHookPackFromLocalPath(params: { ...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}), path: params.resolvedPath, dryRun: true, + ...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}), }); if (!probe.ok) { return probe; @@ -215,6 +284,7 @@ async function tryInstallHookPackFromLocalPath(params: { const merged = uniqueStrings([...existing, params.resolvedPath]); await persistHookPackInstall({ snapshot: { + ...params.snapshot, config: { ...params.snapshot.config, hooks: { @@ -229,7 +299,6 @@ async function tryInstallHookPackFromLocalPath(params: { }, }, }, - baseHash: params.snapshot.baseHash, }, hookPackId: probe.hookPackId, hooks: probe.hooks, @@ -249,6 +318,7 @@ async function tryInstallHookPackFromLocalPath(params: { ...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}), path: params.resolvedPath, mode: params.installMode, + ...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}), logger: createHookPackInstallLogger(params.runtime), }); if (!result.ok) { @@ -272,17 +342,23 @@ async function tryInstallHookPackFromLocalPath(params: { } async function tryInstallHookPackFromNpmSpec(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; installMode: "install" | "update"; spec: string; pin?: boolean; expectedIntegrity?: string; + expectedPackageKind?: "hook-only"; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false; error: string }> { + if (params.snapshot.hookMutation.mode === "blocked") { + return { ok: false, error: params.snapshot.hookMutation.reason }; + } const result = await installHooksFromNpmSpec({ + config: params.snapshot.config, spec: params.spec, mode: params.installMode, ...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}), + ...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}), logger: createHookPackInstallLogger(params.runtime), }); if (!result.ok) { @@ -309,7 +385,7 @@ async function tryInstallHookPackFromNpmSpec(params: { } async function tryInstallPluginOrHookPackFromNpmSpec(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; installMode: "install" | "update"; spec: string; pin?: boolean; @@ -322,6 +398,49 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { + const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(params.snapshot); + if (fullyBlockedReason) { + (params.runtime ?? defaultRuntime).error(fullyBlockedReason); + return { ok: false }; + } + if ( + params.snapshot.pluginMutation.mode === "blocked" || + params.snapshot.hookMutation.mode === "blocked" + ) { + const hookProbe = await probeHookPackFromNpmSpec({ + config: params.snapshot.config, + spec: params.spec, + mode: params.installMode, + inspection: "package-kind", + ...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}), + logger: createHookPackInstallLogger(params.runtime), + }); + if (hookProbe.ok && hookProbe.packageKind === "hook-only") { + if (params.snapshot.hookMutation.mode === "blocked") { + (params.runtime ?? defaultRuntime).error(params.snapshot.hookMutation.reason); + return { ok: false }; + } + const hookFallback = await tryInstallHookPackFromNpmSpec({ + snapshot: params.snapshot, + installMode: params.installMode, + spec: params.spec, + pin: params.pin, + expectedIntegrity: hookProbe.npmResolution?.integrity ?? params.expectedIntegrity, + expectedPackageKind: "hook-only", + runtime: params.runtime, + }); + if (hookFallback.ok) { + return { ok: true }; + } + (params.runtime ?? defaultRuntime).error(hookFallback.error); + return { ok: false }; + } + if (params.snapshot.pluginMutation.mode === "blocked") { + (params.runtime ?? defaultRuntime).error(params.snapshot.pluginMutation.reason); + return { ok: false }; + } + } + const result = await installPluginFromNpmSpec({ ...params.safetyOverrides, mode: params.installMode, @@ -394,7 +513,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { } async function tryInstallPluginFromNpmPackArchive(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; installMode: "install" | "update"; archivePath: string; safetyOverrides: InstallSafetyOverrides; @@ -444,7 +563,7 @@ async function tryInstallPluginFromNpmPackArchive(params: { } async function tryInstallPluginFromGitSpec(params: { - snapshot: ConfigSnapshotForInstallPersist; + snapshot: ConfigSnapshotForInstallExecution; installMode: "install" | "update"; spec: string; safetyOverrides: InstallSafetyOverrides; @@ -494,8 +613,7 @@ function isTerminalPluginInstallFailure(code?: string): boolean { function isAllowedPluginRecoveryIssue( issue: { path?: string; message?: string }, request: PluginInstallRequestContext, - installRecords: Record, - env: NodeJS.ProcessEnv = process.env, + ownedLoadPaths: ReadonlySet, ): boolean { const pluginId = request.bundledPluginId?.trim(); if (!pluginId) { @@ -504,9 +622,7 @@ function isAllowedPluginRecoveryIssue( return ( (issue.path === `channels.${pluginId}` && issue.message === `unknown channel id: ${pluginId}`) || - (issue.path === "plugins.load.paths" && - typeof issue.message === "string" && - isMissingPluginLoadPathForInstallRecord({ issue, installRecords, pluginId, env })) || + isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths) || (issue.path === `plugins.entries.${pluginId}` && typeof issue.message === "string" && issue.message.includes("requires compiled runtime output")) || @@ -522,21 +638,6 @@ function buildInvalidPluginInstallConfigError(message: string): Error { return error; } -function hasConfigInclude(value: unknown): boolean { - if (Array.isArray(value)) { - return value.some((child) => hasConfigInclude(child)); - } - if (!isRecord(value)) { - return false; - } - if (Object.hasOwn(value, "$include")) { - return true; - } - return Object.values(value).some((child) => hasConfigInclude(child)); -} - -const ENV_VAR_REFERENCE_RE = /\$\{[A-Z_][A-Z0-9_]*\}/; - function extractMissingPluginLoadPath(issue: { path?: string; message?: string }): string | null { if (issue.path !== "plugins.load.paths" || typeof issue.message !== "string") { return null; @@ -550,116 +651,68 @@ function extractMissingPluginLoadPath(issue: { path?: string; message?: string } return value || null; } -function resolvePluginInstallRecordPaths(params: { - installRecords: Record; - pluginId: string; - env: NodeJS.ProcessEnv; -}): Set { - const install = params.installRecords[params.pluginId]; +function collectRequestedPluginInstallPaths( + cfg: OpenClawConfig, + installRecords: Awaited>, + request: PluginInstallRequestContext, + env: NodeJS.ProcessEnv = process.env, +): Set { + const pluginId = request.bundledPluginId?.trim(); + if (!pluginId) { + return new Set(); + } const paths = new Set(); - for (const value of [install?.installPath, install?.sourcePath]) { + const record = installRecords[pluginId] ?? cfg.plugins?.installs?.[pluginId]; + for (const value of [record?.sourcePath, record?.installPath]) { if (typeof value === "string" && value.trim()) { - paths.add(resolveUserPath(value, params.env)); + paths.add(resolveUserPath(value, env)); } } return paths; } -function isMissingPluginLoadPathForInstallRecord(params: { - issue: { path?: string; message?: string }; - installRecords: Record; - pluginId: string; - env: NodeJS.ProcessEnv; -}): boolean { - const missingPath = extractMissingPluginLoadPath(params.issue); - if (!missingPath) { - return false; - } - return resolvePluginInstallRecordPaths(params).has(resolveUserPath(missingPath, params.env)); +function isOwnedMissingPluginLoadPathIssue( + issue: { path?: string; message?: string }, + ownedLoadPaths: ReadonlySet, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const missingPath = extractMissingPluginLoadPath(issue); + return missingPath !== null && ownedLoadPaths.has(resolveUserPath(missingPath, env)); } -function readPluginLoadPathEntries(cfg: unknown): unknown[] | undefined { - if (!isRecord(cfg) || !isRecord(cfg.plugins) || !isRecord(cfg.plugins.load)) { - return undefined; +async function collectRequestedPluginLocationBridgePaths( + request: PluginInstallRequestContext, + env: NodeJS.ProcessEnv, +): Promise> { + const pluginId = request.bundledPluginId?.trim(); + if (!pluginId) { + return new Set(); } - const paths = cfg.plugins.load.paths; - return Array.isArray(paths) ? paths : undefined; -} - -function arrayHasEnvRef(value: unknown): boolean { - return ( - Array.isArray(value) && - value.some((entry) => typeof entry === "string" && ENV_VAR_REFERENCE_RE.test(entry)) + const locations = await listPersistedBundledPluginRecoveryLocations({ env }); + return new Set( + locations + .filter((location) => location.pluginId === pluginId) + .flatMap((location) => location.loadPaths.map((loadPath) => resolveUserPath(loadPath, env))), ); } -function hasAuthoredPluginPolicyEnvRefs(params: { - authoredConfig: unknown; - resolvedConfig: OpenClawConfig; - pluginId: string; -}): boolean { - if (!isRecord(params.authoredConfig) || !isRecord(params.authoredConfig.plugins)) { - return false; - } - const resolvedPlugins = params.resolvedConfig.plugins; - const allowWillChange = - Array.isArray(resolvedPlugins?.allow) && - resolvedPlugins.allow.length > 0 && - !resolvedPlugins.allow.includes(params.pluginId); - if (allowWillChange && arrayHasEnvRef(params.authoredConfig.plugins.allow)) { - return true; - } - const denyWillChange = - Array.isArray(resolvedPlugins?.deny) && resolvedPlugins.deny.includes(params.pluginId); - return denyWillChange && arrayHasEnvRef(params.authoredConfig.plugins.deny); -} - -function wouldMoveAuthoredEnvPluginLoadPath(params: { - cfg: OpenClawConfig; - issues: readonly { path?: string; message?: string }[]; - authoredConfig: unknown; - env: NodeJS.ProcessEnv; -}): boolean { - const missingPaths = new Set( - params.issues - .map(extractMissingPluginLoadPath) - .filter((value): value is string => Boolean(value)) - .map((value) => resolveUserPath(value, params.env)), - ); - const paths = params.cfg.plugins?.load?.paths; - const authoredPaths = readPluginLoadPathEntries(params.authoredConfig); - if (missingPaths.size === 0 || !Array.isArray(paths) || !Array.isArray(authoredPaths)) { - return false; - } - let removedBefore = false; - for (const [index, entry] of paths.entries()) { - if (typeof entry === "string" && missingPaths.has(resolveUserPath(entry, params.env))) { - removedBefore = true; - continue; - } - const authoredEntry = authoredPaths[index]; - if ( - removedBefore && - typeof authoredEntry === "string" && - ENV_VAR_REFERENCE_RE.test(authoredEntry) - ) { - return true; - } - } - return false; -} - -function removeMissingPluginLoadPaths( +function removeOwnedMissingPluginLoadPaths( cfg: OpenClawConfig, issues: readonly { path?: string; message?: string }[], + ownedLoadPaths: ReadonlySet, env: NodeJS.ProcessEnv = process.env, ): OpenClawConfig { - const missingPaths = new Set( - issues - .map(extractMissingPluginLoadPath) - .filter((value): value is string => Boolean(value)) - .map((value) => resolveUserPath(value, env)), - ); + const missingPaths = new Set(); + for (const issue of issues) { + const missingPath = extractMissingPluginLoadPath(issue); + if (!missingPath) { + continue; + } + const resolved = resolveUserPath(missingPath, env); + if (ownedLoadPaths.has(resolved)) { + missingPaths.add(resolved); + } + } const paths = cfg.plugins?.load?.paths; if (missingPaths.size === 0 || !Array.isArray(paths)) { return cfg; @@ -682,10 +735,38 @@ function removeMissingPluginLoadPaths( }; } +async function resolveRequestedPluginInstallPaths( + cfg: OpenClawConfig, + issues: readonly { path?: string; message?: string }[], + request: PluginInstallRequestContext, + env: NodeJS.ProcessEnv = process.env, +): Promise> { + if (!issues.some((issue) => extractMissingPluginLoadPath(issue) !== null)) { + return new Set(); + } + const installRecords = await loadInstalledPluginIndexInstallRecords(); + const ownedLoadPaths = collectRequestedPluginInstallPaths(cfg, installRecords, request, env); + const stillNeedsLocationBridge = issues.some( + (issue) => + extractMissingPluginLoadPath(issue) !== null && + !isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths, env), + ); + if (stillNeedsLocationBridge) { + // The persisted bundled registry proves this plugin previously owned its + // removed core path; do not infer ownership from the requested id alone. + for (const loadPath of await collectRequestedPluginLocationBridgePaths(request, env)) { + ownedLoadPaths.add(loadPath); + } + } + return ownedLoadPaths; +} + async function loadConfigFromSnapshotForInstall( request: PluginInstallRequestContext, - snapshot: Awaited>, -): Promise { + prepared: Awaited>, +): Promise { + const { snapshot, writeOptions } = prepared; + const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions); if (resolvePluginInstallInvalidConfigPolicy(request) !== "allow-plugin-recovery") { throw buildInvalidPluginInstallConfigError( "Config invalid; run `openclaw doctor --fix` before installing plugins.", @@ -697,77 +778,77 @@ async function loadConfigFromSnapshotForInstall( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); } - const pluginId = request.bundledPluginId?.trim() ?? ""; - const pluginLabel = pluginId || "the requested plugin"; - if (hasConfigInclude(snapshot.parsed)) { - throw buildInvalidPluginInstallConfigError( - `Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`, - ); - } - if ( - hasAuthoredPluginPolicyEnvRefs({ - authoredConfig: snapshot.parsed, - resolvedConfig: snapshot.config, - pluginId, - }) - ) { - throw buildInvalidPluginInstallConfigError( - `Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`, - ); - } - const persistedInstallRecords = await tracePluginLifecyclePhaseAsync( - "install records load", - () => loadInstalledPluginIndexInstallRecords(), - { command: "install" }, + const ownedLoadPaths = await resolveRequestedPluginInstallPaths( + snapshot.config, + snapshot.issues, + request, + process.env, ); - const installRecords = { - ...snapshot.config.plugins?.installs, - ...persistedInstallRecords, - }; if ( snapshot.legacyIssues.length > 0 || snapshot.issues.length === 0 || - snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, installRecords)) + snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, ownedLoadPaths)) ) { + const pluginLabel = request.bundledPluginId ?? "the requested plugin"; throw buildInvalidPluginInstallConfigError( `Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`, ); } - let nextConfig = snapshot.config; - if ( - wouldMoveAuthoredEnvPluginLoadPath({ - cfg: nextConfig, - issues: snapshot.issues, - authoredConfig: snapshot.parsed, - env: process.env, - }) - ) { + if (!supportsPluginRecoveryIncludeShape(parsed)) { throw buildInvalidPluginInstallConfigError( - `Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`, + "Config plugin recovery uses an unsupported $include shape; use a single-file top-level plugins include or run `openclaw doctor --fix` before reinstalling it.", ); } - nextConfig = removeMissingPluginLoadPaths(nextConfig, snapshot.issues, process.env); + const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({ + parsed, + snapshotPath: snapshot.path, + writeOptions: mutationWriteOptions, + }); + assertPluginConfigMutationAllowed(pluginMutation); + const nextConfig = removeOwnedMissingPluginLoadPaths( + snapshot.config, + snapshot.issues, + ownedLoadPaths, + process.env, + ); return { config: nextConfig, baseHash: snapshot.hash, + writeOptions: mutationWriteOptions, + hookMutation, + pluginMutation, }; } export async function loadConfigForInstall( request: PluginInstallRequestContext, -): Promise { - const snapshot = await tracePluginLifecyclePhaseAsync( +): Promise { + const prepared = await tracePluginLifecyclePhaseAsync( "config read", - () => readConfigFileSnapshot(), + () => readConfigFileSnapshotForWrite(), { command: "install" }, ); + const { snapshot, writeOptions } = prepared; + const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions); if (snapshot.valid) { + const parsed = (snapshot.parsed ?? {}) as Record; + const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({ + parsed, + snapshotPath: snapshot.path, + writeOptions: mutationWriteOptions, + }); + if (request.installKind === "plugin") { + assertPluginConfigMutationAllowed(pluginMutation); + } return { config: snapshot.sourceConfig, baseHash: snapshot.hash, + writeOptions: mutationWriteOptions, + hookMutation, + pluginMutation, }; } - return loadConfigFromSnapshotForInstall(request, snapshot); + return loadConfigFromSnapshotForInstall(request, prepared); } export async function runPluginInstallCommand(params: { @@ -846,6 +927,8 @@ export async function runPluginInstallCommand(params: { ); return runtime.exit(1); } + const npmPackPath = parseNpmPackPrefixPath(raw); + const clawhubSpec = parseClawHubPluginSpec(raw); const requestResolution = resolvePluginInstallRequestContext({ rawSpec: raw, marketplace: opts.marketplace, @@ -854,7 +937,41 @@ export async function runPluginInstallCommand(params: { runtime.error(requestResolution.error); return runtime.exit(1); } - const request = requestResolution.request; + let request = requestResolution.request; + const resolved = request.resolvedPath ?? request.normalizedSpec; + const resolvesToLocalPath = fs.existsSync(resolved); + if (!resolvesToLocalPath && (gitSpec || npmPackPath !== null || clawhubSpec)) { + request = { ...request, installKind: "plugin" }; + } + const bundledPreNpmPlan = resolvesToLocalPath + ? null + : resolveBundledInstallPlanBeforeNpm({ + rawSpec: raw, + findBundledSource: (lookup) => findBundledPluginSource({ lookup }), + }); + const officialExternalPlan = resolvesToLocalPath + ? null + : resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: raw, + findOfficialExternalPlugin: (pluginId) => { + const entry = getOfficialExternalPluginCatalogEntry(pluginId); + const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined; + const install = entry ? resolveOfficialExternalPluginInstall(entry) : null; + const npmSpec = install?.npmSpec; + return resolvedPluginId && npmSpec + ? { + pluginId: resolvedPluginId, + npmSpec, + ...(install.expectedIntegrity + ? { expectedIntegrity: install.expectedIntegrity } + : {}), + } + : undefined; + }, + }); + if (bundledPreNpmPlan || officialExternalPlan) { + request = { ...request, installKind: "plugin" }; + } const snapshot = await loadConfigForInstall(request).catch((error: unknown) => { runtime.error(formatErrorMessage(error)); return null; @@ -898,8 +1015,44 @@ export async function runPluginInstallCommand(params: { return; } - const resolved = request.resolvedPath ?? request.normalizedSpec; if (fs.existsSync(resolved)) { + const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot); + if (fullyBlockedReason) { + runtime.error(fullyBlockedReason); + return runtime.exit(1); + } + if (snapshot.pluginMutation.mode === "blocked" || snapshot.hookMutation.mode === "blocked") { + const hookProbe = await probeHookPackFromPath({ + ...safetyOverrides, + path: resolved, + mode: installMode, + inspection: "package-kind", + }); + if (hookProbe.ok && hookProbe.packageKind === "hook-only") { + if (snapshot.hookMutation.mode === "blocked") { + runtime.error(snapshot.hookMutation.reason); + return runtime.exit(1); + } + const hookFallback = await tryInstallHookPackFromLocalPath({ + snapshot, + installMode, + resolvedPath: resolved, + safetyOverrides, + ...(opts.link ? { link: true } : {}), + expectedPackageKind: "hook-only", + runtime, + }); + if (hookFallback.ok) { + return; + } + runtime.error(hookFallback.error); + return runtime.exit(1); + } + if (snapshot.pluginMutation.mode === "blocked") { + runtime.error(snapshot.pluginMutation.reason); + return runtime.exit(1); + } + } if (opts.link) { const existing = cfg.plugins?.load?.paths ?? []; const merged = uniqueStrings([...existing, resolved]); @@ -934,6 +1087,7 @@ export async function runPluginInstallCommand(params: { await persistPluginInstall({ snapshot: { + ...snapshot, config: { ...cfg, plugins: { @@ -944,7 +1098,6 @@ export async function runPluginInstallCommand(params: { }, }, }, - baseHash: snapshot.baseHash, }, pluginId: probe.pluginId, install: { @@ -1047,7 +1200,6 @@ export async function runPluginInstallCommand(params: { return; } - const npmPackPath = parseNpmPackPrefixPath(raw); if (npmPackPath !== null) { if (!npmPackPath) { runtime.error( @@ -1104,10 +1256,6 @@ export async function runPluginInstallCommand(params: { return runtime.exit(1); } - const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ - rawSpec: raw, - findBundledSource: (lookup) => findBundledPluginSource({ lookup }), - }); if (bundledPreNpmPlan) { await tracePluginLifecyclePhaseAsync( "install execution", @@ -1129,22 +1277,6 @@ export async function runPluginInstallCommand(params: { return; } - const officialExternalPlan = resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: raw, - findOfficialExternalPlugin: (pluginId) => { - const entry = getOfficialExternalPluginCatalogEntry(pluginId); - const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined; - const install = entry ? resolveOfficialExternalPluginInstall(entry) : null; - const npmSpec = install?.npmSpec; - return resolvedPluginId && npmSpec - ? { - pluginId: resolvedPluginId, - npmSpec, - ...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), - } - : undefined; - }, - }); if (officialExternalPlan) { const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, @@ -1166,7 +1298,6 @@ export async function runPluginInstallCommand(params: { return; } - const clawhubSpec = parseClawHubPluginSpec(raw); if (clawhubSpec) { const result = await installPluginFromClawHub({ ...safetyOverrides, diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index 3be89a515f8..b3d8d00986d 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -1,8 +1,12 @@ // Plugin install config tests cover install specs and generated plugin config. import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { bundledPluginRootAt, repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { hashConfigIncludeRaw } from "../config/includes.js"; +import type { ConfigWriteOptions } from "../config/io.js"; import type { ConfigFileSnapshot } from "../config/types.openclaw.js"; import { resolvePluginInstallRequestContext, @@ -11,24 +15,35 @@ import { import { loadConfigForInstall } from "./plugins-install-command.js"; const hoisted = vi.hoisted(() => ({ + assertConfigPathForWriteMock: vi.fn(), + includeFileHashesForWriteMock: vi.fn<() => Record>(), + includeFileTargetsForWriteMock: vi.fn<() => Record>(), readConfigFileSnapshotMock: vi.fn<() => Promise>(), - collectChannelDoctorStaleConfigMutationsMock: vi.fn(), loadInstalledPluginIndexInstallRecordsMock: vi.fn(), + listPersistedBundledPluginRecoveryLocationsMock: vi.fn(), })); const readConfigFileSnapshotMock = hoisted.readConfigFileSnapshotMock; -const collectChannelDoctorStaleConfigMutationsMock = - hoisted.collectChannelDoctorStaleConfigMutationsMock; +const assertConfigPathForWriteMock = hoisted.assertConfigPathForWriteMock; +const includeFileHashesForWriteMock = hoisted.includeFileHashesForWriteMock; +const includeFileTargetsForWriteMock = hoisted.includeFileTargetsForWriteMock; const loadInstalledPluginIndexInstallRecordsMock = hoisted.loadInstalledPluginIndexInstallRecordsMock; +const listPersistedBundledPluginRecoveryLocationsMock = + hoisted.listPersistedBundledPluginRecoveryLocationsMock; vi.mock("../config/config.js", () => ({ - readConfigFileSnapshot: () => readConfigFileSnapshotMock(), -})); - -vi.mock("../commands/doctor/shared/channel-doctor.js", () => ({ - collectChannelDoctorStaleConfigMutations: (cfg: OpenClawConfig) => - collectChannelDoctorStaleConfigMutationsMock(cfg), + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await readConfigFileSnapshotMock(), + writeOptions: { + assertConfigPathForWrite: assertConfigPathForWriteMock, + basePluginMetadataSnapshot: {} as never, + expectedConfigPath: "/tmp/config.json5", + ownedConfigPathForWrite: "/tmp/config.json5", + includeFileHashesForWrite: includeFileHashesForWriteMock(), + includeFileTargetsForWrite: includeFileTargetsForWriteMock(), + }, + }), })); vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { @@ -40,7 +55,19 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) = }; }); +vi.mock("./plugins-location-bridges.js", () => ({ + listPersistedBundledPluginRecoveryLocations: () => + listPersistedBundledPluginRecoveryLocationsMock(), +})); + const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord"); +const installWriteOptions = { + assertConfigPathForWrite: assertConfigPathForWriteMock, + expectedConfigPath: "/tmp/config.json5", + ownedConfigPathForWrite: "/tmp/config.json5", + includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" }, + includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" }, +} satisfies ConfigWriteOptions; function makeSnapshot(overrides: Partial = {}): ConfigFileSnapshot { return { @@ -65,24 +92,26 @@ describe("loadConfigForInstall", () => { const discordNpmRequest = { rawSpec: "@openclaw/discord", normalizedSpec: "@openclaw/discord", + installKind: "plugin", bundledPluginId: "discord", allowInvalidConfigRecovery: true, } satisfies PluginInstallRequestContext; beforeEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); readConfigFileSnapshotMock.mockReset(); - collectChannelDoctorStaleConfigMutationsMock.mockReset(); + includeFileHashesForWriteMock.mockReset(); + includeFileTargetsForWriteMock.mockReset(); loadInstalledPluginIndexInstallRecordsMock.mockReset(); + listPersistedBundledPluginRecoveryLocationsMock.mockReset(); loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({}); - collectChannelDoctorStaleConfigMutationsMock.mockImplementation(async (cfg: OpenClawConfig) => [ - { - config: cfg, - changes: [], - }, - ]); + includeFileHashesForWriteMock.mockReturnValue({ + "/tmp/plugins.json5": "include-1", + }); + includeFileTargetsForWriteMock.mockReturnValue({ + "/tmp/plugins.json5": "/tmp/plugins.json5", + }); + listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([]); }); it("returns the source config and base hash when the snapshot is valid", async () => { @@ -98,10 +127,16 @@ describe("loadConfigForInstall", () => { ); const result = await loadConfigForInstall(discordNpmRequest); - expect(result).toEqual({ config: cfg, baseHash: "config-1" }); + expect(result).toEqual({ + config: cfg, + baseHash: "config-1", + writeOptions: installWriteOptions, + hookMutation: { mode: "allowed" }, + pluginMutation: { mode: "allowed" }, + }); }); - it("does not run stale Discord cleanup on the happy path", async () => { + it("returns valid source config unchanged", async () => { const cfg = { plugins: {} } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ @@ -113,7 +148,6 @@ describe("loadConfigForInstall", () => { ); const result = await loadConfigForInstall(discordNpmRequest); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); expect(result.config).toBe(cfg); }); @@ -134,11 +168,16 @@ describe("loadConfigForInstall", () => { const result = await loadConfigForInstall(discordNpmRequest); expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); + expect(result).toEqual({ + config: snapshotCfg, + baseHash: "abc", + writeOptions: installWriteOptions, + hookMutation: { mode: "allowed" }, + pluginMutation: { mode: "allowed" }, + }); }); - it("allows npm:-prefixed bundled-plugin reinstall recovery", async () => { + it("allows versioned npm:-prefixed bundled-plugin reinstall recovery", async () => { const snapshotCfg = { plugins: { installs: { discord: { source: "path", installPath: "/gone" } } }, } as unknown as OpenClawConfig; @@ -154,7 +193,7 @@ describe("loadConfigForInstall", () => { ); const request = resolvePluginInstallRequestContext({ - rawSpec: "npm:@openclaw/discord", + rawSpec: "npm:@openclaw/discord@2026.5.22", }); if (!request.ok) { throw new Error(request.error); @@ -163,8 +202,38 @@ describe("loadConfigForInstall", () => { expect(request.request.bundledPluginId).toBe("discord"); expect(request.request.allowInvalidConfigRecovery).toBe(true); const result = await loadConfigForInstall(request.request); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); + expect(result).toEqual({ + config: snapshotCfg, + baseHash: "abc", + writeOptions: installWriteOptions, + hookMutation: { mode: "allowed" }, + pluginMutation: { mode: "allowed" }, + }); + }); + + it.each(["file:@openclaw/discord", "FILE:@openclaw/discord"])( + "does not treat %s as an official plugin recovery request", + (rawSpec) => { + const request = resolvePluginInstallRequestContext({ rawSpec }); + if (!request.ok) { + throw new Error(request.error); + } + + expect(request.request.bundledPluginId).toBeUndefined(); + expect(request.request.allowInvalidConfigRecovery).toBeUndefined(); + }, + ); + + it("preserves a caller-proven plugin-only install kind", () => { + const request = resolvePluginInstallRequestContext({ + rawSpec: "clawhub:demo", + installKind: "plugin", + }); + if (!request.ok) { + throw new Error(request.error); + } + + expect(request.request.installKind).toBe("plugin"); }); it("allows versioned official npm spec reinstall recovery", async () => { @@ -173,6 +242,7 @@ describe("loadConfigForInstall", () => { installs: { discord: { source: "npm", installPath: "/gone" } }, load: { paths: ["/gone", "/keep"] }, }, + channels: { discord: { token: "preserve-me" } }, } as unknown as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ @@ -195,92 +265,28 @@ describe("loadConfigForInstall", () => { expect(request.request.bundledPluginId).toBe("discord"); expect(request.request.allowInvalidConfigRecovery).toBe(true); const result = await loadConfigForInstall(request.request); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); expect(result).toEqual({ config: { plugins: { installs: { discord: { source: "npm", installPath: "/gone" } }, load: { paths: ["/keep"] }, }, + channels: { discord: { token: "preserve-me" } }, }, baseHash: "abc", + writeOptions: installWriteOptions, + hookMutation: { mode: "allowed" }, + pluginMutation: { mode: "allowed" }, }); }); - it("does not classify file-normalized versioned paths as official recovery specs", () => { - const request = resolvePluginInstallRequestContext({ - rawSpec: "file:@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - expect(request.request.allowInvalidConfigRecovery).toBeUndefined(); - expect(request.request.bundledPluginId).toBeUndefined(); - }); - - it("does not classify registry-like local paths as official recovery specs", () => { - const existsSync = fs.existsSync; - vi.spyOn(fs, "existsSync").mockImplementation((candidate) => - String(candidate).endsWith("@openclaw/discord@2026.5.22") ? true : existsSync(candidate), - ); - - const request = resolvePluginInstallRequestContext({ - rawSpec: "@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - expect(request.request.allowInvalidConfigRecovery).toBeUndefined(); - expect(request.request.bundledPluginId).toBeUndefined(); - }); - - it("rejects recovery when removing a stale path would shift authored env refs", async () => { - vi.stubEnv("OPENCLAW_TEST_PLUGIN_DIR", "/custom/plugin"); - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - load: { paths: ["/gone", "/custom/plugin"] }, - }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { - plugins: { - installs: { discord: {} }, - load: { paths: ["/gone", "${OPENCLAW_TEST_PLUGIN_DIR}"] }, - }, - }, - sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], - config: snapshotCfg, - issues: [ - { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, - ], - }), - ); - - const request = resolvePluginInstallRequestContext({ - rawSpec: "@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - await expect(loadConfigForInstall(request.request)).rejects.toThrow( - "Config invalid outside the plugin recovery path for discord", - ); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - }); - - it("matches missing load paths against persisted install records", async () => { - loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({ - discord: { source: "npm", installPath: "/gone" }, - }); + it("uses the canonical plugin install record to own a stale recovery load path", async () => { const snapshotCfg = { plugins: { load: { paths: ["/gone", "/keep"] } }, } as unknown as OpenClawConfig; + loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({ + discord: { source: "npm", installPath: "/gone" }, + }); readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ parsed: { plugins: { load: { paths: ["/gone", "/keep"] } } }, @@ -292,125 +298,130 @@ describe("loadConfigForInstall", () => { }), ); - const request = resolvePluginInstallRequestContext({ - rawSpec: "@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - const result = await loadConfigForInstall(request.request); - expect(result.config).toEqual({ - plugins: { load: { paths: ["/keep"] } }, - }); + const result = await loadConfigForInstall(discordNpmRequest); + expect(result.config.plugins?.load?.paths).toEqual(["/keep"]); }); - it("prefers persisted install records over stale legacy config install records", async () => { + it("does not let a stale legacy install record override the canonical record", async () => { + const snapshotCfg = { + plugins: { + installs: { discord: { source: "npm", installPath: "/gone" } }, + load: { paths: ["/gone"] }, + }, + } as unknown as OpenClawConfig; loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({ discord: { source: "npm", installPath: "/canonical" }, }); - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/legacy" } }, - load: { paths: ["/canonical", "/legacy"] }, - }, - } as unknown as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ parsed: { plugins: { - installs: { discord: {} }, - load: { paths: ["/canonical", "/legacy"] }, + installs: { discord: { source: "npm", installPath: "/gone" } }, + load: { paths: ["/gone"] }, }, }, config: snapshotCfg, issues: [ { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /canonical" }, + { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, ], }), ); - const request = resolvePluginInstallRequestContext({ - rawSpec: "@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - const result = await loadConfigForInstall(request.request); - expect(result.config).toEqual({ - plugins: { - installs: { discord: { source: "npm", installPath: "/legacy" } }, - load: { paths: ["/legacy"] }, - }, - }); + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config invalid outside the plugin recovery path for discord", + ); }); - it("rejects recovery when a missing plugin load path is unrelated to the requested plugin", async () => { + it("uses a persisted externalization bridge to own a stale bundled load path", async () => { + const staleBundledPath = "/app/extensions/discord"; + const snapshotCfg = { + plugins: { load: { paths: [staleBundledPath, "/keep"] } }, + } as unknown as OpenClawConfig; + listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([ + { + pluginId: "discord", + loadPaths: [staleBundledPath], + }, + ]); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + parsed: { plugins: { load: { paths: [staleBundledPath, "/keep"] } } }, + config: snapshotCfg, + issues: [ + { path: "channels.discord", message: "unknown channel id: discord" }, + { + path: "plugins.load.paths", + message: `plugin: plugin path not found: ${staleBundledPath}`, + }, + ], + }), + ); + + const result = await loadConfigForInstall(discordNpmRequest); + expect(result.config.plugins?.load?.paths).toEqual(["/keep"]); + }); + + it("rejects recovery rather than removing another plugin's missing load path", async () => { + const operatorCheckoutPath = "/workspace/extensions/discord"; const snapshotCfg = { plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - load: { paths: ["/gone", "/other"] }, + load: { paths: [operatorCheckoutPath] }, }, } as unknown as OpenClawConfig; + listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([ + { + pluginId: "discord", + loadPaths: ["/app/extensions/discord"], + }, + ]); readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ parsed: { plugins: { - installs: { discord: {} }, - load: { paths: ["/gone", "/other"] }, + load: { paths: [operatorCheckoutPath] }, }, }, config: snapshotCfg, - issues: [{ path: "plugins.load.paths", message: "plugin: plugin path not found: /other" }], - }), - ); - - const request = resolvePluginInstallRequestContext({ - rawSpec: "@openclaw/discord@2026.5.22", - }); - if (!request.ok) { - throw new Error(request.error); - } - - await expect(loadConfigForInstall(request.request)).rejects.toThrow( - "Config invalid outside the plugin recovery path for discord", - ); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - }); - - it("allows official plugin reinstall recovery from source-only runtime shadows", async () => { - const snapshotCfg = { - plugins: { installs: { discord: { source: "npm", installPath: "/bad/discord" } } }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { plugins: { installs: { discord: {} } } }, - config: snapshotCfg, issues: [ + { path: "channels.discord", message: "unknown channel id: discord" }, { - path: "plugins.entries.discord", - message: - "plugin discord: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, index.js, index.mjs, index.cjs. This is a plugin packaging issue, not a local config problem; update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then. TypeScript source fallback is only supported for source checkouts and local development paths.", + path: "plugins.load.paths", + message: `plugin: plugin path not found: ${operatorCheckoutPath}`, }, ], }), ); - const request = resolvePluginInstallRequestContext({ - rawSpec: "npm:@openclaw/discord", - }); - if (!request.ok) { - throw new Error(request.error); - } - - const result = await loadConfigForInstall(request.request); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config invalid outside the plugin recovery path for discord", + ); }); - it("rejects unattributed compiled-runtime recovery issues", async () => { + it("rejects malformed install record paths without crashing recovery", async () => { + const snapshotCfg = { + plugins: { + installs: { discord: { source: "npm", installPath: 1 } }, + load: { paths: ["/gone"] }, + }, + } as unknown as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + parsed: { plugins: { installs: { discord: {} }, load: { paths: ["/gone"] } } }, + config: snapshotCfg, + issues: [ + { path: "channels.discord", message: "unknown channel id: discord" }, + { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, + ], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config invalid outside the plugin recovery path for discord", + ); + }); + + it("rejects unattributed source-only runtime failures during official plugin recovery", async () => { const snapshotCfg = { plugins: { installs: { discord: { source: "npm", installPath: "/bad/discord" } } }, } as unknown as OpenClawConfig; @@ -422,7 +433,7 @@ describe("loadConfigForInstall", () => { { path: "plugins", message: - "plugin: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.", + "plugin: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, index.js, index.mjs, index.cjs. This is a plugin packaging issue, not a local config problem; update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then. TypeScript source fallback is only supported for source checkouts and local development paths.", }, ], }), @@ -472,8 +483,13 @@ describe("loadConfigForInstall", () => { expect(request.request.allowInvalidConfigRecovery).toBe(true); const result = await loadConfigForInstall(request.request); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); + expect(result).toEqual({ + config: snapshotCfg, + baseHash: "abc", + writeOptions: installWriteOptions, + hookMutation: { mode: "allowed" }, + pluginMutation: { mode: "allowed" }, + }); }); it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => { @@ -499,6 +515,405 @@ describe("loadConfigForInstall", () => { expect(result.config).toBe(snapshotCfg); }); + it("allows recovery through an exact single-file top-level plugins include", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-include-")); + const configPath = path.join(tempRoot, "config.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const pluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + fs.writeFileSync(pluginsPath, pluginsRaw); + includeFileHashesForWriteMock.mockReturnValue({ + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }); + includeFileTargetsForWriteMock.mockReturnValue({ + [pluginsPath]: fs.realpathSync(pluginsPath), + }); + const snapshotCfg = { plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + path: configPath, + parsed: { plugins: { $include: "./plugins.json5" } }, + config: snapshotCfg, + issues: [{ path: "channels.discord", message: "unknown channel id: discord" }], + }), + ); + + try { + const result = await loadConfigForInstall(discordNpmRequest); + expect(result.config).toBe(snapshotCfg); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects recovery installs through an external plugins include", async () => { + const externalPluginsPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + const snapshotCfg = { plugins: {} } as OpenClawConfig; + includeFileTargetsForWriteMock.mockReturnValue({ + [externalPluginsPath]: externalPluginsPath, + }); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + parsed: { plugins: { $include: externalPluginsPath } }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [{ path: "channels.discord", message: "unknown channel id: discord" }], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("rejects external include aliases even when their target is under the config directory", async () => { + const configPath = path.join(process.cwd(), "config.json5"); + const externalPluginsPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + includeFileTargetsForWriteMock.mockReturnValue({ + [externalPluginsPath]: path.join(fs.realpathSync(process.cwd()), "plugins.json5"), + }); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + path: configPath, + parsed: { plugins: { $include: externalPluginsPath } }, + config: { plugins: {} } as OpenClawConfig, + issues: [{ path: "channels.discord", message: "unknown channel id: discord" }], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config plugins are stored in an external or unresolved top-level $include", + ); + }); + + it("carries a plugin-mutation block for ambiguous installs through external plugin includes", async () => { + const externalPluginsPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + const snapshotCfg = { plugins: {} } as OpenClawConfig; + includeFileTargetsForWriteMock.mockReturnValue({ + [externalPluginsPath]: externalPluginsPath, + }); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + parsed: { plugins: { $include: externalPluginsPath } }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + const result = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + + expect(result.config).toBe(snapshotCfg); + expect(result.pluginMutation).toEqual({ + mode: "blocked", + scope: "plugins", + reason: expect.stringContaining("external or unresolved top-level $include"), + }); + }); + + it("blocks known plugins through external includes", async () => { + const externalPluginsPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "plugins.json5", + ); + const snapshotCfg = { plugins: {} } as OpenClawConfig; + includeFileTargetsForWriteMock.mockReturnValue({ + [externalPluginsPath]: externalPluginsPath, + }); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + parsed: { plugins: { $include: externalPluginsPath } }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "external or unresolved top-level $include", + ); + }); + + it("carries a hook-mutation block through an external hooks include", async () => { + const externalHooksPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "hooks.json5", + ); + const snapshotCfg = { hooks: { internal: {} } } as OpenClawConfig; + includeFileTargetsForWriteMock.mockReturnValue({ + [externalHooksPath]: externalHooksPath, + }); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + parsed: { hooks: { $include: externalHooksPath } }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + const result = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + + expect(result.hookMutation).toEqual({ + mode: "blocked", + scope: "hooks", + reason: expect.stringContaining("external or unresolved top-level $include"), + }); + expect(result.pluginMutation).toEqual({ mode: "allowed" }); + }); + + it("blocks config mutations when plugins and hooks share one canonical include target", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shared-include-")); + const configPath = path.join(tempRoot, "config.json5"); + const sharedPath = path.join(tempRoot, "shared.json5"); + const sharedRaw = "{}\n"; + fs.writeFileSync(sharedPath, sharedRaw); + includeFileHashesForWriteMock.mockReturnValue({ + [sharedPath]: hashConfigIncludeRaw(sharedRaw), + }); + includeFileTargetsForWriteMock.mockReturnValue({ + [sharedPath]: fs.realpathSync(sharedPath), + }); + const snapshotCfg = { hooks: {}, plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + path: configPath, + valid: true, + parsed: { + hooks: { $include: "./shared.json5" }, + plugins: { $include: "./shared.json5" }, + }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + try { + const result = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + expect(result.hookMutation).toEqual({ + mode: "blocked", + scope: "config", + reason: expect.stringContaining("share the same top-level $include target"), + }); + expect(result.pluginMutation).toEqual(result.hookMutation); + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "share the same top-level $include target", + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("blocks both mutations when an external include aliases the other section target", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-aliased-include-")); + const configPath = path.join(tempRoot, "config.json5"); + const sharedPath = path.join(tempRoot, "shared.json5"); + const externalHooksPath = path.join( + path.parse(process.cwd()).root, + "external-openclaw", + "hooks.json5", + ); + const sharedRaw = "{}\n"; + fs.writeFileSync(sharedPath, sharedRaw); + includeFileHashesForWriteMock.mockReturnValue({ + [sharedPath]: hashConfigIncludeRaw(sharedRaw), + }); + includeFileTargetsForWriteMock.mockReturnValue({ + [sharedPath]: fs.realpathSync(sharedPath), + [externalHooksPath]: fs.realpathSync(sharedPath), + }); + const snapshotCfg = { hooks: {}, plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + path: configPath, + valid: true, + parsed: { + hooks: { $include: externalHooksPath }, + plugins: { $include: "./shared.json5" }, + }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + try { + const result = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + expect(result.hookMutation).toEqual({ + mode: "blocked", + scope: "config", + reason: expect.stringContaining("share the same top-level $include target"), + }); + expect(result.pluginMutation).toEqual(result.hookMutation); + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "share the same top-level $include target", + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("blocks nested plugins includes before plugin installation", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-nested-include-")); + const configPath = path.join(tempRoot, "config.json5"); + const pluginsPath = path.join(tempRoot, "plugins.json5"); + const pluginsRaw = `${JSON.stringify({ entries: { $include: "./entries.json5" } }, null, 2)}\n`; + fs.writeFileSync(pluginsPath, pluginsRaw); + includeFileHashesForWriteMock.mockReturnValue({ + [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), + }); + includeFileTargetsForWriteMock.mockReturnValue({ + [pluginsPath]: fs.realpathSync(pluginsPath), + }); + const snapshotCfg = { plugins: { entries: {} } } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + path: configPath, + valid: true, + parsed: { plugins: { $include: "./plugins.json5" } }, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + try { + const ambiguousResult = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + expect(ambiguousResult.pluginMutation).toEqual({ + mode: "blocked", + scope: "plugins", + reason: expect.stringContaining("nested $include"), + }); + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow("nested $include"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + const unsupportedPluginIncludeShapes = [ + { + label: "plugins include array", + parsed: { plugins: { $include: ["./plugins-a.json5", "./plugins-b.json5"] } }, + scope: "plugins", + }, + { + label: "plugins include with siblings", + parsed: { plugins: { $include: "./plugins.json5", entries: {} } }, + scope: "plugins", + }, + { + label: "nested plugins include", + parsed: { plugins: { entries: { $include: "./entries.json5" } } }, + scope: "plugins", + }, + { + label: "root include without authored plugins", + parsed: { $include: "./root.json5" }, + scope: "config", + }, + { + label: "root include with authored plugins", + parsed: { $include: "./root.json5", plugins: { entries: {} } }, + scope: "config", + }, + ] as const; + + it.each(unsupportedPluginIncludeShapes)( + "rejects recovery through an unsupported $label", + async ({ parsed }) => { + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + parsed, + config: { plugins: {} } as OpenClawConfig, + issues: [{ path: "channels.discord", message: "unknown channel id: discord" }], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config plugin recovery uses an unsupported $include shape", + ); + }, + ); + + it.each(unsupportedPluginIncludeShapes)( + "marks valid ambiguous installs through an unsupported $label as plugin-blocked", + async ({ parsed, scope }) => { + const snapshotCfg = { plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + parsed, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + const result = await loadConfigForInstall({ + rawSpec: "maybe-hook-pack", + normalizedSpec: "maybe-hook-pack", + }); + + expect(result.pluginMutation).toEqual({ + mode: "blocked", + scope, + reason: expect.stringContaining("unsupported $include shape"), + }); + }, + ); + + it.each(unsupportedPluginIncludeShapes)( + "blocks valid known plugins through an unsupported $label", + async ({ parsed }) => { + const snapshotCfg = { plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + parsed, + sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"], + config: snapshotCfg, + issues: [], + }), + ); + + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "unsupported $include shape", + ); + }, + ); + it("rejects unrelated invalid config even during bundled-plugin reinstall recovery", async () => { readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ @@ -511,123 +926,6 @@ describe("loadConfigForInstall", () => { ); }); - it("rejects include-backed invalid config instead of flattening it during recovery", async () => { - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - load: { paths: ["/gone"] }, - }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { $include: "./plugins.json" }, - config: snapshotCfg, - issues: [ - { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, - ], - }), - ); - - await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( - "Config invalid outside the plugin recovery path for discord", - ); - expect(loadInstalledPluginIndexInstallRecordsMock).not.toHaveBeenCalled(); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - }); - - it("rejects nested include-backed invalid config instead of flattening it during recovery", async () => { - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - load: { paths: ["/gone"] }, - }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { agents: { list: [{ $include: "./agent.json5" }] } }, - config: snapshotCfg, - issues: [ - { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, - ], - }), - ); - - await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( - "Config invalid outside the plugin recovery path for discord", - ); - expect(loadInstalledPluginIndexInstallRecordsMock).not.toHaveBeenCalled(); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - }); - - it("rejects recovery when install policy arrays contain authored env refs", async () => { - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - deny: ["discord", "keep"], - load: { paths: ["/gone"] }, - }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { - plugins: { - installs: { discord: {} }, - deny: ["discord", "${KEEP_PLUGIN}"], - load: { paths: ["/gone"] }, - }, - }, - config: snapshotCfg, - issues: [ - { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, - ], - }), - ); - - await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( - "Config invalid outside the plugin recovery path for discord", - ); - expect(loadInstalledPluginIndexInstallRecordsMock).not.toHaveBeenCalled(); - expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - }); - - it("allows recovery when env-backed allow policy already includes the requested plugin", async () => { - const snapshotCfg = { - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - allow: ["discord", "keep"], - load: { paths: ["/gone"] }, - }, - } as unknown as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValue( - makeSnapshot({ - parsed: { - plugins: { - installs: { discord: {} }, - allow: ["discord", "${KEEP_PLUGIN}"], - load: { paths: ["/gone"] }, - }, - }, - config: snapshotCfg, - issues: [ - { path: "channels.discord", message: "unknown channel id: discord" }, - { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, - ], - }), - ); - - const result = await loadConfigForInstall(discordNpmRequest); - expect(result.config).toEqual({ - plugins: { - installs: { discord: { source: "npm", installPath: "/gone" } }, - allow: ["discord", "keep"], - load: { paths: [] }, - }, - }); - }); - it("rejects non-Discord install requests when config is invalid", async () => { readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 431893d6aa0..25319605009 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -35,6 +35,12 @@ function expectRuntimeLogIncludes(fragment: string) { expect(runtimeLogs.join("\n")).toContain(fragment); } +const installWriteOptions = { + assertConfigPathForWrite: () => {}, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", +}; + describe("persistPluginInstall", () => { beforeEach(() => { resetPluginsCliTestState(); @@ -49,7 +55,7 @@ describe("persistPluginInstall", () => { } as OpenClawConfig; const enabledConfig = { plugins: { - allow: ["alpha", "memory-core"], + allow: ["memory-core", "alpha"], entries: { alpha: { enabled: true }, }, @@ -58,7 +64,7 @@ describe("persistPluginInstall", () => { enablePluginInConfig.mockImplementation((...args: unknown[]) => { const [cfg, pluginId] = args as [OpenClawConfig, string]; expect(pluginId).toBe("alpha"); - expect(cfg.plugins?.allow).toEqual(["alpha", "memory-core"]); + expect(cfg.plugins?.allow).toEqual(["memory-core", "alpha"]); return { config: enabledConfig }; }); @@ -66,6 +72,13 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: { + assertConfigPathForWrite: installWriteOptions.assertConfigPathForWrite, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" }, + includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" }, + }, }, pluginId: "alpha", install: { @@ -91,6 +104,11 @@ describe("persistPluginInstall", () => { nextConfig: enabledConfig, baseHash: "config-1", writeOptions: { + assertConfigPathForWrite: installWriteOptions.assertConfigPathForWrite, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" }, + includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" }, afterWrite: { mode: "restart", reason: "plugin source changed" }, unsetPaths: [["plugins", "installs"]], }, @@ -130,6 +148,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "alpha", install: { @@ -194,6 +213,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "codex", install: { @@ -257,6 +277,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "codex", install: { @@ -301,6 +322,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "discord", install: { @@ -359,6 +381,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "discord", install: { @@ -392,6 +415,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "alpha", install: { @@ -427,6 +451,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "alpha", install: { @@ -468,6 +493,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "alpha", install: { @@ -535,6 +561,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "legacy-memory", install: { @@ -607,6 +634,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "memory-b", install: { @@ -657,6 +685,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "plain", install: { @@ -689,6 +718,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "memory-lancedb", enable: false, @@ -730,6 +760,7 @@ describe("persistPluginInstall", () => { snapshot: { config: baseConfig, baseHash: "config-1", + writeOptions: installWriteOptions, }, pluginId: "memory-lancedb", enable: false, diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 2a7b8026bb1..2ac74fd510c 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -1,6 +1,15 @@ // Persistence helpers for plugin and hook-pack installs plus related config mutation. +import fs from "node:fs"; +import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { replaceConfigFile } from "../config/config.js"; +import { + hashConfigIncludeRaw, + readConfigIncludeFileWithGuards, + resolveConfigIncludeWritePath, +} from "../config/includes.js"; +import type { ConfigWriteOptions } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js"; @@ -21,6 +30,7 @@ import { } from "../plugins/uninstall.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { applySlotSelectionForPlugin, enableInternalHookEntries, @@ -39,7 +49,9 @@ function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): O ...cfg, plugins: { ...cfg.plugins, - allow: [...allow, pluginId].toSorted(), + // Preserve authored allowlist order so env-backed entries remain aligned + // with the write-time env restoration snapshot. + allow: [...allow, pluginId], }, }; } @@ -66,8 +78,236 @@ function removeInstalledPluginFromDenylist(cfg: OpenClawConfig, pluginId: string export type ConfigSnapshotForInstallPersist = { config: OpenClawConfig; baseHash: string | undefined; + writeOptions: Pick< + ConfigWriteOptions, + | "assertConfigPathForWrite" + | "expectedConfigPath" + | "ownedConfigPathForWrite" + | "envSnapshotForRestore" + | "includeFileHashesForWrite" + | "includeFileTargetsForWrite" + >; }; +type ConfigMutationSection = "hooks" | "plugins"; + +export type ConfigMutationPreflight = + | { mode: "allowed" } + | { mode: "blocked"; scope: "config" | ConfigMutationSection; reason: string }; + +const CONFIG_MUTATION_ALLOWED = { mode: "allowed" } as const; + +export function containsConfigIncludeDirective(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((entry) => containsConfigIncludeDirective(entry)); + } + if (!isRecord(value)) { + return false; + } + return ( + Object.hasOwn(value, "$include") || + Object.values(value).some((entry) => containsConfigIncludeDirective(entry)) + ); +} + +export function supportsInstallConfigSingleTopLevelIncludeShape(authoredSection: unknown): boolean { + if (!containsConfigIncludeDirective(authoredSection)) { + return true; + } + return ( + isRecord(authoredSection) && + Object.keys(authoredSection).length === 1 && + typeof authoredSection.$include === "string" + ); +} + +function resolveSingleTopLevelIncludePath( + parsed: Record, + configPath: string, + section: ConfigMutationSection, +): string | null { + const authoredSection = parsed[section]; + if ( + !isRecord(authoredSection) || + Object.keys(authoredSection).length !== 1 || + typeof authoredSection.$include !== "string" + ) { + return null; + } + return path.normalize( + path.isAbsolute(authoredSection.$include) + ? authoredSection.$include + : path.resolve(path.dirname(configPath), authoredSection.$include), + ); +} + +function resolveConfigMutationPreflight(params: { + parsed: Record; + section: ConfigMutationSection; + snapshotPath: string; + writeOptions: ConfigSnapshotForInstallPersist["writeOptions"]; +}): ConfigMutationPreflight { + if (Object.hasOwn(params.parsed, "$include")) { + return { + mode: "blocked", + scope: "config", + reason: `Config ${params.section} are stored through an unsupported $include shape at the root; edit the included file directly or move ${params.section} into the root config before installing.`, + }; + } + if (!supportsInstallConfigSingleTopLevelIncludeShape(params.parsed[params.section])) { + return { + mode: "blocked", + scope: params.section, + reason: `Config ${params.section} are stored through an unsupported $include shape; edit the included file directly or move ${params.section} to a single-file top-level include before installing.`, + }; + } + const includePath = resolveSingleTopLevelIncludePath( + params.parsed, + params.snapshotPath, + params.section, + ); + if (!includePath) { + return CONFIG_MUTATION_ALLOWED; + } + const expectedTarget = params.writeOptions.includeFileTargetsForWrite?.[includePath]; + let resolvedTarget: string | null = null; + try { + resolvedTarget = resolveConfigIncludeWritePath({ + configPath: params.snapshotPath, + includePath, + allowedRoots: [], + }); + } catch { + // The persistence path rejects includes that are no longer root-bound too. + } + if ( + expectedTarget && + resolvedTarget && + path.normalize(expectedTarget) === path.normalize(resolvedTarget) + ) { + const expectedHash = params.writeOptions.includeFileHashesForWrite?.[includePath]; + try { + const raw = readConfigIncludeFileWithGuards({ + includePath, + resolvedPath: resolvedTarget, + rootRealDir: fs.realpathSync(path.dirname(params.snapshotPath)), + }); + if (expectedHash !== hashConfigIncludeRaw(raw)) { + return { + mode: "blocked", + scope: params.section, + reason: `Config ${params.section} include changed since the config was read; rerun the install after reloading the config.`, + }; + } + if (containsConfigIncludeDirective(parseJsonWithJson5Fallback(raw))) { + return { + mode: "blocked", + scope: params.section, + reason: `Config ${params.section} are stored through a nested $include; edit the included file directly or remove the nested $include before installing.`, + }; + } + return CONFIG_MUTATION_ALLOWED; + } catch { + return { + mode: "blocked", + scope: params.section, + reason: `Config ${params.section} include could not be inspected at its snapshot target; rerun the install after repairing or reloading the config.`, + }; + } + } + return { + mode: "blocked", + scope: params.section, + reason: `Config ${params.section} are stored in an external or unresolved top-level $include; edit the included file directly or move it under the config directory before installing.`, + }; +} + +export function resolveInstallConfigMutationPreflights(params: { + parsed: Record; + snapshotPath: string; + writeOptions: ConfigSnapshotForInstallPersist["writeOptions"]; +}): { + hookMutation: ConfigMutationPreflight; + pluginMutation: ConfigMutationPreflight; +} { + const pluginMutation = resolveConfigMutationPreflight({ + ...params, + section: "plugins", + }); + const hookMutation = resolveConfigMutationPreflight({ + ...params, + section: "hooks", + }); + const pluginIncludePath = resolveSingleTopLevelIncludePath( + params.parsed, + params.snapshotPath, + "plugins", + ); + const hookIncludePath = resolveSingleTopLevelIncludePath( + params.parsed, + params.snapshotPath, + "hooks", + ); + const pluginTarget = pluginIncludePath + ? params.writeOptions.includeFileTargetsForWrite?.[pluginIncludePath] + : undefined; + const hookTarget = hookIncludePath + ? params.writeOptions.includeFileTargetsForWrite?.[hookIncludePath] + : undefined; + if (pluginTarget && hookTarget && path.normalize(pluginTarget) === path.normalize(hookTarget)) { + const blocked = { + mode: "blocked", + scope: "config", + reason: + "Config plugins and hooks share the same top-level $include target; split them into separate include files before installing.", + } as const; + return { hookMutation: blocked, pluginMutation: blocked }; + } + return { hookMutation, pluginMutation }; +} + +export function resolveCombinedPluginAndHookConfigMutationPreflight(params: { + parsed: Record; + snapshotPath: string; +}): ConfigMutationPreflight { + const pluginIncludePath = resolveSingleTopLevelIncludePath( + params.parsed, + params.snapshotPath, + "plugins", + ); + const hookIncludePath = resolveSingleTopLevelIncludePath( + params.parsed, + params.snapshotPath, + "hooks", + ); + if (!pluginIncludePath && !hookIncludePath) { + return CONFIG_MUTATION_ALLOWED; + } + return { + mode: "blocked", + scope: "config", + reason: + "Config plugins and hooks cannot be updated together while either section uses a top-level $include; update them separately.", + }; +} + +export function selectInstallMutationWriteOptions( + writeOptions: ConfigWriteOptions, +): ConfigSnapshotForInstallPersist["writeOptions"] { + // Install work may outlive its config read. Keep only mutation-start ownership + // and conflict facts; plugin metadata must come from the commit-time read. + return { + ...(writeOptions.assertConfigPathForWrite + ? { assertConfigPathForWrite: writeOptions.assertConfigPathForWrite } + : {}), + expectedConfigPath: writeOptions.expectedConfigPath, + ownedConfigPathForWrite: writeOptions.ownedConfigPathForWrite, + envSnapshotForRestore: writeOptions.envSnapshotForRestore, + includeFileHashesForWrite: writeOptions.includeFileHashesForWrite, + includeFileTargetsForWrite: writeOptions.includeFileTargetsForWrite, + }; +} + function sourceMatchesInstalledPath(params: { activeSource: string; installedSource: string; @@ -237,6 +477,7 @@ export async function persistPluginInstall(params: { nextConfig: next, baseHash: params.snapshot.baseHash, writeOptions: { + ...params.snapshot.writeOptions, afterWrite: { mode: "restart", reason: "plugin source changed" }, }, }), @@ -302,6 +543,7 @@ export async function persistHookPackInstall(params: { await replaceConfigFile({ nextConfig: next, baseHash: params.snapshot.baseHash, + writeOptions: params.snapshot.writeOptions, }); runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); logHookPackRestartHint(runtime); diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 57884e68f28..5812c10297c 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -24,7 +24,8 @@ vi.mock("../plugins/manifest-registry-installed.js", () => ({ loadPluginManifestRegistryForInstalledIndexMock(...args), })); -const { listPersistedBundledPluginLocationBridges } = await import("./plugins-location-bridges.js"); +const { listPersistedBundledPluginLocationBridges, listPersistedBundledPluginRecoveryLocations } = + await import("./plugins-location-bridges.js"); function makeIndex(record: InstalledPluginIndex["plugins"][number]): InstalledPluginIndex { return { @@ -185,3 +186,50 @@ describe("listPersistedBundledPluginLocationBridges", () => { await expect(listPersistedBundledPluginLocationBridges({})).resolves.toStrictEqual([]); }); }); + +describe("listPersistedBundledPluginRecoveryLocations", () => { + beforeEach(() => { + readPersistedInstalledPluginIndexMock.mockReset(); + loadPluginManifestRegistryForInstalledIndexMock.mockReset(); + }); + + it("includes exact packaged and legacy paths for disabled bundled records", async () => { + readPersistedInstalledPluginIndexMock.mockResolvedValue( + makeIndex({ + pluginId: "diagnostics-otel", + manifestPath: "/app/dist/extensions/diagnostics-otel/openclaw.plugin.json", + manifestHash: "hash", + source: "/app/dist/extensions/diagnostics-otel/index.js", + rootDir: "/app/dist/extensions/diagnostics-otel", + origin: "bundled", + enabled: false, + startup: startupInfo, + compat: [], + }), + ); + + await expect(listPersistedBundledPluginRecoveryLocations({})).resolves.toEqual([ + { + pluginId: "diagnostics-otel", + loadPaths: ["/app/dist/extensions/diagnostics-otel", "/app/extensions/diagnostics-otel"], + }, + ]); + }); + + it("does not use a relative persisted bundled root as ownership proof", async () => { + readPersistedInstalledPluginIndexMock.mockResolvedValue( + makeIndex({ + pluginId: "diagnostics-otel", + manifestPath: "extensions/diagnostics-otel/openclaw.plugin.json", + manifestHash: "hash", + source: "extensions/diagnostics-otel/index.js", + rootDir: "extensions/diagnostics-otel", + origin: "bundled", + enabled: true, + startup: startupInfo, + compat: [], + }), + ); + await expect(listPersistedBundledPluginRecoveryLocations({})).resolves.toStrictEqual([]); + }); +}); diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index 4b089c90f2f..05bf0fc03dc 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -1,4 +1,6 @@ // Bridge builder for users upgrading from bundled plugins to external plugin packages. +import path from "node:path"; +import { buildBundledPluginLoadPathAliases } from "../plugins/bundled-load-path-aliases.js"; import type { ExternalizedBundledPluginBridge } from "../plugins/externalized-bundled-plugins.js"; import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js"; @@ -10,6 +12,11 @@ import { resolveOfficialExternalPluginInstall, } from "../plugins/official-external-plugin-catalog.js"; +export type PersistedBundledPluginRecoveryLocation = { + pluginId: string; + loadPaths: readonly string[]; +}; + function buildBridgeFromPersistedBundledRecord( record: InstalledPluginIndexRecord, manifest?: PluginManifestRecord, @@ -76,3 +83,23 @@ export async function listPersistedBundledPluginLocationBridges(options: { return bridge ? [bridge] : []; }); } + +/** List exact previous bundled paths that an explicit plugin reinstall may recover. */ +export async function listPersistedBundledPluginRecoveryLocations(options: { + env?: NodeJS.ProcessEnv; +}): Promise { + const index = await readPersistedInstalledPluginIndex(options); + if (!index) { + return []; + } + return index.plugins.flatMap((record) => { + const rootDir = record.rootDir.trim(); + if (record.origin !== "bundled" || !path.isAbsolute(rootDir)) { + return []; + } + const loadPaths = Array.from( + new Set([rootDir, ...buildBundledPluginLoadPathAliases(rootDir).map((alias) => alias.path)]), + ); + return [{ pluginId: record.pluginId, loadPaths }]; + }); +} diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 9b5bf184582..8b8c764177d 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -3,17 +3,32 @@ import { theme } from "../../packages/terminal-core/src/theme.js"; import { assertConfigWriteAllowedInCurrentMode, getRuntimeConfig, - readConfigFileSnapshot, + readConfigFileSnapshotForWrite, replaceConfigFile, } from "../config/config.js"; +import { createMergePatch } from "../config/io.write-prepare.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { extractShippedPluginInstallConfigRecords } from "../config/plugin-install-config-migration.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; import { loadInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, withPluginInstallRecords, } from "../plugins/installed-plugin-index-records.js"; -import { updateNpmInstalledPlugins } from "../plugins/update.js"; +import { + isPluginInstallRecordUpdateSource, + pluginInstallRecordMayMigrateConfigId, + updateNpmInstalledPlugins, +} from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; +import { + containsConfigIncludeDirective, + resolveCombinedPluginAndHookConfigMutationPreflight, + resolveInstallConfigMutationPreflights, + selectInstallMutationWriteOptions, +} from "./plugins-install-persist.js"; import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js"; @@ -26,6 +41,62 @@ import { promptYesNo } from "./prompt.js"; const DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING = "--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; +function mayMutatePluginInstallRecord( + record: PluginInstallRecord | undefined, + specOverride: string | undefined, +): boolean { + if (!isPluginInstallRecordUpdateSource(record)) { + return false; + } + if (record?.source === "npm") { + return Boolean(specOverride ?? record.spec); + } + if (record?.source === "git") { + return Boolean(record.spec); + } + if (record?.source === "clawhub") { + return Boolean(record.clawhubPackage); + } + return Boolean(record?.marketplaceSource && record.marketplacePlugin); +} + +function pluginConfigReferencesId(config: ReturnType, pluginId: string) { + const plugins = config.plugins; + return ( + plugins?.allow?.includes(pluginId) || + plugins?.deny?.includes(pluginId) || + Object.hasOwn(plugins?.entries ?? {}, pluginId) || + plugins?.slots?.memory === pluginId || + plugins?.slots?.contextEngine === pluginId + ); +} + +function shouldPreserveEmptyPlugins(params: { + parsed: unknown; + sourceConfig: ReturnType; +}): boolean { + const plugins = params.sourceConfig.plugins; + const parsedPlugins = + params.parsed && typeof params.parsed === "object" && !Array.isArray(params.parsed) + ? (params.parsed as Record).plugins + : undefined; + return Boolean( + plugins && + (!Object.hasOwn(plugins, "installs") || + Object.keys(plugins).some((key) => key !== "installs") || + containsConfigIncludeDirective(parsedPlugins)), + ); +} + +function projectUpdaterResultOntoSourceConfig(params: { + runtimeBase: OpenClawConfig; + sourceBase: OpenClawConfig; + updatedConfig: OpenClawConfig; +}): OpenClawConfig { + const updatePatch = createMergePatch(params.runtimeBase, params.updatedConfig); + return applyMergePatch(params.sourceBase, updatePatch) as OpenClawConfig; +} + /** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */ export async function runPluginUpdateCommand(params: { id?: string; @@ -33,10 +104,42 @@ export async function runPluginUpdateCommand(params: { }) { assertConfigWriteAllowedInCurrentMode(); - const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const cfg = getRuntimeConfig(); - const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + const sourceSnapshotPromise = readConfigFileSnapshotForWrite() + .then((prepared) => ({ + ...prepared, + writeOptions: selectInstallMutationWriteOptions(prepared.writeOptions), + })) + .catch(() => null); + const mutationSnapshot = params.opts.dryRun ? null : await sourceSnapshotPromise; + if (!params.opts.dryRun && !mutationSnapshot) { + defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks."); + return defaultRuntime.exit(1); + } + if (mutationSnapshot && !mutationSnapshot.snapshot.valid) { + defaultRuntime.error("Cannot update plugins or hooks while the config is invalid."); + return defaultRuntime.exit(1); + } + // Bind selection, updater input, ownership checks, and persistence to one + // mutation-start snapshot so concurrent config changes cannot be resurrected. + const cfg = mutationSnapshot?.snapshot.runtimeConfig ?? getRuntimeConfig(); + const sourceCfg = mutationSnapshot?.snapshot.sourceConfig ?? cfg; + const shippedPluginInstallRecords = mutationSnapshot + ? { + ...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed), + ...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig), + } + : extractShippedPluginInstallConfigRecords(cfg); + const persistedPluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + // Persisted index records win over shipped legacy config during migration. + const pluginInstallRecords = { + ...shippedPluginInstallRecords, + ...persistedPluginInstallRecords, + }; const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords); + const sourceCfgWithPluginInstallRecords = withPluginInstallRecords( + sourceCfg, + pluginInstallRecords, + ); const logger = { info: (msg: string) => defaultRuntime.log(msg), warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), @@ -64,49 +167,143 @@ export async function runPluginUpdateCommand(params: { return defaultRuntime.exit(1); } - const pluginResult = await updateNpmInstalledPlugins({ - config: cfgWithPluginInstallRecords, - pluginIds: pluginSelection.pluginIds, - specOverrides: pluginSelection.specOverrides, - dryRun: params.opts.dryRun, - dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall, - logger, - onIntegrityDrift: async (drift) => { - const specLabel = drift.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for "${drift.pluginId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), + const selectedHooks = cfg.hooks?.internal?.installs ?? {}; + const pluginUpdateMayMutate = + !params.opts.dryRun && + pluginSelection.pluginIds.some((pluginId) => { + return mayMutatePluginInstallRecord( + pluginInstallRecords[pluginId], + pluginSelection.specOverrides?.[pluginId], ); - if (drift.dryRun) { - return true; - } - return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`); - }, - }); - const hookResult = await updateNpmInstalledHookPacks({ - config: pluginResult.config, - hookIds: hookSelection.hookIds, - specOverrides: hookSelection.specOverrides, - dryRun: params.opts.dryRun, - logger, - onIntegrityDrift: async (drift) => { - const specLabel = drift.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), + }); + const hookUpdateMayMutate = + !params.opts.dryRun && + hookSelection.hookIds.some((hookId) => { + const record = selectedHooks[hookId]; + return ( + record?.source === "npm" && Boolean(hookSelection.specOverrides?.[hookId] ?? record.spec) ); - if (drift.dryRun) { - return true; + }); + if (pluginUpdateMayMutate || hookUpdateMayMutate) { + if (!mutationSnapshot) { + defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks."); + return defaultRuntime.exit(1); + } + const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({ + parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record, + snapshotPath: mutationSnapshot.snapshot.path, + writeOptions: mutationSnapshot.writeOptions, + }); + // Write snapshots retain valid shipped install records in sourceConfig after + // include resolution; parsed also catches root-authored legacy records. + const pluginRecordCleanupMayMutate = + Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig)) + .length > 0 || + Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed)) + .length > 0; + const parsedConfig = + mutationSnapshot.snapshot.parsed && + typeof mutationSnapshot.snapshot.parsed === "object" && + !Array.isArray(mutationSnapshot.snapshot.parsed) + ? (mutationSnapshot.snapshot.parsed as Record) + : {}; + const pluginReferencesMayBeUnresolved = + Object.hasOwn(parsedConfig, "$include") || + containsConfigIncludeDirective(mutationSnapshot.snapshot.sourceConfig.plugins); + const pluginIdMigrationMayMutate = pluginSelection.pluginIds.some((pluginId) => { + return ( + pluginInstallRecordMayMigrateConfigId({ + pluginId, + record: pluginInstallRecords[pluginId], + specOverride: pluginSelection.specOverrides?.[pluginId], + }) && + (pluginReferencesMayBeUnresolved || + pluginConfigReferencesId(mutationSnapshot.snapshot.sourceConfig, pluginId)) + ); + }); + // Manual update records stay in the index unless shipped-record cleanup or + // scoped-package compatibility migrates authored references from a legacy id. + const pluginConfigMayMutate = pluginRecordCleanupMayMutate || pluginIdMigrationMayMutate; + const blockedReasons = new Set(); + if (pluginConfigMayMutate && pluginMutation.mode === "blocked") { + blockedReasons.add(pluginMutation.reason); + } + if (hookUpdateMayMutate && hookMutation.mode === "blocked") { + blockedReasons.add(hookMutation.reason); + } + if ( + pluginConfigMayMutate && + hookUpdateMayMutate && + pluginMutation.mode === "allowed" && + hookMutation.mode === "allowed" + ) { + // Config persistence can commit one include-owned top-level section, not + // a mixed plugin-and-hook mutation spanning root and include ownership. + const combinedMutation = resolveCombinedPluginAndHookConfigMutationPreflight({ + parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record, + snapshotPath: mutationSnapshot.snapshot.path, + }); + if (combinedMutation.mode === "blocked") { + blockedReasons.add(combinedMutation.reason); } - return await promptYesNo(`Continue updating hook pack "${drift.hookId}" with this artifact?`); - }, - }); + } + if (blockedReasons.size > 0) { + defaultRuntime.error(Array.from(blockedReasons).join(" ")); + return defaultRuntime.exit(1); + } + } + + const pluginResult = + pluginSelection.pluginIds.length > 0 + ? await updateNpmInstalledPlugins({ + config: cfgWithPluginInstallRecords, + pluginIds: pluginSelection.pluginIds, + specOverrides: pluginSelection.specOverrides, + dryRun: params.opts.dryRun, + dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall, + logger, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for "${drift.pluginId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + if (drift.dryRun) { + return true; + } + return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`); + }, + }) + : { config: cfgWithPluginInstallRecords, changed: false, outcomes: [] }; + const hookResult = + hookSelection.hookIds.length > 0 + ? await updateNpmInstalledHookPacks({ + config: pluginResult.config, + hookIds: hookSelection.hookIds, + specOverrides: hookSelection.specOverrides, + dryRun: params.opts.dryRun, + logger, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + if (drift.dryRun) { + return true; + } + return await promptYesNo( + `Continue updating hook pack "${drift.hookId}" with this artifact?`, + ); + }, + }) + : { config: pluginResult.config, changed: false, outcomes: [] }; const outcomeSummary = logPluginUpdateOutcomes({ outcomes: [...pluginResult.outcomes, ...hookResult.outcomes], @@ -114,27 +311,39 @@ export async function runPluginUpdateCommand(params: { }); if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) { + const sourceSnapshot = mutationSnapshot ?? (await sourceSnapshotPromise); const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {}; const shouldPersistPluginInstallIndex = pluginResult.changed || Object.keys(pluginInstallRecords).length > 0; - // Plugin install records live in the persisted index; config only carries hook-pack changes. - const nextConfig = shouldPersistPluginInstallIndex - ? withoutPluginInstallRecords(hookResult.config) - : hookResult.config; + const sourceShapedUpdateConfig = projectUpdaterResultOntoSourceConfig({ + runtimeBase: cfgWithPluginInstallRecords, + sourceBase: sourceCfgWithPluginInstallRecords, + updatedConfig: hookResult.config, + }); + // Plugin install records live in the persisted index. Preserve an authored + // empty plugins section so include ownership does not become a false mutation. + const nextConfig = withoutPluginInstallRecords(sourceShapedUpdateConfig, { + preserveEmptyPlugins: shouldPreserveEmptyPlugins({ + parsed: sourceSnapshot?.snapshot.parsed, + sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {}, + }), + }); if (shouldPersistPluginInstallIndex) { await commitPluginInstallRecordsWithConfig({ - previousInstallRecords: pluginInstallRecords, + previousInstallRecords: persistedPluginInstallRecords, nextInstallRecords: nextPluginInstallRecords, nextConfig, - baseHash: (await sourceSnapshotPromise)?.hash, + baseHash: sourceSnapshot?.snapshot.hash, writeOptions: { + ...sourceSnapshot?.writeOptions, afterWrite: { mode: "restart", reason: "plugin source changed" }, }, }); } else { await replaceConfigFile({ nextConfig, - baseHash: (await sourceSnapshotPromise)?.hash, + baseHash: sourceSnapshot?.snapshot.hash, + writeOptions: sourceSnapshot?.writeOptions, }); } if (pluginResult.changed) { diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 3b277e18c7d..8c6df841dcd 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -14,11 +14,18 @@ const mocks = vi.hoisted(() => { resolveSearchProviderOptions: vi.fn(), resolvePluginContributionOwners: vi.fn(), setupSearch: vi.fn(), + assertConfigPathForWrite: vi.fn(), readConfigFileSnapshot: vi.fn(), writeConfigFile, - replaceConfigFile: vi.fn(async (params: { nextConfig: unknown }) => { - await writeConfigFile(params.nextConfig); - }), + replaceConfigFile: vi.fn( + async (params: { + nextConfig: unknown; + writeOptions?: { assertConfigPathForWrite?: () => void }; + }) => { + params.writeOptions?.assertConfigPathForWrite?.(); + await writeConfigFile(params.nextConfig); + }, + ), resolveGatewayPort: vi.fn(), ensureControlUiAssetsBuilt: vi.fn(), createClackPrompter: vi.fn(), @@ -52,7 +59,27 @@ vi.mock("@clack/prompts", () => ({ vi.mock("../config/config.js", () => ({ CONFIG_PATH: "~/.openclaw/openclaw.json", + createConfigIO: () => ({ + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await mocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: mocks.assertConfigPathForWrite, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + }), readConfigFileSnapshot: mocks.readConfigFileSnapshot, + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await mocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: mocks.assertConfigPathForWrite, + envSnapshotForRestore: { SECRET: "resolved-secret" }, + expectedConfigPath: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json5": "stale-hash" }, + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), writeConfigFile: mocks.writeConfigFile, replaceConfigFile: mocks.replaceConfigFile, resolveGatewayPort: mocks.resolveGatewayPort, @@ -265,6 +292,7 @@ async function runWebConfigureWizard() { describe("runConfigureWizard", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.assertConfigPathForWrite.mockImplementation(() => {}); mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); mocks.resolveSearchProviderOptions.mockReturnValue([ @@ -300,6 +328,16 @@ describe("runConfigureWizard", () => { await runConfigureWizard({ command: "configure" }, createRuntime()); expect(getGateway(requireWriteConfig()).mode).toBe("local"); + const replaceParams = requireRecord( + mockCallArg(mocks.replaceConfigFile, "replaceConfigFile"), + "replace config params", + ); + const writeOptions = requireRecord(replaceParams.writeOptions, "write options"); + expect(Object.keys(writeOptions).toSorted()).toEqual([ + "assertConfigPathForWrite", + "expectedConfigPath", + "ownedConfigPathForWrite", + ]); }); it("keeps startup gateway hint probes bounded", async () => { setupBaseWizardState({ @@ -672,4 +710,34 @@ describe("runConfigureWizard", () => { expect(pluginConfig.region).toBe("us-east-1"); expect(pluginConfig.accessToken).toBe("plugin-wrote-this"); }); + + it("does not retry after config path ownership changes", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: [], + confirm: [], + }); + mocks.assertConfigPathForWrite.mockImplementation(() => { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + }); + mocks.replaceConfigFile.mockImplementation( + async (params: { + nextConfig: unknown; + writeOptions?: { assertConfigPathForWrite?: () => void }; + }) => { + params.writeOptions?.assertConfigPathForWrite?.(); + await mocks.writeConfigFile(params.nextConfig); + }, + ); + + await expect( + runConfigureWizard({ command: "configure", sections: ["workspace"] }, createRuntime()), + ).rejects.toThrow("config path changed since last load"); + + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 1169db740f5..2fdfe224b56 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -9,7 +9,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { formatPortRangeHint } from "../cli/error-format.js"; import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import { parsePort } from "../cli/shared/parse-port.js"; -import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; +import { + createConfigIO, + readConfigFileSnapshotForWrite, + resolveGatewayPort, +} from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { ConfigMutationConflictError } from "../config/mutate.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -381,7 +385,23 @@ export async function runConfigureWizard( intro(opts.command === "update" ? "OpenClaw update wizard" : "OpenClaw configure"); const prompter = createClackPrompter(); - const snapshot = await readConfigFileSnapshot(); + const prepared = await readConfigFileSnapshotForWrite(); + const snapshot = prepared.snapshot; + // Keep only path ownership across the interactive wizard. Each commit re-reads under + // the mutation lock and must use that fresh snapshot's env/include conflict facts. + const configWriteOwnership = { + ...(prepared.writeOptions.assertConfigPathForWrite + ? { assertConfigPathForWrite: prepared.writeOptions.assertConfigPathForWrite } + : {}), + expectedConfigPath: prepared.writeOptions.expectedConfigPath, + ownedConfigPathForWrite: prepared.writeOptions.ownedConfigPathForWrite, + }; + const readOwnedConfigSnapshot = async () => + ( + await createConfigIO({ + configPath: configWriteOwnership.ownedConfigPathForWrite, + }).readConfigFileSnapshotForWrite() + ).snapshot; let currentBaseHash = snapshot.hash; const baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) @@ -493,6 +513,7 @@ export async function runConfigureWizard( const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: remoteConfig, ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), + writeOptions: configWriteOwnership, }); remoteConfig = committed.config; currentBaseHash = undefined; @@ -533,22 +554,27 @@ export async function runConfigureWizard( const committed = await commitConfigWithPendingPluginInstalls({ nextConfig, ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), + writeOptions: configWriteOwnership, }); nextConfig = committed.config; // After successful write, re-read the snapshot to get the new hash - const freshSnapshot = await readConfigFileSnapshot(); + const freshSnapshot = await readOwnedConfigSnapshot(); currentBaseHash = freshSnapshot.hash ?? undefined; mergeBaseConfig = structuredClone(nextConfig); logConfigUpdated(runtime); return; } catch (err) { - if (err instanceof ConfigMutationConflictError && attempt < maxRetries - 1) { + if ( + err instanceof ConfigMutationConflictError && + err.retryable && + attempt < maxRetries - 1 + ) { // Config was mutated externally (e.g. plugin wrote token during auth setup). // Re-read the on-disk config and merge plugin changes into nextConfig so // the retry won't silently overwrite them. - const freshSnapshot = await readConfigFileSnapshot(); + const freshSnapshot = await readOwnedConfigSnapshot(); currentBaseHash = freshSnapshot.hash ?? undefined; const diskConfig = freshSnapshot.valid ? (freshSnapshot.sourceConfig ?? freshSnapshot.config) diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts index d699d7ae598..31a6f228fcf 100644 --- a/src/commands/doctor/shared/channel-doctor.test.ts +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -150,6 +150,37 @@ describe("channel doctor compatibility mutations", () => { expect(mocks.getBundledChannelPlugin).not.toHaveBeenCalled(); }); + it("limits stale config cleanup to requested channel ids", async () => { + const matrixCleanup = vi.fn(({ cfg }: { cfg: unknown }) => ({ + config: cfg, + changes: ["matrix cleanup"], + })); + const discordCleanup = vi.fn(({ cfg }: { cfg: unknown }) => ({ + config: cfg, + changes: ["discord cleanup"], + })); + mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => ({ + id, + doctor: { + cleanStaleConfig: id === "matrix" ? matrixCleanup : discordCleanup, + }, + })); + const cfg = { + channels: { + discord: { enabled: true }, + matrix: { enabled: true }, + }, + }; + + const result = await collectChannelDoctorStaleConfigMutations(cfg as never, { + channelIds: ["matrix"], + }); + + expect(result).toHaveLength(1); + expect(matrixCleanup).toHaveBeenCalledTimes(1); + expect(discordCleanup).not.toHaveBeenCalled(); + }); + it("skips plugin discovery for explicitly disabled channels", () => { const result = collectChannelDoctorCompatibilityMutations({ channels: { diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index 2f845c9f959..756dc7ef6b4 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -339,11 +339,12 @@ export function collectChannelDoctorCompatibilityMutations( /** Collect stale channel config cleanup mutations from configured channel doctor adapters. */ export async function collectChannelDoctorStaleConfigMutations( cfg: OpenClawConfig, - options: { env?: NodeJS.ProcessEnv } = {}, + options: { env?: NodeJS.ProcessEnv; channelIds?: readonly string[] } = {}, ): Promise { const mutations: ChannelDoctorConfigMutation[] = []; let nextCfg = cfg; - for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(cfg), { + const channelIds = options.channelIds ?? collectConfiguredChannelIds(cfg); + for (const entry of listChannelDoctorEntries(channelIds, { cfg, env: options.env, })) { diff --git a/src/config/env-preserve.test.ts b/src/config/env-preserve.test.ts index 094ecb0f734..3053cb2de6c 100644 --- a/src/config/env-preserve.test.ts +++ b/src/config/env-preserve.test.ts @@ -94,6 +94,659 @@ describe("restoreEnvVarRefs", () => { expect(result).toEqual({ url: "https://${MY_TOKEN}.example.com" }); }); + it("restores partially resolved templates when missing vars remain literal", () => { + const partialEnv = { API_TOKEN: "secret" } as unknown as NodeJS.ProcessEnv; + const incoming = { value: "secret:${OPTIONAL_SUFFIX}" }; + const parsed = { value: "${API_TOKEN}:${OPTIONAL_SUFFIX}" }; + + const result = restoreEnvVarRefs(incoming, parsed, partialEnv); + + expect(result).toEqual({ value: "${API_TOKEN}:${OPTIONAL_SUFFIX}" }); + }); + + it("rejects structural changes to arrays containing environment references", () => { + const duplicateEnv = { + PLUGIN_A: "same-plugin", + PLUGIN_B: "same-plugin", + } as unknown as NodeJS.ProcessEnv; + + expect(() => + restoreEnvVarRefs(["same-plugin"], ["${PLUGIN_A}", "${PLUGIN_B}"], duplicateEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows array edits when placeholders are escaped literals", () => { + const result = restoreEnvVarRefs( + ["${ESCAPED}", "changed"], + ["$${ESCAPED}", "literal"], + {} as NodeJS.ProcessEnv, + ); + + expect(result).toEqual(["$${ESCAPED}", "changed"]); + }); + + it("restores escaped literals beside real environment-backed array entries", () => { + const result = restoreEnvVarRefs(["secret", "${ESCAPED}"], ["${TOKEN}", "$${ESCAPED}"], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["${TOKEN}", "$${ESCAPED}"]); + }); + + it("allows appending after stable environment-backed array entries", () => { + const result = restoreEnvVarRefs(["base-plugin", "extra-plugin"], ["${BASE_PLUGIN}"], { + BASE_PLUGIN: "base-plugin", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["${BASE_PLUGIN}", "extra-plugin"]); + }); + + it("allows removing a unique environment-backed array entry", () => { + const result = restoreEnvVarRefs([], ["${BASE_PLUGIN}"], { + BASE_PLUGIN: "base-plugin", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual([]); + }); + + it("preserves an env-backed allow entry while removing the same plugin from env-backed deny", () => { + const result = restoreEnvVarRefs( + { + plugins: { + allow: ["base-plugin", "demo"], + deny: ["keep"], + }, + }, + { + plugins: { + allow: ["${BASE_PLUGIN}"], + deny: ["${DENIED_PLUGIN}", "keep"], + }, + }, + { + BASE_PLUGIN: "base-plugin", + DENIED_PLUGIN: "demo", + } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual({ + plugins: { + allow: ["${BASE_PLUGIN}", "demo"], + deny: ["keep"], + }, + }); + }); + + it("allows replacing a unique environment-backed array entry", () => { + const result = restoreEnvVarRefs(["replacement"], ["${BASE_PLUGIN}"], { + BASE_PLUGIN: "base-plugin", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["replacement"]); + }); + + it("allows in-place object edits when stable ids preserve array identity", () => { + const result = restoreEnvVarRefs( + [{ id: "main", workspace: "/workspace/main", name: "new" }], + [{ id: "main", workspace: "${WORKSPACE}", name: "old" }], + { WORKSPACE: "/workspace/main" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ id: "main", workspace: "${WORKSPACE}", name: "new" }]); + }); + + it("allows single-position edits to env-backed array objects without stable ids", () => { + const result = restoreEnvVarRefs( + [{ name: "new", token: "secret" }], + [{ name: "old", token: "${TOKEN}" }], + { TOKEN: "secret" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ name: "new", token: "${TOKEN}" }]); + }); + + it("allows appending after unchanged env-backed array objects without ids", () => { + const result = restoreEnvVarRefs( + [{ match: { peer: { id: "peer-1" } } }, { match: { peer: { id: "peer-2" } } }], + [{ match: { peer: { id: "${PEER_ID}" } } }], + { PEER_ID: "peer-1" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([ + { match: { peer: { id: "${PEER_ID}" } } }, + { match: { peer: { id: "peer-2" } } }, + ]); + }); + + it("rejects editing an env-backed array object while appending without stable identity", () => { + expect(() => + restoreEnvVarRefs( + [ + { name: "new", token: "secret" }, + { name: "second", token: "literal" }, + ], + [{ name: "old", token: "${TOKEN}" }], + { TOKEN: "secret" } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects reordered and edited env-backed array objects without stable ids", () => { + expect(() => + restoreEnvVarRefs( + [ + { name: "second-next", token: "secret-b" }, + { name: "first-next", token: "secret-a" }, + ], + [ + { name: "first", token: "${TOKEN_A}" }, + { name: "second", token: "${TOKEN_B}" }, + ], + { + TOKEN_A: "secret-a", + TOKEN_B: "secret-b", + } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects identity swaps that leave old resolved secrets at their original positions", () => { + expect(() => + restoreEnvVarRefs( + [ + { account: "second", token: "secret-a" }, + { account: "first", token: "secret-b" }, + ], + [ + { account: "first", token: "${TOKEN_A}" }, + { account: "second", token: "${TOKEN_B}" }, + ], + { + TOKEN_A: "secret-a", + TOKEN_B: "secret-b", + } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows multi-item object edits with unique agentId identities", () => { + const result = restoreEnvVarRefs( + [ + { agentId: "first", name: "first-next", match: { peer: { id: "peer-a" } } }, + { agentId: "second", name: "second-next", match: { peer: { id: "peer-b" } } }, + ], + [ + { agentId: "first", name: "first", match: { peer: { id: "${PEER_A}" } } }, + { agentId: "second", name: "second", match: { peer: { id: "${PEER_B}" } } }, + ], + { + PEER_A: "peer-a", + PEER_B: "peer-b", + } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([ + { agentId: "first", name: "first-next", match: { peer: { id: "${PEER_A}" } } }, + { agentId: "second", name: "second-next", match: { peer: { id: "${PEER_B}" } } }, + ]); + }); + + it("allows nested accountId changes when agentId preserves array identity", () => { + const result = restoreEnvVarRefs( + [{ agentId: "main", match: { accountId: "next" }, token: "secret" }], + [{ agentId: "main", match: { accountId: "old" }, token: "${TOKEN}" }], + { TOKEN: "secret" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ agentId: "main", match: { accountId: "next" }, token: "${TOKEN}" }]); + }); + + it("allows changing an accountId routing field on a single env-backed target", () => { + const result = restoreEnvVarRefs( + [{ accountId: "next", to: "user@example.com" }], + [{ accountId: "old", to: "${APPROVAL_TARGET}" }], + { APPROVAL_TARGET: "user@example.com" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ accountId: "next", to: "${APPROVAL_TARGET}" }]); + }); + + it("allows changing one unambiguous target in a multi-entry env-backed array", () => { + const result = restoreEnvVarRefs( + [ + { accountId: "next", to: "user-a@example.com" }, + { accountId: "second", to: "user-b@example.com" }, + ], + [ + { accountId: "old", to: "${APPROVAL_TARGET_A}" }, + { accountId: "second", to: "${APPROVAL_TARGET_B}" }, + ], + { + APPROVAL_TARGET_A: "user-a@example.com", + APPROVAL_TARGET_B: "user-b@example.com", + } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([ + { accountId: "next", to: "${APPROVAL_TARGET_A}" }, + { accountId: "second", to: "${APPROVAL_TARGET_B}" }, + ]); + }); + + it("allows same-index non-string edits when every authored literal string stays unchanged", () => { + const result = restoreEnvVarRefs( + [ + { account: "first", enabled: true, token: "secret-a" }, + { account: "second", enabled: false, token: "secret-b" }, + ], + [ + { account: "first", enabled: false, token: "${TOKEN_A}" }, + { account: "second", enabled: true, token: "${TOKEN_B}" }, + ], + { + TOKEN_A: "secret-a", + TOKEN_B: "secret-b", + } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([ + { account: "first", enabled: true, token: "${TOKEN_A}" }, + { account: "second", enabled: false, token: "${TOKEN_B}" }, + ]); + }); + + it("allows deleting an env-backed array object without a stable id", () => { + const result = restoreEnvVarRefs( + [{ agentId: "second", match: { peer: { id: "peer-b" } } }], + [ + { agentId: "first", match: { peer: { id: "${PEER_A}" } } }, + { agentId: "second", match: { peer: { id: "peer-b" } } }, + ], + { PEER_A: "peer-a" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ agentId: "second", match: { peer: { id: "peer-b" } } }]); + }); + + it("allows deleting multiple env-backed array objects without stable ids", () => { + const result = restoreEnvVarRefs( + [{ agentId: "retained", match: { peer: { id: "peer-c" } } }], + [ + { agentId: "first", match: { peer: { id: "${PEER_A}" } } }, + { agentId: "second", match: { peer: { id: "${PEER_B}" } } }, + { agentId: "retained", match: { peer: { id: "peer-c" } } }, + ], + { + PEER_A: "peer-a", + PEER_B: "peer-b", + } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ agentId: "retained", match: { peer: { id: "peer-c" } } }]); + }); + + it("rejects reordered template entries with duplicate stable ids", () => { + expect(() => + restoreEnvVarRefs( + [ + { id: "duplicate", workspace: "/workspace/b", name: "b" }, + { id: "duplicate", workspace: "/workspace/a", name: "a" }, + ], + [ + { id: "duplicate", workspace: "${WORKSPACE_A}", name: "a" }, + { id: "duplicate", workspace: "${WORKSPACE_B}", name: "b" }, + ], + { + WORKSPACE_A: "/workspace/a", + WORKSPACE_B: "/workspace/b", + } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects removing one of two template entries with duplicate stable ids", () => { + expect(() => + restoreEnvVarRefs( + [{ id: "duplicate", sessionKey: "same" }], + [ + { id: "duplicate", sessionKey: "${SESSION_A}" }, + { id: "duplicate", sessionKey: "${SESSION_B}" }, + ], + { + SESSION_A: "same", + SESSION_B: "same", + } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows deleting a templated entry beside a uniquely retained duplicate-id sibling", () => { + const result = restoreEnvVarRefs( + [{ id: "duplicate", sessionKey: "literal" }], + [ + { id: "duplicate", sessionKey: "${SESSION_KEY}" }, + { id: "duplicate", sessionKey: "literal" }, + ], + { SESSION_KEY: "secret" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ id: "duplicate", sessionKey: "literal" }]); + }); + + it("rejects renaming stable ids on env-backed array objects", () => { + expect(() => + restoreEnvVarRefs([{ id: "new", token: "secret" }], [{ id: "old", token: "${TOKEN}" }], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows deleting a uniquely identified env-backed array entry", () => { + const result = restoreEnvVarRefs( + [{ id: "main", workspace: "/workspace/main" }], + [ + { id: "main", workspace: "/workspace/main" }, + { id: "ops", workspace: "${OPS_WORKSPACE}" }, + ], + { OPS_WORKSPACE: "/workspace/ops" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ id: "main", workspace: "/workspace/main" }]); + }); + + it("allows deleting a uniquely identified env-backed entry beside a sibling edit", () => { + const result = restoreEnvVarRefs( + [{ id: "main", name: "new" }], + [ + { id: "ops", workspace: "${OPS_WORKSPACE}" }, + { id: "main", name: "old" }, + ], + { OPS_WORKSPACE: "/workspace/ops" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([{ id: "main", name: "new" }]); + }); + + it("rejects same-index template matches against authored literal duplicates", () => { + expect(() => + restoreEnvVarRefs(["same"], ["${PLUGIN_PATH}", "same"], { + PLUGIN_PATH: "same", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects same-index scalar matches after surrounding array restructuring", () => { + expect(() => + restoreEnvVarRefs(["tail", "secret"], ["old", "${TOKEN}", "tail"], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows trailing sibling edits beside a scalar environment reference", () => { + const result = restoreEnvVarRefs(["base-plugin", "replacement"], ["${BASE_PLUGIN}", "old"], { + BASE_PLUGIN: "base-plugin", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["${BASE_PLUGIN}", "replacement"]); + }); + + it("allows prefix edits before a same-index scalar environment reference", () => { + const result = restoreEnvVarRefs(["new", "base-plugin"], ["old", "${BASE_PLUGIN}"], { + BASE_PLUGIN: "base-plugin", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["new", "${BASE_PLUGIN}"]); + }); + + it("restores escaped literal moves without activating the reference", () => { + const result = restoreEnvVarRefs( + ["literal", "${TOKEN}"], + ["$${TOKEN}", "literal"], + {} as NodeJS.ProcessEnv, + ); + + expect(result).toEqual(["literal", "$${TOKEN}"]); + }); + + it("restores an escaped literal move beside a stable real environment reference", () => { + const result = restoreEnvVarRefs( + ["secret", "literal", "${ESCAPED}"], + ["${TOKEN}", "$${ESCAPED}", "literal"], + { TOKEN: "secret" } as unknown as NodeJS.ProcessEnv, + ); + + expect(result).toEqual(["${TOKEN}", "literal", "$${ESCAPED}"]); + }); + + it("preserves duplicate escaped literals when their positions stay stable", () => { + const result = restoreEnvVarRefs( + ["${TOKEN}", "${TOKEN}"], + ["$${TOKEN}", "$${TOKEN}"], + {} as NodeJS.ProcessEnv, + ); + + expect(result).toEqual(["$${TOKEN}", "$${TOKEN}"]); + }); + + it("rejects ambiguous escaped literal moves beside a new active reference", () => { + expect(() => + restoreEnvVarRefs( + ["literal", "${TOKEN}", "${TOKEN}"], + ["$${TOKEN}", "literal"], + {} as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("restores escaped literals after stable-id object moves", () => { + const result = restoreEnvVarRefs( + [ + { id: "literal", token: "plain" }, + { id: "escaped", token: "${TOKEN}", enabled: true }, + ], + [ + { id: "escaped", token: "$${TOKEN}", enabled: false }, + { id: "literal", token: "plain" }, + ], + {} as NodeJS.ProcessEnv, + ); + + expect(result).toEqual([ + { id: "literal", token: "plain" }, + { id: "escaped", token: "$${TOKEN}", enabled: true }, + ]); + }); + + it("allows deleting an escaped literal entry with a stable id", () => { + const result = restoreEnvVarRefs([], [{ id: "old", token: "$${TOKEN}" }], {}); + + expect(result).toEqual([]); + }); + + it("allows deleting an escaped literal entry beside a stable-id sibling edit", () => { + const result = restoreEnvVarRefs( + [{ id: "main", name: "new" }], + [ + { id: "escaped", token: "$${TOKEN}" }, + { id: "main", name: "old" }, + ], + {}, + ); + + expect(result).toEqual([{ id: "main", name: "new" }]); + }); + + it("restores escaped literals during a same-index object edit with stable neighbors", () => { + const result = restoreEnvVarRefs( + [{ token: "${TOKEN}", enabled: true }, "tail"], + [{ token: "$${TOKEN}", enabled: false }, "tail"], + {}, + ); + + expect(result).toEqual([{ token: "$${TOKEN}", enabled: true }, "tail"]); + }); + + it("rejects restoring escaped literals onto a replacement stable-id entry", () => { + expect(() => + restoreEnvVarRefs( + [{ id: "new", token: "${TOKEN}" }], + [{ id: "old", token: "$${TOKEN}" }], + {}, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows replacing a stable-id escaped entry when no active reference remains", () => { + const result = restoreEnvVarRefs( + [{ id: "new", token: "plain" }], + [{ id: "old", token: "$${TOKEN}" }], + {}, + ); + + expect(result).toEqual([{ id: "new", token: "plain" }]); + }); + + it("allows changing one of multiple identical escaped literals", () => { + const result = restoreEnvVarRefs(["new", "${TOKEN}"], ["$${TOKEN}", "$${TOKEN}"], {}); + + expect(result).toEqual(["new", "$${TOKEN}"]); + }); + + it("rejects ambiguous multi-item edits that could activate escaped literals", () => { + expect(() => + restoreEnvVarRefs( + [ + { token: "${A}", enabled: true }, + { token: "${B}", enabled: true }, + ], + [ + { token: "$${A}", enabled: false }, + { token: "$${B}", enabled: false }, + ], + {}, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects escaped literal moves onto indexes claimed by real references", () => { + expect(() => + restoreEnvVarRefs(["${B}", "changed"], ["${A}", "$${B}", "tail"], { + A: "x", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects escaped literal activation beside a changed stable-id entry", () => { + expect(() => + restoreEnvVarRefs( + [ + { id: "b", token: "${TOKEN}" }, + { id: "a", token: "changed" }, + ], + [ + { id: "a", token: "$${TOKEN}" }, + { id: "b", token: "literal" }, + ], + {}, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("preserves intentional real references beside same-name escaped literals", () => { + const result = restoreEnvVarRefs(["secret", "${TOKEN}"], ["${TOKEN}", "$${TOKEN}"], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv); + + expect(result).toEqual(["${TOKEN}", "$${TOKEN}"]); + }); + + it("rejects ambiguous same-name real and escaped reference reorders", () => { + expect(() => + restoreEnvVarRefs(["${TOKEN}", "secret"], ["${TOKEN}", "$${TOKEN}"], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects escaped references activated inside edited strings", () => { + expect(() => restoreEnvVarRefs(["changed-${TOKEN}"], ["prefix-$${TOKEN}"], {})).toThrow( + "Config write would reorder or modify an array containing environment references", + ); + }); + + it("rejects escaped references activated under a different object key", () => { + expect(() => restoreEnvVarRefs([{ next: "${TOKEN}" }], [{ old: "$${TOKEN}" }], {})).toThrow( + "Config write would reorder or modify an array containing environment references", + ); + }); + + it("does not let an existing active reference mask activation at another key", () => { + expect(() => + restoreEnvVarRefs( + [{ id: "x", moved: "${TOKEN}", active: "changed" }], + [{ id: "x", literal: "$${TOKEN}", active: "${TOKEN}" }], + {}, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("does not let a same-path active reference mask an activated escaped literal", () => { + expect(() => + restoreEnvVarRefs(["changed-${TOKEN}"], ["${TOKEN}-$${TOKEN}"], { + TOKEN: "secret", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("allows adding an active reference when a stable escaped entry remains preserved", () => { + const result = restoreEnvVarRefs( + [ + { id: "literal", token: "${TOKEN}" }, + { id: "new", token: "${TOKEN}" }, + ], + [{ id: "literal", token: "$${TOKEN}" }], + {}, + ); + + expect(result).toEqual([ + { id: "literal", token: "$${TOKEN}" }, + { id: "new", token: "${TOKEN}" }, + ]); + }); + + it("rejects swapping same-name active and escaped values between stable-id entries", () => { + expect(() => + restoreEnvVarRefs( + [ + { id: "literal", token: "secret" }, + { id: "active", token: "${TOKEN}" }, + ], + [ + { id: "literal", token: "$${TOKEN}" }, + { id: "active", token: "${TOKEN}" }, + ], + { TOKEN: "secret" } as unknown as NodeJS.ProcessEnv, + ), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects replacing a scalar template while adding its resolved value elsewhere", () => { + expect(() => + restoreEnvVarRefs(["replacement", "admin"], ["${ADMIN_ID}", "old"], { + ADMIN_ID: "admin", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + + it("rejects replacing a scalar template while adding its resolved value in a longer array", () => { + expect(() => + restoreEnvVarRefs(["old", "replacement", "admin"], ["${ADMIN_ID}", "old"], { + ADMIN_ID: "admin", + } as unknown as NodeJS.ProcessEnv), + ).toThrow("Config write would reorder or modify an array containing environment references"); + }); + it("handles type mismatches between incoming and parsed", () => { // Caller changed type from string to number const incoming = { port: 8080 }; diff --git a/src/config/env-preserve.ts b/src/config/env-preserve.ts index affb4853292..463ca0a923f 100644 --- a/src/config/env-preserve.ts +++ b/src/config/env-preserve.ts @@ -1,4 +1,5 @@ // Normalizes preserved environment-variable config for subprocess launches. +import { isDeepStrictEqual } from "node:util"; import { isPlainObject } from "../infra/plain-object.js"; /** @@ -18,6 +19,14 @@ import { isPlainObject } from "../infra/plain-object.js"; */ const ENV_VAR_PATTERN = /\$\{[A-Z_][A-Z0-9_]*\}/; +const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +export class EnvRefArrayMutationError extends Error { + constructor() { + super("Config write would reorder or modify an array containing environment references."); + this.name = "EnvRefArrayMutationError"; + } +} /** * Check if a string contains any `${VAR}` env var references. @@ -26,16 +35,629 @@ function hasEnvVarRef(value: string): boolean { return ENV_VAR_PATTERN.test(value); } +type AuthoredEnvRef = { kind: "escaped" | "unescaped"; name: string }; + +function collectAuthoredEnvRefs(value: string): AuthoredEnvRef[] { + const refs: AuthoredEnvRef[] = []; + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== "$") { + continue; + } + const isEscaped = value[index + 1] === "$" && value[index + 2] === "{"; + const nameStart = index + (isEscaped ? 3 : 2); + if (!isEscaped && value[index + 1] !== "{") { + continue; + } + const nameEnd = value.indexOf("}", nameStart); + if (nameEnd === -1 || !ENV_VAR_NAME_PATTERN.test(value.slice(nameStart, nameEnd))) { + continue; + } + refs.push({ + kind: isEscaped ? "escaped" : "unescaped", + name: value.slice(nameStart, nameEnd), + }); + index = nameEnd; + } + return refs; +} + +function hasUnescapedEnvVarRef(value: string): boolean { + return collectAuthoredEnvRefs(value).some((ref) => ref.kind === "unescaped"); +} + +function hasEscapedEnvVarRef(value: string): boolean { + return collectAuthoredEnvRefs(value).some((ref) => ref.kind === "escaped"); +} + +function containsAuthoredUnescapedEnvTemplate(value: unknown): boolean { + if (typeof value === "string") { + return hasUnescapedEnvVarRef(value); + } + if (Array.isArray(value)) { + return value.some((item) => containsAuthoredUnescapedEnvTemplate(item)); + } + if (isPlainObject(value)) { + return Object.values(value).some((item) => containsAuthoredUnescapedEnvTemplate(item)); + } + return false; +} + +function containsAuthoredEscapedEnvTemplate(value: unknown): boolean { + if (typeof value === "string") { + return hasEscapedEnvVarRef(value); + } + if (Array.isArray(value)) { + return value.some((item) => containsAuthoredEscapedEnvTemplate(item)); + } + if (isPlainObject(value)) { + return Object.values(value).some((item) => containsAuthoredEscapedEnvTemplate(item)); + } + return false; +} + +function countAuthoredEnvRefsByPath( + value: unknown, + kind: AuthoredEnvRef["kind"], +): Map> { + const countsByName = new Map>(); + const visit = (item: unknown, path: string[]) => { + if (typeof item === "string") { + for (const ref of collectAuthoredEnvRefs(item)) { + if (ref.kind === kind) { + const pathCounts = countsByName.get(ref.name) ?? new Map(); + const pathKey = JSON.stringify(path); + pathCounts.set(pathKey, (pathCounts.get(pathKey) ?? 0) + 1); + countsByName.set(ref.name, pathCounts); + } + } + return; + } + if (Array.isArray(item)) { + item.forEach((child, index) => visit(child, [...path, String(index)])); + return; + } + if (isPlainObject(item)) { + Object.entries(item).forEach(([key, child]) => visit(child, [...path, key])); + } + }; + visit(value, []); + return countsByName; +} + +function countResolvedActiveEnvRefsByPath( + incoming: unknown, + parsed: unknown, + env: NodeJS.ProcessEnv, +): Map> { + const countsByName = new Map>(); + const visit = (incomingItem: unknown, parsedItem: unknown, path: string[]) => { + if (typeof incomingItem === "string" && typeof parsedItem === "string") { + if (!isDeepStrictEqual(incomingItem, tryResolveString(parsedItem, env))) { + return; + } + for (const ref of collectAuthoredEnvRefs(parsedItem)) { + if (ref.kind === "unescaped") { + const pathCounts = countsByName.get(ref.name) ?? new Map(); + const pathKey = JSON.stringify(path); + pathCounts.set(pathKey, (pathCounts.get(pathKey) ?? 0) + 1); + countsByName.set(ref.name, pathCounts); + } + } + return; + } + if (Array.isArray(incomingItem) && Array.isArray(parsedItem)) { + parsedItem.forEach((child, index) => + visit(incomingItem[index], child, [...path, String(index)]), + ); + return; + } + if (isPlainObject(incomingItem) && isPlainObject(parsedItem)) { + Object.entries(parsedItem).forEach(([key, child]) => + visit(incomingItem[key], child, [...path, key]), + ); + } + }; + visit(incoming, parsed, []); + return countsByName; +} + +function containsUnaccountedActiveEscapedEnvRef( + incoming: unknown, + escapedParsed: unknown, + matchedIncoming: unknown, + matchedParsed: unknown, + env: NodeJS.ProcessEnv, +): boolean { + const escapedCounts = countAuthoredEnvRefsByPath(escapedParsed, "escaped"); + const incomingActiveCounts = countAuthoredEnvRefsByPath(incoming, "unescaped"); + const incomingEscapedCounts = countAuthoredEnvRefsByPath(incoming, "escaped"); + const matchedActiveCounts = countResolvedActiveEnvRefsByPath(matchedIncoming, matchedParsed, env); + const matchedEscapedCounts = countAuthoredEnvRefsByPath(matchedParsed, "escaped"); + return [...escapedCounts].some( + ([name, escapedPathCounts]) => + [...(incomingActiveCounts.get(name) ?? new Map())].some( + ([path, count]) => count > (matchedActiveCounts.get(name)?.get(path) ?? 0), + ) || + [...escapedPathCounts.keys()].some((path) => { + const incomingActiveCount = incomingActiveCounts.get(name)?.get(path) ?? 0; + return ( + incomingActiveCount > 0 && + (incomingEscapedCounts.get(name)?.get(path) ?? 0) < + (matchedEscapedCounts.get(name)?.get(path) ?? 0) + ); + }), + ); +} + +function preservesAuthoredEscapedEnvRefs(incoming: unknown, parsed: unknown): boolean { + const parsedEscapedCounts = countAuthoredEnvRefsByPath(parsed, "escaped"); + const incomingEscapedCounts = countAuthoredEnvRefsByPath(incoming, "escaped"); + return [...parsedEscapedCounts].every(([name, parsedPathCounts]) => + [...parsedPathCounts].every( + ([path, count]) => (incomingEscapedCounts.get(name)?.get(path) ?? 0) >= count, + ), + ); +} + +type ArrayIdentityPath = string[]; + +function getArrayIdentityPathValue(value: unknown, path: ArrayIdentityPath): unknown { + let current = value; + for (const segment of path) { + if (!isPlainObject(current)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function collectStableArrayIdentityPaths(value: unknown): ArrayIdentityPath[] { + if (!isPlainObject(value)) { + return []; + } + for (const key of ["id", "agentId"]) { + const child = value[key]; + if (typeof child === "string" && !hasEnvVarRef(child)) { + return [[key]]; + } + } + return []; +} + +function resolveStableArrayIdentityMatch(params: { + incoming: unknown[]; + parsed: unknown[]; + parsedIndex: number; +}): { kind: "none" } | { kind: "invalid" } | { kind: "match"; incomingIndex: number } { + const parsedItem = params.parsed[params.parsedIndex]; + const identityPaths = collectStableArrayIdentityPaths(parsedItem); + if (identityPaths.length === 0) { + return { kind: "none" }; + } + + let incomingIndex: number | undefined; + let hasUniqueAuthoredIdentity = false; + for (const identityPath of identityPaths) { + const identityValue = getArrayIdentityPathValue(parsedItem, identityPath); + const authoredCount = params.parsed.filter((item) => + isDeepStrictEqual(getArrayIdentityPathValue(item, identityPath), identityValue), + ).length; + if (authoredCount !== 1) { + continue; + } + hasUniqueAuthoredIdentity = true; + const incomingMatches = params.incoming.flatMap((item, index) => + isDeepStrictEqual(getArrayIdentityPathValue(item, identityPath), identityValue) + ? [index] + : [], + ); + if ( + incomingMatches.length !== 1 || + (incomingIndex !== undefined && incomingIndex !== incomingMatches[0]) + ) { + return { kind: "invalid" }; + } + incomingIndex = incomingMatches[0]; + } + if (incomingIndex !== undefined) { + return { kind: "match", incomingIndex }; + } + return hasUniqueAuthoredIdentity ? { kind: "invalid" } : { kind: "none" }; +} + +function collectLiteralArrayIdentityPaths( + value: unknown, + path: ArrayIdentityPath = [], +): ArrayIdentityPath[] { + if (typeof value === "string") { + return hasEnvVarRef(value) ? [] : [path]; + } + if (!isPlainObject(value)) { + return []; + } + return Object.entries(value).flatMap(([key, child]) => + collectLiteralArrayIdentityPaths(child, [...path, key]), + ); +} + +function hasStableSameIndexLiteralShape(params: { + incoming: unknown[]; + parsed: unknown[]; + parsedIndex: number; +}): boolean { + if (params.incoming.length !== params.parsed.length) { + return false; + } + const parsedItem = params.parsed[params.parsedIndex]; + const literalPaths = collectLiteralArrayIdentityPaths(parsedItem); + if ( + literalPaths.length === 0 || + literalPaths.some((identityPath) => { + const identityValue = getArrayIdentityPathValue(parsedItem, identityPath); + return !isDeepStrictEqual( + getArrayIdentityPathValue(params.incoming[params.parsedIndex], identityPath), + identityValue, + ); + }) + ) { + return false; + } + return literalPaths.some((identityPath) => { + const identityValue = getArrayIdentityPathValue(parsedItem, identityPath); + const authoredCount = params.parsed.filter((item) => + isDeepStrictEqual(getArrayIdentityPathValue(item, identityPath), identityValue), + ).length; + const incomingCount = params.incoming.filter((item) => + isDeepStrictEqual(getArrayIdentityPathValue(item, identityPath), identityValue), + ).length; + return authoredCount === 1 && incomingCount === 1; + }); +} + +function matchesArrayElementAtSameIndex( + incoming: unknown, + parsed: unknown, + env: NodeJS.ProcessEnv, +): boolean { + return ( + isDeepStrictEqual(incoming, parsed) || + isDeepStrictEqual(incoming, resolveEnvVarRefsForComparison(parsed, env)) + ); +} + +function matchesRetainedArrayItem(params: { + incoming: unknown[]; + incomingIndex: number; + parsed: unknown[]; + parsedIndex: number; + env: NodeJS.ProcessEnv; +}): boolean { + if ( + matchesArrayElementAtSameIndex( + params.incoming[params.incomingIndex], + params.parsed[params.parsedIndex], + params.env, + ) + ) { + return true; + } + const stableIdentity = resolveStableArrayIdentityMatch({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex: params.parsedIndex, + }); + return stableIdentity.kind === "match" && stableIdentity.incomingIndex === params.incomingIndex; +} + +function hasStableSameIndexNeighbors(params: { + incoming: unknown[]; + parsed: unknown[]; + parsedIndex: number; + env: NodeJS.ProcessEnv; +}): boolean { + return ( + params.incoming.length === params.parsed.length && + params.parsed.every( + (item, index) => + index === params.parsedIndex || + matchesArrayElementAtSameIndex(params.incoming[index], item, params.env), + ) + ); +} + +function matchUniqueRetainedArrayItems(params: { + incoming: unknown[]; + parsed: unknown[]; + env: NodeJS.ProcessEnv; +}): Map | undefined { + if (params.incoming.length >= params.parsed.length) { + return undefined; + } + + const earliestParsedIndexes: number[] = []; + let nextParsedIndex = 0; + for (let incomingIndex = 0; incomingIndex < params.incoming.length; incomingIndex += 1) { + const parsedIndex = params.parsed.findIndex( + (_parsedItem, index) => + index >= nextParsedIndex && + matchesRetainedArrayItem({ + ...params, + incomingIndex, + parsedIndex: index, + }), + ); + if (parsedIndex < 0) { + return undefined; + } + earliestParsedIndexes.push(parsedIndex); + nextParsedIndex = parsedIndex + 1; + } + + const latestParsedIndexes = Array.from({ length: params.incoming.length }, () => 0); + nextParsedIndex = params.parsed.length - 1; + for (let incomingIndex = params.incoming.length - 1; incomingIndex >= 0; incomingIndex -= 1) { + let parsedIndex = nextParsedIndex; + while ( + parsedIndex >= 0 && + !matchesRetainedArrayItem({ + ...params, + incomingIndex, + parsedIndex, + }) + ) { + parsedIndex -= 1; + } + if (parsedIndex < 0) { + return undefined; + } + latestParsedIndexes[incomingIndex] = parsedIndex; + nextParsedIndex = parsedIndex - 1; + } + + if (!isDeepStrictEqual(earliestParsedIndexes, latestParsedIndexes)) { + return undefined; + } + return new Map( + earliestParsedIndexes.map((parsedIndex, incomingIndex) => [parsedIndex, incomingIndex]), + ); +} + +function matchAuthoredTemplateArrayItems(params: { + incoming: unknown[]; + parsed: unknown[]; + env: NodeJS.ProcessEnv; +}): Map { + const templateIndexes = params.parsed.flatMap((item, index) => + containsAuthoredUnescapedEnvTemplate(item) ? [index] : [], + ); + if ( + params.incoming.length === params.parsed.length && + params.incoming.every((item, index) => + matchesArrayElementAtSameIndex(item, params.parsed[index], params.env), + ) + ) { + return new Map(templateIndexes.map((index) => [index, index])); + } + const retainedDeletionMatches = matchUniqueRetainedArrayItems(params); + if (retainedDeletionMatches) { + return new Map( + templateIndexes.flatMap((parsedIndex) => { + const incomingIndex = retainedDeletionMatches.get(parsedIndex); + return incomingIndex === undefined ? [] : [[parsedIndex, incomingIndex]]; + }), + ); + } + + const matches = new Map(); + const usedIncomingIndexes = new Set(); + const addMatch = (parsedIndex: number, incomingIndex: number) => { + if (usedIncomingIndexes.has(incomingIndex)) { + throw new EnvRefArrayMutationError(); + } + matches.set(parsedIndex, incomingIndex); + usedIncomingIndexes.add(incomingIndex); + }; + for (const parsedIndex of templateIndexes) { + const parsedItem = params.parsed[parsedIndex]; + const stableIdentity = resolveStableArrayIdentityMatch({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + }); + if (stableIdentity.kind !== "none") { + if (stableIdentity.kind === "invalid") { + throw new EnvRefArrayMutationError(); + } + addMatch(parsedIndex, stableIdentity.incomingIndex); + continue; + } + + if ( + parsedIndex < params.incoming.length && + matchesArrayElementAtSameIndex(params.incoming[parsedIndex], parsedItem, params.env) + ) { + const precedingItemsRemainAligned = params.parsed + .slice(0, parsedIndex) + .every((item, index) => + matchesArrayElementAtSameIndex(params.incoming[index], item, params.env), + ); + const duplicateAuthoredMatch = params.parsed.some( + (item, index) => + index !== parsedIndex && + matchesArrayElementAtSameIndex(params.incoming[parsedIndex], item, params.env), + ); + const duplicateIncomingMatch = params.incoming.some( + (item, index) => + index !== parsedIndex && matchesArrayElementAtSameIndex(item, parsedItem, params.env), + ); + const positionRemainsStable = + params.incoming.length === params.parsed.length || precedingItemsRemainAligned; + if (!positionRemainsStable || duplicateAuthoredMatch || duplicateIncomingMatch) { + throw new EnvRefArrayMutationError(); + } + addMatch(parsedIndex, parsedIndex); + continue; + } + + if (isPlainObject(parsedItem) || Array.isArray(parsedItem)) { + const isSinglePositionEdit = params.incoming.length === 1 && params.parsed.length === 1; + const hasSameIndexLiteralIdentity = hasStableSameIndexLiteralShape({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + }); + const hasSameIndexNeighbors = hasStableSameIndexNeighbors({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + env: params.env, + }); + if (!isSinglePositionEdit && !hasSameIndexLiteralIdentity && !hasSameIndexNeighbors) { + throw new EnvRefArrayMutationError(); + } + addMatch(parsedIndex, parsedIndex); + continue; + } + const crossIndexMatches = params.incoming.some( + (item, incomingIndex) => + incomingIndex !== parsedIndex && + matchesArrayElementAtSameIndex(item, parsedItem, params.env), + ); + if (crossIndexMatches) { + throw new EnvRefArrayMutationError(); + } + if (parsedIndex < params.incoming.length) { + addMatch(parsedIndex, parsedIndex); + } + } + return matches; +} + +function matchAuthoredEscapedTemplateArrayItems(params: { + incoming: unknown[]; + parsed: unknown[]; + env: NodeJS.ProcessEnv; + usedIncomingIndexes: Set; +}): Map { + const escapedTemplateIndexes = params.parsed.flatMap((item, index) => + containsAuthoredEscapedEnvTemplate(item) && !containsAuthoredUnescapedEnvTemplate(item) + ? [index] + : [], + ); + if ( + params.incoming.length === params.parsed.length && + params.incoming.every((item, index) => + matchesArrayElementAtSameIndex(item, params.parsed[index], params.env), + ) + ) { + return new Map(escapedTemplateIndexes.map((index) => [index, index])); + } + const retainedDeletionMatches = matchUniqueRetainedArrayItems(params); + if (retainedDeletionMatches) { + return new Map( + escapedTemplateIndexes.flatMap((parsedIndex) => { + const incomingIndex = retainedDeletionMatches.get(parsedIndex); + if (incomingIndex === undefined) { + return []; + } + if (params.usedIncomingIndexes.has(incomingIndex)) { + throw new EnvRefArrayMutationError(); + } + return [[parsedIndex, incomingIndex]]; + }), + ); + } + const matches = new Map(); + const usedIncomingIndexes = new Set(params.usedIncomingIndexes); + const addMatch = (parsedIndex: number, incomingIndex: number) => { + if (usedIncomingIndexes.has(incomingIndex)) { + throw new EnvRefArrayMutationError(); + } + matches.set(parsedIndex, incomingIndex); + usedIncomingIndexes.add(incomingIndex); + }; + + for (const parsedIndex of escapedTemplateIndexes) { + const parsedItem = params.parsed[parsedIndex]; + const stableIdentity = resolveStableArrayIdentityMatch({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + }); + if (stableIdentity.kind !== "none") { + if (stableIdentity.kind === "match") { + addMatch(parsedIndex, stableIdentity.incomingIndex); + continue; + } + } + + const resolvedItem = resolveEnvVarRefsForComparison(parsedItem, params.env); + const incomingMatches = params.incoming.flatMap((item, incomingIndex) => + !usedIncomingIndexes.has(incomingIndex) && isDeepStrictEqual(item, resolvedItem) + ? [incomingIndex] + : [], + ); + const authoredMatches = escapedTemplateIndexes.filter((index) => + isDeepStrictEqual( + resolveEnvVarRefsForComparison(params.parsed[index], params.env), + resolvedItem, + ), + ); + const authoredRepresentationsAreIdentical = authoredMatches.every((index) => + isDeepStrictEqual(params.parsed[index], parsedItem), + ); + if ( + incomingMatches.length > 0 && + incomingMatches.length <= authoredMatches.length && + authoredRepresentationsAreIdentical + ) { + const sameIndexMatch = incomingMatches.includes(parsedIndex) + ? parsedIndex + : incomingMatches[0]; + addMatch(parsedIndex, sameIndexMatch); + continue; + } + if (incomingMatches.length > 0) { + throw new EnvRefArrayMutationError(); + } + + if (isPlainObject(parsedItem) || Array.isArray(parsedItem)) { + const isSinglePositionEdit = params.incoming.length === 1 && params.parsed.length === 1; + const hasSameIndexLiteralIdentity = hasStableSameIndexLiteralShape({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + }); + const hasSameIndexNeighbors = hasStableSameIndexNeighbors({ + incoming: params.incoming, + parsed: params.parsed, + parsedIndex, + env: params.env, + }); + if ( + stableIdentity.kind === "none" && + parsedIndex < params.incoming.length && + !usedIncomingIndexes.has(parsedIndex) && + (isSinglePositionEdit || hasSameIndexLiteralIdentity || hasSameIndexNeighbors) + ) { + addMatch(parsedIndex, parsedIndex); + continue; + } + } + } + return matches; +} + /** * Resolve `${VAR}` references in a single string using the given env. - * Returns null if any referenced var is missing (instead of throwing). + * Preserves missing references so matching remains aligned with config reads. * * Mirrors the substitution semantics of `substituteString` in env-substitution.ts: * - `${VAR}` → env value (returns null if missing) * - `$${VAR}` → literal `${VAR}` (escape sequence) */ -function tryResolveString(template: string, env: NodeJS.ProcessEnv): string | null { - const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/; +function tryResolveString(template: string, env: NodeJS.ProcessEnv): string { const chunks: string[] = []; for (let i = 0; i < template.length; i++) { @@ -46,7 +668,7 @@ function tryResolveString(template: string, env: NodeJS.ProcessEnv): string | nu const end = template.indexOf("}", start); if (end !== -1) { const name = template.slice(start, end); - if (ENV_VAR_NAME.test(name)) { + if (ENV_VAR_NAME_PATTERN.test(name)) { chunks.push(`\${${name}}`); i = end; continue; @@ -60,10 +682,12 @@ function tryResolveString(template: string, env: NodeJS.ProcessEnv): string | nu const end = template.indexOf("}", start); if (end !== -1) { const name = template.slice(start, end); - if (ENV_VAR_NAME.test(name)) { + if (ENV_VAR_NAME_PATTERN.test(name)) { const val = env[name]; if (val === undefined || val === "") { - return null; + chunks.push(`\${${name}}`); + i = end; + continue; } chunks.push(val); i = end; @@ -78,6 +702,21 @@ function tryResolveString(template: string, env: NodeJS.ProcessEnv): string | nu return chunks.join(""); } +function resolveEnvVarRefsForComparison(value: unknown, env: NodeJS.ProcessEnv): unknown { + if (typeof value === "string") { + return hasEnvVarRef(value) ? tryResolveString(value, env) : value; + } + if (Array.isArray(value)) { + return value.map((item) => resolveEnvVarRefsForComparison(item, env)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, resolveEnvVarRefsForComparison(item, env)]), + ); + } + return value; +} + /** * Deep-walk the incoming config and restore `${VAR}` references from the * pre-substitution parsed config wherever the resolved value matches. @@ -109,11 +748,71 @@ export function restoreEnvVarRefs( return incoming; } - // Arrays: walk element by element + // Array template entries must retain a unique identity before authored refs + // can be restored; ambiguous moves would attach secrets or activate escaped + // literals on the wrong entry. if (Array.isArray(incoming) && Array.isArray(parsed)) { - return incoming.map((item, i) => - i < parsed.length ? restoreEnvVarRefs(item, parsed[i], env) : item, + if ( + !containsAuthoredUnescapedEnvTemplate(parsed) && + !containsAuthoredEscapedEnvTemplate(parsed) + ) { + return incoming.map((item, index) => + index < parsed.length ? restoreEnvVarRefs(item, parsed[index], env) : item, + ); + } + // Keep same-name real/escaped scalar reorders fail-closed: a raw `${VAR}` + // is indistinguishable from a moved escaped literal or a newly active ref. + const unescapedMatches = matchAuthoredTemplateArrayItems({ incoming, parsed, env }); + const escapedMatches = matchAuthoredEscapedTemplateArrayItems({ + incoming, + parsed, + env, + usedIncomingIndexes: new Set(unescapedMatches.values()), + }); + const matches = new Map([...unescapedMatches, ...escapedMatches]); + const next = [...incoming]; + const matchedIncomingIndexes = new Set(matches.values()); + for (const [parsedIndex, incomingIndex] of matches) { + next[incomingIndex] = restoreEnvVarRefs(incoming[incomingIndex], parsed[parsedIndex], env); + } + for (let index = 0; index < incoming.length && index < parsed.length; index += 1) { + if ( + !matchedIncomingIndexes.has(index) && + !containsAuthoredUnescapedEnvTemplate(parsed[index]) && + !containsAuthoredEscapedEnvTemplate(parsed[index]) + ) { + next[index] = restoreEnvVarRefs(incoming[index], parsed[index], env); + } + } + const matchedParsedIndexByIncoming = new Map( + [...matches].map(([parsedIndex, incomingIndex]) => [incomingIndex, parsedIndex]), ); + for (const [escapedParsedIndex, escapedParsedItem] of parsed.entries()) { + if (!containsAuthoredEscapedEnvTemplate(escapedParsedItem)) { + continue; + } + const matchedIncomingIndex = matches.get(escapedParsedIndex); + if ( + matchedIncomingIndex !== undefined && + preservesAuthoredEscapedEnvRefs(next[matchedIncomingIndex], escapedParsedItem) + ) { + continue; + } + const hasUnaccountedActiveReference = next.some((item, incomingIndex) => { + const matchedParsedIndex = matchedParsedIndexByIncoming.get(incomingIndex); + return containsUnaccountedActiveEscapedEnvRef( + item, + escapedParsedItem, + incoming[incomingIndex], + matchedParsedIndex === undefined ? undefined : parsed[matchedParsedIndex], + env, + ); + }); + if (hasUnaccountedActiveReference) { + throw new EnvRefArrayMutationError(); + } + } + return next; } // Objects: walk key by key diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 576e15de3f3..2fc957ca0e7 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -11,6 +11,7 @@ import { MAX_INCLUDE_PATH_LENGTH, deepMerge, type IncludeResolver, + resolveConfigIncludeWritePath, resolveConfigIncludes, } from "./includes.js"; @@ -338,6 +339,31 @@ describe("resolveConfigIncludes", () => { }); }); +describe("resolveConfigIncludeWritePath", () => { + it.runIf(process.platform !== "win32")( + "canonicalizes missing targets through symlinks into allowed roots", + async () => { + await withTempDir({ prefix: "openclaw-include-write-path-" }, async (tempRoot) => { + const configDir = path.join(tempRoot, "config"); + const allowedDir = path.join(tempRoot, "allowed"); + const linkDir = path.join(configDir, "shared"); + await fs.mkdir(configDir, { recursive: true }); + await fs.mkdir(allowedDir, { recursive: true }); + await fs.symlink(allowedDir, linkDir); + const allowedRealDir = await fs.realpath(allowedDir); + + expect( + resolveConfigIncludeWritePath({ + configPath: path.join(configDir, "openclaw.json"), + includePath: path.join(linkDir, "plugins.json5"), + allowedRoots: [allowedDir], + }), + ).toBe(path.join(allowedRealDir, "plugins.json5")); + }); + }, + ); +}); + describe("real-world config patterns", () => { it.each([ { diff --git a/src/config/includes.ts b/src/config/includes.ts index e134973b215..57436d42622 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -10,9 +10,11 @@ * ``` */ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { canUseRootFileOpen, openRootFileSync } from "../infra/boundary-file-read.js"; +import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { isPathInside } from "../security/scan-paths.js"; import { isPlainObject } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; @@ -25,6 +27,45 @@ export const MAX_INCLUDE_FILE_BYTES = 2 * 1024 * 1024; /** Maximum length for $include path and resolved path (CWE-22 hardening). */ export const MAX_INCLUDE_PATH_LENGTH = 4096; +export function hashConfigIncludeRaw(raw: string | null): string { + const hash = crypto.createHash("sha256"); + if (raw === null) { + hash.update("missing"); + } else { + hash.update("present\0"); + hash.update(raw, "utf-8"); + } + return hash.digest("hex"); +} + +/** Resolve an include write target through its current ancestors and allowed roots. */ +export function resolveConfigIncludeWritePath(params: { + configPath: string; + includePath: string; + allowedRoots?: readonly string[]; +}): string { + const resolvedPath = path.normalize(path.resolve(params.includePath)); + const roots = [path.dirname(params.configPath), ...(params.allowedRoots ?? [])] + .filter((root) => path.isAbsolute(root)) + .map((root) => path.normalize(root)); + if (!roots.some((root) => isPathInside(root, resolvedPath))) { + throw new ConfigIncludeError( + `Include write path escapes config directory: ${params.includePath}`, + params.includePath, + ); + } + + const canonicalPath = path.normalize(resolvePathViaExistingAncestorSync(resolvedPath)); + const realRoots = roots.map((root) => path.normalize(safeRealpath(root))); + if (!realRoots.some((root) => isPathInside(root, canonicalPath))) { + throw new ConfigIncludeError( + `Include write path resolves outside config directory (symlink): ${params.includePath}`, + params.includePath, + ); + } + return canonicalPath; +} + // ============================================================================ // Types // ============================================================================ @@ -41,6 +82,7 @@ type IncludeFileReadParams = { rootRealDir: string; ioFs?: typeof fs; maxBytes?: number; + onResolvedPath?: (resolvedPath: string) => void; }; type ResolveConfigIncludesOptions = { @@ -380,7 +422,13 @@ export function readConfigIncludeFileWithGuards(params: IncludeFileReadParams): const ioFs = params.ioFs ?? fs; const maxBytes = params.maxBytes ?? MAX_INCLUDE_FILE_BYTES; if (!canUseRootFileOpen(ioFs)) { - return ioFs.readFileSync(params.resolvedPath, "utf-8"); + const raw = ioFs.readFileSync(params.resolvedPath, "utf-8"); + try { + params.onResolvedPath?.(path.normalize(ioFs.realpathSync(params.resolvedPath))); + } catch { + // The guarded read succeeded; target tracking is best-effort on reduced fs shims. + } + return raw; } const opened = openRootFileSync({ @@ -407,7 +455,9 @@ export function readConfigIncludeFileWithGuards(params: IncludeFileReadParams): } try { - return ioFs.readFileSync(opened.fd, "utf-8"); + const raw = ioFs.readFileSync(opened.fd, "utf-8"); + params.onResolvedPath?.(path.normalize(opened.path)); + return raw; } finally { ioFs.closeSync(opened.fd); } diff --git a/src/config/io.ts b/src/config/io.ts index 412bb5d72cf..563fa50b887 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -11,7 +11,11 @@ import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { isVerbose } from "../global-state.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { isTruthyEnvValue } from "../infra/env.js"; -import { formatErrorMessage } from "../infra/errors.js"; +import { + collectErrorGraphCandidates, + extractErrorCode, + formatErrorMessage, +} from "../infra/errors.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { replaceFileAtomic, replaceFileAtomicSync } from "../infra/replace-file.js"; import { @@ -33,7 +37,7 @@ import { isRecord } from "../utils.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { maintainConfigBackups } from "./backup-rotation.js"; -import { restoreEnvVarRefs } from "./env-preserve.js"; +import { EnvRefArrayMutationError, restoreEnvVarRefs } from "./env-preserve.js"; import { type EnvSubstitutionWarning, containsEnvVarReference, @@ -42,8 +46,10 @@ import { import { applyConfigEnvVars } from "./env-vars.js"; import { ConfigIncludeError, + hashConfigIncludeRaw, INCLUDE_KEY, readConfigIncludeFileWithGuards, + resolveConfigIncludeWritePath, resolveConfigIncludes, } from "./includes.js"; import { @@ -70,6 +76,7 @@ import { createMergePatch, formatConfigValidationFailure, applyUnsetPathsForWrite, + preserveIncludeOwnedConfigForWrite, restoreEnvRefsFromMap, resolvePersistCandidateForWrite, resolveManagedUnsetPathsForWrite, @@ -81,6 +88,7 @@ import { materializeRuntimeConfig, } from "./materialize.js"; import { applyMergePatch } from "./merge-patch.js"; +import { ConfigMutationConflictError } from "./mutation-conflict.js"; import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js"; import { @@ -197,6 +205,13 @@ export type ConfigWriteOptions = { * same config file path that produced the snapshot. */ expectedConfigPath?: string; + /** Internal write destination captured by readConfigFileSnapshotForWrite(). */ + ownedConfigPathForWrite?: string; + /** + * Internal mutation-start ownership guard. Rechecks that the config path + * captured by readConfigFileSnapshotForWrite() is still active at commit. + */ + assertConfigPathForWrite?: () => void; /** * Paths that must be explicitly removed from the persisted file payload, * even if schema/default normalization reintroduces them. @@ -272,6 +287,10 @@ export type ConfigWriteOptions = { * has produced the exact source config that will be committed. */ preCommitRuntimePreflight?: (sourceConfig: OpenClawConfig) => Promise; + /** Internal snapshot-time hashes for include files that mutation writers may update directly. */ + includeFileHashesForWrite?: Record; + /** Internal snapshot-time canonical targets for include files that mutation writers may update. */ + includeFileTargetsForWrite?: Record; }; export type ReadConfigFileSnapshotForWriteResult = { @@ -290,10 +309,43 @@ export class ConfigRuntimeRefreshError extends Error { } function hashConfigRaw(raw: string | null): string { - return crypto - .createHash("sha256") - .update(raw ?? "") - .digest("hex"); + // Present-file hashes stay compatible with last-known-good recovery metadata. + // Missing needs a distinct token so optimistic writes reject missing-to-empty races. + if (raw === null) { + return hashConfigIncludeRaw(null); + } + return crypto.createHash("sha256").update(raw).digest("hex"); +} + +function assertBaseSnapshotStillCurrent( + snapshot: ConfigFileSnapshot, + configPath: string, + ioFs: typeof fs, +): void { + if (snapshot.path !== configPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + const expectedHash = resolveConfigSnapshotHash(snapshot); + let currentRaw: string | null = null; + let currentExists = true; + try { + currentRaw = ioFs.readFileSync(configPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + currentExists = false; + } + const currentHash = currentExists ? hashConfigRaw(currentRaw) : null; + if ( + currentExists !== snapshot.exists || + (currentExists && expectedHash !== null && currentHash !== expectedHash) + ) { + throw new ConfigMutationConflictError("config changed since last load", { currentHash }); + } } async function tightenStateDirPermissionsIfNeeded(params: { @@ -342,10 +394,11 @@ async function rollbackConfigFileWriteIfUnchanged(params: { configPath: string; previousSnapshot: ConfigFileSnapshot; committedHash: string; + fsModule: typeof fs; }): Promise { let currentRaw: string | null = null; try { - currentRaw = await fs.promises.readFile(params.configPath, "utf-8"); + currentRaw = await params.fsModule.promises.readFile(params.configPath, "utf-8"); } catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { throw error; @@ -362,6 +415,7 @@ async function rollbackConfigFileWriteIfUnchanged(params: { mode: 0o600, tempPrefix: path.basename(params.configPath), copyFallbackOnPermissionError: true, + fileSystem: params.fsModule, }); return true; } @@ -369,7 +423,7 @@ async function rollbackConfigFileWriteIfUnchanged(params: { return false; } try { - await fs.promises.unlink(params.configPath); + await params.fsModule.promises.unlink(params.configPath); } catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { throw error; @@ -1229,22 +1283,62 @@ function resolveConfigIncludesForRead( parsed: unknown, configPath: string, deps: Required, + includeFileHashesForWrite?: Record, + includeFileTargetsForWrite?: Record, ): unknown { + const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir); + const recordIncludeTarget = (resolvedPath: string, canonicalPath?: string) => { + if (!includeFileTargetsForWrite) { + return; + } + const normalizedPath = path.normalize(resolvedPath); + try { + includeFileTargetsForWrite[normalizedPath] = path.normalize( + canonicalPath ?? + resolveConfigIncludeWritePath({ + configPath, + includePath: resolvedPath, + allowedRoots, + }), + ); + } catch { + // Unsafe or unresolvable targets remain unavailable to direct include mutation. + } + }; return resolveConfigIncludes( parsed, configPath, { readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"), - readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) => - readConfigIncludeFileWithGuards({ - includePath, - resolvedPath, - rootRealDir, - ioFs: deps.fs, - }), + readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) => { + try { + const raw = readConfigIncludeFileWithGuards({ + includePath, + resolvedPath, + rootRealDir, + ioFs: deps.fs, + onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath), + }); + if (includeFileHashesForWrite) { + includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw); + } + return raw; + } catch (error) { + const missing = collectErrorGraphCandidates(error, (current) => [current.cause]).some( + (candidate) => extractErrorCode(candidate) === "ENOENT", + ); + if (includeFileHashesForWrite && missing) { + includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(null); + } + if (missing) { + recordIncludeTarget(resolvedPath); + } + throw error; + } + }, parseJson: (raw) => deps.json5.parse(raw), }, - { allowedRoots: resolveIncludeRoots(deps.env, deps.homedir) }, + { allowedRoots }, ); } @@ -1296,6 +1390,8 @@ export function restoreEnvChangesIfUnchanged(params: { type ReadConfigFileSnapshotInternalResult = { snapshot: ConfigFileSnapshot; envSnapshotForRestore?: Record; + includeFileHashesForWrite?: Record; + includeFileTargetsForWrite?: Record; pluginMetadataSnapshot?: PluginMetadataSnapshot; }; @@ -1845,6 +1941,9 @@ export function createConfigIO( let fallbackParsed: unknown = {}; let fallbackSourceConfig: OpenClawConfig = {}; let fallbackHash = hashConfigRaw(null); + let fallbackEnvSnapshotForRestore: Record | undefined; + const includeFileHashesForWrite: Record = {}; + const includeFileTargetsForWrite: Record = {}; try { const raw = await deps.measure("config.snapshot.read.file", () => @@ -1887,7 +1986,13 @@ export function createConfigIO( let resolved: unknown; try { resolved = await deps.measure("config.snapshot.read.includes", () => - resolveConfigIncludesForRead(effectiveParsed, configPath, deps), + resolveConfigIncludesForRead( + effectiveParsed, + configPath, + deps, + includeFileHashesForWrite, + includeFileTargetsForWrite, + ), ); } catch (err) { const message = @@ -1908,12 +2013,15 @@ export function createConfigIO( warnings: [], legacyIssues: [], }), + includeFileHashesForWrite, + includeFileTargetsForWrite, }); } const readResolution = await deps.measure("config.snapshot.read.env", () => resolveConfigForRead(resolved, deps.env), ); + fallbackEnvSnapshotForRestore = readResolution.envSnapshotForRestore; // Convert missing env var references to config warnings instead of fatal errors. // This allows the gateway to start in degraded mode when non-critical config @@ -1989,6 +2097,9 @@ export function createConfigIO( warnings: [...validated.warnings, ...envVarWarnings], legacyIssues, }), + envSnapshotForRestore: readResolution.envSnapshotForRestore, + includeFileHashesForWrite, + includeFileTargetsForWrite, }); } @@ -2048,6 +2159,8 @@ export function createConfigIO( legacyIssues: [], }), envSnapshotForRestore: readResolution.envSnapshotForRestore, + includeFileHashesForWrite, + includeFileTargetsForWrite, pluginMetadataSnapshot, }), ); @@ -2085,6 +2198,9 @@ export function createConfigIO( warnings: [], legacyIssues: [], }), + envSnapshotForRestore: fallbackEnvSnapshotForRestore, + includeFileHashesForWrite, + includeFileTargetsForWrite, }); } } @@ -2144,13 +2260,28 @@ export function createConfigIO( } async function readConfigFileSnapshotForWriteLocal(): Promise { + const assertConfigPathForWrite = () => { + const activeConfigPath = resolveConfigPathForDeps(deps); + if (activeConfigPath !== configPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }; + assertConfigPathForWrite(); const result = await readConfigFileSnapshotInternal(); + assertConfigPathForWrite(); return { snapshot: result.snapshot, writeOptions: { + assertConfigPathForWrite, basePluginMetadataSnapshot: result.pluginMetadataSnapshot, envSnapshotForRestore: result.envSnapshotForRestore, expectedConfigPath: configPath, + ownedConfigPathForWrite: configPath, + includeFileHashesForWrite: result.includeFileHashesForWrite, + includeFileTargetsForWrite: result.includeFileTargetsForWrite, unsetPaths: resolveManagedUnsetPathsForWrite(undefined), }, }; @@ -2210,6 +2341,7 @@ export function createConfigIO( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, ): Promise { + options.assertConfigPathForWrite?.(); assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env }); clearConfigCache(); const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths); @@ -2221,8 +2353,17 @@ export function createConfigIO( } : await readConfigFileSnapshotInternal(); const snapshot = snapshotRead.snapshot; + if (options.baseSnapshot) { + assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs); + } let envRefMap: Map | null = null; let changedPaths: Set | null = null; + const identityRestoredPaths = new Set(); + const hasAuthoredIncludes = containsConfigIncludeDirective(snapshot.parsed); + // Valid authored directives keep ownership even when a descendant include + // is broken. Malformed directives remain removable by replacement repairs. + const hasResolvedAuthoredIncludes = + hasAuthoredIncludes && !containsConfigIncludeDirective(snapshot.sourceConfig); if (snapshot.valid && snapshot.exists) { persistCandidate = resolvePersistCandidateForWrite({ runtimeConfig: snapshot.config, @@ -2236,6 +2377,15 @@ export function createConfigIO( ? collectManifestModelIdNormalizationPolicies(snapshotRead.pluginMetadataSnapshot.plugins) : undefined, }); + } else if (snapshot.exists && hasAuthoredIncludes) { + persistCandidate = preserveIncludeOwnedConfigForWrite({ + runtimeConfig: snapshot.config, + sourceConfig: snapshot.resolved, + nextConfig: cfg, + rootAuthoredConfig: snapshot.parsed, + }); + } + if (snapshot.exists && (snapshot.valid || hasResolvedAuthoredIncludes)) { try { const resolvedIncludes = resolveConfigIncludes( snapshot.parsed, @@ -2267,7 +2417,14 @@ export function createConfigIO( persistCandidate = applyUnsetPathsForWrite(persistCandidate as OpenClawConfig, unsetPaths); - const validated = validateConfigObjectRawWithPlugins(persistCandidate, { + const envForRestore = options.envSnapshotForRestore ?? deps.env; + const validationSourceCandidate = containsConfigIncludeDirective(persistCandidate) + ? restoreEnvVarRefs(persistCandidate, snapshot.parsed, envForRestore) + : persistCandidate; + const validationCandidate = containsConfigIncludeDirective(validationSourceCandidate) + ? resolveRuntimePreflightSourceConfig(validationSourceCandidate as OpenClawConfig) + : validationSourceCandidate; + const validated = validateConfigObjectRawWithPlugins(validationCandidate, { env: deps.env, pluginValidation: options.skipPluginValidation ? "skip" : "full", preservedLegacyRootKeys: options.preservedLegacyRootKeys, @@ -2307,15 +2464,19 @@ export function createConfigIO( // Use env snapshot from when config was loaded (if available) to avoid // 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). - const envForRestore = options.envSnapshotForRestore ?? deps.env; + const configBeforeIdentityRestore = cfgToWrite; cfgToWrite = restoreEnvVarRefs( cfgToWrite, parsedRes.parsed, envForRestore, ) as OpenClawConfig; + collectChangedPaths(configBeforeIdentityRestore, cfgToWrite, "", identityRestoredPaths); } } - } catch { + } catch (error) { + if (error instanceof EnvRefArrayMutationError) { + throw error; + } // If reading the current file fails, write cfg as-is (no env restoration) } @@ -2329,7 +2490,13 @@ export function createConfigIO( }); const outputConfigBase = envRefMap && changedPaths - ? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig) + ? (restoreEnvRefsFromMap( + cfgToWrite, + "", + envRefMap, + changedPaths, + identityRestoredPaths, + ) as OpenClawConfig) : cfgToWrite; const tildeRestoredOutputConfig = restoreAuthoredTildePathsForWrite( outputConfigBase, @@ -2497,12 +2664,41 @@ export function createConfigIO( copyFallbackOnPermissionError: true, fileSystem: deps.fs, beforeRename: async () => { + options.assertConfigPathForWrite?.(); + if (options.baseSnapshot) { + assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs); + } if (deps.fs.existsSync(configPath)) { await maintainConfigBackups(configPath, deps.fs.promises); } + if (options.baseSnapshot) { + assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs); + } + options.assertConfigPathForWrite?.(); }, }); configCommitted = true; + try { + options.assertConfigPathForWrite?.(); + } catch (error) { + try { + const rolledBack = await rollbackConfigFileWriteIfUnchanged({ + configPath, + previousSnapshot: snapshot, + committedHash: nextHash, + fsModule: deps.fs, + }); + if (rolledBack) { + rollbackShippedPluginInstallConfigWriteMigration(pluginInstallConfigMigration); + } + } catch (rollbackError) { + throw new ConfigRuntimeRefreshError( + `${formatErrorMessage(error)} Rollback failed: ${formatErrorMessage(rollbackError)}`, + { cause: error }, + ); + } + throw error; + } logConfigOverwrite(); logConfigWriteAnomalies(); await appendWriteAudit( @@ -2661,9 +2857,19 @@ export async function readSourceConfigSnapshot(): Promise { export async function readConfigFileSnapshotForWrite(options?: { skipPluginValidation?: boolean; }): Promise { - return await createConfigIO( - options?.skipPluginValidation ? { pluginValidation: "skip" } : {}, - ).readConfigFileSnapshotForWrite(); + const readOptions = options?.skipPluginValidation ? { pluginValidation: "skip" as const } : {}; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const result = await createConfigIO(readOptions).readConfigFileSnapshotForWrite(); + result.writeOptions.assertConfigPathForWrite?.(); + return result; + } catch (error) { + if (!(error instanceof ConfigMutationConflictError) || error.retryable || attempt === 2) { + throw error; + } + } + } + throw new Error("unreachable"); } export async function readSourceConfigSnapshotForWrite(): Promise { @@ -2674,7 +2880,9 @@ export async function writeConfigFile( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, ): Promise { + options.assertConfigPathForWrite?.(); const io = createConfigIO({ + ...(options.ownedConfigPathForWrite ? { configPath: options.ownedConfigPathForWrite } : {}), ...(options.skipPluginValidation ? { pluginValidation: "skip" as const } : {}), ...(options.preservedLegacyRootKeys ? { preservedLegacyRootKeys: options.preservedLegacyRootKeys } @@ -2701,6 +2909,7 @@ export async function writeConfigFile( const writeResult = await io.writeConfigFile(nextCfg, { baseSnapshot, basePluginMetadataSnapshot: baseSnapshotRead.pluginMetadataSnapshot, + assertConfigPathForWrite: options.assertConfigPathForWrite, envSnapshotForRestore: resolveWriteEnvSnapshotForPath({ actualConfigPath: io.configPath, expectedConfigPath: options.expectedConfigPath, @@ -2783,6 +2992,7 @@ export async function writeConfigFile( // Keep the last-known-good runtime snapshot active until the specialized refresh path // succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh. try { + options.assertConfigPathForWrite?.(); await finalizeRuntimeSnapshotWrite({ nextSourceConfig: canonicalSourceConfig, refreshOptions: options.runtimeRefresh, @@ -2804,6 +3014,7 @@ export async function writeConfigFile( configPath: io.configPath, previousSnapshot: baseSnapshot, committedHash: writeResult.persistedHash, + fsModule: fs, }); if (rolledBackConfig) { restoreEnvChangesIfUnchanged({ diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 938d56ea0e4..f9567e9a752 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -8,16 +8,19 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { clearLoadPluginMetadataSnapshotMemo } from "../plugins/plugin-metadata-snapshot.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { hashConfigIncludeRaw } from "./includes.js"; import { CONFIG_CLOBBER_SNAPSHOT_LIMIT } from "./io.clobber-snapshot.js"; import { createConfigIO, getRuntimeConfigSourceSnapshot, + readConfigFileSnapshotForWrite, registerConfigWriteListener, resetConfigRuntimeState, setRuntimeConfigSnapshot, setRuntimeConfigSnapshotRefreshHandler, writeConfigFile, } from "./io.js"; +import { ConfigMutationConflictError } from "./mutation-conflict.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "./types.openclaw.js"; // Mock the plugin manifest registry so we can register a fake channel whose @@ -331,6 +334,49 @@ describe("config io write", () => { }); }); + it("retains included shipped plugin install records in write snapshots", async () => { + await withSuiteHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const pluginsPath = path.join(configDir, "plugins.json5"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + pluginsPath, + `${JSON.stringify( + { + installs: { + demo: { + source: "npm", + spec: "demo@1.0.0", + installPath: "/tmp/demo", + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const prepared = await createFastConfigIO(home).readConfigFileSnapshotForWrite(); + + expect(prepared.snapshot.valid).toBe(true); + expect(prepared.snapshot.parsed).toEqual({ + plugins: { $include: "./plugins.json5" }, + }); + expectInstallRecord(prepared.snapshot.sourceConfig.plugins?.installs?.demo, { + source: "npm", + spec: "demo@1.0.0", + installPath: "/tmp/demo", + }); + }); + }); + it("migrates shipped plugin install config records into the plugin index during explicit writes", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -1085,13 +1131,14 @@ describe("config io write", () => { }, ); expect(acceptedWrite.persistedConfig.gateway).toEqual({ mode: "local" }); + const acceptedSnapshot = await io.readConfigFileSnapshot(); await expectConfigWriteRejected( io.writeConfigFile( { meta: original.meta }, { allowConfigSizeDrop: true, - baseSnapshot, + baseSnapshot: acceptedSnapshot, }, ), ); @@ -1201,6 +1248,290 @@ describe("config io write", () => { }); }); + it("returns the read-time environment snapshot for invalid config repairs", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + mode: "local", + auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" }, + }, + channels: { "test-plugin-channel": { enabled: true } }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { + OPENCLAW_GATEWAY_TOKEN: "gateway-token-at-read", + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const result = await io.readConfigFileSnapshotForWrite(); + + expect(result.snapshot.valid).toBe(false); + expect(result.writeOptions.envSnapshotForRestore?.OPENCLAW_GATEWAY_TOKEN).toBe( + "gateway-token-at-read", + ); + }); + }); + + it("returns the read-time environment snapshot when invalid reads fall back after resolution", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + mode: "local", + auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" }, + }, + channels: { "test-plugin-channel": { enabled: true } }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + mockLoadPluginManifestRegistry.mockImplementationOnce(() => { + throw new Error("plugin metadata failed"); + }); + const io = createConfigIO({ + env: { + OPENCLAW_GATEWAY_TOKEN: "gateway-token-at-read", + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const result = await io.readConfigFileSnapshotForWrite(); + + expect(result.snapshot.valid).toBe(false); + expect(result.writeOptions.envSnapshotForRestore?.OPENCLAW_GATEWAY_TOKEN).toBe( + "gateway-token-at-read", + ); + }); + }); + + it("returns the snapshot-time hash when an included file is malformed", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "plugins.json5"); + const malformedRaw = "{ malformed"; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(includePath, malformedRaw, "utf-8"); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const result = await io.readConfigFileSnapshotForWrite(); + await fs.writeFile(includePath, "{ differently malformed", "utf-8"); + + expect(result.snapshot.valid).toBe(false); + expect(result.writeOptions.includeFileHashesForWrite?.[includePath]).toBe( + hashConfigIncludeRaw(malformedRaw), + ); + expect(result.writeOptions.includeFileTargetsForWrite?.[includePath]).toBe( + await fs.realpath(includePath), + ); + }); + }); + + it("returns a write guard that rejects a changed active config path", async () => { + await withSuiteHome(async (home) => { + const firstConfigPath = path.join(home, ".openclaw", "first.json"); + const secondConfigPath = path.join(home, ".openclaw", "second.json"); + await fs.mkdir(path.dirname(firstConfigPath), { recursive: true }); + await fs.writeFile(firstConfigPath, "{}", "utf-8"); + await fs.writeFile(secondConfigPath, "{}", "utf-8"); + const env = { + OPENCLAW_CONFIG_PATH: firstConfigPath, + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv; + const io = createConfigIO({ env, homedir: () => home, logger: silentLogger }); + + const result = await io.readConfigFileSnapshotForWrite(); + env.OPENCLAW_CONFIG_PATH = secondConfigPath; + + expect(() => result.writeOptions.assertConfigPathForWrite?.()).toThrow( + "config path changed since last load", + ); + }); + }); + + it("rejects write snapshots when the IO instance no longer owns its config path", async () => { + await withSuiteHome(async (home) => { + const firstConfigPath = path.join(home, ".openclaw", "first.json"); + const secondConfigPath = path.join(home, ".openclaw", "second.json"); + await fs.mkdir(path.dirname(firstConfigPath), { recursive: true }); + await fs.writeFile(firstConfigPath, "{}", "utf-8"); + await fs.writeFile(secondConfigPath, "{}", "utf-8"); + const env = { + OPENCLAW_CONFIG_PATH: firstConfigPath, + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv; + const io = createConfigIO({ env, homedir: () => home, logger: silentLogger }); + env.OPENCLAW_CONFIG_PATH = secondConfigPath; + + await expect(io.readConfigFileSnapshotForWrite()).rejects.toThrow( + "config path changed since last load", + ); + }); + }); + + it("rejects local write ownership when config env changes path selection during the read", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const configuredNextPath = path.join(home, ".openclaw", "next.json"); + const sourceConfig = { + env: { OPENCLAW_CONFIG_PATH: configuredNextPath }, + gateway: { mode: "local" }, + } satisfies OpenClawConfig; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf-8"); + const io = createFastConfigIO(home); + + await expect(io.readConfigFileSnapshotForWrite()).rejects.toThrow( + "config path changed since last load", + ); + }); + }); + + it("follows config env path selection before returning global write ownership", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const configuredNextPath = path.join(home, ".openclaw", "next.json"); + const sourceConfig = { + env: { OPENCLAW_CONFIG_PATH: configuredNextPath }, + gateway: { mode: "local" }, + } satisfies OpenClawConfig; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile( + configuredNextPath, + `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`, + "utf-8", + ); + + await withEnvAsync( + { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_HOME: home, + OPENCLAW_STATE_DIR: undefined, + OPENCLAW_TEST_FAST: "1", + }, + async () => { + const prepared = await readConfigFileSnapshotForWrite(); + + expect(prepared.snapshot.path).toBe(configuredNextPath); + expect(() => prepared.writeOptions.assertConfigPathForWrite?.()).not.toThrow(); + await writeConfigFile( + { + ...prepared.snapshot.sourceConfig, + gateway: { mode: "remote" }, + }, + { + baseSnapshot: prepared.snapshot, + ...prepared.writeOptions, + }, + ); + }, + ); + + const initialConfig = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; + const persisted = JSON.parse( + await fs.readFile(configuredNextPath, "utf-8"), + ) as OpenClawConfig; + expect(initialConfig.gateway?.mode).toBe("local"); + expect(persisted.gateway?.mode).toBe("remote"); + }); + }); + + it("does not use expectedConfigPath as the write destination", async () => { + await withSuiteHome(async (home) => { + const expectedConfigPath = path.join(home, ".openclaw", "expected.json"); + const activeConfigPath = path.join(home, ".openclaw", "active.json"); + await fs.mkdir(path.dirname(expectedConfigPath), { recursive: true }); + await fs.writeFile( + expectedConfigPath, + `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(activeConfigPath, "{}\n", "utf-8"); + + await withEnvAsync( + { + OPENCLAW_CONFIG_PATH: activeConfigPath, + OPENCLAW_TEST_FAST: "1", + }, + async () => { + await writeConfigFile( + { gateway: { mode: "remote" } }, + { + expectedConfigPath, + }, + ); + }, + ); + + const expectedConfig = JSON.parse( + await fs.readFile(expectedConfigPath, "utf-8"), + ) as OpenClawConfig; + const activeConfig = JSON.parse( + await fs.readFile(activeConfigPath, "utf-8"), + ) as OpenClawConfig; + expect(expectedConfig.gateway?.mode).toBe("local"); + expect(activeConfig.gateway?.mode).toBe("remote"); + }); + }); + + it("returns the missing-file hash when an included file is absent", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "plugins.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const result = await io.readConfigFileSnapshotForWrite(); + + expect(result.snapshot.valid).toBe(false); + expect(result.writeOptions.includeFileHashesForWrite?.[includePath]).toBe( + hashConfigIncludeRaw(null), + ); + expect(result.writeOptions.includeFileTargetsForWrite?.[includePath]).toBe( + path.join(await fs.realpath(path.dirname(includePath)), path.basename(includePath)), + ); + }); + }); + it("rejects root-include partial writes instead of flattening the root config", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -1225,6 +1556,546 @@ describe("config io write", () => { }); }); + it("rejects a stale base snapshot before overwriting the root config", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local", port: 18789 } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + const concurrentRaw = `${JSON.stringify( + { gateway: { mode: "local", port: 19001 } }, + null, + 2, + )}\n`; + await fs.writeFile(configPath, concurrentRaw, "utf-8"); + + await expect( + io.writeConfigFile({ gateway: { mode: "local", port: 19002 } }, { baseSnapshot: snapshot }), + ).rejects.toThrow("config changed since last load"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + }); + + it("rejects a base snapshot from a different config path before overwriting the root config", async () => { + await withSuiteHome(async (home) => { + const firstConfigPath = path.join(home, ".openclaw", "first.json"); + const secondConfigPath = path.join(home, ".openclaw", "second.json"); + await fs.mkdir(path.dirname(firstConfigPath), { recursive: true }); + const originalRaw = `${JSON.stringify( + { gateway: { mode: "local", port: 18789 } }, + null, + 2, + )}\n`; + await fs.writeFile(firstConfigPath, originalRaw, "utf-8"); + await fs.writeFile(secondConfigPath, originalRaw, "utf-8"); + const firstIo = createConfigIO({ + configPath: firstConfigPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const secondIo = createConfigIO({ + configPath: secondConfigPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const firstSnapshot = await firstIo.readConfigFileSnapshot(); + + await expect( + secondIo.writeConfigFile( + { gateway: { mode: "local", port: 19002 } }, + { baseSnapshot: firstSnapshot }, + ), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(secondConfigPath, "utf-8")).resolves.toBe(originalRaw); + }); + }); + + it("rolls back a root write when config path ownership changes during commit", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const secondConfigPath = path.join(home, ".openclaw", "second.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const originalRaw = `${JSON.stringify( + { gateway: { mode: "local", port: 18789 } }, + null, + 2, + )}\n`; + await fs.writeFile(configPath, originalRaw, "utf-8"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + let activeConfigPath = configPath; + const assertConfigPathForWrite = () => { + if (fsNode.readFileSync(configPath, "utf-8") !== originalRaw) { + activeConfigPath = secondConfigPath; + } + if (activeConfigPath !== configPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }; + + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 19002 } }, + { baseSnapshot: snapshot, assertConfigPathForWrite }, + ), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); + }); + }); + + it("rejects a base snapshot changed during preflight before replacing the root config", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local", port: 18789 } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + const concurrentRaw = `${JSON.stringify( + { gateway: { mode: "local", port: 19001 } }, + null, + 2, + )}\n`; + + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 19002 } }, + { + baseSnapshot: snapshot, + preCommitRuntimePreflight: async () => { + await fs.writeFile(configPath, concurrentRaw, "utf-8"); + }, + }, + ), + ).rejects.toThrow("config changed since last load"); + + expect(mockMaintainConfigBackups).not.toHaveBeenCalled(); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + }); + + it("rejects a base snapshot changed during backup rotation", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local", port: 18789 } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + const concurrentRaw = `${JSON.stringify( + { gateway: { mode: "local", port: 19001 } }, + null, + 2, + )}\n`; + mockMaintainConfigBackups.mockImplementationOnce(async () => { + await fs.writeFile(configPath, concurrentRaw, "utf-8"); + }); + + await expect( + io.writeConfigFile({ gateway: { mode: "local", port: 19002 } }, { baseSnapshot: snapshot }), + ).rejects.toThrow("config changed since last load"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + }); + + it("rejects a missing base config created empty during preflight", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.exists).toBe(false); + + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 19002 } }, + { + baseSnapshot: snapshot, + preCommitRuntimePreflight: async () => { + await fs.writeFile(configPath, "", "utf-8"); + }, + }, + ), + ).rejects.toThrow("config changed since last load"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(""); + }); + }); + + it("assigns distinct snapshot hashes to missing and empty root config", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const missingSnapshot = await io.readConfigFileSnapshot(); + expect(missingSnapshot.exists).toBe(false); + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, "", "utf-8"); + const emptySnapshot = await io.readConfigFileSnapshot(); + expect(emptySnapshot.exists).toBe(true); + expect(emptySnapshot.hash).not.toBe(missingSnapshot.hash); + }); + }); + + it("rejects an empty base config removed during preflight", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, "", "utf-8"); + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.exists).toBe(true); + + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 19002 } }, + { + baseSnapshot: snapshot, + preCommitRuntimePreflight: async () => { + await fs.unlink(configPath); + }, + }, + ), + ).rejects.toThrow("config changed since last load"); + + await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + + it("rejects invalid include-backed repairs instead of persisting substituted secrets", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "gateway.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + includePath, + `${JSON.stringify( + { + mode: "local", + auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" }, + invalid: true, + }, + null, + 2, + )}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { $include: "./gateway.json5" } }, null, 2)}\n`, + "utf-8", + ); + const originalRootRaw = await fs.readFile(configPath, "utf-8"); + const io = createConfigIO({ + env: { + OPENCLAW_GATEWAY_TOKEN: "gateway-token-runtime", + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + + await expect( + io.writeConfigFile({ + gateway: { + mode: "local", + auth: { mode: "token", token: "gateway-token-runtime" }, + }, + }), + ).rejects.toThrow("Config write would flatten $include-owned config at gateway"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRootRaw); + await expect(fs.readFile(includePath, "utf-8")).resolves.toContain( + '"token": "${OPENCLAW_GATEWAY_TOKEN}"', + ); + }); + }); + + it("repairs invalid root-authored siblings without flattening included config", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "agent-defaults.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + includePath, + `${JSON.stringify({ maxConcurrent: 1 }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { + defaults: { $include: "./agent-defaults.json5", legacyKey: true }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const originalIncludeRaw = await fs.readFile(includePath, "utf-8"); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + + await io.writeConfigFile({ agents: { defaults: { maxConcurrent: 1 } } }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { defaults?: Record }; + }; + expect(persisted.agents?.defaults).toEqual({ $include: "./agent-defaults.json5" }); + await expect(fs.readFile(includePath, "utf-8")).resolves.toBe(originalIncludeRaw); + }); + }); + + it("rejects repairs that would flatten a valid outer include with a broken nested include", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "plugins.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + pluginsPath, + `${JSON.stringify({ $include: "./missing-entries.json5" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + const originalRootRaw = await fs.readFile(configPath, "utf-8"); + const originalPluginsRaw = await fs.readFile(pluginsPath, "utf-8"); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + + await expect(io.writeConfigFile({ plugins: { entries: {} } })).rejects.toThrow( + "Config write would flatten $include-owned config at plugins", + ); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRootRaw); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(originalPluginsRaw); + }); + }); + + it("allows replacement repair of a malformed include directive", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: 42 } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + + await io.writeConfigFile({ plugins: {} }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + plugins?: Record; + }; + expect(persisted.plugins).toEqual({}); + }); + }); + + it("preserves escaped root literals before validating unrelated includes", async () => { + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "literal-plugin", + origin: "bundled", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: "/tmp/openclaw-test-literal-plugin", + source: "/tmp/openclaw-test-literal-plugin/index.ts", + manifestPath: "/tmp/openclaw-test-literal-plugin/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { + token: { type: "string", const: "${ROOT_LITERAL_TOKEN}" }, + }, + required: ["token"], + additionalProperties: false, + }, + }, + ], + } satisfies PluginManifestRegistry); + + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const agentsPath = path.join(home, ".openclaw", "agents.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + agentsPath, + `${JSON.stringify({ list: [{ id: "main", default: true }] }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { $include: "./agents.json5" }, + plugins: { + entries: { + "literal-plugin": { + enabled: true, + config: { token: "$${ROOT_LITERAL_TOKEN}" }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { + OPENCLAW_TEST_FAST: "1", + ROOT_LITERAL_TOKEN: "secret", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(true); + + await io.writeConfigFile({ + ...snapshot.sourceConfig, + gateway: { mode: "local" }, + }); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toContain( + '"token": "$${ROOT_LITERAL_TOKEN}"', + ); + }); + }); + + it("repairs invalid config without flattening array-nested includes", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "main-agent.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + includePath, + `${JSON.stringify({ id: "main", workspace: "${OPENCLAW_AGENT_WORKSPACE}" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { + defaults: { params: { stale: true } }, + list: [{ $include: "./main-agent.json5" }], + }, + channels: { "test-plugin-channel": { enabled: true } }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const originalRootRaw = await fs.readFile(configPath, "utf-8"); + const io = createConfigIO({ + env: { + OPENCLAW_AGENT_WORKSPACE: "/resolved/agent-workspace", + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + + await io.writeConfigFile({ + agents: { list: [{ id: "main", workspace: "/resolved/agent-workspace" }] }, + }); + + await expect(fs.readFile(configPath, "utf-8")).resolves.not.toBe(originalRootRaw); + const persistedRoot = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { defaults?: unknown; list?: unknown[] }; + }; + expect(persistedRoot.agents?.defaults).toBeUndefined(); + expect(persistedRoot.agents?.list).toEqual([{ $include: "./main-agent.json5" }]); + await expect(fs.readFile(includePath, "utf-8")).resolves.toContain( + '"workspace": "${OPENCLAW_AGENT_WORKSPACE}"', + ); + }); + }); + it("writes disabled plugin entries without requiring plugin config", async () => { mockLoadPluginManifestRegistry.mockReturnValue({ diagnostics: [], @@ -1451,6 +2322,58 @@ describe("config io write", () => { }); }); + it("rejects ambiguous removals from arrays containing environment references", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const originalRaw = `${JSON.stringify( + { plugins: { allow: ["${PLUGIN_A}", "${PLUGIN_B}"] } }, + null, + 2, + )}\n`; + await fs.writeFile(configPath, originalRaw, "utf-8"); + const io = createConfigIO({ + env: { + OPENCLAW_TEST_FAST: "1", + PLUGIN_A: "same-plugin", + PLUGIN_B: "same-plugin", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await expect(io.writeConfigFile({ plugins: { allow: ["same-plugin"] } })).rejects.toThrow( + "Config write would reorder or modify an array", + ); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); + }); + }); + + it("preserves escaped literals when config writes reorder arrays", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { allow: ["$${PLUGIN_ID}", "literal-plugin"] } }, null, 2)}\n`, + "utf-8", + ); + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await io.writeConfigFile({ plugins: { allow: ["literal-plugin", "${PLUGIN_ID}"] } }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + plugins?: { allow?: string[] }; + }; + expect(persisted.plugins?.allow).toEqual(["literal-plugin", "$${PLUGIN_ID}"]); + }); + }); + it("notifies in-process reloaders with canonical post-write source config", async () => { mockLoadPluginManifestRegistry.mockReturnValue({ diagnostics: [], @@ -1812,6 +2735,99 @@ describe("config io write", () => { }); }); + it("rolls back root writes when canonical reread changes config path ownership", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const nextConfigPath = path.join(home, ".openclaw", "next.json"); + const initialConfig = { gateway: { mode: "local", port: 18789 } } satisfies OpenClawConfig; + const initialRaw = `${JSON.stringify(initialConfig, null, 2)}\n`; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, initialRaw, "utf-8"); + + await withEnvAsync( + { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_HOME: home, + OPENCLAW_STATE_DIR: undefined, + OPENCLAW_TEST_FAST: "1", + }, + async () => { + const prepared = await readConfigFileSnapshotForWrite(); + + await expect( + writeConfigFile( + { + gateway: { mode: "local", port: 19001 }, + env: { OPENCLAW_CONFIG_PATH: nextConfigPath }, + }, + { + baseSnapshot: prepared.snapshot, + ...prepared.writeOptions, + }, + ), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw); + expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + await expect(fs.stat(nextConfigPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + }); + }); + + it("uses injected filesystem operations when rolling back ownership loss", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const otherConfigPath = path.join(home, ".openclaw", "other.json"); + const initialConfig = { gateway: { mode: "local", port: 18789 } } satisfies OpenClawConfig; + const initialRaw = `${JSON.stringify(initialConfig, null, 2)}\n`; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, initialRaw, "utf-8"); + const env = { + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv; + const readFile = fsNode.promises.readFile.bind(fsNode.promises); + const rename = fsNode.promises.rename.bind(fsNode.promises); + let committed = false; + let rollbackReadUsedInjectedFs = false; + const injectedFs = { + ...fsNode, + promises: { + ...fsNode.promises, + readFile: async (target, options) => { + if (committed && target === configPath) { + rollbackReadUsedInjectedFs = true; + } + return await readFile(target, options); + }, + rename: async (from, to) => { + await rename(from, to); + if (!committed && to === configPath) { + committed = true; + env.OPENCLAW_CONFIG_PATH = otherConfigPath; + } + }, + }, + } as typeof fsNode; + const io = createConfigIO({ env, fs: injectedFs, homedir: () => home, logger: silentLogger }); + const prepared = await io.readConfigFileSnapshotForWrite(); + + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 19001 } }, + { + baseSnapshot: prepared.snapshot, + ...prepared.writeOptions, + }, + ), + ).rejects.toThrow("config path changed since last load"); + + expect(rollbackReadUsedInjectedFs).toBe(true); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw); + }); + }); + it("persists explicit default-valued paths through the exported write wrapper", async () => { mockLoadPluginManifestRegistry.mockReturnValue({ diagnostics: [], diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 3a65751549b..f29419a7ce1 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -469,6 +469,259 @@ describe("config io write prepare", () => { expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); }); + it("allows removing root-authored sibling keys beside an include", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + gateway: { mode: "local", legacyKey: true }, + }, + sourceConfig: { + gateway: { mode: "local", legacyKey: true }, + }, + rootAuthoredConfig: { + gateway: { $include: "./config/gateway.json", legacyKey: true }, + }, + nextConfig: { + gateway: { mode: "local" }, + }, + }) as Record; + + expect(persisted.gateway).toEqual({ $include: "./config/gateway.json" }); + }); + + it("allows nested root-authored sibling edits without flattening included values", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + gateway: { + mode: "local", + auth: { mode: "token", token: "old" }, + }, + }, + sourceConfig: { + gateway: { + mode: "local", + auth: { mode: "token", token: "old" }, + }, + }, + rootAuthoredConfig: { + gateway: { + $include: "./config/gateway.json", + auth: { token: "old" }, + }, + }, + nextConfig: { + gateway: { + mode: "local", + auth: { mode: "none", token: "new", strategy: "strict" }, + }, + }, + }) as Record; + + expect(persisted.gateway).toEqual({ + $include: "./config/gateway.json", + auth: { token: "new", mode: "none", strategy: "strict" }, + }); + }); + + it("does not copy runtime-normalized include values into root-authored siblings", () => { + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + gateway: { + tls: { certPath: "/home/test/cert.pem", enabled: false }, + }, + }, + sourceConfig: { + gateway: { + tls: { certPath: "~/cert.pem", enabled: false }, + }, + }, + rootAuthoredConfig: { + gateway: { + $include: "./config/gateway.json", + tls: { enabled: false }, + }, + }, + nextConfig: { + gateway: { + tls: { certPath: "~/cert.pem", enabled: true }, + }, + }, + }) as Record; + + expect(persisted.gateway).toEqual({ + $include: "./config/gateway.json", + tls: { enabled: true }, + }); + }); + + it("rejects included-value edits beside root-authored sibling edits", () => { + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { + gateway: { mode: "local", legacyKey: "old" }, + }, + sourceConfig: { + gateway: { mode: "local", legacyKey: "old" }, + }, + rootAuthoredConfig: { + gateway: { $include: "./config/gateway.json", legacyKey: "old" }, + }, + nextConfig: { + gateway: { mode: "remote", legacyKey: "new" }, + }, + }), + ).toThrow("Config write would flatten $include-owned config at gateway"); + }); + + it("preserves include-owned array entries across runtime-only normalization", () => { + const sourceAgents = { list: [{ id: "main", workspace: "~/agent" }] }; + const runtimeAgents = { list: [{ id: "main", workspace: "/home/test/agent" }] }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: runtimeAgents, + gateway: { mode: "local" }, + }, + sourceConfig: { + agents: sourceAgents, + gateway: { mode: "local" }, + }, + rootAuthoredConfig: { + agents: { list: [{ $include: "./config/main-agent.json" }] }, + gateway: { mode: "local" }, + }, + nextConfig: { + agents: sourceAgents, + gateway: { mode: "local", port: 18789 }, + }, + }) as Record; + + expect(persisted.agents).toEqual({ + list: [{ $include: "./config/main-agent.json" }], + }); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + + it("allows edits to root-owned siblings beside an include-owned array entry", () => { + const mainAgent = { id: "main", workspace: "~/agent" }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { + agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, + }, + sourceConfig: { + agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, + }, + rootAuthoredConfig: { + agents: { + list: [{ $include: "./config/main-agent.json" }, { id: "ops", workspace: "~/ops" }], + }, + }, + nextConfig: { + agents: { + list: [ + mainAgent, + { id: "ops", workspace: "~/ops-next" }, + { id: "new", workspace: "~/new" }, + ], + }, + }, + }) as Record; + + expect(persisted.agents).toEqual({ + list: [ + { $include: "./config/main-agent.json" }, + { id: "ops", workspace: "~/ops-next" }, + { id: "new", workspace: "~/new" }, + ], + }); + }); + + it("rejects writes that change include-owned array entries", () => { + const agents = { list: [{ id: "main", workspace: "~/agent" }] }; + + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { agents }, + sourceConfig: { agents }, + rootAuthoredConfig: { + agents: { list: [{ $include: "./config/main-agent.json" }] }, + }, + nextConfig: { + agents: { list: [{ id: "main", workspace: "~/other-agent" }] }, + }, + }), + ).toThrow("Config write would flatten $include-owned config at agents.list.0"); + }); + + it("rejects array shifts when an included value has a duplicate sibling", () => { + const paths = ["/same", "/same"]; + + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { plugins: { load: { paths } } }, + sourceConfig: { plugins: { load: { paths } } }, + rootAuthoredConfig: { + plugins: { + load: { paths: [{ $include: "./path.json5" }, "/same"] }, + }, + }, + nextConfig: { plugins: { load: { paths: ["/same"] } } }, + }), + ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.0"); + }); + + it("allows unrelated removals after duplicate include-resolved values", () => { + const paths = ["/same", "/same", "/other"]; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: { plugins: { load: { paths } } }, + sourceConfig: { plugins: { load: { paths } } }, + rootAuthoredConfig: { + plugins: { + load: { paths: [{ $include: "./path.json5" }, "/same", "/other"] }, + }, + }, + nextConfig: { plugins: { load: { paths: ["/same", "/same"] } } }, + }) as Record; + + expect(persisted).toEqual({ + plugins: { + load: { paths: [{ $include: "./path.json5" }, "/same"] }, + }, + }); + }); + + it("rejects included-entry removals hidden by duplicate sibling edits", () => { + const paths = ["/same", "/same", "/old"]; + + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { plugins: { load: { paths } } }, + sourceConfig: { plugins: { load: { paths } } }, + rootAuthoredConfig: { + plugins: { + load: { paths: [{ $include: "./path.json5" }, "/same", "/old"] }, + }, + }, + nextConfig: { plugins: { load: { paths: ["/same", "/new"] } } }, + }), + ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.0"); + }); + + it("rejects newly introduced duplicates of include-owned array entries", () => { + const paths = ["/root", "/included"]; + + expect(() => + resolvePersistCandidateForWrite({ + runtimeConfig: { plugins: { load: { paths } } }, + sourceConfig: { plugins: { load: { paths } } }, + rootAuthoredConfig: { + plugins: { + load: { paths: ["/root", { $include: "./path.json5" }] }, + }, + }, + nextConfig: { plugins: { load: { paths: ["/included", "/included"] } } }, + }), + ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.1"); + }); + it("rejects writes that would flatten include-owned subtrees", () => { expect(() => resolvePersistCandidateForWrite({ @@ -723,6 +976,85 @@ describe("config io write prepare", () => { ]); }); + it("does not overwrite identity-restored env refs with positional map entries", () => { + const restored = restoreEnvRefsFromMap( + { + agents: [ + { id: "b", token: "${TOKEN_B}" }, + { id: "a", token: "${TOKEN_A}" }, + ], + }, + "", + new Map([ + ["agents[0].token", "${TOKEN_A}"], + ["agents[1].token", "${TOKEN_B}"], + ]), + new Set(["agents[0].id", "agents[1].id"]), + new Set(["agents[0].token", "agents[1].token"]), + ); + + expect(restored).toEqual({ + agents: [ + { id: "b", token: "${TOKEN_B}" }, + { id: "a", token: "${TOKEN_A}" }, + ], + }); + }); + + it("does not overwrite identity-restored escaped refs with positional map entries", () => { + const restored = restoreEnvRefsFromMap( + { + agents: [ + { id: "real", token: "${TOKEN}" }, + { id: "literal", token: "$${TOKEN}" }, + ], + }, + "", + new Map([["agents[1].token", "${TOKEN}"]]), + new Set(["agents[0].id", "agents[1].id"]), + new Set(["agents[0].token", "agents[1].token"]), + ); + + expect(restored).toEqual({ + agents: [ + { id: "real", token: "${TOKEN}" }, + { id: "literal", token: "$${TOKEN}" }, + ], + }); + }); + + it("restores unchanged paths even when their values equal another authored template", () => { + const restored = restoreEnvRefsFromMap( + { + included: { + first: "${SECOND}", + second: "second-secret", + third: "$${SECOND}", + escaped: "$${SECOND}", + }, + gateway: { port: 18790 }, + }, + "", + new Map([ + ["included.first", "${FIRST}"], + ["included.second", "${SECOND}"], + ["included.third", "${THIRD}"], + ["included.escaped", "$${SECOND}"], + ]), + new Set(["gateway.port"]), + ); + + expect(restored).toEqual({ + included: { + first: "${FIRST}", + second: "${SECOND}", + third: "${THIRD}", + escaped: "$${SECOND}", + }, + gateway: { port: 18790 }, + }); + }); + it("keeps the read-time env snapshot when writing the same config path", () => { const snapshot = { OPENAI_API_KEY: "sk-secret" }; expect( diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 32900fa922a..1185abaa027 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -80,15 +80,27 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown) return next; } -function hasOwnIncludeKey(value: unknown): value is Record { - return isRecord(value) && Object.hasOwn(value, "$include"); +function hasOwnValidIncludeDirective(value: unknown): value is Record { + if (!isRecord(value) || !Object.hasOwn(value, "$include")) { + return false; + } + const includeValue = value.$include; + return ( + typeof includeValue === "string" || + (Array.isArray(includeValue) && includeValue.every((entry) => typeof entry === "string")) + ); } function collectIncludeOwnedPaths(value: unknown, path: string[] = []): string[][] { + if (Array.isArray(value)) { + return value.flatMap((child, index) => + collectIncludeOwnedPaths(child, [...path, String(index)]), + ); + } if (!isRecord(value)) { return []; } - if (hasOwnIncludeKey(value)) { + if (hasOwnValidIncludeDirective(value)) { return [path]; } return Object.entries(value).flatMap(([key, child]) => @@ -96,24 +108,90 @@ function collectIncludeOwnedPaths(value: unknown, path: string[] = []): string[] ); } -function patchTouchesPath(patch: unknown, path: string[]): boolean { - if (path.length === 0) { - return isRecord(patch) ? Object.keys(patch).length > 0 : true; +function collectMutableSiblingPathsAtInclude(rootAuthoredConfig: unknown, includePath: string[]) { + const includeValue = getPathValue(rootAuthoredConfig, includePath); + if (!hasOwnValidIncludeDirective(includeValue)) { + return []; } - if (!isRecord(patch)) { - return true; - } - const [head, ...tail] = path; - if (!Object.hasOwn(patch, head)) { - return false; - } - return patchTouchesPath(patch[head], tail); + return Object.keys(includeValue).flatMap((key) => + key === "$include" || isBlockedObjectKey(key) ? [] : [[...includePath, key]], + ); +} + +function isMutableSiblingPathAtInclude( + rootAuthoredConfig: unknown, + includePath: string[], + path: string[], +): boolean { + return collectMutableSiblingPathsAtInclude(rootAuthoredConfig, includePath).some( + (siblingPath) => { + if (!pathStartsWith(path, siblingPath)) { + return false; + } + const nestedIncludePaths = collectIncludeOwnedPaths( + getPathValue(rootAuthoredConfig, siblingPath), + siblingPath, + ); + return !nestedIncludePaths.some( + (nestedIncludePath) => + pathStartsWith(path, nestedIncludePath) || pathStartsWith(nestedIncludePath, path), + ); + }, + ); } function formatConfigPath(path: string[]): string { return path.length > 0 ? path.join(".") : ""; } +function findContainingArrayPath(root: unknown, path: string[]): string[] | undefined { + let current = root; + const currentPath: string[] = []; + for (const segment of path) { + if (Array.isArray(current)) { + return currentPath; + } + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + currentPath.push(segment); + } + return undefined; +} + +function hasChangedEquivalentArraySibling( + value: unknown, + nextValue: unknown, + index: number, +): boolean { + if (!Array.isArray(value) || !Array.isArray(nextValue) || index >= value.length) { + return false; + } + return value.some( + (item, itemIndex) => + itemIndex !== index && + isDeepStrictEqual(item, value[index]) && + !isDeepStrictEqual(nextValue[itemIndex], item), + ); +} + +function hasNewEquivalentArraySibling(value: unknown, nextValue: unknown, index: number): boolean { + if (!Array.isArray(value) || !Array.isArray(nextValue) || index >= value.length) { + return false; + } + const includedValue = value[index]; + if (!isDeepStrictEqual(nextValue[index], includedValue)) { + return false; + } + return nextValue.some( + (item, itemIndex) => + itemIndex !== index && + isDeepStrictEqual(item, includedValue) && + !isDeepStrictEqual(value[itemIndex], includedValue), + ); +} + function getPathValue(value: unknown, path: string[]): unknown { let current = value; for (const segment of path) { @@ -169,18 +247,26 @@ function pathOverlapsAny(path: string[], candidates: readonly string[][] | undef } function isIncludeOwnedPath(rootAuthoredConfig: unknown, path: string[]): boolean { - return collectIncludeOwnedPaths(rootAuthoredConfig).some( - (includePath) => pathStartsWith(path, includePath) || pathStartsWith(includePath, path), - ); + return collectIncludeOwnedPaths(rootAuthoredConfig).some((includePath) => { + const overlapsInclude = pathStartsWith(path, includePath) || pathStartsWith(includePath, path); + if (!overlapsInclude) { + return false; + } + return !isMutableSiblingPathAtInclude(rootAuthoredConfig, includePath, path); + }); } function findOverlappingIncludeOwnedPath( rootAuthoredConfig: unknown, path: string[], ): string[] | undefined { - return collectIncludeOwnedPaths(rootAuthoredConfig).find( - (includePath) => pathStartsWith(path, includePath) || pathStartsWith(includePath, path), - ); + return collectIncludeOwnedPaths(rootAuthoredConfig).find((includePath) => { + const overlapsInclude = pathStartsWith(path, includePath) || pathStartsWith(includePath, path); + if (!overlapsInclude) { + return false; + } + return !isMutableSiblingPathAtInclude(rootAuthoredConfig, includePath, path); + }); } function setPathValueCreatingParents(value: unknown, path: string[], nextValue: unknown): unknown { @@ -205,11 +291,20 @@ function setPathValueCreatingParents(value: unknown, path: string[], nextValue: } function deletePathValue(value: unknown, path: string[]): unknown { - if (path.length === 0 || !isRecord(value)) { + if (path.length === 0) { return value; } const [head, ...tail] = path; - if (!Object.hasOwn(value, head)) { + if (Array.isArray(value)) { + const index = parseArrayIndexPathSegment(head); + if (index === undefined || index >= value.length || tail.length === 0) { + return value; + } + const next = [...value]; + next[index] = deletePathValue(value[index], tail); + return next; + } + if (!isRecord(value) || !Object.hasOwn(value, head)) { return value; } const next: Record = { ...value }; @@ -480,25 +575,203 @@ function normalizeModelRefsForWrite( ); } +type IncludeSiblingProjection = + | { ok: true; present: false } + | { ok: true; present: true; value: unknown } + | { ok: false }; + +function projectRootAuthoredIncludeSibling(params: { + authored: unknown; + baseline: unknown; + next: unknown; + baselinePresent: boolean; + nextPresent: boolean; +}): IncludeSiblingProjection { + if ( + params.nextPresent && + params.baselinePresent && + isDeepStrictEqual(params.next, params.baseline) + ) { + return { ok: true, present: true, value: cloneUnknown(params.authored) }; + } + if (!params.nextPresent) { + return collectIncludeOwnedPaths(params.authored).length > 0 + ? { ok: false } + : { ok: true, present: false }; + } + if (!params.baselinePresent) { + return { ok: true, present: true, value: cloneUnknown(params.next) }; + } + if (hasOwnValidIncludeDirective(params.authored)) { + return { ok: false }; + } + if (Array.isArray(params.authored)) { + return Array.isArray(params.next) + ? { ok: false } + : { ok: true, present: true, value: cloneUnknown(params.next) }; + } + if (!isRecord(params.authored)) { + return { ok: true, present: true, value: cloneUnknown(params.next) }; + } + if (!isRecord(params.next)) { + return collectIncludeOwnedPaths(params.authored).length > 0 + ? { ok: false } + : { ok: true, present: true, value: cloneUnknown(params.next) }; + } + if (!isRecord(params.baseline)) { + return { ok: true, present: true, value: cloneUnknown(params.next) }; + } + + const value: Record = cloneUnknown(params.authored); + const keys = new Set([ + ...Object.keys(params.authored), + ...Object.keys(params.baseline), + ...Object.keys(params.next), + ]); + for (const key of keys) { + if (isBlockedObjectKey(key)) { + continue; + } + const authoredPresent = Object.hasOwn(params.authored, key); + const baselinePresent = Object.hasOwn(params.baseline, key); + const nextPresent = Object.hasOwn(params.next, key); + if (!authoredPresent) { + if ( + baselinePresent && + nextPresent && + isDeepStrictEqual(params.baseline[key], params.next[key]) + ) { + continue; + } + if (!nextPresent) { + return { ok: false }; + } + if ( + baselinePresent && + Array.isArray(params.baseline[key]) && + Array.isArray(params.next[key]) + ) { + return { ok: false }; + } + } + const projected = projectRootAuthoredIncludeSibling({ + authored: authoredPresent ? params.authored[key] : {}, + baseline: params.baseline[key], + next: params.next[key], + baselinePresent, + nextPresent, + }); + if (!projected.ok) { + return projected; + } + if (projected.present) { + value[key] = projected.value; + } else { + delete value[key]; + } + } + return { ok: true, present: true, value }; +} + function preserveUntouchedIncludes(params: { - patch: unknown; + runtimeConfig: unknown; + sourceConfig: unknown; + nextConfig: unknown; rootAuthoredConfig: unknown; persistedCandidate: unknown; }): unknown { let next = params.persistedCandidate; for (const includePath of collectIncludeOwnedPaths(params.rootAuthoredConfig)) { - if (patchTouchesPath(params.patch, includePath)) { + const containingArrayPath = findContainingArrayPath(params.rootAuthoredConfig, includePath); + const includeIsArrayEntry = + containingArrayPath !== undefined && includePath.length === containingArrayPath.length + 1; + // Whole-entry array includes keep their positional ownership while allowing + // unrelated sibling edits. Nested array includes require the array unchanged. + const comparisonPath = includeIsArrayEntry ? includePath : (containingArrayPath ?? includePath); + const mutableSiblingPaths = collectMutableSiblingPathsAtInclude( + params.rootAuthoredConfig, + includePath, + ); + const relativeMutableSiblingPaths = mutableSiblingPaths.map((path) => + path.slice(comparisonPath.length), + ); + const omitMutableSiblingValues = (value: unknown) => + relativeMutableSiblingPaths.reduce((current, path) => deletePathValue(current, path), value); + const nextValue = omitMutableSiblingValues(getPathValue(params.nextConfig, comparisonPath)); + const sourceValue = omitMutableSiblingValues(getPathValue(params.sourceConfig, comparisonPath)); + const runtimeValue = omitMutableSiblingValues( + getPathValue(params.runtimeConfig, comparisonPath), + ); + if (!isDeepStrictEqual(nextValue, sourceValue) && !isDeepStrictEqual(nextValue, runtimeValue)) { throw new Error( `Config write would flatten $include-owned config at ${formatConfigPath( includePath, )}; edit that include file directly or remove the $include first.`, ); } - next = setPathValue(next, includePath, getPathValue(params.rootAuthoredConfig, includePath)); + if (includeIsArrayEntry) { + const index = parseArrayIndexPathSegment(includePath.at(-1) ?? ""); + const nextArray = getPathValue(params.nextConfig, containingArrayPath); + const sourceArray = getPathValue(params.sourceConfig, containingArrayPath); + const runtimeArray = getPathValue(params.runtimeConfig, containingArrayPath); + if ( + index !== undefined && + (hasChangedEquivalentArraySibling(sourceArray, nextArray, index) || + hasChangedEquivalentArraySibling(runtimeArray, nextArray, index) || + hasNewEquivalentArraySibling(sourceArray, nextArray, index) || + hasNewEquivalentArraySibling(runtimeArray, nextArray, index)) + ) { + throw new Error( + `Config write would flatten $include-owned config at ${formatConfigPath( + includePath, + )}; edit that include file directly or remove the $include first.`, + ); + } + } + let authoredIncludeValue = getPathValue(params.rootAuthoredConfig, includePath); + for (const siblingPath of mutableSiblingPaths) { + const relativeSiblingPath = siblingPath.slice(includePath.length); + const nextPresent = hasPathValue(params.nextConfig, siblingPath); + const projectAgainst = (baselineConfig: unknown) => + projectRootAuthoredIncludeSibling({ + authored: getPathValue(params.rootAuthoredConfig, siblingPath), + baseline: getPathValue(baselineConfig, siblingPath), + next: getPathValue(params.nextConfig, siblingPath), + baselinePresent: hasPathValue(baselineConfig, siblingPath), + nextPresent, + }); + const sourceProjection = projectAgainst(params.sourceConfig); + const projection = sourceProjection.ok + ? sourceProjection + : projectAgainst(params.runtimeConfig); + if (!projection.ok) { + throw new Error( + `Config write would flatten $include-owned config at ${formatConfigPath( + includePath, + )}; edit that include file directly or remove the $include first.`, + ); + } + authoredIncludeValue = projection.present + ? setPathValue(authoredIncludeValue, relativeSiblingPath, projection.value) + : deletePathValue(authoredIncludeValue, relativeSiblingPath); + } + next = setPathValue(next, includePath, authoredIncludeValue); } return next; } +export function preserveIncludeOwnedConfigForWrite(params: { + runtimeConfig: unknown; + sourceConfig: unknown; + nextConfig: unknown; + rootAuthoredConfig: unknown; +}): unknown { + return preserveUntouchedIncludes({ + ...params, + persistedCandidate: params.nextConfig, + }); +} + function hasPathValue(value: unknown, path: readonly string[]): boolean { if (path.length === 0) { return true; @@ -626,7 +899,9 @@ export function resolvePersistCandidateForWrite(params: { const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig); const rootAuthoredConfig = params.rootAuthoredConfig ?? params.sourceConfig; const persistedBase = preserveUntouchedIncludes({ - patch, + runtimeConfig: params.runtimeConfig, + sourceConfig: params.sourceConfig, + nextConfig: params.nextConfig, rootAuthoredConfig, persistedCandidate: applyMergePatch(projectedSource, patch), }); @@ -916,8 +1191,12 @@ export function restoreEnvRefsFromMap( path: string, envRefMap: Map, changedPaths: Set, + identityRestoredPaths: ReadonlySet = new Set(), ): unknown { if (typeof value === "string") { + if (identityRestoredPaths.has(path)) { + return value; + } if (!isPathChanged(path, changedPaths)) { const original = envRefMap.get(path); if (original !== undefined) { @@ -929,7 +1208,13 @@ export function restoreEnvRefsFromMap( if (Array.isArray(value)) { let changed = false; const next = value.map((item, index) => { - const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths); + const updated = restoreEnvRefsFromMap( + item, + `${path}[${index}]`, + envRefMap, + changedPaths, + identityRestoredPaths, + ); if (updated !== item) { changed = true; } @@ -942,7 +1227,13 @@ export function restoreEnvRefsFromMap( const next: Record = {}; for (const [key, child] of Object.entries(value)) { const childPath = path ? `${path}.${key}` : key; - const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths); + const updated = restoreEnvRefsFromMap( + child, + childPath, + envRefMap, + changedPaths, + identityRestoredPaths, + ); if (updated !== child) { changed = true; } diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index 271244792b1..e5a17ac1a77 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -1,8 +1,10 @@ // Covers config mutation helpers and persisted write behavior. +import fsNode from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { hashConfigIncludeRaw } from "./includes.js"; import type { ConfigWriteOptions } from "./io.js"; import { ConfigMutationConflictError, @@ -10,6 +12,7 @@ import { replaceConfigFile, transformConfigFileWithRetry, } from "./mutate.js"; +import { resolveConfigPath } from "./paths.js"; import { registerRuntimeConfigWriteListener, resetConfigRuntimeState, @@ -22,11 +25,15 @@ type MockValidationResult = | { ok: true; config: OpenClawConfig; warnings: MockValidationIssue[] } | { ok: false; issues: MockValidationIssue[]; warnings: MockValidationIssue[] }; -const ioMocks = vi.hoisted(() => ({ - readConfigFileSnapshotForWrite: vi.fn(), - resolveConfigSnapshotHash: vi.fn(), - writeConfigFile: vi.fn(), -})); +const ioMocks = vi.hoisted(() => { + const readConfigFileSnapshotForWrite = vi.fn(); + return { + createConfigIO: vi.fn(() => ({ readConfigFileSnapshotForWrite })), + readConfigFileSnapshotForWrite, + resolveConfigSnapshotHash: vi.fn(), + writeConfigFile: vi.fn(), + }; +}); const validationMocks = vi.hoisted(() => ({ validateConfigObjectWithPlugins: vi.fn( (config: OpenClawConfig): MockValidationResult => ({ @@ -36,12 +43,23 @@ const validationMocks = vi.hoisted(() => ({ }), ), })); +const backupMocks = vi.hoisted(() => ({ + maintainConfigBackups: vi.fn(), +})); vi.mock("./io.js", async () => ({ ...(await vi.importActual("./io.js")), ...ioMocks, })); vi.mock("./validation.js", () => validationMocks); +vi.mock("./backup-rotation.js", async (importOriginal) => { + const actual = await importOriginal(); + backupMocks.maintainConfigBackups.mockImplementation(actual.maintainConfigBackups); + return { + ...actual, + maintainConfigBackups: backupMocks.maintainConfigBackups, + }; +}); function createSnapshot(params: { hash: string; @@ -53,11 +71,12 @@ function createSnapshot(params: { const runtimeConfig = (params.runtimeConfig ?? params.sourceConfig) as ConfigFileSnapshot["config"]; const sourceConfig = params.sourceConfig as ConfigFileSnapshot["sourceConfig"]; + const parsed = params.parsed ?? params.sourceConfig; return { path: params.path ?? "/tmp/openclaw.json", exists: true, - raw: "{}", - parsed: params.parsed ?? params.sourceConfig, + raw: `${JSON.stringify(parsed, null, 2)}\n`, + parsed, sourceConfig, resolved: sourceConfig, valid: true, @@ -70,6 +89,12 @@ function createSnapshot(params: { }; } +async function resolveIncludeTarget(filePath: string): Promise { + return path.join(await fs.realpath(path.dirname(filePath)), path.basename(filePath)); +} + +const allowConfigPathWrite = () => {}; + describe("config mutate helpers", () => { const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-mutate-" }); const originalNixMode = process.env.OPENCLAW_NIX_MODE; @@ -155,11 +180,17 @@ describe("config mutate helpers", () => { ioMocks.readConfigFileSnapshotForWrite .mockResolvedValueOnce({ snapshot: initial, - writeOptions: { expectedConfigPath: initial.path }, + writeOptions: { + expectedConfigPath: initial.path, + ownedConfigPathForWrite: initial.path, + }, }) .mockResolvedValueOnce({ snapshot: fresh, - writeOptions: { expectedConfigPath: fresh.path }, + writeOptions: { + expectedConfigPath: fresh.path, + ownedConfigPathForWrite: fresh.path, + }, }); ioMocks.writeConfigFile .mockRejectedValueOnce(new ConfigMutationConflictError("stale", { currentHash: "hash-2" })) @@ -193,19 +224,138 @@ describe("config mutate helpers", () => { { baseSnapshot: fresh, expectedConfigPath: fresh.path, + ownedConfigPathForWrite: initial.path, afterWrite: { mode: "auto" }, preCommitRuntimePreflight: expect.any(Function), }, ); }); - it("serializes same-process transform mutations before reading snapshots", async () => { + it("preserves config path ownership across transform retries", async () => { const initial = createSnapshot({ hash: "hash-1", + path: "/tmp/first-openclaw.json", sourceConfig: { agents: { list: [] } }, }); const fresh = createSnapshot({ hash: "hash-2", + path: "/tmp/second-openclaw.json", + sourceConfig: { agents: { list: [] } }, + }); + ioMocks.readConfigFileSnapshotForWrite + .mockResolvedValueOnce({ + snapshot: initial, + writeOptions: { expectedConfigPath: initial.path }, + }) + .mockResolvedValueOnce({ + snapshot: fresh, + writeOptions: { expectedConfigPath: fresh.path }, + }); + ioMocks.writeConfigFile.mockRejectedValueOnce( + new ConfigMutationConflictError("stale", { currentHash: fresh.hash ?? null }), + ); + + const transform = vi.fn((config: OpenClawConfig) => ({ nextConfig: config })); + + await expect( + transformConfigFileWithRetry({ + io: ioMocks, + transform, + }), + ).rejects.toThrow("config path changed since last load"); + + expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledTimes(2); + expect(ioMocks.writeConfigFile).toHaveBeenCalledTimes(1); + expect(transform).toHaveBeenCalledTimes(1); + }); + + it("captures retry ownership before checking a caller base hash", async () => { + const initial = createSnapshot({ + hash: "hash-1", + path: "/tmp/first-openclaw.json", + sourceConfig: { agents: { list: [] } }, + }); + const fresh = createSnapshot({ + hash: "hash-2", + path: "/tmp/second-openclaw.json", + sourceConfig: { agents: { list: [] } }, + }); + ioMocks.readConfigFileSnapshotForWrite + .mockResolvedValueOnce({ + snapshot: initial, + writeOptions: { + expectedConfigPath: initial.path, + ownedConfigPathForWrite: initial.path, + }, + }) + .mockResolvedValueOnce({ + snapshot: fresh, + writeOptions: { + expectedConfigPath: fresh.path, + ownedConfigPathForWrite: fresh.path, + }, + }); + const transform = vi.fn((config: OpenClawConfig) => ({ nextConfig: config })); + + await expect( + transformConfigFileWithRetry({ + baseHash: fresh.hash, + io: ioMocks, + transform, + }), + ).rejects.toThrow("config path changed since last load"); + + expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledTimes(2); + expect(transform).not.toHaveBeenCalled(); + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not retry transform mutations after config path ownership changes", async () => { + const initialConfigPath = resolveConfigPath(); + const snapshot = createSnapshot({ + hash: "hash-1", + path: initialConfigPath, + sourceConfig: { agents: { list: [] } }, + }); + let activeConfigPath = snapshot.path; + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { + assertConfigPathForWrite: () => { + if (activeConfigPath !== snapshot.path) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }, + expectedConfigPath: snapshot.path, + }, + }); + + await expect( + transformConfigFileWithRetry({ + transform(config) { + activeConfigPath = "/tmp/second-openclaw.json"; + return { nextConfig: config }; + }, + }), + ).rejects.toThrow("config path changed since last load"); + + expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledTimes(1); + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("serializes same-process transform mutations before reading snapshots", async () => { + const configPath = resolveConfigPath(); + const initial = createSnapshot({ + hash: "hash-1", + path: configPath, + sourceConfig: { agents: { list: [] } }, + }); + const fresh = createSnapshot({ + hash: "hash-2", + path: configPath, sourceConfig: { agents: { list: [{ id: "first" }] } }, }); ioMocks.readConfigFileSnapshotForWrite @@ -284,6 +434,27 @@ describe("config mutate helpers", () => { expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); }); + it("rejects replace attempts when the active config path changed", async () => { + const snapshot = createSnapshot({ + path: "/tmp/second-openclaw.json", + hash: "same-hash", + sourceConfig: { gateway: { port: 18789 } }, + }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { gateway: { port: 19002 } }, + writeOptions: { expectedConfigPath: "/tmp/first-openclaw.json" }, + }), + ).rejects.toThrow("config path changed since last load"); + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + it("refuses replace writes in Nix mode before touching disk", async () => { process.env.OPENCLAW_NIX_MODE = "1"; const snapshot = createSnapshot({ @@ -438,7 +609,7 @@ describe("config mutate helpers", () => { }); }); - it("writes through a single-file top-level plugins include", async () => { + it("repairs invalid config through a single-file top-level plugins include", async () => { const home = await suiteRootTracker.make("include"); const configPath = path.join(home, ".openclaw", "openclaw.json"); const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); @@ -450,19 +621,40 @@ describe("config mutate helpers", () => { ); await fs.writeFile( pluginsPath, - `${JSON.stringify({ entries: { old: { enabled: true } } }, null, 2)}\n`, + `${JSON.stringify( + { + entries: { + old: { + enabled: true, + config: { token: "${OPENCLAW_TEST_PLUGIN_TOKEN}" }, + }, + }, + }, + null, + 2, + )}\n`, "utf-8", ); - const snapshot = createSnapshot({ - hash: "hash-include", - path: configPath, - parsed: { plugins: { $include: "./config/plugins.json5" } }, - sourceConfig: { - plugins: { - entries: { old: { enabled: true } }, + const previousBackupPath = `${pluginsPath}.bak`; + await fs.writeFile(previousBackupPath, "previous backup", { mode: 0o644 }); + const oldEntry = { + enabled: true, + config: { token: "plugin-token-runtime" }, + }; + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-include", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { + plugins: { + entries: { old: oldEntry }, + }, }, - }, - }); + }), + valid: false, + issues: [{ path: "plugins.load.paths", message: "plugin path not found: /gone" }], + }; const refreshedSnapshot = createSnapshot({ hash: "hash-include-refreshed", path: configPath, @@ -470,16 +662,26 @@ describe("config mutate helpers", () => { sourceConfig: { plugins: { entries: { - old: { enabled: true }, + old: oldEntry, demo: { enabled: true }, }, }, }, }); - ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ - snapshot: refreshedSnapshot, - writeOptions: { expectedConfigPath: configPath }, - }); + ioMocks.readConfigFileSnapshotForWrite + .mockResolvedValueOnce({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + envSnapshotForRestore: { OPENCLAW_TEST_PLUGIN_TOKEN: "plugin-token-runtime" }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }) + .mockResolvedValueOnce({ + snapshot: refreshedSnapshot, + writeOptions: { expectedConfigPath: configPath }, + }); const notifications: unknown[] = []; const unregister = registerRuntimeConfigWriteListener((event) => { notifications.push(event); @@ -488,7 +690,6 @@ describe("config mutate helpers", () => { try { await replaceConfigFile({ baseHash: snapshot.hash, - snapshot, afterWrite: { mode: "restart", reason: "test include refresh" }, writeOptions: { expectedConfigPath: snapshot.path, @@ -497,7 +698,7 @@ describe("config mutate helpers", () => { nextConfig: { plugins: { entries: { - old: { enabled: true }, + old: oldEntry, demo: { enabled: true }, }, installs: { @@ -509,6 +710,11 @@ describe("config mutate helpers", () => { }, }, }, + io: { + env: { OPENCLAW_TEST_PLUGIN_TOKEN: "plugin-token-after-read" }, + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, }); } finally { unregister(); @@ -528,7 +734,7 @@ describe("config mutate helpers", () => { expect(notification?.sourceConfig).toEqual({ plugins: { entries: { - old: { enabled: true }, + old: oldEntry, demo: { enabled: true }, }, }, @@ -536,7 +742,7 @@ describe("config mutate helpers", () => { expect(notification?.runtimeConfig).toEqual({ plugins: { entries: { - old: { enabled: true }, + old: oldEntry, demo: { enabled: true }, }, }, @@ -546,14 +752,357 @@ describe("config mutate helpers", () => { '"$include": "./config/plugins.json5"', ); await expect(fs.readFile(`${pluginsPath}.bak`, "utf-8")).resolves.toContain('"old"'); + await expect(fs.readFile(`${pluginsPath}.bak.1`, "utf-8")).resolves.toBe("previous backup"); + if (process.platform !== "win32") { + expect((await fs.stat(`${pluginsPath}.bak`)).mode & 0o777).toBe(0o600); + expect((await fs.stat(`${pluginsPath}.bak.1`)).mode & 0o777).toBe(0o600); + } const persistedPlugins = JSON.parse(await fs.readFile(pluginsPath, "utf-8")) as { - entries?: Record; + entries?: Record; installs?: Record; }; + expect(persistedPlugins.entries?.old?.config?.token).toBe("${OPENCLAW_TEST_PLUGIN_TOKEN}"); expect(persistedPlugins.entries?.demo).toEqual({ enabled: true }); expect(persistedPlugins.installs).toBeUndefined(); }); + it("repairs a malformed single-file top-level include", async () => { + const home = await suiteRootTracker.make("malformed-include"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, "{ malformed", "utf-8"); + + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-malformed-include", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: {} }, + }), + valid: false, + issues: [ + { + path: "", + message: `Failed to parse include file: ./config/plugins.json5 (resolved: ${pluginsPath})`, + }, + ], + }; + const nextConfig = { + plugins: { entries: { demo: { enabled: true } } }, + } satisfies OpenClawConfig; + ioMocks.readConfigFileSnapshotForWrite + .mockResolvedValueOnce({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw("{ malformed") }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }) + .mockResolvedValueOnce({ + snapshot: createSnapshot({ + hash: "hash-malformed-include-refreshed", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: nextConfig, + }), + writeOptions: { expectedConfigPath: configPath }, + }); + + await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(`${pluginsPath}.bak`, "utf-8")).resolves.toBe("{ malformed"); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe( + `${JSON.stringify(nextConfig.plugins, null, 2)}\n`, + ); + }); + + it("repairs a missing single-file top-level include from its snapshot", async () => { + const home = await suiteRootTracker.make("missing-include"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-missing-include", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: {} }, + }), + valid: false, + issues: [ + { + path: "", + message: `Failed to read include file: ./config/plugins.json5 (resolved: ${pluginsPath})`, + }, + ], + }; + const nextConfig = { + plugins: { entries: { demo: { enabled: true } } }, + } satisfies OpenClawConfig; + ioMocks.readConfigFileSnapshotForWrite + .mockResolvedValueOnce({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(null) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }) + .mockResolvedValueOnce({ + snapshot: createSnapshot({ + hash: "hash-missing-include-refreshed", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: nextConfig, + }), + writeOptions: { expectedConfigPath: configPath }, + }); + + await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe( + `${JSON.stringify(nextConfig.plugins, null, 2)}\n`, + ); + }); + + it.runIf(process.platform !== "win32")( + "rejects missing include repairs through symlinked parents outside config roots", + async () => { + const home = await suiteRootTracker.make("missing-include-symlink-escape"); + const outside = await suiteRootTracker.make("missing-include-symlink-outside"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const linkPath = path.join(home, ".openclaw", "link"); + const pluginsPath = path.join(linkPath, "plugins.json5"); + const outsidePluginsPath = path.join(outside, "plugins.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.symlink(outside, linkPath); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./link/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-missing-include-symlink-escape", + path: configPath, + parsed: { plugins: { $include: "./link/plugins.json5" } }, + sourceConfig: { plugins: {} }, + }), + valid: false, + issues: [ + { + path: "", + message: `Failed to read include file: ./link/plugins.json5 (resolved: ${pluginsPath})`, + }, + ], + }; + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(null) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { plugins: { entries: { demo: { enabled: true } } } }, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow("Config mutation cannot update external $include target"); + + await expect(fs.stat(outsidePluginsPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it("does not overwrite a malformed include changed after its snapshot", async () => { + const home = await suiteRootTracker.make("malformed-include-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const snapshotRaw = "{ malformed"; + const concurrentRaw = "{ differently malformed"; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, concurrentRaw, "utf-8"); + + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-malformed-include-concurrent", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: {} }, + }), + valid: false, + issues: [ + { + path: "", + message: `Failed to parse include file: ./config/plugins.json5 (resolved: ${pluginsPath})`, + }, + ], + }; + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(snapshotRaw) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { plugins: { entries: { demo: { enabled: true } } } }, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow("included config changed since last load"); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + + it("prefers mutation-start include hashes over commit-time reread hashes", async () => { + const home = await suiteRootTracker.make("include-mutation-start-hash"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const initialRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + const concurrentRaw = `${JSON.stringify( + { entries: { concurrent: { enabled: true } } }, + null, + 2, + )}\n`; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, concurrentRaw, "utf-8"); + + const snapshot = createSnapshot({ + hash: "hash-include-mutation-start", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { concurrent: { enabled: true } } } }, + }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(concurrentRaw) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(initialRaw) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { plugins: { entries: { demo: { enabled: true } } } }, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow("included config changed since last load"); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + + it("uses a provided mutation-start snapshot even without write options", async () => { + const home = await suiteRootTracker.make("include-mutation-start-snapshot"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const concurrentRaw = `${JSON.stringify( + { entries: { concurrent: { enabled: true } } }, + null, + 2, + )}\n`; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, concurrentRaw, "utf-8"); + + const snapshot = createSnapshot({ + hash: "hash-include-mutation-start-snapshot", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { old: { enabled: true } } } }, + }); + + await expect( + replaceConfigFile({ + snapshot, + baseHash: snapshot.hash, + nextConfig: { plugins: { entries: { demo: { enabled: true } } } }, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow("included config target changed since last load"); + + expect(ioMocks.readConfigFileSnapshotForWrite).not.toHaveBeenCalled(); + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + it("keeps single-file top-level plugins include writes when plugin validation is skipped", async () => { const home = await suiteRootTracker.make("include-skip-plugin-validation"); const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -600,6 +1149,8 @@ describe("config mutate helpers", () => { snapshot, writeOptions: { expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, skipPluginValidation: true, }, nextConfig, @@ -609,9 +1160,11 @@ describe("config mutate helpers", () => { expect(validationMocks.validateConfigObjectWithPlugins).toHaveBeenCalledWith(nextConfig, { pluginValidation: "skip", }); - expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledWith({ - skipPluginValidation: true, + expect(ioMocks.createConfigIO).toHaveBeenCalledWith({ + configPath, + pluginValidation: "skip", }); + expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledWith(); await expect(fs.readFile(configPath, "utf-8")).resolves.toContain( '"$include": "./config/plugins.json5"', ); @@ -621,6 +1174,62 @@ describe("config mutate helpers", () => { expect(persistedPlugins.entries?.["strict-plugin"]).toEqual({ enabled: true }); }); + it("rejects direct mutations to external include roots", async () => { + const home = await suiteRootTracker.make("include-allowed-root"); + const sharedRoot = path.join(home, "shared"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(sharedRoot, "plugins.json5"); + await fs.mkdir(sharedRoot, { recursive: true }); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: pluginsPath } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, `${JSON.stringify({ entries: {} }, null, 2)}\n`, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-allowed-root", + path: configPath, + parsed: { plugins: { $include: pluginsPath } }, + sourceConfig: { plugins: { entries: {} } }, + }); + const nextConfig = { + plugins: { entries: { demo: { enabled: true } } }, + } satisfies OpenClawConfig; + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: createSnapshot({ + hash: "hash-include-allowed-root-refreshed", + path: configPath, + parsed: { plugins: { $include: pluginsPath } }, + sourceConfig: nextConfig, + }), + writeOptions: { expectedConfigPath: configPath }, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig, + io: { + env: { OPENCLAW_INCLUDE_ROOTS: "~/shared" }, + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow("Config mutation cannot update external $include target"); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe( + `${JSON.stringify({ entries: {} }, null, 2)}\n`, + ); + }); + it("preflights single-file top-level include writes before persisting", async () => { const home = await suiteRootTracker.make("include-runtime-preflight"); const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -652,7 +1261,11 @@ describe("config mutate helpers", () => { replaceConfigFile({ baseHash: snapshot.hash, snapshot, - writeOptions: { expectedConfigPath: snapshot.path }, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, nextConfig: { plugins: { entries: { @@ -669,6 +1282,715 @@ describe("config mutate helpers", () => { } }); + it("does not overwrite concurrent include edits made during preflight", async () => { + const home = await suiteRootTracker.make("include-preflight-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, `${JSON.stringify({ entries: {} }, null, 2)}\n`, "utf-8"); + const concurrentRaw = `${JSON.stringify( + { entries: { concurrent: { enabled: true } } }, + null, + 2, + )}\n`; + const snapshot = createSnapshot({ + hash: "hash-include-preflight-concurrent", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: {} } }, + }); + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: async () => { + await fs.writeFile(pluginsPath, concurrentRaw, "utf-8"); + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentRaw); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }); + + it("does not overwrite concurrent include edits made during backup rotation", async () => { + const home = await suiteRootTracker.make("include-backup-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const rootConfig = { plugins: { $include: "./config/plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + const concurrentPluginsRaw = `${JSON.stringify( + { entries: { concurrent: { enabled: true } } }, + null, + 2, + )}\n`; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-backup-concurrent", + path: configPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + backupMocks.maintainConfigBackups.mockImplementationOnce(async () => { + await fs.writeFile(pluginsPath, concurrentPluginsRaw, "utf-8"); + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentPluginsRaw); + }); + + it("does not write an include after its root ownership changes during backup rotation", async () => { + const home = await suiteRootTracker.make("include-root-backup-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const rootConfig = { plugins: { $include: "./config/plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + const concurrentRootRaw = `${JSON.stringify( + { plugins: { entries: { concurrent: { enabled: true } } } }, + null, + 2, + )}\n`; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-root-backup-concurrent", + path: configPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + backupMocks.maintainConfigBackups.mockImplementationOnce(async () => { + await fs.writeFile(configPath, concurrentRootRaw, "utf-8"); + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(concurrentRootRaw); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + }); + + it("does not write an include after its root ownership changes during preflight", async () => { + const home = await suiteRootTracker.make("include-root-preflight-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + const rootConfig = { plugins: { $include: "./config/plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + const concurrentRootRaw = `${JSON.stringify( + { plugins: { entries: { concurrent: { enabled: true } } } }, + null, + 2, + )}\n`; + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-root-preflight-concurrent", + path: configPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: async () => { + await fs.writeFile(configPath, concurrentRootRaw, "utf-8"); + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(concurrentRootRaw); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }); + + it("does not write an include after the active config path changes during preflight", async () => { + const home = await suiteRootTracker.make("include-active-path-preflight-concurrent"); + const firstConfigPath = path.join(home, "first", "openclaw.json"); + const secondConfigPath = path.join(home, "second", "openclaw.json"); + const pluginsPath = path.join(home, "first", "plugins.json5"); + const rootConfig = { plugins: { $include: "./plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + await fs.mkdir(path.dirname(firstConfigPath), { recursive: true }); + await fs.mkdir(path.dirname(secondConfigPath), { recursive: true }); + await fs.writeFile(firstConfigPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(secondConfigPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-active-path-preflight-concurrent", + path: firstConfigPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + let activeConfigPath = firstConfigPath; + const assertConfigPathForWrite = () => { + if (activeConfigPath !== firstConfigPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }; + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: () => { + activeConfigPath = secondConfigPath; + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }); + + it("rolls back an include write when config path ownership changes during commit", async () => { + const home = await suiteRootTracker.make("include-active-path-commit-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "plugins.json5"); + const rootConfig = { plugins: { $include: "./plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-active-path-commit-concurrent", + path: configPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + let activeConfigPath = configPath; + const assertConfigPathForWrite = () => { + if (fsNode.readFileSync(pluginsPath, "utf-8") !== initialPluginsRaw) { + activeConfigPath = "/tmp/other-openclaw.json"; + } + if (activeConfigPath !== configPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }; + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + }); + + it.each(["active path", "refreshed snapshot path"] as const)( + "rolls back an include write when the %s changes during the post-write read", + async (changeKind) => { + const home = await suiteRootTracker.make( + `include-post-write-${changeKind.replaceAll(" ", "-")}`, + ); + const configPath = path.join(home, "first", "openclaw.json"); + const otherConfigPath = path.join(home, "second", "openclaw.json"); + const pluginsPath = path.join(home, "first", "plugins.json5"); + const rootConfig = { plugins: { $include: "./plugins.json5" } }; + const initialPluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`; + const nextConfig = { plugins: { entries: { demo: { enabled: true } } } }; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(rootConfig, null, 2)}\n`, "utf-8"); + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-post-write-path-change", + path: configPath, + parsed: rootConfig, + sourceConfig: { plugins: { entries: {} } }, + }); + let activeConfigPath = configPath; + const assertConfigPathForWrite = () => { + if (activeConfigPath !== configPath) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: null, + retryable: false, + }); + } + }; + ioMocks.readConfigFileSnapshotForWrite.mockImplementation(async () => { + if (changeKind === "active path") { + activeConfigPath = otherConfigPath; + } + return { + snapshot: createSnapshot({ + hash: "hash-include-post-write-refreshed", + path: changeKind === "refreshed snapshot path" ? otherConfigPath : configPath, + parsed: rootConfig, + sourceConfig: nextConfig, + }), + writeOptions: { expectedConfigPath: configPath }, + }; + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + io: { ...ioMocks, env: {} }, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig, + }), + ).rejects.toThrow("config path changed since last load"); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + }, + ); + + it.runIf(process.platform !== "win32")( + "does not create a missing include through a parent symlink swapped during preflight", + async () => { + const home = await suiteRootTracker.make("include-preflight-parent-swap"); + const outside = await suiteRootTracker.make("include-preflight-parent-swap-outside"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includeDir = path.join(home, ".openclaw", "config"); + const movedIncludeDir = path.join(home, ".openclaw", "config-original"); + const pluginsPath = path.join(includeDir, "plugins.json5"); + const outsidePluginsPath = path.join(outside, "plugins.json5"); + await fs.mkdir(includeDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-include-preflight-parent-swap", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: {} }, + }), + valid: false, + issues: [ + { + path: "", + message: `Failed to read include file: ./config/plugins.json5 (resolved: ${pluginsPath})`, + }, + ], + }; + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: createSnapshot({ + hash: "hash-include-preflight-parent-swap-refreshed", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { demo: { enabled: true } } } }, + }), + writeOptions: { expectedConfigPath: configPath }, + }); + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: async () => { + await fs.rename(includeDir, movedIncludeDir); + await fs.symlink(outside, includeDir); + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: configPath, + includeFileHashesForWrite: { [pluginsPath]: hashConfigIncludeRaw(null) }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { + [pluginsPath]: await resolveIncludeTarget(pluginsPath), + }, + }, + nextConfig: { plugins: { entries: { demo: { enabled: true } } } }, + io: { + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow(); + + await expect(fs.stat(outsidePluginsPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(path.join(movedIncludeDir, "plugins.json5"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }, + ); + + it("does not overwrite include edits made after the mutation snapshot", async () => { + const home = await suiteRootTracker.make("include-snapshot-concurrent"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + const concurrentRaw = `${JSON.stringify( + { entries: { concurrent: { enabled: true } } }, + null, + 2, + )}\n`; + await fs.writeFile(pluginsPath, concurrentRaw, "utf-8"); + const snapshot: ConfigFileSnapshot = { + ...createSnapshot({ + hash: "hash-include-snapshot-concurrent", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: {} } }, + }), + valid: false, + issues: [{ path: "plugins.load.paths", message: "plugin path not found: /gone" }], + }; + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(concurrentRaw); + }); + + it("preflights the restored include payload with the current environment", async () => { + const home = await suiteRootTracker.make("include-restored-preflight"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + const initialPluginsRaw = `${JSON.stringify( + { + entries: { + old: { enabled: true, config: { token: "${OPENCLAW_TEST_INCLUDE_TOKEN}" } }, + }, + }, + null, + 2, + )}\n`; + await fs.writeFile(pluginsPath, initialPluginsRaw, "utf-8"); + const oldEntry = { enabled: true, config: { token: "old-token" } }; + const snapshot = createSnapshot({ + hash: "hash-include-restored-preflight", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { old: oldEntry } } }, + }); + const observedSources: OpenClawConfig[] = []; + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: ({ sourceConfig }) => { + observedSources.push(sourceConfig); + throw new Error("stop before write"); + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + envSnapshotForRestore: { OPENCLAW_TEST_INCLUDE_TOKEN: "old-token" }, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + plugins: { + entries: { + old: oldEntry, + demo: { enabled: true }, + }, + }, + }, + io: { + env: { OPENCLAW_TEST_INCLUDE_TOKEN: "new-token" }, + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow(/active SecretRef resolution failed: stop before write/); + + expect(observedSources[0]?.plugins?.entries?.old?.config).toEqual({ token: "new-token" }); + await expect(fs.readFile(pluginsPath, "utf-8")).resolves.toBe(initialPluginsRaw); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }); + + it("does not re-substitute resolved root values during include preflight", async () => { + const home = await suiteRootTracker.make("include-root-escaped-env"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { auth: { mode: "token", token: "$${ROOT_LITERAL_TOKEN}" } }, + plugins: { $include: "./config/plugins.json5" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + await fs.writeFile(pluginsPath, `${JSON.stringify({ entries: {} }, null, 2)}\n`, "utf-8"); + const snapshot = createSnapshot({ + hash: "hash-include-root-escaped-env", + path: configPath, + parsed: { + gateway: { auth: { mode: "token", token: "$${ROOT_LITERAL_TOKEN}" } }, + plugins: { $include: "./config/plugins.json5" }, + }, + sourceConfig: { + gateway: { auth: { mode: "token", token: "${ROOT_LITERAL_TOKEN}" } }, + plugins: { entries: {} }, + }, + }); + const observedSources: OpenClawConfig[] = []; + + try { + setRuntimeConfigSnapshotRefreshHandler({ + preflight: ({ sourceConfig }) => { + observedSources.push(sourceConfig); + throw new Error("stop before write"); + }, + refresh: () => true, + }); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, + nextConfig: { + gateway: { auth: { mode: "token", token: "${ROOT_LITERAL_TOKEN}" } }, + plugins: { entries: { demo: { enabled: true } } }, + }, + io: { + env: { ROOT_LITERAL_TOKEN: "secret" }, + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }), + ).rejects.toThrow(/active SecretRef resolution failed: stop before write/); + + expect(observedSources[0]?.gateway?.auth?.token).toBe("${ROOT_LITERAL_TOKEN}"); + } finally { + setRuntimeConfigSnapshotRefreshHandler(null); + } + }); + + it("preserves unresolved optional env refs during include write-through", async () => { + const home = await suiteRootTracker.make("include-unresolved-env"); + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5"); + await fs.mkdir(path.dirname(pluginsPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + pluginsPath, + `${JSON.stringify( + { + entries: { + old: { enabled: true, config: { token: "${OPTIONAL_TOKEN}" } }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const oldEntry = { enabled: true, config: { token: "${OPTIONAL_TOKEN}" } }; + const snapshot = createSnapshot({ + hash: "hash-include-unresolved-env", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { old: oldEntry } } }, + }); + + await replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + writeOptions: { + expectedConfigPath: snapshot.path, + envSnapshotForRestore: {}, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + skipRuntimeSnapshotRefresh: true, + }, + nextConfig: { + plugins: { + entries: { + old: oldEntry, + demo: { enabled: true }, + }, + }, + }, + io: { + env: {}, + readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite, + writeConfigFile: ioMocks.writeConfigFile, + }, + }); + + const persisted = JSON.parse(await fs.readFile(pluginsPath, "utf-8")) as { + entries?: Record; + }; + expect(persisted.entries?.old?.config?.token).toBe("${OPTIONAL_TOKEN}"); + expect(persisted.entries?.demo).toEqual({ enabled: true }); + }); + it("rolls back single-file top-level include writes when runtime refresh fails", async () => { const home = await suiteRootTracker.make("include-runtime-refresh-rollback"); const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -723,7 +2045,11 @@ describe("config mutate helpers", () => { baseHash: snapshot.hash, snapshot, io: { ...ioMocks, env }, - writeOptions: { expectedConfigPath: snapshot.path }, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, nextConfig, }), ).rejects.toThrow(/runtime snapshot refresh failed: lost include secret/); @@ -788,7 +2114,11 @@ describe("config mutate helpers", () => { replaceConfigFile({ baseHash: snapshot.hash, snapshot, - writeOptions: { expectedConfigPath: snapshot.path }, + writeOptions: { + expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, + }, nextConfig, }), ).rejects.toThrow(/runtime snapshot refresh failed: lost include secret/); @@ -844,6 +2174,8 @@ describe("config mutate helpers", () => { snapshot, writeOptions: { expectedConfigPath: snapshot.path, + assertConfigPathForWrite: allowConfigPathWrite, + includeFileTargetsForWrite: { [pluginsPath]: await resolveIncludeTarget(pluginsPath) }, skipPluginValidation: true, }, nextConfig, @@ -868,6 +2200,10 @@ describe("config mutate helpers", () => { plugins: { entries: {} }, }, }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); await replaceConfigFile({ snapshot, diff --git a/src/config/mutate.ts b/src/config/mutate.ts index 67289d929ca..ce9387ad891 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -1,18 +1,26 @@ // Applies scoped config mutations while preserving IO and observer state. import { AsyncLocalStorage } from "node:async_hooks"; -import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { formatErrorMessage } from "../infra/errors.js"; import { withFileLock } from "../infra/file-lock.js"; -import { replaceFileAtomic } from "../infra/replace-file.js"; +import { root as createFsRoot, type Root as FsSafeRoot } from "../infra/fs-safe.js"; import { isPathInside } from "../security/scan-paths.js"; import { isRecord } from "../utils.js"; +import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { maintainConfigBackups } from "./backup-rotation.js"; -import { INCLUDE_KEY } from "./includes.js"; +import { restoreEnvVarRefs } from "./env-preserve.js"; +import { resolveConfigEnvVars } from "./env-substitution.js"; +import { + ConfigIncludeError, + hashConfigIncludeRaw, + INCLUDE_KEY, + resolveConfigIncludeWritePath, +} from "./includes.js"; import { createInvalidConfigError, formatInvalidConfigDetails } from "./io.invalid-config.js"; import { + createConfigIO, readConfigFileSnapshotForWrite, restoreEnvChangesIfUnchanged, resolveConfigSnapshotHash, @@ -20,7 +28,12 @@ import { type ConfigWriteOptions, type ConfigWriteResult, } from "./io.js"; -import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js"; +import { + applyUnsetPathsForWrite, + resolveManagedUnsetPathsForWrite, + resolveWriteEnvSnapshotForPath, +} from "./io.write-prepare.js"; +import { ConfigMutationConflictError } from "./mutation-conflict.js"; import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { resolveConfigPath } from "./paths.js"; import { @@ -57,16 +70,7 @@ const DEFAULT_CONFIG_MUTATION_RETRY_ATTEMPTS = 5; const activeConfigMutationLocks = new AsyncLocalStorage>(); const configMutationQueueTails = new Map>(); -/** Raised when a config write loses an optimistic hash race. */ -export class ConfigMutationConflictError extends Error { - readonly currentHash: string | null; - - constructor(message: string, params: { currentHash: string | null }) { - super(message); - this.name = "ConfigMutationConflictError"; - this.currentHash = params.currentHash; - } -} +export { ConfigMutationConflictError } from "./mutation-conflict.js"; export type ConfigReplaceResult = { path: string; @@ -139,6 +143,13 @@ export type ConfigMutationResult = ConfigReplaceResult & { attempts: number; }; +type ConfigMutationOwnership = { + initialized: boolean; + expectedConfigPath: string; + ownedConfigPathForWrite?: string; + assertConfigPathForWrite?: () => void; +}; + function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null { const currentHash = resolveConfigSnapshotHash(snapshot) ?? null; if (expectedHash !== undefined && expectedHash !== currentHash) { @@ -149,6 +160,18 @@ function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: stri return currentHash; } +function assertExpectedConfigPathMatches( + snapshot: ConfigFileSnapshot, + expectedConfigPath?: string, +): void { + if (expectedConfigPath !== undefined && expectedConfigPath !== snapshot.path) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash: resolveConfigSnapshotHash(snapshot) ?? null, + retryable: false, + }); + } +} + async function withConfigMutationLock( params: { io?: ConfigMutationIO; lockPath?: string }, fn: () => Promise, @@ -193,17 +216,69 @@ function markActiveConfigMutationPath(configPath: string): void { } async function readConfigSnapshotForMutation(params: { + ownedConfigPathForWrite?: string; io?: ConfigMutationIO; writeOptions?: ConfigWriteOptions; }): Promise<{ snapshot: ConfigFileSnapshot; writeOptions: ConfigWriteOptions; }> { + const options = params.writeOptions?.skipPluginValidation ? { skipPluginValidation: true } : {}; if (params.io) { - return await params.io.readConfigFileSnapshotForWrite(); + return await params.io.readConfigFileSnapshotForWrite(options); } - return await readConfigFileSnapshotForWrite({ - skipPluginValidation: params.writeOptions?.skipPluginValidation, + if (params.ownedConfigPathForWrite) { + return await createConfigIO({ + configPath: params.ownedConfigPathForWrite, + ...(params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" as const } : {}), + }).readConfigFileSnapshotForWrite(); + } + return await readConfigFileSnapshotForWrite(options); +} + +function createConfigMutationOwnership( + prepared: Awaited>, + writeOptions?: ConfigWriteOptions, +): ConfigMutationOwnership { + const mergedWriteOptions = { + ...prepared.writeOptions, + ...writeOptions, + }; + return { + initialized: true, + expectedConfigPath: mergedWriteOptions.expectedConfigPath ?? prepared.snapshot.path, + ownedConfigPathForWrite: mergedWriteOptions.ownedConfigPathForWrite, + assertConfigPathForWrite: mergedWriteOptions.assertConfigPathForWrite, + }; +} + +async function withConfigMutationSnapshotLock( + params: { writeOptions?: ConfigWriteOptions }, + fn: (prepared: Awaited>) => Promise, +): Promise { + let lockPath = path.resolve(params.writeOptions?.ownedConfigPathForWrite ?? resolveConfigPath()); + for (let attempt = 0; attempt < 3; attempt += 1) { + const outcome = await withConfigMutationLock({ lockPath }, async () => { + const prepared = await readConfigSnapshotForMutation({ + ...(params.writeOptions?.ownedConfigPathForWrite + ? { ownedConfigPathForWrite: params.writeOptions.ownedConfigPathForWrite } + : {}), + writeOptions: params.writeOptions, + }); + const preparedPath = path.resolve(prepared.snapshot.path); + if (preparedPath !== lockPath) { + return { done: false as const, lockPath: preparedPath }; + } + return { done: true as const, value: await fn(prepared) }; + }); + if (outcome.done) { + return outcome.value; + } + lockPath = outcome.lockPath; + } + throw new ConfigMutationConflictError("config path changed repeatedly while acquiring lock", { + currentHash: null, + retryable: false, }); } @@ -233,84 +308,266 @@ function getSingleTopLevelIncludeTarget(params: { } const rootDir = path.dirname(params.snapshot.path); - const resolved = path.normalize( + return path.normalize( path.isAbsolute(includeValue) ? includeValue : path.resolve(rootDir, includeValue), ); - if (!isPathInside(rootDir, resolved)) { - return null; +} + +function containsConfigIncludeDirective(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => containsConfigIncludeDirective(item)); } - return resolved; + if (!isRecord(value)) { + return false; + } + return ( + Object.hasOwn(value, INCLUDE_KEY) || + Object.values(value).some((item) => containsConfigIncludeDirective(item)) + ); +} + +function snapshotProvesBrokenInclude(snapshot: ConfigFileSnapshot, includePath: string): boolean { + return ( + !snapshot.valid && + snapshot.issues.some( + (issue) => + /Failed to (?:read|parse) include file:/.test(issue.message) && + issue.message.includes(includePath), + ) + ); } function formatJsonFileValue(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } -function hashFileRaw(raw: string | null): string { - const hash = crypto.createHash("sha256"); - if (raw === null) { - hash.update("missing"); - } else { - hash.update("present\0"); - hash.update(raw, "utf-8"); - } - return hash.digest("hex"); +type RootBoundIncludeFile = { + absolutePath: string; + relativePath: string; + root: FsSafeRoot; +}; + +function isMissingFileError(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + return code === "ENOENT" || code === "not-found"; } -async function readFileRawIfExists(filePath: string): Promise { +function resolveRootBoundRelativePath(target: RootBoundIncludeFile, absolutePath: string): string { + const relativePath = path.relative(target.root.rootReal, path.resolve(absolutePath)); + const firstSegment = relativePath.split(path.sep)[0]; + if (path.isAbsolute(relativePath) || firstSegment === "..") { + throw new Error(`Config include backup path escaped its approved root: ${absolutePath}`); + } + return relativePath; +} + +async function resolveRootBoundIncludeFile(params: { + configPath: string; + includePath: string; + allowedRoots: readonly string[]; +}): Promise { + const absolutePath = resolveConfigIncludeWritePath(params); + const candidateRoots = [path.dirname(params.configPath), ...params.allowedRoots]; + for (const candidateRoot of candidateRoots) { + const rootReal = await fs.realpath(candidateRoot).catch(() => null); + if (!rootReal || !isPathInside(rootReal, absolutePath)) { + continue; + } + const relativePath = path.relative(rootReal, absolutePath); + if ( + !relativePath || + path.isAbsolute(relativePath) || + relativePath.split(path.sep)[0] === ".." + ) { + continue; + } + return { + absolutePath, + relativePath, + root: await createFsRoot(rootReal, { + hardlinks: "reject", + mkdir: true, + mode: 0o600, + symlinks: "reject", + }), + }; + } + throw new Error(`Config include write path has no approved existing root: ${absolutePath}`); +} + +async function resolveExpectedRootBoundIncludeFile(params: { + configPath: string; + includePath: string; + allowedRoots: readonly string[]; + expectedAbsolutePath: string; +}): Promise { + let target: RootBoundIncludeFile; try { - return await fs.readFile(filePath, "utf-8"); + target = await resolveRootBoundIncludeFile(params); } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + if ( + error instanceof ConfigIncludeError || + (error instanceof Error && + error.message.startsWith("Config include write path has no approved existing root:")) + ) { + throw new ConfigMutationConflictError("included config target changed since last load", { + currentHash: null, + }); + } + throw error; + } + if (path.normalize(target.absolutePath) !== path.normalize(params.expectedAbsolutePath)) { + throw new ConfigMutationConflictError("included config target changed since last load", { + currentHash: null, + }); + } + return target; +} + +async function readRootBoundFileRawIfExists(target: RootBoundIncludeFile): Promise { + try { + return await target.root.readText(target.relativePath); + } catch (error) { + if (isMissingFileError(error)) { return null; } throw error; } } +async function assertRootConfigStillMatchesSnapshot(snapshot: ConfigFileSnapshot): Promise { + let currentRaw: string | null = null; + try { + currentRaw = await fs.readFile(snapshot.path, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + const currentHash = hashConfigIncludeRaw(currentRaw); + const expectedHash = hashConfigIncludeRaw(snapshot.exists ? (snapshot.raw ?? null) : null); + if (currentHash !== expectedHash) { + throw new ConfigMutationConflictError("config changed while preparing include write", { + currentHash, + }); + } +} + async function rollbackJsonFileWriteIfUnchanged(params: { - filePath: string; + target: RootBoundIncludeFile; previousRaw: string | null; committedHash: string; }): Promise { - const currentRaw = await readFileRawIfExists(params.filePath); - if (hashFileRaw(currentRaw) !== params.committedHash) { + const currentRaw = await readRootBoundFileRawIfExists(params.target); + if (hashConfigIncludeRaw(currentRaw) !== params.committedHash) { return false; } if (params.previousRaw !== null) { - await replaceFileAtomic({ - filePath: params.filePath, - content: params.previousRaw, - dirMode: 0o700, + await params.target.root.write(params.target.relativePath, params.previousRaw, { + mkdir: true, mode: 0o600, - tempPrefix: path.basename(params.filePath), + overwrite: true, }); return true; } try { - await fs.unlink(params.filePath); + await params.target.root.remove(params.target.relativePath); } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + if (!isMissingFileError(error)) { throw error; } } return true; } -async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { - await replaceFileAtomic({ - filePath, - content: formatJsonFileValue(value), - dirMode: 0o700, - mode: 0o600, - tempPrefix: path.basename(filePath), - beforeRename: async () => { - await fs.access(filePath).then( - async () => await maintainConfigBackups(filePath, fs), - () => undefined, +function createRootBoundBackupFs(target: RootBoundIncludeFile) { + return { + chmod: async (filePath: string, mode: number) => { + const opened = await target.root.open(resolveRootBoundRelativePath(target, filePath)); + try { + await opened.handle.chmod(mode); + } finally { + await opened[Symbol.asyncDispose](); + } + }, + copyFile: async (from: string, to: string) => { + const content = await target.root.readBytes(resolveRootBoundRelativePath(target, from)); + await target.root.write(resolveRootBoundRelativePath(target, to), content, { + mkdir: true, + mode: 0o600, + overwrite: true, + }); + }, + readdir: async (dir: string) => + await target.root.list(resolveRootBoundRelativePath(target, dir)), + rename: async (from: string, to: string) => { + await target.root.move( + resolveRootBoundRelativePath(target, from), + resolveRootBoundRelativePath(target, to), + { overwrite: true }, ); }, + unlink: async (filePath: string) => { + await target.root.remove(resolveRootBoundRelativePath(target, filePath)); + }, + }; +} + +async function writeRootBoundJsonFile(params: { + configPath: string; + includePath: string; + allowedRoots: readonly string[]; + expectedTargetPath: string; + value: unknown; + expectedRaw: string | null; + rootSnapshot: ConfigFileSnapshot; + assertConfigPathForWrite: () => void; +}): Promise { + params.assertConfigPathForWrite(); + const targetBeforeBackup = await resolveExpectedRootBoundIncludeFile({ + configPath: params.configPath, + includePath: params.includePath, + allowedRoots: params.allowedRoots, + expectedAbsolutePath: params.expectedTargetPath, }); + if (await targetBeforeBackup.root.exists(targetBeforeBackup.relativePath)) { + await maintainConfigBackups( + targetBeforeBackup.absolutePath, + createRootBoundBackupFs(targetBeforeBackup), + ); + } + const targetAtCommit = await resolveExpectedRootBoundIncludeFile({ + configPath: params.configPath, + includePath: params.includePath, + allowedRoots: params.allowedRoots, + expectedAbsolutePath: params.expectedTargetPath, + }); + params.assertConfigPathForWrite(); + await assertRootConfigStillMatchesSnapshot(params.rootSnapshot); + const currentRaw = await readRootBoundFileRawIfExists(targetAtCommit); + const currentHash = hashConfigIncludeRaw(currentRaw); + if (currentHash !== hashConfigIncludeRaw(params.expectedRaw)) { + throw new ConfigMutationConflictError("included config changed while preparing write", { + currentHash, + }); + } + params.assertConfigPathForWrite(); + const content = formatJsonFileValue(params.value); + await targetAtCommit.root.write(targetAtCommit.relativePath, content, { + mkdir: true, + mode: 0o600, + overwrite: true, + }); + try { + params.assertConfigPathForWrite(); + } catch (error) { + await rollbackJsonFileWriteIfUnchanged({ + target: targetAtCommit, + previousRaw: currentRaw, + committedHash: hashConfigIncludeRaw(content), + }); + throw error; + } } async function tryWriteSingleTopLevelIncludeMutation(params: { @@ -336,8 +593,97 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { } const nextConfigRecord = nextConfig as Record; + const writeEnv = params.io?.env ?? process.env; + const allowedRoots: readonly string[] = []; + const expectedIncludeTarget = params.writeOptions?.includeFileTargetsForWrite?.[includePath]; + if (!expectedIncludeTarget) { + throw new ConfigMutationConflictError("included config target changed since last load", { + currentHash: null, + }); + } + const assertConfigPathForWrite = params.writeOptions?.assertConfigPathForWrite; + if (!assertConfigPathForWrite) { + return null; + } + assertConfigPathForWrite(); + const configRoot = await fs.realpath(path.dirname(params.snapshot.path)); + if (!isPathInside(configRoot, expectedIncludeTarget)) { + throw new Error( + `Config mutation cannot update external $include target ${includePath}; edit the included file directly or move it under the config directory.`, + ); + } + const includeTarget = await resolveExpectedRootBoundIncludeFile({ + configPath: params.snapshot.path, + includePath, + allowedRoots, + expectedAbsolutePath: expectedIncludeTarget, + }); + const previousIncludeRaw = await readRootBoundFileRawIfExists(includeTarget); + const previousIncludeHash = hashConfigIncludeRaw(previousIncludeRaw); + const expectedIncludeHash = params.writeOptions?.includeFileHashesForWrite?.[includePath]; + if (expectedIncludeHash !== undefined && expectedIncludeHash !== previousIncludeHash) { + throw new ConfigMutationConflictError("included config changed since last load", { + currentHash: previousIncludeHash, + }); + } + const envForRestore = + resolveWriteEnvSnapshotForPath({ + actualConfigPath: params.snapshot.path, + expectedConfigPath: params.writeOptions?.expectedConfigPath, + envSnapshotForRestore: params.writeOptions?.envSnapshotForRestore, + }) ?? + params.io?.env ?? + process.env; + const snapshotHasBrokenInclude = snapshotProvesBrokenInclude(params.snapshot, includePath); + if ( + previousIncludeRaw === null && + (!snapshotHasBrokenInclude || expectedIncludeHash === undefined) + ) { + throw new ConfigMutationConflictError("included config changed since last load", { + currentHash: previousIncludeHash, + }); + } + let includedValueToWrite = nextConfigRecord[key]; + if (previousIncludeRaw !== null) { + let authoredIncludeValue: unknown; + let parsedInclude = false; + try { + authoredIncludeValue = parseJsonWithJson5Fallback(previousIncludeRaw); + parsedInclude = true; + } catch { + // A validated replacement is the repair path for a malformed include. + if (!snapshotHasBrokenInclude || expectedIncludeHash === undefined) { + throw new ConfigMutationConflictError("included config changed since last load", { + currentHash: previousIncludeHash, + }); + } + } + if (parsedInclude) { + if (containsConfigIncludeDirective(authoredIncludeValue)) { + return null; + } + const currentIncludedValue = resolveConfigEnvVars(authoredIncludeValue, envForRestore, { + onMissing: () => {}, + }); + const snapshotIncludedValue = (params.snapshot.sourceConfig as Record)[key]; + if (!isDeepStrictEqual(currentIncludedValue, snapshotIncludedValue)) { + throw new ConfigMutationConflictError("included config changed since last load", { + currentHash: previousIncludeHash, + }); + } + includedValueToWrite = restoreEnvVarRefs( + includedValueToWrite, + authoredIncludeValue, + envForRestore, + ); + } + } + const runtimeConfigToWrite = { + ...nextConfig, + [key]: resolveConfigEnvVars(includedValueToWrite, writeEnv, { onMissing: () => {} }), + } as OpenClawConfig; const validated = validateConfigObjectWithPlugins( - nextConfig, + runtimeConfigToWrite, params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" } : undefined, ); if (!validated.ok) { @@ -352,7 +698,7 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot); const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot); const runtimePreflightResult = await preflightRuntimeSnapshotWrite({ - nextSourceConfig: nextConfig, + nextSourceConfig: runtimeConfigToWrite, refreshOptions: params.writeOptions?.runtimeRefresh, formatRefreshError: (error) => formatErrorMessage(error), createRefreshError: (detail, cause) => @@ -361,11 +707,26 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { { cause }, ), }); - const previousIncludeRaw = await readFileRawIfExists(includePath); - const committedIncludeRaw = formatJsonFileValue(nextConfigRecord[key]); - const committedIncludeHash = hashFileRaw(committedIncludeRaw); - await writeJsonFileAtomic(includePath, nextConfigRecord[key]); - const writeEnv = params.io?.env ?? process.env; + const committedIncludeRaw = formatJsonFileValue(includedValueToWrite); + const committedIncludeHash = hashConfigIncludeRaw(committedIncludeRaw); + assertConfigPathForWrite(); + await assertRootConfigStillMatchesSnapshot(params.snapshot); + const includeRawAtCommit = await readRootBoundFileRawIfExists(includeTarget); + if (hashConfigIncludeRaw(includeRawAtCommit) !== hashConfigIncludeRaw(previousIncludeRaw)) { + throw new ConfigMutationConflictError("included config changed while preparing write", { + currentHash: hashConfigIncludeRaw(includeRawAtCommit), + }); + } + await writeRootBoundJsonFile({ + configPath: params.snapshot.path, + includePath, + allowedRoots, + expectedTargetPath: expectedIncludeTarget, + value: includedValueToWrite, + expectedRaw: includeRawAtCommit, + rootSnapshot: params.snapshot, + assertConfigPathForWrite, + }); const envBeforePostWriteRead = { ...writeEnv }; let envAfterPostWriteRead = envBeforePostWriteRead; try { @@ -374,18 +735,22 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { !hadRuntimeSnapshot && !getRuntimeConfigSnapshotRefreshHandler() ) { - return { persistedHash: null, persistedConfig: nextConfig }; + return { persistedHash: null, persistedConfig: runtimeConfigToWrite }; } let refreshed: Awaited>; try { - refreshed = await ( - params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite - )(params.writeOptions?.skipPluginValidation ? { skipPluginValidation: true } : undefined); + refreshed = await readConfigSnapshotForMutation({ + ownedConfigPathForWrite: params.snapshot.path, + io: params.io, + writeOptions: params.writeOptions, + }); } finally { envAfterPostWriteRead = { ...writeEnv }; } const refreshedSnapshot = refreshed.snapshot; + assertConfigPathForWrite(); + assertExpectedConfigPathMatches(refreshedSnapshot, params.snapshot.path); const persistedHash = resolveConfigSnapshotHash(refreshedSnapshot); if (!refreshedSnapshot.valid) { throw createInvalidConfigError( @@ -433,8 +798,8 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { } catch (error) { try { const rolledBack = await rollbackJsonFileWriteIfUnchanged({ - filePath: includePath, - previousRaw: previousIncludeRaw, + target: includeTarget, + previousRaw: includeRawAtCommit, committedHash: committedIncludeHash, }); if (rolledBack) { @@ -475,6 +840,20 @@ export async function replaceConfigFile(params: { writeOptions?: ConfigWriteOptions; io?: ConfigMutationIO; }): Promise { + if (!params.snapshot && !params.io) { + return await withConfigMutationSnapshotLock( + { writeOptions: params.writeOptions }, + async (prepared) => + await replaceConfigFileUnlocked({ + ...params, + snapshot: prepared.snapshot, + writeOptions: { + ...prepared.writeOptions, + ...params.writeOptions, + }, + }), + ); + } return await withConfigMutationLock( { io: params.io, lockPath: params.snapshot?.path }, async () => await replaceConfigFileUnlocked(params), @@ -489,14 +868,19 @@ async function replaceConfigFileUnlocked(params: { writeOptions?: ConfigWriteOptions; io?: ConfigMutationIO; }): Promise { - const prepared = - params.snapshot && params.writeOptions - ? { snapshot: params.snapshot, writeOptions: params.writeOptions } - : await readConfigSnapshotForMutation({ - io: params.io, - writeOptions: params.writeOptions, - }); + const prepared = params.snapshot + ? { snapshot: params.snapshot, writeOptions: params.writeOptions ?? {} } + : await readConfigSnapshotForMutation({ + io: params.io, + writeOptions: params.writeOptions, + }); const { snapshot, writeOptions } = prepared; + const mergedWriteOptions = { + ...writeOptions, + ...params.writeOptions, + }; + mergedWriteOptions.assertConfigPathForWrite?.(); + assertExpectedConfigPathMatches(snapshot, mergedWriteOptions.expectedConfigPath); assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); markActiveConfigMutationPath(snapshot.path); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); @@ -507,14 +891,13 @@ async function replaceConfigFileUnlocked(params: { snapshot, nextConfig: params.nextConfig, afterWrite, - writeOptions: params.writeOptions ?? writeOptions, + writeOptions: mergedWriteOptions, io: params.io, }); if (!writeResult) { const fallbackWriteOptions: ConfigWriteOptions = { baseSnapshot: snapshot, - ...writeOptions, - ...params.writeOptions, + ...mergedWriteOptions, afterWrite, }; const ioPreCommitRuntimePreflight = params.io @@ -577,11 +960,43 @@ async function commitPreparedConfigMutation( async function transformConfigFileAttempt( params: TransformConfigFileParams, attempt: number, + ownership?: ConfigMutationOwnership, + prepared?: Awaited>, ): Promise> { - const { snapshot, writeOptions } = await readConfigSnapshotForMutation({ - io: params.io, - writeOptions: params.writeOptions, - }); + ownership?.assertConfigPathForWrite?.(); + const { snapshot, writeOptions } = + prepared ?? + (await readConfigSnapshotForMutation({ + ...(ownership?.ownedConfigPathForWrite + ? { ownedConfigPathForWrite: ownership.ownedConfigPathForWrite } + : {}), + io: params.io, + writeOptions: params.writeOptions, + })); + let mergedWriteOptions: ConfigWriteOptions = { + ...writeOptions, + ...params.writeOptions, + }; + if (ownership) { + if (!ownership.initialized) { + ownership.initialized = true; + ownership.expectedConfigPath = mergedWriteOptions.expectedConfigPath ?? snapshot.path; + ownership.ownedConfigPathForWrite = mergedWriteOptions.ownedConfigPathForWrite; + ownership.assertConfigPathForWrite = mergedWriteOptions.assertConfigPathForWrite; + } + mergedWriteOptions = { + ...mergedWriteOptions, + expectedConfigPath: ownership.expectedConfigPath, + ...(ownership.ownedConfigPathForWrite + ? { ownedConfigPathForWrite: ownership.ownedConfigPathForWrite } + : {}), + ...(ownership.assertConfigPathForWrite + ? { assertConfigPathForWrite: ownership.assertConfigPathForWrite } + : {}), + }; + } + mergedWriteOptions.assertConfigPathForWrite?.(); + assertExpectedConfigPathMatches(snapshot, mergedWriteOptions.expectedConfigPath); assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); markActiveConfigMutationPath(snapshot.path); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); @@ -589,10 +1004,6 @@ async function transformConfigFileAttempt( const afterWrite = resolveConfigWriteAfterWrite( params.afterWrite ?? params.writeOptions?.afterWrite, ); - const mergedWriteOptions = { - ...writeOptions, - ...params.writeOptions, - }; const transformed = await params.transform(baseConfig, { snapshot, previousHash, attempt }); const committed = await (params.commit ?? commitPreparedConfigMutation)({ nextConfig: transformed.nextConfig, @@ -619,6 +1030,18 @@ async function transformConfigFileAttempt( export async function transformConfigFile( params: TransformConfigFileParams, ): Promise> { + if (!params.io) { + return await withConfigMutationSnapshotLock( + { writeOptions: params.writeOptions }, + async (prepared) => + await transformConfigFileAttempt( + params, + 0, + createConfigMutationOwnership(prepared, params.writeOptions), + prepared, + ), + ); + } return await withConfigMutationLock( { io: params.io }, async () => await transformConfigFileAttempt(params, 0), @@ -632,19 +1055,43 @@ export async function transformConfigFileWithRetry( if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { throw new Error("Config mutation maxAttempts must be a positive integer."); } - return await withConfigMutationLock({ io: params.io }, async () => { + const runWithPrepared = async ( + prepared?: Awaited>, + ) => { + const ownership = prepared + ? createConfigMutationOwnership(prepared, params.writeOptions) + : { + initialized: false, + expectedConfigPath: "", + }; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { - return await transformConfigFileAttempt(params, attempt); + return await transformConfigFileAttempt( + params, + attempt, + ownership, + attempt === 0 ? prepared : undefined, + ); } catch (err) { - if (err instanceof ConfigMutationConflictError && attempt < maxAttempts - 1) { + if ( + err instanceof ConfigMutationConflictError && + err.retryable && + attempt < maxAttempts - 1 + ) { continue; } throw err; } } throw new Error("Config mutation retry loop exhausted unexpectedly."); - }); + }; + if (!params.io) { + return await withConfigMutationSnapshotLock( + { writeOptions: params.writeOptions }, + runWithPrepared, + ); + } + return await withConfigMutationLock({ io: params.io }, async () => await runWithPrepared()); } export async function mutateConfigFile(params: { diff --git a/src/config/mutation-conflict.ts b/src/config/mutation-conflict.ts new file mode 100644 index 00000000000..3390c2810fb --- /dev/null +++ b/src/config/mutation-conflict.ts @@ -0,0 +1,12 @@ +/** Raised when a config write loses an optimistic snapshot race. */ +export class ConfigMutationConflictError extends Error { + readonly currentHash: string | null; + readonly retryable: boolean; + + constructor(message: string, params: { currentHash: string | null; retryable?: boolean }) { + super(message); + this.name = "ConfigMutationConflictError"; + this.currentHash = params.currentHash; + this.retryable = params.retryable ?? true; + } +} diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index bb4d1b6e8fa..2e93612be4e 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -18,11 +18,19 @@ type InstallHooksFromArchive = typeof import("./install.js").installHooksFromArc type InstallHooksFromPath = typeof import("./install.js").installHooksFromPath; const runCommandWithTimeoutMock = vi.fn(); +const scanPackageInstallSourceMock = vi.fn(); +const scanInstalledPackageDependencyTreeMock = vi.fn(); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); +vi.mock("../plugins/install-security-scan.js", () => ({ + scanPackageInstallSource: (...args: unknown[]) => scanPackageInstallSourceMock(...args), + scanInstalledPackageDependencyTree: (...args: unknown[]) => + scanInstalledPackageDependencyTreeMock(...args), +})); + vi.resetModules(); const { installHooksFromArchive, installHooksFromNpmSpec, installHooksFromPath } = @@ -69,6 +77,10 @@ afterAll(() => { beforeEach(() => { runCommandWithTimeoutMock.mockReset(); + scanPackageInstallSourceMock.mockReset(); + scanPackageInstallSourceMock.mockResolvedValue(undefined); + scanInstalledPackageDependencyTreeMock.mockReset(); + scanInstalledPackageDependencyTreeMock.mockResolvedValue(undefined); }); beforeAll(() => { @@ -110,13 +122,17 @@ function writeHookPackManifest(params: { pkgDir: string; hooks: string[]; dependencies?: Record; + extensions?: string[]; }) { fs.writeFileSync( path.join(params.pkgDir, "package.json"), JSON.stringify({ name: "@openclaw/test-hooks", version: "0.0.1", - openclaw: { hooks: params.hooks }, + openclaw: { + hooks: params.hooks, + ...(params.extensions ? { extensions: params.extensions } : {}), + }, ...(params.dependencies ? { dependencies: params.dependencies } : {}), }), "utf-8", @@ -375,8 +391,386 @@ describe("installHooksFromPath", () => { } expect(result.hookPackId).toBe("my-hook"); expect(result.hooks).toEqual(["my-hook"]); + expect(result.packageKind).toBe("hook-only"); expect(result.targetDir).toBe(path.join(stateDir, "hooks", "my-hook")); expect(fs.existsSync(path.join(result.targetDir, "HOOK.md"))).toBe(true); + expect(scanPackageInstallSourceMock).toHaveBeenCalledWith( + expect.objectContaining({ + packageDir: hookDir, + pluginId: "my-hook", + extensions: ["handler.ts"], + }), + ); + }); + + it("blocks a staged single hook before publishing the target", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const hookDir = path.join(workDir, "my-hook"); + fs.mkdirSync(hookDir, { recursive: true }); + fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: my-hook\n---\n", "utf8"); + fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n"); + scanInstalledPackageDependencyTreeMock.mockResolvedValue({ + blocked: { + code: "security_scan_blocked", + reason: "blocked staged hook", + }, + }); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromPath({ path: hookDir, hooksDir }); + + expect(result).toEqual({ + ok: false, + code: "security_scan_blocked", + error: "blocked staged hook", + }); + expect(scanInstalledPackageDependencyTreeMock).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "install", + pluginId: "my-hook", + requestKind: "plugin-dir", + }), + ); + const scanCall = scanInstalledPackageDependencyTreeMock.mock.calls[0]?.[0] as { + packageDir?: string; + }; + expect(scanCall.packageDir).toContain(".openclaw-install-stage-"); + expect(fs.existsSync(path.join(hooksDir, "my-hook"))).toBe(false); + }); + + it("classifies hook packages that also declare plugin extensions", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + const hookDir = path.join(pkgDir, "hooks", "one-hook"); + fs.mkdirSync(hookDir, { recursive: true }); + fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: one-hook\n---\n", "utf8"); + fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n"); + writeHookPackManifest({ + pkgDir, + hooks: ["./hooks/one-hook"], + extensions: ["./dist/index.js"], + }); + + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir: path.join(stateDir, "hooks"), + dryRun: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.packageKind).toBe("plugin-capable"); + }); + + it.each([".codex-plugin/plugin.json", "hooks/hooks.json", "openclaw.plugin.json"])( + "classifies hook packages with bundle marker %s as plugin-capable", + async (bundleMarker) => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + const hookDir = path.join(pkgDir, "hooks", "one-hook"); + fs.mkdirSync(hookDir, { recursive: true }); + fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: one-hook\n---\n", "utf8"); + fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n"); + writeHookPackManifest({ + pkgDir, + hooks: ["./hooks/one-hook"], + }); + const markerPath = path.join(pkgDir, bundleMarker); + fs.mkdirSync(path.dirname(markerPath), { recursive: true }); + fs.writeFileSync(markerPath, "{}\n", "utf8"); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir, + dryRun: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.packageKind).toBe("plugin-capable"); + const rejected = await installHooksFromPath({ + path: pkgDir, + hooksDir, + expectedPackageKind: "hook-only", + }); + expect(rejected.ok).toBe(false); + if (rejected.ok) { + return; + } + expect(rejected.error).toContain("hook package kind mismatch"); + expect(fs.existsSync(path.join(hooksDir, "test-hooks"))).toBe(false); + }, + ); + + it("enforces install policy with the validated hook identity before local install side effects", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + writeHookPackFiles({ + pkgDir, + packageName: "@acme/canonical-hooks", + hookName: "one-hook", + hookDescription: "One hook", + heading: "One Hook", + }); + scanPackageInstallSourceMock.mockResolvedValue({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator install policy", + }, + }); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir, + config: { security: { installPolicy: { enabled: true } } }, + mode: "update", + }); + + expect(result).toEqual({ + ok: false, + code: "security_scan_blocked", + error: "blocked by operator install policy", + }); + expect(scanPackageInstallSourceMock).toHaveBeenCalledWith( + expect.objectContaining({ + packageDir: pkgDir, + pluginId: "canonical-hooks", + packageName: "@acme/canonical-hooks", + version: "0.0.1", + extensions: ["./hooks/one-hook"], + mode: "install", + requestKind: "plugin-dir", + requestedSpecifier: pkgDir, + source: { + kind: "local-path", + authority: "user", + mutable: true, + network: false, + }, + }), + ); + expect(fs.existsSync(path.join(hooksDir, "canonical-hooks"))).toBe(false); + }); + + it("reports update policy mode only when the hook target already exists", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + writeHookPackFiles({ + pkgDir, + packageName: "@acme/canonical-hooks", + hookName: "one-hook", + hookDescription: "One hook", + heading: "One Hook", + }); + const hooksDir = path.join(stateDir, "hooks"); + fs.mkdirSync(path.join(hooksDir, "canonical-hooks"), { recursive: true }); + + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir, + mode: "update", + dryRun: true, + }); + + expect(result.ok).toBe(true); + expect(scanPackageInstallSourceMock).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "update", + pluginId: "canonical-hooks", + }), + ); + }); + + it("inspects hook package kind without running install policy or target availability checks", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + writeHookPackFiles({ + pkgDir, + packageName: "@acme/canonical-hooks", + hookName: "one-hook", + hookDescription: "One hook", + heading: "One Hook", + }); + const hooksDir = path.join(stateDir, "hooks"); + const ensureInstallTargetAvailableSpy = vi.spyOn( + hookInstallRuntime, + "ensureInstallTargetAvailable", + ); + + try { + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir, + inspection: "package-kind", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.packageKind).toBe("hook-only"); + expect(scanPackageInstallSourceMock).not.toHaveBeenCalled(); + expect(ensureInstallTargetAvailableSpy).not.toHaveBeenCalled(); + expect(fs.existsSync(hooksDir)).toBe(false); + } finally { + ensureInstallTargetAvailableSpy.mockRestore(); + } + }); + + it("inspects a bare hook package kind without creating the hooks directory", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const hookDir = path.join(workDir, "my-hook"); + fs.mkdirSync(hookDir, { recursive: true }); + fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: my-hook\n---\n", "utf8"); + fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n"); + const hooksDir = path.join(stateDir, "hooks"); + + const result = await installHooksFromPath({ + path: hookDir, + hooksDir, + inspection: "package-kind", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.packageKind).toBe("hook-only"); + expect(result.targetDir).toBe(path.join(hooksDir, "my-hook")); + expect(fs.existsSync(hooksDir)).toBe(false); + }); + + it("enforces archive install policy against the validated extracted hook package", async () => { + const { stateDir, archivePath } = writeArchiveFixture({ + fileName: "policy-hooks.zip", + contents: zipHooksBuffer, + }); + let scannedExtractedPackage = false; + scanPackageInstallSourceMock.mockImplementation(async (params: { packageDir: string }) => { + scannedExtractedPackage = + params.packageDir !== archivePath && + fs.existsSync(path.join(params.packageDir, "package.json")); + return { + blocked: { + code: "security_scan_blocked", + reason: "blocked extracted hook package", + }, + }; + }); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromArchive({ + archivePath, + hooksDir, + }); + + expect(result).toEqual({ + ok: false, + code: "security_scan_blocked", + error: "blocked extracted hook package", + }); + expect(scannedExtractedPackage).toBe(true); + expect(scanPackageInstallSourceMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "zip-hooks", + requestKind: "plugin-archive", + requestedSpecifier: archivePath, + source: { + kind: "archive", + authority: "user", + mutable: true, + network: false, + }, + }), + ); + expect(fs.existsSync(path.join(hooksDir, "zip-hooks"))).toBe(false); + }); + + it("fails closed when hook install policy evaluation throws", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + writeHookPackFiles({ + pkgDir, + packageName: "@acme/canonical-hooks", + hookName: "one-hook", + hookDescription: "One hook", + heading: "One Hook", + }); + scanPackageInstallSourceMock.mockRejectedValue(new Error("policy runner unavailable")); + + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir: path.join(stateDir, "hooks"), + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe("security_scan_failed"); + expect(result.error).toContain("policy runner unavailable"); + }); + + it("blocks materialized hook dependencies before publishing the target", async () => { + const stateDir = makeTempDir(); + const pkgDir = makeTempDir(); + writeHookPackFiles({ + pkgDir, + packageName: "@acme/canonical-hooks", + hookName: "one-hook", + hookDescription: "One hook", + heading: "One Hook", + }); + const manifestPath = path.join(pkgDir, "package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + manifest.dependencies = { "blocked-transitive": "1.0.0" }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest), "utf8"); + runCommandWithTimeoutMock.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit", + }); + scanInstalledPackageDependencyTreeMock.mockResolvedValue({ + blocked: { + code: "security_scan_blocked", + reason: "blocked materialized dependency tree", + }, + }); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromPath({ + path: pkgDir, + hooksDir, + }); + + expect(result).toEqual({ + ok: false, + code: "security_scan_blocked", + error: "blocked materialized dependency tree", + }); + expect(scanInstalledPackageDependencyTreeMock).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "install", + pluginId: "canonical-hooks", + requestKind: "plugin-dir", + }), + ); + const scanCall = scanInstalledPackageDependencyTreeMock.mock.calls[0]?.[0] as { + packageDir?: string; + }; + expect(scanCall.packageDir).toContain(".openclaw-install-stage-"); + expect(fs.existsSync(path.join(hooksDir, "canonical-hooks"))).toBe(false); }); it("rejects out-of-package hook entries", async () => { @@ -434,7 +828,7 @@ describe("installHooksFromPath", () => { }); describe("installHooksFromNpmSpec", () => { - it("does not expose dangerous force unsafe install through npm-spec archive params", async () => { + it("forwards npm install policy metadata through extracted archive validation", async () => { const installFromValidatedNpmSpecArchiveSpy = vi .spyOn(hookInstallRuntime, "installFromValidatedNpmSpecArchive") .mockImplementation( @@ -444,6 +838,20 @@ describe("installHooksFromNpmSpec", () => { expect( (params.archiveInstallParams as Record).dangerouslyForceUnsafeInstall, ).toBeUndefined(); + expect(params.archiveInstallParams).toEqual( + expect.objectContaining({ + installPolicyRequest: { + kind: "plugin-npm", + requestedSpecifier: "@openclaw/test-hooks@0.0.1", + source: { + kind: "npm", + authority: "third-party", + mutable: false, + network: true, + }, + }, + }), + ); return { ok: true, hookPackId: "test-hooks", @@ -511,6 +919,7 @@ describe("installHooksFromNpmSpec", () => { return; } expect(result.hookPackId).toBe("test-hooks"); + expect(result.packageKind).toBe("hook-only"); expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/test-hooks@0.0.1"); expect(result.npmResolution?.integrity).toBe("sha512-hook-test"); expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true); diff --git a/src/hooks/install.ts b/src/hooks/install.ts index f824f6f44b9..104c3100a53 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -5,7 +5,14 @@ import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string- import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-source-utils.js"; -import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; +import { detectBundleManifestFormat } from "../plugins/bundle-manifest.js"; +import { + scanPackageInstallSource, + scanInstalledPackageDependencyTree, + type InstallSafetyOverrides, +} from "../plugins/install-security-scan.js"; +import { PLUGIN_MANIFEST_FILENAME } from "../plugins/manifest.js"; +import type { InstallPolicySource } from "../security/install-policy.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { parseFrontmatter } from "./frontmatter.js"; @@ -26,19 +33,24 @@ type HookPackageManifest = { name?: string; version?: string; dependencies?: Record; -} & Partial>; +} & Partial>; export type InstallHooksResult = | { ok: true; hookPackId: string; hooks: string[]; + packageKind?: "hook-only" | "plugin-capable"; targetDir: string; version?: string; npmResolution?: NpmSpecResolution; integrityDrift?: NpmIntegrityDrift; } - | { ok: false; error: string }; + | { + ok: false; + error: string; + code?: string; + }; /** Integrity drift payload surfaced when npm metadata no longer matches an install record. */ export type HookNpmIntegrityDriftParams = { @@ -57,6 +69,13 @@ type HookInstallForwardParams = InstallSafetyOverrides & { mode?: "install" | "update"; dryRun?: boolean; expectedHookPackId?: string; + expectedPackageKind?: "hook-only"; + inspection?: "package-kind"; + installPolicyRequest?: { + kind: "plugin-archive" | "plugin-dir" | "plugin-npm"; + requestedSpecifier: string; + source: InstallPolicySource; + }; }; type HookPackageInstallParams = { packageDir: string } & HookInstallForwardParams; @@ -65,16 +84,114 @@ type HookPathInstallParams = { path: string } & HookInstallForwardParams; function buildHookInstallForwardParams(params: HookInstallForwardParams): HookInstallForwardParams { return { + config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, hooksDir: params.hooksDir, timeoutMs: params.timeoutMs, logger: params.logger, mode: params.mode, dryRun: params.dryRun, expectedHookPackId: params.expectedHookPackId, + expectedPackageKind: params.expectedPackageKind, + inspection: params.inspection, + installPolicyRequest: params.installPolicyRequest, }; } +function localHookInstallPolicySource(kind: "plugin-archive" | "plugin-dir"): InstallPolicySource { + return kind === "plugin-archive" + ? { kind: "archive", authority: "user", mutable: true, network: false } + : { kind: "local-path", authority: "user", mutable: true, network: false }; +} + +async function runHookInstallScan(params: { + hookPackId: string; + scan: () => ReturnType; +}): Promise | null> { + try { + const result = await params.scan(); + if (!result?.blocked) { + return null; + } + return { + ok: false, + error: result.blocked.reason, + ...(result.blocked.code ? { code: result.blocked.code } : {}), + }; + } catch (error) { + return { + ok: false, + error: `Hook pack "${params.hookPackId}" installation blocked: install policy failed (${String(error)})`, + code: "security_scan_failed", + }; + } +} + +async function runHookInstallPolicy(params: { + hookPackId: string; + hookEntries: string[]; + packageName?: string; + version?: string; + packageDir: string; + forward: HookInstallForwardParams; + logger: HookInstallLogger; + mode: "install" | "update"; +}): Promise | null> { + const request = params.forward.installPolicyRequest; + if (!request) { + return null; + } + return await runHookInstallScan({ + hookPackId: params.hookPackId, + scan: async () => + await scanPackageInstallSource({ + config: params.forward.config, + dangerouslyForceUnsafeInstall: params.forward.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.forward.trustedSourceLinkedOfficialInstall, + packageDir: params.packageDir, + pluginId: params.hookPackId, + extensions: params.hookEntries, + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.version ? { version: params.version } : {}), + logger: params.logger, + requestKind: request.kind, + requestedSpecifier: request.requestedSpecifier, + source: request.source, + mode: params.mode, + }), + }); +} + +async function runHookInstalledDependencyPolicy(params: { + hookPackId: string; + installedDir: string; + forward: HookInstallForwardParams; + logger: HookInstallLogger; + mode: "install" | "update"; +}): Promise | null> { + const request = params.forward.installPolicyRequest; + if (!request) { + return null; + } + return await runHookInstallScan({ + hookPackId: params.hookPackId, + scan: async () => + await scanInstalledPackageDependencyTree({ + config: params.forward.config, + dangerouslyForceUnsafeInstall: params.forward.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.forward.trustedSourceLinkedOfficialInstall, + packageDir: params.installedDir, + pluginId: params.hookPackId, + logger: params.logger, + requestKind: request.kind, + requestedSpecifier: request.requestedSpecifier, + source: request.source, + mode: params.mode, + }), + }); +} + function validateHookId(hookId: string): string | null { if (!hookId) { return "invalid hook name: missing"; @@ -118,6 +235,35 @@ async function ensureOpenClawHooks(manifest: HookPackageManifest) { return list; } +function resolveHookPackageKind( + manifest: HookPackageManifest, + packageKind: "plugin-capable" | undefined, +): "hook-only" | "plugin-capable" { + if (packageKind) { + return packageKind; + } + const extensions = manifest[MANIFEST_KEY]?.extensions; + if (extensions === undefined) { + return "hook-only"; + } + return Array.isArray(extensions) && normalizeTrimmedStringList(extensions).length === 0 + ? "hook-only" + : "plugin-capable"; +} + +function resolveHookInstallTargetPath( + id: string, + hooksDir?: string, +): { ok: true; targetDir: string } | { ok: false; error: string } { + const baseHooksDir = hooksDir ? resolveUserPath(hooksDir) : path.join(CONFIG_DIR, "hooks"); + const result = resolveSafeInstallDir({ + baseDir: baseHooksDir, + id, + invalidNameMessage: "invalid hook name: path traversal detected", + }); + return result.ok ? { ok: true, targetDir: result.path } : result; +} + async function resolveInstallTargetDir( id: string, hooksDir?: string, @@ -132,27 +278,36 @@ async function resolveInstallTargetDir( }); } -async function resolveAvailableHookInstallTarget(params: { +type PreparedHookInstallTarget = { + targetDir: string; + effectiveMode: "install" | "update"; +}; + +async function resolvePreparedHookInstallTarget(params: { id: string; hooksDir?: string; - mode: "install" | "update"; + requestedMode: "install" | "update"; alreadyExistsError: (targetDir: string) => string; -}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> { +}): Promise<{ ok: true; target: PreparedHookInstallTarget } | { ok: false; error: string }> { const runtime = await loadHookInstallRuntime(); const targetDirResult = await resolveInstallTargetDir(params.id, params.hooksDir); if (!targetDirResult.ok) { return targetDirResult; } const targetDir = targetDirResult.targetDir; + const effectiveMode = + params.requestedMode === "update" && (await runtime.fileExists(targetDir)) + ? "update" + : "install"; const availability = await runtime.ensureInstallTargetAvailable({ - mode: params.mode, + mode: effectiveMode, targetDir, alreadyExistsError: params.alreadyExistsError(targetDir), }); if (!availability.ok) { return availability; } - return { ok: true, targetDir }; + return { ok: true, target: { targetDir, effectiveMode } }; } async function installFromResolvedHookDir( @@ -161,26 +316,26 @@ async function installFromResolvedHookDir( ): Promise { const runtime = await loadHookInstallRuntime(); const manifestPath = path.join(resolvedDir, "package.json"); + const hasPluginManifest = await runtime.fileExists( + path.join(resolvedDir, PLUGIN_MANIFEST_FILENAME), + ); + const packageKind = + hasPluginManifest || detectBundleManifestFormat(resolvedDir) !== null + ? "plugin-capable" + : undefined; // A directory with package.json is a hook pack. A bare hook directory must // contain HOOK.md plus a handler file and installs as a single hook. if (await runtime.fileExists(manifestPath)) { return await installHookPackageFromDir({ packageDir: resolvedDir, - hooksDir: params.hooksDir, - timeoutMs: params.timeoutMs, - logger: params.logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, + ...(packageKind ? { packageKind } : {}), + ...buildHookInstallForwardParams(params), }); } return await installHookFromDir({ hookDir: resolvedDir, - hooksDir: params.hooksDir, - logger: params.logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, + ...(packageKind ? { packageKind } : {}), + ...buildHookInstallForwardParams(params), }); } @@ -195,7 +350,7 @@ async function resolveHookNameFromDir(hookDir: string): Promise { return frontmatter.name || path.basename(hookDir); } -async function validateHookDir(hookDir: string): Promise { +async function validateHookDir(hookDir: string): Promise<{ handlerEntry: string }> { const runtime = await loadHookInstallRuntime(); const hookMdPath = path.join(hookDir, "HOOK.md"); if (!(await runtime.fileExists(hookMdPath))) { @@ -203,17 +358,19 @@ async function validateHookDir(hookDir: string): Promise { } const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"]; - const hasHandler = await Promise.all( + const handlerExists = await Promise.all( handlerCandidates.map(async (candidate) => runtime.fileExists(path.join(hookDir, candidate))), - ).then((results) => results.some(Boolean)); + ); + const handlerEntry = handlerCandidates[handlerExists.findIndex(Boolean)]; - if (!hasHandler) { + if (!handlerEntry) { throw new Error(`handler.ts/handler.js/index.ts/index.js missing in ${hookDir}`); } + return { handlerEntry }; } async function installHookPackageFromDir( - params: HookPackageInstallParams, + params: HookPackageInstallParams & { packageKind?: "plugin-capable" }, ): Promise { const runtime = await loadHookInstallRuntime(); const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( @@ -242,6 +399,13 @@ async function installHookPackageFromDir( const pkgName = typeof manifest.name === "string" ? manifest.name : ""; const hookPackId = pkgName ? unscopedPackageName(pkgName) : path.basename(params.packageDir); + const packageKind = resolveHookPackageKind(manifest, params.packageKind); + if (params.expectedPackageKind && packageKind !== params.expectedPackageKind) { + return { + ok: false, + error: `hook package kind mismatch: expected ${params.expectedPackageKind}, got ${packageKind}`, + }; + } const hookIdError = validateHookId(hookPackId); if (hookIdError) { return { ok: false, error: hookIdError }; @@ -253,17 +417,6 @@ async function installHookPackageFromDir( }; } - const target = await resolveAvailableHookInstallTarget({ - id: hookPackId, - hooksDir: params.hooksDir, - mode, - alreadyExistsError: (targetDir) => `hook pack already exists: ${targetDir} (delete it first)`, - }); - if (!target.ok) { - return target; - } - const targetDir = target.targetDir; - const resolvedHooks = [] as string[]; for (const entry of hookEntries) { const hookDir = path.resolve(params.packageDir, entry); @@ -290,11 +443,52 @@ async function installHookPackageFromDir( resolvedHooks.push(hookName); } + if (params.inspection === "package-kind") { + const targetDirResult = resolveHookInstallTargetPath(hookPackId, params.hooksDir); + if (!targetDirResult.ok) { + return targetDirResult; + } + return { + ok: true, + hookPackId, + hooks: resolvedHooks, + packageKind, + targetDir: targetDirResult.targetDir, + version: typeof manifest.version === "string" ? manifest.version : undefined, + }; + } + + const preparedTarget = await resolvePreparedHookInstallTarget({ + id: hookPackId, + hooksDir: params.hooksDir, + requestedMode: mode, + alreadyExistsError: (targetDir) => `hook pack already exists: ${targetDir} (delete it first)`, + }); + if (!preparedTarget.ok) { + return preparedTarget; + } + const { targetDir, effectiveMode } = preparedTarget.target; + + const policyFailure = await runHookInstallPolicy({ + hookPackId, + hookEntries, + ...(pkgName ? { packageName: pkgName } : {}), + ...(typeof manifest.version === "string" ? { version: manifest.version } : {}), + packageDir: params.packageDir, + forward: params, + logger, + mode: effectiveMode, + }); + if (policyFailure) { + return policyFailure; + } + if (dryRun) { return { ok: true, hookPackId, hooks: resolvedHooks, + packageKind, targetDir, version: typeof manifest.version === "string" ? manifest.version : undefined, }; @@ -303,12 +497,22 @@ async function installHookPackageFromDir( const installRes = await runtime.installPackageDirWithManifestDeps({ sourceDir: params.packageDir, targetDir, - mode, + mode: effectiveMode, timeoutMs, logger, copyErrorPrefix: "failed to copy hook pack", depsLogMessage: "Installing hook pack dependencies…", manifestDependencies: manifest.dependencies, + afterInstall: async (installedDir) => { + const dependencyPolicyFailure = await runHookInstalledDependencyPolicy({ + hookPackId, + installedDir, + forward: params, + logger, + mode: effectiveMode, + }); + return dependencyPolicyFailure ?? { ok: true }; + }, }); if (!installRes.ok) { return installRes; @@ -318,24 +522,30 @@ async function installHookPackageFromDir( ok: true, hookPackId, hooks: resolvedHooks, + packageKind, targetDir, version: typeof manifest.version === "string" ? manifest.version : undefined, }; } -async function installHookFromDir(params: { - hookDir: string; - hooksDir?: string; - logger?: HookInstallLogger; - mode?: "install" | "update"; - dryRun?: boolean; - expectedHookPackId?: string; -}): Promise { +async function installHookFromDir( + params: { + hookDir: string; + packageKind?: "plugin-capable"; + } & HookInstallForwardParams, +): Promise { const runtime = await loadHookInstallRuntime(); const { logger, mode, dryRun } = runtime.resolveInstallModeOptions(params, defaultLogger); - await validateHookDir(params.hookDir); + const { handlerEntry } = await validateHookDir(params.hookDir); const hookName = await resolveHookNameFromDir(params.hookDir); + const packageKind = params.packageKind ?? "hook-only"; + if (params.expectedPackageKind && packageKind !== params.expectedPackageKind) { + return { + ok: false, + error: `hook package kind mismatch: expected ${params.expectedPackageKind}, got ${packageKind}`, + }; + } const hookIdError = validateHookId(hookName); if (hookIdError) { return { ok: false, error: hookIdError }; @@ -348,36 +558,84 @@ async function installHookFromDir(params: { }; } - const target = await resolveAvailableHookInstallTarget({ + if (params.inspection === "package-kind") { + const targetDirResult = resolveHookInstallTargetPath(hookName, params.hooksDir); + if (!targetDirResult.ok) { + return targetDirResult; + } + return { + ok: true, + hookPackId: hookName, + hooks: [hookName], + packageKind, + targetDir: targetDirResult.targetDir, + }; + } + + const preparedTarget = await resolvePreparedHookInstallTarget({ id: hookName, hooksDir: params.hooksDir, - mode, + requestedMode: mode, alreadyExistsError: (targetDir) => `hook already exists: ${targetDir} (delete it first)`, }); - if (!target.ok) { - return target; + if (!preparedTarget.ok) { + return preparedTarget; + } + const { targetDir, effectiveMode } = preparedTarget.target; + + const policyFailure = await runHookInstallPolicy({ + hookPackId: hookName, + hookEntries: [handlerEntry], + packageDir: params.hookDir, + forward: params, + logger, + mode: effectiveMode, + }); + if (policyFailure) { + return policyFailure; } - const targetDir = target.targetDir; if (dryRun) { - return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir }; + return { + ok: true, + hookPackId: hookName, + hooks: [hookName], + packageKind, + targetDir, + }; } const installRes = await runtime.installPackageDir({ sourceDir: params.hookDir, targetDir, - mode, + mode: effectiveMode, timeoutMs: 120_000, logger, copyErrorPrefix: "failed to copy hook", hasDeps: false, depsLogMessage: "Installing hook dependencies…", + afterInstall: async (installedDir) => { + const stagedPolicyFailure = await runHookInstalledDependencyPolicy({ + hookPackId: hookName, + installedDir, + forward: params, + logger, + mode: effectiveMode, + }); + return stagedPolicyFailure ?? { ok: true }; + }, }); if (!installRes.ok) { return installRes; } - return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir }; + return { + ok: true, + hookPackId: hookName, + hooks: [hookName], + packageKind, + targetDir, + }; } /** Install hooks from an archive after extracting and validating the archive root. */ @@ -392,6 +650,11 @@ export async function installHooksFromArchive( return archivePathResult; } const archivePath = archivePathResult.path; + const installPolicyRequest = params.installPolicyRequest ?? { + kind: "plugin-archive", + requestedSpecifier: params.archivePath, + source: localHookInstallPolicySource("plugin-archive"), + }; return await runtime.withExtractedArchiveRoot({ archivePath, @@ -402,36 +665,36 @@ export async function installHooksFromArchive( await installFromResolvedHookDir( rootDir, buildHookInstallForwardParams({ - hooksDir: params.hooksDir, + ...params, timeoutMs, logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, + installPolicyRequest, }), ), }); } /** Download, verify, and install an npm hook pack tarball. */ -export async function installHooksFromNpmSpec(params: { - spec: string; - dangerouslyForceUnsafeInstall?: boolean; - hooksDir?: string; - timeoutMs?: number; - logger?: HookInstallLogger; - mode?: "install" | "update"; - dryRun?: boolean; - expectedHookPackId?: string; - expectedIntegrity?: string; - onIntegrityDrift?: (params: HookNpmIntegrityDriftParams) => boolean | Promise; -}): Promise { +export async function installHooksFromNpmSpec( + params: { + spec: string; + hooksDir?: string; + timeoutMs?: number; + logger?: HookInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedHookPackId?: string; + expectedPackageKind?: "hook-only"; + inspection?: "package-kind"; + expectedIntegrity?: string; + onIntegrityDrift?: (params: HookNpmIntegrityDriftParams) => boolean | Promise; + } & InstallSafetyOverrides, +): Promise { const runtime = await loadHookInstallRuntime(); const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( params, defaultLogger, ); - const expectedHookPackId = params.expectedHookPackId; const spec = params.spec; logger.info?.(`Downloading ${spec.trim()}…`); @@ -446,12 +709,16 @@ export async function installHooksFromNpmSpec(params: { }, installFromArchive: installHooksFromArchive, archiveInstallParams: buildHookInstallForwardParams({ - hooksDir: params.hooksDir, + ...params, timeoutMs, logger, mode, dryRun, - expectedHookPackId, + installPolicyRequest: { + kind: "plugin-npm", + requestedSpecifier: spec, + source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + }, }), }); } @@ -466,14 +733,14 @@ export async function installHooksFromPath( return pathResult; } const { resolvedPath: resolved, stat } = pathResult; + const installPolicyKind = stat.isDirectory() ? "plugin-dir" : "plugin-archive"; const forwardParams = buildHookInstallForwardParams({ - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - hooksDir: params.hooksDir, - timeoutMs: params.timeoutMs, - logger: params.logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, + ...params, + installPolicyRequest: { + kind: installPolicyKind, + requestedSpecifier: params.path, + source: localHookInstallPolicySource(installPolicyKind), + }, }); if (stat.isDirectory()) { diff --git a/src/hooks/update.test.ts b/src/hooks/update.test.ts index 0cf1c4c4af7..cffda9f31eb 100644 --- a/src/hooks/update.test.ts +++ b/src/hooks/update.test.ts @@ -114,14 +114,22 @@ describe("updateNpmInstalledHookPacks", () => { }, }); + const config = createHookInstallConfig({ + hookId: "demo-hooks", + spec: "@openclaw/demo-hooks", + }); const result = await updateNpmInstalledHookPacks({ - config: createHookInstallConfig({ - hookId: "demo-hooks", - spec: "@openclaw/demo-hooks", - }), + config, hookIds: ["demo-hooks"], }); + expect(installHooksFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + config, + expectedHookPackId: "demo-hooks", + mode: "update", + }), + ); expect(result.changed).toBe(true); expect(result.config.hooks?.internal?.installs?.["demo-hooks"]).toEqual({ source: "npm", diff --git a/src/hooks/update.ts b/src/hooks/update.ts index a225be2ed57..a83797b95cf 100644 --- a/src/hooks/update.ts +++ b/src/hooks/update.ts @@ -136,6 +136,7 @@ export async function updateNpmInstalledHookPacks(params: { } const currentVersion = await readInstalledPackageVersion(installPath); const result = await installHooksFromNpmSpec({ + config: params.config, spec: effectiveSpec, mode: "update", dryRun: params.dryRun, diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 7868dad5561..327e9745726 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -42,6 +42,7 @@ export { root, type OpenResult, type ReadResult, + type Root, } from "@openclaw/fs-safe/root"; export { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced"; export { diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 6f1853e8937..1521675a1ea 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -374,7 +374,10 @@ export async function installPackageDirWithManifestDeps(params: { depsLogMessage: string; manifestDependencies?: Record; afterCopy?: (installedDir: string) => void | Promise; -}): Promise<{ ok: true } | { ok: false; error: string }> { + afterInstall?: ( + installedDir: string, + ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; +}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0; return installPackageDir({ ...params, diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 633c88be533..ec7eb05f25e 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -674,6 +674,21 @@ describe("plugin index install records store", () => { }); }); + it("preserves an authored empty plugins section while stripping transient install records", () => { + expect( + withoutPluginInstallRecords( + { + plugins: { + installs: { + twitch: { source: "npm", spec: "twitch@1.0.0" }, + }, + }, + }, + { preserveEmptyPlugins: true }, + ), + ).toEqual({ plugins: {} }); + }); + it("returns empty records when the persisted plugin index is missing", async () => { const stateDir = makeStateDir(); diff --git a/src/plugins/installed-plugin-index-records.ts b/src/plugins/installed-plugin-index-records.ts index 1d8c5643098..1a8ef64fdda 100644 --- a/src/plugins/installed-plugin-index-records.ts +++ b/src/plugins/installed-plugin-index-records.ts @@ -87,12 +87,18 @@ export function withPluginInstallRecords( } /** Returns config with legacy plugin install records removed. */ -export function withoutPluginInstallRecords(config: OpenClawConfig): OpenClawConfig { +export function withoutPluginInstallRecords( + config: OpenClawConfig, + options: { preserveEmptyPlugins?: boolean } = {}, +): OpenClawConfig { if (!config.plugins?.installs) { return config; } const { installs: _installs, ...plugins } = config.plugins; if (Object.keys(plugins).length === 0) { + if (options.preserveEmptyPlugins) { + return { ...config, plugins: {} }; + } const { plugins: _plugins, ...rest } = config; return rest; } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1ccefd1b709..8ef5f4834be 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -3613,6 +3613,41 @@ describe("updateNpmInstalledPlugins", () => { expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined(); }); + it("keeps authored plugin config shape when only the install key migrates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "@openclaw/voice-call", + targetDir: "/tmp/openclaw-voice-call", + version: "0.0.2", + extensions: ["index.ts"], + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + }, + }, + }, + }, + pluginIds: ["voice-call"], + }); + + expect(result.config.plugins).toEqual({ + installs: { + "@openclaw/voice-call": expect.objectContaining({ + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/openclaw-voice-call", + }), + }, + }); + }); + it("migrates context engine slot when a plugin id changes during update", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index eb6cf69b4e8..9484950c4f6 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; +import { unscopedPackageName } from "../infra/install-safe-path.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; import { @@ -126,6 +127,43 @@ export type PluginChannelSyncResult = { summary: PluginChannelSyncSummary; }; +/** Return whether a tracked plugin install source can be updated in place. */ +export function isPluginInstallRecordUpdateSource( + record: PluginInstallRecord | undefined, +): boolean { + return ( + record?.source === "npm" || + record?.source === "marketplace" || + record?.source === "clawhub" || + record?.source === "git" + ); +} + +/** Return whether update identity compatibility can migrate an unscoped install key. */ +export function pluginInstallRecordMayMigrateConfigId(params: { + pluginId: string; + record: PluginInstallRecord | undefined; + specOverride?: string; +}): boolean { + if (!isPluginInstallRecordUpdateSource(params.record)) { + return false; + } + if (params.record?.source !== "npm") { + // Generic package/archive installers can resolve an unscoped tracked key + // to a scoped package id; the exact package identity is unavailable preflight. + return !params.pluginId.includes("/"); + } + const packageName = + resolveNpmSpecPackageName(params.specOverride ?? params.record.spec) ?? + params.record.resolvedName ?? + resolveNpmSpecPackageName(params.record.resolvedSpec); + return Boolean( + packageName && + packageName !== params.pluginId && + unscopedPackageName(packageName) === params.pluginId, + ); +} + function formatNpmInstallFailure(params: { pluginId: string; spec: string; @@ -961,7 +999,7 @@ function replacePluginIdInList( fromId: string, toId: string, ): string[] | undefined { - if (!entries || entries.length === 0 || fromId === toId) { + if (!entries || entries.length === 0 || fromId === toId || !entries.includes(fromId)) { return entries; } const next: string[] = []; @@ -975,27 +1013,33 @@ function replacePluginIdInList( } function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig { - if (fromId === toId) { + const plugins = cfg.plugins; + if (fromId === toId || !plugins) { return cfg; } - const installs = cfg.plugins?.installs; - const entries = cfg.plugins?.entries; - const slots = cfg.plugins?.slots; - const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId); - const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId); + let nextPlugins = plugins; + const ensureNextPlugins = () => { + if (nextPlugins === plugins) { + nextPlugins = { ...plugins }; + } + return nextPlugins; + }; - const nextInstalls = installs ? { ...installs } : undefined; - if (nextInstalls && fromId in nextInstalls) { + const installs = plugins.installs; + if (installs && Object.hasOwn(installs, fromId)) { + const nextInstalls = { ...installs }; const record = nextInstalls[fromId]; if (record && !(toId in nextInstalls)) { nextInstalls[toId] = record; } delete nextInstalls[fromId]; + ensureNextPlugins().installs = nextInstalls; } - const nextEntries = entries ? { ...entries } : undefined; - if (nextEntries && fromId in nextEntries) { + const entries = plugins.entries; + if (entries && Object.hasOwn(entries, fromId)) { + const nextEntries = { ...entries }; const entry = nextEntries[fromId]; if (entry) { nextEntries[toId] = nextEntries[toId] @@ -1006,27 +1050,28 @@ function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string : entry; } delete nextEntries[fromId]; + ensureNextPlugins().entries = nextEntries; } - const nextSlots = slots - ? { - ...slots, - ...(slots.memory === fromId ? { memory: toId } : {}), - ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}), - } - : undefined; + const allow = replacePluginIdInList(plugins.allow, fromId, toId); + if (allow !== plugins.allow) { + ensureNextPlugins().allow = allow; + } + const deny = replacePluginIdInList(plugins.deny, fromId, toId); + if (deny !== plugins.deny) { + ensureNextPlugins().deny = deny; + } - return { - ...cfg, - plugins: { - ...cfg.plugins, - allow, - deny, - entries: nextEntries, - installs: nextInstalls, - slots: nextSlots, - }, - }; + const slots = plugins.slots; + if (slots?.memory === fromId || slots?.contextEngine === fromId) { + ensureNextPlugins().slots = { + ...slots, + ...(slots.memory === fromId ? { memory: toId } : {}), + ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}), + }; + } + + return nextPlugins === plugins ? cfg : { ...cfg, plugins: nextPlugins }; } function withoutPluginInstallRecord(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { @@ -1287,12 +1332,7 @@ export async function updateNpmInstalledPlugins(params: { } } - if ( - record.source !== "npm" && - record.source !== "marketplace" && - record.source !== "clawhub" && - record.source !== "git" - ) { + if (!isPluginInstallRecordUpdateSource(record)) { outcomes.push({ pluginId, status: "skipped",