@@ -7,6 +7,7 @@ public enum LifecycleAction: String, Codable, Sendable {
77 case start
88 case stop
99 case restart
10+ case applyCodexCatalog
1011}
1112
1213public enum LifecycleState : String , Codable , Sendable {
@@ -27,6 +28,13 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
2728 public let port : Int ?
2829 public let message : String
2930 public let errorCode : String ?
31+ /// Catalog-action fields are additive so older lifecycle results remain decodable.
32+ /// The app deliberately receives counts rather than process identifiers.
33+ public let catalogUpdated : Bool ?
34+ public let codexRestartRequired : Bool ?
35+ public let staleWorkerCount : Int ?
36+ public let stoppedWorkerCount : Int ?
37+ public let survivingWorkerCount : Int ?
3038
3139 public init (
3240 schemaVersion : Int = 1 ,
@@ -37,7 +45,12 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
3745 pid : Int ? = nil ,
3846 port : Int ? = nil ,
3947 message : String ,
40- errorCode : String ? = nil
48+ errorCode : String ? = nil ,
49+ catalogUpdated : Bool ? = nil ,
50+ codexRestartRequired : Bool ? = nil ,
51+ staleWorkerCount : Int ? = nil ,
52+ stoppedWorkerCount : Int ? = nil ,
53+ survivingWorkerCount : Int ? = nil
4154 ) {
4255 self . schemaVersion = schemaVersion
4356 self . action = action
@@ -48,6 +61,11 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
4861 self . port = port
4962 self . message = message
5063 self . errorCode = errorCode
64+ self . catalogUpdated = catalogUpdated
65+ self . codexRestartRequired = codexRestartRequired
66+ self . staleWorkerCount = staleWorkerCount
67+ self . stoppedWorkerCount = stoppedWorkerCount
68+ self . survivingWorkerCount = survivingWorkerCount
5169 }
5270}
5371
@@ -92,10 +110,19 @@ public enum LifecycleHelperDiscovery {
92110 home : URL = FileManager . default . homeDirectoryForCurrentUser ,
93111 fileManager : FileManager = . default
94112 ) -> LifecycleInvocation ? {
113+ // The repository build is deliberately live: while developing, edits in the
114+ // checkout must take effect without rebuilding the copied Resources snapshot.
95115 if let source = sourceInvocation ( bundleURL : bundleURL , fileManager : fileManager ) {
96116 return source
97117 }
98118
119+ // A released companion carries the complete Bun + OpenCodex package under
120+ // Contents/Resources/runtime. Resolve it before global installs so a copied app
121+ // never accidentally controls a different checkout or npm installation.
122+ if let bundled = bundledInvocation ( bundleURL : bundleURL , fileManager : fileManager ) {
123+ return bundled
124+ }
125+
99126 // Release companions may be launched outside the source tree. Only inspect
100127 // fixed installation locations, then resolve an npm/bun symlink back to an
101128 // actual OpenCodex package root. PATH and version-directory scans would turn
@@ -125,6 +152,19 @@ public enum LifecycleHelperDiscovery {
125152 return nil
126153 }
127154
155+ private static func bundledInvocation (
156+ bundleURL : URL ,
157+ fileManager : FileManager
158+ ) -> LifecycleInvocation ? {
159+ let bundle = bundleURL . resolvingSymlinksInPath ( )
160+ guard bundle . pathExtension == " app " else { return nil }
161+ let runtime = bundle
162+ . appendingPathComponent ( " Contents " , isDirectory : true )
163+ . appendingPathComponent ( " Resources " , isDirectory : true )
164+ . appendingPathComponent ( " runtime " , isDirectory : true )
165+ return repositoryInvocation ( runtime , fileManager : fileManager )
166+ }
167+
128168 private static func sourceInvocation (
129169 bundleURL : URL ,
130170 fileManager : FileManager
@@ -263,7 +303,7 @@ public actor LifecycleHelper: LifecycleCommandRunning {
263303 process . arguments = invocation . prefixArguments + [ " __macos-lifecycle " , action . rawValue ]
264304 process . standardOutput = pipe
265305 process . standardError = FileHandle . nullDevice
266- process . environment = Self . controlledEnvironment ( )
306+ process . environment = Self . controlledEnvironment ( for : invocation . executable )
267307 pipe . fileHandleForReading . readabilityHandler = { handle in
268308 output . append ( handle . availableData )
269309 }
@@ -313,6 +353,15 @@ public actor LifecycleHelper: LifecycleCommandRunning {
313353 result . message . utf8 . count <= 240 ,
314354 result . pid . map ( { $0 > 0 } ) ?? true ,
315355 result . port . map ( { ( 1 ... 65_535 ) . contains ( $0 ) } ) ?? true ,
356+ result . staleWorkerCount . map ( { $0 >= 0 } ) ?? true ,
357+ result . stoppedWorkerCount . map ( { $0 >= 0 } ) ?? true ,
358+ result . survivingWorkerCount . map ( { $0 >= 0 } ) ?? true ,
359+ action != . applyCodexCatalog || ! result . ok || (
360+ result . catalogUpdated != nil
361+ && result . codexRestartRequired != nil
362+ && result . stoppedWorkerCount != nil
363+ && result . survivingWorkerCount != nil
364+ ) ,
316365 ( process . terminationStatus == 0 ) == result . ok
317366 else {
318367 continuation . resume ( throwing : LifecycleHelperError . invalidResponse )
@@ -325,9 +374,14 @@ public actor LifecycleHelper: LifecycleCommandRunning {
325374
326375 /// Preserve OpenCodex/Codex configuration while removing runtime preloads and an
327376 /// attacker-controlled PATH from this privileged fixed-action bridge.
328- private nonisolated static func controlledEnvironment ( ) -> [ String : String ] {
377+ private nonisolated static func controlledEnvironment ( for executable : URL ) -> [ String : String ] {
329378 var environment = ProcessInfo . processInfo . environment
330379 environment [ " PATH " ] = " /usr/bin:/bin:/usr/sbin:/sbin "
380+ if executable . path . contains ( " /Contents/Resources/runtime/ " ) {
381+ // The app-owned runtime must never enter npm/source self-update paths.
382+ // This marker is inherited by the Bun proxy process and its management API.
383+ environment [ " OCX_APP_RUNTIME " ] = " 1 "
384+ }
331385 for key in [
332386 " BUN_OPTIONS " , " BUN_INSPECT " , " BUN_INSPECT_CONNECT_TO " ,
333387 " NODE_OPTIONS " , " NODE_PATH " , " LD_PRELOAD " ,
0 commit comments