amalgm 0.1.96 → 0.1.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/attach.js +4 -4
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +6 -5
- package/runtime/scripts/amalgm-mcp/browser/backend.js +28 -7
- package/runtime/scripts/amalgm-mcp/browser/capture.js +2 -1
- package/runtime/scripts/amalgm-mcp/browser/engine.js +25 -8
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +1 -2
- package/runtime/scripts/amalgm-mcp/browser/sessions.js +21 -2
- package/runtime/scripts/amalgm-mcp/browser/typing.js +96 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +53 -15
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
const cli = require('./cli');
|
|
35
35
|
const backend = require('./backend');
|
|
36
36
|
const cdp = require('./cdp');
|
|
37
|
-
const { surfaces, knownSessions } = require('./sessions');
|
|
37
|
+
const { surfaces, knownSessions, backendFor } = require('./sessions');
|
|
38
38
|
|
|
39
39
|
const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
|
|
40
40
|
const WEBVIEW_MARKER_PREFIX = 'amalgm-bsid-';
|
|
@@ -58,10 +58,10 @@ function guardCommand(session) {
|
|
|
58
58
|
+ `throw new Error(${JSON.stringify(GUARD_ERROR)}); })()`];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Options every cli call needs for
|
|
61
|
+
/** Options every cli call needs for this session's backend. */
|
|
62
62
|
function cliOptions(session, extra = {}) {
|
|
63
63
|
const options = { session, ...extra };
|
|
64
|
-
if (
|
|
64
|
+
if (backendFor(session) === 'attached') options.cdp = backend.cdpEndpoint();
|
|
65
65
|
return options;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -224,7 +224,7 @@ async function focusSurface(session, surface) {
|
|
|
224
224
|
* target it names.
|
|
225
225
|
*/
|
|
226
226
|
async function ensureReady(session, context) {
|
|
227
|
-
if (
|
|
227
|
+
if (backendFor(session) !== 'attached') return null;
|
|
228
228
|
const live = surfaces.get(session);
|
|
229
229
|
if (live) return live;
|
|
230
230
|
|
|
@@ -82,11 +82,12 @@ module.exports = [
|
|
|
82
82
|
|| ctx?.callerSessionId
|
|
83
83
|
|| `login-${Date.now().toString(36)}`,
|
|
84
84
|
);
|
|
85
|
-
//
|
|
86
|
-
// session already drives — open the page there and say so.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
85
|
+
// A desktop-born conversation logs in on the visible surface this
|
|
86
|
+
// session already drives — open the page there and say so. Everyone
|
|
87
|
+
// else gets the link, where a separate live view (noVNC today) shows
|
|
88
|
+
// the headless browser. sessionFor records the login session's home
|
|
89
|
+
// from the caller's birth stamp, same as every other verb.
|
|
90
|
+
const attached = engine.backendFor(engine.sessionFor({ session: profileId }, ctx)) === 'attached';
|
|
90
91
|
const result = createBrowserLoginSession({
|
|
91
92
|
...args,
|
|
92
93
|
transport: args.transport || (attached ? 'electron-splitview' : undefined),
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Backend routing — which browser
|
|
4
|
+
* Backend routing — which browser a session drives.
|
|
5
5
|
*
|
|
6
6
|
* Exactly two backends:
|
|
7
7
|
* - "attached": the visible chromium the Amalgm desktop app advertises over
|
|
8
8
|
* CDP (or an explicit AMALGM_BROWSER_CDP_URL endpoint). The user watches
|
|
9
9
|
* this browser in the app's split view.
|
|
10
10
|
* - "cli": a standalone agent-browser chromium, headless. The deterministic
|
|
11
|
-
* path for npm-only machines, servers, Fly workers,
|
|
11
|
+
* path for npm-only machines, servers, Fly workers, web chats, sub-agents,
|
|
12
|
+
* and automations.
|
|
12
13
|
*
|
|
13
|
-
* The rule: a
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* The rule: a browser session inherits its home from the conversation that
|
|
15
|
+
* created it. Conversations born in the desktop app carry client:'desktop'
|
|
16
|
+
* in their session metadata (stamped at creation, immutable); only those get
|
|
17
|
+
* the visible browser, and only while the app's advertisement is fresh
|
|
18
|
+
* ("fresh" = the advertising pid is alive). Everything else — web chats,
|
|
19
|
+
* sub-agents, automations, anything unstamped — is headless by construction.
|
|
16
20
|
* AMALGM_BROWSER_BACKEND forces a backend for debugging.
|
|
17
21
|
*/
|
|
18
22
|
|
|
@@ -84,10 +88,26 @@ function cdpEndpoint() {
|
|
|
84
88
|
return cdpFlagValue(advertisement.cdpUrl);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
/** The debug override, or null when the owner's stamp decides. */
|
|
92
|
+
function forcedMode() {
|
|
88
93
|
const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
|
|
89
94
|
if (['agent-browser', 'external', 'cli', 'headless'].includes(requested)) return 'cli';
|
|
90
95
|
if (['attached', 'cdp', 'electron'].includes(requested)) return 'attached';
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The backend for a session whose owner conversation carries `client` in its
|
|
101
|
+
* metadata. Pure: same stamp, same answer. 'desktop' is the only value that
|
|
102
|
+
* earns the visible browser, and only while the app is actually there.
|
|
103
|
+
*/
|
|
104
|
+
function modeFor(client) {
|
|
105
|
+
const forced = forcedMode();
|
|
106
|
+
if (forced) return forced;
|
|
107
|
+
// An explicit CDP endpoint is an operator instruction to drive that
|
|
108
|
+
// chromium, regardless of who asks.
|
|
109
|
+
if (process.env.AMALGM_BROWSER_CDP_URL) return 'attached';
|
|
110
|
+
if (client !== 'desktop') return 'cli';
|
|
91
111
|
return cdpEndpoint() ? 'attached' : 'cli';
|
|
92
112
|
}
|
|
93
113
|
|
|
@@ -95,6 +115,7 @@ module.exports = {
|
|
|
95
115
|
bridgeFileCandidates,
|
|
96
116
|
cdpEndpoint,
|
|
97
117
|
cdpFlagValue,
|
|
98
|
-
|
|
118
|
+
forcedMode,
|
|
119
|
+
modeFor,
|
|
99
120
|
readBridgeAdvertisement,
|
|
100
121
|
};
|
|
@@ -26,6 +26,7 @@ const backend = require('./backend');
|
|
|
26
26
|
const cli = require('./cli');
|
|
27
27
|
const attach = require('./attach');
|
|
28
28
|
const cdp = require('./cdp');
|
|
29
|
+
const { backendFor } = require('./sessions');
|
|
29
30
|
|
|
30
31
|
// An on-screen surface answers captureScreenshot in well under 100ms
|
|
31
32
|
// (measured 54–100ms live); one that has not answered in 4s is not going to.
|
|
@@ -51,7 +52,7 @@ async function pageTargetWsUrl(endpoint) {
|
|
|
51
52
|
* is normalized to CSS pixels so coordinates map 1:1 onto cua_* input.
|
|
52
53
|
*/
|
|
53
54
|
async function plan(session, context) {
|
|
54
|
-
if (
|
|
55
|
+
if (backendFor(session) !== 'attached') {
|
|
55
56
|
const raw = await daemonCdpUrl(session);
|
|
56
57
|
if (!raw) throw new Error('Could not resolve a CDP endpoint for this browser session.');
|
|
57
58
|
const wsUrl = /\/devtools\/browser\//.test(raw) ? await pageTargetWsUrl(raw) : raw;
|
|
@@ -20,8 +20,9 @@ const backend = require('./backend');
|
|
|
20
20
|
const attach = require('./attach');
|
|
21
21
|
const capture = require('./capture');
|
|
22
22
|
const sessions = require('./sessions');
|
|
23
|
+
const typing = require('./typing');
|
|
23
24
|
|
|
24
|
-
const { sessionFor, sessionInfo } = sessions;
|
|
25
|
+
const { backendFor, sessionFor, sessionInfo } = sessions;
|
|
25
26
|
const { run, runBatch, readText } = attach;
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -96,18 +97,33 @@ async function fill(args = {}, context) {
|
|
|
96
97
|
const session = sessionFor(args, context);
|
|
97
98
|
const target = String(args.target || '');
|
|
98
99
|
const text = String(args.text ?? '');
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
if (backendFor(session) === 'attached') {
|
|
101
|
+
// Shared window: synthesized keystrokes follow window focus and can land
|
|
102
|
+
// in the user's chat box (typing.js). Focus the element through the
|
|
103
|
+
// daemon (in-page, resolves @ref/CSS/text= alike), then set the value
|
|
104
|
+
// inside the page — the text cannot leave the session's page.
|
|
105
|
+
const focusCommand = target.startsWith('text=') ? findArgs(target, 'focus') : ['focus', target];
|
|
106
|
+
const commands = [focusCommand, ['eval', typing.setValueScript(text)]];
|
|
107
|
+
if (args.submit) commands.push(['eval', typing.keyScript('Enter')]);
|
|
108
|
+
await runBatch(session, commands, { context });
|
|
102
109
|
} else {
|
|
103
|
-
|
|
110
|
+
const fillCommand = target.startsWith('text=') ? findArgs(target, 'fill', text) : ['fill', target, text];
|
|
111
|
+
if (args.submit) {
|
|
112
|
+
await runBatch(session, [fillCommand, ['press', 'Enter']], { context });
|
|
113
|
+
} else {
|
|
114
|
+
await run(session, fillCommand, { context });
|
|
115
|
+
}
|
|
104
116
|
}
|
|
105
117
|
return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
async function press(args = {}, context) {
|
|
109
121
|
const session = sessionFor(args, context);
|
|
110
|
-
|
|
122
|
+
if (backendFor(session) === 'attached') {
|
|
123
|
+
await run(session, ['eval', typing.keyScript(String(args.key || ''))], { context });
|
|
124
|
+
} else {
|
|
125
|
+
await run(session, ['press', args.key], { context });
|
|
126
|
+
}
|
|
111
127
|
return sessionInfo(session, { pressed: args.key });
|
|
112
128
|
}
|
|
113
129
|
|
|
@@ -174,7 +190,7 @@ async function passthrough(args = {}, context) {
|
|
|
174
190
|
const session = sessionFor(args, context);
|
|
175
191
|
const commandArgs = (args.args || []).map(String);
|
|
176
192
|
if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
|
|
177
|
-
if (
|
|
193
|
+
if (sessions.backendFor(session) === 'attached' && commandArgs[0] === 'tab' && commandArgs.length > 1) {
|
|
178
194
|
// In the desktop app a session IS its surface; hopping targets is how a
|
|
179
195
|
// session ends up driving another session's page. Listing stays allowed.
|
|
180
196
|
throw new Error(
|
|
@@ -328,9 +344,10 @@ module.exports = {
|
|
|
328
344
|
// Routing façade (backend.js) — kept here so engine stays the one import.
|
|
329
345
|
bridgeFileCandidates: backend.bridgeFileCandidates,
|
|
330
346
|
cdpEndpoint: backend.cdpEndpoint,
|
|
331
|
-
|
|
347
|
+
modeFor: backend.modeFor,
|
|
332
348
|
readBridgeAdvertisement: backend.readBridgeAdvertisement,
|
|
333
349
|
// Session façade (sessions.js).
|
|
350
|
+
backendFor: sessions.backendFor,
|
|
334
351
|
knownSessions: sessions.knownSessions,
|
|
335
352
|
sessionFor,
|
|
336
353
|
sessionInfo,
|
|
@@ -25,7 +25,6 @@ const path = require('path');
|
|
|
25
25
|
const { spawn } = require('child_process');
|
|
26
26
|
|
|
27
27
|
const cli = require('./cli');
|
|
28
|
-
const backend = require('./backend');
|
|
29
28
|
const capture = require('./capture');
|
|
30
29
|
const sessions = require('./sessions');
|
|
31
30
|
|
|
@@ -246,7 +245,7 @@ async function start(args = {}, context = {}) {
|
|
|
246
245
|
path: file,
|
|
247
246
|
fps,
|
|
248
247
|
mimeType: 'video/webm',
|
|
249
|
-
backend:
|
|
248
|
+
backend: sessions.backendFor(session),
|
|
250
249
|
source: source.guest ? 'visible surface' : 'page',
|
|
251
250
|
};
|
|
252
251
|
}
|
|
@@ -23,8 +23,21 @@ const surfaces = new Map();
|
|
|
23
23
|
/** Per-session bookkeeping: webviewTabId, lastActiveAt, last url/title. */
|
|
24
24
|
const knownSessions = new Map();
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Each session's home backend, decided once at first use from the owner
|
|
28
|
+
* conversation's birth stamp (metadata.client) and never re-decided — a
|
|
29
|
+
* session does not migrate between a visible surface and a headless browser
|
|
30
|
+
* mid-life. An MCP restart clears the map; the next call re-derives the same
|
|
31
|
+
* answer from the same stamp.
|
|
32
|
+
*/
|
|
33
|
+
const homes = new Map();
|
|
34
|
+
|
|
35
|
+
function backendFor(session) {
|
|
36
|
+
return homes.get(session) || backend.modeFor(undefined);
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
function sessionFor(args = {}, context = {}) {
|
|
27
|
-
|
|
40
|
+
const session = cli.safeId(
|
|
28
41
|
args.session
|
|
29
42
|
|| args.profile
|
|
30
43
|
|| args.browserProfileId
|
|
@@ -34,6 +47,10 @@ function sessionFor(args = {}, context = {}) {
|
|
|
34
47
|
|| context?.callerSessionId
|
|
35
48
|
|| cli.DEFAULT_SESSION,
|
|
36
49
|
);
|
|
50
|
+
if (!homes.has(session)) {
|
|
51
|
+
homes.set(session, backend.modeFor(context?.sessionMetadata?.client));
|
|
52
|
+
}
|
|
53
|
+
return session;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
function rememberProfile(session) {
|
|
@@ -53,7 +70,7 @@ function sessionInfo(session, extra = {}) {
|
|
|
53
70
|
session,
|
|
54
71
|
browserSessionId: session,
|
|
55
72
|
browserProfileId: session,
|
|
56
|
-
backend:
|
|
73
|
+
backend: backendFor(session),
|
|
57
74
|
...extra,
|
|
58
75
|
};
|
|
59
76
|
knownSessions.set(session, { ...knownSessions.get(session), ...info, lastActiveAt: Date.now() });
|
|
@@ -61,6 +78,8 @@ function sessionInfo(session, extra = {}) {
|
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
module.exports = {
|
|
81
|
+
backendFor,
|
|
82
|
+
homes,
|
|
64
83
|
knownSessions,
|
|
65
84
|
rememberProfile,
|
|
66
85
|
sessionFor,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-page typing — the scripts behind fill and press in attached mode.
|
|
5
|
+
*
|
|
6
|
+
* In the desktop app the agent and the user share one chromium window, and
|
|
7
|
+
* synthesized keystrokes (CDP Input events) are routed by *window focus* —
|
|
8
|
+
* an agent "typing" while the user's caret sits in the chat box lands the
|
|
9
|
+
* text in the chat box (observed live). These scripts act inside the
|
|
10
|
+
* session's page instead, through the same stamp-guarded transport as every
|
|
11
|
+
* other command, so agent input physically cannot reach anything but the
|
|
12
|
+
* page it was meant for.
|
|
13
|
+
*
|
|
14
|
+
* The boundary: page-level semantics work (value setting with real
|
|
15
|
+
* input/change events, key handlers, Enter activating buttons and
|
|
16
|
+
* submitting forms); OS-level editing defaults (Tab focus traversal,
|
|
17
|
+
* arrow-key scrolling) are real-input territory — that is what the cua_*
|
|
18
|
+
* verbs are for, and they stay focus-dependent by design.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Set the focused field's value the way the page expects: through the
|
|
23
|
+
* native prototype setter (so frameworks tracking the value descriptor —
|
|
24
|
+
* React — see the change), followed by real input/change events.
|
|
25
|
+
*/
|
|
26
|
+
function setValueScript(text) {
|
|
27
|
+
return `(() => {
|
|
28
|
+
const el = document.activeElement;
|
|
29
|
+
if (!el || el === document.body) throw new Error('focus did not land on a field');
|
|
30
|
+
const text = ${JSON.stringify(String(text))};
|
|
31
|
+
if (el.isContentEditable) {
|
|
32
|
+
el.textContent = text;
|
|
33
|
+
} else {
|
|
34
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
|
|
35
|
+
: el instanceof HTMLInputElement ? HTMLInputElement.prototype
|
|
36
|
+
: null;
|
|
37
|
+
if (!proto && !('value' in el)) {
|
|
38
|
+
throw new Error('focused element <' + el.tagName.toLowerCase() + '> is not fillable');
|
|
39
|
+
}
|
|
40
|
+
const setter = proto && Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
41
|
+
if (setter) setter.call(el, text); else el.value = text;
|
|
42
|
+
}
|
|
43
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertReplacementText' }));
|
|
44
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
45
|
+
return true;
|
|
46
|
+
})()`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "KeyA" for letters, "Digit1" for digits, the key's own name otherwise. */
|
|
50
|
+
function codeFor(key) {
|
|
51
|
+
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
52
|
+
if (/^[0-9]$/.test(key)) return `Digit${key}`;
|
|
53
|
+
if (key === ' ') return 'Space';
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Dispatch a key combination ("Enter", "Escape", "Control+a") to the focused
|
|
59
|
+
* element, mirroring the browser's own Enter defaults when no handler
|
|
60
|
+
* prevents them: buttons and links activate, single-line form fields submit
|
|
61
|
+
* their form (through requestSubmit, so submit handlers and validation run).
|
|
62
|
+
*/
|
|
63
|
+
function keyScript(combo) {
|
|
64
|
+
const parts = String(combo).split('+');
|
|
65
|
+
const key = parts.pop() || '';
|
|
66
|
+
const mods = parts.map((part) => part.toLowerCase());
|
|
67
|
+
const init = {
|
|
68
|
+
key,
|
|
69
|
+
code: codeFor(key),
|
|
70
|
+
bubbles: true,
|
|
71
|
+
cancelable: true,
|
|
72
|
+
ctrlKey: mods.includes('control') || mods.includes('ctrl'),
|
|
73
|
+
shiftKey: mods.includes('shift'),
|
|
74
|
+
altKey: mods.includes('alt') || mods.includes('option'),
|
|
75
|
+
metaKey: mods.includes('meta') || mods.includes('cmd') || mods.includes('command'),
|
|
76
|
+
};
|
|
77
|
+
return `(() => {
|
|
78
|
+
const el = document.activeElement || document.body;
|
|
79
|
+
const init = ${JSON.stringify(init)};
|
|
80
|
+
const proceed = el.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
81
|
+
if (proceed) {
|
|
82
|
+
el.dispatchEvent(new KeyboardEvent('keypress', init));
|
|
83
|
+
if (init.key === 'Enter') {
|
|
84
|
+
if (el instanceof HTMLButtonElement || el instanceof HTMLAnchorElement) {
|
|
85
|
+
el.click();
|
|
86
|
+
} else if (el instanceof HTMLInputElement && el.form) {
|
|
87
|
+
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
92
|
+
return true;
|
|
93
|
+
})()`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { codeFor, keyScript, setValueScript };
|
|
@@ -48,35 +48,36 @@ function writeAdvertisement(dir, data) {
|
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
test('
|
|
51
|
+
test('ownership routing: no advertisement means headless for everyone', () => {
|
|
52
52
|
withEnv({ AMALGM_DIR: tempDir('empty') }, () => {
|
|
53
|
-
assert.equal(engine.
|
|
53
|
+
assert.equal(engine.modeFor('desktop'), 'cli');
|
|
54
|
+
assert.equal(engine.modeFor('web'), 'cli');
|
|
54
55
|
assert.equal(engine.cdpEndpoint(), null);
|
|
55
56
|
});
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
test('
|
|
59
|
+
test('ownership routing: AMALGM_BROWSER_BACKEND forces a backend for everyone', () => {
|
|
59
60
|
withEnv({ AMALGM_BROWSER_BACKEND: 'cli', AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9242/x' }, () => {
|
|
60
|
-
assert.equal(engine.
|
|
61
|
+
assert.equal(engine.modeFor('desktop'), 'cli');
|
|
61
62
|
});
|
|
62
63
|
withEnv({ AMALGM_BROWSER_BACKEND: 'attached' }, () => {
|
|
63
|
-
assert.equal(engine.
|
|
64
|
+
assert.equal(engine.modeFor('web'), 'attached');
|
|
64
65
|
});
|
|
65
66
|
// Old override value keeps meaning "the visible desktop browser".
|
|
66
67
|
withEnv({ AMALGM_BROWSER_BACKEND: 'electron' }, () => {
|
|
67
|
-
assert.equal(engine.
|
|
68
|
+
assert.equal(engine.modeFor(undefined), 'attached');
|
|
68
69
|
});
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
test('
|
|
72
|
+
test('ownership routing: explicit AMALGM_BROWSER_CDP_URL attaches regardless of owner', () => {
|
|
72
73
|
withEnv({ AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9999/devtools/browser/abc' }, () => {
|
|
73
|
-
assert.equal(engine.
|
|
74
|
+
assert.equal(engine.modeFor('web'), 'attached');
|
|
74
75
|
// --cdp takes a port; URLs are reduced to their port.
|
|
75
76
|
assert.equal(engine.cdpEndpoint(), '9999');
|
|
76
77
|
});
|
|
77
78
|
});
|
|
78
79
|
|
|
79
|
-
test('
|
|
80
|
+
test('ownership routing: a fresh advertisement attaches desktop-born chats ONLY', () => {
|
|
80
81
|
const stateDir = tempDir('state');
|
|
81
82
|
writeAdvertisement(stateDir, {
|
|
82
83
|
cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/live',
|
|
@@ -84,35 +85,72 @@ test('entrypoint routing: fresh advertisement in the runtime state dir attaches'
|
|
|
84
85
|
pid: process.pid,
|
|
85
86
|
});
|
|
86
87
|
withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir }, () => {
|
|
87
|
-
assert.equal(engine.
|
|
88
|
+
assert.equal(engine.modeFor('desktop'), 'attached');
|
|
89
|
+
// The app being alive is not enough: web chats, sub-agents, automations,
|
|
90
|
+
// and anything unstamped stay headless — the 2026-06-11 leak was a web
|
|
91
|
+
// chat's browser mounting visibly inside the user's open splitview.
|
|
92
|
+
assert.equal(engine.modeFor('web'), 'cli');
|
|
93
|
+
assert.equal(engine.modeFor(undefined), 'cli');
|
|
88
94
|
assert.equal(engine.cdpEndpoint(), '9242');
|
|
89
95
|
});
|
|
90
96
|
});
|
|
91
97
|
|
|
92
|
-
test('
|
|
98
|
+
test('ownership routing: stale advertisement (dead pid) means headless', () => {
|
|
93
99
|
const stateDir = tempDir('stale');
|
|
94
100
|
writeAdvertisement(stateDir, {
|
|
95
101
|
cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/dead',
|
|
96
102
|
pid: 2 ** 30, // not a real pid
|
|
97
103
|
});
|
|
98
104
|
withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir, AMALGM_DIR: tempDir('empty2') }, () => {
|
|
99
|
-
assert.equal(engine.
|
|
105
|
+
assert.equal(engine.modeFor('desktop'), 'cli');
|
|
100
106
|
});
|
|
101
107
|
});
|
|
102
108
|
|
|
103
|
-
test('
|
|
109
|
+
test('ownership routing: AMALGM_DIR + label derives the state dir path', () => {
|
|
104
110
|
const amalgmDir = tempDir('scoped');
|
|
105
111
|
writeAdvertisement(path.join(amalgmDir, 'runtimes', 'main'), {
|
|
106
112
|
cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/scoped',
|
|
107
113
|
pid: process.pid,
|
|
108
114
|
});
|
|
109
115
|
withEnv({ AMALGM_DIR: amalgmDir, AMALGM_RUNTIME_LABEL: 'main' }, () => {
|
|
110
|
-
assert.equal(engine.
|
|
116
|
+
assert.equal(engine.modeFor('desktop'), 'attached');
|
|
111
117
|
assert.equal(engine.cdpEndpoint(), '9242');
|
|
112
118
|
});
|
|
113
119
|
// A different label does not see it (branch isolation).
|
|
114
120
|
withEnv({ AMALGM_DIR: amalgmDir, AMALGM_RUNTIME_LABEL: 'preview' }, () => {
|
|
115
|
-
assert.equal(engine.
|
|
121
|
+
assert.equal(engine.modeFor('desktop'), 'cli');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('ownership routing: a session home is decided at first use and sticks', () => {
|
|
126
|
+
const sessions = require('../browser/sessions');
|
|
127
|
+
const stateDir = tempDir('homes');
|
|
128
|
+
writeAdvertisement(stateDir, {
|
|
129
|
+
cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/live',
|
|
130
|
+
cdpPort: 9242,
|
|
131
|
+
pid: process.pid,
|
|
132
|
+
});
|
|
133
|
+
withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir }, () => {
|
|
134
|
+
sessions.homes.clear();
|
|
135
|
+
// Desktop-born conversation: visible browser.
|
|
136
|
+
const desktopSession = sessions.sessionFor(
|
|
137
|
+
{ session: 'home-desktop' },
|
|
138
|
+
{ callerSessionId: 'chat-1', sessionMetadata: { client: 'desktop' } },
|
|
139
|
+
);
|
|
140
|
+
assert.equal(sessions.backendFor(desktopSession), 'attached');
|
|
141
|
+
// Web-born conversation: headless, even with the app alive.
|
|
142
|
+
const webSession = sessions.sessionFor(
|
|
143
|
+
{ session: 'home-web' },
|
|
144
|
+
{ callerSessionId: 'chat-2', sessionMetadata: { client: 'web' } },
|
|
145
|
+
);
|
|
146
|
+
assert.equal(sessions.backendFor(webSession), 'cli');
|
|
147
|
+
// Unstamped caller (sub-agent, automation, legacy session): headless.
|
|
148
|
+
const unstampedSession = sessions.sessionFor({ session: 'home-none' }, { callerSessionId: 'chat-3' });
|
|
149
|
+
assert.equal(sessions.backendFor(unstampedSession), 'cli');
|
|
150
|
+
// Decided at birth: a later call with different metadata does not migrate it.
|
|
151
|
+
sessions.sessionFor({ session: 'home-desktop' }, { sessionMetadata: { client: 'web' } });
|
|
152
|
+
assert.equal(sessions.backendFor(desktopSession), 'attached');
|
|
153
|
+
sessions.homes.clear();
|
|
116
154
|
});
|
|
117
155
|
});
|
|
118
156
|
|