amalgm 0.1.257 → 0.1.259

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.
Files changed (26) hide show
  1. package/lib/cli.js +207 -3
  2. package/lib/layout.js +30 -9
  3. package/lib/shared-realtime-tunnel.js +12 -14
  4. package/package.json +2 -2
  5. package/runtime/scripts/amalgm-mcp/lib/layout.js +32 -9
  6. package/runtime/scripts/amalgm-mcp/observer/index.js +65 -4
  7. package/runtime/scripts/amalgm-mcp/observer/scan.js +60 -1
  8. package/runtime/scripts/amalgm-mcp/observer/store.js +43 -8
  9. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -32
  10. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +33 -6
  11. package/runtime/scripts/amalgm-mcp/registration/index.js +21 -0
  12. package/runtime/scripts/amalgm-mcp/registration/service.js +117 -11
  13. package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
  14. package/runtime/scripts/amalgm-mcp/repocard/capture.js +16 -8
  15. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +6 -0
  16. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +67 -8
  17. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +3 -3
  18. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +85 -0
  19. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +95 -3
  20. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +21 -0
  21. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +3 -6
  22. package/runtime/scripts/amalgm-mcp/tests/toolbox-artifacts.test.js +88 -0
  23. package/runtime/scripts/amalgm-mcp/tests/toolbox-cloud-registration.test.js +91 -0
  24. package/runtime/scripts/amalgm-mcp/toolbox/artifacts.js +225 -0
  25. package/runtime/scripts/amalgm-mcp/toolbox/store.js +59 -10
  26. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +14 -5
@@ -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 persisted records', () => {
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, 'Persisted Browser');
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
+ };
@@ -12,14 +12,25 @@
12
12
  */
13
13
 
14
14
  const path = require('path');
15
- const { AMALGM_DIR } = require('../config');
16
- const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
15
+ const { readJson } = require('../lib/storage');
17
16
  const { openLocalDb } = require('../state/db');
18
17
  const { insertStateEvent, publishStateEvent } = require('../state/events');
18
+ const {
19
+ TOOLBOX_DIR,
20
+ TOOLBOX_INDEX_FILE,
21
+ isSystemToolId,
22
+ readToolArtifacts,
23
+ removeUserToolArtifact,
24
+ writeToolArtifact,
25
+ writeToolboxIndex,
26
+ } = require('./artifacts');
19
27
 
20
28
  const TOOLBOX_VERSION = 1;
21
- const TOOLBOX_DIR = path.join(AMALGM_DIR, 'toolbox');
22
- const TOOLBOX_FILE = path.join(TOOLBOX_DIR, 'toolbox.json');
29
+ // `toolbox.index.json` is a local cache, deliberately outside the registered
30
+ // toolbox folder. Individual `toolbox/*.json` artifacts are the portable
31
+ // source of truth; `toolbox/toolbox.json` remains only a legacy import path.
32
+ const TOOLBOX_FILE = TOOLBOX_INDEX_FILE;
33
+ const LEGACY_TOOLBOX_FILE = path.join(TOOLBOX_DIR, 'toolbox.json');
23
34
 
24
35
  const TOOL_TYPES = new Set(['mcp', 'api', 'cli']);
25
36
  const TOOL_ORIGINS = new Set(['system', 'user', 'catalog']);
@@ -528,19 +539,32 @@ function writeToolboxToDb(toolbox, options = {}) {
528
539
  }
529
540
 
530
541
  function exportToolboxJson(toolbox) {
531
- ensureDir(TOOLBOX_DIR);
532
- writeJsonAtomic(TOOLBOX_FILE, normalizeToolbox(toolbox));
542
+ writeToolboxIndex(normalizeToolbox(toolbox), { toolboxDir: TOOLBOX_DIR });
533
543
  }
534
544
 
535
545
  function migrateLegacyToolboxFileIfNeeded() {
536
546
  const db = openLocalDb();
537
547
  if (toolboxRowCount(db) > 0) return;
538
548
 
539
- const legacyToolbox = dropInheritedHarnessTools(normalizeToolbox(readJson(TOOLBOX_FILE, emptyToolbox())));
549
+ const legacyToolbox = dropInheritedHarnessTools(normalizeToolbox(readJson(LEGACY_TOOLBOX_FILE, emptyToolbox())));
540
550
  if (Object.keys(legacyToolbox.tools).length === 0 && Object.keys(legacyToolbox.toolActions).length === 0) return;
541
551
  writeToolboxToDb(legacyToolbox, { replace: true });
542
552
  }
