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.
@@ -14,15 +14,14 @@ const { rewriteAppBoundPaths } = require('./tool-bindings');
14
14
  const {
15
15
  BUNDLE_KIND,
16
16
  BUNDLE_SCHEMA_VERSION,
17
- LEGACY_AGENT_BUNDLE_KIND,
18
17
  agentEntryId,
19
18
  appEntryId,
19
+ assertBundleGraphMatches,
20
20
  automationEntryId,
21
21
  isObject,
22
22
  nowIso,
23
23
  standaloneToolIds,
24
24
  uniqueStrings,
25
- withBundleGraph,
26
25
  } = require('./graph');
27
26
 
28
27
  function assertUniqueRecords(label, records, idForRecord) {
@@ -121,7 +120,7 @@ function assertClosedAgentBundleGraph(bundle) {
121
120
 
122
121
  function validateBundle(bundle) {
123
122
  if (!isObject(bundle)) throw new Error('bundle must be an object');
124
- if (bundle.kind !== BUNDLE_KIND && bundle.kind !== LEGACY_AGENT_BUNDLE_KIND) {
123
+ if (bundle.kind !== BUNDLE_KIND) {
125
124
  throw new Error(`Unsupported bundle kind: ${bundle.kind || 'unknown'}`);
126
125
  }
127
126
  if (bundle.schemaVersion !== BUNDLE_SCHEMA_VERSION) {
@@ -143,7 +142,7 @@ function validateBundle(bundle) {
143
142
  assertUniqueRecords('tool action', toolActions, (action) => String(action?.id || '').trim());
144
143
  apps.forEach(assertPortableAppEntry);
145
144
  if (agents.length > 0) assertClosedAgentBundleGraph(bundle);
146
- return withBundleGraph(bundle);
145
+ return assertBundleGraphMatches(bundle);
147
146
  }
148
147
 
149
148
  function installTools(bundle, appDirBySourceId = new Map(), appIdBySourceId = new Map()) {
@@ -166,16 +165,12 @@ function installTools(bundle, appDirBySourceId = new Map(), appIdBySourceId = ne
166
165
  for (const tool of Array.isArray(bundle.tools) ? bundle.tools : []) {
167
166
  if (!tool?.id || tool.origin === 'system') continue;
168
167
  const record = rewrite(tool);
169
- // The app link travels by source app id; point it at the recipient's
170
- // installed copy, or drop it if that app was not part of the install.
168
+ // The app link travels by source app id; validation guarantees that the
169
+ // owning app is in the same bundle and was installed first.
171
170
  if (typeof record.appId === 'string' && record.appId) {
172
171
  const installedAppId = appIdBySourceId.get(record.appId);
173
- if (installedAppId) {
174
- record.appId = installedAppId;
175
- } else {
176
- delete record.appId;
177
- warnings.push(`Tool ${tool.id} belongs to an app that was not installed with this bundle; the app link was removed.`);
178
- }
172
+ if (!installedAppId) throw new Error(`Tool ${tool.id} references app that was not installed: ${record.appId}`);
173
+ record.appId = installedAppId;
179
174
  }
180
175
  const result = defaultToolboxService.registerTool({
181
176
  ...record,
@@ -229,6 +229,11 @@ function writeEncryptedStateBundle(input = {}) {
229
229
  cookies: normalizedCookies,
230
230
  };
231
231
  const storageKinds = stateStorageKinds(normalizedState);
232
+ const payloadExtensions = input.payloadExtensions
233
+ && typeof input.payloadExtensions === 'object'
234
+ && !Array.isArray(input.payloadExtensions)
235
+ ? input.payloadExtensions
236
+ : {};
232
237
  const encrypted = encryptJson({
233
238
  version: 2,
234
239
  profileId,
@@ -237,11 +242,17 @@ function writeEncryptedStateBundle(input = {}) {
237
242
  capturedAt: nowIso(),
238
243
  cookies: normalizedCookies,
239
244
  state: normalizedState,
245
+ ...payloadExtensions,
240
246
  });
241
247
  const id = bundleIdFor(input.id);
242
248
  const file = path.join(authBundleRoot(), `${id}.json.enc`);
243
249
  const payload = `${JSON.stringify(encrypted.envelope, null, 2)}\n`;
244
- fs.writeFileSync(file, payload, { mode: 0o600 });
250
+ // Cookie jars are updated frequently. Write beside the destination and
251
+ // rename so a crash can leave either the previous complete envelope or the
252
+ // next complete envelope, never a truncated encrypted blob.
253
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
254
+ fs.writeFileSync(temporary, payload, { mode: 0o600 });
255
+ fs.renameSync(temporary, file);
245
256
  try { fs.chmodSync(file, 0o600); } catch {}
246
257
 
247
258
  return upsertBrowserAuthBundle({
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Browser auth actions — durable profiles, encrypted auth bundles, and
4
+ * Browser auth actions — explicitly saved profiles, encrypted auth bundles, and
5
5
  * temporary login links. Split from the core surface: these manage login
6
6
  * state, they don't drive pages.
7
7
  *
@@ -31,9 +31,8 @@ function jsonText(value) {
31
31
  return textResult(JSON.stringify(value, null, 2));
32
32
  }
33
33
 
34
- /** Every browser session leaves a durable profile behind, so the full lists
35
- * grow without bound (a stress test found ~130 profiles / 86K chars from one
36
- * unpaged dump). Summaries are the default; `full` is the opt-in. */
34
+ /** Saved auth/login history can still be long. Summaries are the default;
35
+ * ordinary chat working profiles are no longer registered here. */
37
36
  const SUMMARY_LIMIT = 15;
38
37
 
39
38
  function summarizeEntry(entry) {
@@ -59,7 +58,7 @@ function summarizeList(entries) {
59
58
  module.exports = [
60
59
  {
61
60
  name: 'auth_list',
62
- description: 'List saved browser auth state on this computer: durable profiles, encrypted auth bundles, and pending login links. Returns a recent-first summary by default; pass full: true for complete records. Raw cookies and tokens are never returned.',
61
+ description: 'List explicitly saved browser auth state on this computer: profiles, encrypted auth bundles, and pending login links. Returns a recent-first summary by default; pass full: true for complete records. Raw cookies and tokens are never returned.',
63
62
  inputSchema: {
64
63
  type: 'object',
65
64
  properties: {
@@ -157,8 +156,8 @@ module.exports = [
157
156
  inputSchema: {
158
157
  type: 'object',
159
158
  properties: {
160
- browserProfileId: { type: 'string', description: 'Durable browser profile id to save from' },
161
- id: { type: 'string', description: 'Optional bundle id. Defaults to the shared browser cookie source.' },
159
+ browserProfileId: { type: 'string', description: 'Browser session/profile id to save from' },
160
+ id: { type: 'string', description: 'Optional bundle id. A new id is generated when omitted.' },
162
161
  name: { type: 'string', description: 'Human-readable bundle name' },
163
162
  domains: { type: 'array', items: { type: 'string' }, description: 'Optional domains to include. Omit to save all current browser auth state.' },
164
163
  },
@@ -168,8 +167,8 @@ module.exports = [
168
167
  const profileId = safeId(args.browserProfileId || args.browserSessionId || ctx?.callerSessionId || DEFAULT_COOKIE_SOURCE_PROFILE_ID);
169
168
  const saved = await engine.saveState({ ...args, session: profileId }, ctx);
170
169
  const bundle = writeEncryptedStateBundle({
171
- id: args.id || COOKIE_SOURCE_BUNDLE_ID,
172
- name: args.name || 'Browser cookie source',
170
+ id: args.id,
171
+ name: args.name || `${profileId} login`,
173
172
  profileId,
174
173
  domains: Array.isArray(args.domains) ? args.domains : [],
175
174
  state: saved.state || {},
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * Low-level runner for the agent-browser CLI (vercel-labs/agent-browser).
5
5
  *
6
- * One agent-browser daemon exists per session; sessions map 1:1 to durable
7
- * Amalgm browser profiles. This module owns process spawning, environment
6
+ * One agent-browser daemon and working profile exists per live session;
7
+ * durability belongs to the auth/profile layer, not this process wrapper.
8
+ * This module owns process spawning, environment
8
9
  * setup, and the JSON envelope — nothing else. Verb-level behavior lives in
9
10
  * engine.js.
10
11
  *
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Browser-neutral shared cookie jar.
5
+ *
6
+ * This is the source of truth. Electron, agent-browser, and future browser
7
+ * providers are adapters: they read snapshots, apply them locally, then send
8
+ * record-level mutations back. No adapter knows about another adapter.
9
+ *
10
+ * The jar intentionally rides the existing encrypted browser auth bundle so
11
+ * current installs migrate in place. Cookie values, record provenance, and
12
+ * deletion tombstones all remain inside the encrypted envelope; database
13
+ * rows and state events continue to contain metadata only.
14
+ */
15
+
16
+ const {
17
+ getBrowserAuthBundle,
18
+ normalizeChromiumCookie,
19
+ readEncryptedStateBundle,
20
+ writeEncryptedStateBundle,
21
+ } = require('./auth-bundles');
22
+ const {
23
+ COOKIE_SOURCE_BUNDLE_ID,
24
+ DEFAULT_COOKIE_SOURCE_PROFILE_ID,
25
+ } = require('./cookie-source');
26
+
27
+ const COOKIE_JAR_SCHEMA_VERSION = 1;
28
+ const MAX_MUTATIONS_PER_MERGE = 10_000;
29
+ const MAX_TOMBSTONES = 4_096;
30
+ const DEFAULT_EXCLUDED_HOST_SUFFIXES = [
31
+ 'supabase.co',
32
+ 'supabase.io',
33
+ 'localhost',
34
+ '127.0.0.1',
35
+ 'amalgm.ai',
36
+ ];
37
+
38
+ function nowIso() {
39
+ return new Date().toISOString();
40
+ }
41
+
42
+ function cleanSourceId(value) {
43
+ return String(value || 'unknown-adapter').trim().slice(0, 128) || 'unknown-adapter';
44
+ }
45
+
46
+ function normalizeHost(value) {
47
+ return String(value || '').trim().toLowerCase().replace(/^\.+/, '');
48
+ }
49
+
50
+ function normalizeExcludedHosts(values = []) {
51
+ const environment = String(process.env.AMALGM_BROWSER_COOKIE_EXCLUDE_HOSTS || '')
52
+ .split(',');
53
+ return Array.from(new Set([
54
+ ...DEFAULT_EXCLUDED_HOST_SUFFIXES,
55
+ ...environment,
56
+ ...(Array.isArray(values) ? values : []),
57
+ ].map(normalizeHost).filter(Boolean)));
58
+ }
59
+
60
+ function hostIsExcluded(rawHost, extraHosts = []) {
61
+ const host = normalizeHost(rawHost);
62
+ if (!host) return true;
63
+ return normalizeExcludedHosts(extraHosts)
64
+ .some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
65
+ }
66
+
67
+ function stableValue(value) {
68
+ if (Array.isArray(value)) return value.map(stableValue);
69
+ if (!value || typeof value !== 'object') return value;
70
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
71
+ }
72
+
73
+ function partitionKeyFor(cookie = {}) {
74
+ if (cookie.partitionKey === undefined || cookie.partitionKey === null) return '';
75
+ return JSON.stringify(stableValue(cookie.partitionKey));
76
+ }
77
+
78
+ function normalizeJarCookie(cookie = {}) {
79
+ const normalized = normalizeChromiumCookie(cookie);
80
+ normalized.hostOnly = cookie.hostOnly === undefined
81
+ ? !String(normalized.domain || '').startsWith('.')
82
+ : Boolean(cookie.hostOnly);
83
+ return normalized;
84
+ }
85
+
86
+ function injectableCookie(cookie = {}) {
87
+ const { hostOnly, ...injectable } = normalizeJarCookie(cookie);
88
+ if (hostOnly) injectable.domain = normalizeHost(injectable.domain);
89
+ return injectable;
90
+ }
91
+
92
+ function cookieKey(cookie = {}) {
93
+ const normalized = normalizeJarCookie(cookie);
94
+ return [
95
+ normalizeHost(normalized.domain),
96
+ normalized.hostOnly ? 'host' : 'domain',
97
+ normalized.path || '/',
98
+ normalized.name,
99
+ partitionKeyFor(normalized),
100
+ ].join('\u0000');
101
+ }
102
+
103
+ function cookieFingerprint(cookie = {}) {
104
+ return JSON.stringify(stableValue(normalizeJarCookie(cookie)));
105
+ }
106
+
107
+ function validCookie(cookie = {}, extraHosts = []) {
108
+ return Boolean(
109
+ String(cookie.name || '')
110
+ && normalizeHost(cookie.domain)
111
+ && !hostIsExcluded(cookie.domain, extraHosts),
112
+ );
113
+ }
114
+
115
+ function isExpiredCookie(cookie = {}) {
116
+ return typeof cookie.expires === 'number'
117
+ && cookie.expires >= 0
118
+ && cookie.expires <= Date.now() / 1000;
119
+ }
120
+
121
+ function normalizeCookieList(cookies = [], extraHosts = []) {
122
+ const byKey = new Map();
123
+ for (const input of Array.isArray(cookies) ? cookies : []) {
124
+ const cookie = normalizeJarCookie(input);
125
+ if (!validCookie(cookie, extraHosts) || isExpiredCookie(cookie)) continue;
126
+ byKey.set(cookieKey(cookie), cookie);
127
+ }
128
+ return [...byKey.values()].sort((a, b) => cookieKey(a).localeCompare(cookieKey(b)));
129
+ }
130
+
131
+ function emptyInternalJar() {
132
+ return {
133
+ exists: false,
134
+ version: 0,
135
+ bundle: null,
136
+ records: new Map(),
137
+ tombstones: new Map(),
138
+ migratedFromLegacy: false,
139
+ };
140
+ }
141
+
142
+ function loadInternalCookieJar() {
143
+ const bundle = getBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
144
+ if (!bundle?.blobPath) return emptyInternalJar();
145
+
146
+ const { payload } = readEncryptedStateBundle(COOKIE_SOURCE_BUNDLE_ID);
147
+ const stored = payload?.cookieJar;
148
+ const records = new Map();
149
+ const tombstones = new Map();
150
+
151
+ if (stored?.schemaVersion === COOKIE_JAR_SCHEMA_VERSION) {
152
+ for (const item of Array.isArray(stored.records) ? stored.records : []) {
153
+ const cookie = normalizeJarCookie(item?.cookie || {});
154
+ if (!validCookie(cookie)) continue;
155
+ const key = cookieKey(cookie);
156
+ records.set(key, {
157
+ key,
158
+ cookie,
159
+ revision: Number(item.revision) || 0,
160
+ sourceId: cleanSourceId(item.sourceId),
161
+ updatedAt: String(item.updatedAt || bundle.updatedAt || nowIso()),
162
+ });
163
+ }
164
+ for (const item of Array.isArray(stored.tombstones) ? stored.tombstones : []) {
165
+ const key = String(item?.key || '');
166
+ if (!key) continue;
167
+ tombstones.set(key, {
168
+ key,
169
+ revision: Number(item.revision) || 0,
170
+ sourceId: cleanSourceId(item.sourceId),
171
+ deletedAt: String(item.deletedAt || bundle.updatedAt || nowIso()),
172
+ });
173
+ }
174
+ return {
175
+ exists: true,
176
+ version: Math.max(1, Number(stored.version) || 1),
177
+ bundle,
178
+ records,
179
+ tombstones,
180
+ migratedFromLegacy: false,
181
+ };
182
+ }
183
+
184
+ // Migration: the previous implementation stored a whole Playwright state
185
+ // snapshot in this same encrypted bundle. Read it as revision 1; the first
186
+ // mutation rewrites it in the SDK format without exposing or dropping data.
187
+ const capturedAt = String(payload?.capturedAt || bundle.updatedAt || nowIso());
188
+ for (const cookie of normalizeCookieList(payload?.state?.cookies || payload?.cookies || [])) {
189
+ const key = cookieKey(cookie);
190
+ records.set(key, {
191
+ key,
192
+ cookie,
193
+ revision: 1,
194
+ sourceId: 'legacy-cookie-source',
195
+ updatedAt: capturedAt,
196
+ });
197
+ }
198
+ return {
199
+ exists: true,
200
+ version: 1,
201
+ bundle,
202
+ records,
203
+ tombstones,
204
+ migratedFromLegacy: true,
205
+ };
206
+ }
207
+
208
+ function publicSnapshot(jar) {
209
+ const records = [...jar.records.values()].sort((a, b) => a.key.localeCompare(b.key));
210
+ const tombstones = [...jar.tombstones.values()].sort((a, b) => a.key.localeCompare(b.key));
211
+ return {
212
+ schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
213
+ exists: jar.exists,
214
+ version: jar.version,
215
+ versionToken: jar.exists
216
+ ? `${jar.version}:${jar.bundle?.blobSha256 || 'pending'}`
217
+ : '0:none',
218
+ cookies: records.map((record) => record.cookie),
219
+ tombstones: tombstones.map((item) => ({
220
+ key: item.key,
221
+ revision: item.revision,
222
+ deletedAt: item.deletedAt,
223
+ })),
224
+ };
225
+ }
226
+
227
+ function readCookieJar() {
228
+ return publicSnapshot(loadInternalCookieJar());
229
+ }
230
+
231
+ function pruneTombstones(tombstones) {
232
+ return new Map(
233
+ [...tombstones.values()]
234
+ .sort((a, b) => b.revision - a.revision || b.deletedAt.localeCompare(a.deletedAt))
235
+ .slice(0, MAX_TOMBSTONES)
236
+ .map((item) => [item.key, item]),
237
+ );
238
+ }
239
+
240
+ function persistCookieJar(jar) {
241
+ const records = [...jar.records.values()].sort((a, b) => a.key.localeCompare(b.key));
242
+ const tombstones = [...jar.tombstones.values()].sort((a, b) => a.key.localeCompare(b.key));
243
+ const bundle = writeEncryptedStateBundle({
244
+ id: COOKIE_SOURCE_BUNDLE_ID,
245
+ name: 'Browser cookie jar',
246
+ profileId: DEFAULT_COOKIE_SOURCE_PROFILE_ID,
247
+ state: { cookies: records.map((record) => injectableCookie(record.cookie)), origins: [] },
248
+ payloadExtensions: {
249
+ cookieJar: {
250
+ schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
251
+ version: jar.version,
252
+ updatedAt: nowIso(),
253
+ records,
254
+ tombstones,
255
+ },
256
+ },
257
+ });
258
+ return { ...jar, exists: true, bundle, migratedFromLegacy: false };
259
+ }
260
+
261
+ function deleteKeyFor(input) {
262
+ if (typeof input === 'string') return input;
263
+ if (!input || typeof input !== 'object' || !validCookie(input)) return '';
264
+ return cookieKey(input);
265
+ }
266
+
267
+ function mergeCookieJar(input = {}) {
268
+ const sourceId = cleanSourceId(input.sourceId);
269
+ const extraHosts = Array.isArray(input.excludeHosts) ? input.excludeHosts : [];
270
+ const upserts = Array.isArray(input.upserts) ? input.upserts : [];
271
+ const deletes = Array.isArray(input.deletes) ? input.deletes : [];
272
+ if (upserts.length + deletes.length > MAX_MUTATIONS_PER_MERGE) {
273
+ throw new Error(`Cookie mutation batch exceeds ${MAX_MUTATIONS_PER_MERGE}`);
274
+ }
275
+
276
+ let jar = loadInternalCookieJar();
277
+ const nextRevision = jar.version + 1;
278
+ const changedAt = nowIso();
279
+ let changed = false;
280
+
281
+ for (const inputCookie of upserts) {
282
+ const cookie = normalizeJarCookie(inputCookie);
283
+ if (!validCookie(cookie, extraHosts)) continue;
284
+ const key = cookieKey(cookie);
285
+ if (isExpiredCookie(cookie)) {
286
+ if (jar.records.delete(key) || !jar.tombstones.has(key)) {
287
+ jar.tombstones.set(key, { key, revision: nextRevision, sourceId, deletedAt: changedAt });
288
+ changed = true;
289
+ }
290
+ continue;
291
+ }
292
+ const existing = jar.records.get(key);
293
+ if (!existing || cookieFingerprint(existing.cookie) !== cookieFingerprint(cookie)) {
294
+ jar.records.set(key, { key, cookie, revision: nextRevision, sourceId, updatedAt: changedAt });
295
+ changed = true;
296
+ }
297
+ if (jar.tombstones.delete(key)) changed = true;
298
+ }
299
+
300
+ for (const deletion of deletes) {
301
+ if (deletion && typeof deletion === 'object' && hostIsExcluded(deletion.domain, extraHosts)) continue;
302
+ const key = deleteKeyFor(deletion);
303
+ if (!key) continue;
304
+ const removed = jar.records.delete(key);
305
+ const existing = jar.tombstones.get(key);
306
+ if (removed || !existing) {
307
+ jar.tombstones.set(key, { key, revision: nextRevision, sourceId, deletedAt: changedAt });
308
+ changed = true;
309
+ }
310
+ }
311
+
312
+ if (!changed) return publicSnapshot(jar);
313
+ jar = {
314
+ ...jar,
315
+ exists: true,
316
+ version: nextRevision,
317
+ tombstones: pruneTombstones(jar.tombstones),
318
+ };
319
+ return publicSnapshot(persistCookieJar(jar));
320
+ }
321
+
322
+ function diffCookieSnapshots(before = [], after = [], options = {}) {
323
+ const beforeMap = new Map(normalizeCookieList(before, options.excludeHosts).map((cookie) => [cookieKey(cookie), cookie]));
324
+ const afterMap = new Map(normalizeCookieList(after, options.excludeHosts).map((cookie) => [cookieKey(cookie), cookie]));
325
+ const upserts = [];
326
+ const deletes = [];
327
+
328
+ for (const [key, cookie] of afterMap) {
329
+ const previous = beforeMap.get(key);
330
+ if (!previous || cookieFingerprint(previous) !== cookieFingerprint(cookie)) upserts.push(cookie);
331
+ }
332
+ for (const [key, cookie] of beforeMap) {
333
+ if (!afterMap.has(key)) deletes.push(cookie);
334
+ }
335
+ return { upserts, deletes };
336
+ }
337
+
338
+ function cookiesMissingFromJar(cookies = [], snapshot = readCookieJar(), options = {}) {
339
+ const live = new Set((snapshot.cookies || []).map(cookieKey));
340
+ const deleted = new Set((snapshot.tombstones || []).map((item) => item.key));
341
+ return normalizeCookieList(cookies, options.excludeHosts)
342
+ .filter((cookie) => {
343
+ const key = cookieKey(cookie);
344
+ return !live.has(key) && !deleted.has(key);
345
+ });
346
+ }
347
+
348
+ function cookieJarState(snapshot = readCookieJar()) {
349
+ return { cookies: (snapshot.cookies || []).map(injectableCookie), origins: [] };
350
+ }
351
+
352
+ module.exports = {
353
+ COOKIE_JAR_SCHEMA_VERSION,
354
+ DEFAULT_EXCLUDED_HOST_SUFFIXES,
355
+ cookieFingerprint,
356
+ cookieJarState,
357
+ cookieKey,
358
+ cookiesMissingFromJar,
359
+ diffCookieSnapshots,
360
+ hostIsExcluded,
361
+ mergeCookieJar,
362
+ normalizeCookieList,
363
+ readCookieJar,
364
+ };