Skip to content

Commit 68553be

Browse files
authored
fix: live updater survives launchd teardown and retargets wrapped gateways (#105022)
* fix(updater): tolerate launchd teardown and replace plist args safely * test(updater): declare plist argument helper
1 parent 7197eef commit 68553be

4 files changed

Lines changed: 142 additions & 22 deletions

File tree

.agents/skills/openclaw-live-updater/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Keep `/Users/steipete/openclaw` a read-only-to-the-agent deployment mirror: clea
3232
- A dependency-input change, absent `node_modules`, or missing/invalid build provenance requires `pnpm install --frozen-lockfile`. When a build is required, do not install before acquiring the maintenance suspension and stopping the managed Gateway.
3333
- Before mutating any entrypoint currently executed by the Gateway, invoke an exact trusted source CLI to acquire `gateway.suspend.prepare`, binding both prepare and resume to this checkout's managed LaunchAgent loopback port and service auth even when normal CLI configuration points at a remote Gateway. The LaunchAgent may execute either this checkout's `dist/index.js` or a clean detached canonical snapshot under `~/.openclaw/runtime/gateway-<sha>` whose commit is an ancestor of the checkout; reject every other entrypoint. Never execute snapshot code: capture an exact source control build before the Git fast-forward for its prepare and failure-resume calls, preserve any validated generated service-environment wrapper, and stop the managed LaunchAgent with native launchd bootout. If that control build is missing, first accept native proof that the snapshot job is already booted out with its port free; when the isolated snapshot is still running, build the verified clean source checkout to obtain an exact suspension client, then use only that source client for prepare and failure-resume. Never use this recovery build while launchd targets the source checkout. This atomically pauses cron scheduling, closes new work admission, and refuses while active work remains. A busy result defers further mutation to the next heartbeat; never replace this fence with `cron list` polling. Once ready, stop directly without a source launcher, install frozen dependencies when required, then build unless the exact recovery build already produced the deployment artifact; source launchers can auto-build stale output before dispatching the stop. Resume the suspension if stop fails. If suspension RPC is unavailable on macOS, proceed only when native inspection proves this checkout's managed LaunchAgent is booted out and its configured port has no listener; never accept a loaded KeepAlive job's transient stopped state. On other platforms, require the existing CLI to prove the managed service is stopped with no PID, listener, or RPC. This preserves retry after a post-stop failure without weakening the live-work fence. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
3434
- Snapshot ownership validation must never invoke Git inside the snapshot. Treat its worktree, local Git configuration, filters, hooks, attributes, and build artifacts as untrusted; prove only that the regular owned LaunchAgent entrypoint is under the canonical detached ancestor snapshot path. Snapshot validation authorizes native bootout and retargeting only. Every CLI path must reject snapshot execution and use the trusted source checkout build instead.
35-
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`. A validated ancestor snapshot is suspension-only: prove the old launchd job is booted out with its port free, then atomically retarget only the owned LaunchAgent entrypoint to this checkout's exact `dist/index.js`, including on a retry where the build is already current. Preserve all other service arguments and environment unchanged. After restart, prove the loaded launchd PID owns the configured listener.
35+
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`. A validated ancestor snapshot is suspension-only: prove the old launchd job is booted out with its port free, allowing bounded retries while launchd and the listener finish teardown, then atomically retarget only the owned LaunchAgent entrypoint to this checkout's exact `dist/index.js`, including on a retry where the build is already current. Replace the verified `ProgramArguments` array as one value; never use array-index plist mutation that can insert a duplicate argument. Preserve all other service arguments and environment unchanged. After restart, prove the loaded launchd PID owns the configured listener.
3636
- After every managed restart, query Gateway logs through RPC, restrict the audit to entries emitted since that restart began, report warning summaries, and fail the pass on any error/fatal entry. If RPC verification or log retrieval fails, still inspect the local structured log for that restart window. Never accept supervisor or RPC health without this restart-window log audit.
3737

3838
Treat supervisor state alone as insufficient. If build or proof fails, leave the new mirror head intact and retry the stale/missing build on the next heartbeat; never run the old `dist` against new source.

.agents/skills/openclaw-live-updater/scripts/update-main.mjs

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const DEFAULT_EXPECTED_ORIGIN = "openclaw/openclaw";
3737
const FULL_SHA_RE = /^[0-9a-f]{40}$/u;
3838
const GATEWAY_READINESS_ATTEMPTS = 3;
3939
const GATEWAY_READINESS_RETRY_DELAY_MS = 5_000;
40+
const GATEWAY_STOP_PROOF_ATTEMPTS = 100;
41+
const GATEWAY_STOP_PROOF_RETRY_DELAY_MS = 100;
4042
const GATEWAY_SUSPEND_TIMEOUT_MS = 10_000;
4143
const GENERATED_LAUNCH_AGENT_ENV_WRAPPER = `#!/bin/sh
4244
set -eu
@@ -875,23 +877,43 @@ export function repointManagedGatewayDeployment(
875877
};
876878
}
877879

880+
export function replaceLaunchAgentProgramArgument(programArguments, index, expected, replacement) {
881+
if (!Array.isArray(programArguments) || programArguments[index] !== expected) {
882+
throw new UpdateInvariantError(
883+
"gateway_repoint_failed",
884+
"managed Gateway LaunchAgent changed before its entrypoint could be replaced",
885+
);
886+
}
887+
return programArguments.with(index, replacement);
888+
}
889+
878890
function replaceLaunchAgentEntrypoint(deployment, entrypoint) {
879-
const original = readFileSync(deployment.plistPath);
880891
const temporaryPath = `${deployment.plistPath}.openclaw-live-updater-${randomUUID()}`;
881-
writeFileSync(temporaryPath, original, {
892+
writeFileSync(temporaryPath, readFileSync(deployment.plistPath), {
882893
flag: "wx",
883894
mode: statSync(deployment.plistPath).mode,
884895
});
885896
try {
897+
const plistResult = spawnSync(
898+
"/usr/bin/plutil",
899+
["-convert", "json", "-o", "-", temporaryPath],
900+
{ encoding: "utf8" },
901+
);
902+
if (plistResult.status !== 0) {
903+
throw new UpdateInvariantError(
904+
"gateway_repoint_failed",
905+
`could not read the managed Gateway LaunchAgent: ${String(plistResult.stderr).trim()}`,
906+
);
907+
}
908+
const programArguments = replaceLaunchAgentProgramArgument(
909+
JSON.parse(plistResult.stdout)?.ProgramArguments,
910+
deployment.entrypointIndex,
911+
deployment.entrypoint,
912+
entrypoint,
913+
);
886914
execFileSync(
887915
"/usr/bin/plutil",
888-
[
889-
"-replace",
890-
`ProgramArguments.${deployment.entrypointIndex}`,
891-
"-string",
892-
entrypoint,
893-
temporaryPath,
894-
],
916+
["-replace", "ProgramArguments", "-json", JSON.stringify(programArguments), temporaryPath],
895917
{ stdio: ["ignore", "ignore", "pipe"] },
896918
);
897919
execFileSync("/usr/bin/plutil", ["-lint", temporaryPath], {
@@ -1143,6 +1165,33 @@ function stopManagedGateway(runCommand, checkout, deployment) {
11431165
);
11441166
}
11451167

1168+
function stopManagedGatewayAndProve(runCommand, checkout, deployment, proveGatewayStopped, sleep) {
1169+
let stopError;
1170+
try {
1171+
stopManagedGateway(runCommand, checkout, deployment);
1172+
} catch (error) {
1173+
stopError = error;
1174+
}
1175+
let proofError;
1176+
for (let attempt = 0; attempt < GATEWAY_STOP_PROOF_ATTEMPTS; attempt += 1) {
1177+
try {
1178+
return proveGatewayStopped(checkout);
1179+
} catch (error) {
1180+
proofError = error;
1181+
if (attempt + 1 < GATEWAY_STOP_PROOF_ATTEMPTS) {
1182+
sleep(GATEWAY_STOP_PROOF_RETRY_DELAY_MS);
1183+
}
1184+
}
1185+
}
1186+
if (!stopError) {
1187+
throw proofError;
1188+
}
1189+
throw new AggregateError(
1190+
[stopError, proofError],
1191+
"Gateway stop command failed and native stopped proof did not converge",
1192+
);
1193+
}
1194+
11461195
function isTrustedSourceControlBuild(checkout, buildState, currentHead) {
11471196
if (buildState.current) {
11481197
return true;
@@ -1802,10 +1851,15 @@ export function maintainMain(options, dependencies = {}) {
18021851
// Native bootout prevents launchd from retaining old ProgramArguments
18031852
// and avoids source launchers that can rebuild stale dist before stopping.
18041853
try {
1805-
stopManagedGateway(runCommand, update.checkout, gatewayDeploymentBefore);
1806-
// Retarget only after launchd has discarded the old ProgramArguments.
1807-
// Otherwise a later kickstart can revive its cached snapshot command.
1808-
proveGatewayStopped(update.checkout);
1854+
// launchctl can return before the job and listener have disappeared.
1855+
// Retarget only after bounded native proof prevents cached snapshot revival.
1856+
stopManagedGatewayAndProve(
1857+
runCommand,
1858+
update.checkout,
1859+
gatewayDeploymentBefore,
1860+
proveGatewayStopped,
1861+
sleep,
1862+
);
18091863
} catch (error) {
18101864
try {
18111865
resumeSuspension(

test/external-script-modules.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ declare module "*openclaw-live-updater/scripts/update-main.mjs" {
159159
home: string,
160160
stateDir?: string,
161161
): string | null;
162+
export function replaceLaunchAgentProgramArgument(
163+
programArguments: unknown,
164+
index: number,
165+
expected: string,
166+
replacement: string,
167+
): string[];
162168
export function repointManagedGatewayDeployment(
163169
checkout: string,
164170
deployment: GatewayDeployment,

test/scripts/openclaw-live-updater.test.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
parseGatewayLogAudit,
2828
parseLaunchctlArguments,
2929
prepareGatewaySuspension,
30+
replaceLaunchAgentProgramArgument,
3031
repointManagedGatewayDeployment,
3132
resolveManagedGatewayEntrypoint,
3233
runBuiltGatewayCall,
@@ -502,6 +503,29 @@ describe("openclaw live updater", () => {
502503
});
503504
});
504505

506+
test("replaces a wrapped LaunchAgent entrypoint without inserting another argument", () => {
507+
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
508+
const source = "/Users/test/openclaw/dist/index.js";
509+
const original = [
510+
"/bin/sh",
511+
"/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh",
512+
"/Users/test/.openclaw/service-env/ai.openclaw.gateway.env",
513+
"/opt/homebrew/bin/node",
514+
snapshot,
515+
"gateway",
516+
"--port",
517+
"18789",
518+
];
519+
520+
expect(replaceLaunchAgentProgramArgument(original, 4, snapshot, source)).toEqual([
521+
...original.slice(0, 4),
522+
source,
523+
...original.slice(5),
524+
]);
525+
expect(original).toHaveLength(8);
526+
expect(original[4]).toBe(snapshot);
527+
});
528+
505529
test("fails closed when Gateway service retargeting does not stick", () => {
506530
const checkout = "/Users/test/openclaw";
507531
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
@@ -790,26 +814,57 @@ describe("openclaw live updater", () => {
790814
expect(inspectBuildState(mirror, git(mirror, "rev-parse", "HEAD")).current).toBe(false);
791815
});
792816

793-
test("resumes a prepared suspension when the managed Gateway stop fails", () => {
817+
test("accepts native stopped proof when the stop command reports an error", () => {
794818
const { root, mirror } = makeFixture();
795819
mkdirSync(path.join(mirror, "node_modules"));
820+
const commands = fakeCommands(mirror);
796821
const resumed: string[] = [];
797822

823+
const output = maintainFixture(
824+
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
825+
{
826+
runCommand(command: string, args: string[]) {
827+
if (command === process.execPath && args.includes("stop")) {
828+
throw new Error("stop failed after stopping");
829+
}
830+
commands.runCommand(command, args);
831+
},
832+
resumeGatewaySuspension: (_checkout: string, suspensionId: string) => {
833+
resumed.push(suspensionId);
834+
},
835+
},
836+
);
837+
838+
expect(output.ok).toBe(true);
839+
expect(resumed).toEqual([]);
840+
});
841+
842+
test("resumes a prepared suspension when stopped proof never converges", () => {
843+
const { root, mirror } = makeFixture();
844+
mkdirSync(path.join(mirror, "node_modules"));
845+
const resumed: string[] = [];
846+
let proofAttempts = 0;
847+
let sleepAttempts = 0;
848+
798849
expect(() =>
799850
maintainFixture(
800851
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
801852
{
802-
runCommand(command: string, args: string[]) {
803-
if (command === process.execPath && args.includes("stop")) {
804-
throw new Error("stop failed");
805-
}
853+
proveGatewayStopped: () => {
854+
proofAttempts += 1;
855+
throw new Error("listener still present");
856+
},
857+
sleep: () => {
858+
sleepAttempts += 1;
806859
},
807860
resumeGatewaySuspension: (_checkout: string, suspensionId: string) => {
808861
resumed.push(suspensionId);
809862
},
810863
},
811864
),
812-
).toThrow("stop failed");
865+
).toThrow("native stopped proof did not converge");
866+
expect(proofAttempts).toBe(100);
867+
expect(sleepAttempts).toBe(99);
813868
expect(resumed).toEqual(["fixture-suspension"]);
814869
});
815870

@@ -1138,7 +1193,7 @@ describe("openclaw live updater", () => {
11381193
proveGatewayStopped: () => {
11391194
stoppedProofAttempts += 1;
11401195
commands.calls.push("prove gateway stopped");
1141-
if (stoppedProofAttempts === 1) {
1196+
if (stoppedProofAttempts <= 2) {
11421197
throw new Error("snapshot still owns its listener");
11431198
}
11441199
return {
@@ -1148,6 +1203,9 @@ describe("openclaw live updater", () => {
11481203
proofSource: "fixture",
11491204
};
11501205
},
1206+
sleep: (ms: number) => {
1207+
commands.calls.push(`sleep ${ms}`);
1208+
},
11511209
repointGatewayDeployment: (
11521210
_checkout: string,
11531211
deployment: { entrypoint: string; invocationPrefix: string[] },
@@ -1171,7 +1229,7 @@ describe("openclaw live updater", () => {
11711229
);
11721230

11731231
expect(controlEntrypoint).toBe(source);
1174-
expect(stoppedProofAttempts).toBe(2);
1232+
expect(stoppedProofAttempts).toBe(3);
11751233
expect(output.gatewayDeployment).toMatchObject({
11761234
changed: true,
11771235
entrypoint: source,
@@ -1184,6 +1242,8 @@ describe("openclaw live updater", () => {
11841242
"pnpm build",
11851243
`/bin/launchctl bootout gui/${uid}/ai.openclaw.gateway`,
11861244
"prove gateway stopped",
1245+
"sleep 100",
1246+
"prove gateway stopped",
11871247
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
11881248
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
11891249
]);

0 commit comments

Comments
 (0)