543
553
 
554
+ function writeUserToolArtifacts(toolbox) {
555
+ for (const tool of Object.values(toolbox.tools || {})) {
556
+ if (!tool || isSystemToolId(tool.id) || tool.origin === 'system') continue;
557
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
558
+ }
559
+ }
560
+
561
+ function cacheToolboxFromArtifacts(toolbox) {
562
+ const normalized = normalizeToolbox(toolbox);
563
+ writeToolboxToDb(normalized, { replace: true });
564
+ exportToolboxJson(normalized);
565
+ return normalized;
566
+ }
567
+
544
568
  function publishCommittedEvents(events) {
545
569
  for (const event of events) publishStateEvent(event);
546
570
  }
@@ -569,14 +593,18 @@ function insertToolActionEvent(db, op, action, options = {}) {
569
593
 
570
594
  function readToolbox() {
571
595
  migrateLegacyToolboxFileIfNeeded();
572
- const toolbox = readToolboxFromDb();
573
- const removedLegacyAggregate = dropLegacyAggregateAmalgmTool(toolbox);
574
- if (removedLegacyAggregate) writeToolbox(toolbox);
596
+ // An existing device may still have user records only in its local cache.
597
+ // Give each one a portable artifact before treating artifacts as canonical.
598
+ const cached = readToolboxFromDb();
599
+ dropLegacyAggregateAmalgmTool(cached);
600
+ writeUserToolArtifacts(cached);
601
+ const toolbox = cacheToolboxFromArtifacts(readToolArtifacts({ toolboxDir: TOOLBOX_DIR }));
575
602
  return toolbox;
576
603
  }
577
604
 
578
605
  function writeToolbox(toolbox) {
579
606
  const normalized = writeToolboxToDb(toolbox, { replace: true });
607
+ writeUserToolArtifacts(normalized);
580
608
  exportToolboxJson(normalized);
581
609
  return normalized;
582
610
  }
@@ -614,6 +642,9 @@ function upsertTool(input) {
614
642
  const existing = input?.id ? toolbox.tools[sanitizeId(input.id)] : null;
615
643
  const existingActionIds = new Set(Object.keys(toolbox.toolActions));
616
644
  const tool = normalizeTool(input, existing);
645
+ if (isSystemToolId(tool.id) || tool.origin === 'system') {
646
+ throw new Error('System tools are managed by Amalgm and cannot be created or replaced here');
647
+ }
617
648
  toolbox.tools[tool.id] = tool;
618
649
 
619
650
  const actions = [];
@@ -659,6 +690,8 @@ function upsertTool(input) {
659
690
  }
660
691
  return inserted;
661
692
  })();
693
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
694
+ exportToolboxJson(toolbox);
662
695
  publishCommittedEvents(events);
663
696
  return { tool, actions };
664
697
  }
@@ -735,6 +768,8 @@ function updateTool(input) {
735
768
  source: 'toolbox:update-tool',
736
769
  })];
737
770
  })();
771
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
772
+ exportToolboxJson(toolbox);
738
773
  publishCommittedEvents(events);
739
774
  return { tool, actions: toolActionsForTool(toolbox, tool.id) };
740
775
  }
@@ -744,6 +779,7 @@ function upsertToolAction(input) {
744
779
  const toolId = sanitizeId(input?.toolId || input?.providerId || input?.tool);
745
780
  const tool = toolbox.tools[toolId];
746
781
  if (!tool) throw new Error(`Unknown tool: ${toolId}`);
782
+ if (tool.origin === 'system' || isSystemToolId(tool.id)) throw new Error('System tool actions cannot be modified');
747
783
  const existing = input?.id ? toolbox.toolActions[sanitizeId(input.id)] : null;
748
784
  const action = normalizeAction(input, tool, existing);
749
785
  toolbox.toolActions[action.id] = action;
@@ -755,6 +791,8 @@ function upsertToolAction(input) {
755
791
  source: 'toolbox:upsert-action',
756
792
  })];
757
793
  })();
794
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
795
+ exportToolboxJson(toolbox);
758
796
  publishCommittedEvents(events);
759
797
  return action;
760
798
  }
@@ -805,6 +843,8 @@ function updateToolAction(input) {
805
843
  source: 'toolbox:update-action',
806
844
  })];
