mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(launchd): verify profile updater jobs by metadata (#97264)
Some checks are pending
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Native App Locale Refresh / Refresh native hi (push) Waiting to run
Native App Locale Refresh / Refresh native id (push) Waiting to run
Native App Locale Refresh / Refresh native it (push) Waiting to run
Native App Locale Refresh / Refresh native ja-JP (push) Waiting to run
Native App Locale Refresh / Refresh native ko (push) Waiting to run
Native App Locale Refresh / Refresh native nl (push) Waiting to run
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / native-i18n (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / QA Smoke CI (matrix) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 1/2) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 2/2) (push) Blocked by required conditions
CI / QA Smoke CI (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / plan (push) Waiting to run
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Waiting to run
Native App Locale Refresh / Refresh native de (push) Waiting to run
Native App Locale Refresh / Refresh native es (push) Waiting to run
Native App Locale Refresh / Refresh native fa (push) Waiting to run
Native App Locale Refresh / Refresh native fr (push) Waiting to run
Native App Locale Refresh / Refresh native zh-TW (push) Waiting to run
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Native App Locale Refresh / Refresh native pl (push) Waiting to run
Native App Locale Refresh / Refresh native pt-BR (push) Waiting to run
Native App Locale Refresh / Refresh native ru (push) Waiting to run
Native App Locale Refresh / Refresh native sv (push) Waiting to run
Native App Locale Refresh / Refresh native th (push) Waiting to run
Native App Locale Refresh / Refresh native tr (push) Waiting to run
Native App Locale Refresh / Refresh native uk (push) Waiting to run
Native App Locale Refresh / Refresh native vi (push) Waiting to run
Native App Locale Refresh / Refresh native zh-CN (push) Waiting to run
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Website Installer Sync / static (push) Waiting to run
Website Installer Sync / linux-docker (push) Waiting to run
Website Installer Sync / macos-installer (push) Waiting to run
Website Installer Sync / windows-installer (push) Waiting to run
Website Installer Sync / sync-website (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run
Some checks are pending
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Native App Locale Refresh / Refresh native hi (push) Waiting to run
Native App Locale Refresh / Refresh native id (push) Waiting to run
Native App Locale Refresh / Refresh native it (push) Waiting to run
Native App Locale Refresh / Refresh native ja-JP (push) Waiting to run
Native App Locale Refresh / Refresh native ko (push) Waiting to run
Native App Locale Refresh / Refresh native nl (push) Waiting to run
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / native-i18n (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / QA Smoke CI (matrix) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 1/2) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 2/2) (push) Blocked by required conditions
CI / QA Smoke CI (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / plan (push) Waiting to run
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Waiting to run
Native App Locale Refresh / Refresh native de (push) Waiting to run
Native App Locale Refresh / Refresh native es (push) Waiting to run
Native App Locale Refresh / Refresh native fa (push) Waiting to run
Native App Locale Refresh / Refresh native fr (push) Waiting to run
Native App Locale Refresh / Refresh native zh-TW (push) Waiting to run
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Native App Locale Refresh / Refresh native pl (push) Waiting to run
Native App Locale Refresh / Refresh native pt-BR (push) Waiting to run
Native App Locale Refresh / Refresh native ru (push) Waiting to run
Native App Locale Refresh / Refresh native sv (push) Waiting to run
Native App Locale Refresh / Refresh native th (push) Waiting to run
Native App Locale Refresh / Refresh native tr (push) Waiting to run
Native App Locale Refresh / Refresh native uk (push) Waiting to run
Native App Locale Refresh / Refresh native vi (push) Waiting to run
Native App Locale Refresh / Refresh native zh-CN (push) Waiting to run
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Website Installer Sync / static (push) Waiting to run
Website Installer Sync / linux-docker (push) Waiting to run
Website Installer Sync / macos-installer (push) Waiting to run
Website Installer Sync / windows-installer (push) Waiting to run
Website Installer Sync / sync-website (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run
* fix(launchd): verify profile updater jobs by metadata * fix(launchd): bind updater proof to job metadata --------- Co-authored-by: Peter Steinberger <peter@steipete.me> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
f6c16d22b4
commit
4f9a371c95
2 changed files with 488 additions and 19 deletions
|
|
@ -96,6 +96,52 @@ function createDefaultLaunchdEnv(): Record<string, string | undefined> {
|
|||
};
|
||||
}
|
||||
|
||||
function createTestLaunchAgentPlist(params: {
|
||||
label: string;
|
||||
programArguments: string[];
|
||||
environment?: Record<string, string>;
|
||||
}): string {
|
||||
const argsXml = params.programArguments.map((arg) => ` <string>${arg}</string>`).join("\n");
|
||||
const envXml = params.environment
|
||||
? [
|
||||
" <key>EnvironmentVariables</key>",
|
||||
" <dict>",
|
||||
...Object.entries(params.environment).flatMap(([key, value]) => [
|
||||
` <key>${key}</key>`,
|
||||
` <string>${value}</string>`,
|
||||
]),
|
||||
" </dict>",
|
||||
].join("\n")
|
||||
: "";
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0">',
|
||||
" <dict>",
|
||||
" <key>Label</key>",
|
||||
` <string>${params.label}</string>`,
|
||||
" <key>ProgramArguments</key>",
|
||||
" <array>",
|
||||
argsXml,
|
||||
" </array>",
|
||||
envXml,
|
||||
" </dict>",
|
||||
"</plist>",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function setLaunchAgentPlist(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
label: string;
|
||||
programArguments: string[];
|
||||
environment?: Record<string, string>;
|
||||
}): void {
|
||||
state.files.set(
|
||||
`${params.env.HOME}/Library/LaunchAgents/${params.label}.plist`,
|
||||
createTestLaunchAgentPlist(params),
|
||||
);
|
||||
}
|
||||
|
||||
async function withProcessEnv<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>,
|
||||
|
|
@ -478,6 +524,7 @@ describe("launchctl list detection", () => {
|
|||
"- 127 ai.openclaw.update.2026.5.12",
|
||||
"- 0 ai.openclaw.manual-update.1717168800",
|
||||
"8142 0 ai.openclaw.update.2026.5.13-beta.1",
|
||||
"915 0 ai.openclaw.tayoun.update.20260625T201026-0400",
|
||||
"- 0 ai.openclaw.manual-updater.1717168800",
|
||||
"- 0 com.example.other",
|
||||
].join("\n"),
|
||||
|
|
@ -516,6 +563,169 @@ describe("launchctl list detection", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"reports profile-scoped updater jobs only when launchd metadata confirms an update command",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400";
|
||||
const nonOpenClawLabel = "ai.openclaw.fake.update.20260625T201026-0400";
|
||||
const prefixedCliLabel = "ai.openclaw.helper.update.20260625T201026-0400";
|
||||
state.listOutput = [
|
||||
`4321 0 ${updaterLabel}`,
|
||||
`9876 0 ${gatewayLikeLabel}`,
|
||||
`2468 0 ${nonOpenClawLabel}`,
|
||||
`1357 0 ${prefixedCliLabel}`,
|
||||
].join("\n");
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: updaterLabel,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "update", "--yes", "--json"],
|
||||
});
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: gatewayLikeLabel,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
|
||||
});
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: nonOpenClawLabel,
|
||||
programArguments: ["/bin/echo", "update", "--yes"],
|
||||
});
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: prefixedCliLabel,
|
||||
programArguments: ["/usr/local/bin/openclaw-helper", "update", "--yes"],
|
||||
});
|
||||
|
||||
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
label: updaterLabel,
|
||||
pid: 4321,
|
||||
lastExitStatus: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"accepts an explicit updater marker when confirming profile-scoped updater jobs",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
state.listOutput = `4321 0 ${updaterLabel}`;
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: updaterLabel,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
|
||||
environment: { OPENCLAW_UPDATE_RUN_HANDOFF: "1" },
|
||||
});
|
||||
|
||||
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
label: updaterLabel,
|
||||
pid: 4321,
|
||||
lastExitStatus: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"unwraps generated environment-wrapper metadata for profile-scoped updater jobs",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
const envDir = "/Users/test/.openclaw-tayoun/service-env";
|
||||
const wrapperPath = `${envDir}/${label}-env-wrapper.sh`;
|
||||
const envFilePath = `${envDir}/${label}.env`;
|
||||
state.listOutput = `4321 0 ${label}`;
|
||||
state.files.set(envFilePath, "export PATH='/opt/homebrew/bin:/usr/bin'\n");
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: [
|
||||
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
|
||||
wrapperPath,
|
||||
envFilePath,
|
||||
"/opt/homebrew/bin/openclaw",
|
||||
"update",
|
||||
"--yes",
|
||||
],
|
||||
});
|
||||
|
||||
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
label,
|
||||
pid: 4321,
|
||||
lastExitStatus: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"reads the updater marker from a generated environment file",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
const envDir = "/Users/test/.openclaw-tayoun/service-env";
|
||||
const wrapperPath = `${envDir}/${label}-env-wrapper.sh`;
|
||||
const envFilePath = `${envDir}/${label}.env`;
|
||||
state.listOutput = `4321 0 ${label}`;
|
||||
state.files.set(envFilePath, "export OPENCLAW_UPDATE_RUN_HANDOFF='1'\n");
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: [
|
||||
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
|
||||
wrapperPath,
|
||||
envFilePath,
|
||||
"/opt/homebrew/bin/openclaw",
|
||||
"gateway",
|
||||
"run",
|
||||
],
|
||||
});
|
||||
|
||||
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
label,
|
||||
pid: 4321,
|
||||
lastExitStatus: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"does not use the scanner process marker to confirm other profile-scoped jobs",
|
||||
async () => {
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
};
|
||||
const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400";
|
||||
state.listOutput = `9876 0 ${gatewayLikeLabel}`;
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label: gatewayLikeLabel,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
|
||||
});
|
||||
|
||||
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
|
||||
|
||||
expect(jobs).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"does not report current gateway labels that collide with manual update labels",
|
||||
async () => {
|
||||
|
|
@ -653,6 +863,117 @@ describe("launchctl list detection", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"disables current profile-scoped updater launchd jobs only after metadata confirmation",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: ["/usr/local/bin/node", "/opt/openclaw/openclaw.mjs", "update", "--yes"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
disableCurrentOpenClawUpdateLaunchdJob({
|
||||
...env,
|
||||
LAUNCH_JOB_LABEL: label,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"lets a profile-scoped updater self-disarm from launchd runtime metadata",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
|
||||
await expect(
|
||||
disableCurrentOpenClawUpdateLaunchdJob({
|
||||
...env,
|
||||
LAUNCH_JOB_LABEL: label,
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"requires plist proof for a configured label preserved by an update handoff",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.dev.team.update.20260625T201026-0400";
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
disableCurrentOpenClawUpdateLaunchdJob({
|
||||
...env,
|
||||
OPENCLAW_LAUNCHD_LABEL: label,
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(state.launchctlCalls).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"disables a configured profile-scoped updater only with confirming plist metadata",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "update", "--yes"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
disableCurrentOpenClawUpdateLaunchdJob({
|
||||
...env,
|
||||
OPENCLAW_LAUNCHD_LABEL: label,
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"does not disable profile-scoped gateway labels without updater metadata",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
setLaunchAgentPlist({
|
||||
env,
|
||||
label,
|
||||
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
disableCurrentOpenClawUpdateLaunchdJob({
|
||||
...env,
|
||||
LAUNCH_JOB_LABEL: label,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(state.launchctlCalls).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"does not disable custom gateway launchd labels under the manual-update prefix",
|
||||
async () => {
|
||||
|
|
@ -705,6 +1026,23 @@ describe("launchctl list detection", () => {
|
|||
`${domain}/ai.openclaw.manual-update.1717168800`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"does not let the process marker bypass metadata for an explicit profile job",
|
||||
async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
|
||||
|
||||
await expect(
|
||||
disableOpenClawUpdateLaunchdJob(label, {
|
||||
...env,
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(state.launchctlCalls).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("launchd bootstrap repair", () => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ const LAUNCH_AGENT_ENV_DIR_NAME = "service-env";
|
|||
const LAUNCH_AGENT_STDERR_PATH = "/dev/null";
|
||||
const OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX = "ai.openclaw.update.";
|
||||
const OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN = /^ai\.openclaw\.manual-update\.\d+$/;
|
||||
const OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN =
|
||||
/^ai\.openclaw\.[A-Za-z0-9._-]+\.update\.[A-Za-z0-9._-]+$/;
|
||||
const OPENCLAW_DIRECT_CLI_NAMES = new Set(["openclaw", "openclaw.mjs"]);
|
||||
const OPENCLAW_NODE_RUNTIME_NAMES = new Set(["bun", "bun.exe", "node", "node.exe"]);
|
||||
const OPENCLAW_SCRIPT_NAMES = new Set(["openclaw.mjs"]);
|
||||
const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000;
|
||||
const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100;
|
||||
|
||||
|
|
@ -62,6 +67,11 @@ export type StaleOpenClawUpdateLaunchdJob = {
|
|||
lastExitStatus?: number;
|
||||
};
|
||||
|
||||
type OpenClawUpdateLaunchdLabelCandidate = {
|
||||
label: string;
|
||||
requiresMetadata: boolean;
|
||||
};
|
||||
|
||||
function normalizeOpenClawUpdateLaunchdLabel(label: unknown): string | null {
|
||||
if (typeof label !== "string") {
|
||||
return null;
|
||||
|
|
@ -75,6 +85,22 @@ function normalizeOpenClawUpdateLaunchdLabel(label: unknown): string | null {
|
|||
return OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeOpenClawUpdateLaunchdLabelCandidate(
|
||||
label: unknown,
|
||||
): OpenClawUpdateLaunchdLabelCandidate | null {
|
||||
const normalized = normalizeOpenClawUpdateLaunchdLabel(label);
|
||||
if (normalized) {
|
||||
return { label: normalized, requiresMetadata: false };
|
||||
}
|
||||
if (typeof label !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = label.trim();
|
||||
return OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed)
|
||||
? { label: trimmed, requiresMetadata: true }
|
||||
: null;
|
||||
}
|
||||
|
||||
function isCurrentGatewayLaunchdLabel(label: string, env: NodeJS.ProcessEnv): boolean {
|
||||
const gatewayProfileLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
|
||||
if (label === gatewayProfileLabel) {
|
||||
|
|
@ -92,19 +118,19 @@ function isCurrentGatewayLaunchdLabel(label: string, env: NodeJS.ProcessEnv): bo
|
|||
|
||||
function resolveCurrentOpenClawUpdateLaunchdJobLabel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
): OpenClawUpdateLaunchdLabelCandidate | null {
|
||||
for (const label of [
|
||||
env.LAUNCH_JOB_LABEL,
|
||||
env.LAUNCH_JOB_NAME,
|
||||
env.XPC_SERVICE_NAME,
|
||||
env.OPENCLAW_LAUNCHD_LABEL,
|
||||
]) {
|
||||
const normalized = normalizeOpenClawUpdateLaunchdLabel(label);
|
||||
if (normalized) {
|
||||
if (isCurrentGatewayLaunchdLabel(normalized, env)) {
|
||||
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label);
|
||||
if (candidate) {
|
||||
if (isCurrentGatewayLaunchdLabel(candidate.label, env)) {
|
||||
continue;
|
||||
}
|
||||
return normalized;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
@ -352,7 +378,15 @@ async function execLaunchctl(
|
|||
export function parseLaunchctlListOpenClawUpdateJobs(
|
||||
output: string,
|
||||
): StaleOpenClawUpdateLaunchdJob[] {
|
||||
const jobs: StaleOpenClawUpdateLaunchdJob[] = [];
|
||||
return parseLaunchctlListOpenClawUpdateJobCandidates(output)
|
||||
.filter((job) => !job.requiresMetadata)
|
||||
.map(({ requiresMetadata: _requiresMetadata, ...job }) => job);
|
||||
}
|
||||
|
||||
function parseLaunchctlListOpenClawUpdateJobCandidates(
|
||||
output: string,
|
||||
): Array<StaleOpenClawUpdateLaunchdJob & OpenClawUpdateLaunchdLabelCandidate> {
|
||||
const jobs: Array<StaleOpenClawUpdateLaunchdJob & OpenClawUpdateLaunchdLabelCandidate> = [];
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
|
|
@ -360,14 +394,15 @@ export function parseLaunchctlListOpenClawUpdateJobs(
|
|||
}
|
||||
const parts = line.split(/\s+/);
|
||||
const [pidRaw, statusRaw, ...labelParts] = parts;
|
||||
const label = normalizeOpenClawUpdateLaunchdLabel(labelParts.join(" "));
|
||||
if (!label) {
|
||||
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(labelParts.join(" "));
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const pid = pidRaw === "-" ? undefined : parseStrictPositiveInteger(pidRaw ?? "");
|
||||
const lastExitStatus = parseStrictInteger(statusRaw ?? "");
|
||||
jobs.push({
|
||||
label,
|
||||
label: candidate.label,
|
||||
requiresMetadata: candidate.requiresMetadata,
|
||||
...(pid !== undefined ? { pid } : {}),
|
||||
...(lastExitStatus !== undefined ? { lastExitStatus } : {}),
|
||||
});
|
||||
|
|
@ -375,6 +410,49 @@ export function parseLaunchctlListOpenClawUpdateJobs(
|
|||
return jobs.toSorted((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function hasOpenClawUpdateLaunchdMarker(env: Record<string, string | undefined> | undefined) {
|
||||
return env?.OPENCLAW_UPDATE_RUN_HANDOFF?.trim() === "1";
|
||||
}
|
||||
|
||||
function isOpenClawUpdateCommandPrefix(programArguments: string[], updateIndex: number): boolean {
|
||||
if (updateIndex === 1) {
|
||||
const cliName = path.basename(programArguments[0] ?? "").toLowerCase();
|
||||
return OPENCLAW_DIRECT_CLI_NAMES.has(cliName);
|
||||
}
|
||||
if (updateIndex !== 2) {
|
||||
return false;
|
||||
}
|
||||
const runtimeName = path.basename(programArguments[0] ?? "").toLowerCase();
|
||||
const entryName = path.basename(programArguments[1] ?? "").toLowerCase();
|
||||
return OPENCLAW_NODE_RUNTIME_NAMES.has(runtimeName) && OPENCLAW_SCRIPT_NAMES.has(entryName);
|
||||
}
|
||||
|
||||
function isOpenClawUpdateProgramArguments(programArguments: string[] | undefined): boolean {
|
||||
if (!Array.isArray(programArguments) || programArguments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const updateIndex = programArguments.findIndex((arg) => arg.trim() === "update");
|
||||
if (updateIndex < 0 || !programArguments.slice(updateIndex + 1).includes("--yes")) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isOpenClawUpdateCommandPrefix(programArguments, updateIndex) &&
|
||||
!programArguments.some((arg) => arg.trim() === "gateway")
|
||||
);
|
||||
}
|
||||
|
||||
async function isLaunchdJobConfirmedOpenClawUpdater(params: {
|
||||
label: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<boolean> {
|
||||
const plistPath = resolveLaunchAgentPlistPathForLabel(params.env, params.label);
|
||||
const command = await readLaunchAgentProgramArgumentsFromFile(plistPath);
|
||||
return (
|
||||
hasOpenClawUpdateLaunchdMarker(command?.environment) ||
|
||||
isOpenClawUpdateProgramArguments(command?.programArguments)
|
||||
);
|
||||
}
|
||||
|
||||
export async function findStaleOpenClawUpdateLaunchdJobs(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<StaleOpenClawUpdateLaunchdJob[]> {
|
||||
|
|
@ -387,29 +465,82 @@ export async function findStaleOpenClawUpdateLaunchdJobs(
|
|||
}
|
||||
// Never report the active gateway label as stale even when a wrapper exposes
|
||||
// update-like launchd metadata through the current environment.
|
||||
return parseLaunchctlListOpenClawUpdateJobs(result.stdout).filter(
|
||||
(job) => !isCurrentGatewayLaunchdLabel(job.label, env),
|
||||
);
|
||||
const jobs: StaleOpenClawUpdateLaunchdJob[] = [];
|
||||
for (const job of parseLaunchctlListOpenClawUpdateJobCandidates(result.stdout)) {
|
||||
if (isCurrentGatewayLaunchdLabel(job.label, env)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
job.requiresMetadata &&
|
||||
!(await isLaunchdJobConfirmedOpenClawUpdater({ label: job.label, env }))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
jobs.push({
|
||||
label: job.label,
|
||||
...(job.pid !== undefined ? { pid: job.pid } : {}),
|
||||
...(job.lastExitStatus !== undefined ? { lastExitStatus: job.lastExitStatus } : {}),
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export async function disableOpenClawUpdateLaunchdJob(label: string): Promise<boolean> {
|
||||
const normalizedLabel = normalizeOpenClawUpdateLaunchdLabel(label);
|
||||
if (process.platform !== "darwin" || !normalizedLabel) {
|
||||
async function disableOpenClawUpdateLaunchdJobCandidate(params: {
|
||||
candidate: OpenClawUpdateLaunchdLabelCandidate;
|
||||
env: NodeJS.ProcessEnv;
|
||||
trustCurrentEnvMarker: boolean;
|
||||
}): Promise<boolean> {
|
||||
if (process.platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
const serviceTarget = `${resolveGuiDomain()}/${assertValidLaunchAgentLabel(normalizedLabel)}`;
|
||||
if (
|
||||
params.candidate.requiresMetadata &&
|
||||
!(
|
||||
(params.trustCurrentEnvMarker && hasOpenClawUpdateLaunchdMarker(params.env)) ||
|
||||
(await isLaunchdJobConfirmedOpenClawUpdater({
|
||||
label: params.candidate.label,
|
||||
env: params.env,
|
||||
}))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const serviceTarget = `${resolveGuiDomain()}/${assertValidLaunchAgentLabel(params.candidate.label)}`;
|
||||
const result = await execLaunchctl(["disable", serviceTarget]);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
export async function disableOpenClawUpdateLaunchdJob(
|
||||
label: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<boolean> {
|
||||
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
return await disableOpenClawUpdateLaunchdJobCandidate({
|
||||
candidate,
|
||||
env,
|
||||
trustCurrentEnvMarker: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableCurrentOpenClawUpdateLaunchdJob(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<boolean> {
|
||||
const label = resolveCurrentOpenClawUpdateLaunchdJobLabel(env);
|
||||
if (!label) {
|
||||
const candidate = resolveCurrentOpenClawUpdateLaunchdJobLabel(env);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
return await disableOpenClawUpdateLaunchdJob(label);
|
||||
return await disableOpenClawUpdateLaunchdJobCandidate({
|
||||
candidate,
|
||||
env,
|
||||
// Detached handoffs preserve the configured label, so only launchd-backed
|
||||
// current-process identity may turn the ambient marker into proof.
|
||||
trustCurrentEnvMarker: isCurrentProcessLaunchdServiceLabel(candidate.label, env, {
|
||||
allowConfiguredLabelFallback: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseGatewayPortFromProgramArguments(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue