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
|
@@ -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
|
|
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
|
|
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;
|
|
170
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
/**
|
|
35
|
-
*
|
|
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:
|
|
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: '
|
|
161
|
-
id: { type: 'string', description: 'Optional bundle id.
|
|
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
|
|
172
|
-
name: args.name ||
|
|
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;
|
|
7
|
-
*
|
|
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
|
+
};
|