fix: propagate update timeout to plugin installs

This commit is contained in:
Peter Steinberger
2026-04-26 10:44:58 +01:00
parent d1f40731e3
commit b67d9bf7f0
6 changed files with 99 additions and 6 deletions

View File

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

View File

@@ -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<UpdateRunResult> {
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<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
timeoutMs: number;
}): Promise<PostCorePluginUpdateResult> {
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<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
timeoutMs: number;
}): Promise<PostCorePluginUpdateResult> {
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<void> {
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<void> {
channel: postCoreUpdateChannel,
configSnapshot: await readConfigFileSnapshot(),
opts,
timeoutMs: updateStepTimeoutMs,
});
if (opts.json) {
await writePostCorePluginUpdateResultFile(
@@ -1146,7 +1156,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
mode = await resolveGlobalManager({
root,
installKind,
timeoutMs: timeoutMs ?? 20 * 60_000,
timeoutMs: updateStepTimeoutMs,
});
}
@@ -1271,7 +1281,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
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<void> {
channel,
configSnapshot: postUpdateConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
});
}

View File

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

View File

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

View File

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

View File

@@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: {
logger?: PluginUpdateLogger;
pluginIds?: string[];
skipIds?: Set<string>;
timeoutMs?: number;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
@@ -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,