fix: serialize onboarding and plugin installation (#121482)

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-10 00:49:25 -07:00
committed by GitHub
parent f6298bf84d
commit da2684b890
19 changed files with 732 additions and 615 deletions
@@ -3,7 +3,7 @@
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
2ccf6bdc0cae7e136a0ed9feba2cab10047f432fb1c50c4f711df1a5cc6e5414 module/account-resolution
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
fe78f6361092d29375d3ef0306aab785ea51a0f3b05a73cd826d9563e1beedfc module/agent-harness
d0773747c5392c38c8406ea218cc5686aaaa8578fce210008394b4ba683d0a99 module/agent-harness
61b7d640db1f066c300fef858db8bd729cb46722794405c0159d14f9dbe86aa8 module/agent-harness-runtime
cdf661f6e5b9118ae3b33f0c5e4aec1351ad89b0b61e8b3e02abb7409b4e16da module/agent-media-payload
c0b60f2e239bc88e8dadbf7b9583c126822678f5d70c880f90f1d447b00e5170 module/agent-runtime
+40 -1
View File
@@ -95,7 +95,8 @@ const mocks = vi.hoisted(() => ({
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
}));
vi.mock("../infra/fs-safe.js", () => ({
vi.mock("../infra/fs-safe.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/fs-safe.js")>()),
movePathToTrash: mocks.movePathToTrash,
}));
@@ -147,6 +148,44 @@ function expectedTrashSourcePath(targetPath: string): string {
}
describe("handleReset", () => {
it("rejects full-reset workspaces that contain the active onboarding lock", async () => {
const homeDir = tempDirs.make("openclaw-reset-lock-overlap-");
const stateDir = path.join(homeDir, "state");
const migrationDir = path.join(stateDir, "migration");
const migrationAlias = path.join(homeDir, "migration-alias");
const lockSidecar = path.join(migrationDir, "onboarding.lock-target.lock");
const lockSidecarViaAlias = path.join(migrationAlias, "onboarding.lock-target.lock");
const configPath = path.join(stateDir, "openclaw.json");
fs.mkdirSync(migrationDir, { recursive: true });
fs.writeFileSync(configPath, "{}\n");
fs.symlinkSync(migrationDir, migrationAlias, process.platform === "win32" ? "junction" : "dir");
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
for (const workspaceDir of [
homeDir,
stateDir,
migrationDir,
migrationAlias,
lockSidecar,
lockSidecarViaAlias,
]) {
await expect(
withEnvAsync(
{
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: configPath,
},
async () => await handleReset("full", workspaceDir, runtime),
),
).rejects.toThrow("overlaps the active onboarding lock directory");
}
expect(mocks.movePathToTrash).not.toHaveBeenCalled();
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
});
it("uses active profile paths for destructive reset targets", async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
const profileStateDir = path.join(homeDir, ".openclaw-work");
+27 -1
View File
@@ -42,7 +42,11 @@ import {
resolveBrowserOpenCommand,
} from "../infra/browser-open.js";
import { detectBinary } from "../infra/detect-binary.js";
import { movePathToTrash } from "../infra/fs-safe.js";
import {
canonicalPathFromExistingAncestor,
isPathInside,
movePathToTrash,
} from "../infra/fs-safe.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveConfigDir, shortenHomeInString, shortenHomePath, sleep } from "../utils.js";
import { VERSION } from "../version.js";
@@ -308,8 +312,30 @@ async function resolveMoveToTrashAllowedRoots(targetPath: string): Promise<strin
return uniqueStrings(allowedRoots);
}
async function assertFullResetPreservesOnboardingLock(workspaceDir: string): Promise<void> {
const [workspacePath, migrationDir] = await Promise.all([
canonicalPathFromExistingAncestor(path.resolve(workspaceDir)),
canonicalPathFromExistingAncestor(path.join(resolveStateDir(), "migration")),
]);
if (
workspacePath === migrationDir ||
isPathInside(workspacePath, migrationDir) ||
isPathInside(migrationDir, workspacePath)
) {
throw new Error(
"Full reset workspace overlaps the active onboarding lock directory. " +
"Choose a workspace outside the OpenClaw state migration directory or use a narrower reset scope.",
);
}
}
/** Deletes onboarding-managed state according to the selected reset scope. */
export async function handleReset(scope: ResetScope, workspaceDir: string, runtime: RuntimeEnv) {
if (scope === "full") {
// Validate before moving config or credentials so an unsafe full reset has
// no partial destructive effects and cannot discard its own lock sidecar.
await assertFullResetPreservesOnboardingLock(workspaceDir);
}
await moveToTrash(resolveConfigPath(), runtime);
if (scope === "config") {
return;
@@ -15,6 +15,9 @@ import type { installGatewayDaemonNonInteractive } from "./onboard-non-interacti
const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
const testConfigStore = new Map<string, OpenClawConfig>();
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 }));
const configWritePluginLeaseDepths: number[] = [];
type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemonNonInteractive>>;
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
@@ -52,33 +55,59 @@ function readTestConfig<T = OpenClawConfig>(): T {
return (testConfigStore.get(resolveTestConfigPath()) ?? {}) as T;
}
readConfigFileSnapshotMock.mockImplementation(async () => {
const configPath = resolveTestConfigPath();
const config = testConfigStore.get(configPath);
if (config) {
const raw = `${JSON.stringify(config, null, 2)}\n`;
return {
exists: true,
valid: true,
config,
sourceConfig: config,
raw,
hash: "test-config-hash",
};
}
return {
exists: false,
valid: true,
config: {},
sourceConfig: {},
raw: null,
hash: undefined,
};
});
vi.mock("../config/io.js", () => ({
createConfigIO: () => ({
configPath: resolveTestConfigPath(),
}),
loadConfig: () => testConfigStore.get(resolveTestConfigPath()) ?? {},
readConfigFileSnapshot: async () => {
const configPath = resolveTestConfigPath();
const config = testConfigStore.get(configPath);
if (config) {
const raw = `${JSON.stringify(config, null, 2)}\n`;
return {
exists: true,
valid: true,
config,
sourceConfig: config,
raw,
hash: "test-config-hash",
};
readConfigFileSnapshot: readConfigFileSnapshotMock,
}));
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (
_options: unknown,
run: (lease: {
databasePath: string;
signal: AbortSignal;
assertOwned: () => void;
assertOwnedInTransaction: () => void;
}) => Promise<unknown>,
) => {
pluginLifecycleLeaseState.depth += 1;
try {
return await run({
databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"),
signal: new AbortController().signal,
assertOwned: () => {},
assertOwnedInTransaction: () => {},
});
} finally {
pluginLifecycleLeaseState.depth -= 1;
}
return {
exists: false,
valid: true,
config: {},
sourceConfig: {},
raw: null,
hash: undefined,
};
},
}));
@@ -95,6 +124,7 @@ vi.mock("../config/config.js", () => ({
nextConfig: OpenClawConfig;
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
}) => {
configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth);
capturedReplaceConfigFileCalls.push({ nextConfig, ...(writeOptions ? { writeOptions } : {}) });
testConfigStore.set(resolveTestConfigPath(), nextConfig);
},
@@ -352,6 +382,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
waitForGatewayReachableMock = undefined;
testConfigStore.clear();
capturedReplaceConfigFileCalls.length = 0;
configWritePluginLeaseDepths.length = 0;
readConfigFileSnapshotMock.mockClear();
ensureWorkspaceAndSessionsMock.mockClear();
installGatewayDaemonNonInteractiveMock.mockClear();
healthCommandMock.mockClear();
@@ -360,6 +392,66 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
readLastGatewayErrorLineMock.mockClear();
});
it("serializes concurrent onboarding runs sharing one state directory", async () => {
await withStateDir("state-concurrent-onboard-", async (stateDir) => {
let activeWorkspaceSetups = 0;
let maxActiveWorkspaceSetups = 0;
let workspaceSetupCalls = 0;
let releaseFirstSetup!: () => void;
const firstSetupEntered = new Promise<void>((resolve) => {
ensureWorkspaceAndSessionsMock.mockImplementation(async () => {
workspaceSetupCalls += 1;
activeWorkspaceSetups += 1;
maxActiveWorkspaceSetups = Math.max(maxActiveWorkspaceSetups, activeWorkspaceSetups);
try {
if (workspaceSetupCalls === 1) {
resolve();
await new Promise<void>((release) => {
releaseFirstSetup = release;
});
}
} finally {
activeWorkspaceSetups -= 1;
}
});
});
const options = {
nonInteractive: true,
mode: "local" as const,
workspace: path.join(stateDir, "openclaw"),
authChoice: "skip" as const,
skipSkills: true,
skipHealth: true,
installDaemon: false,
};
try {
const first = runNonInteractiveSetup(options, runtime);
await firstSetupEntered;
const readsBeforeSecond = readConfigFileSnapshotMock.mock.calls.length;
const writesBeforeSecond = capturedReplaceConfigFileCalls.length;
const second = runNonInteractiveSetup(options, runtime);
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(readsBeforeSecond);
expect(capturedReplaceConfigFileCalls).toHaveLength(writesBeforeSecond);
expect(ensureWorkspaceAndSessionsMock).toHaveBeenCalledOnce();
releaseFirstSetup();
await Promise.all([first, second]);
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(readsBeforeSecond + 1);
expect(maxActiveWorkspaceSetups).toBe(1);
expect(configWritePluginLeaseDepths).toHaveLength(2);
expect(configWritePluginLeaseDepths.every((depth) => depth > 0)).toBe(true);
} finally {
releaseFirstSetup?.();
ensureWorkspaceAndSessionsMock.mockImplementation(async () => {});
}
});
});
it("writes the implicit workspace under a non-default state directory", async () => {
await withStateDir("state-isolated-workspace-", async (stateDir) => {
await runNonInteractiveSetup(
+42 -6
View File
@@ -9,14 +9,24 @@ import { formatCliCommand } from "../cli/command-format.js";
import { ConfigMutationConflictError, replaceConfigFile } from "../config/config.js";
import { readConfigFileSnapshot } from "../config/io.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { withOpenClawStateLease } from "../state/openclaw-state-lease.js";
import { withSetupMigrationTargetLock } from "../wizard/setup.migration-snapshot.js";
import { createNonInteractiveLoggingPrompter } from "./non-interactive-prompter.js";
import { runNonInteractiveLocalSetup } from "./onboard-non-interactive/local.js";
import { runNonInteractiveRemoteSetup } from "./onboard-non-interactive/remote.js";
import type { OnboardOptions } from "./onboard-types.js";
function isMigrationImport(opts: OnboardOptions): boolean {
return Boolean(
opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import",
);
}
/** Runs a setup migration import with non-interactive prompt failures. */
async function runNonInteractiveMigrationImport(params: {
opts: OnboardOptions;
@@ -80,11 +90,7 @@ async function runNonInteractiveMigrationImport(params: {
await outcome.acknowledgePromotion?.();
}
/** Runs non-interactive onboarding in local, remote, or migration-import mode. */
export async function runNonInteractiveSetup(
opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
async function runNonInteractiveSetupExclusive(opts: OnboardOptions, runtime: RuntimeEnv) {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
// Avoid rewriting an invalid config snapshot; doctor owns recovery so setup
@@ -110,7 +116,7 @@ export async function runNonInteractiveSetup(
return;
}
if (opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import") {
if (isMigrationImport(opts)) {
// Import flow owns its own commit path because migrations may intentionally
// shrink legacy config after extracting credentials.
await runNonInteractiveMigrationImport({ opts, runtime, baseConfig });
@@ -124,3 +130,33 @@ export async function runNonInteractiveSetup(
await runNonInteractiveLocalSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash });
}
/** Runs non-interactive onboarding in local, remote, or migration-import mode. */
export async function runNonInteractiveSetup(
opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
await withSetupMigrationTargetLock(resolveStateDir(), async () => {
if (isMigrationImport(opts)) {
// Migration must inspect freshness before opening the shared lease DB.
await runNonInteractiveSetupExclusive(opts, runtime);
return;
}
await withOpenClawStateLease(
{
scope: "core:onboarding",
key: "global",
database: { scope: "shared" },
// Bound one run to five minutes while allowing one predecessor to finish.
leaseMs: 5 * 60_000,
waitMs: 10 * 60_000,
leaseLabel: "non-interactive onboarding lease",
operationLabel: "onboarding.non-interactive.lease",
},
async () =>
await withPluginLifecycleLease({}, async () =>
runNonInteractiveSetupExclusive(opts, runtime),
),
);
});
}
+12 -2
View File
@@ -79,6 +79,9 @@ const mocks = vi.hoisted(() => ({
config: {},
})),
handleReset: vi.fn(async () => {}),
withSetupMigrationTargetLock: vi.fn(
async (_stateDir: string, run: () => Promise<unknown>) => await run(),
),
}));
vi.mock("./onboard-interactive.js", () => ({
@@ -106,6 +109,10 @@ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: mocks.resolvePluginProviders,
}));
vi.mock("../wizard/setup.migration-snapshot.js", () => ({
withSetupMigrationTargetLock: mocks.withSetupMigrationTargetLock,
}));
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
@@ -262,13 +269,16 @@ describe("setupWizardCommand", () => {
await setupWizardCommand({ reset: true, nonInteractive: true, acceptRisk: true }, runtime);
expect(mocks.withSetupMigrationTargetLock).toHaveBeenCalledOnce();
expect(mocks.handleReset).toHaveBeenCalledOnce();
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
const lockOrder = mocks.withSetupMigrationTargetLock.mock.invocationCallOrder[0];
const resetOrder = mocks.handleReset.mock.invocationCallOrder[0];
const setupOrder = mocks.runNonInteractiveSetup.mock.invocationCallOrder[0];
if (resetOrder === undefined || setupOrder === undefined) {
throw new Error("expected reset and non-interactive setup calls");
if (lockOrder === undefined || resetOrder === undefined || setupOrder === undefined) {
throw new Error("expected lock, reset, and non-interactive setup calls");
}
expect(lockOrder).toBeLessThan(resetOrder);
expect(resetOrder).toBeLessThan(setupOrder);
});
+75 -70
View File
@@ -7,6 +7,7 @@
import { formatCliCommand } from "../cli/command-format.js";
import { formatInvalidPortOption } from "../cli/error-format.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isValidEnvSecretRefId } from "../config/types.secrets.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -23,6 +24,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import { withSetupMigrationTargetLock } from "../wizard/setup.migration-snapshot.js";
import {
formatDeprecatedNonInteractiveAuthChoiceError,
isDeprecatedAuthChoice,
@@ -563,77 +565,80 @@ export async function setupWizardCommand(
? runInteractiveSetup
: runGuidedOnboarding;
if (normalizedOpts.reset) {
const snapshot = await readConfigFileSnapshot();
const baseConfig = snapshot.sourceConfig ?? (snapshot.valid ? snapshot.config : {});
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
// Every reset scope removes the config file. Validate setup against the
// empty config and requested/default workspace that dispatch will see.
const setupBaseConfig: OpenClawConfig = {};
const setupWorkspaceDir = resolveUserPath(normalizedOpts.workspace ?? DEFAULT_WORKSPACE);
const configuredWorkspace: unknown =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace;
if (
resetScope === "full" &&
normalizedOpts.workspace === undefined &&
snapshot.exists &&
!snapshot.valid &&
// A snapshot always carries a sourceConfig object (empty on failure), so
// only readError distinguishes "config could not be read" from "config
// parsed but configures no workspace", where the default is correct.
snapshot.readError !== undefined
) {
rejectOption(
runtime,
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
const runSetupAfterOptionalReset = async () => {
if (normalizedOpts.reset) {
const snapshot = await readConfigFileSnapshot();
const baseConfig = snapshot.sourceConfig ?? (snapshot.valid ? snapshot.config : {});
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
// Every reset scope removes the config file. Validate setup against the
// empty config and requested/default workspace that dispatch will see.
const setupBaseConfig: OpenClawConfig = {};
const setupWorkspaceDir = resolveUserPath(normalizedOpts.workspace ?? DEFAULT_WORKSPACE);
const configuredWorkspace: unknown =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace;
if (
resetScope === "full" &&
normalizedOpts.workspace === undefined &&
snapshot.exists &&
!snapshot.valid &&
// A snapshot always carries a sourceConfig object (empty on failure), so
// only readError distinguishes "config could not be read" from "config
// parsed but configures no workspace", where the default is correct.
snapshot.readError !== undefined
) {
rejectOption(
runtime,
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
return;
}
if (
resetScope === "full" &&
configuredWorkspace !== undefined &&
(typeof configuredWorkspace !== "string" || !configuredWorkspace.trim())
) {
rejectOption(
runtime,
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
return;
}
// Non-full scopes never touch the workspace, so the fallback is only an
// inert handleReset argument when an invalid config contains bad data.
const workspaceDir = resolveUserPath(
typeof configuredWorkspace === "string" && configuredWorkspace.trim()
? configuredWorkspace
: DEFAULT_WORKSPACE,
);
return;
if (
!(await validateResetAuthChoice({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
workspaceDir: setupWorkspaceDir,
resetScope,
}))
) {
return;
}
if (
!validateResetNonInteractiveGateway({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
})
) {
return;
}
if (!validateResetMigrationImport({ opts: normalizedOpts, runtime })) {
return;
}
// Reset is deliberately the final pre-dispatch step: no rejectable option
// checks may run after user state has moved to Trash.
await handleReset(resetScope, workspaceDir, runtime);
}
if (
resetScope === "full" &&
configuredWorkspace !== undefined &&
(typeof configuredWorkspace !== "string" || !configuredWorkspace.trim())
) {
rejectOption(
runtime,
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
return;
}
// Non-full scopes never touch the workspace, so the fallback is only an
// inert handleReset argument when an invalid config contains bad data.
const workspaceDir = resolveUserPath(
typeof configuredWorkspace === "string" && configuredWorkspace.trim()
? configuredWorkspace
: DEFAULT_WORKSPACE,
);
if (
!(await validateResetAuthChoice({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
workspaceDir: setupWorkspaceDir,
resetScope,
}))
) {
return;
}
if (
!validateResetNonInteractiveGateway({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
})
) {
return;
}
if (!validateResetMigrationImport({ opts: normalizedOpts, runtime })) {
return;
}
// Reset is deliberately the final pre-dispatch step: no rejectable option
// checks may run after user state has moved to Trash.
await handleReset(resetScope, workspaceDir, runtime);
}
await runSetup(normalizedOpts, runtime);
await runSetup(normalizedOpts, runtime);
};
await withSetupMigrationTargetLock(resolveStateDir(), runSetupAfterOptionalReset);
}
+56 -2
View File
@@ -112,6 +112,13 @@ vi.mock("../plugins/installed-plugin-index-records.js", () => ({
clearLoadInstalledPluginIndexInstallRecordsCache,
}));
const withPluginLifecycleLease = vi.hoisted(() =>
vi.fn(async (_options: unknown, run: () => Promise<unknown>) => await run()),
);
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease,
}));
const withTimeout = vi.hoisted(() => vi.fn(async <T>(promise: Promise<T>) => await promise));
vi.mock("../utils/with-timeout.js", () => ({
withTimeout,
@@ -308,6 +315,7 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(progress).toHaveBeenCalledWith("正在安装 Demo Plugin 插件...");
expect(note).toHaveBeenCalledWith("无法启用 Demo Pluginblocked by allowlist。", "插件安装");
expect(withPluginLifecycleLease).toHaveBeenCalledOnce();
} finally {
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
@@ -873,12 +881,45 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(result.status).toBe("installed");
});
it("returns a timed out status and notes the retry path when npm install hangs", async () => {
it("cancels a timed out npm install before returning and releasing its lease", async () => {
const note = vi.fn(async () => {});
const stop = vi.fn();
let installSignal: AbortSignal | undefined;
let releaseCleanup = () => {};
let leaseActive = false;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
let observeAbort = () => {};
const abortObserved = new Promise<void>((resolve) => {
observeAbort = resolve;
});
withPluginLifecycleLease.mockImplementationOnce(async (_options, run) => {
leaseActive = true;
try {
return await run();
} finally {
leaseActive = false;
}
});
installPluginFromNpmSpec.mockImplementationOnce(async (params: { signal?: AbortSignal }) => {
installSignal = params.signal;
await new Promise<void>((resolve) => {
params.signal?.addEventListener(
"abort",
() => {
observeAbort();
resolve();
},
{ once: true },
);
});
await cleanupGate;
return { ok: false, error: "installer canceled" };
});
withTimeout.mockRejectedValue(new Error("timeout"));
const result = await ensureOnboardingPluginInstalled({
const pendingResult = ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
@@ -897,7 +938,20 @@ describe("ensureOnboardingPluginInstalled", () => {
error: vi.fn(),
} as never,
});
let returned = false;
void pendingResult.then(() => {
returned = true;
});
await abortObserved;
expect(installSignal?.aborted).toBe(true);
expect(leaseActive).toBe(true);
expect(returned).toBe(false);
releaseCleanup();
const result = await pendingResult;
expect(leaseActive).toBe(false);
expect(result).toEqual({
cfg: {},
installed: false,
+281 -411
View File
@@ -49,6 +49,7 @@ import {
resolveNpmInstallRecordSpec,
} from "../plugins/installs.js";
import type { PluginPackageInstall } from "../plugins/manifest.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { invalidatePluginRuntimeDiscoveryAfterConfigMutation } from "../plugins/registry-refresh.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -61,6 +62,7 @@ type InstallChoice = "clawhub" | "npm" | "local" | "skip";
type InstallPluginFromClawHubResult = Awaited<
ReturnType<(typeof import("../plugins/clawhub.js"))["installPluginFromClawHub"]>
>;
type InstallOutcome<T> = { status: "timed_out" } | { status: "completed"; result: T };
const ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
const ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS = ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS + 5_000;
@@ -86,6 +88,15 @@ type OnboardingPluginInstallResult = {
error?: string;
};
function incompletePluginInstall(
cfg: OpenClawConfig,
pluginId: string,
status: Exclude<OnboardingPluginInstallStatus, "installed">,
error?: string,
): OnboardingPluginInstallResult {
return { cfg, installed: false, pluginId, status, ...(error === undefined ? {} : { error }) };
}
async function markOnboardingPluginInstalled(params: {
cfg: OpenClawConfig;
pluginId: string;
@@ -242,13 +253,13 @@ function formatPortableLocalPath(localPath: string, workspaceDir?: string): stri
return undefined;
}
async function recordLocalPluginInstall(params: {
function recordLocalPluginInstall(params: {
cfg: OpenClawConfig;
entry: OnboardingPluginInstallEntry;
localPath: string;
npmSpec?: string | null;
workspaceDir?: string;
}): Promise<OpenClawConfig> {
}): OpenClawConfig {
const sourcePath = formatPortableLocalPath(params.localPath, params.workspaceDir);
const install = {
pluginId: params.entry.pluginId,
@@ -408,24 +419,14 @@ async function promptInstallChoice(params: {
bundledLocalPath?: string | null;
defaultChoice: InstallChoice;
prompter: WizardPrompter;
/** When true and only one real install source (npm *or* local, not both)
* exists, skip the "Install <plugin>? / Skip" prompt and resolve directly
* to that source. Useful when the caller already knows the user's intent
* (e.g. they just picked the channel in a previous menu). */
/** Skip the redundant prompt when the caller already chose the only viable source. */
autoConfirmSingleSource?: boolean;
effectiveNpmSpec?: string | null;
effectiveClawHubSpec?: string | null;
}): Promise<InstallChoice> {
const rawClawHubSpec = resolveClawHubSpecForOnboarding(params.entry.install);
const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
// When the plugin already ships bundled with the host (i.e. lives under
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
// the bundled copy is the source of truth: it is version-locked to the
// current host build and is what `defaultChoice` will pick anyway (see
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
// case is misleading; those catalog specs only exist as fallback metadata for
// non-bundled builds. Hide them so bundled channels like Tlon look identical
// to Twitch / Slack in the menu.
// Bundled plugins are version-locked to the host; remote specs are fallback metadata only.
const clawhubSpec = params.bundledLocalPath
? null
: (params.effectiveClawHubSpec ?? rawClawHubSpec);
@@ -467,8 +468,6 @@ async function promptInstallChoice(params: {
realSources.push("local");
}
if (realSources.length === 1) {
// Callers that already selected a plugin/channel can skip an extra prompt
// when there is only one viable source.
return expectDefined(realSources[0], "real sources entry at 0");
}
}
@@ -564,6 +563,23 @@ function formatInstallErrorDetail(message: string): string {
return `${truncateUtf16Safe(cleaned, ONBOARDING_PLUGIN_INSTALL_ERROR_MAX_CHARS - marker.length).trimEnd()}${marker}`;
}
async function notePluginInstallFailure(
prompter: WizardPrompter,
spec: string,
error: string,
): Promise<void> {
await prompter.note(
[
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(spec),
error: summarizeInstallError(error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
t("wizard.plugins.installTitle"),
);
}
const testing = { formatInstallErrorDetail, summarizeInstallError };
if (process.env.VITEST || process.env.NODE_ENV === "test") {
@@ -606,20 +622,16 @@ async function finishOnboardingPluginInstall(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
install?: Parameters<typeof recordPluginInstall>[1];
prepareConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
}): Promise<OnboardingPluginInstallResult> {
const enableResult = await applyPluginEnablement(params);
if (!enableResult.enabled) {
return {
cfg: enableResult.config,
installed: false,
pluginId: params.pluginId,
status: "failed",
};
return incompletePluginInstall(enableResult.config, params.pluginId, "failed");
}
return await markOnboardingPluginInstalled({
cfg: params.install
? recordPluginInstall(enableResult.config, params.install)
: enableResult.config,
: (params.prepareConfig?.(enableResult.config) ?? enableResult.config),
pluginId: params.pluginId,
runtime: params.runtime,
});
@@ -635,36 +647,23 @@ async function installLocalOnboardingPlugin(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
}): Promise<OnboardingPluginInstallResult> {
const enableResult = await applyPluginEnablement({
return await finishOnboardingPluginInstall({
cfg: params.cfg,
pluginId: params.entry.pluginId,
label: params.entry.label,
prompter: params.prompter,
runtime: params.runtime,
});
if (!enableResult.enabled) {
return {
cfg: enableResult.config,
installed: false,
pluginId: params.entry.pluginId,
status: "failed",
};
}
// Bundled sources already belong to the host and must not gain an install
// record or a duplicate plugin load path.
const cfg = pathsReferToSameDirectory(params.localPath, params.bundledLocalPath)
? enableResult.config
: await recordLocalPluginInstall({
cfg: addPluginLoadPath(enableResult.config, params.localPath),
entry: params.entry,
localPath: params.localPath,
npmSpec: params.npmSpec,
workspaceDir: params.workspaceDir,
});
return await markOnboardingPluginInstalled({
cfg,
pluginId: params.entry.pluginId,
runtime: params.runtime,
// Bundled sources already belong to the host and need no record or load path.
prepareConfig: (cfg) =>
pathsReferToSameDirectory(params.localPath, params.bundledLocalPath)
? cfg
: recordLocalPluginInstall({
cfg: addPluginLoadPath(cfg, params.localPath),
entry: params.entry,
localPath: params.localPath,
npmSpec: params.npmSpec,
workspaceDir: params.workspaceDir,
}),
});
}
@@ -678,18 +677,9 @@ const PROGRESS_BAR_TICK_MS = 200;
const PROGRESS_BAR_DURATION_MS = 10_000;
const PROGRESS_BAR_MAX_PERCENT = 99;
/**
* Maps a verbose install log line (e.g. `Downloading @scope/pkg@1.2.3 from
* ClawHub…`, `Extracting /tmp/…/wecom-…-2026.4.23.tgz…`, `Installing to
* /home/.../plugins/demo…`) to a short verb suitable for a progress label.
*
* Falls back to the raw message when no known verb prefix is recognised so
* that unexpected log lines still surface to the user instead of being
* swallowed.
*/
/** Shortens known install steps while preserving unfamiliar output verbatim. */
function shortenInstallLabel(message: string): string {
const trimmed = message.trim();
// Match a leading verb phrase. Order matters: more specific phrases first.
const patterns: Array<[RegExp, string]> = [
[/^Downloading\b/i, "Downloading"],
[/^Extracting\b/i, "Extracting"],
@@ -713,23 +703,7 @@ function shortenInstallLabel(message: string): string {
return trimmed;
}
/**
* Wraps a {@link WizardProgress} so the spinner message keeps a steadily
* growing ASCII bar attached to whatever the current install step label is.
*
* The plugin install pipeline only emits coarse `info` log lines, so without
* animation the spinner can sit on the same string for many seconds with no
* visible feedback. We render a deterministic left-to-right filling bar that
* advances linearly over {@link PROGRESS_BAR_DURATION_MS} (default 10s) up to
* {@link PROGRESS_BAR_MAX_PERCENT} (99%). If the install takes longer than the
* preset duration the bar simply stays pinned at 99% — never wrapping back to
* 0% — so the user always sees forward motion and a ceiling that signals
* "almost there, just waiting on the last bit".
*
* The bare label is forwarded to `progress.update` first on every label
* change so callers/tests that assert on the unadorned message continue to
* observe it before any decorated frame is overlaid.
*/
/** Adds a steadily growing, 99%-capped bar between coarse installer updates. */
function createAnimatedInstallProgress(
progress: { update: (message: string) => void },
options: { totalMs?: number } = {},
@@ -763,8 +737,7 @@ function createAnimatedInstallProgress(
progress.update(decorate(currentLabel));
}
}, PROGRESS_BAR_TICK_MS);
// Animation is decorative: never let it hold the event loop open if a caller
// forgets to stop us (e.g. an unexpected throw bypasses the `finally`).
// Decorative progress must never keep the process alive.
if (typeof timer.unref === "function") {
timer.unref();
}
@@ -772,8 +745,7 @@ function createAnimatedInstallProgress(
return {
setLabel: (label: string) => {
currentLabel = label;
// Always emit the bare label first so existing log/test expectations
// continue to observe the unadorned message before any animation frame.
// Emit the bare label before decorated animation frames.
progress.update(label);
},
stop: () => {
@@ -814,23 +786,35 @@ function isClawHubTrustWarning(message: string): boolean {
);
}
async function runInstallWatchdog<T>(install: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const ownedInstallPromise = install(controller.signal);
try {
return await withTimeout(ownedInstallPromise, ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS);
} catch (error) {
if (isTimeoutError(error)) {
// Cancel owned child processes, then retain the lifecycle lease through rollback.
controller.abort();
await ownedInstallPromise.catch(() => undefined);
}
throw error;
}
}
async function runOnboardingPluginInstallWithProgress(params: {
cfg: OpenClawConfig;
entry: OnboardingPluginInstallEntry;
prompter: WizardPrompter;
runtime: RuntimeEnv;
install: (logger: {
info: (message: string) => void;
warn: (message: string) => void;
}) => Promise<InstallPluginResult>;
install: (
logger: {
info: (message: string) => void;
warn: (message: string) => void;
},
signal: AbortSignal,
) => Promise<InstallPluginResult>;
rethrowUnexpectedErrors?: boolean;
}): Promise<
| { status: "timed_out" }
| {
status: "completed";
result: InstallPluginResult;
}
> {
}): Promise<InstallOutcome<InstallPluginResult>> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
const animated = createAnimatedInstallProgress(progress);
@@ -844,23 +828,23 @@ async function runOnboardingPluginInstallWithProgress(params: {
};
try {
const result = await withTimeout(
params.install({
info: updateProgress,
warn: (message) => {
updateProgress(message);
logInstallWarningWithSpacing(params.runtime, message);
const result = await runInstallWatchdog((signal) =>
params.install(
{
info: updateProgress,
warn: (message) => {
updateProgress(message);
logInstallWarningWithSpacing(params.runtime, message);
},
},
}),
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
signal,
),
);
animated.stop();
progress.stop(
result.ok ? formatPluginInstalled(safeLabel) : formatPluginInstallFailed(safeLabel),
);
return { status: "completed", result };
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(formatPluginInstallTimedOut(safeLabel));
return { status: "timed_out" };
@@ -888,16 +872,10 @@ async function installPluginFromNpmSpecWithProgress(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
trustedSourceLinkedOfficialInstall?: boolean;
}): Promise<
| { status: "timed_out" }
| {
status: "completed";
result: InstallPluginResult;
}
> {
}): Promise<InstallOutcome<InstallPluginResult>> {
return await runOnboardingPluginInstallWithProgress({
...params,
install: (logger) =>
install: (logger, signal) =>
installPluginFromNpmSpec({
spec: params.npmSpec,
mode: "update",
@@ -911,6 +889,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
: {}),
extensionsDir: resolveDefaultPluginExtensionsDir(),
logger,
signal,
}),
});
}
@@ -921,16 +900,10 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
archivePath: string;
prompter: WizardPrompter;
runtime: RuntimeEnv;
}): Promise<
| { status: "timed_out" }
| {
status: "completed";
result: InstallPluginResult & { npmTarballName?: string };
}
> {
}): Promise<InstallOutcome<InstallPluginResult & { npmTarballName?: string }>> {
return await runOnboardingPluginInstallWithProgress({
...params,
install: (logger) =>
install: (logger, signal) =>
installPluginFromNpmPackArchive({
archivePath: params.archivePath,
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
@@ -939,6 +912,7 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
expectedIntegrity: params.entry.install.expectedIntegrity,
extensionsDir: resolveDefaultPluginExtensionsDir(),
logger,
signal,
}),
// Archive overrides retain their existing unexpected-error contract.
rethrowUnexpectedErrors: true,
@@ -988,35 +962,15 @@ async function installPluginFromOverride(params: {
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(displaySpec)}`,
);
return {
cfg: params.cfg,
installed: false,
pluginId: entry.pluginId,
status: "timed_out",
};
return incompletePluginInstall(params.cfg, entry.pluginId, "timed_out");
}
const { result } = installOutcome;
if (!result.ok) {
const errorDetail = formatInstallErrorDetail(result.error);
await prompter.note(
[
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(displaySpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
t("wizard.plugins.installTitle"),
);
await notePluginInstallFailure(prompter, displaySpec, result.error);
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return {
cfg: params.cfg,
installed: false,
pluginId: entry.pluginId,
status: "failed",
error: errorDetail,
};
return incompletePluginInstall(params.cfg, entry.pluginId, "failed", errorDetail);
}
const npmTarballName =
@@ -1065,13 +1019,7 @@ async function installPluginFromClawHubSpecWithProgress(params: {
clawhubSpec: string;
prompter: WizardPrompter;
runtime: RuntimeEnv;
}): Promise<
| { status: "timed_out" }
| {
status: "completed";
result: InstallPluginFromClawHubResult;
}
> {
}): Promise<InstallPluginFromClawHubResult> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
const animated = createAnimatedInstallProgress(progress);
@@ -1091,49 +1039,46 @@ async function installPluginFromClawHubSpecWithProgress(params: {
try {
const { installPluginFromClawHub } = await import("../plugins/clawhub.js");
const result = await withTimeout(
installPluginFromClawHub({
spec: params.clawhubSpec,
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
config: params.cfg,
extensionsDir: resolveDefaultPluginExtensionsDir(),
expectedPluginId: params.entry.pluginId,
mode: "install",
logger: {
info: updateProgress,
warn: (message) => {
updateProgress(message);
if (isReviewRequiredClawHubTrustWarning(message)) {
return;
}
if (isClawHubTrustWarning(message)) {
renderTrustWarning(message);
return;
}
logInstallWarningWithSpacing(params.runtime, message);
},
},
onClawHubRisk: async (request) => {
animated.stop();
progress.stop("Review ClawHub warning");
renderTrustWarning(request.warning);
const packageName = sanitizeTerminalText(request.packageName);
const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`;
if (request.acknowledgementKind === "type-package") {
const answer = await params.prompter.text({
message: `To install anyway, type the package name for "${releaseLabel}"`,
placeholder: packageName,
});
return answer.trim() === packageName;
const result = await installPluginFromClawHub({
spec: params.clawhubSpec,
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
config: params.cfg,
extensionsDir: resolveDefaultPluginExtensionsDir(),
expectedPluginId: params.entry.pluginId,
mode: "install",
logger: {
info: updateProgress,
warn: (message) => {
updateProgress(message);
if (isReviewRequiredClawHubTrustWarning(message)) {
return;
}
return await params.prompter.confirm({
message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`,
initialValue: false,
});
if (isClawHubTrustWarning(message)) {
renderTrustWarning(message);
return;
}
logInstallWarningWithSpacing(params.runtime, message);
},
}),
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
);
},
onClawHubRisk: async (request) => {
animated.stop();
progress.stop("Review ClawHub warning");
renderTrustWarning(request.warning);
const packageName = sanitizeTerminalText(request.packageName);
const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`;
if (request.acknowledgementKind === "type-package") {
const answer = await params.prompter.text({
message: `To install anyway, type the package name for "${releaseLabel}"`,
placeholder: packageName,
});
return answer.trim() === packageName;
}
return await params.prompter.confirm({
message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`,
initialValue: false,
});
},
});
animated.stop();
const failureWarning = readInstallFailureWarning(result);
if (failureWarning && !renderedTrustWarning) {
@@ -1145,23 +1090,13 @@ async function installPluginFromClawHubSpecWithProgress(params: {
} else {
progress.stop(formatPluginInstallFailed(safeLabel));
}
return {
status: "completed",
result,
};
return result;
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(formatPluginInstallTimedOut(safeLabel));
return { status: "timed_out" };
}
progress.stop(formatPluginInstallFailed(safeLabel));
return {
status: "completed",
result: {
ok: false,
error: error instanceof Error ? error.message : String(error),
},
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
@@ -1185,13 +1120,15 @@ export async function ensureOnboardingPluginInstalled(params: {
// same write-mode check as normal installs.
assertConfigWriteAllowedInCurrentMode();
await params.beforePersistentEffect?.();
return await installPluginFromOverride({
cfg: next,
entry,
override: installOverride,
prompter,
runtime,
});
return await withPluginLifecycleLease({}, async () =>
installPluginFromOverride({
cfg: next,
entry,
override: installOverride,
prompter,
runtime,
}),
);
}
const allowLocal = hasGitWorkspace(workspaceDir);
const bundledLocalPath = entry.preferRemoteInstall
@@ -1253,203 +1190,12 @@ export async function ensureOnboardingPluginInstalled(params: {
});
if (choice === "skip") {
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "skipped",
};
return incompletePluginInstall(next, entry.pluginId, "skipped");
}
assertConfigWriteAllowedInCurrentMode();
if (choice === "local" && localPath) {
return await installLocalOnboardingPlugin({
cfg: next,
entry,
localPath,
bundledLocalPath,
npmSpec,
workspaceDir,
prompter,
runtime,
});
}
let shouldTryNpm = choice === "npm";
if (choice === "clawhub" && clawhubInstallSpec) {
await params.beforePersistentEffect?.();
const installOutcome = await installPluginFromClawHubSpecWithProgress({
cfg: next,
entry,
clawhubSpec: clawhubInstallSpec,
prompter,
runtime,
});
if (installOutcome.status === "timed_out") {
await prompter.note(
formatPluginInstallTimedOutNote(sanitizeTerminalText(clawhubInstallSpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`,
);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "timed_out",
};
}
const { result } = installOutcome;
if (result.ok) {
return await finishOnboardingPluginInstall({
cfg: next,
pluginId: result.pluginId,
label: entry.label,
prompter,
runtime,
install: {
pluginId: result.pluginId,
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
installPath: result.targetDir,
},
});
}
await prompter.note(
[
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(clawhubInstallSpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
t("wizard.plugins.installTitle"),
);
const errorDetail = formatInstallErrorDetail(result.error);
if (!npmInstallSpec || !shouldFallbackClawHubToNpm({ result, npmSpec: npmInstallSpec })) {
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "failed",
error: errorDetail,
};
}
// ClawHub package/version misses for official packages can recover through
// npm, but keep the operator in control before changing install source.
shouldTryNpm = await prompter.confirm({
message: t("wizard.plugins.useNpmPackageInstead", {
spec: sanitizeTerminalText(npmInstallSpec),
}),
initialValue: true,
});
if (!shouldTryNpm) {
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "failed",
error: errorDetail,
};
}
}
if (!shouldTryNpm || !npmInstallSpec) {
await prompter.note(
t("wizard.plugins.noRemoteInstallSource", {
plugin: sanitizeTerminalText(entry.label),
}),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "failed",
};
}
await params.beforePersistentEffect?.();
const installOutcome = await installPluginFromNpmSpecWithProgress({
cfg: next,
entry,
npmSpec: npmInstallSpec,
prompter,
runtime,
});
if (installOutcome.status === "timed_out") {
await prompter.note(
formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "timed_out",
};
}
const { result } = installOutcome;
if (result.ok) {
return await finishOnboardingPluginInstall({
cfg: next,
pluginId: result.pluginId,
label: entry.label,
prompter,
runtime,
install: {
pluginId: result.pluginId,
source: "npm",
spec: resolveNpmInstallRecordSpec({
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
resolution: result.npmResolution,
pinResolvedRegistrySpec: false,
}),
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
},
});
}
await prompter.note(
[
t("wizard.plugins.installFailed", {
spec: sanitizeTerminalText(npmInstallSpec),
error: summarizeInstallError(result.error),
}),
t("wizard.plugins.returningToSelection"),
].join("\n"),
t("wizard.plugins.installTitle"),
);
if (localPath) {
// If npm fails and a trusted local checkout exists, offer it as a recovery
// path instead of leaving setup stuck on the remote artifact.
const fallback = await prompter.confirm({
message: t("wizard.plugins.useLocalPluginPathInstead", {
path: sanitizeTerminalText(localPath),
}),
initialValue: true,
});
if (fallback) {
return await withPluginLifecycleLease({}, async () => {
if (choice === "local" && localPath) {
return await installLocalOnboardingPlugin({
cfg: next,
entry,
@@ -1461,16 +1207,140 @@ export async function ensureOnboardingPluginInstalled(params: {
runtime,
});
}
}
const errorDetail = formatInstallErrorDetail(result.error);
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return {
cfg: next,
installed: false,
pluginId: entry.pluginId,
status: "failed",
error: errorDetail,
};
let shouldTryNpm = choice === "npm";
if (choice === "clawhub" && clawhubInstallSpec) {
await params.beforePersistentEffect?.();
const result = await installPluginFromClawHubSpecWithProgress({
cfg: next,
entry,
clawhubSpec: clawhubInstallSpec,
prompter,
runtime,
});
if (result.ok) {
return await finishOnboardingPluginInstall({
cfg: next,
pluginId: result.pluginId,
label: entry.label,
prompter,
runtime,
install: {
pluginId: result.pluginId,
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
installPath: result.targetDir,
},
});
}
await notePluginInstallFailure(prompter, clawhubInstallSpec, result.error);
const errorDetail = formatInstallErrorDetail(result.error);
if (!npmInstallSpec || !shouldFallbackClawHubToNpm({ result, npmSpec: npmInstallSpec })) {
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
}
// ClawHub package/version misses for official packages can recover through
// npm, but keep the operator in control before changing install source.
shouldTryNpm = await prompter.confirm({
message: t("wizard.plugins.useNpmPackageInstead", {
spec: sanitizeTerminalText(npmInstallSpec),
}),
initialValue: true,
});
if (!shouldTryNpm) {
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
}
}
if (!shouldTryNpm || !npmInstallSpec) {
await prompter.note(
t("wizard.plugins.noRemoteInstallSource", {
plugin: sanitizeTerminalText(entry.label),
}),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
);
return incompletePluginInstall(next, entry.pluginId, "failed");
}
await params.beforePersistentEffect?.();
const installOutcome = await installPluginFromNpmSpecWithProgress({
cfg: next,
entry,
npmSpec: npmInstallSpec,
prompter,
runtime,
});
if (installOutcome.status === "timed_out") {
await prompter.note(
formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)),
t("wizard.plugins.installTitle"),
);
runtime.error?.(
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
);
return incompletePluginInstall(next, entry.pluginId, "timed_out");
}
const { result } = installOutcome;
if (result.ok) {
return await finishOnboardingPluginInstall({
cfg: next,
pluginId: result.pluginId,
label: entry.label,
prompter,
runtime,
install: {
pluginId: result.pluginId,
source: "npm",
spec: resolveNpmInstallRecordSpec({
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
resolution: result.npmResolution,
pinResolvedRegistrySpec: false,
}),
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
},
});
}
await notePluginInstallFailure(prompter, npmInstallSpec, result.error);
if (localPath) {
// If npm fails and a trusted local checkout exists, offer it as a recovery
// path instead of leaving setup stuck on the remote artifact.
const fallback = await prompter.confirm({
message: t("wizard.plugins.useLocalPluginPathInstead", {
path: sanitizeTerminalText(localPath),
}),
initialValue: true,
});
if (fallback) {
return await installLocalOnboardingPlugin({
cfg: next,
entry,
localPath,
bundledLocalPath,
npmSpec,
workspaceDir,
prompter,
runtime,
});
}
}
const errorDetail = formatInstallErrorDetail(result.error);
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+9 -1
View File
@@ -378,7 +378,13 @@ describe("packNpmSpecToArchive", () => {
]),
});
const result = await runPack("openclaw-plugin@1.2.3", cwd);
const signal = new AbortController().signal;
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 1000,
cwd,
signal,
});
expect(result).toEqual({
ok: true,
@@ -396,6 +402,8 @@ describe("packNpmSpecToArchive", () => {
{
cwd,
timeoutMs: 300_000,
signal,
killProcessTree: true,
env: {
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
+13 -1
View File
@@ -122,7 +122,11 @@ function normalizeNpmViewMetadata(value: unknown, spec: string): NpmSpecResoluti
/** Reads npm registry metadata for a package spec without running package scripts. */
type NpmMetadataFailureCategory = "metadata-env";
export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?: number }): Promise<
export async function resolveNpmSpecMetadata(params: {
spec: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<
| {
ok: true;
metadata: NpmSpecResolution;
@@ -147,6 +151,8 @@ export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?:
],
{
timeoutMs: Math.max(params.timeoutMs ?? 60_000, 60_000),
signal: params.signal,
killProcessTree: true,
env: createNpmMetadataEnv(),
},
);
@@ -344,6 +350,7 @@ export async function packNpmSpecToArchive(params: {
spec: string;
timeoutMs: number;
cwd: string;
signal?: AbortSignal;
}): Promise<
| {
ok: true;
@@ -359,6 +366,8 @@ export async function packNpmSpecToArchive(params: {
["npm", "pack", params.spec, "--ignore-scripts", "--json"],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
signal: params.signal,
killProcessTree: true,
cwd: params.cwd,
env: createNpmMetadataEnv({ npmConfigCwd: params.cwd }),
},
@@ -407,6 +416,7 @@ export async function packNpmSpecToArchive(params: {
export async function resolveNpmPackArchiveMetadata(params: {
archivePath: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<
| {
ok: true;
@@ -431,6 +441,8 @@ export async function resolveNpmPackArchiveMetadata(params: {
["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"],
{
timeoutMs: Math.max(params.timeoutMs ?? archiveMetadataTimeoutMs, archiveMetadataTimeoutMs),
signal: params.signal,
killProcessTree: true,
env: createNpmMetadataEnv(),
},
);
+8
View File
@@ -747,6 +747,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
npmRoot: string;
runCommand?: ManagedNpmRootRunCommand;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<Record<string, string>> {
const manifest = await readManagedNpmRootManifest(path.join(params.npmRoot, "package.json"));
const dependencies = readDependencyRecord(manifest.dependencies);
@@ -787,6 +788,8 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
const npmPlanOptions = {
cwd: tempRoot,
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
signal: params.signal,
killProcessTree: true,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: false,
npmConfigCwd: tempRoot,
@@ -900,6 +903,7 @@ export async function syncManagedNpmRootPeerDependencies(params: {
overrideOmissions?: ManagedNpmOverrideOmissions;
runCommand?: ManagedNpmRootRunCommand;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<boolean> {
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
@@ -910,6 +914,7 @@ export async function syncManagedNpmRootPeerDependencies(params: {
npmRoot: params.npmRoot,
runCommand: params.runCommand,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
const managedPeerDependencyNames = new Set(
Object.keys(peerPins).filter(
@@ -977,6 +982,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
npmRoot: string;
packageRoot?: string | null;
timeoutMs?: number;
signal?: AbortSignal;
logger?: ManagedNpmRootLogger;
runCommand?: ManagedNpmRootRunCommand;
}): Promise<boolean> {
@@ -1034,6 +1040,8 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
const result = await command(npmArgs, {
cwd: params.npmRoot,
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
signal: params.signal,
killProcessTree: true,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: params.npmRoot,
+7
View File
@@ -84,6 +84,7 @@ export async function installPluginFromManagedNpmRoot(
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
@@ -170,6 +171,7 @@ export async function installPluginFromManagedNpmRoot(
...(params.integrityDrift ? { integrityDrift: params.integrityDrift } : {}),
};
}
params.signal?.throwIfAborted();
let rollbackSnapshot: ManagedNpmPluginInstallRollbackSnapshot;
let preparedDependency: ManagedNpmRootPreparedDependency | undefined;
@@ -199,6 +201,7 @@ export async function installPluginFromManagedNpmRoot(
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
timeoutMs,
signal: params.signal,
logger,
});
if (repairedOpenClawPeer) {
@@ -256,6 +259,7 @@ export async function installPluginFromManagedNpmRoot(
managedOverrides,
overrideOmissions: options?.overrideOmissions,
timeoutMs,
signal: params.signal,
}),
};
} catch (error) {
@@ -294,6 +298,8 @@ export async function installPluginFromManagedNpmRoot(
const npmInstallOptions = {
cwd: npmRoot,
timeoutMs: Math.max(timeoutMs, 300_000),
signal: params.signal,
killProcessTree: true,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: npmRoot,
@@ -488,6 +494,7 @@ export async function installPluginFromManagedNpmRoot(
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
timeoutMs,
signal: params.signal,
logger,
});
if (repairedOpenClawPeer) {
+10
View File
@@ -46,11 +46,14 @@ type TrustedOfficialPrereleaseResolution =
async function loadNpmPackageVersions(params: {
packageName: string;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<string[] | null> {
const versions = await runCommandWithTimeout(
["npm", "view", params.packageName, "versions", "--json"],
{
timeoutMs: Math.max(params.timeoutMs, 60_000),
signal: params.signal,
killProcessTree: true,
env: createNpmMetadataEnv(),
},
);
@@ -73,6 +76,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
spec: ParsedRegistryNpmSpec;
resolvedPrereleaseVersion: string;
timeoutMs: number;
signal?: AbortSignal;
logger: PluginInstallLogger;
}): Promise<TrustedOfficialPrereleaseResolution | null> {
if (!params.spec.name.startsWith("@openclaw/")) {
@@ -81,6 +85,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
const semverVersions = await loadNpmPackageVersions({
packageName: params.spec.name,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!semverVersions) {
return null;
@@ -100,6 +105,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
const metadataResult = await resolveNpmSpecMetadata({
spec: prereleaseSpec,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!metadataResult.ok) {
return null;
@@ -121,6 +127,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
const metadataResult = await resolveNpmSpecMetadata({
spec: stableSpec,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!metadataResult.ok) {
return null;
@@ -187,6 +194,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
expectedPluginId?: string;
currentResolution: NpmSpecResolution;
timeoutMs: number;
signal?: AbortSignal;
logger: PluginInstallLogger;
}): Promise<NpmSpecResolution | null> {
if (!params.currentResolution.version) {
@@ -207,6 +215,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
const versions = await loadNpmPackageVersions({
packageName: params.parsedSpec.name,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!versions) {
return null;
@@ -226,6 +235,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
const metadataResult = await resolveNpmSpecMetadata({
spec,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!metadataResult.ok) {
params.logger.warn?.(
+3
View File
@@ -161,6 +161,7 @@ export async function installPluginFromNpmPackArchive(
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
@@ -177,6 +178,7 @@ export async function installPluginFromNpmPackArchive(
const metadataResult = await resolveNpmPackArchiveMetadata({
archivePath: params.archivePath,
timeoutMs,
signal: params.signal,
});
if (!metadataResult.ok) {
return metadataResult;
@@ -267,6 +269,7 @@ export async function installPluginFromNpmPackArchive(
extensionsDir: params.extensionsDir,
npmDir: npmBaseDir,
timeoutMs,
signal: params.signal,
logger,
mode,
dryRun,
+5 -1
View File
@@ -49,6 +49,7 @@ export async function installPluginFromNpmSpec(
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
@@ -83,7 +84,7 @@ export async function installPluginFromNpmSpec(
};
}
const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs });
const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs, signal: params.signal });
if (!metadataResult.ok) {
return {
ok: false,
@@ -111,6 +112,7 @@ export async function installPluginFromNpmSpec(
spec: parsedSpec,
resolvedPrereleaseVersion: npmResolution.version,
timeoutMs,
signal: params.signal,
logger,
})
: null;
@@ -143,6 +145,7 @@ export async function installPluginFromNpmSpec(
expectedPluginId,
currentResolution: npmResolution,
timeoutMs,
signal: params.signal,
logger,
});
if (compatibleResolution) {
@@ -265,6 +268,7 @@ export async function installPluginFromNpmSpec(
extensionsDir: params.extensionsDir,
npmDir: params.npmDir,
timeoutMs,
signal: params.signal,
logger,
mode,
dryRun,
+3 -3
View File
@@ -113,7 +113,7 @@ describe("setup migration import options", () => {
);
});
it("offers official installable Codex when bundled plugins are unavailable", async () => {
it("does not offer install-only providers during a transactional import", async () => {
const previousDisableBundled = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
try {
@@ -122,8 +122,8 @@ describe("setup migration import options", () => {
detections: [],
});
expect(options).toEqual(
expect.arrayContaining([expect.objectContaining({ providerId: "codex", label: "Codex" })]),
expect(options).not.toEqual(
expect.arrayContaining([expect.objectContaining({ providerId: "codex" })]),
);
} finally {
if (previousDisableBundled === undefined) {
+11 -88
View File
@@ -1,8 +1,4 @@
import type { OnboardOptions } from "../commands/onboard-types.js";
import {
ensureOnboardingPluginInstalled,
type OnboardingPluginInstallEntry,
} from "../commands/onboarding-plugin-install.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -10,13 +6,6 @@ import {
listAvailableManifestContractPlugins,
loadManifestContractSnapshot,
} from "../plugins/manifest-contract-eligibility.js";
import {
getOfficialExternalPluginCatalogManifest,
listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginLabel,
} from "../plugins/official-external-plugin-catalog.js";
import type {
MigrationPlan,
MigrationProviderContext,
@@ -59,11 +48,6 @@ type SetupMigrationOption = {
label: string;
hint?: string;
};
type InstallableSetupMigrationProvider = {
providerId: string;
entry: OnboardingPluginInstallEntry;
description?: string;
};
type ManifestSetupMigrationProvider = {
providerId: string;
label: string;
@@ -138,31 +122,6 @@ function resolveImportSourceDefault(params: {
return params.providerId === "hermes" ? "~/.hermes" : "";
}
function resolveInstallableSetupMigrationProviders(): InstallableSetupMigrationProvider[] {
const providers: InstallableSetupMigrationProvider[] = [];
for (const catalogEntry of listOfficialExternalPluginCatalogEntries()) {
const manifest = getOfficialExternalPluginCatalogManifest(catalogEntry);
const pluginId = resolveOfficialExternalPluginId(catalogEntry);
const install = resolveOfficialExternalPluginInstall(catalogEntry);
if (!pluginId || !install) {
continue;
}
for (const providerId of manifest?.contracts?.migrationProviders ?? []) {
providers.push({
providerId,
entry: {
pluginId,
label: resolveOfficialExternalPluginLabel(catalogEntry),
install,
trustedSourceLinkedOfficialInstall: true,
},
...(catalogEntry.description ? { description: catalogEntry.description } : {}),
});
}
}
return providers;
}
function formatMigrationProviderId(providerId: string): string {
return providerId
.split(/[-_]+/)
@@ -240,13 +199,6 @@ export async function listSetupMigrationOptions(params: {
hint: provider.description ?? t("wizard.migration.sourcePathHint"),
});
}
for (const provider of resolveInstallableSetupMigrationProviders()) {
addOption({
providerId: provider.providerId,
label: provider.entry.label,
hint: provider.description ?? t("wizard.migration.sourcePathHint"),
});
}
return options;
}
@@ -261,11 +213,17 @@ async function selectSetupMigrationProvider(params: {
baseConfig: params.baseConfig,
detections: params.detections,
});
const requestedProviderId = params.opts.importFrom?.trim();
if (requestedProviderId && !options.some((option) => option.providerId === requestedProviderId)) {
throw new Error(
`Migration provider "${requestedProviderId}" is not installed or bundled. Install it before starting the transactional import.`,
);
}
if (options.length === 0) {
throw new Error("No migration providers found.");
}
const providerId =
params.opts.importFrom?.trim() ||
requestedProviderId ||
(await params.prompter.select({
message: t("wizard.migration.source"),
options: options.map((option) => ({
@@ -276,7 +234,9 @@ async function selectSetupMigrationProvider(params: {
initialValue: params.detections[0]?.providerId ?? options[0]?.providerId,
}));
if (!options.some((option) => option.providerId === providerId)) {
throw new Error(`Unknown migration provider "${providerId}".`);
throw new Error(
`Migration provider "${providerId}" is not installed or bundled. Install it before starting the transactional import.`,
);
}
return providerId;
}
@@ -284,9 +244,6 @@ async function selectSetupMigrationProvider(params: {
async function resolveSetupMigrationProvider(params: {
providerId: string;
baseConfig: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir: string;
}): Promise<{ provider: MigrationProviderPlugin; baseConfig: OpenClawConfig }> {
const { ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProvider } =
await loadMigrationProviderRuntimeModule();
@@ -301,35 +258,7 @@ async function resolveSetupMigrationProvider(params: {
if (existing) {
return { provider: existing, baseConfig: params.baseConfig };
}
const installable = resolveInstallableSetupMigrationProviders().find(
(provider) => provider.providerId === params.providerId,
);
if (!installable) {
throw new Error(`Unknown migration provider "${params.providerId}".`);
}
const result = await ensureOnboardingPluginInstalled({
cfg: params.baseConfig,
entry: installable.entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
promptInstall: false,
});
if (!result.installed) {
throw new Error(`Could not install migration provider "${params.providerId}".`);
}
ensureStandaloneMigrationProviderRegistryLoaded({
cfg: result.cfg,
providerId: params.providerId,
});
const provider = resolvePluginMigrationProvider({
providerId: params.providerId,
cfg: result.cfg,
});
if (!provider) {
throw new Error(`Installed plugin did not register migration provider "${params.providerId}".`);
}
return { provider, baseConfig: result.cfg };
throw new Error(`Migration provider "${params.providerId}" did not register after activation.`);
}
function hasCredentialCandidate(plan: MigrationPlan): boolean {
@@ -416,9 +345,6 @@ export async function runSetupMigrationImport(params: {
const resolvedProvider = await resolveSetupMigrationProvider({
providerId,
baseConfig: committedConfig,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: promotionResume.continuation.workspaceDir,
});
assertDeferredMigrationApplyContract(
resolvedProvider.provider,
@@ -447,9 +373,6 @@ export async function runSetupMigrationImport(params: {
const resolvedProvider = await resolveSetupMigrationProvider({
providerId,
baseConfig: lockedBaseConfig,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir,
});
const planningBaseConfig = await params.readConfigFile();
const planningTargetSnapshotHash = await buildSetupMigrationTargetSnapshot({
+16 -6
View File
@@ -1,4 +1,5 @@
// Setup migration snapshots bind retries to unchanged source and target state.
import { AsyncLocalStorage } from "node:async_hooks";
import crypto from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
@@ -10,11 +11,12 @@ import type { MigrationPlan } from "../plugins/types.js";
import { resolveUserPath } from "../utils.js";
import { canonicalizeSetupMigrationValue } from "./setup.migration-canonical.js";
const SETUP_MIGRATION_LOCK_OPTIONS = {
retries: { retries: 60, factor: 1, minTimeout: 500, maxTimeout: 500 },
const ONBOARDING_TARGET_LOCK_OPTIONS = {
retries: { retries: 1_200, factor: 1, minTimeout: 500, maxTimeout: 500 },
stale: 30 * 60 * 1000,
staleRecovery: "remove-if-unchanged" as const,
};
const activeSetupMigrationTargetLock = new AsyncLocalStorage<string>();
const MEANINGFUL_CONFIG_IGNORED_KEYS = new Set(["$schema", "meta"]);
const MEANINGFUL_WIZARD_CONFIG_IGNORED_KEYS = new Set(["securityAcknowledgedAt"]);
const MEANINGFUL_WORKSPACE_ENTRIES = [
@@ -301,17 +303,25 @@ export async function prepareSetupMigrationAttemptBoundary(params: {
};
}
/** Serializes all onboarding migration writes that share one OpenClaw state target. */
/** Serializes onboarding writes that share one OpenClaw state target. */
export async function withSetupMigrationTargetLock<T>(
stateDir: string,
fn: () => Promise<T>,
): Promise<T> {
const migrationDir = path.join(stateDir, "migration");
const resolvedStateDir = path.resolve(stateDir);
const activeStateDir = activeSetupMigrationTargetLock.getStore();
if (activeStateDir) {
if (activeStateDir !== resolvedStateDir) {
throw new Error("nested onboarding target lock cannot switch the OpenClaw state directory");
}
return await fn();
}
const migrationDir = path.join(resolvedStateDir, "migration");
await fs.mkdir(migrationDir, { recursive: true, mode: 0o700 });
return await withFileLock(
path.join(migrationDir, "onboarding.lock-target"),
SETUP_MIGRATION_LOCK_OPTIONS,
fn,
ONBOARDING_TARGET_LOCK_OPTIONS,
async () => await activeSetupMigrationTargetLock.run(resolvedStateDir, fn),
);
}