From b67d9bf7f06f4c655b1e0869395056c7d631abc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:44:58 +0100 Subject: [PATCH] fix: propagate update timeout to plugin installs --- src/cli/update-cli.test.ts | 20 ++++++++++-- src/cli/update-cli/update-command.ts | 17 ++++++++-- src/plugins/clawhub.ts | 7 ++++ src/plugins/marketplace.ts | 1 + src/plugins/update.test.ts | 48 ++++++++++++++++++++++++++++ src/plugins/update.ts | 12 ++++++- 6 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 78fe06baa73..a52affc945c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -524,11 +524,11 @@ describe("update-cli", () => { it("respawns into the updated package root before running post-update tasks", async () => { const { entrypoints } = setupUpdatedRootRefresh(); - await updateCommand({ yes: true }); + await updateCommand({ yes: true, timeout: "1800" }); expect(spawn).toHaveBeenCalledWith( expect.stringMatching(/node/), - [entrypoints[0], "update", "--yes"], + [entrypoints[0], "update", "--yes", "--timeout", "1800"], expect.objectContaining({ stdio: "inherit", env: expect.objectContaining({ @@ -625,6 +625,22 @@ describe("update-cli", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("passes the update timeout budget into post-core plugin updates", async () => { + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + }, + async () => { + await updateCommand({ restart: false, timeout: "1800" }); + }, + ); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 1_800_000 }), + ); + }); + it("uses a fail-closed integrity policy for post-core plugin updates", async () => { await withEnvAsync( { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 9336cd13db6..9c911ab05c7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -89,6 +89,7 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; +const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 20 * 60_000; const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; @@ -455,7 +456,7 @@ async function runGitUpdate(params: { devTargetRef?: string; }): Promise { const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root; - const effectiveTimeout = params.timeoutMs ?? 20 * 60_000; + const effectiveTimeout = params.timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; const installEnv = await createGlobalInstallEnv(); const cloneStep = params.switchToGit @@ -537,6 +538,7 @@ async function updatePluginsAfterCoreUpdate(params: { channel: "stable" | "beta" | "dev"; configSnapshot: Awaited>; opts: UpdateCommandOptions; + timeoutMs: number; }): Promise { if (!params.configSnapshot.valid) { if (!params.opts.json) { @@ -589,6 +591,7 @@ async function updatePluginsAfterCoreUpdate(params: { const npmResult = await updateNpmInstalledPlugins({ config: pluginConfig, + timeoutMs: params.timeoutMs, skipIds: new Set(syncResult.summary.switchedToNpm), logger: pluginLogger, onIntegrityDrift: async (drift) => { @@ -896,12 +899,14 @@ async function runPostCorePluginUpdate(params: { channel: "stable" | "beta" | "dev"; configSnapshot: Awaited>; opts: UpdateCommandOptions; + timeoutMs: number; }): Promise { return await updatePluginsAfterCoreUpdate({ root: params.root, channel: params.channel, configSnapshot: params.configSnapshot, opts: params.opts, + timeoutMs: params.timeoutMs, }); } @@ -954,6 +959,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.yes) { argv.push("--yes"); } + if (params.opts.timeout) { + argv.push("--timeout", params.opts.timeout); + } const resultDir = params.opts.json === true ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")) @@ -1018,6 +1026,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (timeoutMs === null) { return; } + const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; const root = await resolveUpdateRoot(); if (postCoreUpdateResume) { @@ -1036,6 +1045,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { channel: postCoreUpdateChannel, configSnapshot: await readConfigFileSnapshot(), opts, + timeoutMs: updateStepTimeoutMs, }); if (opts.json) { await writePostCorePluginUpdateResultFile( @@ -1146,7 +1156,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { mode = await resolveGlobalManager({ root, installKind, - timeoutMs: timeoutMs ?? 20 * 60_000, + timeoutMs: updateStepTimeoutMs, }); } @@ -1271,7 +1281,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { root, installKind, tag, - timeoutMs: timeoutMs ?? 20 * 60_000, + timeoutMs: updateStepTimeoutMs, startedAt, progress, jsonMode: Boolean(opts.json), @@ -1414,6 +1424,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { channel, configSnapshot: postUpdateConfigSnapshot, opts, + timeoutMs: updateStepTimeoutMs, }); } diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 3fe8bfba624..d2e53a03fc2 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -594,6 +594,7 @@ async function resolveCompatiblePackageVersion(params: { requestedVersion?: string; baseUrl?: string; token?: string; + timeoutMs?: number; }): Promise< | { ok: true; @@ -617,6 +618,7 @@ async function resolveCompatiblePackageVersion(params: { version: requestedVersion, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return mapClawHubRequestError(error, { @@ -747,6 +749,7 @@ export async function installPluginFromClawHub( logger?: PluginInstallLogger; mode?: "install" | "update"; extensionsDir?: string; + timeoutMs?: number; dryRun?: boolean; expectedPluginId?: string; }, @@ -775,6 +778,7 @@ export async function installPluginFromClawHub( name: parsed.name, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return mapClawHubRequestError(error, { @@ -787,6 +791,7 @@ export async function installPluginFromClawHub( requestedVersion: parsed.version, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); if (!versionState.ok) { return versionState; @@ -821,6 +826,7 @@ export async function installPluginFromClawHub( version: versionState.version, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return buildClawHubInstallFailure(formatErrorMessage(error)); @@ -864,6 +870,7 @@ export async function installPluginFromClawHub( logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, + timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 7442aedb73c..68776355ab9 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -1156,6 +1156,7 @@ export async function installPluginFromMarketplace( logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, + timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 69c9995b448..8b99e90f2bf 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -214,12 +214,14 @@ function expectNpmUpdateCall(params: { spec: string; expectedIntegrity?: string; expectedPluginId?: string; + timeoutMs?: number; }) { expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( expect.objectContaining({ spec: params.spec, expectedIntegrity: params.expectedIntegrity, ...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}), + ...(params.timeoutMs ? { timeoutMs: params.timeoutMs } : {}), }), ); } @@ -355,6 +357,48 @@ describe("updateNpmInstalledPlugins", () => { }, ); + it("passes timeout budget to npm plugin metadata checks and installs", async () => { + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + mockNpmViewMetadata({ + name: "@martian-engineering/lossless-claw", + version: "0.10.0", + integrity: "sha512-next", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "lossless-claw", + targetDir: installPath, + version: "0.10.0", + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "lossless-claw", + spec: "@martian-engineering/lossless-claw", + installPath, + resolvedName: "@martian-engineering/lossless-claw", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + resolvedVersion: "0.9.0", + }), + pluginIds: ["lossless-claw"], + timeoutMs: 1_800_000, + }); + + const npmViewCall = runCommandWithTimeoutMock.mock.calls.find( + ([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "view", + ); + expect(npmViewCall?.[1]).toEqual(expect.objectContaining({ timeoutMs: 1_800_000 })); + expectNpmUpdateCall({ + spec: "@martian-engineering/lossless-claw", + expectedPluginId: "lossless-claw", + timeoutMs: 1_800_000, + }); + }); + it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => { const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", @@ -798,6 +842,7 @@ describe("updateNpmInstalledPlugins", () => { clawhubChannel: "official", }), pluginIds: ["demo"], + timeoutMs: 1_800_000, }); expect(installPluginFromClawHubMock).toHaveBeenCalledWith( @@ -806,6 +851,7 @@ describe("updateNpmInstalledPlugins", () => { baseUrl: "https://clawhub.ai", expectedPluginId: "demo", mode: "update", + timeoutMs: 1_800_000, }), ); expect(result.config.plugins?.installs?.demo).toMatchObject({ @@ -930,6 +976,7 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }), pluginIds: ["claude-bundle"], + timeoutMs: 1_800_000, dryRun: true, }); @@ -939,6 +986,7 @@ describe("updateNpmInstalledPlugins", () => { plugin: "claude-bundle", expectedPluginId: "claude-bundle", dryRun: true, + timeoutMs: 1_800_000, }), ); expect(result.outcomes).toEqual([ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 226844485a7..c6fcb78ba3e 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: { logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; + timeoutMs?: number; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; @@ -567,7 +568,10 @@ export async function updateNpmInstalledPlugins(params: { }); if (!params.dryRun && record.source === "npm" && currentVersion) { - const metadataResult = await resolveNpmSpecMetadata({ spec: effectiveSpec! }); + const metadataResult = await resolveNpmSpecMetadata({ + spec: effectiveSpec!, + timeoutMs: params.timeoutMs, + }); if (metadataResult.ok) { if ( shouldSkipUnchangedNpmInstall({ @@ -604,6 +608,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -622,6 +627,7 @@ export async function updateNpmInstalledPlugins(params: { baseUrl: record.clawhubUrl, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -632,6 +638,7 @@ export async function updateNpmInstalledPlugins(params: { plugin: record.marketplacePlugin!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -708,6 +715,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, expectedIntegrity, @@ -725,6 +733,7 @@ export async function updateNpmInstalledPlugins(params: { baseUrl: record.clawhubUrl, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, @@ -734,6 +743,7 @@ export async function updateNpmInstalledPlugins(params: { plugin: record.marketplacePlugin!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger,