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.
@@ -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.agent.bundle');
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.agent.bundle',
196
- schemaVersion: 1,
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 regenerates stale flat graph fields from canonical agent arrays', async () => {
223
- const result = await installBundle({
224
- kind: 'amalgm.agent.bundle',
225
- schemaVersion: 1,
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', data: {} }],
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
- // Graph elements carry a manifest, not file contents.
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 agent-only bundles still validate and empty bundles are rejected', () => {
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: 1 }),
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.data.id, 'acme.standalone.run');
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 === 'uses-tool' && edge.from === `app:${app.id}`),
609
- [{ from: `app:${app.id}`, to: 'tool:appowned.tool', kind: 'uses-tool' }],
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 === 'uses-tool',
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
- const toolEdges = bundle.edges.filter((edge) => edge.to === 'tool:agentapp.tool' && edge.kind === 'uses-tool');
725
- assert.deepEqual(toolEdges.map((edge) => edge.from).sort(), [`agent:${agent.id}`, `app:${app.id}`]);
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 degrades to a warning instead of failing the export', () => {
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
- const { bundle } = createBundle({ toolIds: ['stale.applink'] });
794
- assert.equal(bundle.apps.length, 0);
795
- assert.equal(bundle.tools.length, 1);
796
- assert.equal(bundle.warnings.some((warning) => warning.includes('app-that-was-deleted')), true);
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 () => {