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
This commit is contained in:
Vincent Koc 2026-03-13 18:09:01 -04:00 committed by GitHub
parent fac754041c
commit 65f92fd839
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 67 additions and 2 deletions

View File

@ -668,6 +668,54 @@ describe("update-cli", () => {
expect(runDaemonInstall).not.toHaveBeenCalled(); 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 () => { it("updateCommand falls back to restart when env refresh install fails", async () => {
await runRestartFallbackScenario({ daemonInstall: "fail" }); await runRestartFallbackScenario({ daemonInstall: "fail" });
}); });

View File

@ -124,9 +124,17 @@ function formatCommandFailure(stdout: string, stderr: string): string {
return detail.split("\n").slice(-3).join("\n"); return detail.split("\n").slice(-3).join("\n");
} }
function tryResolveInvocationCwd(): string | undefined {
try {
return process.cwd();
} catch {
return undefined;
}
}
function resolveServiceRefreshEnv( function resolveServiceRefreshEnv(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
invocationCwd: string = process.cwd(), invocationCwd?: string,
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
const resolvedEnv: NodeJS.ProcessEnv = { ...env }; const resolvedEnv: NodeJS.ProcessEnv = { ...env };
for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) { for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) {
@ -138,6 +146,10 @@ function resolveServiceRefreshEnv(
resolvedEnv[key] = rawValue; resolvedEnv[key] = rawValue;
continue; continue;
} }
if (!invocationCwd) {
resolvedEnv[key] = rawValue;
continue;
}
resolvedEnv[key] = path.resolve(invocationCwd, rawValue); resolvedEnv[key] = path.resolve(invocationCwd, rawValue);
} }
return resolvedEnv; return resolvedEnv;
@ -205,6 +217,7 @@ function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): vo
async function refreshGatewayServiceEnv(params: { async function refreshGatewayServiceEnv(params: {
result: UpdateRunResult; result: UpdateRunResult;
jsonMode: boolean; jsonMode: boolean;
invocationCwd?: string;
}): Promise<void> { }): Promise<void> {
const args = ["gateway", "install", "--force"]; const args = ["gateway", "install", "--force"];
if (params.jsonMode) { if (params.jsonMode) {
@ -217,7 +230,7 @@ async function refreshGatewayServiceEnv(params: {
} }
const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], {
cwd: params.result.root, cwd: params.result.root,
env: resolveServiceRefreshEnv(process.env), env: resolveServiceRefreshEnv(process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
}); });
if (res.code === 0) { if (res.code === 0) {
@ -547,6 +560,7 @@ async function maybeRestartService(params: {
refreshServiceEnv: boolean; refreshServiceEnv: boolean;
gatewayPort: number; gatewayPort: number;
restartScriptPath?: string | null; restartScriptPath?: string | null;
invocationCwd?: string;
}): Promise<void> { }): Promise<void> {
if (params.shouldRestart) { if (params.shouldRestart) {
if (!params.opts.json) { if (!params.opts.json) {
@ -562,6 +576,7 @@ async function maybeRestartService(params: {
await refreshGatewayServiceEnv({ await refreshGatewayServiceEnv({
result: params.result, result: params.result,
jsonMode: Boolean(params.opts.json), jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
}); });
} catch (err) { } catch (err) {
if (!params.opts.json) { if (!params.opts.json) {
@ -667,6 +682,7 @@ async function maybeRestartService(params: {
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> { export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
suppressDeprecations(); suppressDeprecations();
const invocationCwd = tryResolveInvocationCwd();
const timeoutMs = parseTimeoutMsOrExit(opts.timeout); const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
const shouldRestart = opts.restart !== false; const shouldRestart = opts.restart !== false;
@ -949,6 +965,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
refreshServiceEnv: refreshGatewayServiceEnv, refreshServiceEnv: refreshGatewayServiceEnv,
gatewayPort, gatewayPort,
restartScriptPath, restartScriptPath,
invocationCwd,
}); });
if (!opts.json) { if (!opts.json) {