1+ import path from "node:path" ;
12import { PassThrough } from "node:stream" ;
23import { beforeEach , describe , expect , it , vi } from "vitest" ;
34import {
@@ -49,6 +50,19 @@ const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
4950) ;
5051const defaultProgramArguments = [ "node" , "-e" , "process.exit(0)" ] ;
5152
53+ function resolveDisableMarkerPath (
54+ env : Record < string , string | undefined > ,
55+ label = "ai.openclaw.gateway" ,
56+ ) {
57+ const profile = env . OPENCLAW_PROFILE ?. trim ( ) ;
58+ const suffix = ! profile || profile . toLowerCase ( ) === "default" ? "" : `-${ profile } ` ;
59+ return path . join (
60+ env . OPENCLAW_STATE_DIR ?? path . join ( env . HOME ?? "/Users/test" , `.openclaw${ suffix } ` ) ,
61+ "service" ,
62+ `${ encodeURIComponent ( label ) } .launchd-disabled-by-openclaw` ,
63+ ) ;
64+ }
65+
5266function expectLaunchctlEnableBootstrapOrder ( env : Record < string , string | undefined > ) {
5367 const domain = typeof process . getuid === "function" ? `gui/${ process . getuid ( ) } ` : "gui/501" ;
5468 const label = "ai.openclaw.gateway" ;
@@ -318,19 +332,24 @@ describe("launchctl list detection", () => {
318332} ) ;
319333
320334describe ( "launchd bootstrap repair" , ( ) => {
321- it ( "enables, bootstraps, and kickstarts the resolved label" , async ( ) => {
335+ it ( "bootstraps and kickstarts the resolved label without enabling unrelated disabled state " , async ( ) => {
322336 const env : Record < string , string | undefined > = {
323337 HOME : "/Users/test" ,
324338 OPENCLAW_PROFILE : "default" ,
325339 } ;
326340 const repair = await repairLaunchAgentBootstrap ( { env } ) ;
327341 expect ( repair ) . toEqual ( { ok : true , status : "repaired" } ) ;
328342
329- const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder ( env ) ;
343+ const domain = typeof process . getuid === "function" ? `gui/${ process . getuid ( ) } ` : "gui/501" ;
344+ const serviceId = `${ domain } /ai.openclaw.gateway` ;
345+ const bootstrapIndex = state . launchctlCalls . findIndex (
346+ ( c ) => c [ 0 ] === "bootstrap" && c [ 1 ] === domain ,
347+ ) ;
330348 const kickstartIndex = state . launchctlCalls . findIndex (
331349 ( c ) => c [ 0 ] === "kickstart" && c [ 1 ] === "-k" && c [ 2 ] === serviceId ,
332350 ) ;
333351
352+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
334353 expect ( kickstartIndex ) . toBeGreaterThanOrEqual ( 0 ) ;
335354 expect ( bootstrapIndex ) . toBeLessThan ( kickstartIndex ) ;
336355 } ) ;
@@ -396,6 +415,30 @@ describe("launchd bootstrap repair", () => {
396415 detail : "launchctl kickstart failed: permission denied" ,
397416 } ) ;
398417 } ) ;
418+
419+ it ( "re-enables when the disabled marker shows OpenClaw owns the stop state" , async ( ) => {
420+ const env : Record < string , string | undefined > = {
421+ HOME : "/Users/test" ,
422+ OPENCLAW_PROFILE : "default" ,
423+ } ;
424+ state . files . set ( resolveDisableMarkerPath ( env ) , "disabled_by_openclaw\n" ) ;
425+
426+ await repairLaunchAgentBootstrap ( { env } ) ;
427+
428+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( true ) ;
429+ expect ( state . files . has ( resolveDisableMarkerPath ( env ) ) ) . toBe ( false ) ;
430+ } ) ;
431+
432+ it ( "allows explicit repairs to force re-enable disabled services" , async ( ) => {
433+ const env : Record < string , string | undefined > = {
434+ HOME : "/Users/test" ,
435+ OPENCLAW_PROFILE : "default" ,
436+ } ;
437+
438+ await repairLaunchAgentBootstrap ( { env, forceEnable : true } ) ;
439+
440+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( true ) ;
441+ } ) ;
399442} ) ;
400443
401444describe ( "launchd install" , ( ) => {
@@ -492,6 +535,7 @@ describe("launchd install", () => {
492535 expect ( state . launchctlCalls ) . toContainEqual ( [ "disable" , serviceId ] ) ;
493536 expect ( state . launchctlCalls ) . toContainEqual ( [ "stop" , "ai.openclaw.gateway" ] ) ;
494537 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootout" ) ) . toBe ( false ) ;
538+ expect ( state . files . has ( resolveDisableMarkerPath ( env ) ) ) . toBe ( true ) ;
495539 expect ( output ) . toContain ( "Stopped LaunchAgent" ) ;
496540 } ) ;
497541
@@ -508,6 +552,7 @@ describe("launchd install", () => {
508552
509553 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "stop" ) ) . toBe ( false ) ;
510554 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootout" ) ) . toBe ( true ) ;
555+ expect ( state . files . has ( resolveDisableMarkerPath ( env ) ) ) . toBe ( false ) ;
511556 expect ( output ) . toContain ( "Stopped LaunchAgent (degraded)" ) ;
512557 expect ( output ) . toContain ( "used bootout fallback" ) ;
513558 } ) ;
@@ -529,6 +574,22 @@ describe("launchd install", () => {
529574 expect ( output ) . toContain ( "did not fully stop the service" ) ;
530575 } ) ;
531576
577+ it ( "falls back to bootout when launchctl stop itself errors" , async ( ) => {
578+ const env = createDefaultLaunchdEnv ( ) ;
579+ const stdout = new PassThrough ( ) ;
580+ let output = "" ;
581+ state . stopError = "stop failed due to transient launchd error" ;
582+ stdout . on ( "data" , ( chunk : Buffer ) => {
583+ output += chunk . toString ( ) ;
584+ } ) ;
585+
586+ await stopLaunchAgent ( { env, stdout } ) ;
587+
588+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootout" ) ) . toBe ( true ) ;
589+ expect ( output ) . toContain ( "Stopped LaunchAgent (degraded)" ) ;
590+ expect ( output ) . toContain ( "launchctl stop failed; used bootout fallback" ) ;
591+ } ) ;
592+
532593 it ( "falls back to bootout when launchctl print cannot confirm the stop state" , async ( ) => {
533594 const env = createDefaultLaunchdEnv ( ) ;
534595 const stdout = new PassThrough ( ) ;
@@ -553,10 +614,26 @@ describe("launchd install", () => {
553614 state . bootoutError = "launchctl bootout permission denied" ;
554615
555616 await expect ( stopLaunchAgent ( { env, stdout : new PassThrough ( ) } ) ) . rejects . toThrow (
556- "launchctl print could not confirm stop: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied" ,
617+ "launchctl print could not confirm stop; used bootout fallback and left service unloaded : launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied" ,
557618 ) ;
558619 } ) ;
559620
621+ it ( "sanitizes launchctl details before writing warnings" , async ( ) => {
622+ const env = createDefaultLaunchdEnv ( ) ;
623+ const stdout = new PassThrough ( ) ;
624+ let output = "" ;
625+ state . disableError = "boom\n\u001b[31mred\u001b[0m\tmsg" ;
626+ stdout . on ( "data" , ( chunk : Buffer ) => {
627+ output += chunk . toString ( ) ;
628+ } ) ;
629+
630+ await stopLaunchAgent ( { env, stdout } ) ;
631+
632+ expect ( output ) . not . toContain ( "\u001b[31m" ) ;
633+ expect ( output ) . not . toContain ( "\nred\n" ) ;
634+ expect ( output ) . toContain ( "boom red msg" ) ;
635+ } ) ;
636+
560637 it ( "restarts LaunchAgent with kickstart and no bootout" , async ( ) => {
561638 const env = {
562639 ...createDefaultLaunchdEnv ( ) ,
@@ -572,12 +649,30 @@ describe("launchd install", () => {
572649 const serviceId = `${ domain } /${ label } ` ;
573650 expect ( result ) . toEqual ( { outcome : "completed" } ) ;
574651 expect ( cleanStaleGatewayProcessesSync ) . toHaveBeenCalledWith ( 18789 ) ;
575- expect ( state . launchctlCalls ) . toContainEqual ( [ "enable" , serviceId ] ) ;
652+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
576653 expect ( state . launchctlCalls ) . toContainEqual ( [ "kickstart" , "-k" , serviceId ] ) ;
577654 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootout" ) ) . toBe ( false ) ;
578655 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootstrap" ) ) . toBe ( false ) ;
579656 } ) ;
580657
658+ it ( "re-enables before restart when OpenClaw owns the persisted disabled state" , async ( ) => {
659+ const env = {
660+ ...createDefaultLaunchdEnv ( ) ,
661+ OPENCLAW_GATEWAY_PORT : "18789" ,
662+ } ;
663+ const domain = typeof process . getuid === "function" ? `gui/${ process . getuid ( ) } ` : "gui/501" ;
664+ const serviceId = `${ domain } /ai.openclaw.gateway` ;
665+ state . files . set ( resolveDisableMarkerPath ( env ) , "disabled_by_openclaw\n" ) ;
666+
667+ await restartLaunchAgent ( {
668+ env,
669+ stdout : new PassThrough ( ) ,
670+ } ) ;
671+
672+ expect ( state . launchctlCalls ) . toContainEqual ( [ "enable" , serviceId ] ) ;
673+ expect ( state . files . has ( resolveDisableMarkerPath ( env ) ) ) . toBe ( false ) ;
674+ } ) ;
675+
581676 it ( "uses the configured gateway port for stale cleanup" , async ( ) => {
582677 const env = {
583678 ...createDefaultLaunchdEnv ( ) ,
@@ -614,12 +709,15 @@ describe("launchd install", () => {
614709 stdout : new PassThrough ( ) ,
615710 } ) ;
616711
617- const { serviceId } = expectLaunchctlEnableBootstrapOrder ( env ) ;
712+ const domain = typeof process . getuid === "function" ? `gui/${ process . getuid ( ) } ` : "gui/501" ;
713+ const serviceId = `${ domain } /ai.openclaw.gateway` ;
618714 const kickstartCalls = state . launchctlCalls . filter (
619715 ( c ) => c [ 0 ] === "kickstart" && c [ 1 ] === "-k" && c [ 2 ] === serviceId ,
620716 ) ;
621717
622718 expect ( result ) . toEqual ( { outcome : "completed" } ) ;
719+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
720+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootstrap" ) ) . toBe ( true ) ;
623721 expect ( kickstartCalls ) . toHaveLength ( 2 ) ;
624722 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootout" ) ) . toBe ( false ) ;
625723 } ) ;
@@ -636,7 +734,7 @@ describe("launchd install", () => {
636734 } ) ,
637735 ) . rejects . toThrow ( "launchctl kickstart failed: Input/output error" ) ;
638736
639- expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( true ) ;
737+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
640738 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootstrap" ) ) . toBe ( false ) ;
641739 } ) ;
642740
@@ -653,7 +751,7 @@ describe("launchd install", () => {
653751 } ) ,
654752 ) . rejects . toThrow ( "launchctl kickstart failed: Input/output error" ) ;
655753
656- expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( true ) ;
754+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
657755 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootstrap" ) ) . toBe ( true ) ;
658756 } ) ;
659757
@@ -669,7 +767,7 @@ describe("launchd install", () => {
669767 } ) ,
670768 ) . rejects . toThrow ( "launchctl kickstart failed: Input/output error" ) ;
671769
672- expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( true ) ;
770+ expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "enable" ) ) . toBe ( false ) ;
673771 expect ( state . launchctlCalls . some ( ( call ) => call [ 0 ] === "bootstrap" ) ) . toBe ( false ) ;
674772 } ) ;
675773
@@ -686,11 +784,32 @@ describe("launchd install", () => {
686784 expect ( launchdRestartHandoffState . scheduleDetachedLaunchdRestartHandoff ) . toHaveBeenCalledWith ( {
687785 env,
688786 mode : "kickstart" ,
787+ shouldEnable : false ,
689788 waitForPid : process . pid ,
789+ enableMarkerPath : undefined ,
690790 } ) ;
691791 expect ( state . launchctlCalls ) . toEqual ( [ ] ) ;
692792 } ) ;
693793
794+ it ( "passes marker-owned re-enable intent to the detached handoff" , async ( ) => {
795+ const env = createDefaultLaunchdEnv ( ) ;
796+ state . files . set ( resolveDisableMarkerPath ( env ) , "disabled_by_openclaw\n" ) ;
797+ launchdRestartHandoffState . isCurrentProcessLaunchdServiceLabel . mockReturnValue ( true ) ;
798+
799+ await restartLaunchAgent ( {
800+ env,
801+ stdout : new PassThrough ( ) ,
802+ } ) ;
803+
804+ expect ( launchdRestartHandoffState . scheduleDetachedLaunchdRestartHandoff ) . toHaveBeenCalledWith ( {
805+ env,
806+ mode : "kickstart" ,
807+ shouldEnable : true ,
808+ waitForPid : process . pid ,
809+ enableMarkerPath : resolveDisableMarkerPath ( env ) ,
810+ } ) ;
811+ } ) ;
812+
694813 it ( "shows actionable guidance when launchctl gui domain does not support bootstrap" , async ( ) => {
695814 state . bootstrapError = "Bootstrap failed: 125: Domain does not support specified action" ;
696815 const env = createDefaultLaunchdEnv ( ) ;
0 commit comments