From 4f9a371c9530e9d2eb2912a44054a44228a59612 Mon Sep 17 00:00:00 2001 From: tayoun <39609208+tayoun@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:55:23 -0400 Subject: [PATCH] fix(launchd): verify profile updater jobs by metadata (#97264) * fix(launchd): verify profile updater jobs by metadata * fix(launchd): bind updater proof to job metadata --------- Co-authored-by: Peter Steinberger Co-authored-by: Peter Steinberger --- src/daemon/launchd.test.ts | 338 +++++++++++++++++++++++++++++++++++++ src/daemon/launchd.ts | 169 ++++++++++++++++--- 2 files changed, 488 insertions(+), 19 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3e88ac731c3..32dde31e13e 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -96,6 +96,52 @@ function createDefaultLaunchdEnv(): Record { }; } +function createTestLaunchAgentPlist(params: { + label: string; + programArguments: string[]; + environment?: Record; +}): string { + const argsXml = params.programArguments.map((arg) => ` ${arg}`).join("\n"); + const envXml = params.environment + ? [ + " EnvironmentVariables", + " ", + ...Object.entries(params.environment).flatMap(([key, value]) => [ + ` ${key}`, + ` ${value}`, + ]), + " ", + ].join("\n") + : ""; + return [ + '', + '', + " ", + " Label", + ` ${params.label}`, + " ProgramArguments", + " ", + argsXml, + " ", + envXml, + " ", + "", + "", + ].join("\n"); +} + +function setLaunchAgentPlist(params: { + env: Record; + label: string; + programArguments: string[]; + environment?: Record; +}): void { + state.files.set( + `${params.env.HOME}/Library/LaunchAgents/${params.label}.plist`, + createTestLaunchAgentPlist(params), + ); +} + async function withProcessEnv( overrides: Record, fn: () => Promise, @@ -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", () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 3633deb79bd..56941e9b033 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 { + const jobs: Array = []; 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 | 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 { + 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 { @@ -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 { - const normalizedLabel = normalizeOpenClawUpdateLaunchdLabel(label); - if (process.platform !== "darwin" || !normalizedLabel) { +async function disableOpenClawUpdateLaunchdJobCandidate(params: { + candidate: OpenClawUpdateLaunchdLabelCandidate; + env: NodeJS.ProcessEnv; + trustCurrentEnvMarker: boolean; +}): Promise { + 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 { + 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 { - 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(