amalgm 0.1.257 → 0.1.258
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 +72 -3
- package/lib/layout.js +30 -9
- package/lib/shared-realtime-tunnel.js +12 -14
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/lib/layout.js +32 -9
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -32
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +33 -6
- package/runtime/scripts/amalgm-mcp/registration/index.js +21 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -11
- package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +6 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +67 -8
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +45 -3
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +3 -6
- package/runtime/scripts/amalgm-mcp/tests/toolbox-artifacts.test.js +88 -0
- package/runtime/scripts/amalgm-mcp/tests/toolbox-cloud-registration.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/toolbox/artifacts.js +225 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +59 -10
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +14 -5
|
@@ -76,6 +76,37 @@ test('a signed-in registration is refused until the cloud entity resource is act
|
|
|
76
76
|
assert.doesNotThrow(() => cloud.assertRegistrationReady());
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('entity-cloud status reports an unsettled catalog as catching up without repairing it', () => {
|
|
80
|
+
const db = new Database(':memory:');
|
|
81
|
+
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|
|
82
|
+
const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
83
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
|
|
84
|
+
const bootstrap = cloud.bootstrap(registry.syncRecords());
|
|
85
|
+
cloud.install({
|
|
86
|
+
registry,
|
|
87
|
+
resourceId: bootstrap.resourceId,
|
|
88
|
+
authorityEpoch: 1,
|
|
89
|
+
headVersion: 0,
|
|
90
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
91
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const current = cloud.status();
|
|
95
|
+
assert.equal(current.catalog.current, true);
|
|
96
|
+
assert.equal(current.catalog.records[0].uuid, root.uuid);
|
|
97
|
+
assert.equal(current.outbox.pending, 0);
|
|
98
|
+
|
|
99
|
+
const child = registry.create({ type: 'folder', parentUUID: root.uuid, name: 'child' });
|
|
100
|
+
const catchingUp = cloud.status();
|
|
101
|
+
assert.equal(catchingUp.catalog.current, false, 'an unacknowledged local mutation never masquerades as an active cloud head');
|
|
102
|
+
assert.equal(catchingUp.outbox.pending, 1);
|
|
103
|
+
assert.equal(catchingUp.catalog.records.some((record) => record.uuid === child.uuid), true,
|
|
104
|
+
'status may show the local catalog projection while naming it catching up');
|
|
105
|
+
|
|
106
|
+
const after = cloud.status();
|
|
107
|
+
assert.deepEqual(after, catchingUp, 'status is read-only: it does not claim, retry, or repair delivery');
|
|
108
|
+
});
|
|
109
|
+
|
|
79
110
|
test('a large registration replaces the complete graph in one cloud operation and a recipient waits for its compacted snapshot', () => {
|
|
80
111
|
const db = new Database(':memory:');
|
|
81
112
|
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|
|
@@ -244,7 +275,7 @@ test('a compacted remote snapshot enters the cloud catalog, not locally watched
|
|
|
244
275
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
245
276
|
});
|
|
246
277
|
|
|
247
|
-
test('add
|
|
278
|
+
test('add follows in-order mirrored mutations and refuses after a version gap until the compacted snapshot returns', () => {
|
|
248
279
|
const sourceDb = new Database(':memory:');
|
|
249
280
|
const sourceCloud = createEntityCloud({ db: sourceDb, userId: 'user-1', deviceId: 'source-device' });
|
|
250
281
|
const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
|
|
@@ -277,7 +308,7 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
|
|
|
277
308
|
envelope: {
|
|
278
309
|
resourceId,
|
|
279
310
|
mutationId: 'ent_remote_change',
|
|
280
|
-
contract: 'amalgm-entity-registry@
|
|
311
|
+
contract: 'amalgm-entity-registry@3',
|
|
281
312
|
schemaVersion: 1,
|
|
282
313
|
operationKind: 'entity.upsert',
|
|
283
314
|
operation: { kind: 'entity.upsert', record: changed },
|
|
@@ -286,25 +317,53 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
|
|
|
286
317
|
committedAt: '2026-08-04T00:00:00.000Z',
|
|
287
318
|
});
|
|
288
319
|
|
|
320
|
+
// Version 13 arrived in official order on a snapshot-consistent catalog:
|
|
321
|
+
// the catalog IS the compacted head, so add needs no re-download.
|
|
322
|
+
const inOrder = path.join(targetDir, 'in-order');
|
|
323
|
+
fs.mkdirSync(inOrder);
|
|
324
|
+
assert.equal(
|
|
325
|
+
targetCloud.startMaterialization({ uuid: root.uuid, targetPath: inOrder }).headVersion,
|
|
326
|
+
13,
|
|
327
|
+
'an in-order mirrored mutation keeps the catalog materializable at the new head',
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Version 15 skips 14: the catalog can no longer prove it equals the
|
|
331
|
+
// head, so add refuses until a fresh compacted snapshot is installed.
|
|
332
|
+
const gapped = { ...changed, name: 'gapped.md', version: 'd'.repeat(64) };
|
|
333
|
+
targetCloud.ingest({
|
|
334
|
+
registry: targetRegistry,
|
|
335
|
+
envelope: {
|
|
336
|
+
resourceId,
|
|
337
|
+
mutationId: 'ent_remote_gap',
|
|
338
|
+
contract: 'amalgm-entity-registry@3',
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
operationKind: 'entity.upsert',
|
|
341
|
+
operation: { kind: 'entity.upsert', record: gapped },
|
|
342
|
+
},
|
|
343
|
+
version: 15,
|
|
344
|
+
committedAt: '2026-08-04T00:00:01.000Z',
|
|
345
|
+
});
|
|
346
|
+
const afterGap = path.join(targetDir, 'after-gap');
|
|
347
|
+
fs.mkdirSync(afterGap);
|
|
289
348
|
assert.throws(
|
|
290
|
-
() => targetCloud.startMaterialization({ uuid: root.uuid, targetPath:
|
|
349
|
+
() => targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }),
|
|
291
350
|
/catalog is not pinned to the active cloud head/,
|
|
292
|
-
'
|
|
351
|
+
'a version gap drops the snapshot barrier until compaction returns',
|
|
293
352
|
);
|
|
294
353
|
|
|
295
354
|
const cloudRoot = initialSnapshot.records.find((record) => record.uuid === root.uuid);
|
|
296
|
-
const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot,
|
|
355
|
+
const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot, gapped])), 'utf8');
|
|
297
356
|
targetCloud.install({
|
|
298
357
|
registry: targetRegistry,
|
|
299
358
|
resourceId,
|
|
300
359
|
authorityEpoch: 3,
|
|
301
|
-
headVersion:
|
|
360
|
+
headVersion: 15,
|
|
302
361
|
snapshotBase64: compactedBytes.toString('base64'),
|
|
303
362
|
snapshotChecksum: require('crypto').createHash('sha256').update(compactedBytes).digest('hex'),
|
|
304
363
|
});
|
|
305
364
|
assert.equal(
|
|
306
|
-
targetCloud.startMaterialization({ uuid: root.uuid, targetPath:
|
|
307
|
-
|
|
365
|
+
targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }).headVersion,
|
|
366
|
+
15,
|
|
308
367
|
'the fresh compacted snapshot—not a history replay—makes add available again',
|
|
309
368
|
);
|
|
310
369
|
} finally {
|
|
@@ -189,7 +189,7 @@ test('an added ordinary workspace stays live: a later cloud file head replaces l
|
|
|
189
189
|
envelope: {
|
|
190
190
|
resourceId: sourceCloud.identity().resourceId,
|
|
191
191
|
mutationId: 'ent_remote_file_change',
|
|
192
|
-
contract: 'amalgm-entity-registry@
|
|
192
|
+
contract: 'amalgm-entity-registry@3',
|
|
193
193
|
schemaVersion: 1,
|
|
194
194
|
operationKind: 'entity.upsert',
|
|
195
195
|
operation: { kind: 'entity.upsert', record: remoteRecord },
|
|
@@ -296,7 +296,7 @@ test('add preserves a symlink UUID and later retargets the same literal link wit
|
|
|
296
296
|
const ingested = target.ingestCloudMutation({
|
|
297
297
|
envelope: {
|
|
298
298
|
resourceId: sourceCloud.identity().resourceId, mutationId: 'ent_remote_link_change',
|
|
299
|
-
contract: 'amalgm-entity-registry@
|
|
299
|
+
contract: 'amalgm-entity-registry@3', schemaVersion: 1,
|
|
300
300
|
operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: sourceRegistry.syncRecord(changed.uuid) },
|
|
301
301
|
},
|
|
302
302
|
version: 42, committedAt: '2026-08-05T00:00:00.000Z',
|
|
@@ -420,7 +420,7 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
420
420
|
const ingested = target.ingestCloudMutation({
|
|
421
421
|
envelope: {
|
|
422
422
|
resourceId: bootstrap.resourceId, mutationId: 'ent_remote_repo_change',
|
|
423
|
-
contract: 'amalgm-entity-registry@
|
|
423
|
+
contract: 'amalgm-entity-registry@3', schemaVersion: 1,
|
|
424
424
|
operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: remoteRecord },
|
|
425
425
|
},
|
|
426
426
|
version: 2, committedAt: '2026-08-05T00:00:00.000Z',
|
|
@@ -54,6 +54,7 @@ const { ENTITIES_DIR, createRegistrationService, registrationService } = require
|
|
|
54
54
|
const { adapterFor } = require('../adapters');
|
|
55
55
|
const { createRuntimeHttpServer } = require('../server/http-service');
|
|
56
56
|
const { createLocalServiceRouter } = require('../server/local-service-router');
|
|
57
|
+
const { SYSTEM_TOOL_ARTIFACTS } = require('../lib/layout');
|
|
57
58
|
|
|
58
59
|
const services = [];
|
|
59
60
|
let server;
|
|
@@ -203,6 +204,7 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
203
204
|
['agents-root', 'exact'], ['apps-root', 'exact'], ['automations-root', 'exact'],
|
|
204
205
|
['toolbox-root', 'exact'], ['workspaces-root', 'exact'], ['system-root', 'exact'],
|
|
205
206
|
['home-manifest', 'exact'], ['defaults', 'exact'], ['policy', 'exact'],
|
|
207
|
+
...SYSTEM_TOOL_ARTIFACTS.map((entry) => [entry.key, 'exact']),
|
|
206
208
|
],
|
|
207
209
|
);
|
|
208
210
|
for (const entry of bootstrapped.exactManaged) {
|
|
@@ -218,7 +220,7 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
const cloudRecords = await service.exactUserHomeCloudRecords();
|
|
221
|
-
assert.equal(cloudRecords.length,
|
|
223
|
+
assert.equal(cloudRecords.length, 16,
|
|
222
224
|
'the declared home is the entire graph eligible to repair a legacy empty cloud registry');
|
|
223
225
|
assert.deepEqual(
|
|
224
226
|
new Set(cloudRecords.map((record) => record.uuid)),
|
|
@@ -233,6 +235,46 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
233
235
|
);
|
|
234
236
|
});
|
|
235
237
|
|
|
238
|
+
test('an upgraded declared home registers every built-in tool through Detect and Record', async () => {
|
|
239
|
+
const home = path.join(tempRoot, 'user-home-upgrade');
|
|
240
|
+
const service = createRegistrationService({
|
|
241
|
+
dir: path.join(tempRoot, 'state-user-home-upgrade'),
|
|
242
|
+
homeDir: home,
|
|
243
|
+
});
|
|
244
|
+
services.push(service);
|
|
245
|
+
|
|
246
|
+
for (const root of ['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']) {
|
|
247
|
+
fs.mkdirSync(path.join(home, root), { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
fs.mkdirSync(path.join(home, 'system', 'defaults'));
|
|
250
|
+
fs.mkdirSync(path.join(home, 'system', 'policy'));
|
|
251
|
+
fs.writeFileSync(path.join(home, 'system', 'home.json'), JSON.stringify({
|
|
252
|
+
schemaVersion: 1,
|
|
253
|
+
kind: 'amalgm-user-home',
|
|
254
|
+
roots: [],
|
|
255
|
+
exactManaged: [],
|
|
256
|
+
}));
|
|
257
|
+
service.registration.addMany(['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']
|
|
258
|
+
.map((root) => path.join(home, root)));
|
|
259
|
+
|
|
260
|
+
const bootstrapped = await service.bootstrapUserHome();
|
|
261
|
+
const toolbox = bootstrapped.roots.find((entry) => entry.key === 'toolbox');
|
|
262
|
+
const browser = bootstrapped.exactManaged.find((entry) => entry.key === 'amalgm-browser');
|
|
263
|
+
const manifest = bootstrapped.exactManaged.find((entry) => entry.key === 'home-manifest');
|
|
264
|
+
assert.equal(browser.record.parentUUID, toolbox.record.uuid,
|
|
265
|
+
'the declared browser tool is a real child of the existing toolbox root');
|
|
266
|
+
assert.equal(browser.record.type, 'file.text');
|
|
267
|
+
assert.equal(service.registration.registry.syncRecord(manifest.record.uuid).payloadVersion, require('crypto')
|
|
268
|
+
.createHash('sha256')
|
|
269
|
+
.update(fs.readFileSync(path.join(home, 'system', 'home.json')))
|
|
270
|
+
.digest('hex'), 'the rewritten exact manifest is observed before its cloud identity is returned');
|
|
271
|
+
for (const artifact of SYSTEM_TOOL_ARTIFACTS) {
|
|
272
|
+
assert.ok(fs.existsSync(path.join(home, artifact.relativePath)), `${artifact.key} is materialized`);
|
|
273
|
+
assert.ok(bootstrapped.exactManaged.some((entry) => entry.key === artifact.key),
|
|
274
|
+
`${artifact.key} is an exact managed entity`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
236
278
|
test('entity-cloud bootstrap publishes only the verified user home when an old local repo exists', async () => {
|
|
237
279
|
const service = createRegistrationService({
|
|
238
280
|
dir: path.join(tempRoot, 'state-user-home-cloud-boundary'),
|
|
@@ -248,13 +290,13 @@ test('entity-cloud bootstrap publishes only the verified user home when an old l
|
|
|
248
290
|
});
|
|
249
291
|
|
|
250
292
|
const records = await service.exactUserHomeCloudRecords();
|
|
251
|
-
assert.equal(records.length,
|
|
293
|
+
assert.equal(records.length, 16);
|
|
252
294
|
assert.equal(records.some((record) => record.uuid === oldRepo.uuid), false,
|
|
253
295
|
'login never turns existing user-selected ground into an implicit cloud registration');
|
|
254
296
|
|
|
255
297
|
const bootstrap = service.entityCloud.bootstrap(records);
|
|
256
298
|
const snapshot = JSON.parse(Buffer.from(bootstrap.snapshotBase64, 'base64').toString('utf8'));
|
|
257
|
-
assert.equal(snapshot.records.length,
|
|
299
|
+
assert.equal(snapshot.records.length, 16);
|
|
258
300
|
service.entityCloud.install({
|
|
259
301
|
registry: service.registration.registry,
|
|
260
302
|
resourceId: bootstrap.resourceId,
|
|
@@ -35,7 +35,7 @@ test('system toolbox catalog exposes first-party default tools', () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test('system toolbox catalog merge preserves
|
|
38
|
+
test('system toolbox catalog merge preserves user records but keeps built-ins authoritative', () => {
|
|
39
39
|
const merged = mergeSystemToolboxCatalog({
|
|
40
40
|
version: 1,
|
|
41
41
|
tools: {
|
|
@@ -59,12 +59,9 @@ test('system toolbox catalog merge preserves persisted records', () => {
|
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
assert.equal(merged.tools.browser.name, '
|
|
62
|
+
assert.equal(merged.tools.browser.name, 'Browser');
|
|
63
63
|
assert.equal(merged.tools['custom.cli'].name, 'Custom CLI');
|
|
64
|
-
assert.equal(
|
|
65
|
-
merged.toolActions['browser.browser_open'].name,
|
|
66
|
-
'persisted_navigate',
|
|
67
|
-
);
|
|
64
|
+
assert.equal(merged.toolActions['browser.browser_open'], undefined);
|
|
68
65
|
assert.equal(merged.toolActions['apps.apps_list'].name, 'apps_list');
|
|
69
66
|
});
|
|
70
67
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
SYSTEM_TOOL_ARTIFACTS,
|
|
11
|
+
artifactPathForTool,
|
|
12
|
+
isSystemToolId,
|
|
13
|
+
materializeSystemToolArtifacts,
|
|
14
|
+
migrateLegacyToolboxCatalog,
|
|
15
|
+
readToolArtifacts,
|
|
16
|
+
writeToolArtifact,
|
|
17
|
+
} = require('../toolbox/artifacts');
|
|
18
|
+
|
|
19
|
+
function temporaryToolbox() {
|
|
20
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-toolbox-artifacts-'));
|
|
21
|
+
const toolboxDir = path.join(home, 'toolbox');
|
|
22
|
+
fs.mkdirSync(toolboxDir);
|
|
23
|
+
return { home, toolboxDir };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('each built-in tool materializes as one distinct managed artifact', () => {
|
|
27
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
28
|
+
try {
|
|
29
|
+
const first = materializeSystemToolArtifacts({ toolboxDir });
|
|
30
|
+
assert.deepEqual(first.changedRelativePaths, SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.relativePath));
|
|
31
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
32
|
+
assert.deepEqual(Object.keys(catalog.tools).sort(), SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.toolId).sort());
|
|
33
|
+
for (const entry of SYSTEM_TOOL_ARTIFACTS) {
|
|
34
|
+
const tool = catalog.tools[entry.toolId];
|
|
35
|
+
assert.equal(tool.origin, 'system');
|
|
36
|
+
assert.equal(tool.owner, 'amalgm');
|
|
37
|
+
assert.deepEqual(tool.source, { transport: 'amalgm-runtime', service: 'mcp' },
|
|
38
|
+
'the cloud artifact has no machine-local port');
|
|
39
|
+
assert.equal(path.basename(artifactPathForTool(tool, { toolboxDir })), path.basename(entry.relativePath));
|
|
40
|
+
assert.equal(isSystemToolId(tool.id), true);
|
|
41
|
+
}
|
|
42
|
+
assert.deepEqual(materializeSystemToolArtifacts({ toolboxDir }).changedRelativePaths, [],
|
|
43
|
+
'rerunning login does not rewrite an unchanged built-in tool');
|
|
44
|
+
} finally {
|
|
45
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a user tool is a sibling artifact and remains independent of built-in ownership', () => {
|
|
50
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
51
|
+
try {
|
|
52
|
+
materializeSystemToolArtifacts({ toolboxDir });
|
|
53
|
+
const tool = { id: 'aayush.stock-price', name: 'Stock Price', origin: 'user', owner: 'aayush' };
|
|
54
|
+
const actions = [{ id: 'aayush.stock-price.lookup', toolId: tool.id, name: 'lookup' }];
|
|
55
|
+
const target = writeToolArtifact({ tool, actions }, { toolboxDir });
|
|
56
|
+
assert.equal(path.basename(target), 'tool-aayush.stock-price.json');
|
|
57
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
58
|
+
assert.equal(catalog.tools[tool.id].origin, 'user');
|
|
59
|
+
assert.equal(catalog.toolActions[actions[0].id].toolId, tool.id);
|
|
60
|
+
assert.equal(isSystemToolId(tool.id), false);
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a legacy aggregate catalog becomes user tool artifacts before leaving the registered toolbox tree', () => {
|
|
67
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
68
|
+
try {
|
|
69
|
+
fs.writeFileSync(path.join(toolboxDir, 'toolbox.json'), JSON.stringify({
|
|
70
|
+
version: 1,
|
|
71
|
+
tools: {
|
|
72
|
+
'user.example': { id: 'user.example', name: 'Example', origin: 'user', owner: 'user' },
|
|
73
|
+
},
|
|
74
|
+
toolActions: {
|
|
75
|
+
'user.example.run': { id: 'user.example.run', toolId: 'user.example', name: 'run' },
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
const migrated = migrateLegacyToolboxCatalog({ toolboxDir });
|
|
79
|
+
assert.equal(migrated.migrated, true);
|
|
80
|
+
assert.equal(fs.existsSync(path.join(toolboxDir, 'toolbox.json')), false);
|
|
81
|
+
assert.equal(fs.existsSync(path.join(home, 'toolbox.legacy.json')), true);
|
|
82
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
83
|
+
assert.equal(catalog.tools['user.example'].name, 'Example');
|
|
84
|
+
assert.equal(catalog.toolActions['user.example.run'].name, 'run');
|
|
85
|
+
} finally {
|
|
86
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The full automatic rail for a user-created tool:
|
|
5
|
+
*
|
|
6
|
+
* toolbox API write → one sibling artifact → existing toolbox watcher
|
|
7
|
+
* → UUID registry → durable entity-cloud outbox.
|
|
8
|
+
*
|
|
9
|
+
* This must run in its own test file because AMALGM_DIR is intentionally read
|
|
10
|
+
* once by the runtime composition root.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const Database = require('better-sqlite3');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const test = require('node:test');
|
|
19
|
+
|
|
20
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-toolbox-cloud-'));
|
|
21
|
+
process.env.AMALGM_DIR = home;
|
|
22
|
+
|
|
23
|
+
const { createRegistrationService } = require('../registration/service');
|
|
24
|
+
const { deleteTool, updateTool, upsertTool } = require('../toolbox/store');
|
|
25
|
+
|
|
26
|
+
let service = null;
|
|
27
|
+
|
|
28
|
+
test.after(() => {
|
|
29
|
+
service?.close();
|
|
30
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function waitFor(read, message) {
|
|
34
|
+
const deadline = Date.now() + 10_000;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
const value = read();
|
|
37
|
+
if (value) return value;
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
39
|
+
}
|
|
40
|
+
assert.fail(message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('a user tool becomes a cloud-backed entity through the existing toolbox watcher', async () => {
|
|
44
|
+
service = createRegistrationService({
|
|
45
|
+
dir: path.join(home, 'entities'),
|
|
46
|
+
homeDir: home,
|
|
47
|
+
cloudUserId: '11111111-1111-4111-8111-111111111111',
|
|
48
|
+
cloudDeviceId: 'toolbox-cloud-proof-device',
|
|
49
|
+
});
|
|
50
|
+
const userHome = await service.bootstrapUserHome();
|
|
51
|
+
const bootstrapRecords = await service.exactUserHomeCloudRecords();
|
|
52
|
+
const bootstrap = service.entityCloud.bootstrap(bootstrapRecords);
|
|
53
|
+
service.entityCloud.install({
|
|
54
|
+
registry: service.registration.registry,
|
|
55
|
+
resourceId: bootstrap.resourceId,
|
|
56
|
+
authorityEpoch: 1,
|
|
57
|
+
headVersion: 0,
|
|
58
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
59
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
60
|
+
bootstrapRecords,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.throws(() => updateTool({ id: 'browser', name: 'Changed Browser' }),
|
|
64
|
+
/System tools cannot be modified/);
|
|
65
|
+
assert.throws(() => deleteTool('browser'), /System tools cannot be deleted/);
|
|
66
|
+
|
|
67
|
+
upsertTool({
|
|
68
|
+
id: 'proof.stock',
|
|
69
|
+
name: 'Proof Stock',
|
|
70
|
+
type: 'cli',
|
|
71
|
+
origin: 'user',
|
|
72
|
+
owner: 'user',
|
|
73
|
+
source: { command: 'echo' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const toolbox = userHome.roots.find((entry) => entry.key === 'toolbox').record;
|
|
77
|
+
const artifact = await waitFor(
|
|
78
|
+
() => service.registration.registry.activeAt(toolbox.uuid, 'tool-proof.stock.json'),
|
|
79
|
+
'the watcher did not register the user tool artifact',
|
|
80
|
+
);
|
|
81
|
+
assert.ok(fs.existsSync(path.join(home, 'toolbox', 'tool-proof.stock.json')));
|
|
82
|
+
|
|
83
|
+
const db = new Database(path.join(home, 'entities', 'identity.db'), { readonly: true });
|
|
84
|
+
try {
|
|
85
|
+
const rows = db.prepare('SELECT operation_json FROM entity_cloud_mutations').all();
|
|
86
|
+
assert.equal(rows.some((row) => JSON.parse(row.operation_json).record?.uuid === artifact.uuid), true,
|
|
87
|
+
'registration wrote one durable cloud delivery intent for the tool entity');
|
|
88
|
+
} finally {
|
|
89
|
+
db.close();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toolbox filesystem artifacts.
|
|
5
|
+
*
|
|
6
|
+
* Purpose: one tool must have one cloud-addressable file, so a user-created
|
|
7
|
+
* tool can travel between machines without ever granting it the authority of
|
|
8
|
+
* a built-in Amalgm tool.
|
|
9
|
+
*
|
|
10
|
+
* Axioms:
|
|
11
|
+
* - A tool definition and every action belonging to it live in one artifact.
|
|
12
|
+
* - Built-in artifact paths are the exact managed nodes declared by layout.
|
|
13
|
+
* - User artifacts are siblings, never children of a managed tool path.
|
|
14
|
+
* - Portable artifacts never carry a machine-local address or watcher fact.
|
|
15
|
+
* - The SQLite toolbox tables and toolbox.index.json are local derived caches;
|
|
16
|
+
* artifact files are the portable representation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const { AMALGM_DIR } = require('../config');
|
|
23
|
+
const { SYSTEM_TOOL_ARTIFACTS } = require('../lib/layout');
|
|
24
|
+
const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
|
|
25
|
+
|
|
26
|
+
const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
|
|
27
|
+
const TOOLBOX_DIR = path.join(AMALGM_DIR, 'toolbox');
|
|
28
|
+
const TOOLBOX_INDEX_FILE = path.join(AMALGM_DIR, 'toolbox.index.json');
|
|
29
|
+
const LEGACY_TOOLBOX_FILE_NAME = 'toolbox.json';
|
|
30
|
+
|
|
31
|
+
function safeToolId(value) {
|
|
32
|
+
return String(value || '')
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9_.-]+/g, '.')
|
|
36
|
+
.replace(/\.+/g, '.')
|
|
37
|
+
.replace(/^[._-]+|[._-]+$/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function systemArtifactForTool(toolId) {
|
|
41
|
+
return SYSTEM_TOOL_ARTIFACTS.find((entry) => entry.toolId === toolId) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isSystemToolId(toolId) {
|
|
45
|
+
return !!systemArtifactForTool(toolId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function userArtifactFileName(toolId) {
|
|
49
|
+
const id = safeToolId(toolId);
|
|
50
|
+
if (!id) throw new Error('tool artifact requires a stable tool id');
|
|
51
|
+
return `tool-${id}.json`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolboxDirFor(options = {}) {
|
|
55
|
+
return path.resolve(options.toolboxDir || TOOLBOX_DIR);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function indexFileFor(toolboxDir) {
|
|
59
|
+
return path.join(path.dirname(toolboxDir), 'toolbox.index.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function artifactPathForTool(tool, options = {}) {
|
|
63
|
+
const toolboxDir = toolboxDirFor(options);
|
|
64
|
+
const toolId = safeToolId(tool?.id || tool);
|
|
65
|
+
const system = systemArtifactForTool(toolId);
|
|
66
|
+
if (system) return path.join(toolboxDir, path.basename(system.relativePath));
|
|
67
|
+
return path.join(toolboxDir, userArtifactFileName(toolId));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function artifactDocument({ tool, actions = [] }) {
|
|
71
|
+
if (!tool || typeof tool !== 'object' || !safeToolId(tool.id)) {
|
|
72
|
+
throw new Error('tool artifact requires a tool with an id');
|
|
73
|
+
}
|
|
74
|
+
const toolId = safeToolId(tool.id);
|
|
75
|
+
const normalizedActions = Array.isArray(actions)
|
|
76
|
+
? actions.filter((action) => action && action.toolId === toolId)
|
|
77
|
+
: [];
|
|
78
|
+
const portableTool = isSystemToolId(toolId)
|
|
79
|
+
? {
|
|
80
|
+
...tool,
|
|
81
|
+
id: toolId,
|
|
82
|
+
// `127.0.0.1:<port>` is a device binding, not tool identity. The
|
|
83
|
+
// system catalog resolves the current runtime endpoint when serving
|
|
84
|
+
// the local tool surface.
|
|
85
|
+
source: { transport: 'amalgm-runtime', service: 'mcp' },
|
|
86
|
+
}
|
|
87
|
+
: { ...tool, id: toolId };
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: TOOL_ARTIFACT_SCHEMA_VERSION,
|
|
90
|
+
kind: 'amalgm-tool',
|
|
91
|
+
tool: portableTool,
|
|
92
|
+
actions: normalizedActions
|
|
93
|
+
.map((action) => ({ ...action, toolId }))
|
|
94
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validDocument(value) {
|
|
99
|
+
return !!value
|
|
100
|
+
&& value.schemaVersion === TOOL_ARTIFACT_SCHEMA_VERSION
|
|
101
|
+
&& value.kind === 'amalgm-tool'
|
|
102
|
+
&& value.tool
|
|
103
|
+
&& typeof value.tool === 'object'
|
|
104
|
+
&& !!safeToolId(value.tool.id)
|
|
105
|
+
&& Array.isArray(value.actions)
|
|
106
|
+
&& value.actions.every((action) => action && action.toolId === safeToolId(value.tool.id));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeToolArtifact({ tool, actions }, options = {}) {
|
|
110
|
+
const target = artifactPathForTool(tool, options);
|
|
111
|
+
ensureDir(path.dirname(target));
|
|
112
|
+
const document = artifactDocument({ tool, actions });
|
|
113
|
+
const current = readJson(target, null);
|
|
114
|
+
if (JSON.stringify(current) !== JSON.stringify(document)) writeJsonAtomic(target, document);
|
|
115
|
+
return target;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function removeUserToolArtifact(toolId, options = {}) {
|
|
119
|
+
if (isSystemToolId(safeToolId(toolId))) {
|
|
120
|
+
throw new Error('System tool artifacts cannot be removed');
|
|
121
|
+
}
|
|
122
|
+
const target = artifactPathForTool(toolId, options);
|
|
123
|
+
try { fs.unlinkSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
124
|
+
return target;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readToolArtifacts(options = {}) {
|
|
128
|
+
const toolboxDir = toolboxDirFor(options);
|
|
129
|
+
const tools = {};
|
|
130
|
+
const toolActions = {};
|
|
131
|
+
let entries = [];
|
|
132
|
+
try { entries = fs.readdirSync(toolboxDir, { withFileTypes: true }); } catch { return { tools, toolActions }; }
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name === LEGACY_TOOLBOX_FILE_NAME) continue;
|
|
135
|
+
const document = readJson(path.join(toolboxDir, entry.name), null);
|
|
136
|
+
if (!validDocument(document)) continue;
|
|
137
|
+
const toolId = safeToolId(document.tool.id);
|
|
138
|
+
const expected = artifactPathForTool(toolId, { toolboxDir });
|
|
139
|
+
if (path.resolve(expected) !== path.resolve(path.join(toolboxDir, entry.name))) continue;
|
|
140
|
+
tools[toolId] = { ...document.tool, id: toolId };
|
|
141
|
+
for (const action of document.actions) toolActions[action.id] = { ...action, toolId };
|
|
142
|
+
}
|
|
143
|
+
return { tools, toolActions };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writeToolboxIndex(toolbox, options = {}) {
|
|
147
|
+
const toolboxDir = toolboxDirFor(options);
|
|
148
|
+
const target = options.indexFile || indexFileFor(toolboxDir);
|
|
149
|
+
const document = {
|
|
150
|
+
version: 1,
|
|
151
|
+
tools: toolbox?.tools || {},
|
|
152
|
+
toolActions: toolbox?.toolActions || {},
|
|
153
|
+
};
|
|
154
|
+
const current = readJson(target, null);
|
|
155
|
+
if (JSON.stringify(current) !== JSON.stringify(document)) writeJsonAtomic(target, document);
|
|
156
|
+
return target;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function materializeSystemToolArtifacts(options = {}) {
|
|
160
|
+
const toolboxDir = toolboxDirFor(options);
|
|
161
|
+
const { buildSystemToolboxCatalog } = require('./system-catalog');
|
|
162
|
+
const catalog = buildSystemToolboxCatalog();
|
|
163
|
+
const changedRelativePaths = [];
|
|
164
|
+
for (const system of SYSTEM_TOOL_ARTIFACTS) {
|
|
165
|
+
const tool = catalog.tools[system.toolId];
|
|
166
|
+
if (!tool) throw new Error(`built-in toolbox definition missing for ${system.toolId}`);
|
|
167
|
+
const target = artifactPathForTool(tool, { toolboxDir });
|
|
168
|
+
const document = artifactDocument({
|
|
169
|
+
tool,
|
|
170
|
+
actions: Object.values(catalog.toolActions).filter((action) => action.toolId === tool.id),
|
|
171
|
+
});
|
|
172
|
+
if (JSON.stringify(readJson(target, null)) !== JSON.stringify(document)) {
|
|
173
|
+
ensureDir(path.dirname(target));
|
|
174
|
+
writeJsonAtomic(target, document);
|
|
175
|
+
changedRelativePaths.push(system.relativePath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { catalog, changedRelativePaths };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Move the old aggregate catalog out of the registered toolbox tree only
|
|
182
|
+
* after all its user records have a per-tool replacement. The new index is a
|
|
183
|
+
* local cache next to, not inside, the cloud-backed toolbox folder. */
|
|
184
|
+
function migrateLegacyToolboxCatalog(options = {}) {
|
|
185
|
+
const toolboxDir = toolboxDirFor(options);
|
|
186
|
+
const legacy = path.join(toolboxDir, LEGACY_TOOLBOX_FILE_NAME);
|
|
187
|
+
if (!fs.existsSync(legacy)) return { migrated: false, artifactPaths: [] };
|
|
188
|
+
const parsed = readJson(legacy, null);
|
|
189
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.tools || typeof parsed.tools !== 'object') {
|
|
190
|
+
return { migrated: false, artifactPaths: [] };
|
|
191
|
+
}
|
|
192
|
+
const artifactPaths = [];
|
|
193
|
+
for (const [rawId, rawTool] of Object.entries(parsed.tools)) {
|
|
194
|
+
const toolId = safeToolId(rawTool?.id || rawId);
|
|
195
|
+
if (!toolId || isSystemToolId(toolId) || !rawTool || typeof rawTool !== 'object') continue;
|
|
196
|
+
const actions = Object.values(parsed.toolActions || {})
|
|
197
|
+
.filter((action) => action && action.toolId === toolId);
|
|
198
|
+
artifactPaths.push(writeToolArtifact({ tool: { ...rawTool, id: toolId }, actions }, { toolboxDir }));
|
|
199
|
+
}
|
|
200
|
+
writeToolboxIndex({ tools: parsed.tools || {}, toolActions: parsed.toolActions || {} }, { toolboxDir });
|
|
201
|
+
let migrated = path.join(path.dirname(toolboxDir), 'toolbox.legacy.json');
|
|
202
|
+
if (fs.existsSync(migrated)) migrated = path.join(path.dirname(toolboxDir), `toolbox.legacy-${Date.now()}.json`);
|
|
203
|
+
fs.renameSync(legacy, migrated);
|
|
204
|
+
return { migrated: true, artifactPaths };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
LEGACY_TOOLBOX_FILE_NAME,
|
|
209
|
+
SYSTEM_TOOL_ARTIFACTS,
|
|
210
|
+
TOOL_ARTIFACT_SCHEMA_VERSION,
|
|
211
|
+
TOOLBOX_DIR,
|
|
212
|
+
TOOLBOX_INDEX_FILE,
|
|
213
|
+
artifactDocument,
|
|
214
|
+
artifactPathForTool,
|
|
215
|
+
indexFileFor,
|
|
216
|
+
isSystemToolId,
|
|
217
|
+
materializeSystemToolArtifacts,
|
|
218
|
+
migrateLegacyToolboxCatalog,
|
|
219
|
+
readToolArtifacts,
|
|
220
|
+
removeUserToolArtifact,
|
|
221
|
+
safeToolId,
|
|
222
|
+
userArtifactFileName,
|
|
223
|
+
writeToolArtifact,
|
|
224
|
+
writeToolboxIndex,
|
|
225
|
+
};
|