Skip to content

Commit 4f9a371

Browse files
tayounsteipete
andauthored
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 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent f6c16d2 commit 4f9a371

2 files changed

Lines changed: 488 additions & 19 deletions

File tree

src/daemon/launchd.test.ts

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,52 @@ function createDefaultLaunchdEnv(): Record<string, string | undefined> {
9696
};
9797
}
9898

99+
function createTestLaunchAgentPlist(params: {
100+
label: string;
101+
programArguments: string[];
102+
environment?: Record<string, string>;
103+
}): string {
104+
const argsXml = params.programArguments.map((arg) => ` <string>${arg}</string>`).join("\n");
105+
const envXml = params.environment
106+
? [
107+
" <key>EnvironmentVariables</key>",
108+
" <dict>",
109+
...Object.entries(params.environment).flatMap(([key, value]) => [
110+
` <key>${key}</key>`,
111+
` <string>${value}</string>`,
112+
]),
113+
" </dict>",
114+
].join("\n")
115+
: "";
116+
return [
117+
'<?xml version="1.0" encoding="UTF-8"?>',
118+
'<plist version="1.0">',
119+
" <dict>",
120+
" <key>Label</key>",
121+
` <string>${params.label}</string>`,
122+
" <key>ProgramArguments</key>",
123+
" <array>",
124+
argsXml,
125+
" </array>",
126+
envXml,
127+
" </dict>",
128+
"</plist>",
129+
"",
130+
].join("\n");
131+
}
132+
133+
function setLaunchAgentPlist(params: {
134+
env: Record<string, string | undefined>;
135+
label: string;
136+
programArguments: string[];
137+
environment?: Record<string, string>;
138+
}): void {
139+
state.files.set(
140+
`${params.env.HOME}/Library/LaunchAgents/${params.label}.plist`,
141+
createTestLaunchAgentPlist(params),
142+
);
143+
}
144+
99145
async function withProcessEnv<T>(
100146
overrides: Record<string, string | undefined>,
101147
fn: () => Promise<T>,
@@ -478,6 +524,7 @@ describe("launchctl list detection", () => {
478524
"- 127 ai.openclaw.update.2026.5.12",
479525
"- 0 ai.openclaw.manual-update.1717168800",
480526
"8142 0 ai.openclaw.update.2026.5.13-beta.1",
527+
"915 0 ai.openclaw.tayoun.update.20260625T201026-0400",
481528
"- 0 ai.openclaw.manual-updater.1717168800",
482529
"- 0 com.example.other",
483530
].join("\n"),
@@ -516,6 +563,169 @@ describe("launchctl list detection", () => {
516563
},
517564
);
518565

566+
it.runIf(process.platform === "darwin")(
567+
"reports profile-scoped updater jobs only when launchd metadata confirms an update command",
568+
async () => {
569+
const env = createDefaultLaunchdEnv();
570+
const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400";
571+
const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400";
572+
const nonOpenClawLabel = "ai.openclaw.fake.update.20260625T201026-0400";
573+
const prefixedCliLabel = "ai.openclaw.helper.update.20260625T201026-0400";
574+
state.listOutput = [
575+
`4321 0 ${updaterLabel}`,
576+
`9876 0 ${gatewayLikeLabel}`,
577+
`2468 0 ${nonOpenClawLabel}`,
578+
`1357 0 ${prefixedCliLabel}`,
579+
].join("\n");
580+
setLaunchAgentPlist({
581+
env,
582+
label: updaterLabel,
583+
programArguments: ["/opt/homebrew/bin/openclaw", "update", "--yes", "--json"],
584+
});
585+
setLaunchAgentPlist({
586+
env,
587+
label: gatewayLikeLabel,
588+
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
589+
});
590+
setLaunchAgentPlist({
591+
env,
592+
label: nonOpenClawLabel,
593+
programArguments: ["/bin/echo", "update", "--yes"],
594+
});
595+
setLaunchAgentPlist({
596+
env,
597+
label: prefixedCliLabel,
598+
programArguments: ["/usr/local/bin/openclaw-helper", "update", "--yes"],
599+
});
600+
601+
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
602+
603+
expect(jobs).toEqual([
604+
{
605+
label: updaterLabel,
606+
pid: 4321,
607+
lastExitStatus: 0,
608+
},
609+
]);
610+
},
611+
);
612+
613+
it.runIf(process.platform === "darwin")(
614+
"accepts an explicit updater marker when confirming profile-scoped updater jobs",
615+
async () => {
616+
const env = createDefaultLaunchdEnv();
617+
const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400";
618+
state.listOutput = `4321 0 ${updaterLabel}`;
619+
setLaunchAgentPlist({
620+
env,
621+
label: updaterLabel,
622+
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
623+
environment: { OPENCLAW_UPDATE_RUN_HANDOFF: "1" },
624+
});
625+
626+
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
627+
628+
expect(jobs).toEqual([
629+
{
630+
label: updaterLabel,
631+
pid: 4321,
632+
lastExitStatus: 0,
633+
},
634+
]);
635+
},
636+
);
637+
638+
it.runIf(process.platform === "darwin")(
639+
"unwraps generated environment-wrapper metadata for profile-scoped updater jobs",
640+
async () => {
641+
const env = createDefaultLaunchdEnv();
642+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
643+
const envDir = "/Users/test/.openclaw-tayoun/service-env";
644+
const wrapperPath = `${envDir}/${label}-env-wrapper.sh`;
645+
const envFilePath = `${envDir}/${label}.env`;
646+
state.listOutput = `4321 0 ${label}`;
647+
state.files.set(envFilePath, "export PATH='/opt/homebrew/bin:/usr/bin'\n");
648+
setLaunchAgentPlist({
649+
env,
650+
label,
651+
programArguments: [
652+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
653+
wrapperPath,
654+
envFilePath,
655+
"/opt/homebrew/bin/openclaw",
656+
"update",
657+
"--yes",
658+
],
659+
});
660+
661+
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
662+
663+
expect(jobs).toEqual([
664+
{
665+
label,
666+
pid: 4321,
667+
lastExitStatus: 0,
668+
},
669+
]);
670+
},
671+
);
672+
673+
it.runIf(process.platform === "darwin")(
674+
"reads the updater marker from a generated environment file",
675+
async () => {
676+
const env = createDefaultLaunchdEnv();
677+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
678+
const envDir = "/Users/test/.openclaw-tayoun/service-env";
679+
const wrapperPath = `${envDir}/${label}-env-wrapper.sh`;
680+
const envFilePath = `${envDir}/${label}.env`;
681+
state.listOutput = `4321 0 ${label}`;
682+
state.files.set(envFilePath, "export OPENCLAW_UPDATE_RUN_HANDOFF='1'\n");
683+
setLaunchAgentPlist({
684+
env,
685+
label,
686+
programArguments: [
687+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
688+
wrapperPath,
689+
envFilePath,
690+
"/opt/homebrew/bin/openclaw",
691+
"gateway",
692+
"run",
693+
],
694+
});
695+
696+
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
697+
698+
expect(jobs).toEqual([
699+
{
700+
label,
701+
pid: 4321,
702+
lastExitStatus: 0,
703+
},
704+
]);
705+
},
706+
);
707+
708+
it.runIf(process.platform === "darwin")(
709+
"does not use the scanner process marker to confirm other profile-scoped jobs",
710+
async () => {
711+
const env = {
712+
...createDefaultLaunchdEnv(),
713+
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
714+
};
715+
const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400";
716+
state.listOutput = `9876 0 ${gatewayLikeLabel}`;
717+
setLaunchAgentPlist({
718+
env,
719+
label: gatewayLikeLabel,
720+
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
721+
});
722+
723+
const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv);
724+
725+
expect(jobs).toEqual([]);
726+
},
727+
);
728+
519729
it.runIf(process.platform === "darwin")(
520730
"does not report current gateway labels that collide with manual update labels",
521731
async () => {
@@ -653,6 +863,117 @@ describe("launchctl list detection", () => {
653863
},
654864
);
655865

866+
it.runIf(process.platform === "darwin")(
867+
"disables current profile-scoped updater launchd jobs only after metadata confirmation",
868+
async () => {
869+
const env = createDefaultLaunchdEnv();
870+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
871+
setLaunchAgentPlist({
872+
env,
873+
label,
874+
programArguments: ["/usr/local/bin/node", "/opt/openclaw/openclaw.mjs", "update", "--yes"],
875+
});
876+
877+
await expect(
878+
disableCurrentOpenClawUpdateLaunchdJob({
879+
...env,
880+
LAUNCH_JOB_LABEL: label,
881+
}),
882+
).resolves.toBe(true);
883+
884+
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
885+
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
886+
},
887+
);
888+
889+
it.runIf(process.platform === "darwin")(
890+
"lets a profile-scoped updater self-disarm from launchd runtime metadata",
891+
async () => {
892+
const env = createDefaultLaunchdEnv();
893+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
894+
895+
await expect(
896+
disableCurrentOpenClawUpdateLaunchdJob({
897+
...env,
898+
LAUNCH_JOB_LABEL: label,
899+
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
900+
}),
901+
).resolves.toBe(true);
902+
903+
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
904+
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
905+
},
906+
);
907+
908+
it.runIf(process.platform === "darwin")(
909+
"requires plist proof for a configured label preserved by an update handoff",
910+
async () => {
911+
const env = createDefaultLaunchdEnv();
912+
const label = "ai.openclaw.dev.team.update.20260625T201026-0400";
913+
setLaunchAgentPlist({
914+
env,
915+
label,
916+
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
917+
});
918+
919+
await expect(
920+
disableCurrentOpenClawUpdateLaunchdJob({
921+
...env,
922+
OPENCLAW_LAUNCHD_LABEL: label,
923+
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
924+
}),
925+
).resolves.toBe(false);
926+
927+
expect(state.launchctlCalls).toEqual([]);
928+
},
929+
);
930+
931+
it.runIf(process.platform === "darwin")(
932+
"disables a configured profile-scoped updater only with confirming plist metadata",
933+
async () => {
934+
const env = createDefaultLaunchdEnv();
935+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
936+
setLaunchAgentPlist({
937+
env,
938+
label,
939+
programArguments: ["/opt/homebrew/bin/openclaw", "update", "--yes"],
940+
});
941+
942+
await expect(
943+
disableCurrentOpenClawUpdateLaunchdJob({
944+
...env,
945+
OPENCLAW_LAUNCHD_LABEL: label,
946+
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
947+
}),
948+
).resolves.toBe(true);
949+
950+
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
951+
expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]);
952+
},
953+
);
954+
955+
it.runIf(process.platform === "darwin")(
956+
"does not disable profile-scoped gateway labels without updater metadata",
957+
async () => {
958+
const env = createDefaultLaunchdEnv();
959+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
960+
setLaunchAgentPlist({
961+
env,
962+
label,
963+
programArguments: ["/opt/homebrew/bin/openclaw", "gateway", "run"],
964+
});
965+
966+
await expect(
967+
disableCurrentOpenClawUpdateLaunchdJob({
968+
...env,
969+
LAUNCH_JOB_LABEL: label,
970+
}),
971+
).resolves.toBe(false);
972+
973+
expect(state.launchctlCalls).toEqual([]);
974+
},
975+
);
976+
656977
it.runIf(process.platform === "darwin")(
657978
"does not disable custom gateway launchd labels under the manual-update prefix",
658979
async () => {
@@ -705,6 +1026,23 @@ describe("launchctl list detection", () => {
7051026
`${domain}/ai.openclaw.manual-update.1717168800`,
7061027
]);
7071028
});
1029+
1030+
it.runIf(process.platform === "darwin")(
1031+
"does not let the process marker bypass metadata for an explicit profile job",
1032+
async () => {
1033+
const env = createDefaultLaunchdEnv();
1034+
const label = "ai.openclaw.tayoun.update.20260625T201026-0400";
1035+
1036+
await expect(
1037+
disableOpenClawUpdateLaunchdJob(label, {
1038+
...env,
1039+
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
1040+
}),
1041+
).resolves.toBe(false);
1042+
1043+
expect(state.launchctlCalls).toEqual([]);
1044+
},
1045+
);
7081046
});
7091047

7101048
describe("launchd bootstrap repair", () => {

0 commit comments

Comments
 (0)