Skip to content

Commit 797058c

Browse files
fix(update): harden managed handoff cwd
1 parent 8961eae commit 797058c

9 files changed

Lines changed: 163 additions & 14 deletions

src/gateway/server-methods/update-managed-service-handoff.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,14 @@ describe("managed service update handoff", () => {
162162
expect(args).toHaveLength(2);
163163
tempDirs.add(path.dirname(args[0] ?? result.logPath));
164164
const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf-8")) as {
165+
cwd?: string;
165166
metaPath?: string;
166167
sentinelPath?: string;
167168
};
168169
expect(helperParams.metaPath).toMatch(/sentinel-meta\.json$/u);
169170
expect(helperParams.sentinelPath).toMatch(/restart-sentinel\.json$/u);
170-
expect(options.cwd).toBe("/tmp/openclaw");
171+
expect(options.cwd).toBe(os.homedir());
172+
expect(helperParams.cwd).toBe(os.homedir());
171173
expect(options.detached).toBe(true);
172174
expect(options.env.KEEP_ME).toBe("1");
173175
for (const [key, value] of Object.entries(serviceIdentityEnv)) {

src/gateway/server-methods/update-managed-service-handoff.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const SERVICE_IDENTITY_ENV_VARS = new Set<string>([
2121
const HANDOFF_SCRIPT = String.raw`
2222
const { spawn } = require("node:child_process");
2323
const fs = require("node:fs");
24+
const os = require("node:os");
2425
const path = require("node:path");
2526
2627
const params = JSON.parse(fs.readFileSync(process.argv[2], "utf-8"));
@@ -62,6 +63,23 @@ function cleanupSensitiveFiles() {
6263
}
6364
}
6465
66+
function resolveExistingDirectory(candidates) {
67+
for (const candidate of candidates) {
68+
if (!candidate || typeof candidate !== "string") {
69+
continue;
70+
}
71+
try {
72+
const stat = fs.statSync(candidate);
73+
if (stat.isDirectory()) {
74+
return candidate;
75+
}
76+
} catch {
77+
// Try the next candidate.
78+
}
79+
}
80+
return undefined;
81+
}
82+
6583
function readJsonFile(filePath) {
6684
try {
6785
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -170,8 +188,18 @@ function markUpdateSentinelFailureIfPending(reason) {
170188
let outputFd;
171189
try {
172190
outputFd = fs.openSync(params.logPath, "a", 0o600);
191+
const commandCwd =
192+
resolveExistingDirectory([
193+
params.cwd,
194+
os.homedir(),
195+
os.tmpdir(),
196+
path.parse(process.execPath).root,
197+
]) || params.cwd;
198+
if (commandCwd !== params.cwd) {
199+
appendLog("managed update command cwd fallback: " + params.cwd + " -> " + commandCwd);
200+
}
173201
const child = spawn(params.commandArgv[0], params.commandArgv.slice(1), {
174-
cwd: params.cwd,
202+
cwd: commandCwd,
175203
env: process.env,
176204
detached: true,
177205
stdio: ["ignore", outputFd, outputFd],
@@ -273,6 +301,24 @@ export function stripSupervisorHintEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
273301
return next;
274302
}
275303

304+
async function resolveManagedServiceHandoffCwd(root: string): Promise<string> {
305+
const candidates = [os.homedir(), os.tmpdir(), path.dirname(process.execPath), root];
306+
for (const candidate of candidates) {
307+
if (!candidate.trim()) {
308+
continue;
309+
}
310+
try {
311+
const stat = await fs.stat(candidate);
312+
if (stat.isDirectory()) {
313+
return candidate;
314+
}
315+
} catch {
316+
// Try the next candidate.
317+
}
318+
}
319+
return root;
320+
}
321+
276322
export async function startManagedServiceUpdateHandoff(params: {
277323
root: string;
278324
timeoutMs?: number;
@@ -295,14 +341,15 @@ export async function startManagedServiceUpdateHandoff(params: {
295341
argv1: params.argv1 ?? process.argv[1],
296342
});
297343
const commandLabel = formatManagedServiceUpdateCommand(params.timeoutMs);
344+
const handoffCwd = await resolveManagedServiceHandoffCwd(params.root);
298345
const metaFile: ControlPlaneUpdateSentinelMetaFile = {
299346
version: 1,
300347
meta: params.meta,
301348
};
302349
const helperParams = {
303350
parentPid: params.parentPid ?? process.pid,
304351
parentExitTimeoutMs: Math.max(0, params.restartDelayMs ?? 0) + PARENT_EXIT_GRACE_MS,
305-
cwd: params.root,
352+
cwd: handoffCwd,
306353
commandArgv,
307354
commandLabel,
308355
handoffId: params.handoffId,
@@ -322,7 +369,7 @@ export async function startManagedServiceUpdateHandoff(params: {
322369
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
323370
};
324371
const child = spawn(params.execPath ?? process.execPath, [scriptPath, paramsPath], {
325-
cwd: params.root,
372+
cwd: handoffCwd,
326373
env,
327374
detached: true,
328375
stdio: "ignore",

src/gateway/server-methods/update.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,31 @@ describe("update.run restart scheduling", () => {
416416
);
417417
});
418418

419+
it("starts managed package handoff when the gateway cwd is unavailable", async () => {
420+
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
421+
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
422+
kind: "global",
423+
mode: "npm",
424+
root: "/tmp/openclaw-global",
425+
packageRoot: "/tmp/openclaw-global",
426+
});
427+
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
428+
throw Object.assign(new Error("uv_cwd"), { code: "ENOENT", syscall: "uv_cwd" });
429+
});
430+
try {
431+
await invokeUpdateRun({});
432+
} finally {
433+
cwdSpy.mockRestore();
434+
}
435+
436+
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1);
437+
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
438+
expect.objectContaining({
439+
root: "/tmp/openclaw",
440+
}),
441+
);
442+
});
443+
419444
it("keeps git/dev updates on the in-process gateway update path", async () => {
420445
runGatewayUpdateMock.mockResolvedValueOnce({
421446
status: "ok",

src/gateway/server-methods/update.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from "node:crypto";
2+
import os from "node:os";
23
import { isRestartEnabled } from "../../config/commands.flags.js";
34
import { extractDeliveryInfo } from "../../config/sessions.js";
45
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
@@ -35,6 +36,14 @@ function formatUpdateRunErrorMessage(err: unknown): string {
3536
return String(err);
3637
}
3738

39+
function tryResolveProcessCwd(): string | undefined {
40+
try {
41+
return process.cwd();
42+
} catch {
43+
return undefined;
44+
}
45+
}
46+
3847
export const updateHandlers: GatewayRequestHandlers = {
3948
"update.status": async ({ params, respond }) => {
4049
if (!assertValidParams(params, validateUpdateStatusParams, "update.status", respond)) {
@@ -82,12 +91,15 @@ export const updateHandlers: GatewayRequestHandlers = {
8291
try {
8392
const config = context.getRuntimeConfig();
8493
const configChannel = normalizeUpdateChannel(config.update?.channel);
94+
const invocationCwd = tryResolveProcessCwd();
8595
const root =
8696
(await resolveOpenClawPackageRoot({
8797
moduleUrl: import.meta.url,
8898
argv1: process.argv[1],
89-
cwd: process.cwd(),
90-
})) ?? process.cwd();
99+
...(invocationCwd ? { cwd: invocationCwd } : {}),
100+
})) ??
101+
invocationCwd ??
102+
os.homedir();
91103
const installSurface = await resolveUpdateInstallSurface({
92104
timeoutMs,
93105
cwd: root,

src/infra/is-main.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import { isMainModule } from "./is-main.js";
33

44
describe("isMainModule", () => {
@@ -13,6 +13,23 @@ describe("isMainModule", () => {
1313
).toBe(true);
1414
});
1515

16+
it("falls back to the current file directory when process.cwd is unavailable", () => {
17+
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
18+
throw Object.assign(new Error("uv_cwd"), { code: "ENOENT", syscall: "uv_cwd" });
19+
});
20+
try {
21+
expect(
22+
isMainModule({
23+
currentFile: "/repo/dist/index.js",
24+
argv: ["node", "/repo/dist/index.js"],
25+
env: {},
26+
}),
27+
).toBe(true);
28+
} finally {
29+
cwdSpy.mockRestore();
30+
}
31+
});
32+
1633
it("returns true under PM2 when pm_exec_path matches current file", () => {
1734
expect(
1835
isMainModule({

src/infra/is-main.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,32 @@ function normalizePathCandidate(candidate: string | undefined, cwd: string): str
2525
}
2626
}
2727

28+
function resolveDefaultCwd(currentFile: string): string {
29+
try {
30+
return process.cwd();
31+
} catch {
32+
return path.dirname(currentFile);
33+
}
34+
}
35+
2836
export function isMainModule({
2937
currentFile,
3038
argv = process.argv,
3139
env = process.env,
32-
cwd = process.cwd(),
40+
cwd,
3341
wrapperEntryPairs = [],
3442
}: IsMainModuleOptions): boolean {
35-
const normalizedCurrent = normalizePathCandidate(currentFile, cwd);
36-
const normalizedArgv1 = normalizePathCandidate(argv[1], cwd);
43+
const resolvedCwd = cwd ?? resolveDefaultCwd(currentFile);
44+
const normalizedCurrent = normalizePathCandidate(currentFile, resolvedCwd);
45+
const normalizedArgv1 = normalizePathCandidate(argv[1], resolvedCwd);
3746

3847
if (normalizedCurrent && normalizedArgv1 && normalizedCurrent === normalizedArgv1) {
3948
return true;
4049
}
4150

4251
// PM2 runs the script via an internal wrapper; `argv[1]` points at the wrapper.
4352
// PM2 exposes the actual script path in `pm_exec_path`.
44-
const normalizedPmExecPath = normalizePathCandidate(env.pm_exec_path, cwd);
53+
const normalizedPmExecPath = normalizePathCandidate(env.pm_exec_path, resolvedCwd);
4554
if (normalizedCurrent && normalizedPmExecPath && normalizedCurrent === normalizedPmExecPath) {
4655
return true;
4756
}

src/infra/npm-install-env.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,16 @@ function readNpmGlobalConfigPath(
110110
}
111111

112112
function resolveScopedProjectNpmrc(scope: NpmFreshnessConfigScope): string | null {
113-
const cwd = scope.npmConfigCwd?.trim() || process.cwd();
114-
return cwd ? path.join(cwd, ".npmrc") : null;
113+
const scopedCwd = scope.npmConfigCwd?.trim();
114+
if (scopedCwd) {
115+
return path.join(scopedCwd, ".npmrc");
116+
}
117+
try {
118+
const cwd = process.cwd();
119+
return cwd ? path.join(cwd, ".npmrc") : null;
120+
} catch {
121+
return null;
122+
}
115123
}
116124

117125
function resolveScopedGlobalNpmrc(scope: NpmFreshnessConfigScope): string | null {

src/infra/update-runner.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,30 @@ describe("runGatewayUpdate", () => {
348348
expect(calls.filter((call) => call.includes("rebase"))).toEqual([]);
349349
});
350350

351+
it("uses the supplied update cwd when the process cwd disappeared", async () => {
352+
await setupGitCheckout();
353+
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
354+
throw Object.assign(new Error("ENOENT: uv_cwd"), { code: "ENOENT" });
355+
});
356+
const { runner, calls } = createRunner({
357+
...buildGitWorktreeProbeResponses(),
358+
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
359+
code: 1,
360+
stderr: "no upstream configured",
361+
},
362+
});
363+
364+
try {
365+
const result = await runWithRunner(runner);
366+
367+
expect(result.status).toBe("skipped");
368+
expect(result.reason).toBe("no-upstream");
369+
expect(calls).toContain(`git -C ${tempDir} rev-parse --show-toplevel`);
370+
} finally {
371+
cwdSpy.mockRestore();
372+
}
373+
});
374+
351375
it.each([
352376
{ name: "upstream", options: {} },
353377
{ name: "target ref", options: { devTargetRef: "main" } },

src/infra/update-runner.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,12 @@ function buildStartDirs(opts: UpdateRunnerOptions): string[] {
226226
dirs.push(packageRoot);
227227
}
228228
}
229-
const proc = normalizeDir(process.cwd());
229+
let proc: string | null = null;
230+
try {
231+
proc = normalizeDir(process.cwd());
232+
} catch {
233+
proc = null;
234+
}
230235
if (proc) {
231236
dirs.push(proc);
232237
}

0 commit comments

Comments
 (0)