amalgm 0.1.141 → 0.1.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.141",
3
+ "version": "0.1.142",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -25,6 +25,16 @@ function cleanString(value) {
25
25
  return typeof value === 'string' ? value.trim() : '';
26
26
  }
27
27
 
28
+ function uniqueToolIds(value) {
29
+ if (!Array.isArray(value)) return [];
30
+ const out = [];
31
+ for (const item of value) {
32
+ const id = cleanString(item);
33
+ if (id && !out.includes(id)) out.push(id);
34
+ }
35
+ return out;
36
+ }
37
+
28
38
  const NODE_INSTALL_COMMANDS = [
29
39
  ['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
30
40
  ['yarn.lock', 'yarn install --frozen-lockfile'],
@@ -84,6 +94,9 @@ function exportAppEntry(appId, options = {}) {
84
94
  autostart: app.autostart !== false,
85
95
  keepAlive: app.keepAlive !== false,
86
96
  },
97
+ // Tools that belong to this app; ids are stable across install, so the
98
+ // recipient's registration points at the same tool ids.
99
+ toolIds: uniqueToolIds(app.toolIds),
87
100
  files: packed.files,
88
101
  totalBytes: packed.totalBytes,
89
102
  skipped: packed.skipped,
@@ -133,8 +146,11 @@ function materializeAppEntry(entry, options = {}) {
133
146
  cwd: targetDir,
134
147
  build_command: buildCommand,
135
148
  start_command: entry.app.startCommand,
136
- autostart: entry.app.autostart !== false,
137
- keep_alive: entry.app.keepAlive !== false,
149
+ tool_ids: uniqueToolIds(entry.toolIds),
150
+ // A downloaded app is a fresh registration: it should come up now and
151
+ // keep coming back with the recipient runtime.
152
+ autostart: true,
153
+ keep_alive: true,
138
154
  };
139
155
  }
140
156
 
@@ -7,6 +7,7 @@ const { normalizeAgentConfig } = require('../agent-config/schema');
7
7
  const { createAgent, getAllAgentsWithBuiltins, resolveAgent } = require('../agents/store');
8
8
  const { hydrateConfigSkills } = require('../skills/store');
9
9
  const { defaultToolboxService } = require('../toolbox/service');
10
+ const { getApp } = require('../apps/store');
10
11
  const { exportAutomationEntry, installAutomationEntry } = require('./automation-entries');
11
12
  const { exportAppEntry, installAppEntry } = require('./app-entries');
12
13
  const { annotateAppBoundPaths, rewriteAppBoundPaths } = require('./tool-bindings');
@@ -298,6 +299,20 @@ function buildFlatAgentBundleGraph(bundle) {
298
299
  });
299
300
  }
300
301
  }
