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
This commit is contained in:
Vincent Koc
2026-06-15 23:07:29 +08:00
committed by GitHub
parent c1219d161d
commit 767e8280ac
39 changed files with 9380 additions and 898 deletions

View File

@@ -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

View File

@@ -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<typeof import("../../cli/plugins-install-persist.js")>()),
persistPluginInstall: persistPluginInstallMock,
}));
@@ -70,6 +71,16 @@ function buildPluginsParams(commandBodyNormalized: string, workspaceDir: string)
function expectPersistedInstall(pluginId: string, expectedInstall: Record<string, unknown>): void {
const persisted = mockFirstObjectArg(persistPluginInstallMock);
expect(persisted.pluginId).toBe(pluginId);
const snapshot = persisted.snapshot as Record<string, unknown>;
const writeOptions = snapshot.writeOptions as Record<string, unknown>;
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,

View File

@@ -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<string, unknown>,
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,
},
};
}

View File

@@ -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<PluginInstallRequestContext, "rawSpec" | "marketplace">,
request: Pick<PluginInstallRequestContext, "rawSpec" | "normalizedSpec" | "marketplace">,
): {
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 }

View File

@@ -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<string, PluginInstallRecord>;
let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {};
@@ -37,6 +41,7 @@ function invokeMock<TArgs extends unknown[], TResult>(mock: unknown, ...args: TA
export const loadConfig: Mock<LoadConfigFn> = vi.fn<LoadConfigFn>(() => ({}) 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<UpdateNpmInstalledPluginsFn> = vi.fn();
export const updateNpmInstalledHookPacks: Mock<UpdateNpmInstalledHookPacksFn> = 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<typeof import("../plugins/update.js")>();
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 }) =>

View File

@@ -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"]),

View File

@@ -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<typeof vi.fn>) {
return params;
}
function primeUpdateConfigSnapshot(params: {
config: OpenClawConfig;
configPath?: string;
loadedConfig?: OpenClawConfig;
parsed?: Record<string, unknown>;
runtimeConfig?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
valid?: boolean;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
}): void {
const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5");
const parsed = params.parsed ?? (params.config as Record<string, unknown>);
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,

View File

@@ -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<typeof installHooksFromNpmSpec>[0],
): Promise<InstallHooksResult> {
try {
return await installHooksFromNpmSpec(params);
} catch (error) {
return { ok: false, error: formatErrorMessage(error) };
}
}
async function probeHookPackFromPath(
params: Parameters<typeof installHooksFromPath>[0],
): Promise<InstallHooksResult> {
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<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function supportsPluginRecoveryIncludeShape(parsed: Record<string, unknown>): 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<string, PluginInstallRecord>,
env: NodeJS.ProcessEnv = process.env,
ownedLoadPaths: ReadonlySet<string>,
): 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<string, PluginInstallRecord>;
pluginId: string;
env: NodeJS.ProcessEnv;
}): Set<string> {
const install = params.installRecords[params.pluginId];
function collectRequestedPluginInstallPaths(
cfg: OpenClawConfig,
installRecords: Awaited<ReturnType<typeof loadInstalledPluginIndexInstallRecords>>,
request: PluginInstallRequestContext,
env: NodeJS.ProcessEnv = process.env,
): Set<string> {
const pluginId = request.bundledPluginId?.trim();
if (!pluginId) {
return new Set();
}
const paths = new Set<string>();
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<string, PluginInstallRecord>;
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<string>,
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<Set<string>> {
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<string>,
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<string>();
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<Set<string>> {
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<ReturnType<typeof readConfigFileSnapshot>>,
): Promise<ConfigSnapshotForInstallPersist> {
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
): Promise<ConfigSnapshotForInstallExecution> {
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<ConfigSnapshotForInstallPersist> {
const snapshot = await tracePluginLifecyclePhaseAsync(
): Promise<ConfigSnapshotForInstallExecution> {
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<string, unknown>;
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,

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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<string, unknown>,
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);

View File

@@ -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([]);
});
});

View File

@@ -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<readonly PersistedBundledPluginRecoveryLocation[]> {
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 }];
});
}

View File

@@ -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<typeof getRuntimeConfig>, 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<typeof getRuntimeConfig>;
}): boolean {
const plugins = params.sourceConfig.plugins;
const parsedPlugins =
params.parsed && typeof params.parsed === "object" && !Array.isArray(params.parsed)
? (params.parsed as Record<string, unknown>).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<string, unknown>,
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<string, unknown>)
: {};
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<string>();
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<string, unknown>,
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) {

View File

@@ -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);
});
});

View File

@@ -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)

View File

@@ -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: {

View File

@@ -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<ChannelDoctorConfigMutation[]> {
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,
})) {

View File

@@ -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 };

View File

