From 65f92fd83915a6d01fb2a7e1ef379630bcc0d1ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 18:09:01 -0400 Subject: [PATCH] Guard updater service refresh against missing invocation cwd (#45486) * Update: capture a stable cwd for service refresh env * Test: cover service refresh when cwd disappears --- src/cli/update-cli.test.ts | 48 ++++++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 21 ++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 34ca4efaa87..f2138215327 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -668,6 +668,54 @@ describe("update-cli", () => { expect(runDaemonInstall).not.toHaveBeenCalled(); }); + it("updateCommand reuses the captured invocation cwd when process.cwd later fails", async () => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + + const originalCwd = process.cwd(); + let restoreCwd: (() => void) | undefined; + vi.mocked(runGatewayUpdate).mockImplementation(async () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: current working directory is gone"); + }); + restoreCwd = () => cwdSpy.mockRestore(); + return { + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }; + }); + serviceLoaded.mockResolvedValue(true); + + try { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + }, + async () => { + await updateCommand({}); + }, + ); + } finally { + restoreCwd?.(); + } + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve(originalCwd, "./state"), + }), + timeoutMs: 60_000, + }), + ); + expect(runDaemonInstall).not.toHaveBeenCalled(); + }); + it("updateCommand falls back to restart when env refresh install fails", async () => { await runRestartFallbackScenario({ daemonInstall: "fail" }); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d0d39e0215a..b94fbd4ffb9 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -124,9 +124,17 @@ function formatCommandFailure(stdout: string, stderr: string): string { return detail.split("\n").slice(-3).join("\n"); } +function tryResolveInvocationCwd(): string | undefined { + try { + return process.cwd(); + } catch { + return undefined; + } +} + function resolveServiceRefreshEnv( env: NodeJS.ProcessEnv, - invocationCwd: string = process.cwd(), + invocationCwd?: string, ): NodeJS.ProcessEnv { const resolvedEnv: NodeJS.ProcessEnv = { ...env }; for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) { @@ -138,6 +146,10 @@ function resolveServiceRefreshEnv( resolvedEnv[key] = rawValue; continue; } + if (!invocationCwd) { + resolvedEnv[key] = rawValue; + continue; + } resolvedEnv[key] = path.resolve(invocationCwd, rawValue); } return resolvedEnv; @@ -205,6 +217,7 @@ function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): vo async function refreshGatewayServiceEnv(params: { result: UpdateRunResult; jsonMode: boolean; + invocationCwd?: string; }): Promise { const args = ["gateway", "install", "--force"]; if (params.jsonMode) { @@ -217,7 +230,7 @@ async function refreshGatewayServiceEnv(params: { } const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { cwd: params.result.root, - env: resolveServiceRefreshEnv(process.env), + env: resolveServiceRefreshEnv(process.env, params.invocationCwd), timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, }); if (res.code === 0) { @@ -547,6 +560,7 @@ async function maybeRestartService(params: { refreshServiceEnv: boolean; gatewayPort: number; restartScriptPath?: string | null; + invocationCwd?: string; }): Promise { if (params.shouldRestart) { if (!params.opts.json) { @@ -562,6 +576,7 @@ async function maybeRestartService(params: { await refreshGatewayServiceEnv({ result: params.result, jsonMode: Boolean(params.opts.json), + invocationCwd: params.invocationCwd, }); } catch (err) { if (!params.opts.json) { @@ -667,6 +682,7 @@ async function maybeRestartService(params: { export async function updateCommand(opts: UpdateCommandOptions): Promise { suppressDeprecations(); + const invocationCwd = tryResolveInvocationCwd(); const timeoutMs = parseTimeoutMsOrExit(opts.timeout); const shouldRestart = opts.restart !== false; @@ -949,6 +965,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { refreshServiceEnv: refreshGatewayServiceEnv, gatewayPort, restartScriptPath, + invocationCwd, }); if (!opts.json) {