Skip to content

Commit 56a082f

Browse files
authored
feat(memory-core): discover wake instance address from parent chain (#12419) (#12563)
1 parent 930b6ae commit 56a082f

2 files changed

Lines changed: 267 additions & 12 deletions

File tree

ai/mcp/server/shared/services/BootEnvelopeResolver.mjs

Lines changed: 184 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import {execFileSync as defaultExecFileSync} from 'child_process';
2+
3+
import {resolveInstancePid} from '../../../../daemons/bridge/instanceResolver.mjs';
14
import Base from '../../../../../src/core/Base.mjs';
25

6+
const USER_DATA_DIR_PATTERN = /(?:^|\s)--user-data-dir=(?:"([^"]+)"|'([^']+)'|(.+?))(?=\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/') || /Helper|Framework/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

128302
export default Neo.setupClass(BootEnvelopeResolver);

test/playwright/unit/ai/mcp/server/shared/services/BootEnvelopeResolver.spec.mjs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,22 @@ import BootEnvelopeResolver from '../../../../../../../../ai/mcp/server/shared/s
3030
*/
3131

3232
test.describe('Neo.ai.mcp.server.shared.services.BootEnvelopeResolver (#12418)', () => {
33-
const NEO_DIR = '/Users/example/.claude-instances/Neo';
33+
const NEO_DIR = '/Users/example/.claude-instances/Neo';
34+
const DEFAULT_DIR = '/Users/example/Library/Application Support/Claude';
35+
const PS_ELECTRON_PARENT_CHAIN = [
36+
`90001 20005 /Users/example/.nvm/versions/node/v22/bin/node /Users/example/neo/ai/mcp/server/memory-core/main.mjs`,
37+
`20005 20001 /Applications/Claude.app/Contents/Frameworks/Claude Helper (Renderer).app/Contents/MacOS/Claude Helper (Renderer) --type=renderer --user-data-dir=${NEO_DIR} --app-path=/x`,
38+
`20001 1 /Applications/Claude.app/Contents/MacOS/Claude --user-data-dir=${NEO_DIR}`,
39+
`13106 1 /Applications/Claude.app/Contents/MacOS/Claude`,
40+
`13119 13106 /Applications/Claude.app/Contents/Frameworks/Claude Helper.app/Contents/MacOS/Claude Helper --type=gpu-process --user-data-dir=${DEFAULT_DIR} --gpu-preferences=xyz`
41+
].join('\n');
3442

3543
test('default instance — neither env var set returns null (routed by absence)', () => {
36-
expect(BootEnvelopeResolver.resolveOverrideMetadata({})).toBeNull();
44+
expect(BootEnvelopeResolver.resolveOverrideMetadata({}, {
45+
bootPid : 90001,
46+
platform: 'darwin',
47+
psOutput: '90001 1 /usr/local/bin/node /tmp/server.mjs'
48+
})).toBeNull();
3749
});
3850

3951
test('empty-string env vars are treated as absent (default instance)', () => {
@@ -47,6 +59,10 @@ test.describe('Neo.ai.mcp.server.shared.services.BootEnvelopeResolver (#12418)',
4759
const result = BootEnvelopeResolver.resolveOverrideMetadata({
4860
NEO_HARNESS_INSTANCE_ADDRESS : NEO_DIR,
4961
NEO_HARNESS_INSTANCE_ADDRESS_TYPE: 'userDataDir'
62+
}, {
63+
bootPid : 90001,
64+
platform: 'darwin',
65+
psOutput: PS_ELECTRON_PARENT_CHAIN
5066
});
5167

5268
expect(result).toEqual({
@@ -70,12 +86,20 @@ test.describe('Neo.ai.mcp.server.shared.services.BootEnvelopeResolver (#12418)',
7086
test('fails closed on a partial envelope — address without type', () => {
7187
expect(() => BootEnvelopeResolver.resolveOverrideMetadata({
7288
NEO_HARNESS_INSTANCE_ADDRESS: NEO_DIR
89+
}, {
90+
bootPid : 90001,
91+
platform: 'darwin',
92+
psOutput: PS_ELECTRON_PARENT_CHAIN
7393
})).toThrow(/Partial boot envelope/);
7494
});
7595

7696
test('fails closed on a partial envelope — type without address', () => {
7797
expect(() => BootEnvelopeResolver.resolveOverrideMetadata({
7898
NEO_HARNESS_INSTANCE_ADDRESS_TYPE: 'userDataDir'
99+
}, {
100+
bootPid : 90001,
101+
platform: 'darwin',
102+
psOutput: PS_ELECTRON_PARENT_CHAIN
79103
})).toThrow(/Partial boot envelope/);
80104
});
81105

@@ -133,4 +157,61 @@ test.describe('Neo.ai.mcp.server.shared.services.BootEnvelopeResolver (#12418)',
133157
expect(BootEnvelopeResolver.validAddressTypes).toEqual(['userDataDir', 'pid', 'tmuxSession', 'webhookUrl']);
134158
expect(BootEnvelopeResolver.dispatchableAddressTypes).toEqual(['userDataDir', 'pid', 'tmuxSession', 'webhookUrl']);
135159
});
160+
161+
test('fallback discovers userDataDir from macOS Electron helper→main parent chain (#12419)', () => {
162+
expect(BootEnvelopeResolver.resolveOverrideMetadata({}, {
163+
bootPid : 90001,
164+
platform: 'darwin',
165+
psOutput: PS_ELECTRON_PARENT_CHAIN
166+
})).toEqual({
167+
instanceAddress: NEO_DIR,
168+
addressType : 'userDataDir'
169+
});
170+
});
171+
172+
test('fallback never overrides an explicit envelope value (#12419)', () => {
173+
const explicitDir = '/Users/example/.claude-instances/Explicit';
174+
175+
expect(BootEnvelopeResolver.resolveOverrideMetadata({
176+
NEO_HARNESS_INSTANCE_ADDRESS : explicitDir,
177+
NEO_HARNESS_INSTANCE_ADDRESS_TYPE: 'userDataDir'
178+
}, {
179+
bootPid : 90001,
180+
platform: 'darwin',
181+
psOutput: PS_ELECTRON_PARENT_CHAIN
182+
})).toEqual({
183+
instanceAddress: explicitDir,
184+
addressType : 'userDataDir'
185+
});
186+
});
187+
188+
test('fallback no-ops outside macOS / Electron parent chains (#12419)', () => {
189+
expect(BootEnvelopeResolver.resolveOverrideMetadata({}, {
190+
bootPid : 90001,
191+
platform: 'linux',
192+
psOutput: PS_ELECTRON_PARENT_CHAIN
193+
})).toBeNull();
194+
195+
expect(BootEnvelopeResolver.resolveOverrideMetadata({}, {
196+
bootPid : 90001,
197+
platform: 'darwin',
198+
psOutput: [
199+
'90001 42 /usr/local/bin/node /Users/example/neo/ai/mcp/server/memory-core/main.mjs',
200+
'42 1 /usr/bin/tmux new-session'
201+
].join('\n')
202+
})).toBeNull();
203+
});
204+
205+
test('fallback fails closed when discovered userDataDir cannot map to a main pid (#12419)', () => {
206+
const helperOnlySnapshot = [
207+
`90001 20005 /Users/example/.nvm/versions/node/v22/bin/node /Users/example/neo/ai/mcp/server/memory-core/main.mjs`,
208+
`20005 20001 /Applications/Claude.app/Contents/Frameworks/Claude Helper (Renderer).app/Contents/MacOS/Claude Helper (Renderer) --type=renderer --user-data-dir=${NEO_DIR} --app-path=/x`
209+
].join('\n');
210+
211+
expect(BootEnvelopeResolver.resolveOverrideMetadata({}, {
212+
bootPid : 90001,
213+
platform: 'darwin',
214+
psOutput: helperOnlySnapshot
215+
})).toBeNull();
216+
});
136217
});

0 commit comments

Comments
 (0)