@@ -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<string, Map<string, number>> {
const countsByName = new Map<string, Map<string, number>>();
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<string, number>();
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<string, Map<string, number>> {
const countsByName = new Map<string, Map<string, number>>();
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<string, number>();
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<number, number> | 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<number, number> {
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<number, number>();
const usedIncomingIndexes = new Set<number>();
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<number>;
}): Map<number, number> {
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<number, number>();
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

View File

@@ -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([
{

View File

@@ -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);
}

View File

@@ -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<unknown>;
/** Internal snapshot-time hashes for include files that mutation writers may update directly. */
includeFileHashesForWrite?: Record<string, string>;
/** Internal snapshot-time canonical targets for include files that mutation writers may update. */
includeFileTargetsForWrite?: Record<string, string>;
};
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<boolean> {
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<ConfigIoDeps>,
includeFileHashesForWrite?: Record<string, string>,
includeFileTargetsForWrite?: Record<string, string>,
): 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<string, string | undefined>;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
@@ -1845,6 +1941,9 @@ export function createConfigIO(
let fallbackParsed: unknown = {};
let fallbackSourceConfig: OpenClawConfig = {};
let fallbackHash = hashConfigRaw(null);
let fallbackEnvSnapshotForRestore: Record<string, string | undefined> | undefined;
const includeFileHashesForWrite: Record<string, string> = {};
const includeFileTargetsForWrite: Record<string, string> = {};
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<ReadConfigFileSnapshotForWriteResult> {
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<InternalConfigWriteResult> {
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<string, string> | null = null;
let changedPaths: Set<string> | null = null;
const identityRestoredPaths = new Set<string>();
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<ConfigFileSnapshot> {
export async function readConfigFileSnapshotForWrite(options?: {
skipPluginValidation?: boolean;
}): Promise<ReadConfigFileSnapshotForWriteResult> {
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<ReadConfigFileSnapshotForWriteResult> {
@@ -2674,7 +2880,9 @@ export async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<ConfigWriteResult> {
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({

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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(

View File

@@ -80,15 +80,27 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown)
return next;
}
function hasOwnIncludeKey(value: unknown): value is Record<string, unknown> {
return isRecord(value) && Object.hasOwn(value, "$include");
function hasOwnValidIncludeDirective(value: unknown): value is Record<string, unknown> {
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(".") : "<root>";
}
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<string, unknown> = { ...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<string, unknown> = 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<string, string>,
changedPaths: Set<string>,
identityRestoredPaths: ReadonlySet<string> = 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<string, unknown> = {};
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Set<string>>();
const configMutationQueueTails = new Map<string, Promise<void>>();
/** 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<T> = 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<T>(
params: { io?: ConfigMutationIO; lockPath?: string },
fn: () => Promise<T>,
@@ -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<ReturnType<typeof readConfigSnapshotForMutation>>,
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<T>(
params: { writeOptions?: ConfigWriteOptions },
fn: (prepared: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>) => Promise<T>,
): Promise<T> {
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<string | null> {
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<RootBoundIncludeFile> {
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<RootBoundIncludeFile> {
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<string | null> {
try {
return await target.root.readText(target.relativePath);
} catch (error) {
if (isMissingFileError(error)) {
return null;
}
throw error;
}
}
async function assertRootConfigStillMatchesSnapshot(snapshot: ConfigFileSnapshot): Promise<void> {
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<boolean> {
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<void> {
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<void> {
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<string, unknown>;
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<string, unknown>)[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<ReturnType<typeof readConfigFileSnapshotForWrite>>;
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<ConfigReplaceResult> {
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<ConfigReplaceResult> {
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<T>(
params: TransformConfigFileParams<T>,
attempt: number,
ownership?: ConfigMutationOwnership,
prepared?: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>,
): Promise<ConfigMutationResult<T>> {
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<T>(
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<T>(
export async function transformConfigFile<T = void>(
params: TransformConfigFileParams<T>,
): Promise<ConfigMutationResult<T>> {
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<T = void>(
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<ReturnType<typeof readConfigSnapshotForMutation>>,
) => {
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<T = void>(params: {

View File

@@ -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;
}
}

View File

@@ -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<string, string>;
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<string, unknown>;
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<string, unknown>).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);

View File

@@ -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<string, string>;
} & Partial<Record<typeof MANIFEST_KEY, { hooks?: string[] }>>;
} & Partial<Record<typeof MANIFEST_KEY, { extensions?: string[]; hooks?: string[] }>>;
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<typeof scanPackageInstallSource>;
}): Promise<Extract<InstallHooksResult, { ok: false }> | 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<Extract<InstallHooksResult, { ok: false }> | 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<Extract<InstallHooksResult, { ok: false }> | 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<InstallHooksResult> {
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<string> {
return frontmatter.name || path.basename(hookDir);
}
async function validateHookDir(hookDir: string): Promise<void> {
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<void> {
}
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<InstallHooksResult> {
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<InstallHooksResult> {
async function installHookFromDir(
params: {
hookDir: string;
packageKind?: "plugin-capable";
} & HookInstallForwardParams,
): Promise<InstallHooksResult> {
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<boolean>;
}): Promise<InstallHooksResult> {
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<boolean>;
} & InstallSafetyOverrides,
): Promise<InstallHooksResult> {
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()) {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 {

View File

@@ -374,7 +374,10 @@ export async function installPackageDirWithManifestDeps(params: {
depsLogMessage: string;
manifestDependencies?: Record<string, unknown>;
afterCopy?: (installedDir: string) => void | Promise<void>;
}): 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,

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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",