Skip to content

Commit 35c9958

Browse files
committed
fix(update): escape systemd update handoffs
1 parent 32f9150 commit 35c9958

5 files changed

Lines changed: 218 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ Docs: https://docs.openclaw.ai
140140
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
141141
- Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.
142142
- Gateway/agents: return phase-aware `agent.wait` timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.
143+
- Gateway/systemd: launch managed update handoff helpers in a transient user scope so systemd-supervised Update Now flows survive the gateway unit restart. Fixes #84068.
143144
- Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.
144145
- Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.
145146
- Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ async function runHelperWithExistingSentinel(params: {
4444
}) {
4545
const { execFile } =
4646
await vi.importActual<typeof import("node:child_process")>("node:child_process");
47-
const { startManagedServiceUpdateHandoff } =
48-
await import("./update-managed-service-handoff.js");
47+
const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js");
4948
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-handoff-helper-test-"));
5049
tempDirs.add(tmpDir);
5150

@@ -184,6 +183,77 @@ describe("managed service update handoff", () => {
184183
expect(options.env[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]).toMatch(/sentinel-meta\.json$/u);
185184
});
186185

186+
it("launches systemd handoffs through a transient user scope", async () => {
187+
const { startManagedServiceUpdateHandoff } =
188+
await import("./update-managed-service-handoff.js");
189+
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-systemd-run-bin-"));
190+
tempDirs.add(binDir);
191+
const systemdRunPath = path.join(binDir, "systemd-run");
192+
await fs.writeFile(systemdRunPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
193+
194+
const result = await startManagedServiceUpdateHandoff({
195+
root: "/tmp/openclaw",
196+
timeoutMs: 1_800_000,
197+
restartDelayMs: 500,
198+
parentPid: 12345,
199+
execPath: "/usr/local/bin/node",
200+
argv1: "/opt/openclaw/openclaw.mjs",
201+
handoffId: "handoff-123",
202+
supervisor: "systemd",
203+
env: {
204+
PATH: binDir,
205+
OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service",
206+
INVOCATION_ID: "gateway-invocation",
207+
KEEP_ME: "1",
208+
},
209+
meta: {
210+
handoffId: "handoff-123",
211+
sessionKey: "agent:test:webchat:dm:user-123",
212+
continuationMessage: "continue after restart",
213+
},
214+
});
215+
216+
expect(result.status).toBe("started");
217+
expect(spawnMock).toHaveBeenCalledTimes(1);
218+
const [command, args, options] = spawnMock.mock.calls[0] as unknown as [
219+
string,
220+
string[],
221+
{ env: NodeJS.ProcessEnv; detached?: boolean; cwd?: string },
222+
];
223+
expect(command).toBe(systemdRunPath);
224+
expect(args.slice(0, 4)).toEqual([
225+
"--user",
226+
"--scope",
227+
"--collect",
228+
"--unit=openclaw-update-handoff-123.scope",
229+
]);
230+
expect(args.slice(4, 7)).toEqual([
231+
"/usr/local/bin/node",
232+
expect.stringMatching(/handoff\.cjs$/u),
233+
expect.stringMatching(/handoff\.json$/u),
234+
]);
235+
tempDirs.add(path.dirname(args[5] ?? result.logPath));
236+
const helperParams = JSON.parse(await fs.readFile(args[6] ?? "", "utf-8")) as {
237+
commandArgv?: string[];
238+
handoffId?: string;
239+
};
240+
expect(helperParams.commandArgv).toEqual([
241+
"/usr/local/bin/node",
242+
"/opt/openclaw/openclaw.mjs",
243+
"update",
244+
"--yes",
245+
"--json",
246+
"--timeout",
247+
"1800",
248+
]);
249+
expect(helperParams.handoffId).toBe("handoff-123");
250+
expect(options.detached).toBe(true);
251+
expect(options.env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service");
252+
expect(options.env.INVOCATION_ID).toBeUndefined();
253+
expect(options.env.KEEP_ME).toBe("1");
254+
expect(options.env.OPENCLAW_UPDATE_RUN_HANDOFF).toBe("1");
255+
});
256+
187257
it("does not overwrite a restart sentinel owned by another startup task", async () => {
188258
const unrelatedSentinel = {
189259
version: 1,

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

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55
import { resolveRestartSentinelPath } from "../../infra/restart-sentinel.js";
6-
import { SUPERVISOR_HINT_ENV_VARS } from "../../infra/supervisor-markers.js";
6+
import {
7+
SUPERVISOR_HINT_ENV_VARS,
8+
type RespawnSupervisor,
9+
} from "../../infra/supervisor-markers.js";
710
import {
811
CONTROL_PLANE_UPDATE_SENTINEL_META_ENV,
912
type ControlPlaneUpdateSentinelMetaFile,
@@ -12,6 +15,7 @@ import { MANAGED_SERVICE_UPDATE_HANDOFF_TEMP_PREFIX } from "../../infra/update-m
1215
import type { UpdateRestartSentinelMeta } from "../../infra/update-restart-sentinel-payload.js";
1316

1417
const PARENT_EXIT_GRACE_MS = 60_000;
18+
const SYSTEMD_RUN_CANDIDATE_PATHS = ["/usr/bin/systemd-run", "/bin/systemd-run"] as const;
1519
const SERVICE_IDENTITY_ENV_VARS = new Set<string>([
1620
"OPENCLAW_LAUNCHD_LABEL",
1721
"OPENCLAW_SYSTEMD_UNIT",
@@ -319,12 +323,95 @@ async function resolveManagedServiceHandoffCwd(root: string): Promise<string> {
319323
return root;
320324
}
321325

326+
async function resolveExecutableOnPath(
327+
name: string,
328+
env: NodeJS.ProcessEnv,
329+
fallbackPaths: readonly string[],
330+
): Promise<string | null> {
331+
const candidates = new Set<string>();
332+
const pathValue = env.PATH?.trim();
333+
if (pathValue) {
334+
for (const dir of pathValue.split(path.delimiter)) {
335+
if (dir.trim()) {
336+
candidates.add(path.join(dir, name));
337+
}
338+
}
339+
}
340+
for (const candidate of fallbackPaths) {
341+
candidates.add(candidate);
342+
}
343+
344+
for (const candidate of candidates) {
345+
try {
346+
await fs.access(candidate, fs.constants.X_OK);
347+
return candidate;
348+
} catch {
349+
// Try the next candidate.
350+
}
351+
}
352+
return null;
353+
}
354+
355+
function sanitizeSystemdUnitFragment(value: string | undefined): string {
356+
const normalized = value?.trim().replace(/[^A-Za-z0-9_.:@-]+/gu, "-") ?? "";
357+
return normalized.replace(/^-+|-+$/gu, "").slice(0, 80);
358+
}
359+
360+
function buildSystemdHandoffUnitName(handoffId: string | undefined): string {
361+
const suffix =
362+
sanitizeSystemdUnitFragment(handoffId) ||
363+
sanitizeSystemdUnitFragment(`${process.pid}-${Date.now()}`) ||
364+
"handoff";
365+
return `openclaw-update-${suffix}.scope`;
366+
}
367+
368+
async function resolveHandoffSpawn(params: {
369+
supervisor?: RespawnSupervisor | null;
370+
env: NodeJS.ProcessEnv;
371+
execPath: string;
372+
scriptPath: string;
373+
paramsPath: string;
374+
handoffId?: string;
375+
}): Promise<{ command: string; args: string[] }> {
376+
if (params.supervisor !== "systemd") {
377+
return {
378+
command: params.execPath,
379+
args: [params.scriptPath, params.paramsPath],
380+
};
381+
}
382+
383+
const systemdRunPath = await resolveExecutableOnPath(
384+
"systemd-run",
385+
params.env,
386+
SYSTEMD_RUN_CANDIDATE_PATHS,
387+
);
388+
if (!systemdRunPath) {
389+
throw new Error(
390+
"systemd-run is required to start the managed update handoff outside openclaw-gateway.service",
391+
);
392+
}
393+
394+
return {
395+
command: systemdRunPath,
396+
args: [
397+
"--user",
398+
"--scope",
399+
"--collect",
400+
`--unit=${buildSystemdHandoffUnitName(params.handoffId)}`,
401+
params.execPath,
402+
params.scriptPath,
403+
params.paramsPath,
404+
],
405+
};
406+
}
407+
322408
export async function startManagedServiceUpdateHandoff(params: {
323409
root: string;
324410
timeoutMs?: number;
325411
restartDelayMs?: number;
326412
meta: UpdateRestartSentinelMeta;
327413
handoffId?: string;
414+
supervisor?: RespawnSupervisor | null;
328415
env?: NodeJS.ProcessEnv;
329416
execPath?: string;
330417
argv1?: string;
@@ -368,7 +455,15 @@ export async function startManagedServiceUpdateHandoff(params: {
368455
[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
369456
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
370457
};
371-
const child = spawn(params.execPath ?? process.execPath, [scriptPath, paramsPath], {
458+
const spawnTarget = await resolveHandoffSpawn({
459+
supervisor: params.supervisor,
460+
env,
461+
execPath: params.execPath ?? process.execPath,
462+
scriptPath,
463+
paramsPath,
464+
handoffId: params.handoffId,
465+
});
466+
const child = spawn(spawnTarget.command, spawnTarget.args, {
372467
cwd: handoffCwd,
373468
env,
374469
detached: true,

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ vi.mock("./restart-request.js", () => ({
114114
sessionKey: params.sessionKey,
115115
note: params.note,
116116
continuationMessage: params.continuationMessage,
117-
restartDelayMs: undefined,
117+
restartDelayMs: params.restartDelayMs,
118118
}),
119119
}));
120120

@@ -367,6 +367,7 @@ describe("update.run restart scheduling", () => {
367367
expect.objectContaining({
368368
root: "/tmp/openclaw",
369369
handoffId: expect.any(String),
370+
supervisor: "launchd",
370371
meta: expect.objectContaining({
371372
handoffId: expect.any(String),
372373
}),
@@ -416,6 +417,33 @@ describe("update.run restart scheduling", () => {
416417
);
417418
});
418419

420+
it("keeps a startup grace before restarting after systemd handoff spawn", async () => {
421+
detectRespawnSupervisorMock.mockReturnValueOnce("systemd");
422+
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
423+
kind: "global",
424+
mode: "npm",
425+
root: "/tmp/openclaw-global",
426+
packageRoot: "/tmp/openclaw-global",
427+
});
428+
429+
await invokeUpdateRun({ restartDelayMs: 0 });
430+
431+
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
432+
expect.objectContaining({
433+
supervisor: "systemd",
434+
restartDelayMs: 0,
435+
}),
436+
);
437+
expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledWith(
438+
expect.objectContaining({
439+
delayMs: 2000,
440+
reason: "update.run",
441+
skipCooldown: true,
442+
skipDeferral: true,
443+
}),
444+
);
445+
});
446+
419447
it("starts managed package handoff when the gateway cwd is unavailable", async () => {
420448
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
421449
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({

src/gateway/server-methods/update.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
} from "./update-managed-service-handoff.js";
3030
import { assertValidParams } from "./validation.js";
3131

32+
const SYSTEMD_HANDOFF_RESTART_GRACE_MS = 2000;
33+
3234
function formatUpdateRunErrorMessage(err: unknown): string {
3335
if (err instanceof Error) {
3436
return err.message || err.name;
@@ -44,6 +46,19 @@ function tryResolveProcessCwd(): string | undefined {
4446
}
4547
}
4648

49+
function resolveManagedServiceHandoffRestartDelayMs(
50+
restartDelayMs: number | undefined,
51+
supervisor: ReturnType<typeof detectRespawnSupervisor>,
52+
): number | undefined {
53+
if (supervisor !== "systemd") {
54+
return restartDelayMs;
55+
}
56+
return Math.max(
57+
restartDelayMs ?? SYSTEMD_HANDOFF_RESTART_GRACE_MS,
58+
SYSTEMD_HANDOFF_RESTART_GRACE_MS,
59+
);
60+
}
61+
4762
export const updateHandlers: GatewayRequestHandlers = {
4863
"update.status": async ({ params, respond }) => {
4964
if (!assertValidParams(params, validateUpdateStatusParams, "update.status", respond)) {
@@ -88,6 +103,7 @@ export const updateHandlers: GatewayRequestHandlers = {
88103
...(note !== undefined ? { note } : {}),
89104
...(continuationMessage !== undefined ? { continuationMessage } : {}),
90105
};
106+
let supervisor: ReturnType<typeof detectRespawnSupervisor> = null;
91107
try {
92108
const config = context.getRuntimeConfig();
93109
const configChannel = normalizeUpdateChannel(config.update?.channel);
@@ -105,7 +121,7 @@ export const updateHandlers: GatewayRequestHandlers = {
105121
cwd: root,
106122
argv1: process.argv[1],
107123
});
108-
const supervisor = detectRespawnSupervisor(process.env, process.platform);
124+
supervisor = detectRespawnSupervisor(process.env, process.platform);
109125
if (!isRestartEnabled(config) && !supervisor) {
110126
const beforeVersion = installSurface.root
111127
? await readPackageVersion(installSurface.root)
@@ -132,6 +148,7 @@ export const updateHandlers: GatewayRequestHandlers = {
132148
restartDelayMs,
133149
meta: sentinelMeta,
134150
handoffId,
151+
supervisor,
135152
});
136153
handoff = {
137154
status: "started",
@@ -230,7 +247,7 @@ export const updateHandlers: GatewayRequestHandlers = {
230247
? scheduleGatewaySigusr1Restart({
231248
delayMs:
232249
handoff?.status === "started"
233-
? restartDelayMs
250+
? resolveManagedServiceHandoffRestartDelayMs(restartDelayMs, supervisor)
234251
: updateWasPackageSwap
235252
? 0
236253
: restartDelayMs,

0 commit comments

Comments
 (0)