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.
@@ -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, 1:1 with durable profiles
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 { COOKIE_SOURCE_BUNDLE_ID, cookieSourceVersion } = require('./cookie-source');
25
- const { getBrowserAuthBundle, readEncryptedStateBundle } = require('./auth-bundles');
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 drive the desktop app's own jar — the jar the cookie
83
- // source is exported FROM (electron/browserCookieSource.ts). Importing the
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
- const { payload } = readEncryptedStateBundle(COOKIE_SOURCE_BUNDLE_ID);
93
- if (payload?.state && typeof payload.state === 'object') {
94
- await loadStateIntoSession(session, payload.state, context);
95
- console.error(`[browser] cookie source ${String(version).slice(0, 12)} imported into session ${session}`);
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 bad bundle (key mismatch, corrupt blob, load failure) must degrade to
99
- // a logged-out session, never break every browser verb.
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
- // Recorded even on failure: the failure is a property of this bundle
103
- // version, so retrying on every verb would only repeat the error.
104
- if (version) loadedCookieSources.set(session, version);
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, ''))) return sessionInfo(session, { matched: current });
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 bundle = writeEncryptedStateBundle({
70
- id: body.id || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? COOKIE_SOURCE_BUNDLE_ID : undefined),
71
- name: body.name || (profileId === DEFAULT_COOKIE_SOURCE_PROFILE_ID ? 'Browser cookie source' : saved.title || profileId),
72
- profileId,
73
- domains: Array.isArray(body.domains) ? body.domains : [],
74
- state: saved.state || {},
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
- * A session is 1:1 with a durable profile directory; the default session id
7
- * is the caller's chat session, so one conversation keeps one browser across
8
- * tool calls without anyone passing ids around. Parallel sessions stay
9
- * isolated because each gets its own agent-browser daemon and profile.
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').touchBrowserProfile({
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,
@@ -3,6 +3,7 @@
3
3
  module.exports = {
4
4
  ...require('./paths'),
5
5
  ...require('./profiles'),
6
+ ...require('./cookie-jar'),
6
7
  ...require('./auth-bundles'),
7
8
  ...require('./login-sessions'),
8
9
  };
@@ -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;