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
|
@@ -81,7 +81,8 @@ test('agent bundle includes recursive subagents, selected user tools, system too
|
|
|
81
81
|
|
|
82
82
|
const { bundle, preview } = createAgentBundle(root.id, { bundleId: 'agent-bundle-test' });
|
|
83
83
|
|
|
84
|
-
assert.equal(bundle.kind, 'amalgm.
|
|
84
|
+
assert.equal(bundle.kind, 'amalgm.bundle');
|
|
85
|
+
assert.equal(bundle.schemaVersion, 2);
|
|
85
86
|
assert.equal(bundle.rootAgentId, root.id);
|
|
86
87
|
assert.deepEqual(bundle.agents.map((entry) => entry.sourceAgentId), [root.id, child.id]);
|
|
87
88
|
assert.deepEqual(bundle.tools.map((tool) => tool.id), ['acme.cli', 'browser']);
|
|
@@ -192,8 +193,8 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
|
|
|
192
193
|
test('installing an agent bundle rejects missing shared subagents', async () => {
|
|
193
194
|
await assert.rejects(
|
|
194
195
|
() => installBundle({
|
|
195
|
-
kind: 'amalgm.
|
|
196
|
-
schemaVersion:
|
|
196
|
+
kind: 'amalgm.bundle',
|
|
197
|
+
schemaVersion: 2,
|
|
197
198
|
rootAgentId: 'missing-child-root',
|
|
198
199
|
title: 'Missing child root',
|
|
199
200
|
agents: [{
|
|
@@ -214,15 +215,21 @@ test('installing an agent bundle rejects missing shared subagents', async () =>
|
|
|
214
215
|
}],
|
|
215
216
|
},
|
|
216
217
|
}],
|
|
218
|
+
automations: [],
|
|
219
|
+
apps: [],
|
|
220
|
+
tools: [],
|
|
221
|
+
toolActions: [],
|
|
222
|
+
skills: [],
|
|
223
|
+
heads: [{ type: 'agent', id: 'missing-child-root' }],
|
|
217
224
|
}),
|
|
218
225
|
/missing shared subagents: missing-child/,
|
|
219
226
|
);
|
|
220
227
|
});
|
|
221
228
|
|
|
222
|
-
test('installing an agent bundle
|
|
223
|
-
|
|
224
|
-
kind: 'amalgm.
|
|
225
|
-
schemaVersion:
|
|
229
|
+
test('installing an agent bundle rejects stale flat graph fields', async () => {
|
|
230
|
+
await assert.rejects(() => installBundle({
|
|
231
|
+
kind: 'amalgm.bundle',
|
|
232
|
+
schemaVersion: 2,
|
|
226
233
|
rootAgentId: 'graph-root',
|
|
227
234
|
title: 'Graph root',
|
|
228
235
|
agents: [{
|
|
@@ -238,13 +245,16 @@ test('installing an agent bundle regenerates stale flat graph fields from canoni
|
|
|
238
245
|
subagents: [],
|
|
239
246
|
},
|
|
240
247
|
}],
|
|
248
|
+
automations: [],
|
|
249
|
+
apps: [],
|
|
250
|
+
tools: [],
|
|
251
|
+
toolActions: [],
|
|
252
|
+
skills: [],
|
|
253
|
+
heads: [{ type: 'agent', id: 'graph-root' }],
|
|
241
254
|
headIds: ['agent:graph-root'],
|
|
242
|
-
elements: [{ id: 'agent:graph-root', type: 'agent', label: 'Graph root'
|
|
255
|
+
elements: [{ id: 'agent:graph-root', type: 'agent', label: 'Graph root' }],
|
|
243
256
|
edges: [{ from: 'agent:graph-root', to: 'tool:missing', kind: 'uses-tool' }],
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
assert.equal(result.ok, true);
|
|
247
|
-
assert.equal(Boolean(resolveAgent(result.rootAgentId)), true);
|
|
257
|
+
}), /bundle edges does not match/);
|
|
248
258
|
});
|
|
249
259
|
|
|
250
260
|
test('agent bundle snapshots installed skill text from the local registry', () => {
|
|
@@ -0,0 +1,152 @@
|
|
|
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-jar-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_BROWSER_AUTH_KEY = 'd'.repeat(64);
|
|
12
|
+
|
|
13
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
14
|
+
const { listEventsAfter } = require('../state/events');
|
|
15
|
+
const {
|
|
16
|
+
deleteBrowserAuthBundle,
|
|
17
|
+
readEncryptedStateBundle,
|
|
18
|
+
writeEncryptedStateBundle,
|
|
19
|
+
} = require('../browser/auth-bundles');
|
|
20
|
+
const { COOKIE_SOURCE_BUNDLE_ID } = require('../browser/cookie-source');
|
|
21
|
+
const {
|
|
22
|
+
cookieJarState,
|
|
23
|
+
cookieKey,
|
|
24
|
+
cookiesMissingFromJar,
|
|
25
|
+
diffCookieSnapshots,
|
|
26
|
+
mergeCookieJar,
|
|
27
|
+
readCookieJar,
|
|
28
|
+
} = require('../browser/cookie-jar');
|
|
29
|
+
|
|
30
|
+
function cookie(name, value, domain = '.example.com', extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
value,
|
|
34
|
+
domain,
|
|
35
|
+
path: '/',
|
|
36
|
+
expires: -1,
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
secure: true,
|
|
39
|
+
sameSite: 'Lax',
|
|
40
|
+
...extra,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test.beforeEach(() => {
|
|
45
|
+
deleteBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
46
|
+
openLocalDb().prepare('DELETE FROM event_log').run();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.after(() => {
|
|
50
|
+
closeLocalDb();
|
|
51
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('central jar merges browser-neutral mutations and keeps secrets encrypted', () => {
|
|
55
|
+
const first = mergeCookieJar({
|
|
56
|
+
sourceId: 'electron:default-session',
|
|
57
|
+
upserts: [cookie('electron-session', 'electron-secret')],
|
|
58
|
+
});
|
|
59
|
+
const second = mergeCookieJar({
|
|
60
|
+
sourceId: 'headless:chat-a',
|
|
61
|
+
upserts: [cookie('headless-session', 'headless-secret', '.other.example')],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(first.version, 1);
|
|
65
|
+
assert.equal(second.version, 2);
|
|
66
|
+
assert.deepEqual(second.cookies.map((item) => item.name).sort(), ['electron-session', 'headless-session']);
|
|
67
|
+
|
|
68
|
+
const { bundle, payload } = readEncryptedStateBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
69
|
+
const encrypted = fs.readFileSync(bundle.blobPath, 'utf8');
|
|
70
|
+
assert.equal(encrypted.includes('electron-secret'), false);
|
|
71
|
+
assert.equal(encrypted.includes('headless-secret'), false);
|
|
72
|
+
assert.equal(payload.cookieJar.schemaVersion, 1);
|
|
73
|
+
assert.equal(payload.cookieJar.records.length, 2);
|
|
74
|
+
assert.equal(JSON.stringify(listEventsAfter(0)).includes('electron-secret'), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('deletions create tombstones and stale adapter startup cannot resurrect them', () => {
|
|
78
|
+
const stale = cookie('session', 'old-secret');
|
|
79
|
+
mergeCookieJar({ sourceId: 'electron', upserts: [stale] });
|
|
80
|
+
const deleted = mergeCookieJar({ sourceId: 'headless', deletes: [stale] });
|
|
81
|
+
|
|
82
|
+
assert.equal(deleted.cookies.length, 0);
|
|
83
|
+
assert.equal(deleted.tombstones.some((item) => item.key === cookieKey(stale)), true);
|
|
84
|
+
assert.deepEqual(cookiesMissingFromJar([stale], deleted), []);
|
|
85
|
+
|
|
86
|
+
const unchanged = mergeCookieJar({ sourceId: 'headless', deletes: [stale] });
|
|
87
|
+
assert.equal(unchanged.version, deleted.version, 'repeated deletion should not churn the jar');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('snapshot diff reports only real record changes', () => {
|
|
91
|
+
const before = [cookie('same', 'value'), cookie('removed', 'old')];
|
|
92
|
+
const after = [cookie('same', 'value'), cookie('added', 'new')];
|
|
93
|
+
const diff = diffCookieSnapshots(before, after);
|
|
94
|
+
assert.deepEqual(diff.upserts.map((item) => item.name), ['added']);
|
|
95
|
+
assert.deepEqual(diff.deletes.map((item) => item.name), ['removed']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('legacy browser-cookie-source bundle migrates in place on first mutation', () => {
|
|
99
|
+
writeEncryptedStateBundle({
|
|
100
|
+
id: COOKIE_SOURCE_BUNDLE_ID,
|
|
101
|
+
name: 'Legacy cookie source',
|
|
102
|
+
profileId: 'user-default',
|
|
103
|
+
state: { cookies: [cookie('legacy', 'kept-secret')], origins: [] },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const legacy = readCookieJar();
|
|
107
|
+
assert.equal(legacy.version, 1);
|
|
108
|
+
assert.equal(legacy.cookies[0].name, 'legacy');
|
|
109
|
+
|
|
110
|
+
const migrated = mergeCookieJar({ sourceId: 'future-browser', upserts: [cookie('future', 'new-secret')] });
|
|
111
|
+
assert.deepEqual(migrated.cookies.map((item) => item.name).sort(), ['future', 'legacy']);
|
|
112
|
+
const { payload } = readEncryptedStateBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
113
|
+
assert.equal(payload.cookieJar.schemaVersion, 1);
|
|
114
|
+
assert.deepEqual(payload.cookieJar.records.map((item) => item.cookie.name).sort(), ['future', 'legacy']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('partitioned cookies remain distinct and protected hosts are rejected centrally', () => {
|
|
118
|
+
const jar = mergeCookieJar({
|
|
119
|
+
sourceId: 'future-browser',
|
|
120
|
+
upserts: [
|
|
121
|
+
cookie('chip', 'one', '.example.com', { partitionKey: 'https://one.test' }),
|
|
122
|
+
cookie('chip', 'two', '.example.com', { partitionKey: 'https://two.test' }),
|
|
123
|
+
cookie('app-auth', 'must-not-share', 'localhost'),
|
|
124
|
+
cookie('supabase-auth', 'must-not-share', '.supabase.co'),
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.equal(jar.cookies.length, 2);
|
|
129
|
+
assert.notEqual(cookieKey(jar.cookies[0]), cookieKey(jar.cookies[1]));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('host-only and domain cookies have distinct identities and inject correctly', () => {
|
|
133
|
+
const domainCookie = cookie('scope', 'domain', '.example.com', { hostOnly: false });
|
|
134
|
+
const hostCookie = cookie('scope', 'host', '.example.com', { hostOnly: true });
|
|
135
|
+
const jar = mergeCookieJar({
|
|
136
|
+
sourceId: 'future-browser',
|
|
137
|
+
upserts: [domainCookie, hostCookie],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
assert.equal(jar.cookies.length, 2);
|
|
141
|
+
assert.notEqual(cookieKey(domainCookie), cookieKey(hostCookie));
|
|
142
|
+
const injected = cookieJarState(jar).cookies;
|
|
143
|
+
assert.equal(injected.find((item) => item.value === 'domain').domain, '.example.com');
|
|
144
|
+
assert.equal(injected.find((item) => item.value === 'host').domain, 'example.com');
|
|
145
|
+
assert.equal(injected.some((item) => Object.hasOwn(item, 'hostOnly')), false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('atomic encrypted writes leave no temporary files behind', () => {
|
|
149
|
+
mergeCookieJar({ sourceId: 'electron', upserts: [cookie('atomic', 'secret')] });
|
|
150
|
+
const root = path.join(process.env.AMALGM_DIR, 'browser', 'auth-bundles');
|
|
151
|
+
assert.equal(fs.readdirSync(root).some((name) => name.endsWith('.tmp')), false);
|
|
152
|
+
});
|
|
@@ -11,6 +11,7 @@ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
|
11
11
|
process.env.AMALGM_BROWSER_AUTH_KEY = 'b'.repeat(64);
|
|
12
12
|
process.env.AMALGM_BROWSER_BACKEND = 'cli';
|
|
13
13
|
process.env.FAKE_BROWSER_LOG = path.join(tempRoot, 'browser-calls.jsonl');
|
|
14
|
+
process.env.FAKE_BROWSER_STATE = path.join(tempRoot, 'browser-state');
|
|
14
15
|
|
|
15
16
|
const fakeBrowser = path.join(tempRoot, 'fake-agent-browser.js');
|
|
16
17
|
fs.writeFileSync(fakeBrowser, `#!/usr/bin/env node
|
|
@@ -18,14 +19,21 @@ const fs = require('fs');
|
|
|
18
19
|
const valueFlags = new Set(['--session', '--profile', '--screenshot-dir', '--cdp', '--executable-path', '--provider']);
|
|
19
20
|
const args = process.argv.slice(2);
|
|
20
21
|
let command = [];
|
|
22
|
+
let session = 'default';
|
|
21
23
|
for (let i = 0; i < args.length; i += 1) {
|
|
22
24
|
const arg = args[i];
|
|
25
|
+
if (arg === '--session') session = args[i + 1] || session;
|
|
23
26
|
if (!arg.startsWith('--')) {
|
|
24
27
|
command = args.slice(i);
|
|
25
28
|
break;
|
|
26
29
|
}
|
|
27
30
|
if (valueFlags.has(arg)) i += 1;
|
|
28
31
|
}
|
|
32
|
+
const stateFile = process.env.FAKE_BROWSER_STATE + '.' + session.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json';
|
|
33
|
+
function readState() {
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch { return { cookies: [], origins: [] }; }
|
|
35
|
+
}
|
|
36
|
+
function writeState(state) { fs.writeFileSync(stateFile, JSON.stringify(state)); }
|
|
29
37
|
let stdin = '';
|
|
30
38
|
if (command[0] === 'batch') {
|
|
31
39
|
try { stdin = fs.readFileSync(0, 'utf8'); } catch {}
|
|
@@ -41,6 +49,31 @@ if (command[0] === 'batch') {
|
|
|
41
49
|
: { ok: true },
|
|
42
50
|
}));
|
|
43
51
|
process.stdout.write(JSON.stringify({ success: true, data: entries }));
|
|
52
|
+
} else if (command[0] === 'state' && command[1] === 'save') {
|
|
53
|
+
fs.writeFileSync(command[2], JSON.stringify(readState()));
|
|
54
|
+
process.stdout.write(JSON.stringify({ success: true, data: { saved: true } }));
|
|
55
|
+
} else if (command[0] === 'state' && command[1] === 'load') {
|
|
56
|
+
writeState(JSON.parse(fs.readFileSync(command[2], 'utf8')));
|
|
57
|
+
process.stdout.write(JSON.stringify({ success: true, data: { loaded: true } }));
|
|
58
|
+
} else if (command[0] === 'cookies' && command[1] === 'clear') {
|
|
59
|
+
writeState({ ...readState(), cookies: [] });
|
|
60
|
+
process.stdout.write(JSON.stringify({ success: true, data: { cleared: true } }));
|
|
61
|
+
} else if (command[0] === 'cookies' && command[1] === 'set') {
|
|
62
|
+
const state = readState();
|
|
63
|
+
const domainIndex = command.indexOf('--domain');
|
|
64
|
+
const urlIndex = command.indexOf('--url');
|
|
65
|
+
const domain = domainIndex >= 0
|
|
66
|
+
? command[domainIndex + 1]
|
|
67
|
+
: (urlIndex >= 0 ? new URL(command[urlIndex + 1]).hostname : 'example.com');
|
|
68
|
+
const next = {
|
|
69
|
+
name: command[2], value: command[3], domain, path: '/', expires: -1,
|
|
70
|
+
httpOnly: false, secure: true, sameSite: 'Lax',
|
|
71
|
+
};
|
|
72
|
+
state.cookies = [...(state.cookies || []).filter((cookie) => !(cookie.name === next.name && cookie.domain === next.domain)), next];
|
|
73
|
+
writeState(state);
|
|
74
|
+
process.stdout.write(JSON.stringify({ success: true, data: { set: true } }));
|
|
75
|
+
} else if (command[0] === 'cookies') {
|
|
76
|
+
process.stdout.write(JSON.stringify({ success: true, data: { cookies: readState().cookies || [] } }));
|
|
44
77
|
} else {
|
|
45
78
|
process.stdout.write(JSON.stringify({ success: true, data: { ok: true } }));
|
|
46
79
|
}
|
|
@@ -50,6 +83,8 @@ process.env.AMALGM_AGENT_BROWSER_BIN = fakeBrowser;
|
|
|
50
83
|
const { closeLocalDb } = require('../state/db');
|
|
51
84
|
const { COOKIE_SOURCE_BUNDLE_ID } = require('../browser/cookie-source');
|
|
52
85
|
const { writeEncryptedStateBundle } = require('../browser/auth-bundles');
|
|
86
|
+
const { cookieKey, readCookieJar } = require('../browser/cookie-jar');
|
|
87
|
+
const { listBrowserAuthBundles } = require('../browser/store');
|
|
53
88
|
const engine = require('../browser/engine');
|
|
54
89
|
|
|
55
90
|
function readCalls() {
|
|
@@ -116,3 +151,35 @@ test('new browser session imports the shared cookie source before opening', asyn
|
|
|
116
151
|
);
|
|
117
152
|
assert.equal(stateLoadCalls().length, 1, 'same source version should not reload for same session');
|
|
118
153
|
});
|
|
154
|
+
|
|
155
|
+
test('headless mutations and deletions flow back into the central jar', async () => {
|
|
156
|
+
const context = { callerSessionId: 'chat-writeback', sessionMetadata: { client: 'web' } };
|
|
157
|
+
await engine.passthrough({
|
|
158
|
+
args: ['cookies', 'set', 'headless-token', 'rotated-secret', '--domain', 'writeback.example'],
|
|
159
|
+
}, context);
|
|
160
|
+
|
|
161
|
+
let jar = readCookieJar();
|
|
162
|
+
const written = jar.cookies.find((cookie) => cookie.name === 'headless-token');
|
|
163
|
+
assert.equal(written?.value, 'rotated-secret');
|
|
164
|
+
|
|
165
|
+
await engine.passthrough({ args: ['cookies', 'clear'] }, context);
|
|
166
|
+
jar = readCookieJar();
|
|
167
|
+
assert.equal(jar.cookies.some((cookie) => cookie.name === 'headless-token'), false);
|
|
168
|
+
assert.equal(jar.tombstones.some((item) => item.key === cookieKey(written)), true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('auth_save creates a named bundle and never replaces the central jar', async () => {
|
|
172
|
+
const authSave = require('../browser/auth-tools').find((tool) => tool.name === 'auth_save');
|
|
173
|
+
const before = readCookieJar();
|
|
174
|
+
const result = await authSave.handler(
|
|
175
|
+
{ browserProfileId: 'chat-a', name: 'Explicit saved login' },
|
|
176
|
+
{ callerSessionId: 'chat-a', sessionMetadata: { client: 'web' } },
|
|
177
|
+
);
|
|
178
|
+
assert.equal(result.isError, undefined);
|
|
179
|
+
|
|
180
|
+
const bundles = listBrowserAuthBundles();
|
|
181
|
+
const saved = bundles.find((bundle) => bundle.name === 'Explicit saved login');
|
|
182
|
+
assert.ok(saved);
|
|
183
|
+
assert.notEqual(saved.id, COOKIE_SOURCE_BUNDLE_ID);
|
|
184
|
+
assert.equal(readCookieJar().versionToken, before.versionToken);
|
|
185
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
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-profiles-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_BROWSER_AUTH_KEY = 'e'.repeat(64);
|
|
12
|
+
|
|
13
|
+
const { closeLocalDb } = require('../state/db');
|
|
14
|
+
const { writeEncryptedStateBundle } = require('../browser/auth-bundles');
|
|
15
|
+
const { profilePathFor } = require('../browser/paths');
|
|
16
|
+
const {
|
|
17
|
+
deleteBrowserProfile,
|
|
18
|
+
getBrowserProfile,
|
|
19
|
+
pruneEphemeralBrowserProfiles,
|
|
20
|
+
upsertBrowserProfile,
|
|
21
|
+
} = require('../browser/profiles');
|
|
22
|
+
const sessions = require('../browser/sessions');
|
|
23
|
+
|
|
24
|
+
function oldProfile(id) {
|
|
25
|
+
const target = profilePathFor(id);
|
|
26
|
+
fs.mkdirSync(path.join(target, 'Default'), { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(target, 'Default', 'Cookies'), 'test');
|
|
28
|
+
const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
|
29
|
+
fs.utimesSync(path.join(target, 'Default', 'Cookies'), old, old);
|
|
30
|
+
fs.utimesSync(path.join(target, 'Default'), old, old);
|
|
31
|
+
fs.utimesSync(target, old, old);
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test.after(() => {
|
|
36
|
+
closeLocalDb();
|
|
37
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('ordinary sessions are not registered as durable profiles', () => {
|
|
41
|
+
sessions.sessionInfo('ordinary-chat');
|
|
42
|
+
assert.equal(getBrowserProfile('ordinary-chat'), null);
|
|
43
|
+
assert.equal(fs.existsSync(profilePathFor('ordinary-chat')), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('profile pruning removes stale ephemeral state and preserves explicit, auth, and live profiles', () => {
|
|
47
|
+
const ephemeralPath = oldProfile('ephemeral-chat');
|
|
48
|
+
upsertBrowserProfile({ id: 'ephemeral-chat', source: 'agent-browser', profilePath: ephemeralPath });
|
|
49
|
+
|
|
50
|
+
const manualPath = oldProfile('manual-profile');
|
|
51
|
+
upsertBrowserProfile({ id: 'manual-profile', source: 'manual', profilePath: manualPath });
|
|
52
|
+
|
|
53
|
+
const savedPath = oldProfile('saved-auth-profile');
|
|
54
|
+
writeEncryptedStateBundle({
|
|
55
|
+
id: 'saved-auth-bundle',
|
|
56
|
+
profileId: 'saved-auth-profile',
|
|
57
|
+
state: { cookies: [], origins: [] },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const livePath = oldProfile('live-profile');
|
|
61
|
+
fs.writeFileSync(path.join(livePath, 'SingletonLock'), 'locked');
|
|
62
|
+
|
|
63
|
+
const result = pruneEphemeralBrowserProfiles({ ttlHours: 1 });
|
|
64
|
+
assert.equal(fs.existsSync(ephemeralPath), false);
|
|
65
|
+
assert.equal(getBrowserProfile('ephemeral-chat'), null);
|
|
66
|
+
assert.equal(fs.existsSync(manualPath), true);
|
|
67
|
+
assert.equal(fs.existsSync(savedPath), true);
|
|
68
|
+
assert.equal(fs.existsSync(livePath), true);
|
|
69
|
+
assert.equal(result.removedDirectories, 1);
|
|
70
|
+
assert.equal(result.removedRows, 1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('closing an unreferenced session can remove its working directory without a registry row', () => {
|
|
74
|
+
const target = oldProfile('closed-chat');
|
|
75
|
+
const result = deleteBrowserProfile('closed-chat', { onlyIfUnreferenced: true });
|
|
76
|
+
assert.equal(result.directoryDeleted, true);
|
|
77
|
+
assert.equal(fs.existsSync(target), false);
|
|
78
|
+
});
|
|
@@ -259,8 +259,7 @@ test('mixed bundle carries automations and apps with heads and installs everythi
|
|
|
259
259
|
]);
|
|
260
260
|
const appElement = bundle.elements.find((element) => element.type === 'app');
|
|
261
261
|
assert.equal(appElement.label, 'Sample App');
|
|
262
|
-
|
|
263
|
-
assert.equal('content' in appElement.data.files[0], false);
|
|
262
|
+
assert.equal('data' in appElement, false);
|
|
264
263
|
assert.equal(preview.automations.length, 1);
|
|
265
264
|
assert.equal(preview.apps.length, 1);
|
|
266
265
|
assert.equal(preview.apps[0].files, 5);
|
|
@@ -292,13 +291,21 @@ test('mixed bundle carries automations and apps with heads and installs everythi
|
|
|
292
291
|
assert.equal(fs.existsSync(path.join(registered[0].cwd, 'src', 'index.js')), true);
|
|
293
292
|
});
|
|
294
293
|
|
|
295
|
-
test('legacy
|
|
294
|
+
test('legacy and empty bundles are rejected', () => {
|
|
295
|
+
assert.throws(
|
|
296
|
+
() => validateBundle({ kind: 'amalgm.agent.bundle', schemaVersion: 2 }),
|
|
297
|
+
/Unsupported bundle kind/,
|
|
298
|
+
);
|
|
296
299
|
assert.throws(
|
|
297
300
|
() => validateBundle({ kind: 'amalgm.bundle', schemaVersion: 1 }),
|
|
301
|
+
/Unsupported bundle schema version/,
|
|
302
|
+
);
|
|
303
|
+
assert.throws(
|
|
304
|
+
() => validateBundle({ kind: 'amalgm.bundle', schemaVersion: 2 }),
|
|
298
305
|
/at least one agent, automation, app, or tool/,
|
|
299
306
|
);
|
|
300
307
|
assert.throws(
|
|
301
|
-
() => validateBundle({ kind: 'something.else', schemaVersion:
|
|
308
|
+
() => validateBundle({ kind: 'something.else', schemaVersion: 2 }),
|
|
302
309
|
/Unsupported bundle kind/,
|
|
303
310
|
);
|
|
304
311
|
});
|
|
@@ -346,7 +353,8 @@ test('standalone tool bundle carries the tool as a head and installs it', async
|
|
|
346
353
|
const toolElement = bundle.elements.find((element) => element.id === 'tool:acme.standalone');
|
|
347
354
|
assert.equal(toolElement.label, 'Acme Standalone');
|
|
348
355
|
const actionElement = bundle.elements.find((element) => element.type === 'tool_action');
|
|
349
|
-
assert.equal(actionElement.
|
|
356
|
+
assert.equal(actionElement.id, 'tool_action:acme.standalone.run');
|
|
357
|
+
assert.equal('data' in actionElement, false);
|
|
350
358
|
assert.equal(preview.tools.length, 1);
|
|
351
359
|
|
|
352
360
|
const revived = validateBundle(JSON.parse(JSON.stringify(bundle)));
|
|
@@ -584,6 +592,13 @@ test('sharing an app pulls its owned tools, nested under the app in the graph',
|
|
|
584
592
|
startCommand: 'PORT={port} node server.js',
|
|
585
593
|
toolIds: ['appowned.tool'],
|
|
586
594
|
});
|
|
595
|
+
// The portable manifest is authoritative even if a saved runtime summary
|
|
596
|
+
// has drifted since registration.
|
|
597
|
+
const apps = loadApps();
|
|
598
|
+
apps.apps = apps.apps.map((entry) => (
|
|
599
|
+
entry.id === app.id ? { ...entry, toolIds: [] } : entry
|
|
600
|
+
));
|
|
601
|
+
saveApps(apps);
|
|
587
602
|
defaultToolboxService.registerTool({
|
|
588
603
|
id: 'appowned.tool',
|
|
589
604
|
name: 'App Owned Tool',
|
|
@@ -605,8 +620,8 @@ test('sharing an app pulls its owned tools, nested under the app in the graph',
|
|
|
605
620
|
assert.equal(bundle.tools.filter((tool) => tool.id === 'appowned.tool').length, 1);
|
|
606
621
|
assert.equal(bundle.tools[0].appId, app.id);
|
|
607
622
|
assert.deepEqual(
|
|
608
|
-
bundle.edges.filter((edge) => edge.kind === '
|
|
609
|
-
[{ from: `app:${app.id}`, to: 'tool:appowned.tool', kind: '
|
|
623
|
+
bundle.edges.filter((edge) => edge.kind === 'provides-tool' && edge.from === `app:${app.id}`),
|
|
624
|
+
[{ from: `app:${app.id}`, to: 'tool:appowned.tool', kind: 'provides-tool' }],
|
|
610
625
|
);
|
|
611
626
|
// The tool's paths bind to the app, so nothing stays machine-bound.
|
|
612
627
|
assert.equal(bundle.requires.bindings.some((entry) => entry.includes('appowned.tool')), false);
|
|
@@ -632,6 +647,48 @@ test('sharing an app pulls its owned tools, nested under the app in the graph',
|
|
|
632
647
|
assert.equal(installedTool.source.cwd, result.installedApps[0].cwd);
|
|
633
648
|
});
|
|
634
649
|
|
|
650
|
+
test('tool↔app closure is transitive and packs every discovered owner once', () => {
|
|
651
|
+
const { defaultToolboxService } = require('../toolbox/service');
|
|
652
|
+
const ownerDir = makeTempDir('bundle-transitive-owner-');
|
|
653
|
+
fs.writeFileSync(path.join(ownerDir, 'package.json'), JSON.stringify({ name: 'transitive-owner' }));
|
|
654
|
+
fs.writeFileSync(path.join(ownerDir, 'server.js'), 'console.log("owner");\n');
|
|
655
|
+
const owner = registerSampleApp(ownerDir, {
|
|
656
|
+
id: 'app-transitive-owner',
|
|
657
|
+
appRef: 'transowner001',
|
|
658
|
+
startCommand: 'node server.js',
|
|
659
|
+
toolIds: ['transitive.tool'],
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const entryDir = makeTempDir('bundle-transitive-entry-');
|
|
663
|
+
fs.writeFileSync(path.join(entryDir, 'package.json'), JSON.stringify({ name: 'transitive-entry' }));
|
|
664
|
+
fs.writeFileSync(path.join(entryDir, 'server.js'), 'console.log("entry");\n');
|
|
665
|
+
const entry = registerSampleApp(entryDir, {
|
|
666
|
+
id: 'app-transitive-entry',
|
|
667
|
+
appRef: 'transentry001',
|
|
668
|
+
startCommand: 'node server.js',
|
|
669
|
+
toolIds: ['transitive.tool'],
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
defaultToolboxService.registerTool({
|
|
673
|
+
id: 'transitive.tool',
|
|
674
|
+
name: 'Transitive Tool',
|
|
675
|
+
type: 'cli',
|
|
676
|
+
origin: 'user',
|
|
677
|
+
appId: owner.id,
|
|
678
|
+
source: { command: 'transitive-tool', inputMode: 'json-stdin', outputMode: 'json' },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const { bundle } = createBundle({ appIds: [entry.id] });
|
|
682
|
+
assert.deepEqual(bundle.heads, [{ type: 'app', id: entry.id }]);
|
|
683
|
+
assert.deepEqual(bundle.apps.map((app) => app.sourceAppId), [entry.id, owner.id]);
|
|
684
|
+
assert.equal(bundle.tools.filter((tool) => tool.id === 'transitive.tool').length, 1);
|
|
685
|
+
assert.equal(bundle.edges.some((edge) => (
|
|
686
|
+
edge.from === 'tool:transitive.tool'
|
|
687
|
+
&& edge.to === `app:${owner.id}`
|
|
688
|
+
&& edge.kind === 'requires-app'
|
|
689
|
+
)), true);
|
|
690
|
+
});
|
|
691
|
+
|
|
635
692
|
test('sharing an app-owned tool pulls the app as a dependency, not a head', async () => {
|
|
636
693
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
637
694
|
const appDir = makeTempDir('bundle-toolowner-src-');
|
|
@@ -669,7 +726,10 @@ test('sharing an app-owned tool pulls the app as a dependency, not a head', asyn
|
|
|
669
726
|
assert.equal(bundle.apps[0].sourceAppId, app.id);
|
|
670
727
|
assert.deepEqual(bundle.apps[0].toolIds, ['toolowner.tool']);
|
|
671
728
|
assert.equal(bundle.edges.some(
|
|
672
|
-
(edge) => edge.from === `app:${app.id}` && edge.to === 'tool:toolowner.tool' && edge.kind === '
|
|
729
|
+
(edge) => edge.from === `app:${app.id}` && edge.to === 'tool:toolowner.tool' && edge.kind === 'provides-tool',
|
|
730
|
+
), true);
|
|
731
|
+
assert.equal(bundle.edges.some(
|
|
732
|
+
(edge) => edge.from === 'tool:toolowner.tool' && edge.to === `app:${app.id}` && edge.kind === 'requires-app',
|
|
673
733
|
), true);
|
|
674
734
|
// App-bound paths resolve through the pulled-in app.
|
|
675
735
|
const toolEntry = bundle.tools.find((tool) => tool.id === 'toolowner.tool');
|
|
@@ -721,8 +781,15 @@ test('an agent using an app-owned tool stays canonical: one tool record, app pul
|
|
|
721
781
|
bundle.elements.filter((element) => element.id === 'tool:agentapp.tool').length,
|
|
722
782
|
1,
|
|
723
783
|
);
|
|
724
|
-
|
|
725
|
-
|
|
784
|
+
assert.equal(bundle.edges.some((edge) => (
|
|
785
|
+
edge.from === `agent:${agent.id}` && edge.to === 'tool:agentapp.tool' && edge.kind === 'uses-tool'
|
|
786
|
+
)), true);
|
|
787
|
+
assert.equal(bundle.edges.some((edge) => (
|
|
788
|
+
edge.from === `app:${app.id}` && edge.to === 'tool:agentapp.tool' && edge.kind === 'provides-tool'
|
|
789
|
+
)), true);
|
|
790
|
+
assert.equal(bundle.edges.some((edge) => (
|
|
791
|
+
edge.from === 'tool:agentapp.tool' && edge.to === `app:${app.id}` && edge.kind === 'requires-app'
|
|
792
|
+
)), true);
|
|
726
793
|
});
|
|
727
794
|
|
|
728
795
|
test('deleting either side of the app↔tool relation scrubs the other side', async () => {
|
|
@@ -779,7 +846,7 @@ test('deleting either side of the app↔tool relation scrubs the other side', as
|
|
|
779
846
|
assert.equal('appId' in tool, false);
|
|
780
847
|
});
|
|
781
848
|
|
|
782
|
-
test('a stale tool→app link
|
|
849
|
+
test('a stale tool→app link fails export', () => {
|
|
783
850
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
784
851
|
defaultToolboxService.registerTool({
|
|
785
852
|
id: 'stale.applink',
|
|
@@ -790,10 +857,10 @@ test('a stale tool→app link degrades to a warning instead of failing the expor
|
|
|
790
857
|
source: { command: 'stale-applink', inputMode: 'json-stdin', outputMode: 'json' },
|
|
791
858
|
});
|
|
792
859
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
860
|
+
assert.throws(
|
|
861
|
+
() => createBundle({ toolIds: ['stale.applink'] }),
|
|
862
|
+
/references missing app: app-that-was-deleted/,
|
|
863
|
+
);
|
|
797
864
|
});
|
|
798
865
|
|
|
799
866
|
test('installBundle defaults app materialization to the owned apps root', async () => {
|