amalgm 0.1.131 → 0.1.133

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