amalgm 0.1.88 → 0.1.89
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 +16 -6
- package/lib/service.js +19 -0
- package/lib/supervisor.js +51 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +0 -25
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +179 -0
- package/runtime/scripts/amalgm-mcp/browser/cli.js +217 -0
- package/runtime/scripts/amalgm-mcp/browser/cua-tools.js +147 -0
- package/runtime/scripts/amalgm-mcp/browser/engine.js +454 -0
- package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +82 -0
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +199 -0
- package/runtime/scripts/amalgm-mcp/browser/rest.js +5 -1
- package/runtime/scripts/amalgm-mcp/browser/tools.js +104 -731
- package/runtime/scripts/amalgm-mcp/fs/rest.js +5 -6
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +32 -66
- package/runtime/scripts/amalgm-mcp/project-context/paths.js +266 -0
- package/runtime/scripts/amalgm-mcp/project-context/prompt.js +232 -0
- package/runtime/scripts/amalgm-mcp/project-context/rest.js +100 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +671 -0
- package/runtime/scripts/amalgm-mcp/project-context/tools.js +146 -0
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +35 -2
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/project-context.js +33 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +11 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -4
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +328 -0
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +9 -9
- package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +1 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +0 -15
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +11 -7
- package/runtime/scripts/chat-core/adapters/opencode.js +1 -1
- package/runtime/scripts/chat-core/engine.js +16 -2
- package/runtime/scripts/chat-core/input.js +19 -1
- package/runtime/scripts/chat-core/tool-shape.js +6 -4
- package/runtime/scripts/chat-core/tooling/system-instructions.js +51 -0
- package/runtime/scripts/chat-core/tooling/system-prompt.js +34 -8
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +0 -569
- package/runtime/scripts/amalgm-mcp/browser/page.js +0 -25
- package/runtime/scripts/chat-core/tooling/active-memory.js +0 -574
- package/runtime/scripts/chat-core/tooling/passive-memory.js +0 -576
package/lib/cli.js
CHANGED
|
@@ -65,6 +65,7 @@ const {
|
|
|
65
65
|
ensureNativeBinaries,
|
|
66
66
|
} = require('../runtime/scripts/chat-core/tooling/native-binaries');
|
|
67
67
|
const {
|
|
68
|
+
compareVersions,
|
|
68
69
|
shouldAutoUpdate,
|
|
69
70
|
updatePackage,
|
|
70
71
|
} = require('./updater');
|
|
@@ -713,18 +714,27 @@ function runningDaemonRestartReason(pid, record, localOnly) {
|
|
|
713
714
|
const supervisorPid = Number(state?.supervisor_pid || state?.pid || 0);
|
|
714
715
|
if (!state || supervisorPid !== pid) return 'runtime ownership state is missing';
|
|
715
716
|
if (state.source && state.source !== 'npm') return `it is owned by ${state.source}`;
|
|
716
|
-
if (state.package_version && state.package_version !== PACKAGE_VERSION) {
|
|
717
|
-
return `it is running package ${state.package_version}, not ${PACKAGE_VERSION}`;
|
|
718
|
-
}
|
|
719
|
-
if (state.runtime_dir && state.runtime_dir !== RUNTIME_DIR) {
|
|
720
|
-
return 'it is running a different package install';
|
|
721
|
-
}
|
|
722
717
|
if (!!state.local_only !== !!localOnly) {
|
|
723
718
|
return localOnly ? 'it is running in registered mode' : 'it is running in local-only mode';
|
|
724
719
|
}
|
|
725
720
|
if (!localOnly && (state.computer_id || '') !== (record?.computer_id || '')) {
|
|
726
721
|
return `it is connected as ${state.computer_id || 'another computer'}`;
|
|
727
722
|
}
|
|
723
|
+
// An older CLI (e.g. the copy bundled inside the desktop app) must not
|
|
724
|
+
// restart a runtime from an equal-or-newer install: restarting it through
|
|
725
|
+
// startService() would re-home the service to the older tree and the
|
|
726
|
+
// auto-updater would restart-loop trying to escape it.
|
|
727
|
+
const runningNewerOrSame = state.package_version
|
|
728
|
+
? compareVersions(state.package_version, PACKAGE_VERSION) >= 0
|
|
729
|
+
: false;
|
|
730
|
+
if (state.package_version && state.package_version !== PACKAGE_VERSION) {
|
|
731
|
+
if (runningNewerOrSame) return '';
|
|
732
|
+
return `it is running package ${state.package_version}, not ${PACKAGE_VERSION}`;
|
|
733
|
+
}
|
|
734
|
+
if (state.runtime_dir && state.runtime_dir !== RUNTIME_DIR) {
|
|
735
|
+
if (runningNewerOrSame) return '';
|
|
736
|
+
return 'it is running a different package install';
|
|
737
|
+
}
|
|
728
738
|
return '';
|
|
729
739
|
}
|
|
730
740
|
|
package/lib/service.js
CHANGED
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
cleanupStaleRuntimeProcesses,
|
|
26
26
|
RUNTIME_INSTANCE_ARG,
|
|
27
27
|
} = require('./process-cleanup');
|
|
28
|
+
const { compareVersions } = require('./updater');
|
|
28
29
|
|
|
29
30
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
30
31
|
const SERVICE_LABEL_SCOPE = `${AMALGM_RUNTIME_USER_SCOPE}.${AMALGM_RUNTIME_LABEL}`
|
|
@@ -634,9 +635,27 @@ function stateMatchesRequest(state, options = {}) {
|
|
|
634
635
|
return true;
|
|
635
636
|
}
|
|
636
637
|
|
|
638
|
+
// A service registration owned by an equal-or-newer install of this package
|
|
639
|
+
// must not be replaced by an older caller (e.g. the CLI bundled inside the
|
|
640
|
+
// desktop app re-registering the service over the auto-updated npm install).
|
|
641
|
+
// Re-pointing the service at an older tree makes the auto-updater restart-loop:
|
|
642
|
+
// it installs the newer version, restarts, and boots the older tree again.
|
|
643
|
+
// Explicit `amalgm service install` still takes over via installService().
|
|
644
|
+
function deferToExistingInstall(state, options = {}) {
|
|
645
|
+
if (!state || !state.backend || !state.bin || !state.package_version) return false;
|
|
646
|
+
if (state.package_root === PACKAGE_ROOT) return false;
|
|
647
|
+
if (!!state.local_only !== !!options.localOnly) return false;
|
|
648
|
+
const requested = normalizeMode(options.mode || 'auto');
|
|
649
|
+
if (requested !== 'auto' && requested !== state.backend) return false;
|
|
650
|
+
if (compareVersions(state.package_version, PACKAGE_VERSION) < 0) return false;
|
|
651
|
+
if (!fs.existsSync(state.bin)) return false;
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
|
|
637
655
|
function ensureServiceInstalled(options = {}) {
|
|
638
656
|
const state = readJson(SERVICE_STATE_FILE, null);
|
|
639
657
|
if (stateMatchesRequest(state, options)) return state;
|
|
658
|
+
if (deferToExistingInstall(state, options)) return state;
|
|
640
659
|
return installService(options);
|
|
641
660
|
}
|
|
642
661
|
|
package/lib/supervisor.js
CHANGED
|
@@ -51,6 +51,20 @@ const {
|
|
|
51
51
|
updatePackage,
|
|
52
52
|
} = require('./updater');
|
|
53
53
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
54
|
+
const PACKAGE_ROOT_DIR = path.join(__dirname, '..');
|
|
55
|
+
|
|
56
|
+
// npm installs land in the npm prefix, which is only the tree this process runs
|
|
57
|
+
// from when the runtime was started from that prefix. When the service points at
|
|
58
|
+
// another install (e.g. the CLI bundled inside the desktop app), the update can
|
|
59
|
+
// never apply by restarting, so restarting would loop forever.
|
|
60
|
+
function installedPackageVersionOnDisk() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8');
|
|
63
|
+
return JSON.parse(raw).version || null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
54
68
|
|
|
55
69
|
const CHILD_RESTART_POLICY = {
|
|
56
70
|
initialDelayMs: 1000,
|
|
@@ -532,6 +546,13 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
|
|
|
532
546
|
let timer = null;
|
|
533
547
|
let inFlight = false;
|
|
534
548
|
let installedUpdate = false;
|
|
549
|
+
// Survives restarts via the state file so a relaunched runtime does not
|
|
550
|
+
// re-install a target version that already failed to apply to this install.
|
|
551
|
+
let suppressedTarget = null;
|
|
552
|
+
const persistedAutoUpdateState = readJson(AUTO_UPDATE_STATE_FILE, null);
|
|
553
|
+
if (persistedAutoUpdateState?.status === 'restart_suppressed') {
|
|
554
|
+
suppressedTarget = persistedAutoUpdateState.target_version || null;
|
|
555
|
+
}
|
|
535
556
|
const checkIntervalMs = numberFromEnv(
|
|
536
557
|
'AMALGM_UPDATE_CHECK_INTERVAL_MS',
|
|
537
558
|
AUTO_UPDATE_POLICY.checkIntervalMs,
|
|
@@ -663,6 +684,19 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
|
|
|
663
684
|
return;
|
|
664
685
|
}
|
|
665
686
|
|
|
687
|
+
if (suppressedTarget && check.targetVersion === suppressedTarget) {
|
|
688
|
+
writeAutoUpdateState({
|
|
689
|
+
status: 'restart_suppressed',
|
|
690
|
+
current_version: PACKAGE_VERSION,
|
|
691
|
+
target_version: check.targetVersion,
|
|
692
|
+
tag: check.tag,
|
|
693
|
+
service_managed: serviceManaged,
|
|
694
|
+
last_checked_at: checkedAt,
|
|
695
|
+
last_error: `update ${check.targetVersion} does not apply to this install (running from ${PACKAGE_ROOT_DIR}); waiting for an install that includes it`,
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
666
700
|
log(`installing ${check.targetVersion} from ${check.tag}`);
|
|
667
701
|
writeAutoUpdateState({
|
|
668
702
|
status: 'installing',
|
|
@@ -681,6 +715,23 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
|
|
|
681
715
|
if (trimmed) log(trimmed, stream);
|
|
682
716
|
},
|
|
683
717
|
});
|
|
718
|
+
const diskVersion = installedPackageVersionOnDisk();
|
|
719
|
+
if (diskVersion !== check.targetVersion) {
|
|
720
|
+
suppressedTarget = check.targetVersion;
|
|
721
|
+
const reason = `installed ${check.targetVersion}, but this runtime still runs ${diskVersion || PACKAGE_VERSION} from ${PACKAGE_ROOT_DIR}; restart suppressed to avoid a restart loop`;
|
|
722
|
+
writeAutoUpdateState({
|
|
723
|
+
status: 'restart_suppressed',
|
|
724
|
+
current_version: PACKAGE_VERSION,
|
|
725
|
+
target_version: check.targetVersion,
|
|
726
|
+
tag: check.tag,
|
|
727
|
+
critical: isCriticalUpdate(check),
|
|
728
|
+
service_managed: serviceManaged,
|
|
729
|
+
last_checked_at: checkedAt,
|
|
730
|
+
last_error: reason,
|
|
731
|
+
});
|
|
732
|
+
log(reason, 'stderr');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
684
735
|
installedUpdate = true;
|
|
685
736
|
await waitForIdleAndRestart(check);
|
|
686
737
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -9,7 +9,6 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
11
|
const { DEFAULT_CWD } = require('../config');
|
|
12
|
-
const activeMemory = require('../../chat-core/tooling/active-memory');
|
|
13
12
|
const { runtimePort } = require('../../../lib/runtime-manifest');
|
|
14
13
|
const { syncAppRoutesToGateway } = require('./advertise');
|
|
15
14
|
const {
|
|
@@ -330,22 +329,10 @@ async function registerApp(input) {
|
|
|
330
329
|
const data = loadApps();
|
|
331
330
|
data.apps.push(app);
|
|
332
331
|
saveApps(data);
|
|
333
|
-
activeMemory.ensureConstructMemory({
|
|
334
|
-
type: 'app',
|
|
335
|
-
id: app.id,
|
|
336
|
-
name: app.name,
|
|
337
|
-
projectPath: app.cwd,
|
|
338
|
-
}, { source: 'app:register' });
|
|
339
332
|
|
|
340
333
|
if (app.buildCommand) await runBuild(app);
|
|
341
334
|
await startApp(app.id);
|
|
342
335
|
const deployed = updateAppMeta(app.id, { lastDeployedAt: new Date().toISOString() });
|
|
343
|
-
activeMemory.ensureConstructMemory({
|
|
344
|
-
type: 'app',
|
|
345
|
-
id: deployed.id,
|
|
346
|
-
name: deployed.name,
|
|
347
|
-
projectPath: deployed.cwd,
|
|
348
|
-
}, { source: 'app:deploy' });
|
|
349
336
|
return deployed;
|
|
350
337
|
}
|
|
351
338
|
|
|
@@ -367,12 +354,6 @@ async function redeployApp(appId, updates = {}) {
|
|
|
367
354
|
if (updates.keep_alive !== undefined) patch.keepAlive = updates.keep_alive !== false;
|
|
368
355
|
if (updates.autostart !== undefined) patch.autostart = updates.autostart !== false;
|
|
369
356
|
if (Object.keys(patch).length > 0) app = updateAppMeta(appId, patch);
|
|
370
|
-
activeMemory.ensureConstructMemory({
|
|
371
|
-
type: 'app',
|
|
372
|
-
id: app.id,
|
|
373
|
-
name: app.name,
|
|
374
|
-
projectPath: app.cwd,
|
|
375
|
-
}, { source: 'app:update' });
|
|
376
357
|
|
|
377
358
|
if (!app.startCommand) throw new Error('startCommand is required');
|
|
378
359
|
if (!fs.existsSync(app.cwd)) throw new Error(`cwd does not exist: ${app.cwd}`);
|
|
@@ -381,12 +362,6 @@ async function redeployApp(appId, updates = {}) {
|
|
|
381
362
|
await stopApp(appId, { desiredState: 'running' });
|
|
382
363
|
await startApp(appId);
|
|
383
364
|
const deployed = updateAppMeta(appId, { lastDeployedAt: new Date().toISOString() });
|
|
384
|
-
activeMemory.ensureConstructMemory({
|
|
385
|
-
type: 'app',
|
|
386
|
-
id: deployed.id,
|
|
387
|
-
name: deployed.name,
|
|
388
|
-
projectPath: deployed.cwd,
|
|
389
|
-
}, { source: 'app:deploy' });
|
|
390
365
|
return deployed;
|
|
391
366
|
}
|
|
392
367
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser auth tools — durable profiles, encrypted auth bundles, and
|
|
5
|
+
* temporary login links. Split from the core browser surface: these manage
|
|
6
|
+
* login state, they don't drive pages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { textResult, errorResult } = require('../lib/tool-result');
|
|
10
|
+
const engine = require('./engine');
|
|
11
|
+
const {
|
|
12
|
+
createBrowserLoginSession,
|
|
13
|
+
listBrowserAuthBundles,
|
|
14
|
+
listBrowserLoginSessions,
|
|
15
|
+
listBrowserProfiles,
|
|
16
|
+
markBrowserAuthBundleImported,
|
|
17
|
+
readEncryptedStateBundle,
|
|
18
|
+
safeId,
|
|
19
|
+
touchBrowserProfile,
|
|
20
|
+
writeEncryptedStateBundle,
|
|
21
|
+
} = require('./store');
|
|
22
|
+
|
|
23
|
+
function jsonText(value) {
|
|
24
|
+
return textResult(JSON.stringify(value, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function authDomainsFrom(inputDomains, fallbackUrl) {
|
|
28
|
+
if (Array.isArray(inputDomains) && inputDomains.length > 0) return inputDomains;
|
|
29
|
+
try {
|
|
30
|
+
const hostname = new URL(fallbackUrl).hostname;
|
|
31
|
+
return hostname ? [hostname] : [];
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = [
|
|
38
|
+
{
|
|
39
|
+
name: 'browser_profiles_list',
|
|
40
|
+
description: 'List durable Amalgm browser profiles on this computer.',
|
|
41
|
+
inputSchema: { type: 'object', properties: {} },
|
|
42
|
+
async handler() {
|
|
43
|
+
try {
|
|
44
|
+
return jsonText({ profiles: listBrowserProfiles() });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return errorResult(`List browser profiles failed: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'browser_auth_bundles_list',
|
|
52
|
+
description: 'List encrypted browser auth bundles available on this computer. Raw cookies/storage are never returned.',
|
|
53
|
+
inputSchema: { type: 'object', properties: {} },
|
|
54
|
+
async handler() {
|
|
55
|
+
try {
|
|
56
|
+
return jsonText({ bundles: listBrowserAuthBundles() });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return errorResult(`List browser auth bundles failed: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'browser_login_sessions_list',
|
|
64
|
+
description: 'List temporary browser login links on this computer. Raw tokens and cookies are never returned.',
|
|
65
|
+
inputSchema: { type: 'object', properties: {} },
|
|
66
|
+
async handler() {
|
|
67
|
+
try {
|
|
68
|
+
return jsonText({ sessions: listBrowserLoginSessions() });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return errorResult(`List browser login sessions failed: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'browser_auth_link_create',
|
|
76
|
+
description: 'Create a short-lived browser login link for the user. Use this when an agent needs the user to log into a site before QA or browser work.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
targetUrl: { type: 'string', description: 'The site or app URL the user should log into.' },
|
|
81
|
+
browserProfileId: { type: 'string', description: 'Durable browser profile id that will receive the login.' },
|
|
82
|
+
name: { type: 'string', description: 'Human-readable login/bundle name.' },
|
|
83
|
+
domains: { type: 'array', items: { type: 'string' }, description: 'Domains to save after login, e.g. app.example.com.' },
|
|
84
|
+
transport: { type: 'string', description: 'Optional live-view transport: electron-splitview, novnc, or auto.' },
|
|
85
|
+
liveUrl: { type: 'string', description: 'Optional noVNC/live-view URL for web/mobile login.' },
|
|
86
|
+
appBaseUrl: { type: 'string', description: 'Optional Amalgm app base URL used to build an absolute link.' },
|
|
87
|
+
ttlMs: { type: 'number', description: 'Optional link lifetime in milliseconds. Defaults to 15 minutes, max 1 hour.' },
|
|
88
|
+
},
|
|
89
|
+
required: ['targetUrl'],
|
|
90
|
+
},
|
|
91
|
+
async handler(args, ctx) {
|
|
92
|
+
try {
|
|
93
|
+
const profileId = safeId(
|
|
94
|
+
args.browserProfileId
|
|
95
|
+
|| args.browserSessionId
|
|
96
|
+
|| ctx?.callerSessionId
|
|
97
|
+
|| `login-${Date.now().toString(36)}`,
|
|
98
|
+
);
|
|
99
|
+
const result = createBrowserLoginSession({
|
|
100
|
+
...args,
|
|
101
|
+
browserProfileId: profileId,
|
|
102
|
+
profileId,
|
|
103
|
+
}, { source: 'browser-auth-link-tool' });
|
|
104
|
+
return jsonText({
|
|
105
|
+
session: result.session,
|
|
106
|
+
authLink: result.authLink,
|
|
107
|
+
authLinkPath: result.authLinkPath,
|
|
108
|
+
message: `Open this temporary login link: ${result.authLink}`,
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return errorResult(`Create browser auth link failed: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'browser_auth_save',
|
|
117
|
+
description: 'Save the current browser profile cookies/storage into an encrypted auth bundle for later agent use.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
browserProfileId: { type: 'string', description: 'Durable browser profile id to save from' },
|
|
122
|
+
name: { type: 'string', description: 'Human-readable bundle name' },
|
|
123
|
+
domains: { type: 'array', items: { type: 'string' }, description: 'Optional domains to include, e.g. example.com' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
async handler(args, ctx) {
|
|
127
|
+
try {
|
|
128
|
+
const profileId = safeId(args.browserProfileId || args.browserSessionId || ctx?.callerSessionId || 'default');
|
|
129
|
+
const saved = await engine.saveState({ ...args, session: profileId }, ctx);
|
|
130
|
+
const bundle = writeEncryptedStateBundle({
|
|
131
|
+
name: args.name || saved.title || profileId,
|
|
132
|
+
profileId,
|
|
133
|
+
domains: authDomainsFrom(args.domains, saved.url),
|
|
134
|
+
state: saved.state || {},
|
|
135
|
+
});
|
|
136
|
+
touchBrowserProfile({
|
|
137
|
+
id: profileId,
|
|
138
|
+
name: args.name || profileId,
|
|
139
|
+
source: saved.backend || 'browser-auth-save',
|
|
140
|
+
}, { source: 'browser-auth-save' });
|
|
141
|
+
return jsonText({ bundle });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return errorResult(`Save browser auth bundle failed: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'browser_auth_load',
|
|
149
|
+
description: 'Load an encrypted browser auth bundle into a durable browser profile so an agent can start past login.',
|
|
150
|
+
inputSchema: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
id: { type: 'string', description: 'Auth bundle id' },
|
|
154
|
+
browserProfileId: { type: 'string', description: 'Target durable browser profile id' },
|
|
155
|
+
},
|
|
156
|
+
required: ['id'],
|
|
157
|
+
},
|
|
158
|
+
async handler(args, ctx) {
|
|
159
|
+
try {
|
|
160
|
+
const { bundle, payload } = readEncryptedStateBundle(args.id);
|
|
161
|
+
const profileId = safeId(args.browserProfileId || args.browserSessionId || bundle.profileId);
|
|
162
|
+
const loaded = await engine.loadState({
|
|
163
|
+
...args,
|
|
164
|
+
session: profileId,
|
|
165
|
+
state: payload.state,
|
|
166
|
+
}, ctx);
|
|
167
|
+
const updated = markBrowserAuthBundleImported(args.id, profileId);
|
|
168
|
+
touchBrowserProfile({
|
|
169
|
+
id: profileId,
|
|
170
|
+
name: profileId,
|
|
171
|
+
source: loaded.backend || 'browser-auth-load',
|
|
172
|
+
}, { source: 'browser-auth-load' });
|
|
173
|
+
return jsonText({ bundle: updated, loaded });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return errorResult(`Load browser auth bundle failed: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Low-level runner for the agent-browser CLI (vercel-labs/agent-browser).
|
|
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
|
|
8
|
+
* setup, and the JSON envelope — nothing else. Verb-level behavior lives in
|
|
9
|
+
* engine.js.
|
|
10
|
+
*
|
|
11
|
+
* Environment fixes handled here (learned from live testing):
|
|
12
|
+
* - AGENT_BROWSER_SOCKET_DIR must be short or the daemon socket path can
|
|
13
|
+
* exceed the 103-byte unix socket limit and every command fails.
|
|
14
|
+
* - The daemon resolves `ffmpeg` from PATH at daemon spawn time, so video
|
|
15
|
+
* recording only works if ffmpeg is on PATH for the *first* command of a
|
|
16
|
+
* session. We prepend ffmpeg-static's directory when available.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawn } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
25
|
+
const DEFAULT_SESSION = 'default';
|
|
26
|
+
|
|
27
|
+
function amalgmDir() {
|
|
28
|
+
return process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDir(dir) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
try { fs.chmodSync(dir, 0o700); } catch {}
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeId(value, fallback = DEFAULT_SESSION) {
|
|
38
|
+
const raw = String(value || fallback).replace(/^agent-browser:/, '');
|
|
39
|
+
return raw.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64) || fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function browserRoot() {
|
|
43
|
+
return ensureDir(path.join(amalgmDir(), 'browser'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function profileDir(session) {
|
|
47
|
+
return ensureDir(path.join(browserRoot(), 'profiles', session));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function screenshotDir() {
|
|
51
|
+
return ensureDir(path.join(browserRoot(), 'screenshots'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Unix socket paths cap at ~103 bytes on macOS. The default socket dir is
|
|
56
|
+
* derived from TMPDIR which can be deep; keep ours short and stable.
|
|
57
|
+
*/
|
|
58
|
+
function socketDir() {
|
|
59
|
+
if (process.env.AGENT_BROWSER_SOCKET_DIR) return process.env.AGENT_BROWSER_SOCKET_DIR;
|
|
60
|
+
return ensureDir(path.join(os.tmpdir(), 'amalgm-ab'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ffmpegDir() {
|
|
64
|
+
try {
|
|
65
|
+
const resolved = require('ffmpeg-static');
|
|
66
|
+
if (resolved && fs.existsSync(resolved)) return path.dirname(resolved);
|
|
67
|
+
} catch {}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function agentBrowserCommand() {
|
|
72
|
+
if (process.env.AMALGM_AGENT_BROWSER_BIN) {
|
|
73
|
+
return { command: process.env.AMALGM_AGENT_BROWSER_BIN, prefix: [] };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return {
|
|
77
|
+
command: process.execPath,
|
|
78
|
+
prefix: [require.resolve('agent-browser/bin/agent-browser.js')],
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return { command: 'agent-browser', prefix: [] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function wantsHeaded() {
|
|
86
|
+
const explicit = process.env.AMALGM_BROWSER_HEADED || process.env.AGENT_BROWSER_HEADED;
|
|
87
|
+
if (explicit) return !/^(0|false|no)$/i.test(explicit);
|
|
88
|
+
if (/^(1|true|yes)$/i.test(process.env.AMALGM_BROWSER_HEADLESS || '')) return false;
|
|
89
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function baseArgs(session, json = true) {
|
|
94
|
+
const args = [
|
|
95
|
+
'--session', session,
|
|
96
|
+
'--profile', profileDir(session),
|
|
97
|
+
'--screenshot-dir', screenshotDir(),
|
|
98
|
+
];
|
|
99
|
+
if (json) args.push('--json');
|
|
100
|
+
if (wantsHeaded()) args.push('--headed');
|
|
101
|
+
if (process.env.AMALGM_BROWSER_EXECUTABLE_PATH) {
|
|
102
|
+
args.push('--executable-path', process.env.AMALGM_BROWSER_EXECUTABLE_PATH);
|
|
103
|
+
}
|
|
104
|
+
if (process.env.AMALGM_BROWSER_PROVIDER) {
|
|
105
|
+
args.push('--provider', process.env.AMALGM_BROWSER_PROVIDER);
|
|
106
|
+
}
|
|
107
|
+
return args;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseJson(stdout) {
|
|
111
|
+
const text = String(stdout || '').trim();
|
|
112
|
+
if (!text) return {};
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
117
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
118
|
+
if (!/^[{[]/.test(lines[i])) continue;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(lines.slice(i).join('\n'));
|
|
121
|
+
} catch {
|
|
122
|
+
// Keep looking for a JSON suffix.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { text };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asText(value) {
|
|
130
|
+
if (value == null) return '';
|
|
131
|
+
if (typeof value === 'string') return value;
|
|
132
|
+
if (value.success === true && value.data !== undefined) return asText(value.data);
|
|
133
|
+
if (typeof value.snapshot === 'string') return value.snapshot;
|
|
134
|
+
if (typeof value.text === 'string') return value.text;
|
|
135
|
+
if (typeof value.value === 'string') return value.value;
|
|
136
|
+
if (typeof value.result === 'string') return value.result;
|
|
137
|
+
if (typeof value.output === 'string') return value.output;
|
|
138
|
+
if (typeof value.url === 'string') return value.url;
|
|
139
|
+
if (typeof value.title === 'string') return value.title;
|
|
140
|
+
return JSON.stringify(value, null, 2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runCli(commandArgs, options = {}) {
|
|
144
|
+
const session = safeId(options.session);
|
|
145
|
+
const json = options.json !== false;
|
|
146
|
+
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
147
|
+
const bin = agentBrowserCommand();
|
|
148
|
+
const args = [...bin.prefix, ...baseArgs(session, json), ...commandArgs.map(String)];
|
|
149
|
+
|
|
150
|
+
const env = {
|
|
151
|
+
...process.env,
|
|
152
|
+
AGENT_BROWSER_SESSION: session,
|
|
153
|
+
AGENT_BROWSER_PROFILE: profileDir(session),
|
|
154
|
+
AGENT_BROWSER_SOCKET_DIR: socketDir(),
|
|
155
|
+
};
|
|
156
|
+
// The daemon (spawned by the first command of a session) inherits this env,
|
|
157
|
+
// and resolves ffmpeg from PATH when recording starts.
|
|
158
|
+
const ffmpeg = ffmpegDir();
|
|
159
|
+
if (ffmpeg) env.PATH = `${ffmpeg}${path.delimiter}${env.PATH || ''}`;
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const child = spawn(bin.command, args, {
|
|
163
|
+
cwd: os.homedir(),
|
|
164
|
+
env,
|
|
165
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let stdout = '';
|
|
169
|
+
let stderr = '';
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
child.kill('SIGTERM');
|
|
172
|
+
reject(new Error(`agent-browser timed out running: ${commandArgs.join(' ')}`));
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
|
|
175
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
176
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
177
|
+
child.on('error', (err) => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
reject(err.code === 'ENOENT'
|
|
180
|
+
? new Error('agent-browser is not installed. Reinstall Amalgm or run `npm install agent-browser`.')
|
|
181
|
+
: err);
|
|
182
|
+
});
|
|
183
|
+
child.on('close', (code) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (code !== 0) {
|
|
186
|
+
reject(new Error((stderr || stdout || `agent-browser exited with code ${code}`).trim()));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const result = json ? parseJson(stdout) : { text: stdout.trim() };
|
|
190
|
+
if (result && result.success === false) {
|
|
191
|
+
reject(new Error(result.error || result.message || 'agent-browser command failed'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
resolve(result);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function readText(commandArgs, session, timeoutMs = 30_000) {
|
|
200
|
+
return asText(await runCli(commandArgs, { session, timeoutMs }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
DEFAULT_SESSION,
|
|
205
|
+
amalgmDir,
|
|
206
|
+
asText,
|
|
207
|
+
browserRoot,
|
|
208
|
+
ensureDir,
|
|
209
|
+
ffmpegDir,
|
|
210
|
+
profileDir,
|
|
211
|
+
readText,
|
|
212
|
+
runCli,
|
|
213
|
+
safeId,
|
|
214
|
+
screenshotDir,
|
|
215
|
+
socketDir,
|
|
216
|
+
wantsHeaded,
|
|
217
|
+
};
|