1+ import { execFileSync as defaultExecFileSync } from 'child_process' ;
2+
3+ import { resolveInstancePid } from '../../../../daemons/bridge/instanceResolver.mjs' ;
14import Base from '../../../../../src/core/Base.mjs' ;
25
6+ const USER_DATA_DIR_PATTERN = / (?: ^ | \s ) - - u s e r - d a t a - d i r = (?: " ( [ ^ " ] + ) " | ' ( [ ^ ' ] + ) ' | ( .+ ?) ) (? = \s - - | $ ) / ;
7+
38/**
49 * @summary Resolves the per-instance wake-routing address from the boot environment into
510 * `overrideMetadata` for the auto-bootstrapped wake subscription.
@@ -24,12 +29,14 @@ import Base from '../../../../../src/core/Base.mjs';
2429 * - `NEO_HARNESS_INSTANCE_ADDRESS_TYPE` — the address kind: one of `userDataDir`, `pid`,
2530 * `tmuxSession`, `webhookUrl`.
2631 *
27- * Both must be set together or both omitted. Omitting both is the normal **default instance** — the
28- * primary macOS app started without `--user-data-dir`, which is routed by the *absence* of an
29- * address (see the bridge daemon's default-instance resolution). A partially-set envelope is a
30- * configuration error and **fails closed** (throws): a non-default instance with a broken address
31- * must never silently fall back to the default route, because that misroutes its wakes into the
32- * default instance's window.
32+ * Both must be set together or both omitted. Omitting both first attempts a narrow macOS Electron
33+ * parent-chain fallback: if this MCP server was spawned under an app/helper process whose command
34+ * line carries `--user-data-dir`, that path is used as a `userDataDir` address. Otherwise omission
35+ * remains the normal **default instance** — the primary macOS app started without `--user-data-dir`,
36+ * routed by the *absence* of an address (see the bridge daemon's default-instance resolution). A
37+ * partially-set envelope is a configuration error and **fails closed** (throws): a non-default
38+ * instance with a broken address must never silently fall back to the default route, because that
39+ * misroutes its wakes into the default instance's window.
3340 *
3441 * ## Dispatch coverage
3542 *
@@ -78,21 +85,36 @@ class BootEnvelopeResolver extends Base {
7885 * Resolves the boot instance-address envelope into wake-subscription `overrideMetadata`.
7986 *
8087 * @param {Object } [env=process.env] Environment source (injectable for tests).
88+ * @param {Object } [options]
89+ * @param {Boolean } [options.enableParentChainFallback=true] Whether to use the macOS Electron
90+ * parent-chain fallback when the explicit envelope is absent.
91+ * @param {Number } [options.bootPid=process.pid] MCP server boot pid for fallback discovery.
92+ * @param {String } [options.psOutput] Injectable `ps axww -o pid=,ppid=,command=` snapshot.
93+ * @param {Function } [options.execFileSync=child_process.execFileSync] Injectable `ps` runner.
94+ * @param {String } [options.platform=process.platform] Platform guard for tests.
8195 * @returns {Object|null } `overrideMetadata` to merge into the bootstrapped subscription
8296 * (`{instanceAddress, addressType, ...}`), or `null` for the default instance (no address
8397 * configured → routed by absence).
8498 * @throws {Error } When the envelope is partially configured, the address type is unrecognized,
8599 * or the address type is recognized but not yet dispatchable (fail-closed to prevent
86100 * misrouting a non-default instance to the default route).
87101 */
88- resolveOverrideMetadata ( env = process . env ) {
102+ resolveOverrideMetadata ( env = process . env , {
103+ bootPid = process . pid ,
104+ enableParentChainFallback = true ,
105+ execFileSync = defaultExecFileSync ,
106+ platform = process . platform ,
107+ psOutput
108+ } = { } ) {
89109 const instanceAddress = env . NEO_HARNESS_INSTANCE_ADDRESS ?. trim ( ) ,
90110 addressType = env . NEO_HARNESS_INSTANCE_ADDRESS_TYPE ?. trim ( ) ;
91111
92- // Default instance: no address configured → no override. The daemon routes the arg-less
93- // main instance by the absence of an address .
112+ // No explicit address configured: try the narrow macOS Electron fallback. If it cannot
113+ // prove an instance address, keep the existing default-instance behavior (null) .
94114 if ( ! instanceAddress && ! addressType ) {
95- return null ;
115+ return enableParentChainFallback
116+ ? this . resolveParentChainOverrideMetadata ( { bootPid, execFileSync, platform, psOutput} )
117+ : null ;
96118 }
97119
98120 // Partial envelope is a misconfiguration. Fail closed rather than degrade to default
@@ -123,6 +145,158 @@ class BootEnvelopeResolver extends Base {
123145
124146 return { instanceAddress, addressType} ;
125147 }
148+
149+ /**
150+ * @summary Resolves `overrideMetadata` from the MCP server's macOS Electron parent chain.
151+ *
152+ * This fallback never runs when the explicit env envelope is present, and it returns `null` on
153+ * every ambiguous/non-Electron/cloud shape so bootstrapping keeps the default-instance absence
154+ * route instead of guessing.
155+ *
156+ * @param {Object } options
157+ * @param {Number } [options.bootPid=process.pid] Starting pid for the parent-chain walk.
158+ * @param {String } [options.psOutput] Injectable process snapshot.
159+ * @param {Function } [options.execFileSync=child_process.execFileSync] Injectable `ps` runner.
160+ * @param {String } [options.platform=process.platform] Platform guard.
161+ * @returns {{instanceAddress:String,addressType:String}|null }
162+ */
163+ resolveParentChainOverrideMetadata ( {
164+ bootPid = process . pid ,
165+ execFileSync = defaultExecFileSync ,
166+ platform = process . platform ,
167+ psOutput
168+ } = { } ) {
169+ if ( platform !== 'darwin' ) {
170+ return null ;
171+ }
172+
173+ const snapshot = psOutput ?? this . readProcessSnapshot ( { execFileSync} ) ;
174+ if ( ! snapshot ) {
175+ return null ;
176+ }
177+
178+ const userDataDir = this . resolveParentChainUserDataDir ( { bootPid, psOutput : snapshot } ) ;
179+ if ( ! userDataDir ) {
180+ return null ;
181+ }
182+
183+ // Reuse the bridge daemon's main-pid resolver as the final proof that the discovered
184+ // address maps to a concrete Electron app instance. If the snapshot cannot prove that,
185+ // fail closed rather than emitting a route that might mis-target.
186+ if ( resolveInstancePid ( { userDataDir, psOutput : snapshot } ) === null ) {
187+ return null ;
188+ }
189+
190+ return {
191+ instanceAddress : userDataDir ,
192+ addressType : 'userDataDir'
193+ } ;
194+ }
195+
196+ /**
197+ * @summary Reads a full process snapshot for parent-chain address discovery.
198+ * @param {Object } options
199+ * @param {Function } [options.execFileSync=child_process.execFileSync]
200+ * @returns {String|null }
201+ */
202+ readProcessSnapshot ( { execFileSync = defaultExecFileSync } = { } ) {
203+ try {
204+ return execFileSync ( 'ps' , [ 'axww' , '-o' , 'pid=,ppid=,command=' ] , {
205+ encoding : 'utf8' ,
206+ stdio : [ 'ignore' , 'pipe' , 'pipe' ]
207+ } ) ;
208+ } catch {
209+ return null ;
210+ }
211+ }
212+
213+ /**
214+ * @summary Walks from the MCP server pid to its parents and extracts a proven Electron
215+ * `--user-data-dir` value when present.
216+ *
217+ * @param {Object } options
218+ * @param {Number } options.bootPid Starting pid for the parent-chain walk.
219+ * @param {String } options.psOutput `ps axww -o pid=,ppid=,command=` snapshot.
220+ * @param {Number } [options.maxDepth=12] Parent traversal cap.
221+ * @returns {String|null }
222+ */
223+ resolveParentChainUserDataDir ( { bootPid, psOutput, maxDepth = 12 } = { } ) {
224+ if ( ! bootPid || ! psOutput ) {
225+ return null ;
226+ }
227+
228+ const byPid = this . parseProcessSnapshot ( psOutput ) ,
229+ seen = new Set ( ) ;
230+ let cursor = Number ( bootPid ) ;
231+
232+ for ( let depth = 0 ; depth < maxDepth ; depth ++ ) {
233+ const row = byPid . get ( cursor ) ;
234+ if ( ! row || seen . has ( row . pid ) ) {
235+ return null ;
236+ }
237+
238+ seen . add ( row . pid ) ;
239+
240+ if ( this . isElectronAppProcess ( row . command ) ) {
241+ const userDataDir = this . extractUserDataDir ( row . command ) ;
242+ if ( userDataDir ) {
243+ return userDataDir ;
244+ }
245+ }
246+
247+ if ( ! row . ppid || row . ppid === 1 || row . ppid === row . pid ) {
248+ return null ;
249+ }
250+
251+ cursor = row . ppid ;
252+ }
253+
254+ return null ;
255+ }
256+
257+ /**
258+ * @summary Parses `ps axww -o pid=,ppid=,command=` rows by pid.
259+ * @param {String } psOutput
260+ * @returns {Map<Number,{pid:Number,ppid:Number,command:String}> }
261+ */
262+ parseProcessSnapshot ( psOutput = '' ) {
263+ return new Map ( psOutput . split ( '\n' )
264+ . map ( line => line . trim ( ) )
265+ . filter ( Boolean )
266+ . map ( line => {
267+ const match = line . match ( / ^ ( \d + ) \s + ( \d + ) \s + ( .* ) $ / ) ;
268+ if ( ! match ) {
269+ return null ;
270+ }
271+ const pid = Number ( match [ 1 ] ) ,
272+ ppid = Number ( match [ 2 ] ) ;
273+
274+ return { pid, ppid, command : match [ 3 ] } ;
275+ } )
276+ . filter ( Boolean )
277+ . map ( row => [ row . pid , row ] ) ) ;
278+ }
279+
280+ /**
281+ * @summary Detects macOS Electron app/main/helper process commands.
282+ * @param {String } command
283+ * @returns {Boolean }
284+ */
285+ isElectronAppProcess ( command = '' ) {
286+ return command . includes ( '.app/Contents/' ) &&
287+ ( command . includes ( '/Contents/MacOS/' ) || / H e l p e r | F r a m e w o r k / i. test ( command ) ) ;
288+ }
289+
290+ /**
291+ * @summary Extracts a `--user-data-dir` value from a process command line.
292+ * @param {String } command
293+ * @returns {String|null }
294+ */
295+ extractUserDataDir ( command = '' ) {
296+ const match = command . match ( USER_DATA_DIR_PATTERN ) ;
297+
298+ return match ? ( match [ 1 ] || match [ 2 ] || match [ 3 ] || '' ) . trim ( ) || null : null ;
299+ }
126300}
127301
128302export default Neo . setupClass ( BootEnvelopeResolver ) ;
0 commit comments