807
845
  })();
846
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
847
+ exportToolboxJson(toolbox);
808
848
  publishCommittedEvents(events);
809
849
  return action;
810
850
  }
@@ -830,6 +870,12 @@ function deleteTool(id, options = {}) {
830
870
  }));
831
871
  return inserted;
832
872
  })();
873
+ removeUserToolArtifact(toolId, { toolboxDir: TOOLBOX_DIR });
874
+ exportToolboxJson({
875
+ ...toolbox,
876
+ tools: Object.fromEntries(Object.entries(toolbox.tools).filter(([id]) => id !== toolId)),
877
+ toolActions: Object.fromEntries(Object.entries(toolbox.toolActions).filter(([, action]) => action.toolId !== toolId)),
878
+ });
833
879
  publishCommittedEvents(events);
834
880
  scrubToolFromApps(toolId);
835
881
  return { ok: true, deleted: true };
@@ -867,6 +913,9 @@ function deleteToolAction(id, options = {}) {
867
913
  source: 'toolbox:delete-action',
868
914
  })];
869
915
  })();
916
+ delete toolbox.toolActions[actionId];
917
+ writeToolArtifact({ tool, actions: toolActionsForTool(toolbox, tool.id) }, { toolboxDir: TOOLBOX_DIR });
918
+ exportToolboxJson(toolbox);
870
919
  publishCommittedEvents(events);
871
920
  return { ok: true, deleted: true };
872
921
  }
@@ -3,9 +3,10 @@
3
3
  /**
4
4
  * Virtual first-party Toolbox catalog entries.
5
5
  *
6
- * These records make Amalgm's default capabilities look like normal Toolbox
7
- * tools/actions to UI and control-plane consumers without changing the legacy
8
- * MCP tool names or writing additional system rows to local storage.
6
+ * These records are the canonical definitions materialized as one exact
7
+ * `toolbox/amalgm-*.json` artifact per built-in tool. The overlay keeps the
8
+ * legacy MCP surface stable and refuses a stale local cache from overriding a
9
+ * product-managed definition.
9
10
  */
10
11
 
11
12
  const { PORT } = require('../config');
@@ -29,6 +30,10 @@ function coreToolGroups() {
29
30
  return require('../server/core-tools').CORE_TOOL_GROUPS || [];
30
31
  }
31
32
 
33
+ function systemToolIds() {
34
+ return new Set(coreToolGroups().map((group) => group.id));
35
+ }
36
+
32
37
  function systemToolForGroup(group) {
33
38
  return {
34
39
  id: group.id,
@@ -95,12 +100,15 @@ function buildSystemToolboxCatalog() {
95
100
  function persistedCatalogWithoutLegacyAggregate(toolbox) {
96
101
  const tools = { ...(toolbox?.tools || {}) };
97
102
  const toolActions = { ...(toolbox?.toolActions || {}) };
103
+ const systemIds = systemToolIds();
98
104
  delete tools.amalgm;
105
+ for (const systemId of systemIds) delete tools[systemId];
99
106
  for (const toolId of LEGACY_FIRST_PARTY_TOOL_IDS) delete tools[toolId];
100
107
  for (const [actionId, action] of Object.entries(toolActions)) {
101
108
  const legacyGroupId = actionId.split('.').slice(0, 2).join('.');
102
109
  if (
103
110
  action?.toolId === 'amalgm'
111
+ || systemIds.has(action?.toolId)
104
112
  || LEGACY_FIRST_PARTY_TOOL_IDS.has(action?.toolId)
105
113
  || LEGACY_FIRST_PARTY_TOOL_IDS.has(legacyGroupId)
106
114
  ) {
@@ -116,18 +124,19 @@ function mergeSystemToolboxCatalog(toolbox) {
116
124
  return {
117
125
  ...toolbox,
118
126
  tools: {
119
- ...catalog.tools,
120
127
  ...persisted.tools,
128
+ ...catalog.tools,
121
129
  },
122
130
  toolActions: {
123
- ...catalog.toolActions,
124
131
  ...persisted.toolActions,
132
+ ...catalog.toolActions,
125
133
  },
126
134
  };
127
135
  }
128
136
 
129
137
  module.exports = {
130
138
  buildSystemToolboxCatalog,
139
+ systemToolIds,
131
140
  mergeSystemToolboxCatalog,
132
141
  persistedCatalogWithoutLegacyAggregate,
133
142
  };