mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 16:08:13 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
})) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
259
src/config/io.ts
259
src/config/io.ts
@@ -11,7 +11,11 @@ import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import { isVerbose } from "../global-state.js";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
collectErrorGraphCandidates,
|
||||
extractErrorCode,
|
||||
formatErrorMessage,
|
||||
} from "../infra/errors.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { replaceFileAtomic, replaceFileAtomicSync } from "../infra/replace-file.js";
|
||||
import {
|
||||
@@ -33,7 +37,7 @@ import { isRecord } from "../utils.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
||||
import { maintainConfigBackups } from "./backup-rotation.js";
|
||||
import { restoreEnvVarRefs } from "./env-preserve.js";
|
||||
import { EnvRefArrayMutationError, restoreEnvVarRefs } from "./env-preserve.js";
|
||||
import {
|
||||
type EnvSubstitutionWarning,
|
||||
containsEnvVarReference,
|
||||
@@ -42,8 +46,10 @@ import {
|
||||
import { applyConfigEnvVars } from "./env-vars.js";
|
||||
import {
|
||||
ConfigIncludeError,
|
||||
hashConfigIncludeRaw,
|
||||
INCLUDE_KEY,
|
||||
readConfigIncludeFileWithGuards,
|
||||
resolveConfigIncludeWritePath,
|
||||
resolveConfigIncludes,
|
||||
} from "./includes.js";
|
||||
import {
|
||||
@@ -70,6 +76,7 @@ import {
|
||||
createMergePatch,
|
||||
formatConfigValidationFailure,
|
||||
applyUnsetPathsForWrite,
|
||||
preserveIncludeOwnedConfigForWrite,
|
||||
restoreEnvRefsFromMap,
|
||||
resolvePersistCandidateForWrite,
|
||||
resolveManagedUnsetPathsForWrite,
|
||||
@@ -81,6 +88,7 @@ import {
|
||||
materializeRuntimeConfig,
|
||||
} from "./materialize.js";
|
||||
import { applyMergePatch } from "./merge-patch.js";
|
||||
import { ConfigMutationConflictError } from "./mutation-conflict.js";
|
||||
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
|
||||
import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js";
|
||||
import {
|
||||
@@ -197,6 +205,13 @@ export type ConfigWriteOptions = {
|
||||
* same config file path that produced the snapshot.
|
||||
*/
|
||||
expectedConfigPath?: string;
|
||||
/** Internal write destination captured by readConfigFileSnapshotForWrite(). */
|
||||
ownedConfigPathForWrite?: string;
|
||||
/**
|
||||
* Internal mutation-start ownership guard. Rechecks that the config path
|
||||
* captured by readConfigFileSnapshotForWrite() is still active at commit.
|
||||
*/
|
||||
assertConfigPathForWrite?: () => void;
|
||||
/**
|
||||
* Paths that must be explicitly removed from the persisted file payload,
|
||||
* even if schema/default normalization reintroduces them.
|
||||
@@ -272,6 +287,10 @@ export type ConfigWriteOptions = {
|
||||
* has produced the exact source config that will be committed.
|
||||
*/
|
||||
preCommitRuntimePreflight?: (sourceConfig: OpenClawConfig) => Promise<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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
12
src/config/mutation-conflict.ts
Normal file
12
src/config/mutation-conflict.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user