amalgm 0.1.154 → 0.1.155
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/agent-bundles/bundles.js +1 -2
- package/runtime/scripts/amalgm-mcp/agent-bundles/export.js +46 -38
- package/runtime/scripts/amalgm-mcp/agent-bundles/graph.js +232 -261
- package/runtime/scripts/amalgm-mcp/agent-bundles/install.js +7 -12
- package/runtime/scripts/amalgm-mcp/browser/auth-bundles.js +12 -1
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +8 -9
- package/runtime/scripts/amalgm-mcp/browser/cli.js +3 -2
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +364 -0
- package/runtime/scripts/amalgm-mcp/browser/engine.js +120 -42
- package/runtime/scripts/amalgm-mcp/browser/profiles.js +124 -0
- package/runtime/scripts/amalgm-mcp/browser/rest.js +47 -7
- package/runtime/scripts/amalgm-mcp/browser/sessions.js +10 -7
- package/runtime/scripts/amalgm-mcp/browser/store.js +1 -0
- package/runtime/scripts/amalgm-mcp/index.js +13 -0
- package/runtime/scripts/amalgm-mcp/server/routes/browser.js +10 -0
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +22 -12
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-jar.test.js +152 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-source.test.js +67 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-profile-lifecycle.test.js +78 -0
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +82 -15
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* backend.js — which browser: visible desktop surface vs headless cli
|
|
8
8
|
* attach.js — the visible-surface handshake + command transport
|
|
9
9
|
* capture.js — pixels: screenshots, and the recorder's frame source
|
|
10
|
-
* sessions.js — session identity
|
|
10
|
+
* sessions.js — live session identity; saved profile registration is explicit
|
|
11
11
|
* cli.js — the agent-browser process wrapper
|
|
12
12
|
*
|
|
13
13
|
* This module stays the single import for tool handlers and tests; the
|
|
@@ -21,12 +21,18 @@ const attach = require('./attach');
|
|
|
21
21
|
const capture = require('./capture');
|
|
22
22
|
const sessions = require('./sessions');
|
|
23
23
|
const typing = require('./typing');
|
|
24
|
-
const {
|
|
25
|
-
|
|
24
|
+
const {
|
|
25
|
+
cookieJarState,
|
|
26
|
+
diffCookieSnapshots,
|
|
27
|
+
mergeCookieJar,
|
|
28
|
+
normalizeCookieList,
|
|
29
|
+
readCookieJar,
|
|
30
|
+
} = require('./cookie-jar');
|
|
26
31
|
|
|
27
32
|
const { backendFor, sessionFor, sessionInfo } = sessions;
|
|
28
33
|
const { run, runBatch, readText } = attach;
|
|
29
34
|
const loadedCookieSources = new Map();
|
|
35
|
+
const headlessCookieBaselines = new Map();
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Target grammar shared by click/fill:
|
|
@@ -79,29 +85,83 @@ async function loadStateIntoSession(session, statePayload, context) {
|
|
|
79
85
|
|
|
80
86
|
async function ensureCookieSourceLoaded(session, context) {
|
|
81
87
|
if (cookieSourceDisabled()) return;
|
|
82
|
-
// Attached sessions
|
|
83
|
-
//
|
|
84
|
-
// bundle back into it is at best an echo and at worst overwrites the user's
|
|
85
|
-
// live cookies with older ones. Only headless sessions import.
|
|
88
|
+
// Attached sessions synchronize through Electron's cookie-jar adapter.
|
|
89
|
+
// This adapter owns only standalone agent-browser sessions.
|
|
86
90
|
if (sessions.backendFor(session) === 'attached') return;
|
|
87
|
-
const bundle = getBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
88
|
-
if (!bundle?.blobPath) return;
|
|
89
|
-
const version = cookieSourceVersion(bundle);
|
|
90
|
-
if (version && loadedCookieSources.get(session) === version) return;
|
|
91
91
|
try {
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
let jar = readCookieJar();
|
|
93
|
+
if (loadedCookieSources.get(session) === jar.versionToken) return;
|
|
94
|
+
|
|
95
|
+
if (!jar.exists) {
|
|
96
|
+
// First adapter wins only when the canonical jar has never existed.
|
|
97
|
+
// This migrates a deliberately reused headless profile without making
|
|
98
|
+
// an empty new profile authoritative over other adapters.
|
|
99
|
+
const localCookies = await readHeadlessCookies(session, context);
|
|
100
|
+
if (localCookies.length) {
|
|
101
|
+
jar = mergeCookieJar({ sourceId: `headless:${session}:seed`, upserts: localCookies });
|
|
102
|
+
}
|
|
103
|
+
loadedCookieSources.set(session, jar.versionToken);
|
|
104
|
+
headlessCookieBaselines.set(session, { versionToken: jar.versionToken, cookies: localCookies });
|
|
105
|
+
return;
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
// State loading adds/overwrites cookies but does not reliably remove
|
|
109
|
+
// cookies absent from the snapshot. Clear first so tombstones and logout
|
|
110
|
+
// mutations converge too. Headless profiles contain no app UI session.
|
|
111
|
+
await run(session, ['cookies', 'clear'], { context });
|
|
112
|
+
await loadStateIntoSession(session, cookieJarState(jar), context);
|
|
113
|
+
const cookies = normalizeCookieList(jar.cookies);
|
|
114
|
+
loadedCookieSources.set(session, jar.versionToken);
|
|
115
|
+
headlessCookieBaselines.set(session, { versionToken: jar.versionToken, cookies });
|
|
116
|
+
console.error(`[browser] cookie jar ${jar.versionToken.slice(0, 18)} applied to session ${session}`);
|
|
97
117
|
} catch (err) {
|
|
98
|
-
// A
|
|
99
|
-
|
|
100
|
-
console.error(`[browser] cookie source import failed for session ${session}: ${err.message}`);
|
|
118
|
+
// A corrupt jar or adapter failure must not break every browser verb.
|
|
119
|
+
console.error(`[browser] cookie jar apply failed for session ${session}: ${err.message}`);
|
|
101
120
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cookieResultList(result) {
|
|
124
|
+
const data = result?.data ?? result;
|
|
125
|
+
return normalizeCookieList(data?.cookies || []);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readHeadlessCookies(session, context) {
|
|
129
|
+
return cookieResultList(await run(session, ['cookies'], { context }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function syncHeadlessCookies(session, context) {
|
|
133
|
+
if (cookieSourceDisabled() || sessions.backendFor(session) === 'attached') return;
|
|
134
|
+
const baseline = headlessCookieBaselines.get(session);
|
|
135
|
+
if (!baseline) return;
|
|
136
|
+
try {
|
|
137
|
+
const cookies = await readHeadlessCookies(session, context);
|
|
138
|
+
const mutations = diffCookieSnapshots(baseline.cookies, cookies);
|
|
139
|
+
if (!mutations.upserts.length && !mutations.deletes.length) return;
|
|
140
|
+
|
|
141
|
+
// If another adapter changed the jar while this browser action was in
|
|
142
|
+
// flight, merge only this session's actual diff and deliberately leave
|
|
143
|
+
// its loaded token stale. The next verb will apply the combined snapshot.
|
|
144
|
+
const beforeMerge = readCookieJar();
|
|
145
|
+
const jar = mergeCookieJar({
|
|
146
|
+
sourceId: `headless:${session}`,
|
|
147
|
+
upserts: mutations.upserts,
|
|
148
|
+
deletes: mutations.deletes,
|
|
149
|
+
});
|
|
150
|
+
const converged = beforeMerge.versionToken === baseline.versionToken;
|
|
151
|
+
const versionToken = converged ? jar.versionToken : baseline.versionToken;
|
|
152
|
+
headlessCookieBaselines.set(session, { versionToken, cookies });
|
|
153
|
+
loadedCookieSources.set(session, versionToken);
|
|
154
|
+
console.error(`[browser] cookie jar accepted ${mutations.upserts.length} upsert(s), ${mutations.deletes.length} deletion(s) from session ${session}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// Cookie write-back is best-effort per verb; the browser action itself is
|
|
157
|
+
// still truthful and the next verb retries from the last good baseline.
|
|
158
|
+
console.error(`[browser] cookie jar write-back failed for session ${session}: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function finishCookieAction(session, result, context) {
|
|
163
|
+
await syncHeadlessCookies(session, context);
|
|
164
|
+
return result;
|
|
105
165
|
}
|
|
106
166
|
|
|
107
167
|
async function state(args = {}, context) {
|
|
@@ -109,31 +169,31 @@ async function state(args = {}, context) {
|
|
|
109
169
|
await ensureCookieSourceLoaded(session, context);
|
|
110
170
|
let current = { url: '', title: '' };
|
|
111
171
|
try { current = parseState(await run(session, STATE_COMMAND, { context })); } catch {}
|
|
112
|
-
return sessionInfo(session, current);
|
|
172
|
+
return finishCookieAction(session, sessionInfo(session, current), context);
|
|
113
173
|
}
|
|
114
174
|
|
|
115
175
|
async function open(args = {}, context) {
|
|
116
176
|
const session = sessionFor(args, context);
|
|
117
177
|
await ensureCookieSourceLoaded(session, context);
|
|
118
178
|
const entries = await runBatch(session, [['open', args.url], STATE_COMMAND], { context });
|
|
119
|
-
return sessionInfo(session, parseState(entries[1]));
|
|
179
|
+
return finishCookieAction(session, sessionInfo(session, parseState(entries[1])), context);
|
|
120
180
|
}
|
|
121
181
|
|
|
122
182
|
async function snapshot(args = {}, context) {
|
|
123
183
|
const session = sessionFor(args, context);
|
|
124
184
|
await ensureCookieSourceLoaded(session, context);
|
|
125
185
|
const entries = await runBatch(session, [['snapshot', '-i'], STATE_COMMAND], { timeoutMs: 30_000, context });
|
|
126
|
-
return {
|
|
186
|
+
return finishCookieAction(session, {
|
|
127
187
|
...sessionInfo(session, parseState(entries[1])),
|
|
128
188
|
snapshotText: cli.asText(entries[0]?.result ?? entries[0]),
|
|
129
|
-
};
|
|
189
|
+
}, context);
|
|
130
190
|
}
|
|
131
191
|
|
|
132
192
|
async function screenshot(args = {}, context) {
|
|
133
193
|
const session = sessionFor(args, context);
|
|
134
194
|
await ensureCookieSourceLoaded(session, context);
|
|
135
195
|
const shot = await capture.screenshot(session, args, context);
|
|
136
|
-
return { ...sessionInfo(session), ...shot };
|
|
196
|
+
return finishCookieAction(session, { ...sessionInfo(session), ...shot }, context);
|
|
137
197
|
}
|
|
138
198
|
|
|
139
199
|
async function click(args = {}, context) {
|
|
@@ -143,7 +203,7 @@ async function click(args = {}, context) {
|
|
|
143
203
|
await run(session, target.startsWith('text=')
|
|
144
204
|
? findArgs(target, 'click')
|
|
145
205
|
: ['click', target], { context });
|
|
146
|
-
return sessionInfo(session, { clicked: target });
|
|
206
|
+
return finishCookieAction(session, sessionInfo(session, { clicked: target }), context);
|
|
147
207
|
}
|
|
148
208
|
|
|
149
209
|
async function fill(args = {}, context) {
|
|
@@ -168,7 +228,7 @@ async function fill(args = {}, context) {
|
|
|
168
228
|
await run(session, fillCommand, { context });
|
|
169
229
|
}
|
|
170
230
|
}
|
|
171
|
-
return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
|
|
231
|
+
return finishCookieAction(session, sessionInfo(session, { filled: target, submitted: Boolean(args.submit) }), context);
|
|
172
232
|
}
|
|
173
233
|
|
|
174
234
|
async function press(args = {}, context) {
|
|
@@ -179,7 +239,7 @@ async function press(args = {}, context) {
|
|
|
179
239
|
} else {
|
|
180
240
|
await run(session, ['press', args.key], { context });
|
|
181
241
|
}
|
|
182
|
-
return sessionInfo(session, { pressed: args.key });
|
|
242
|
+
return finishCookieAction(session, sessionInfo(session, { pressed: args.key }), context);
|
|
183
243
|
}
|
|
184
244
|
|
|
185
245
|
/**
|
|
@@ -191,7 +251,7 @@ async function select(args = {}, context) {
|
|
|
191
251
|
const session = sessionFor(args, context);
|
|
192
252
|
await ensureCookieSourceLoaded(session, context);
|
|
193
253
|
await run(session, ['select', String(args.target || ''), String(args.value ?? '')], { context });
|
|
194
|
-
return sessionInfo(session, { selected: args.target, value: args.value });
|
|
254
|
+
return finishCookieAction(session, sessionInfo(session, { selected: args.target, value: args.value }), context);
|
|
195
255
|
}
|
|
196
256
|
|
|
197
257
|
async function evaluate(args = {}, context) {
|
|
@@ -211,7 +271,7 @@ async function evaluate(args = {}, context) {
|
|
|
211
271
|
const data = result?.data ?? result;
|
|
212
272
|
// agent-browser wraps eval results as { origin, result }.
|
|
213
273
|
const value = data && typeof data === 'object' && 'result' in data ? data.result : data;
|
|
214
|
-
return { ...sessionInfo(session), result: value };
|
|
274
|
+
return finishCookieAction(session, { ...sessionInfo(session), result: value }, context);
|
|
215
275
|
}
|
|
216
276
|
|
|
217
277
|
async function wait(args = {}, context) {
|
|
@@ -221,20 +281,22 @@ async function wait(args = {}, context) {
|
|
|
221
281
|
|
|
222
282
|
if (args.ms) {
|
|
223
283
|
await new Promise((resolve) => setTimeout(resolve, Math.min(Number(args.ms) || 0, 60_000)));
|
|
224
|
-
return sessionInfo(session, { waited: `${args.ms}ms` });
|
|
284
|
+
return finishCookieAction(session, sessionInfo(session, { waited: `${args.ms}ms` }), context);
|
|
225
285
|
}
|
|
226
286
|
if (args.url) {
|
|
227
287
|
const deadline = Date.now() + timeoutMs;
|
|
228
288
|
while (Date.now() < deadline) {
|
|
229
289
|
const current = await readText(session, ['get', 'url']);
|
|
230
|
-
if (current.includes(args.url.replace(/\*/g, '')))
|
|
290
|
+
if (current.includes(args.url.replace(/\*/g, ''))) {
|
|
291
|
+
return finishCookieAction(session, sessionInfo(session, { matched: current }), context);
|
|
292
|
+
}
|
|
231
293
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
232
294
|
}
|
|
233
295
|
throw new Error(`Timed out waiting for URL ${args.url}`);
|
|
234
296
|
}
|
|
235
297
|
if (args.selector) {
|
|
236
298
|
await run(session, ['wait', args.selector], { timeoutMs, context });
|
|
237
|
-
return sessionInfo(session, { matched: args.selector });
|
|
299
|
+
return finishCookieAction(session, sessionInfo(session, { matched: args.selector }), context);
|
|
238
300
|
}
|
|
239
301
|
throw new Error('wait requires one of: selector, url, ms');
|
|
240
302
|
}
|
|
@@ -261,15 +323,15 @@ async function passthrough(args = {}, context) {
|
|
|
261
323
|
if (attached && commandArgs[0] === 'keyboard' && commandArgs[1] === 'type') {
|
|
262
324
|
const text = commandArgs.slice(2).join(' ');
|
|
263
325
|
const result = await run(session, ['eval', typing.insertTextScript(text)], { timeoutMs: args.timeoutMs || 60_000, context });
|
|
264
|
-
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
326
|
+
return finishCookieAction(session, { ...sessionInfo(session), result: result?.data ?? result }, context);
|
|
265
327
|
}
|
|
266
328
|
if (attached && commandArgs[0] === 'press') {
|
|
267
329
|
const key = commandArgs.slice(1).join('+');
|
|
268
330
|
const result = await run(session, ['eval', typing.keyScript(key)], { timeoutMs: args.timeoutMs || 60_000, context });
|
|
269
|
-
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
331
|
+
return finishCookieAction(session, { ...sessionInfo(session), result: result?.data ?? result }, context);
|
|
270
332
|
}
|
|
271
333
|
const result = await run(session, commandArgs, { timeoutMs: args.timeoutMs || 60_000, context });
|
|
272
|
-
return { ...sessionInfo(session), result: result?.data ?? result };
|
|
334
|
+
return finishCookieAction(session, { ...sessionInfo(session), result: result?.data ?? result }, context);
|
|
273
335
|
}
|
|
274
336
|
|
|
275
337
|
/** Schema convention is 1 left / 2 middle / 3 right; agent-browser's mouse
|
|
@@ -347,7 +409,7 @@ async function cua(action, args = {}, context) {
|
|
|
347
409
|
['mouse', 'up', 'left'],
|
|
348
410
|
], options);
|
|
349
411
|
}
|
|
350
|
-
return sessionInfo(session, { ok: true });
|
|
412
|
+
return finishCookieAction(session, sessionInfo(session, { ok: true }), context);
|
|
351
413
|
}
|
|
352
414
|
|
|
353
415
|
// ---------------------------------------------------------------------------
|
|
@@ -356,6 +418,7 @@ async function cua(action, args = {}, context) {
|
|
|
356
418
|
|
|
357
419
|
async function saveState(args = {}, context) {
|
|
358
420
|
const session = sessionFor(args, context);
|
|
421
|
+
await ensureCookieSourceLoaded(session, context);
|
|
359
422
|
const fs = require('fs');
|
|
360
423
|
const os = require('os');
|
|
361
424
|
const path = require('path');
|
|
@@ -368,28 +431,31 @@ async function saveState(args = {}, context) {
|
|
|
368
431
|
}
|
|
369
432
|
let current = { url: '', title: '' };
|
|
370
433
|
try { current = parseState(await run(session, STATE_COMMAND, { context })); } catch {}
|
|
371
|
-
return {
|
|
434
|
+
return finishCookieAction(session, {
|
|
372
435
|
...sessionInfo(session, current),
|
|
373
436
|
state: stateData,
|
|
374
437
|
path: args.path ? file : undefined,
|
|
375
438
|
storageKinds: ['cookies', 'localStorage', 'sessionStorage'],
|
|
376
|
-
};
|
|
439
|
+
}, context);
|
|
377
440
|
}
|
|
378
441
|
|
|
379
442
|
async function loadState(args = {}, context) {
|
|
380
443
|
const session = sessionFor(args, context);
|
|
444
|
+
await ensureCookieSourceLoaded(session, context);
|
|
381
445
|
let file = args.path;
|
|
382
446
|
if (!file) {
|
|
383
447
|
if (!args.state || typeof args.state !== 'object') throw new Error('state or path is required');
|
|
384
448
|
await loadStateIntoSession(session, args.state, context);
|
|
385
|
-
return sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] });
|
|
449
|
+
return finishCookieAction(session, sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] }), context);
|
|
386
450
|
}
|
|
387
451
|
await run(session, ['state', 'load', file], { context });
|
|
388
|
-
return sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] });
|
|
452
|
+
return finishCookieAction(session, sessionInfo(session, { loaded: true, storageKinds: ['cookies', 'localStorage', 'sessionStorage'] }), context);
|
|
389
453
|
}
|
|
390
454
|
|
|
391
455
|
async function close(args = {}, context) {
|
|
392
456
|
const session = sessionFor(args, context);
|
|
457
|
+
const attached = sessions.backendFor(session) === 'attached';
|
|
458
|
+
await syncHeadlessCookies(session, context);
|
|
393
459
|
// A recording keeps a CDP connection and an encoder alive — closing the
|
|
394
460
|
// session finalizes it (saving what was captured) rather than orphaning it.
|
|
395
461
|
const recorder = require('./recorder');
|
|
@@ -413,6 +479,17 @@ async function close(args = {}, context) {
|
|
|
413
479
|
}
|
|
414
480
|
attach.detach(session);
|
|
415
481
|
sessions.knownSessions.delete(session);
|
|
482
|
+
sessions.homes.delete(session);
|
|
483
|
+
loadedCookieSources.delete(session);
|
|
484
|
+
headlessCookieBaselines.delete(session);
|
|
485
|
+
if (!attached) {
|
|
486
|
+
try {
|
|
487
|
+
require('./profiles').deleteBrowserProfile(session, {
|
|
488
|
+
onlyIfUnreferenced: true,
|
|
489
|
+
source: 'browser-session-close',
|
|
490
|
+
});
|
|
491
|
+
} catch {}
|
|
492
|
+
}
|
|
416
493
|
}
|
|
417
494
|
|
|
418
495
|
module.exports = {
|
|
@@ -424,6 +501,7 @@ module.exports = {
|
|
|
424
501
|
// Session façade (sessions.js).
|
|
425
502
|
backendFor: sessions.backendFor,
|
|
426
503
|
ensureCookieSourceLoaded,
|
|
504
|
+
syncHeadlessCookies,
|
|
427
505
|
knownSessions: sessions.knownSessions,
|
|
428
506
|
sessionFor,
|
|
429
507
|
sessionInfo,
|
|
@@ -142,11 +142,135 @@ function touchBrowserProfile(input = {}, options = {}) {
|
|
|
142
142
|
}, options);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
function referencedProfileIds(database = openLocalDb()) {
|
|
146
|
+
const ids = new Set();
|
|
147
|
+
for (const row of database.prepare('SELECT DISTINCT profile_id FROM browser_auth_bundles').all()) {
|
|
148
|
+
if (row.profile_id) ids.add(safeId(row.profile_id));
|
|
149
|
+
}
|
|
150
|
+
for (const row of database.prepare(`
|
|
151
|
+
SELECT DISTINCT profile_id FROM browser_login_sessions
|
|
152
|
+
WHERE status IN ('pending', 'active')
|
|
153
|
+
`).all()) {
|
|
154
|
+
if (row.profile_id) ids.add(safeId(row.profile_id));
|
|
155
|
+
}
|
|
156
|
+
return ids;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isBrowserProfileDurable(id, database = openLocalDb()) {
|
|
160
|
+
const profileId = safeId(id);
|
|
161
|
+
if (referencedProfileIds(database).has(profileId)) return true;
|
|
162
|
+
const row = database.prepare('SELECT source FROM browser_profiles WHERE id = ?').get(profileId);
|
|
163
|
+
return Boolean(row && !['agent-browser', 'agent-browser-ephemeral'].includes(row.source));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function removeProfileDirectory(profileId) {
|
|
167
|
+
const target = profilePathFor(profileId);
|
|
168
|
+
const root = path.dirname(profilePathFor('__profile_root_marker__'));
|
|
169
|
+
if (path.dirname(target) !== root) throw new Error(`Unsafe browser profile path: ${target}`);
|
|
170
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function deleteBrowserProfile(id, options = {}) {
|
|
174
|
+
const db = openLocalDb();
|
|
175
|
+
const profileId = safeId(id);
|
|
176
|
+
if (options.onlyIfUnreferenced !== false && isBrowserProfileDurable(profileId, db)) {
|
|
177
|
+
return { ok: true, deleted: false, preserved: true, id: profileId };
|
|
178
|
+
}
|
|
179
|
+
if (options.deleteDirectory !== false) removeProfileDirectory(profileId);
|
|
180
|
+
const result = db.prepare('DELETE FROM browser_profiles WHERE id = ?').run(profileId);
|
|
181
|
+
if (result.changes) publishProfiles(db, options.source || 'browser-profile-delete');
|
|
182
|
+
return { ok: true, deleted: Boolean(result.changes), directoryDeleted: options.deleteDirectory !== false, id: profileId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function profileLastTouchedAt(profilePath) {
|
|
186
|
+
const candidates = [
|
|
187
|
+
profilePath,
|
|
188
|
+
path.join(profilePath, 'Local State'),
|
|
189
|
+
path.join(profilePath, 'Default'),
|
|
190
|
+
path.join(profilePath, 'Default', 'Cookies'),
|
|
191
|
+
path.join(profilePath, 'Default', 'Network', 'Cookies'),
|
|
192
|
+
path.join(profilePath, 'Default', 'History'),
|
|
193
|
+
];
|
|
194
|
+
let latest = 0;
|
|
195
|
+
for (const candidate of candidates) {
|
|
196
|
+
try { latest = Math.max(latest, fs.statSync(candidate).mtimeMs); } catch {}
|
|
197
|
+
}
|
|
198
|
+
return latest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hasLiveProfileLock(profilePath) {
|
|
202
|
+
const lock = path.join(profilePath, 'SingletonLock');
|
|
203
|
+
try {
|
|
204
|
+
const stat = fs.lstatSync(lock);
|
|
205
|
+
if (stat.isSymbolicLink()) {
|
|
206
|
+
const target = fs.readlinkSync(lock);
|
|
207
|
+
const pid = Number(target.match(/-(\d+)$/)?.[1] || 0);
|
|
208
|
+
if (!pid) return false;
|
|
209
|
+
try {
|
|
210
|
+
process.kill(pid, 0);
|
|
211
|
+
return true;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Some Chromium builds use a regular lock file. Treat only a recently
|
|
217
|
+
// touched one as live; an ancient crash artifact must not defeat GC.
|
|
218
|
+
return Date.now() - stat.mtimeMs < 5 * 60 * 1000;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pruneEphemeralBrowserProfiles(options = {}) {
|
|
225
|
+
const db = openLocalDb();
|
|
226
|
+
const root = path.dirname(profilePathFor('__profile_root_marker__'));
|
|
227
|
+
const ttlHours = Number(options.ttlHours ?? process.env.AMALGM_BROWSER_PROFILE_TTL_HOURS ?? 24);
|
|
228
|
+
if (!Number.isFinite(ttlHours) || ttlHours < 0) return { removedDirectories: 0, removedRows: 0 };
|
|
229
|
+
const cutoff = Date.now() - ttlHours * 60 * 60 * 1000;
|
|
230
|
+
const durable = referencedProfileIds(db);
|
|
231
|
+
for (const row of db.prepare('SELECT id, source FROM browser_profiles').all()) {
|
|
232
|
+
if (!['agent-browser', 'agent-browser-ephemeral'].includes(row.source)) durable.add(safeId(row.id));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let removedDirectories = 0;
|
|
236
|
+
const removedIds = [];
|
|
237
|
+
let entries = [];
|
|
238
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch {}
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (!(entry.isDirectory() || entry.isSymbolicLink())) continue;
|
|
241
|
+
const id = safeId(entry.name);
|
|
242
|
+
const target = path.join(root, entry.name);
|
|
243
|
+
if (durable.has(id) || hasLiveProfileLock(target) || profileLastTouchedAt(target) > cutoff) continue;
|
|
244
|
+
removeProfileDirectory(id);
|
|
245
|
+
removedDirectories += 1;
|
|
246
|
+
removedIds.push(id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let removedRows = 0;
|
|
250
|
+
const ephemeralRows = db.prepare(`
|
|
251
|
+
SELECT id FROM browser_profiles
|
|
252
|
+
WHERE source IN ('agent-browser', 'agent-browser-ephemeral')
|
|
253
|
+
`).all();
|
|
254
|
+
for (const row of ephemeralRows) {
|
|
255
|
+
const id = safeId(row.id);
|
|
256
|
+
if (durable.has(id)) continue;
|
|
257
|
+
const target = profilePathFor(id);
|
|
258
|
+
if (hasLiveProfileLock(target) || profileLastTouchedAt(target) > cutoff) continue;
|
|
259
|
+
removedRows += db.prepare('DELETE FROM browser_profiles WHERE id = ?').run(id).changes;
|
|
260
|
+
}
|
|
261
|
+
if (removedRows) publishProfiles(db, options.source || 'browser-profile-prune');
|
|
262
|
+
return { removedDirectories, removedRows, removedIds };
|
|
263
|
+
}
|
|
264
|
+
|
|
145
265
|
module.exports = {
|
|
146
266
|
PROFILE_RESOURCE,
|
|
267
|
+
deleteBrowserProfile,
|
|
147
268
|
getBrowserProfile,
|
|
269
|
+
isBrowserProfileDurable,
|
|
148
270
|
listBrowserProfiles,
|
|
149
271
|
normalizeProfile,
|
|
272
|
+
pruneEphemeralBrowserProfiles,
|
|
273
|
+
referencedProfileIds,
|
|
150
274
|
touchBrowserProfile,
|
|
151
275
|
upsertBrowserProfile,
|
|
152
276
|
};
|
|
@@ -12,11 +12,14 @@ const {
|
|
|
12
12
|
completeBrowserLoginSession,
|
|
13
13
|
createBrowserLoginSession,
|
|
14
14
|
deleteBrowserAuthBundle,
|
|
15
|
+
getBrowserAuthBundle,
|
|
15
16
|
listBrowserLoginSessions,
|
|
16
17
|
listBrowserAuthBundles,
|
|
17
18
|
listBrowserProfiles,
|
|
18
19
|
markBrowserLoginSessionActive,
|
|
19
20
|
markBrowserAuthBundleImported,
|
|
21
|
+
mergeCookieJar,
|
|
22
|
+
readCookieJar,
|
|
20
23
|
readEncryptedStateBundle,
|
|
21
24
|
safeId,
|
|
22
25
|
touchBrowserProfile,
|
|
@@ -66,13 +69,31 @@ async function exportAuthBundleFromProfile(body = {}) {
|
|
|
66
69
|
browserSessionId: profileId,
|
|
67
70
|
}, {});
|
|
68
71
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
const requestedId = body.id || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? COOKIE_SOURCE_BUNDLE_ID : undefined);
|
|
73
|
+
const isLegacyCookieJarPush = requestedId === COOKIE_SOURCE_BUNDLE_ID
|
|
74
|
+
&& body.state
|
|
75
|
+
&& typeof body.state === 'object';
|
|
76
|
+
let bundle;
|
|
77
|
+
if (isLegacyCookieJarPush) {
|
|
78
|
+
// Rolling-upgrade compatibility for older Electron clients. Merge their
|
|
79
|
+
// snapshot into the canonical jar instead of replacing it, so cookies
|
|
80
|
+
// written by headless/future adapters cannot be clobbered by an old
|
|
81
|
+
// one-way producer. New adapters use /browser/cookie-jar mutations.
|
|
82
|
+
mergeCookieJar({
|
|
83
|
+
sourceId: body.sourceId || 'legacy-electron-snapshot',
|
|
84
|
+
upserts: saved.state?.cookies || [],
|
|
85
|
+
excludeHosts: body.excludeHosts,
|
|
86
|
+
});
|
|
87
|
+
bundle = getBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
88
|
+
} else {
|
|
89
|
+
bundle = writeEncryptedStateBundle({
|
|
90
|
+
id: requestedId,
|
|
91
|
+
name: body.name || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? 'Browser cookie source' : saved.title || profileId),
|
|
92
|
+
profileId,
|
|
93
|
+
domains: Array.isArray(body.domains) ? body.domains : [],
|
|
94
|
+
state: saved.state || {},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
76
97
|
|
|
77
98
|
touchBrowserProfile({
|
|
78
99
|
id: profileId,
|
|
@@ -83,6 +104,23 @@ async function exportAuthBundleFromProfile(body = {}) {
|
|
|
83
104
|
return { bundle, saved, profileId };
|
|
84
105
|
}
|
|
85
106
|
|
|
107
|
+
function handleCookieJarGet(sendJson) {
|
|
108
|
+
// Internal authenticated runtime endpoint. Cookie values never enter tool
|
|
109
|
+
// output, resource snapshots, state events, or logs; browser adapters need
|
|
110
|
+
// them here to apply the canonical jar to their local implementation.
|
|
111
|
+
sendJson(200, { jar: readCookieJar() });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleCookieJarMerge(body = {}, sendJson) {
|
|
115
|
+
const jar = mergeCookieJar({
|
|
116
|
+
sourceId: body.sourceId,
|
|
117
|
+
upserts: body.upserts,
|
|
118
|
+
deletes: body.deletes,
|
|
119
|
+
excludeHosts: body.excludeHosts,
|
|
120
|
+
});
|
|
121
|
+
sendJson(200, { jar });
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
async function handleAuthBundleExport(body = {}, sendJson) {
|
|
87
125
|
const result = await exportAuthBundleFromProfile(body);
|
|
88
126
|
sendJson(200, { bundle: result.bundle });
|
|
@@ -293,6 +331,8 @@ module.exports = {
|
|
|
293
331
|
handleAuthBundleExport,
|
|
294
332
|
handleAuthBundleImport,
|
|
295
333
|
handleAuthBundlesList,
|
|
334
|
+
handleCookieJarGet,
|
|
335
|
+
handleCookieJarMerge,
|
|
296
336
|
handleLoginSessionCancel,
|
|
297
337
|
handleLoginSessionComplete,
|
|
298
338
|
handleLoginSessionCreate,
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Browser session identity and registry.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The default session id is the caller's chat session, so one conversation
|
|
7
|
+
* keeps one live browser across tool calls without anyone passing ids around.
|
|
8
|
+
* Parallel sessions stay isolated because each gets its own agent-browser
|
|
9
|
+
* daemon and working profile. A working profile becomes durable only when an
|
|
10
|
+
* auth bundle/login/manual record explicitly references it.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
const cli = require('./cli');
|
|
@@ -55,17 +56,19 @@ function sessionFor(args = {}, context = {}) {
|
|
|
55
56
|
|
|
56
57
|
function rememberProfile(session) {
|
|
57
58
|
try {
|
|
58
|
-
require('./profiles')
|
|
59
|
+
const profiles = require('./profiles');
|
|
60
|
+
if (!profiles.isBrowserProfileDurable(session)) return;
|
|
61
|
+
profiles.touchBrowserProfile({
|
|
59
62
|
id: session,
|
|
60
63
|
name: session,
|
|
61
|
-
source: 'agent-browser',
|
|
64
|
+
source: 'agent-browser-ephemeral',
|
|
62
65
|
profilePath: cli.profileDir(session),
|
|
63
66
|
}, { source: 'agent-browser' });
|
|
64
67
|
} catch {}
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
function sessionInfo(session, extra = {}) {
|
|
68
|
-
rememberProfile(session);
|
|
71
|
+
if (!knownSessions.has(session)) rememberProfile(session);
|
|
69
72
|
const info = {
|
|
70
73
|
session,
|
|
71
74
|
browserSessionId: session,
|
|
@@ -95,6 +95,19 @@ async function boot() {
|
|
|
95
95
|
// look alive first and warm up second.
|
|
96
96
|
await listen();
|
|
97
97
|
|
|
98
|
+
// Ordinary chat browsers are working directories, not saved identities.
|
|
99
|
+
// Explicit auth/login profiles are preserved; closed sessions are removed
|
|
100
|
+
// immediately and crash leftovers age out here without touching live locks.
|
|
101
|
+
try {
|
|
102
|
+
const { pruneEphemeralBrowserProfiles } = require('./browser/profiles');
|
|
103
|
+
const pruned = pruneEphemeralBrowserProfiles({ source: 'runtime-startup' });
|
|
104
|
+
if (pruned.removedDirectories || pruned.removedRows) {
|
|
105
|
+
console.log(`[AmalgmMCP] Pruned ${pruned.removedDirectories} ephemeral browser profile(s) and ${pruned.removedRows} stale registry row(s)`);
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn(`[AmalgmMCP] Browser profile pruning skipped: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
await ensureProxyToken();
|
|
99
112
|
|
|
100
113
|
const { ensureAutomationsStore } = require('./automations/store');
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
const browserRest = require('../../browser/rest');
|
|
4
4
|
|
|
5
5
|
async function handleBrowserRoutes(ctx) {
|
|
6
|
+
if (ctx.pathname === '/browser/cookie-jar' && ctx.method === 'GET') {
|
|
7
|
+
ctx.res.setHeader('Cache-Control', 'no-store');
|
|
8
|
+
browserRest.handleCookieJarGet(ctx.sendJson);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (ctx.pathname === '/browser/cookie-jar/merge' && ctx.method === 'POST') {
|
|
12
|
+
ctx.res.setHeader('Cache-Control', 'no-store');
|
|
13
|
+
browserRest.handleCookieJarMerge(await ctx.readJsonBody(), ctx.sendJson);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
6
16
|
if (ctx.pathname === '/browser/profiles' && ctx.method === 'GET') {
|
|
7
17
|
browserRest.handleProfilesList(ctx.sendJson);
|
|
8
18
|
return true;
|