@@ -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+
99145async 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
7101048describe ( "launchd bootstrap repair" , ( ) => {
0 commit comments