302
+ // Tools that belong to an app render nested under it in the share sidebar;
303
+ // the canonical tool record still appears exactly once.
304
+ for (const entry of bundle.apps || []) {
305
+ const currentAppId = appEntryId(entry);
306
+ if (!currentAppId) continue;
307
+ for (const toolId of uniqueStrings(entry.toolIds)) {
308
+ includedToolIds.add(toolId);
309
+ pushGraphEdge(edges, {
310
+ from: graphElementId('app', currentAppId),
311
+ to: graphElementId('tool', toolId),
312
+ kind: 'uses-tool',
313
+ });
314
+ }
315
+ }
301
316
  for (const entry of bundle.agents || []) {
302
317
  const currentAgentId = agentEntryId(entry);
303
318
  if (!currentAgentId) continue;
@@ -605,17 +620,39 @@ function createBundle(input = {}, options = {}) {
605
620
  for (const ref of exported.missingRefs) addUnique(requires.missingTools, ref);
606
621
  }
607
622
 
623
+ // Tool → app closure: an app-owned tool only works next to its app, so any
624
+ // requested tool whose record points at an app pulls that app in as a
625
+ // dependency (not a head). Stale pointers degrade to a warning.
626
+ const appLinkWarnings = [];
627
+ const dependencyAppIds = [];
628
+ const catalog = defaultToolboxService.readCatalog();
629
+ for (const selectedId of uniqueStrings([...loadoutToolIds, ...toolIds])) {
630
+ const selectedAction = catalog.toolActions?.[selectedId];
631
+ const tool = catalog.tools?.[selectedId]
632
+ || (selectedAction ? catalog.tools?.[selectedAction.toolId] : null);
633
+ const owningAppId = typeof tool?.appId === 'string' ? tool.appId.trim() : '';
634
+ if (!owningAppId || appIds.includes(owningAppId) || dependencyAppIds.includes(owningAppId)) continue;
635
+ if (!getApp(owningAppId)) {
636
+ appLinkWarnings.push(`Tool ${tool.id} belongs to app ${owningAppId}, which no longer exists; the app was not bundled.`);
637
+ continue;
638
+ }
639
+ dependencyAppIds.push(owningAppId);
640
+ }
641
+
608
642
  const apps = [];
609
643
  const appCwdBySourceId = new Map();
610
644
  let skippedAppFiles = 0;
611
- for (const appId of appIds) {
645
+ const appToolIds = [];
646
+ for (const appId of [...appIds, ...dependencyAppIds]) {
612
647
  const exported = exportAppEntry(appId);
613
648
  apps.push(exported.entry);
614
649
  appCwdBySourceId.set(exported.entry.sourceAppId, exported.sourceCwd);
615
650
  skippedAppFiles += exported.skipped.length;
651
+ // App → tool closure: tools that belong to a bundled app travel with it.
652
+ appToolIds.push(...(exported.entry.toolIds || []));
616
653
  }
617
654
 
618
- const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...toolIds], requires);
655
+ const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...appToolIds, ...toolIds], requires);
619
656
 
620
657
  // Tool files that live inside a shared app travel with that app: record
621
658
  // app-relative bindings so install rewrites them to the recipient's copy.
