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

* 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:
tayoun 2026-07-09 11:55:23 -04:00 committed by GitHub
parent f6c16d22b4
commit 4f9a371c95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 488 additions and 19 deletions

View file

@ -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", () => {

View file

@ -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(