1111import { execFile } from "node:child_process" ;
1212import { existsSync , readFileSync , writeFileSync } from "node:fs" ;
1313import * as fs from "node:fs/promises" ;
14- import { createConnection } from "node:net" ;
14+ import net , { createConnection } from "node:net" ;
1515import * as os from "node:os" ;
1616import * as path from "node:path" ;
1717import { promisify } from "node:util" ;
@@ -83,6 +83,8 @@ export interface LaunchdBootstrapEnv {
8383 openclawTmpDir : string ;
8484 /** Normalized proxy env propagated to controller/openclaw launchd services */
8585 proxyEnv : Record < string , string > ;
86+ /** Optional structured logger for packaged mode (console.log is lost in packaged builds) */
87+ log ?: ( message : string ) => void ;
8688}
8789
8890export interface LaunchdBootstrapResult {
@@ -278,20 +280,28 @@ function isProcessAlive(pid: number): boolean {
278280// Port occupier detection
279281// ---------------------------------------------------------------------------
280282
283+ /**
284+ * Check if a port is occupied by attempting to bind a temporary server.
285+ * Returns `{ pid: 0 }` if occupied, `null` if free.
286+ *
287+ * Uses net.createServer().listen() instead of lsof or net.connect because:
288+ * - lsof is blocked by macOS hardened runtime in packaged Electron apps
289+ * - net.connect conflicts with probePort (both use createConnection)
290+ */
281291async function detectPortOccupier (
282292 port : number ,
283293) : Promise < { pid : number } | null > {
284- try {
285- const { stdout } = await execFileAsync ( "lsof" , [
286- `-iTCP: ${ port } ` ,
287- "-sTCP:LISTEN" ,
288- "-t" ,
289- ] ) ;
290- const pid = Number . parseInt ( stdout . trim ( ) , 10 ) ;
291- return Number . isNaN ( pid ) ? null : { pid } ;
292- } catch {
293- return null ;
294- }
294+ return new Promise ( ( resolve ) => {
295+ const server = net . createServer ( ) ;
296+ server . once ( "error" , ( ) => {
297+ // EADDRINUSE or other bind failure — port is occupied
298+ resolve ( { pid : 0 } ) ;
299+ } ) ;
300+ server . listen ( port , "127.0.0.1" , ( ) => {
301+ // Successfully bound — port is free. Close immediately.
302+ server . close ( ( ) => resolve ( null ) ) ;
303+ } ) ;
304+ } ) ;
295305}
296306
297307/**
@@ -374,6 +384,7 @@ async function cleanupStalePlists(
374384export async function bootstrapWithLaunchd (
375385 env : LaunchdBootstrapEnv ,
376386) : Promise < LaunchdBootstrapResult > {
387+ const log = env . log ?? console . log ;
377388 const logDir = await ensureLogDir ( env . nexuHome ) ;
378389 const plistDir = env . plistDir ?? getDefaultPlistDir ( env . isDev ) ;
379390
@@ -617,7 +628,23 @@ export async function bootstrapWithLaunchd(
617628 }
618629
619630 if ( openclawRunning && useRecoveredPorts ) {
620- openclawHealthy = await probePort ( effectivePorts . openclawPort ) ;
631+ const portListening = await probePort ( effectivePorts . openclawPort ) ;
632+ // Port listening isn't enough — verify it's OUR openclaw by checking
633+ // that the launchd service env matches our expected token/state dir.
634+ // This prevents attaching to a global openclaw or ClawX on the same port.
635+ if ( portListening ) {
636+ const ocEnv = ( await launchd . getServiceStatus ( labels . openclaw ) ) . env ;
637+ const expectedToken = env . gatewayToken ;
638+ const runningToken = ocEnv ?. OPENCLAW_GATEWAY_TOKEN ;
639+ if ( expectedToken && runningToken && runningToken !== expectedToken ) {
640+ console . log (
641+ "OpenClaw port is listening but gateway token mismatch — not our instance" ,
642+ ) ;
643+ openclawHealthy = false ;
644+ } else {
645+ openclawHealthy = true ;
646+ }
647+ }
621648 if ( openclawHealthy ) {
622649 console . log ( "OpenClaw already running and healthy" ) ;
623650 } else {
@@ -644,12 +671,20 @@ export async function bootstrapWithLaunchd(
644671 }
645672 }
646673 if ( ! openclawHealthy ) {
674+ const preOccupier = await detectPortOccupier ( effectivePorts . openclawPort ) ;
675+ log (
676+ `[bootstrap] pre-findFreePort: openclawPort=${ effectivePorts . openclawPort } occupier=${ preOccupier ? `PID ${ preOccupier . pid } ` : "none" } ` ,
677+ ) ;
647678 const freePort = await findFreePort ( effectivePorts . openclawPort ) ;
648679 if ( freePort !== effectivePorts . openclawPort ) {
649680 console . log (
650681 `OpenClaw port ${ effectivePorts . openclawPort } occupied, using ${ freePort } ` ,
651682 ) ;
652683 effectivePorts . openclawPort = freePort ;
684+ } else {
685+ log (
686+ `[bootstrap] openclawPort ${ effectivePorts . openclawPort } appears free, keeping` ,
687+ ) ;
653688 }
654689 }
655690
@@ -697,6 +732,66 @@ export async function bootstrapWithLaunchd(
697732 if ( ! openclawHealthy ) {
698733 await ensureService ( labels . openclaw , "openclaw" ) ;
699734 await ensureRunning ( labels . openclaw , "openclaw" ) ;
735+
736+ // Verify our openclaw actually owns the port. Another launchd service
737+ // (e.g. global `ai.openclaw.gateway` with KeepAlive=true) may have
738+ // raced us and grabbed the port first. If so, pick a new port and
739+ // re-bootstrap our service.
740+ // Wait briefly for the port to be bound (our openclaw needs time to start).
741+ await new Promise ( ( r ) => setTimeout ( r , 2000 ) ) ;
742+ const occupier = await detectPortOccupier ( effectivePorts . openclawPort ) ;
743+ const ocStatus = await launchd . getServiceStatus ( labels . openclaw ) ;
744+ log (
745+ `[bootstrap] post-launch check: port=${ effectivePorts . openclawPort } occupied=${ ! ! occupier } ocStatus=${ JSON . stringify ( { pid : ocStatus . pid , status : ocStatus . status } ) } ` ,
746+ ) ;
747+ // Port is stolen if someone is listening but our service crashed or
748+ // isn't running. We can't compare PIDs (lsof blocked by hardened
749+ // runtime), so check if our service is healthy instead.
750+ const portStolen =
751+ occupier && ( ocStatus . pid == null || ocStatus . status !== "running" ) ;
752+ log ( `[bootstrap] portStolen=${ portStolen } ` ) ;
753+ if ( portStolen ) {
754+ log (
755+ `[bootstrap] OpenClaw port ${ effectivePorts . openclawPort } stolen by PID ${ occupier . pid } (ours is ${ ocStatus . pid } ), reassigning` ,
756+ ) ;
757+ // Bootout crashed openclaw and wait for launchd to fully release it.
758+ // Use bootoutAndWaitForExit which captures the PID before bootout
759+ // so waitForExit can SIGKILL if needed (plain waitForExit without
760+ // knownPid exits early on "unknown" status).
761+ await launchd
762+ . bootoutAndWaitForExit ( labels . openclaw , 5000 )
763+ . catch ( ( ) => { } ) ;
764+
765+ const newPort = await findFreePort ( effectivePorts . openclawPort + 1 ) ;
766+ effectivePorts . openclawPort = newPort ;
767+
768+ // Regenerate plists with new port for both openclaw and controller
769+ const retryPlistEnv : PlistEnv = {
770+ ...plistEnv ,
771+ openclawPort : newPort ,
772+ } ;
773+
774+ // Re-bootstrap openclaw on new port
775+ const retryPlist = generatePlist ( "openclaw" , retryPlistEnv ) ;
776+ await launchd . installService ( labels . openclaw , retryPlist ) ;
777+ await launchd . startService ( labels . openclaw ) ;
778+ await ensureRunning ( labels . openclaw , "openclaw" ) ;
779+
780+ // Controller needs the new port — re-bootstrap it too
781+ await launchd
782+ . bootoutAndWaitForExit ( labels . controller , 5000 )
783+ . catch ( ( ) => { } ) ;
784+ const retryControllerPlist = generatePlist ( "controller" , retryPlistEnv ) ;
785+ await launchd . installService ( labels . controller , retryControllerPlist ) ;
786+ await launchd . startService ( labels . controller ) ;
787+ await ensureRunning ( labels . controller , "controller" ) ;
788+ // Controller was restarted — must wait for readiness again even if
789+ // it was previously healthy (attach path sets needsControllerReady=false).
790+ needsControllerReady = true ;
791+ log (
792+ `[bootstrap] OpenClaw reassigned to port ${ newPort } , controller restarted` ,
793+ ) ;
794+ }
700795 } else {
701796 console . log ( "[bootstrap] openclaw already healthy, skipping" ) ;
702797 }
0 commit comments