@@ -636,14 +673,20 @@ function createBundle(input = {}, options = {}) {
636
673
  const sharedTools = sharedToolIds.map((toolId) => tools.find((tool) => tool.id === toolId));
637
674
  const skills = collectSkillRecords(agents);
638
675
  const hooks = countHooks(agents);
639
- const warnings = bundleWarnings({
640
- hooks,
641
- tools: tools.length,
642
- automations: automations.length,
643
- apps: apps.length,
644
- skippedAppFiles,
645
- });
646
- const { title, description } = bundleTitleAndDescription(agents, automations, apps, sharedTools);
676
+ const warnings = [
677
+ ...bundleWarnings({
678
+ hooks,
679
+ tools: tools.length,
680
+ automations: automations.length,
681
+ apps: apps.length,
682
+ skippedAppFiles,
683
+ }),
684
+ ...appLinkWarnings,
685
+ ];
686
+ // Title comes from what the user deliberately shared: dependency apps
687
+ // pulled in by an app-owned tool must not name the bundle.
688
+ const headApps = apps.filter((entry) => appIds.includes(entry.sourceAppId));
689
+ const { title, description } = bundleTitleAndDescription(agents, automations, headApps, sharedTools);
647
690
  const heads = [
648
691
  ...agentIds.map((id) => ({ type: 'agent', id })),
649
692
  ...automationIds.map((id) => ({ type: 'automation', id })),
@@ -808,7 +851,7 @@ function validateBundle(bundle) {
808
851
  return withBundleGraph(bundle);
809
852
  }
810
853
 
811
- function installTools(bundle, appDirBySourceId = new Map()) {
854
+ function installTools(bundle, appDirBySourceId = new Map(), appIdBySourceId = new Map()) {
812
855
  const warnings = [];
813
856
  const rewrite = (record) => {
814
857
  const result = rewriteAppBoundPaths(record, appDirBySourceId);
@@ -827,8 +870,20 @@ function installTools(bundle, appDirBySourceId = new Map()) {
827
870
  const installed = [];
828
871
  for (const tool of Array.isArray(bundle.tools) ? bundle.tools : []) {
829
872
  if (!tool?.id || tool.origin === 'system') continue;
873
+ const record = rewrite(tool);
874
+ // The app link travels by source app id; point it at the recipient's
875
+ // installed copy, or drop it if that app was not part of the install.
876
+ if (typeof record.appId === 'string' && record.appId) {
877
+ const installedAppId = appIdBySourceId.get(record.appId);
878
+ if (installedAppId) {
879
+ record.appId = installedAppId;
880
+ } else {
881
+ delete record.appId;
882
+ warnings.push(`Tool ${tool.id} belongs to an app that was not installed with this bundle; the app link was removed.`);
883
+ }
884
+ }
830
885
  const result = defaultToolboxService.registerTool({
831
- ...rewrite(tool),
886
+ ...record,
832
887
  origin: tool.origin === 'catalog' ? 'catalog' : 'user',
833
888
  actions: toolActionsByToolId.get(tool.id) || [],
834
889
  });
@@ -912,17 +967,19 @@ async function installBundle(input, options = {}) {
912
967
 
913
968
  const installedApps = [];
914
969
  const appDirBySourceId = new Map();
970
+ const appIdBySourceId = new Map();
915
971
  const appEntries = Array.isArray(bundle.apps) ? bundle.apps : [];
916
972
  if (appEntries.length > 0) {
917
973
  const rootDir = options.appsRootDir || defaultAppsInstallRoot();
918
974
  for (const entry of appEntries) {
919
975
  const installed = await installAppEntry(entry, { rootDir, register: options.registerApp });
920
976
  appDirBySourceId.set(appEntryId(entry), installed.cwd);
977
+ if (installed.id) appIdBySourceId.set(appEntryId(entry), installed.id);
921
978
  installedApps.push(installed);
922
979
  }
923
980
  }
924
981
 
925
- const { installed: installedTools, warnings: bindingWarnings } = installTools(bundle, appDirBySourceId);
982
+ const { installed: installedTools, warnings: bindingWarnings } = installTools(bundle, appDirBySourceId, appIdBySourceId);
926
983
  const { idMap, installedAgents } = installBundleAgents(bundle, options);
927
984
 
928
985
  const installedAutomations = [];
@@ -42,6 +42,16 @@ function genAppRef() {
42
42
  return out;
43
43
  }
44
44
 
45
+ function normalizeAppToolIds(value) {
46
+ if (!Array.isArray(value)) return [];
47
+ const out = [];
48
+ for (const item of value) {
49
+ const id = typeof item === 'string' ? item.trim() : '';
50
+ if (id && !out.includes(id)) out.push(id);
51
+ }
52
+ return out;
53
+ }
54
+
45
55
  function normalizeApp(app) {
46
56
  const appRef = app.appRef || app.app_ref || app.artifactRef || app.artifact_ref || genAppRef();
47
57
  const savedUrl = app.appUrl || app.publicUrl || '';
@@ -64,6 +74,9 @@ function normalizeApp(app) {
64
74
  dnsConnected,
65
75
  autostart: app.autostart !== false,
66
76
  keepAlive: app.keepAlive !== false,
77
+ // Toolbox tools that belong to this app: they travel with the app when it
78
+ // is shared, and the app travels with them when one of them is shared.
79
+ toolIds: normalizeAppToolIds(app.toolIds ?? app.tool_ids),
67
80
  desiredState: app.desiredState || (app.status === 'stopped' ? 'stopped' : 'running'),
68
81
  status: app.status || 'stopped',
69
82
  pid: app.pid || null,
@@ -421,6 +421,7 @@ async function registerApp(input) {
421
421
  dnsConnected: input.connect_dns !== false,
422
422
  autostart: input.autostart !== false,
423
423
  keepAlive: input.keep_alive !== false,
424
+ toolIds: Array.isArray(input.tool_ids || input.toolIds) ? (input.tool_ids || input.toolIds) : [],
424
425
  desiredState: 'running',
425
426
  status: 'registered',
426
427
  pid: null,
@@ -647,6 +647,17 @@ function ensureCheckout({ dir, repoUrl, branch, ref }) {
647
647
  }
648
648
 
649
649
  function resolveBuildRefs(request) {
650
+ const explicitEngineRef = cleanString(process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF);
651
+ const explicitUiRef = cleanString(process.env.AMALGM_DESKTOP_RELEASE_UI_REF);
652
+ if (explicitEngineRef || explicitUiRef) {
653
+ if (!explicitEngineRef || !explicitUiRef) {
654
+ throw new Error('Explicit desktop release refs require both AMALGM_DESKTOP_RELEASE_ENGINE_REF and AMALGM_DESKTOP_RELEASE_UI_REF.');
655
+ }
656
+ return {
657
+ engineRef: explicitEngineRef,
658
+ uiRef: explicitUiRef,
659
+ };
660
+ }
650
661
  const branch = request.branch;
651
662
  return {
652
663
  engineRef: request.repository === ENGINE_REPO_FULL_NAME ? request.after : branch,
@@ -224,7 +224,12 @@ test('materializeAppEntry bootstraps node dependencies for imported apps without
224
224
  test('mixed bundle carries automations and apps with heads and installs everything', async () => {
225
225
  const dir = makeTempDir('bundle-mixed-app-');
226
226
  writeSampleApp(dir);
227
- const app = registerSampleApp(dir);
227
+ const app = registerSampleApp(dir, {
228
+ autostart: false,
229
+ keepAlive: false,
230
+ desiredState: 'stopped',
231
+ status: 'stopped',
232
+ });
228
233
  const automation = createAutomation({
229
234
  name: 'Mixed Bundle Automation',
230
235
  triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '30 6 * * *', tz: 'UTC' } }],
@@ -250,6 +255,8 @@ test('mixed bundle carries automations and apps with heads and installs everythi
250
255
  assert.equal(preview.automations.length, 1);
251
256
  assert.equal(preview.apps.length, 1);
252
257
  assert.equal(preview.apps[0].files, 4);
258
+ assert.equal(bundle.apps[0].app.autostart, false);
259
+ assert.equal(bundle.apps[0].app.keepAlive, false);
253
260
 
254
261
  // Round-trip through JSON like the storage path does.
255
262
  const revived = JSON.parse(JSON.stringify(bundle));
@@ -271,6 +278,8 @@ test('mixed bundle carries automations and apps with heads and installs everythi
271
278
  assert.equal(result.installedApps.length, 1);
272
279
  assert.equal(listAutomations().length, before + 1);
273
280
  assert.equal(registered.length, 1);
281
+ assert.equal(registered[0].autostart, true);
282
+ assert.equal(registered[0].keep_alive, true);
274
283
  assert.equal(fs.existsSync(path.join(registered[0].cwd, 'src', 'index.js')), true);
275
284
  });
276
285
 
@@ -536,6 +545,179 @@ test('app-backed tool paths still bind when the tool uses a symlink alias of the
536
545
  assert.equal(bundle.requires.bindings.some((entry) => entry.includes('appbound.alias.tool')), false);
537
546
  });
538
547
 
548
+ test('sharing an app pulls its owned tools, nested under the app in the graph', async () => {
549
+ const { defaultToolboxService } = require('../toolbox/service');
550
+ const appDir = makeTempDir('bundle-appowned-src-');
551
+ fs.mkdirSync(path.join(appDir, 'tool'), { recursive: true });
552
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'appowned' }));
553
+ fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
554
+ fs.writeFileSync(path.join(appDir, 'tool', 'cli.mjs'), 'console.log("tool");\n');
555
+
556
+ const app = registerSampleApp(appDir, {
557
+ id: 'app-owned-test',
558
+ name: 'App Owned',
559
+ appRef: 'appownedref01',
560
+ startCommand: 'PORT={port} node server.js',
561
+ toolIds: ['appowned.tool'],
562
+ });
563
+ defaultToolboxService.registerTool({
564
+ id: 'appowned.tool',
565
+ name: 'App Owned Tool',
566
+ type: 'cli',
567
+ origin: 'user',
568
+ appId: app.id,
569
+ source: {
570
+ command: 'node',
571
+ args: [path.join(appDir, 'tool', 'cli.mjs')],
572
+ cwd: appDir,
573
+ inputMode: 'json-stdin',
574
+ outputMode: 'json',
575
+ },
576
+ });
577
+
578
+ // Share only the app: the owned tool comes along as a dependency, not a head.
579
+ const { bundle } = createBundle({ appIds: [app.id] });
580
+ assert.deepEqual(bundle.heads, [{ type: 'app', id: app.id }]);
581
+ assert.equal(bundle.tools.filter((tool) => tool.id === 'appowned.tool').length, 1);
582
+ assert.equal(bundle.tools[0].appId, app.id);
583
+ assert.deepEqual(
584
+ bundle.edges.filter((edge) => edge.kind === 'uses-tool' && edge.from === `app:${app.id}`),
585
+ [{ from: `app:${app.id}`, to: 'tool:appowned.tool', kind: 'uses-tool' }],
586
+ );
587
+ // The tool's paths bind to the app, so nothing stays machine-bound.
588
+ assert.equal(bundle.requires.bindings.some((entry) => entry.includes('appowned.tool')), false);
589
+
590
+ const revived = JSON.parse(JSON.stringify(bundle));
591
+ validateBundle(revived);
592
+
593
+ // Install: the registered app carries its tool ids, and the installed
594
+ // tool's app link points at the recipient's app, not the sharer's.
595
+ defaultToolboxService.deleteTool('appowned.tool');
596
+ const rootDir = makeTempDir('bundle-appowned-install-');
597
+ const registered = [];
598
+ const result = await installBundle(revived, {
599
+ appsRootDir: rootDir,
600
+ registerApp: async (input) => {
601
+ registered.push(input);
602
+ return { id: 'app-installed-owned', ...input, status: 'running' };
603
+ },
604
+ });
605
+ assert.deepEqual(registered[0].tool_ids, ['appowned.tool']);
606
+ const installedTool = result.installedTools.find((tool) => tool.id === 'appowned.tool');
607
+ assert.equal(installedTool.appId, 'app-installed-owned');
608
+ assert.equal(installedTool.source.cwd, result.installedApps[0].cwd);
609
+ });
610
+
611
+ test('sharing an app-owned tool pulls the app as a dependency, not a head', async () => {
612
+ const { defaultToolboxService } = require('../toolbox/service');
613
+ const appDir = makeTempDir('bundle-toolowner-src-');
614
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'toolowner' }));
615
+ fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
616
+ fs.writeFileSync(path.join(appDir, 'cli.mjs'), 'console.log("tool");\n');
617
+
618
+ const app = registerSampleApp(appDir, {
619
+ id: 'app-toolowner-test',
620
+ name: 'Tool Owner App',
621
+ appRef: 'toolownerref1',
622
+ startCommand: 'PORT={port} node server.js',
623
+ toolIds: ['toolowner.tool'],
624
+ });
625
+ defaultToolboxService.registerTool({
626
+ id: 'toolowner.tool',
627
+ name: 'Tool Owner Tool',
628
+ type: 'cli',
629
+ origin: 'user',
630
+ appId: app.id,
631
+ source: {
632
+ command: 'node',
633
+ args: [path.join(appDir, 'cli.mjs')],
634
+ cwd: appDir,
635
+ inputMode: 'json-stdin',
636
+ outputMode: 'json',
637
+ },
638
+ });
639
+
640
+ const { bundle } = createBundle({ toolIds: ['toolowner.tool'] });
641
+ // The tool is the only head; the app rides along and does not name the bundle.
642
+ assert.deepEqual(bundle.heads, [{ type: 'tool', id: 'toolowner.tool' }]);
643
+ assert.equal(bundle.title, 'Tool Owner Tool');
644
+ assert.equal(bundle.apps.length, 1);
645
+ assert.equal(bundle.apps[0].sourceAppId, app.id);
646
+ assert.deepEqual(bundle.apps[0].toolIds, ['toolowner.tool']);
647
+ assert.equal(bundle.edges.some(
648
+ (edge) => edge.from === `app:${app.id}` && edge.to === 'tool:toolowner.tool' && edge.kind === 'uses-tool',
649
+ ), true);
650
+ // App-bound paths resolve through the pulled-in app.
651
+ const toolEntry = bundle.tools.find((tool) => tool.id === 'toolowner.tool');
652
+ assert.equal(Array.isArray(toolEntry.appPathBindings), true);
653
+ assert.equal(bundle.requires.bindings.some((entry) => entry.includes('toolowner.tool')), false);
654
+ validateBundle(JSON.parse(JSON.stringify(bundle)));
655
+ });
656
+
657
+ test('an agent using an app-owned tool stays canonical: one tool record, app pulled once', () => {
658
+ const { createAgent } = require('../agents/store');
659
+ const { upsertAgentConfig } = require('../agent-config/store');
660
+ const { defaultToolboxService } = require('../toolbox/service');
661
+
662
+ const appDir = makeTempDir('bundle-agentapp-src-');
663
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'agentapp' }));
664
+ fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
665
+ const app = registerSampleApp(appDir, {
666
+ id: 'app-agentapp-test',
667
+ name: 'Agent App',
668
+ appRef: 'agentappref01',
669
+ startCommand: 'PORT={port} node server.js',
670
+ toolIds: ['agentapp.tool'],
671
+ });
672
+ defaultToolboxService.registerTool({
673
+ id: 'agentapp.tool',
674
+ name: 'Agent App Tool',
675
+ type: 'cli',
676
+ origin: 'user',
677
+ appId: app.id,
678
+ source: { command: 'agentapp-tool', inputMode: 'json-stdin', outputMode: 'json' },
679
+ });
680
+ const agent = createAgent({
681
+ id: 'agentapp-agent',
682
+ name: 'Agent App User',
683
+ baseHarnessId: 'codex',
684
+ loadout: { toolIds: ['agentapp.tool'] },
685
+ authMethod: 'amalgm',
686
+ });
687
+ upsertAgentConfig(agent.id, { instructions: 'use the app tool', loadout: { toolIds: ['agentapp.tool'] } });
688
+
689
+ const { bundle } = createBundle({ agentIds: [agent.id] });
690
+ // Agent head only; the tool pulled the app, the app re-listed the tool —
691
+ // still one tool record, one app entry, and one edge per relationship.
692
+ assert.deepEqual(bundle.heads, [{ type: 'agent', id: agent.id }]);
693
+ assert.equal(bundle.title, 'Agent App User');
694
+ assert.equal(bundle.tools.filter((tool) => tool.id === 'agentapp.tool').length, 1);
695
+ assert.equal(bundle.apps.length, 1);
696
+ assert.equal(
697
+ bundle.elements.filter((element) => element.id === 'tool:agentapp.tool').length,
698
+ 1,
699
+ );
700
+ const toolEdges = bundle.edges.filter((edge) => edge.to === 'tool:agentapp.tool' && edge.kind === 'uses-tool');
701
+ assert.deepEqual(toolEdges.map((edge) => edge.from).sort(), [`agent:${agent.id}`, `app:${app.id}`]);
702
+ });
703
+
704
+ test('a stale tool→app link degrades to a warning instead of failing the export', () => {
705
+ const { defaultToolboxService } = require('../toolbox/service');
706
+ defaultToolboxService.registerTool({
707
+ id: 'stale.applink',
708
+ name: 'Stale App Link',
709
+ type: 'cli',
710
+ origin: 'user',
711
+ appId: 'app-that-was-deleted',
712
+ source: { command: 'stale-applink', inputMode: 'json-stdin', outputMode: 'json' },
713
+ });
714
+
715
+ const { bundle } = createBundle({ toolIds: ['stale.applink'] });
716
+ assert.equal(bundle.apps.length, 0);
717
+ assert.equal(bundle.tools.length, 1);
718
+ assert.equal(bundle.warnings.some((warning) => warning.includes('app-that-was-deleted')), true);
719
+ });
720
+
539
721
  test('installBundle defaults app materialization to the owned apps root', async () => {
540
722
  const appDir = makeTempDir('bundle-appsroot-src-');
541
723
  fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'appsroot' }));
@@ -92,6 +92,38 @@ test('desktop release runner maps UI pushes to UI SHA plus engine branch', () =>
92
92
  });
93
93
  });
94
94
 
95
+ test('desktop release runner honors explicit engine and UI ref overrides', () => {
96
+ const previousEngineRef = process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF;
97
+ const previousUiRef = process.env.AMALGM_DESKTOP_RELEASE_UI_REF;
98
+
99
+ try {
100
+ process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF = '4'.repeat(40);
101
+ process.env.AMALGM_DESKTOP_RELEASE_UI_REF = '5'.repeat(40);
102
+
103
+ const request = requestFromEnv({
104
+ AMALGM_DESKTOP_RELEASE_DRY_RUN: '1',
105
+ AMALGM_DESKTOP_RELEASE_SOURCE_REPO: 'amalgm-inc/amalgm-engine',
106
+ AMALGM_DESKTOP_RELEASE_REF: 'refs/heads/main',
107
+ AMALGM_DESKTOP_RELEASE_AFTER: '3'.repeat(40),
108
+ });
109
+
110
+ const result = publishDesktopRelease(request, {
111
+ checkoutRoot: '/tmp/amalgm-desktop-release-test',
112
+ });
113
+
114
+ assert.equal(result.dryRun, true);
115
+ assert.deepEqual(result.refs, {
116
+ engineRef: '4'.repeat(40),
117
+ uiRef: '5'.repeat(40),
118
+ });
119
+ } finally {
120
+ if (previousEngineRef === undefined) delete process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF;
121
+ else process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF = previousEngineRef;
122
+ if (previousUiRef === undefined) delete process.env.AMALGM_DESKTOP_RELEASE_UI_REF;
123
+ else process.env.AMALGM_DESKTOP_RELEASE_UI_REF = previousUiRef;
124
+ }
125
+ });
126
+
95
127
  test('desktop release runner skips irrelevant pushes and branch deletions', () => {
96
128
  assert.equal(shouldHandleRequest({
97
129
  repository: 'amalgm-inc/amalgm-engine',
@@ -352,6 +352,9 @@ function normalizeTool(input, existing) {
352
352
  name,
353
353
  type,
354
354
  owner: normalizeOwner(input.owner || input.toolOwner || input.ownership || input.policy?.owner),
355
+ // Reverse pointer to the app this tool belongs to (app.toolIds is the
356
+ // forward edge): sharing either side pulls the other into the bundle.
357
+ ...(nonEmptyString(input.appId) ? { appId: input.appId.trim() } : {}),
355
358
  origin: TOOL_ORIGINS.has(input.origin) ? input.origin : 'user',
356
359
  status: RECORD_STATUSES.has(input.status) ? input.status : 'enabled',
357
360
  source: normalizeSource(type, isObject(input.source) ? input.source : input),