amalgm 0.1.132 → 0.1.133
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/auth-bundles.js +31 -3
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +13 -17
- package/runtime/scripts/amalgm-mcp/browser/cookie-source.js +17 -0
- package/runtime/scripts/amalgm-mcp/browser/engine.js +51 -13
- package/runtime/scripts/amalgm-mcp/browser/novnc.js +3 -2
- package/runtime/scripts/amalgm-mcp/browser/rest.js +86 -15
- package/runtime/scripts/amalgm-mcp/server/routes/browser.js +8 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-auth-bundles.test.js +25 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-source.test.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-login-live.test.js +108 -0
package/package.json
CHANGED
|
@@ -65,6 +65,26 @@ function stateStorageKinds(state = {}) {
|
|
|
65
65
|
return Array.from(kinds).sort();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Normalize to the Chromium/Playwright cookie shape we can save and inject.
|
|
69
|
+
// Keep newer partition metadata when Chromium supplies it; older importers
|
|
70
|
+
// safely ignore fields they do not understand.
|
|
71
|
+
function normalizeChromiumCookie(cookie = {}) {
|
|
72
|
+
const out = {
|
|
73
|
+
name: String(cookie.name ?? ''),
|
|
74
|
+
value: String(cookie.value ?? ''),
|
|
75
|
+
domain: String(cookie.domain ?? ''),
|
|
76
|
+
path: String(cookie.path ?? '/'),
|
|
77
|
+
expires: typeof cookie.expires === 'number' ? cookie.expires : -1,
|
|
78
|
+
httpOnly: Boolean(cookie.httpOnly),
|
|
79
|
+
secure: Boolean(cookie.secure),
|
|
80
|
+
sameSite: ['Strict', 'Lax', 'None'].includes(cookie.sameSite) ? cookie.sameSite : 'Lax',
|
|
81
|
+
};
|
|
82
|
+
for (const key of ['partitionKey', 'priority', 'sameParty', 'sourceScheme']) {
|
|
83
|
+
if (cookie[key] !== undefined && cookie[key] !== null) out[key] = cookie[key];
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
function filterStateByDomains(state = {}, domains = []) {
|
|
69
89
|
const cleanDomains = normalizeList(domains).map(normalizeDomain).filter(Boolean);
|
|
70
90
|
if (cleanDomains.length === 0) return state;
|
|
@@ -202,14 +222,21 @@ function writeEncryptedStateBundle(input = {}) {
|
|
|
202
222
|
const profileId = safeId(input.profileId || input.browserProfileId);
|
|
203
223
|
const domains = normalizeList(input.domains);
|
|
204
224
|
const filteredState = filterStateByDomains(input.state || {}, domains);
|
|
205
|
-
const
|
|
225
|
+
const normalizedCookies = (Array.isArray(filteredState.cookies) ? filteredState.cookies : [])
|
|
226
|
+
.map(normalizeChromiumCookie);
|
|
227
|
+
const normalizedState = {
|
|
228
|
+
...filteredState,
|
|
229
|
+
cookies: normalizedCookies,
|
|
230
|
+
};
|
|
231
|
+
const storageKinds = stateStorageKinds(normalizedState);
|
|
206
232
|
const encrypted = encryptJson({
|
|
207
|
-
version:
|
|
233
|
+
version: 2,
|
|
208
234
|
profileId,
|
|
209
235
|
domains,
|
|
210
236
|
storageKinds,
|
|
211
237
|
capturedAt: nowIso(),
|
|
212
|
-
|
|
238
|
+
cookies: normalizedCookies,
|
|
239
|
+
state: normalizedState,
|
|
213
240
|
});
|
|
214
241
|
const id = bundleIdFor(input.id);
|
|
215
242
|
const file = path.join(authBundleRoot(), `${id}.json.enc`);
|
|
@@ -275,6 +302,7 @@ module.exports = {
|
|
|
275
302
|
listBrowserAuthBundles,
|
|
276
303
|
markBrowserAuthBundleImported,
|
|
277
304
|
normalizeAuthBundle,
|
|
305
|
+
normalizeChromiumCookie,
|
|
278
306
|
readEncryptedStateBundle,
|
|
279
307
|
stateStorageKinds,
|
|
280
308
|
upsertBrowserAuthBundle,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const { textResult, errorResult } = require('../lib/tool-result');
|
|
16
16
|
const engine = require('./engine');
|
|
17
|
+
const { COOKIE_SOURCE_BUNDLE_ID, DEFAULT_COOKIE_SOURCE_PROFILE_ID } = require('./cookie-source');
|
|
17
18
|
const {
|
|
18
19
|
createBrowserLoginSession,
|
|
19
20
|
listBrowserAuthBundles,
|
|
@@ -30,16 +31,6 @@ function jsonText(value) {
|
|
|
30
31
|
return textResult(JSON.stringify(value, null, 2));
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function authDomainsFrom(inputDomains, fallbackUrl) {
|
|
34
|
-
if (Array.isArray(inputDomains) && inputDomains.length > 0) return inputDomains;
|
|
35
|
-
try {
|
|
36
|
-
const hostname = new URL(fallbackUrl).hostname;
|
|
37
|
-
return hostname ? [hostname] : [];
|
|
38
|
-
} catch {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
34
|
module.exports = [
|
|
44
35
|
{
|
|
45
36
|
name: 'auth_list',
|
|
@@ -51,6 +42,10 @@ module.exports = [
|
|
|
51
42
|
profiles: listBrowserProfiles(),
|
|
52
43
|
bundles: listBrowserAuthBundles(),
|
|
53
44
|
loginSessions: listBrowserLoginSessions(),
|
|
45
|
+
cookieSource: {
|
|
46
|
+
bundleId: COOKIE_SOURCE_BUNDLE_ID,
|
|
47
|
+
profileId: DEFAULT_COOKIE_SOURCE_PROFILE_ID,
|
|
48
|
+
},
|
|
54
49
|
});
|
|
55
50
|
} catch (err) {
|
|
56
51
|
return errorResult(`auth_list failed: ${err.message}`);
|
|
@@ -79,8 +74,7 @@ module.exports = [
|
|
|
79
74
|
const profileId = safeId(
|
|
80
75
|
args.browserProfileId
|
|
81
76
|
|| args.browserSessionId
|
|
82
|
-
||
|
|
83
|
-
|| `login-${Date.now().toString(36)}`,
|
|
77
|
+
|| DEFAULT_COOKIE_SOURCE_PROFILE_ID,
|
|
84
78
|
);
|
|
85
79
|
// A desktop-born conversation logs in on the visible surface this
|
|
86
80
|
// session already drives — open the page there and say so. Everyone
|
|
@@ -125,18 +119,20 @@ module.exports = [
|
|
|
125
119
|
type: 'object',
|
|
126
120
|
properties: {
|
|
127
121
|
browserProfileId: { type: 'string', description: 'Durable browser profile id to save from' },
|
|
122
|
+
id: { type: 'string', description: 'Optional bundle id. Defaults to the shared browser cookie source.' },
|
|
128
123
|
name: { type: 'string', description: 'Human-readable bundle name' },
|
|
129
|
-
domains: { type: 'array', items: { type: 'string' }, description: 'Optional domains to include
|
|
124
|
+
domains: { type: 'array', items: { type: 'string' }, description: 'Optional domains to include. Omit to save all current browser auth state.' },
|
|
130
125
|
},
|
|
131
126
|
},
|
|
132
127
|
async handler(args, ctx) {
|
|
133
128
|
try {
|
|
134
|
-
const profileId = safeId(args.browserProfileId || args.browserSessionId || ctx?.callerSessionId ||
|
|
129
|
+
const profileId = safeId(args.browserProfileId || args.browserSessionId || ctx?.callerSessionId || DEFAULT_COOKIE_SOURCE_PROFILE_ID);
|
|
135
130
|
const saved = await engine.saveState({ ...args, session: profileId }, ctx);
|
|
136
131
|
const bundle = writeEncryptedStateBundle({
|
|
137
|
-
|
|
132
|
+
id: args.id || COOKIE_SOURCE_BUNDLE_ID,
|
|
133
|
+
name: args.name || 'Browser cookie source',
|
|
138
134
|
profileId,
|
|
139
|
-
domains:
|
|
135
|
+
domains: Array.isArray(args.domains) ? args.domains : [],
|
|
140
136
|
state: saved.state || {},
|
|
141
137
|
});
|
|
142
138
|
touchBrowserProfile({
|
|
@@ -164,7 +160,7 @@ module.exports = [
|
|
|
164
160
|
async handler(args, ctx) {
|
|
165
161
|
try {
|
|
166
162
|
const { bundle, payload } = readEncryptedStateBundle(args.id);
|
|
167
|
-
const profileId = safeId(args.browserProfileId || args.browserSessionId || bundle.profileId);
|
|
163
|
+
const profileId = safeId(args.browserProfileId || args.browserSessionId || bundle.profileId || DEFAULT_COOKIE_SOURCE_PROFILE_ID);
|
|
168
164
|
const loaded = await engine.loadState({
|
|
169
165
|
...args,
|
|
170
166
|
session: profileId,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Machine-local browser auth source. Sessions keep their own browser process
|
|
4
|
+
// identity, but import this encrypted bundle on first use.
|
|
5
|
+
const COOKIE_SOURCE_BUNDLE_ID = 'browser-cookie-source';
|
|
6
|
+
const DEFAULT_COOKIE_SOURCE_PROFILE_ID = 'user-default';
|
|
7
|
+
|
|
8
|
+
function cookieSourceVersion(bundle) {
|
|
9
|
+
if (!bundle) return null;
|
|
10
|
+
return bundle.blobSha256 || bundle.updatedAt || bundle.id || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
COOKIE_SOURCE_BUNDLE_ID,
|
|
15
|
+
DEFAULT_COOKIE_SOURCE_PROFILE_ID,
|
|
16
|
+
cookieSourceVersion,
|
|
17
|
+
};
|
|
@@ -21,9 +21,12 @@ const attach = require('./attach');
|
|
|
21
21
|
const capture = require('./capture');
|
|
22
22
|
const sessions = require('./sessions');
|
|
23
23
|
const typing = require('./typing');
|
|
24
|
+
const { COOKIE_SOURCE_BUNDLE_ID, cookieSourceVersion } = require('./cookie-source');
|
|
25
|
+
const { getBrowserAuthBundle, readEncryptedStateBundle } = require('./auth-bundles');
|
|
24
26
|
|
|
25
27
|
const { backendFor, sessionFor, sessionInfo } = sessions;
|
|
26
28
|
const { run, runBatch, readText } = attach;
|
|
29
|
+
const loadedCookieSources = new Map();
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Target grammar shared by click/fill:
|
|
@@ -56,8 +59,39 @@ function parseState(entryOrResult) {
|
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
function cookieSourceDisabled() {
|
|
63
|
+
return /^(0|false|no)$/i.test(String(process.env.AMALGM_BROWSER_COOKIE_SOURCE || ''));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadStateIntoSession(session, statePayload, context) {
|
|
67
|
+
const fs = require('fs');
|
|
68
|
+
const os = require('os');
|
|
69
|
+
const path = require('path');
|
|
70
|
+
const crypto = require('crypto');
|
|
71
|
+
const file = path.join(os.tmpdir(), `amalgm-browser-state-load-${session}-${Date.now()}-${crypto.randomUUID()}.json`);
|
|
72
|
+
fs.writeFileSync(file, `${JSON.stringify(statePayload, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
try {
|
|
74
|
+
await run(session, ['state', 'load', file], { context });
|
|
75
|
+
} finally {
|
|
76
|
+
try { fs.rmSync(file, { force: true }); } catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function ensureCookieSourceLoaded(session, context) {
|
|
81
|
+
if (cookieSourceDisabled()) return;
|
|
82
|
+
const bundle = getBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
83
|
+
if (!bundle?.blobPath) return;
|
|
84
|
+
const version = cookieSourceVersion(bundle);
|
|
85
|
+
if (version && loadedCookieSources.get(session) === version) return;
|
|
86
|
+
const { payload } = readEncryptedStateBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
87
|
+
if (!payload?.state || typeof payload.state !== 'object') return;
|
|
88
|
+
await loadStateIntoSession(session, payload.state, context);
|
|
89
|
+
if (version) loadedCookieSources.set(session, version);
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
async function state(args = {}, context) {
|
|
60
93
|
const session = sessionFor(args, context);
|
|
94
|
+
await ensureCookieSourceLoaded(session, context);
|
|
61
95
|
let current = { url: '', title: '' };
|
|
62
96
|
try { current = parseState(await run(session, STATE_COMMAND, { context })); } catch {}
|
|
63
97
|
return sessionInfo(session, current);
|
|
@@ -65,12 +99,14 @@ async function state(args = {}, context) {
|
|
|
65
99
|
|
|
66
100
|
async function open(args = {}, context) {
|
|
67
101
|
const session = sessionFor(args, context);
|
|
102
|
+
await ensureCookieSourceLoaded(session, context);
|
|
68
103
|
const entries = await runBatch(session, [['open', args.url], STATE_COMMAND], { context });
|
|
69
104
|
return sessionInfo(session, parseState(entries[1]));
|
|
70
105
|
}
|
|
71
106
|
|
|
72
107
|
async function snapshot(args = {}, context) {
|
|
73
108
|
const session = sessionFor(args, context);
|
|
109
|
+
await ensureCookieSourceLoaded(session, context);
|
|
74
110
|
const entries = await runBatch(session, [['snapshot', '-i'], STATE_COMMAND], { timeoutMs: 30_000, context });
|
|
75
111
|
return {
|
|
76
112
|
...sessionInfo(session, parseState(entries[1])),
|
|
@@ -80,12 +116,14 @@ async function snapshot(args = {}, context) {
|
|
|
80
116
|
|
|
81
117
|
async function screenshot(args = {}, context) {
|
|
82
118
|
const session = sessionFor(args, context);
|
|
119
|
+
await ensureCookieSourceLoaded(session, context);
|
|
83
120
|
const shot = await capture.screenshot(session, args, context);
|
|
84
121
|
return { ...sessionInfo(session), ...shot };
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
async function click(args = {}, context) {
|
|
88
125
|
const session = sessionFor(args, context);
|
|
126
|
+
await ensureCookieSourceLoaded(session, context);
|
|
89
127
|
const target = String(args.target || '');
|
|
90
128
|
await run(session, target.startsWith('text=')
|
|
91
129
|
? findArgs(target, 'click')
|
|
@@ -95,6 +133,7 @@ async function click(args = {}, context) {
|
|
|
95
133
|
|
|
96
134
|
async function fill(args = {}, context) {
|
|
97
135
|
const session = sessionFor(args, context);
|
|
136
|
+
await ensureCookieSourceLoaded(session, context);
|
|
98
137
|
const target = String(args.target || '');
|
|
99
138
|
const text = String(args.text ?? '');
|
|
100
139
|
if (backendFor(session) === 'attached') {
|
|
@@ -119,6 +158,7 @@ async function fill(args = {}, context) {
|
|
|
119
158
|
|
|
120
159
|
async function press(args = {}, context) {
|
|
121
160
|
const session = sessionFor(args, context);
|
|
161
|
+
await ensureCookieSourceLoaded(session, context);
|
|
122
162
|
if (backendFor(session) === 'attached') {
|
|
123
163
|
await run(session, ['eval', typing.keyScript(String(args.key || ''))], { context });
|
|
124
164
|
} else {
|
|
@@ -134,12 +174,14 @@ async function press(args = {}, context) {
|
|
|
134
174
|
*/
|
|
135
175
|
async function select(args = {}, context) {
|
|
136
176
|
const session = sessionFor(args, context);
|
|
177
|
+
await ensureCookieSourceLoaded(session, context);
|
|
137
178
|
await run(session, ['select', String(args.target || ''), String(args.value ?? '')], { context });
|
|
138
179
|
return sessionInfo(session, { selected: args.target, value: args.value });
|
|
139
180
|
}
|
|
140
181
|
|
|
141
182
|
async function evaluate(args = {}, context) {
|
|
142
183
|
const session = sessionFor(args, context);
|
|
184
|
+
await ensureCookieSourceLoaded(session, context);
|
|
143
185
|
const script = args.script || '';
|
|
144
186
|
let result;
|
|
145
187
|
try {
|
|
@@ -159,6 +201,7 @@ async function evaluate(args = {}, context) {
|
|
|
159
201
|
|
|
160
202
|
async function wait(args = {}, context) {
|
|
161
203
|
const session = sessionFor(args, context);
|
|
204
|
+
await ensureCookieSourceLoaded(session, context);
|
|
162
205
|
const timeoutMs = args.timeoutMs || 30_000;
|
|
163
206
|
|
|
164
207
|
if (args.ms) {
|
|
@@ -188,6 +231,7 @@ async function wait(args = {}, context) {
|
|
|
188
231
|
*/
|
|
189
232
|
async function passthrough(args = {}, context) {
|
|
190
233
|
const session = sessionFor(args, context);
|
|
234
|
+
await ensureCookieSourceLoaded(session, context);
|
|
191
235
|
const commandArgs = (args.args || []).map(String);
|
|
192
236
|
if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
|
|
193
237
|
const attached = sessions.backendFor(session) === 'attached';
|
|
@@ -225,6 +269,7 @@ function mouseButton(value) {
|
|
|
225
269
|
|
|
226
270
|
async function cua(action, args = {}, context) {
|
|
227
271
|
const session = sessionFor(args, context);
|
|
272
|
+
await ensureCookieSourceLoaded(session, context);
|
|
228
273
|
const options = { context };
|
|
229
274
|
|
|
230
275
|
const x = Math.round(Number(args.x || 0));
|
|
@@ -306,9 +351,10 @@ async function saveState(args = {}, context) {
|
|
|
306
351
|
if (!args.path) {
|
|
307
352
|
try { fs.rmSync(file, { force: true }); } catch {}
|
|
308
353
|
}
|
|
309
|
-
|
|
354
|
+
let current = { url: '', title: '' };
|
|
355
|
+
try { current = parseState(await run(session, STATE_COMMAND, { context })); } catch {}
|
|
310
356
|
return {
|
|
311
|
-
...current,
|
|
357
|
+
...sessionInfo(session, current),
|
|
312
358
|
state: stateData,
|
|
313
359
|
path: args.path ? file : undefined,
|
|
314
360
|
storageKinds: ['cookies', 'localStorage', 'sessionStorage'],
|
|
@@ -317,22 +363,13 @@ async function saveState(args = {}, context) {
|
|
|
317
363
|
|
|
318
364
|
async function loadState(args = {}, context) {
|
|
319
365
|
const session = sessionFor(args, context);
|
|
320
|
-
const fs = require('fs');
|
|
321
|
-
const os = require('os');
|
|
322
|
-
const path = require('path');
|
|
323
|
-
const crypto = require('crypto');
|
|
324
366
|
let file = args.path;
|
|
325
|
-
let temporary = false;
|
|
326
367
|
if (!file) {
|
|
327
368
|
if (!args.state || typeof args.state !== 'object') throw new Error('state or path is required');
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
temporary = true;
|
|
369
|
+
await loadStateIntoSession(session, args.state, context);
|
|
370
|
+
return sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] });
|
|
331
371
|
}
|
|
332
372
|
await run(session, ['state', 'load', file], { context });
|
|
333
|
-
if (temporary) {
|
|
334
|
-
try { fs.rmSync(file, { force: true }); } catch {}
|
|
335
|
-
}
|
|
336
373
|
return sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] });
|
|
337
374
|
}
|
|
338
375
|
|
|
@@ -371,6 +408,7 @@ module.exports = {
|
|
|
371
408
|
readBridgeAdvertisement: backend.readBridgeAdvertisement,
|
|
372
409
|
// Session façade (sessions.js).
|
|
373
410
|
backendFor: sessions.backendFor,
|
|
411
|
+
ensureCookieSourceLoaded,
|
|
374
412
|
knownSessions: sessions.knownSessions,
|
|
375
413
|
sessionFor,
|
|
376
414
|
sessionInfo,
|
|
@@ -31,6 +31,7 @@ function requestedTransport(value) {
|
|
|
31
31
|
const text = String(value || '').trim().toLowerCase();
|
|
32
32
|
if (['electron', 'electron-splitview', 'desktop'].includes(text)) return 'electron-splitview';
|
|
33
33
|
if (['novnc', 'vnc', 'remote-live-view', 'remote'].includes(text)) return 'novnc';
|
|
34
|
+
if (['amalgm-live', 'browser-auth-live', 'cdp-live', 'live'].includes(text)) return 'amalgm-live';
|
|
34
35
|
if (['none', 'manual'].includes(text)) return 'none';
|
|
35
36
|
return '';
|
|
36
37
|
}
|
|
@@ -69,9 +70,9 @@ function resolveLoginTransport(input = {}) {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
return {
|
|
72
|
-
transport: '
|
|
73
|
+
transport: 'amalgm-live',
|
|
73
74
|
liveUrl: null,
|
|
74
|
-
setupStatus: '
|
|
75
|
+
setupStatus: 'ready',
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const engine = require('./engine');
|
|
4
|
+
const { COOKIE_SOURCE_BUNDLE_ID, DEFAULT_COOKIE_SOURCE_PROFILE_ID } = require('./cookie-source');
|
|
4
5
|
|
|
5
6
|
const loadStateBrowser = (args, ctx) => engine.loadState(args, ctx);
|
|
6
7
|
const saveStateBrowser = (args, ctx) => engine.saveState(args, ctx);
|
|
@@ -23,7 +24,7 @@ const {
|
|
|
23
24
|
writeEncryptedStateBundle,
|
|
24
25
|
} = require('./store');
|
|
25
26
|
|
|
26
|
-
function profileIdFrom(input = {}, fallback =
|
|
27
|
+
function profileIdFrom(input = {}, fallback = DEFAULT_COOKIE_SOURCE_PROFILE_ID) {
|
|
27
28
|
return safeId(
|
|
28
29
|
input.browserProfileId
|
|
29
30
|
|| input.profileId
|
|
@@ -33,16 +34,6 @@ function profileIdFrom(input = {}, fallback = 'default') {
|
|
|
33
34
|
);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
function domainsFrom(inputDomains, fallbackUrl) {
|
|
37
|
-
if (Array.isArray(inputDomains) && inputDomains.length > 0) return inputDomains;
|
|
38
|
-
try {
|
|
39
|
-
const hostname = new URL(fallbackUrl).hostname;
|
|
40
|
-
return hostname ? [hostname] : [];
|
|
41
|
-
} catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
37
|
function handleProfilesList(sendJson) {
|
|
47
38
|
sendJson(200, { profiles: listBrowserProfiles() });
|
|
48
39
|
}
|
|
@@ -69,10 +60,10 @@ async function exportAuthBundleFromProfile(body = {}) {
|
|
|
69
60
|
}, {});
|
|
70
61
|
|
|
71
62
|
const bundle = writeEncryptedStateBundle({
|
|
72
|
-
id: body.id,
|
|
73
|
-
name: body.name || saved.title || profileId,
|
|
63
|
+
id: body.id || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? COOKIE_SOURCE_BUNDLE_ID : undefined),
|
|
64
|
+
name: body.name || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? 'Browser cookie source' : saved.title || profileId),
|
|
74
65
|
profileId,
|
|
75
|
-
domains:
|
|
66
|
+
domains: Array.isArray(body.domains) ? body.domains : [],
|
|
76
67
|
state: saved.state || {},
|
|
77
68
|
});
|
|
78
69
|
|
|
@@ -133,9 +124,26 @@ function loginErrorStatus(error) {
|
|
|
133
124
|
return 400;
|
|
134
125
|
}
|
|
135
126
|
|
|
127
|
+
function loginSessionIdFrom(input = {}) {
|
|
128
|
+
return String(input.id || input.sessionId || '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loginSessionTokenFrom(input = {}) {
|
|
132
|
+
return String(input.token || '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function browserSessionArgs(session, extra = {}) {
|
|
136
|
+
return {
|
|
137
|
+
...extra,
|
|
138
|
+
browserProfileId: session.profileId,
|
|
139
|
+
browserSessionId: session.profileId,
|
|
140
|
+
session: session.profileId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
async function activateLoginSessionForView(session) {
|
|
137
145
|
if (!session?.targetUrl) return session;
|
|
138
|
-
if (!['novnc', 'auto'].includes(session.transport)) return session;
|
|
146
|
+
if (!['novnc', 'auto', 'amalgm-live'].includes(session.transport)) return session;
|
|
139
147
|
|
|
140
148
|
try {
|
|
141
149
|
await navigateBrowser({
|
|
@@ -153,6 +161,18 @@ async function activateLoginSessionForView(session) {
|
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
async function activeLiveLoginSession(input = {}) {
|
|
165
|
+
let session = assertBrowserLoginSessionToken(
|
|
166
|
+
loginSessionIdFrom(input),
|
|
167
|
+
loginSessionTokenFrom(input),
|
|
168
|
+
);
|
|
169
|
+
if (session.status === 'pending') {
|
|
170
|
+
session = markBrowserLoginSessionActive(session.id, loginSessionTokenFrom(input));
|
|
171
|
+
session = await activateLoginSessionForView(session);
|
|
172
|
+
}
|
|
173
|
+
return session;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
async function handleLoginSessionsList(query = {}, sendJson) {
|
|
157
177
|
try {
|
|
158
178
|
if (query.id) {
|
|
@@ -168,6 +188,55 @@ async function handleLoginSessionsList(query = {}, sendJson) {
|
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
190
|
|
|
191
|
+
async function handleLoginSessionLiveView(query = {}, sendJson) {
|
|
192
|
+
try {
|
|
193
|
+
const session = await activeLiveLoginSession(query);
|
|
194
|
+
const shot = await engine.screenshot(browserSessionArgs(session, { fullPage: false }), {});
|
|
195
|
+
sendJson(200, {
|
|
196
|
+
session,
|
|
197
|
+
screenshot: {
|
|
198
|
+
mimeType: 'image/png',
|
|
199
|
+
data: shot.base64,
|
|
200
|
+
bytes: shot.bytes,
|
|
201
|
+
mode: shot.mode,
|
|
202
|
+
capturedAt: new Date().toISOString(),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sendJson(loginErrorStatus(error), { error: error.message });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleLoginSessionLiveInput(body = {}, sendJson) {
|
|
211
|
+
try {
|
|
212
|
+
const session = assertBrowserLoginSessionToken(
|
|
213
|
+
loginSessionIdFrom(body),
|
|
214
|
+
loginSessionTokenFrom(body),
|
|
215
|
+
);
|
|
216
|
+
const action = String(body.action || '');
|
|
217
|
+
const args = browserSessionArgs(session, body);
|
|
218
|
+
|
|
219
|
+
if (action === 'click') {
|
|
220
|
+
await engine.cua('cua_click', args, {});
|
|
221
|
+
} else if (action === 'double_click') {
|
|
222
|
+
await engine.cua('cua_double_click', args, {});
|
|
223
|
+
} else if (action === 'scroll') {
|
|
224
|
+
await engine.cua('cua_scroll', args, {});
|
|
225
|
+
} else if (action === 'type') {
|
|
226
|
+
await engine.cua('cua_type', args, {});
|
|
227
|
+
} else if (action === 'keypress') {
|
|
228
|
+
await engine.cua('cua_keypress', args, {});
|
|
229
|
+
} else {
|
|
230
|
+
sendJson(400, { error: 'Unsupported live browser action' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
sendJson(200, { ok: true, session });
|
|
235
|
+
} catch (error) {
|
|
236
|
+
sendJson(loginErrorStatus(error), { error: error.message });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
171
240
|
function handleLoginSessionCreate(body = {}, sendJson) {
|
|
172
241
|
try {
|
|
173
242
|
const result = createBrowserLoginSession(body, { source: 'browser-login-session-rest' });
|
|
@@ -220,6 +289,8 @@ module.exports = {
|
|
|
220
289
|
handleLoginSessionCancel,
|
|
221
290
|
handleLoginSessionComplete,
|
|
222
291
|
handleLoginSessionCreate,
|
|
292
|
+
handleLoginSessionLiveInput,
|
|
293
|
+
handleLoginSessionLiveView,
|
|
223
294
|
handleLoginSessionsList,
|
|
224
295
|
handleProfilesList,
|
|
225
296
|
handleProfilesUpsert,
|
|
@@ -28,9 +28,17 @@ async function handleBrowserRoutes(ctx) {
|
|
|
28
28
|
return true;
|
|
29
29
|
}
|
|
30
30
|
if (ctx.pathname.startsWith('/browser/login-sessions') && ctx.method === 'GET') {
|
|
31
|
+
if (ctx.pathname === '/browser/login-sessions/live') {
|
|
32
|
+
await browserRest.handleLoginSessionLiveView(ctx.getQuery(), ctx.sendJson);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
31
35
|
browserRest.handleLoginSessionsList(ctx.getQuery(), ctx.sendJson);
|
|
32
36
|
return true;
|
|
33
37
|
}
|
|
38
|
+
if (ctx.pathname === '/browser/login-sessions/live' && ctx.method === 'POST') {
|
|
39
|
+
await browserRest.handleLoginSessionLiveInput(await ctx.readJsonBody(), ctx.sendJson);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
34
42
|
if (ctx.pathname === '/browser/login-sessions' && ctx.method === 'POST') {
|
|
35
43
|
browserRest.handleLoginSessionCreate(await ctx.readJsonBody(), ctx.sendJson);
|
|
36
44
|
return true;
|
|
@@ -14,6 +14,7 @@ const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
|
14
14
|
const { listEventsAfter } = require('../state/events');
|
|
15
15
|
const { buildSnapshot } = require('../state/snapshot');
|
|
16
16
|
const {
|
|
17
|
+
normalizeChromiumCookie,
|
|
17
18
|
readEncryptedStateBundle,
|
|
18
19
|
writeEncryptedStateBundle,
|
|
19
20
|
} = require('../browser/auth-bundles');
|
|
@@ -88,3 +89,27 @@ test('browser auth bundles encrypt cookie/storage payloads and publish metadata
|
|
|
88
89
|
const rows = openLocalDb().prepare('SELECT value_json FROM event_log WHERE resource = ?').all('browser_auth_bundles');
|
|
89
90
|
assert.equal(JSON.stringify(rows).includes('secret-cookie'), false);
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
test('browser auth bundles normalize cookies to an injectable Chromium shape', () => {
|
|
94
|
+
const normalized = normalizeChromiumCookie({
|
|
95
|
+
name: 'sid',
|
|
96
|
+
value: 'secret-cookie',
|
|
97
|
+
domain: '.example.com',
|
|
98
|
+
httpOnly: 1,
|
|
99
|
+
secure: 1,
|
|
100
|
+
sameSite: 'Bogus',
|
|
101
|
+
partitionKey: 'https://example.com',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(normalized, {
|
|
105
|
+
name: 'sid',
|
|
106
|
+
value: 'secret-cookie',
|
|
107
|
+
domain: '.example.com',
|
|
108
|
+
path: '/',
|
|
109
|
+
expires: -1,
|
|
110
|
+
httpOnly: true,
|
|
111
|
+
secure: true,
|
|
112
|
+
sameSite: 'Lax',
|
|
113
|
+
partitionKey: 'https://example.com',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-cookie-source-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_BROWSER_AUTH_KEY = 'b'.repeat(64);
|
|
12
|
+
process.env.AMALGM_BROWSER_BACKEND = 'cli';
|
|
13
|
+
process.env.FAKE_BROWSER_LOG = path.join(tempRoot, 'browser-calls.jsonl');
|
|
14
|
+
|
|
15
|
+
const fakeBrowser = path.join(tempRoot, 'fake-agent-browser.js');
|
|
16
|
+
fs.writeFileSync(fakeBrowser, `#!/usr/bin/env node
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const valueFlags = new Set(['--session', '--profile', '--screenshot-dir', '--cdp', '--executable-path', '--provider']);
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
let command = [];
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (!arg.startsWith('--')) {
|
|
24
|
+
command = args.slice(i);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (valueFlags.has(arg)) i += 1;
|
|
28
|
+
}
|
|
29
|
+
let stdin = '';
|
|
30
|
+
if (command[0] === 'batch') {
|
|
31
|
+
try { stdin = fs.readFileSync(0, 'utf8'); } catch {}
|
|
32
|
+
}
|
|
33
|
+
fs.appendFileSync(process.env.FAKE_BROWSER_LOG, JSON.stringify({ command, stdin }) + '\\n');
|
|
34
|
+
if (command[0] === 'batch') {
|
|
35
|
+
const commands = stdin ? JSON.parse(stdin) : [];
|
|
36
|
+
const entries = commands.map((item) => ({
|
|
37
|
+
success: true,
|
|
38
|
+
command: item,
|
|
39
|
+
result: item[0] === 'eval'
|
|
40
|
+
? { result: JSON.stringify({ url: 'https://example.com/', title: 'Example' }) }
|
|
41
|
+
: { ok: true },
|
|
42
|
+
}));
|
|
43
|
+
process.stdout.write(JSON.stringify({ success: true, data: entries }));
|
|
44
|
+
} else {
|
|
45
|
+
process.stdout.write(JSON.stringify({ success: true, data: { ok: true } }));
|
|
46
|
+
}
|
|
47
|
+
`, { mode: 0o755 });
|
|
48
|
+
process.env.AMALGM_AGENT_BROWSER_BIN = fakeBrowser;
|
|
49
|
+
|
|
50
|
+
const { closeLocalDb } = require('../state/db');
|
|
51
|
+
const { COOKIE_SOURCE_BUNDLE_ID } = require('../browser/cookie-source');
|
|
52
|
+
const { writeEncryptedStateBundle } = require('../browser/auth-bundles');
|
|
53
|
+
const engine = require('../browser/engine');
|
|
54
|
+
|
|
55
|
+
function readCalls() {
|
|
56
|
+
try {
|
|
57
|
+
return fs.readFileSync(process.env.FAKE_BROWSER_LOG, 'utf8')
|
|
58
|
+
.trim()
|
|
59
|
+
.split(/\n+/)
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((line) => JSON.parse(line));
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stateLoadCalls() {
|
|
68
|
+
return readCalls().filter((call) => call.command[0] === 'state' && call.command[1] === 'load');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test.after(() => {
|
|
72
|
+
closeLocalDb();
|
|
73
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('new browser session imports the shared cookie source before opening', async () => {
|
|
77
|
+
writeEncryptedStateBundle({
|
|
78
|
+
id: COOKIE_SOURCE_BUNDLE_ID,
|
|
79
|
+
name: 'Browser cookie source',
|
|
80
|
+
profileId: 'user-default',
|
|
81
|
+
state: {
|
|
82
|
+
cookies: [
|
|
83
|
+
{
|
|
84
|
+
name: 'session',
|
|
85
|
+
value: 'secret-cookie',
|
|
86
|
+
domain: '.example.com',
|
|
87
|
+
path: '/',
|
|
88
|
+
httpOnly: true,
|
|
89
|
+
secure: true,
|
|
90
|
+
sameSite: 'Lax',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
origins: [],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const first = await engine.open(
|
|
98
|
+
{ url: 'https://example.com/' },
|
|
99
|
+
{ callerSessionId: 'chat-a', sessionMetadata: { client: 'web' } },
|
|
100
|
+
);
|
|
101
|
+
assert.equal(first.url, 'https://example.com/');
|
|
102
|
+
assert.equal(first.title, 'Example');
|
|
103
|
+
|
|
104
|
+
const calls = readCalls();
|
|
105
|
+
const firstLoad = calls.findIndex((call) => call.command[0] === 'state' && call.command[1] === 'load');
|
|
106
|
+
const firstOpen = calls.findIndex((call) => call.command[0] === 'batch');
|
|
107
|
+
assert.ok(firstLoad >= 0, 'source should be loaded');
|
|
108
|
+
assert.ok(firstOpen > firstLoad, 'source should load before page commands');
|
|
109
|
+
|
|
110
|
+
const loadedFile = calls[firstLoad].command[2];
|
|
111
|
+
assert.equal(fs.existsSync(loadedFile), false, 'temporary state file should be removed after import');
|
|
112
|
+
|
|
113
|
+
await engine.open(
|
|
114
|
+
{ url: 'https://example.com/again' },
|
|
115
|
+
{ callerSessionId: 'chat-a', sessionMetadata: { client: 'web' } },
|
|
116
|
+
);
|
|
117
|
+
assert.equal(stateLoadCalls().length, 1, 'same source version should not reload for same session');
|
|
118
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-browser-login-live-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
delete process.env.AMALGM_BROWSER_NOVNC_PUBLIC_URL;
|
|
12
|
+
delete process.env.AMALGM_BROWSER_NOVNC_URL;
|
|
13
|
+
delete process.env.AMALGM_BROWSER_LOGIN_TRANSPORT;
|
|
14
|
+
|
|
15
|
+
const engine = require('../browser/engine');
|
|
16
|
+
const calls = [];
|
|
17
|
+
engine.open = async (args) => {
|
|
18
|
+
calls.push(['open', args]);
|
|
19
|
+
return { session: args.session, url: args.url };
|
|
20
|
+
};
|
|
21
|
+
engine.screenshot = async (args) => {
|
|
22
|
+
calls.push(['screenshot', args]);
|
|
23
|
+
return { base64: Buffer.from('png').toString('base64'), bytes: 3, mode: 'viewport' };
|
|
24
|
+
};
|
|
25
|
+
engine.cua = async (action, args) => {
|
|
26
|
+
calls.push(['cua', action, args]);
|
|
27
|
+
return { session: args.session };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const { closeLocalDb } = require('../state/db');
|
|
31
|
+
const { createBrowserLoginSession } = require('../browser/login-sessions');
|
|
32
|
+
const rest = require('../browser/rest');
|
|
33
|
+
|
|
34
|
+
function captureSend() {
|
|
35
|
+
const sent = [];
|
|
36
|
+
return {
|
|
37
|
+
sent,
|
|
38
|
+
sendJson(status, data) {
|
|
39
|
+
sent.push({ status, data });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test.after(() => {
|
|
45
|
+
closeLocalDb();
|
|
46
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('browser auth live view activates once and returns a screenshot', async () => {
|
|
50
|
+
const created = createBrowserLoginSession({
|
|
51
|
+
targetUrl: 'https://example.com/login',
|
|
52
|
+
browserProfileId: 'live-profile',
|
|
53
|
+
name: 'Live profile',
|
|
54
|
+
});
|
|
55
|
+
assert.equal(created.session.transport, 'amalgm-live');
|
|
56
|
+
assert.equal(created.session.setupStatus, 'ready');
|
|
57
|
+
|
|
58
|
+
const first = captureSend();
|
|
59
|
+
await rest.handleLoginSessionLiveView({ id: created.session.id, token: created.token }, first.sendJson);
|
|
60
|
+
|
|
61
|
+
assert.equal(first.sent[0].status, 200);
|
|
62
|
+
assert.equal(first.sent[0].data.session.status, 'active');
|
|
63
|
+
assert.equal(first.sent[0].data.screenshot.mimeType, 'image/png');
|
|
64
|
+
assert.equal(first.sent[0].data.screenshot.data, Buffer.from('png').toString('base64'));
|
|
65
|
+
assert.deepEqual(calls.map((call) => call[0]), ['open', 'screenshot']);
|
|
66
|
+
assert.equal(calls[0][1].browserProfileId, 'live-profile');
|
|
67
|
+
assert.equal(calls[1][1].session, 'live-profile');
|
|
68
|
+
|
|
69
|
+
calls.length = 0;
|
|
70
|
+
const second = captureSend();
|
|
71
|
+
await rest.handleLoginSessionLiveView({ id: created.session.id, token: created.token }, second.sendJson);
|
|
72
|
+
assert.equal(second.sent[0].status, 200);
|
|
73
|
+
assert.deepEqual(calls.map((call) => call[0]), ['screenshot']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('browser auth live input validates token and dispatches CUA actions to the login profile', async () => {
|
|
77
|
+
const created = createBrowserLoginSession({
|
|
78
|
+
targetUrl: 'https://example.com/login',
|
|
79
|
+
browserProfileId: 'input-profile',
|
|
80
|
+
name: 'Input profile',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
calls.length = 0;
|
|
84
|
+
const good = captureSend();
|
|
85
|
+
await rest.handleLoginSessionLiveInput({
|
|
86
|
+
id: created.session.id,
|
|
87
|
+
token: created.token,
|
|
88
|
+
action: 'click',
|
|
89
|
+
x: 12,
|
|
90
|
+
y: 34,
|
|
91
|
+
}, good.sendJson);
|
|
92
|
+
|
|
93
|
+
assert.equal(good.sent[0].status, 200);
|
|
94
|
+
assert.equal(calls[0][0], 'cua');
|
|
95
|
+
assert.equal(calls[0][1], 'cua_click');
|
|
96
|
+
assert.equal(calls[0][2].session, 'input-profile');
|
|
97
|
+
assert.equal(calls[0][2].browserSessionId, 'input-profile');
|
|
98
|
+
|
|
99
|
+
const bad = captureSend();
|
|
100
|
+
await rest.handleLoginSessionLiveInput({
|
|
101
|
+
id: created.session.id,
|
|
102
|
+
token: 'wrong',
|
|
103
|
+
action: 'click',
|
|
104
|
+
x: 1,
|
|
105
|
+
y: 2,
|
|
106
|
+
}, bad.sendJson);
|
|
107
|
+
assert.equal(bad.sent[0].status, 401);
|
|
108
|
+
});
|