From da2684b890dcad57d18cc8edba652c328070484b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 00:49:25 -0700 Subject: [PATCH] fix: serialize onboarding and plugin installation (#121482) Co-authored-by: Amp --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- src/commands/onboard-helpers.test.ts | 41 +- src/commands/onboard-helpers.ts | 28 +- .../onboard-non-interactive.gateway.test.ts | 134 +++- src/commands/onboard-non-interactive.ts | 48 +- src/commands/onboard.test.ts | 14 +- src/commands/onboard.ts | 145 ++-- .../onboarding-plugin-install.test.ts | 58 +- src/commands/onboarding-plugin-install.ts | 692 +++++++----------- src/infra/install-source-utils.test.ts | 10 +- src/infra/install-source-utils.ts | 14 +- src/infra/npm-managed-root.ts | 8 + src/plugins/install-managed-npm.ts | 7 + src/plugins/install-npm-metadata.ts | 10 + src/plugins/install-npm-pack.ts | 3 + src/plugins/install-npm.ts | 6 +- src/wizard/setup.migration-import.test.ts | 6 +- src/wizard/setup.migration-import.ts | 99 +-- src/wizard/setup.migration-snapshot.ts | 22 +- 19 files changed, 732 insertions(+), 615 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index da44d0a2ba62..1ff1b459e88b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 870dfe00a8ab..7312cd72ef4a 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -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()), 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"); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 9a5295fe783f..c269c1d1fc67 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -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 { + 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; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index b92bb1663be3..d69f5f6cf507 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -15,6 +15,9 @@ import type { installGatewayDaemonNonInteractive } from "./onboard-non-interacti const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); const testConfigStore = new Map(); +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 })); +const configWritePluginLeaseDepths: number[] = []; type InstallGatewayDaemonResult = Awaited>; const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async (): Promise => ({ installed: true })), @@ -52,33 +55,59 @@ function readTestConfig(): 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, + ) => { + 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((resolve) => { + ensureWorkspaceAndSessionsMock.mockImplementation(async () => { + workspaceSetupCalls += 1; + activeWorkspaceSetups += 1; + maxActiveWorkspaceSetups = Math.max(maxActiveWorkspaceSetups, activeWorkspaceSetups); + try { + if (workspaceSetupCalls === 1) { + resolve(); + await new Promise((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((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( diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 8addc93ec6b3..512407253be9 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -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), + ), + ); + }); +} diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 9a0be279b121..894ffda07179 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -79,6 +79,9 @@ const mocks = vi.hoisted(() => ({ config: {}, })), handleReset: vi.fn(async () => {}), + withSetupMigrationTargetLock: vi.fn( + async (_stateDir: string, run: () => Promise) => 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()), 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); }); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 1ba6e4e08aba..0a6c4f68d7df 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -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); } diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 8ed385aaf5b1..18b4442bc0e3 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -112,6 +112,13 @@ vi.mock("../plugins/installed-plugin-index-records.js", () => ({ clearLoadInstalledPluginIndexInstallRecordsCache, })); +const withPluginLifecycleLease = vi.hoisted(() => + vi.fn(async (_options: unknown, run: () => Promise) => await run()), +); +vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({ + withPluginLifecycleLease, +})); + const withTimeout = vi.hoisted(() => vi.fn(async (promise: Promise) => await promise)); vi.mock("../utils/with-timeout.js", () => ({ withTimeout, @@ -308,6 +315,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(progress).toHaveBeenCalledWith("正在安装 Demo Plugin 插件..."); expect(note).toHaveBeenCalledWith("无法启用 Demo Plugin:blocked 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((resolve) => { + releaseCleanup = resolve; + }); + let observeAbort = () => {}; + const abortObserved = new Promise((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((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, diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 1ff889049489..b2109fc90f39 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -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 = { 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, + 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 { 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 ? / 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 { 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/` 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 { + 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[1]; + prepareConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }): Promise { 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 { - 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(install: (signal: AbortSignal) => Promise): Promise { + 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; + install: ( + logger: { + info: (message: string) => void; + warn: (message: string) => void; + }, + signal: AbortSignal, + ) => Promise; rethrowUnexpectedErrors?: boolean; -}): Promise< - | { status: "timed_out" } - | { - status: "completed"; - result: InstallPluginResult; - } -> { +}): Promise> { 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> { 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> { 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 { 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. */ diff --git a/src/infra/install-source-utils.test.ts b/src/infra/install-source-utils.test.ts index 5052922350e3..758deb846e9b 100644 --- a/src/infra/install-source-utils.test.ts +++ b/src/infra/install-source-utils.test.ts @@ -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", diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index bc0c2a2652cd..9e339af65083 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -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(), }, ); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index d881cd12bfed..b8450aeed787 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -747,6 +747,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: { npmRoot: string; runCommand?: ManagedNpmRootRunCommand; timeoutMs?: number; + signal?: AbortSignal; }): Promise> { 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 { 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 { @@ -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, diff --git a/src/plugins/install-managed-npm.ts b/src/plugins/install-managed-npm.ts index 3e733c838e66..58dd0185a64b 100644 --- a/src/plugins/install-managed-npm.ts +++ b/src/plugins/install-managed-npm.ts @@ -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) { diff --git a/src/plugins/install-npm-metadata.ts b/src/plugins/install-npm-metadata.ts index 76d414e20a79..aaf8ffee9ca7 100644 --- a/src/plugins/install-npm-metadata.ts +++ b/src/plugins/install-npm-metadata.ts @@ -46,11 +46,14 @@ type TrustedOfficialPrereleaseResolution = async function loadNpmPackageVersions(params: { packageName: string; timeoutMs: number; + signal?: AbortSignal; }): Promise { 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 { 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 { 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?.( diff --git a/src/plugins/install-npm-pack.ts b/src/plugins/install-npm-pack.ts index 561db25bbc44..8494e92285e8 100644 --- a/src/plugins/install-npm-pack.ts +++ b/src/plugins/install-npm-pack.ts @@ -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, diff --git a/src/plugins/install-npm.ts b/src/plugins/install-npm.ts index 9cf151544424..67d9d93c58f7 100644 --- a/src/plugins/install-npm.ts +++ b/src/plugins/install-npm.ts @@ -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, diff --git a/src/wizard/setup.migration-import.test.ts b/src/wizard/setup.migration-import.test.ts index ec94e55ae33d..142ec3089c62 100644 --- a/src/wizard/setup.migration-import.test.ts +++ b/src/wizard/setup.migration-import.test.ts @@ -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) { diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index d5fdf794b490..b5b85c552099 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -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({ diff --git a/src/wizard/setup.migration-snapshot.ts b/src/wizard/setup.migration-snapshot.ts index 264ebdef50ce..f1921ea4efde 100644 --- a/src/wizard/setup.migration-snapshot.ts +++ b/src/wizard/setup.migration-snapshot.ts @@ -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(); 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( stateDir: string, fn: () => Promise, ): Promise { - 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), ); }