amalgm 0.1.135 → 0.1.136

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 (37) hide show
  1. package/package.json +1 -1
  2. package/runtime/lib/harnesses.js +61 -162
  3. package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
  4. package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
  5. package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
  6. package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
  7. package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
  8. package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
  9. package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
  10. package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
  11. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
  13. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
  14. package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
  15. package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
  16. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  17. package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
  18. package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
  19. package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
  20. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
  21. package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
  22. package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
  24. package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
  25. package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
  26. package/runtime/scripts/chat-core/adapters/pi.js +302 -0
  27. package/runtime/scripts/chat-core/auth.js +20 -3
  28. package/runtime/scripts/chat-core/chat-payload.js +2 -0
  29. package/runtime/scripts/chat-core/contract.js +56 -1
  30. package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
  31. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
  32. package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
  33. package/runtime/scripts/chat-core/server.js +4 -0
  34. package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
  35. package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
  36. package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
  37. package/runtime/scripts/chat-core/usage.js +20 -0
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  const embeds = require('./email-embeds');
21
+ const { buildEmailRenderContext, normalizeEmailLink } = require('./email-deeplinks');
21
22
 
22
23
  const EMAIL_TOKENS = {
23
24
  font: {
@@ -86,7 +87,7 @@ function renderAmalgmCitation(label, _kindAndPath, description) {
86
87
  /**
87
88
  * Inline format: bold, italic, code, links, images, amalgm:// citations.
88
89
  */
89
- function inlineFormat(text) {
90
+ function inlineFormat(text, renderContext = {}) {
90
91
  return (
91
92
  text
92
93
  // Media mid-sentence: images stay inline; video becomes a play link;
@@ -103,7 +104,8 @@ function inlineFormat(text) {
103
104
  return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || embeds.mediaNameOf(url) || 'file')}</span>`;
104
105
  }
105
106
  if (embeds.mediaKindOf(url) === 'video') {
106
- return `<a href="${escapeHtmlAttr(url)}" target="_blank" style="color:${EMAIL_TOKENS.color.link};text-decoration:none">&#9654;&nbsp;${escapeHtmlText(alt || 'video')}</a>`;
107
+ const href = normalizeEmailLink(url, renderContext.appOrigin) || url;
108
+ return `<a href="${escapeHtmlAttr(href)}" target="_blank" style="color:${EMAIL_TOKENS.color.link};text-decoration:none">&#9654;&nbsp;${escapeHtmlText(alt || 'video')}</a>`;
107
109
  }
108
110
  return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(alt)}" style="max-width:100%;height:auto;border-radius:16px;margin:8px 0">`;
109
111
  },
@@ -111,7 +113,7 @@ function inlineFormat(text) {
111
113
  // Entity renders mid-sentence collapse to the compact tile.
112
114
  .replace(
113
115
  /\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)/g,
114
- (_, label, mode, type) => embeds.renderEntityEmbed('tile', type, label),
116
+ (_, label, _mode, type, id) => embeds.renderEntityEmbed('tile', type, label, id, renderContext),
115
117
  )
116
118
  // amalgm:// citations (run before generic link regex so the optional
117
119
  // title attribute doesn't get swallowed into the href). Format:
@@ -125,7 +127,10 @@ function inlineFormat(text) {
125
127
  // Links: [text](url) — blue hyperlink per the rendering ruleset.
126
128
  .replace(
127
129
  /\[([^\]]+)\]\(([^)]+)\)/g,
128
- `<a href="$2" style="color:${EMAIL_TOKENS.color.link};text-decoration:none" target="_blank">$1</a>`,
130
+ (_, label, url) => {
131
+ const href = normalizeEmailLink(url, renderContext.appOrigin) || url;
132
+ return `<a href="${escapeHtmlAttr(href)}" style="color:${EMAIL_TOKENS.color.link};text-decoration:none" target="_blank">${escapeHtmlText(label)}</a>`;
133
+ },
129
134
  )
130
135
  // Bold + italic: ***text***
131
136
  .replace(
@@ -145,7 +150,8 @@ function inlineFormat(text) {
145
150
  );
146
151
  }
147
152
 
148
- function markdownToEmailHtml(md) {
153
+ function markdownToEmailHtml(md, renderContextInput = {}) {
154
+ const renderContext = buildEmailRenderContext(renderContextInput);
149
155
  let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
150
156
  // Normalize literal \n sequences (LLMs sometimes send \\n in JSON).
151
157
  text = text.replace(/\\n/g, '\n');
@@ -172,7 +178,7 @@ function markdownToEmailHtml(md) {
172
178
 
173
179
  function flushPara() {
174
180
  if (!paraLines.length) return;
175
- const content = inlineFormat(paraLines.join('\n')).replace(/\n/g, '<br>');
181
+ const content = inlineFormat(paraLines.join('\n'), renderContext).replace(/\n/g, '<br>');
176
182
  out.push(`<p style="margin:12px 0;line-height:1.7;color:#ffffff">${content}</p>`);
177
183
  paraLines = [];
178
184
  }
@@ -180,7 +186,7 @@ function markdownToEmailHtml(md) {
180
186
  if (!listItems.length) return;
181
187
  const tag = listType;
182
188
  const items = listItems
183
- .map((t) => `<li style="margin:4px 0;color:#ffffff">${inlineFormat(t)}</li>`)
189
+ .map((t) => `<li style="margin:4px 0;color:#ffffff">${inlineFormat(t, renderContext)}</li>`)
184
190
  .join('');
185
191
  out.push(
186
192
  `<${tag} style="margin:12px 0;padding-left:24px;color:#ffffff">${items}</${tag}>`,
@@ -207,7 +213,7 @@ function markdownToEmailHtml(md) {
207
213
  const hdr = headerCells
208
214
  .map(
209
215
  (c, i) =>
210
- `<th style="${cellBase};${cellBorders(i === headerCells.length - 1, false)}background:${EMAIL_TOKENS.color.tableHeaderBg};font-weight:500;color:#ffffff">${inlineFormat(c)}</th>`,
216
+ `<th style="${cellBase};${cellBorders(i === headerCells.length - 1, false)}background:${EMAIL_TOKENS.color.tableHeaderBg};font-weight:500;color:#ffffff">${inlineFormat(c, renderContext)}</th>`,
211
217
  )
212
218
  .join('');
213
219
  const body = bodyRows
@@ -217,7 +223,7 @@ function markdownToEmailHtml(md) {
217
223
  r.cells
218
224
  .map(
219
225
  (c, i) =>
220
- `<td style="${cellBase};${cellBorders(i === r.cells.length - 1, rowIdx === bodyRows.length - 1)}color:#ffffff">${inlineFormat(c)}</td>`,
226
+ `<td style="${cellBase};${cellBorders(i === r.cells.length - 1, rowIdx === bodyRows.length - 1)}color:#ffffff">${inlineFormat(c, renderContext)}</td>`,
221
227
  )
222
228
  .join('') +
223
229
  '</tr>',
@@ -230,7 +236,7 @@ function markdownToEmailHtml(md) {
230
236
  }
231
237
  function flushQuote() {
232
238
  if (!quoteLines.length) return;
233
- const content = inlineFormat(quoteLines.join('\n')).replace(/\n/g, '<br>');
239
+ const content = inlineFormat(quoteLines.join('\n'), renderContext).replace(/\n/g, '<br>');
234
240
  out.push(
235
241
  `<blockquote style="margin:16px 0;padding:8px 16px;border-left:3px solid ${EMAIL_TOKENS.color.tableBorder};color:${EMAIL_TOKENS.color.textSecondary};font-style:italic">${content}</blockquote>`,
236
242
  );
@@ -275,14 +281,14 @@ function markdownToEmailHtml(md) {
275
281
  const mediaMatch = parseStandaloneMediaLine(trimmed);
276
282
  if (mediaMatch) {
277
283
  flushAll();
278
- out.push(embeds.renderMediaEmbed(mediaMatch.alt, mediaMatch.src, mediaMatch.title || null));
284
+ out.push(embeds.renderMediaEmbed(mediaMatch.alt, mediaMatch.src, mediaMatch.title || null, renderContext));
279
285
  continue;
280
286
  }
281
287
  // Standalone entity render → tile card or expanded card.
282
288
  const entityMatch = trimmed.match(/^\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)$/);
283
289
  if (entityMatch) {
284
290
  flushAll();
285
- out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1]));
291
+ out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1], entityMatch[4], renderContext));
286
292
  continue;
287
293
  }
288
294
  const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
@@ -291,7 +297,7 @@ function markdownToEmailHtml(md) {
291
297
  const level = headingMatch[1].length;
292
298
  const size = EMAIL_TOKENS.type.headings[level];
293
299
  out.push(
294
- `<h${level} style="margin:20px 0 8px;font-size:${size}px;font-weight:650;color:#ffffff;line-height:1.35">${inlineFormat(headingMatch[2])}</h${level}>`,
300
+ `<h${level} style="margin:20px 0 8px;font-size:${size}px;font-weight:650;color:#ffffff;line-height:1.35">${inlineFormat(headingMatch[2], renderContext)}</h${level}>`,
295
301
  );
296
302
  continue;
297
303
  }
@@ -349,10 +355,11 @@ function markdownToEmailHtml(md) {
349
355
  /**
350
356
  * Wrap the converted markdown body in a full notification email shell.
351
357
  */
352
- function formatNotificationEmail(message, link) {
353
- const htmlMessage = markdownToEmailHtml(message);
354
- const linkHtml = link
355
- ? `<p style="margin:24px 0 0"><a href="${link}" style="color:${EMAIL_TOKENS.color.link};text-decoration:none;font-size:14px">View details &rarr;</a></p>`
358
+ function formatNotificationEmail(message, link, renderContextInput = {}) {
359
+ const renderContext = buildEmailRenderContext({ ...renderContextInput, link });
360
+ const htmlMessage = markdownToEmailHtml(message, renderContext);
361
+ const linkHtml = renderContext.detailsUrl
362
+ ? `<p style="margin:24px 0 0"><a href="${escapeHtmlAttr(renderContext.detailsUrl)}" style="color:${EMAIL_TOKENS.color.link};text-decoration:none;font-size:14px">View details &rarr;</a></p>`
356
363
  : '';
357
364
  return `<!DOCTYPE html>
358
365
  <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
@@ -32,7 +32,7 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
32
32
  // Local media embeds become attachments riding inside the email: images
33
33
  // inline via cid:, videos as regular attachments with a poster card.
34
34
  const media = prepareEmailMedia(message);
35
- const html = formatNotificationEmail(media.message, link || null);
35
+ const html = formatNotificationEmail(media.message, link || null, { sessionId });
36
36
  const attachments = [
37
37
  ...media.attachments,
38
38
  ...prepareEmailIconAttachments(html, media.attachments),
@@ -92,7 +92,11 @@ module.exports = [
92
92
  enum: ['info', 'warning', 'error', 'success'],
93
93
  description: 'Severity level. Defaults to info.',
94
94
  },
95
- link: { type: 'string', description: 'Optional URL for details.' },
95
+ link: {
96
+ type: 'string',
97
+ description:
98
+ 'Optional URL for details. This also becomes the fallback click target for local email embeds when a more specific deep link is not available.',
99
+ },
96
100
  },
97
101
  required: ['message'],
98
102
  },
@@ -13,10 +13,10 @@ function normalizeResources(resources) {
13
13
  function readResource(resource, cache) {
14
14
  switch (resource) {
15
15
  case 'automations':
16
- cache.automations ||= require('../automations/store').listAutomations();
16
+ cache.automations ||= require('../automations/store').listPublicAutomations();
17
17
  return cache.automations;
18
18
  case 'triggers':
19
- cache.triggers ||= require('../automations/store').listTriggers();
19
+ cache.triggers ||= require('../automations/store').listPublicTriggers();
20
20
  return cache.triggers;
21
21
  case 'automation_runs':
22
22
  return require('../automations/store').listAutomationRuns({ limit: 300 });
@@ -31,7 +31,7 @@ function readResource(resource, cache) {
31
31
  case 'event_triggers':
32
32
  return require('../events/store').loadEventTriggers().triggers;
33
33
  case 'workflows':
34
- cache.workflows ||= require('../automations/store').listWorkflows();
34
+ cache.workflows ||= require('../automations/store').listPublicWorkflows();
35
35
  return cache.workflows;
36
36
  case 'agents': {
37
37
  const { agentWithConfig } = require('../agent-config/store');
@@ -13,7 +13,7 @@ const { createAgent, resolveAgent } = require('../agents/store');
13
13
  const { upsertAgentConfig, getAgentConfig } = require('../agent-config/store');
14
14
  const { syncInstalledSkills } = require('../skills/store');
15
15
  const { defaultToolboxService } = require('../toolbox/service');
16
- const { createAgentBundle, installAgentBundle } = require('../agent-bundles/bundles');
16
+ const { createAgentBundle, installBundle } = require('../agent-bundles/bundles');
17
17
 
18
18
  test('agent bundle includes recursive subagents, selected user tools, system tools, and warnings', () => {
19
19
  defaultToolboxService.registerTool({
@@ -145,7 +145,7 @@ test('agent bundle includes every tool action when the loadout references a sing
145
145
  assert.equal(preview.tools.some((tool) => tool.id === 'browser' && tool.actionCount === browserActions.length), true);
146
146
  });
147
147
 
148
- test('installing an agent bundle creates local copies and rewrites subagent refs', () => {
148
+ test('installing an agent bundle creates local copies and rewrites subagent refs', async () => {
149
149
  const source = createAgent({
150
150
  id: 'install-source-root',
151
151
  name: 'Install Source Root',
@@ -175,7 +175,7 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
175
175
  });
176
176
 
177
177
  const { bundle } = createAgentBundle(source.id, { bundleId: 'agent-bundle-install-test' });
178
- const result = installAgentBundle(bundle);
178
+ const result = await installBundle(bundle);
179
179
 
180
180
  assert.equal(result.ok, true);
181
181
  assert.equal(result.installedAgents.length, 2);
@@ -189,9 +189,9 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
189
189
  assert.deepEqual(bundle.headIds, ['agent:install-source-root']);
190
190
  });
191
191
 
192
- test('installing an agent bundle rejects missing shared subagents', () => {
193
- assert.throws(
194
- () => installAgentBundle({
192
+ test('installing an agent bundle rejects missing shared subagents', async () => {
193
+ await assert.rejects(
194
+ () => installBundle({
195
195
  kind: 'amalgm.agent.bundle',
196
196
  schemaVersion: 1,
197
197
  rootAgentId: 'missing-child-root',
@@ -219,8 +219,8 @@ test('installing an agent bundle rejects missing shared subagents', () => {
219
219
  );
220
220
  });
221
221
 
222
- test('installing an agent bundle regenerates stale flat graph fields from canonical agent arrays', () => {
223
- const result = installAgentBundle({
222
+ test('installing an agent bundle regenerates stale flat graph fields from canonical agent arrays', async () => {
223
+ const result = await installBundle({
224
224
  kind: 'amalgm.agent.bundle',
225
225
  schemaVersion: 1,
226
226
  rootAgentId: 'graph-root',
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
4
+ require('node:path').join(require('node:os').tmpdir(), 'amalgm-bundle-entries-'),
5
+ );
6
+
7
+ const assert = require('node:assert/strict');
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const test = require('node:test');
12
+
13
+ const { createAutomation, getAutomation, listAutomations } = require('../automations/store');
14
+ const { loadApps, saveApps } = require('../apps/store');
15
+ const { exportAutomationEntry, installAutomationEntry } = require('../agent-bundles/automation-entries');
16
+ const { exportAppEntry, materializeAppEntry } = require('../agent-bundles/app-entries');
17
+ const { packAppFiles, unpackAppFiles } = require('../agent-bundles/app-files');
18
+ const { createBundle, installBundle, validateBundle } = require('../agent-bundles/bundles');
19
+
20
+ function makeTempDir(prefix) {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
22
+ }
23
+
24
+ function writeSampleApp(dir) {
25
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
26
+ fs.mkdirSync(path.join(dir, 'node_modules', 'left-pad'), { recursive: true });
27
+ fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
28
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'sample-app' }));
29
+ fs.writeFileSync(path.join(dir, 'src', 'index.js'), 'console.log("hi");\n');
30
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=super-secret\n');
31
+ fs.writeFileSync(path.join(dir, 'node_modules', 'left-pad', 'index.js'), 'module.exports = () => {};\n');
32
+ fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
33
+ fs.writeFileSync(path.join(dir, 'logo.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]));
34
+ fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\necho run\n', { mode: 0o755 });
35
+ }
36
+
37
+ function registerSampleApp(cwd) {
38
+ const data = loadApps();
39
+ const app = {
40
+ id: 'app-sample-test',
41
+ kind: 'app',
42
+ name: 'Sample App',
43
+ description: 'A sample app',
44
+ cwd,
45
+ port: 43210,
46
+ buildCommand: 'npm run build',
47
+ startCommand: 'npm run start -- --port {port}',
48
+ appRef: 'sampleref0001',
49
+ publicUrl: 'https://sampleref0001.apps.amalgm.ai/',
50
+ dnsConnected: true,
51
+ autostart: true,
52
+ keepAlive: true,
53
+ desiredState: 'running',
54
+ status: 'running',
55
+ createdAt: new Date().toISOString(),
56
+ updatedAt: new Date().toISOString(),
57
+ };
58
+ data.apps = data.apps.filter((item) => item.id !== app.id);
59
+ data.apps.push(app);
60
+ saveApps(data);
61
+ return app;
62
+ }
63
+
64
+ const SCHEDULED_WORKFLOW = `export default workflow({
65
+ trigger: event("scheduled.run"),
66
+ cells: [
67
+ code("start", async () => ({ ok: true }))
68
+ ]
69
+ })`;
70
+
71
+ test('automation entry export strips machine state and install recreates an active automation', () => {
72
+ const source = createAutomation({
73
+ name: 'Nightly Digest',
74
+ description: 'Summarize the day',
75
+ enabled: false,
76
+ triggers: [
77
+ { kind: 'scheduled', name: 'Midnight', schedule: { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' } },
78
+ { kind: 'event', name: 'Manual hook', source: 'github', event: 'push' },
79
+ ],
80
+ workflowText: SCHEDULED_WORKFLOW,
81
+ });
82
+
83
+ const exported = exportAutomationEntry(source.id);
84
+ const entry = exported.entry;
85
+
86
+ assert.equal(entry.sourceAutomationId, source.id);
87
+ assert.equal(entry.automation.name, 'Nightly Digest');
88
+ assert.equal(entry.triggers.length, 2);
89
+ const scheduled = entry.triggers.find((trigger) => trigger.kind === 'scheduled');
90
+ const eventTrigger = entry.triggers.find((trigger) => trigger.kind === 'event');
91
+ assert.deepEqual(scheduled.schedule, { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' });
92
+ assert.equal(eventTrigger.source, 'github');
93
+ assert.equal('secret' in eventTrigger, false);
94
+ assert.equal('webhookUrl' in eventTrigger, false);
95
+ assert.equal('id' in scheduled, false);
96
+ assert.equal(entry.workflows.length, 1);
97
+ assert.equal(entry.workflows[0].workflowText, SCHEDULED_WORKFLOW);
98
+ assert.equal('projectPath' in entry.workflows[0], false);
99
+
100
+ const installed = installAutomationEntry(entry);
101
+ assert.notEqual(installed.id, source.id);
102
+ assert.equal(installed.enabled, true);
103
+ assert.equal(installed.name, 'Nightly Digest (imported)');
104
+ assert.equal(installed.triggers.length, 2);
105
+
106
+ const installedEvent = installed.triggers.find((trigger) => trigger.kind === 'event');
107
+ const sourceEvent = getAutomation(source.id).triggers.find((trigger) => trigger.kind === 'event');
108
+ assert.equal(Boolean(installedEvent.secret), true);
109
+ assert.notEqual(installedEvent.secret, sourceEvent.secret);
110
+
111
+ const installedScheduled = installed.triggers.find((trigger) => trigger.kind === 'scheduled');
112
+ assert.equal(Boolean(installedScheduled.nextRunAt), true);
113
+ });
114
+
115
+ test('automation entry install rejects entries without workflow text', () => {
116
+ assert.throws(
117
+ () => installAutomationEntry({
118
+ automation: { name: 'Broken' },
119
+ triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' } }],
120
+ workflows: [{ name: 'Empty' }],
121
+ }),
122
+ /workflowText/,
123
+ );
124
+ });
125
+
126
+ test('app file packing excludes dependencies, env files, and vcs state', () => {
127
+ const dir = makeTempDir('bundle-app-src-');
128
+ writeSampleApp(dir);
129
+
130
+ const packed = packAppFiles(dir);
131
+ const paths = packed.files.map((file) => file.path);
132
+
133
+ assert.deepEqual(paths, ['logo.bin', 'package.json', 'run.sh', 'src/index.js']);
134
+ assert.equal(packed.files.find((file) => file.path === 'logo.bin').encoding, 'base64');
135
+ assert.equal(packed.files.find((file) => file.path === 'run.sh').executable, true);
136
+ assert.equal(packed.skipped.some((item) => item.path === '.env'), true);
137
+ assert.equal(packed.skipped.some((item) => item.path === 'node_modules/'), true);
138
+ assert.equal(packed.skipped.some((item) => item.path === '.git/'), true);
139
+
140
+ const target = makeTempDir('bundle-app-dst-');
141
+ const result = unpackAppFiles(packed.files, target);
142
+ assert.equal(result.written, 4);
143
+ assert.equal(fs.readFileSync(path.join(target, 'src', 'index.js'), 'utf8'), 'console.log("hi");\n');
144
+ assert.equal(fs.existsSync(path.join(target, '.env')), false);
145
+ assert.equal(Boolean(fs.statSync(path.join(target, 'run.sh')).mode & 0o100), true);
146
+ assert.deepEqual(
147
+ fs.readFileSync(path.join(target, 'logo.bin')),
148
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]),
149
+ );
150
+ });
151
+
152
+ test('unpacking rejects paths that escape the install directory', () => {
153
+ const target = makeTempDir('bundle-app-escape-');
154
+ assert.throws(
155
+ () => unpackAppFiles([{ path: '../evil.txt', encoding: 'utf8', content: 'nope' }], target),
156
+ /escapes the install directory/,
157
+ );
158
+ });
159
+
160
+ test('app entry export carries the portable record and install materializes a runnable copy', async () => {
161
+ const dir = makeTempDir('bundle-app-reg-');
162
+ writeSampleApp(dir);
163
+ const app = registerSampleApp(dir);
164
+
165
+ const { entry } = exportAppEntry(app.id);
166
+ assert.equal(entry.app.name, 'Sample App');
167
+ assert.equal(entry.app.startCommand, 'npm run start -- --port {port}');
168
+ assert.equal(entry.app.buildCommand, 'npm run build');
169
+ assert.equal('cwd' in entry.app, false);
170
+ assert.equal('port' in entry.app, false);
171
+ assert.equal('appRef' in entry.app, false);
172
+ assert.equal(entry.files.length, 4);
173
+
174
+ const rootDir = makeTempDir('bundle-app-install-root-');
175
+ const registerInput = materializeAppEntry(entry, { rootDir });
176
+ assert.equal(registerInput.start_command, entry.app.startCommand);
177
+ assert.equal(registerInput.build_command, entry.app.buildCommand);
178
+ assert.equal(registerInput.cwd.startsWith(rootDir), true);
179
+ assert.equal(fs.readFileSync(path.join(registerInput.cwd, 'package.json'), 'utf8'), JSON.stringify({ name: 'sample-app' }));
180
+
181
+ // A second install of the same entry gets its own directory.
182
+ const secondInput = materializeAppEntry(entry, { rootDir });
183
+ assert.notEqual(secondInput.cwd, registerInput.cwd);
184
+ });
185
+
186
+ test('mixed bundle carries automations and apps with heads and installs everything', async () => {
187
+ const dir = makeTempDir('bundle-mixed-app-');
188
+ writeSampleApp(dir);
189
+ const app = registerSampleApp(dir);
190
+ const automation = createAutomation({
191
+ name: 'Mixed Bundle Automation',
192
+ triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '30 6 * * *', tz: 'UTC' } }],
193
+ workflowText: SCHEDULED_WORKFLOW,
194
+ });
195
+
196
+ const { bundle, preview } = createBundle({
197
+ automationIds: [automation.id],
198
+ appIds: [app.id],
199
+ }, { bundleId: 'bundle-mixed-test' });
200
+
201
+ assert.equal(bundle.kind, 'amalgm.bundle');
202
+ assert.equal(bundle.summary.automations, 1);
203
+ assert.equal(bundle.summary.apps, 1);
204
+ assert.deepEqual(bundle.headIds, [
205
+ `automation:${automation.id}`,
206
+ `app:${app.id}`,
207
+ ]);
208
+ const appElement = bundle.elements.find((element) => element.type === 'app');
209
+ assert.equal(appElement.label, 'Sample App');
210
+ // Graph elements carry a manifest, not file contents.
211
+ assert.equal('content' in appElement.data.files[0], false);
212
+ assert.equal(preview.automations.length, 1);
213
+ assert.equal(preview.apps.length, 1);
214
+ assert.equal(preview.apps[0].files, 4);
215
+
216
+ // Round-trip through JSON like the storage path does.
217
+ const revived = JSON.parse(JSON.stringify(bundle));
218
+ validateBundle(revived);
219
+
220
+ const registered = [];
221
+ const rootDir = makeTempDir('bundle-mixed-install-');
222
+ const before = listAutomations().length;
223
+ const result = await installBundle(revived, {
224
+ appsRootDir: rootDir,
225
+ registerApp: async (input) => {
226
+ registered.push(input);
227
+ return { id: 'app-installed', ...input, status: 'running' };
228
+ },
229
+ });
230
+
231
+ assert.equal(result.ok, true);
232
+ assert.equal(result.installedAutomations.length, 1);
233
+ assert.equal(result.installedApps.length, 1);
234
+ assert.equal(listAutomations().length, before + 1);
235
+ assert.equal(registered.length, 1);
236
+ assert.equal(fs.existsSync(path.join(registered[0].cwd, 'src', 'index.js')), true);
237
+ });
238
+
239
+ test('legacy agent-only bundles still validate and empty bundles are rejected', () => {
240
+ assert.throws(
241
+ () => validateBundle({ kind: 'amalgm.bundle', schemaVersion: 1 }),
242
+ /at least one agent, automation, or app/,
243
+ );
244
+ assert.throws(
245
+ () => validateBundle({ kind: 'something.else', schemaVersion: 1 }),
246
+ /Unsupported bundle kind/,
247
+ );
248
+ });
249
+
250
+ test('rest handlers round-trip a share: preview exports a bundle and install recreates it', async () => {
251
+ const { handlePreview, handleInstall } = require('../agent-bundles/rest');
252
+ const automation = createAutomation({
253
+ name: 'REST Round Trip',
254
+ triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '15 3 * * *', tz: 'UTC' } }],
255
+ workflowText: SCHEDULED_WORKFLOW,
256
+ });
257
+
258
+ function capture() {
259
+ const result = {};
260
+ return {
261
+ result,
262
+ sendJson: (status, payload) => {
263
+ result.status = status;
264
+ result.payload = payload;
265
+ },
266
+ };
267
+ }
268
+
269
+ const preview = capture();
270
+ await handlePreview({ automation_ids: [automation.id] }, preview.sendJson);
271
+ assert.equal(preview.result.status, 200);
272
+ assert.equal(preview.result.payload.ok, true);
273
+ assert.equal(preview.result.payload.bundle.kind, 'amalgm.bundle');
274
+
275
+ // Simulate the storage download: what the recipient installs is the JSON round-trip.
276
+ const downloaded = JSON.parse(JSON.stringify(preview.result.payload.bundle));
277
+
278
+ const install = capture();
279
+ await handleInstall({ bundle: downloaded }, install.sendJson);
280
+ assert.equal(install.result.status, 200);
281
+ assert.equal(install.result.payload.ok, true);
282
+ assert.equal(install.result.payload.installedAutomations.length, 1);
283
+ assert.notEqual(install.result.payload.installedAutomations[0].id, automation.id);
284
+
285
+ const empty = capture();
286
+ await handlePreview({}, empty.sendJson);
287
+ assert.equal(empty.result.status, 400);
288
+ });
@@ -11,7 +11,14 @@ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
11
  process.env.AMALGM_EVENTS_PUBLIC_URL = 'https://desktop-release.example/events';
12
12
 
13
13
  const { closeLocalDb } = require('../state/db');
14
- const { listAutomations, listTriggers } = require('../automations/store');
14
+ const { buildSnapshot } = require('../state/snapshot');
15
+ const {
16
+ listAutomations,
17
+ listPublicAutomations,
18
+ listPublicTriggers,
19
+ listPublicWorkflows,
20
+ listTriggers,
21
+ } = require('../automations/store');
15
22
 
16
23
  test.after(() => {
17
24
  closeLocalDb();
@@ -67,3 +74,32 @@ test('automations store seeds the internal release webhook automation', () => {
67
74
  assert.equal(prTrigger.webhookUrl, 'https://desktop-release.example/events');
68
75
  assert.match(prTrigger.secret, /^[a-f0-9]{32}$/);
69
76
  });
77
+
78
+ test('public automation surfaces hide internal release automations', () => {
79
+ assert.equal(
80
+ listPublicAutomations().some((item) => item.id === 'internal-amalgm-desktop-release'),
81
+ false,
82
+ );
83
+ assert.equal(
84
+ listPublicTriggers().some((item) => item.id === 'internal-amalgm-desktop-release-github-push'),
85
+ false,
86
+ );
87
+ assert.equal(
88
+ listPublicWorkflows().some((item) => item.id === 'internal-amalgm-desktop-release-workflow'),
89
+ false,
90
+ );
91
+
92
+ const snapshot = buildSnapshot('automations,triggers,workflows');
93
+ assert.equal(
94
+ snapshot.resources.automations.some((item) => item.id === 'internal-amalgm-desktop-release'),
95
+ false,
96
+ );
97
+ assert.equal(
98
+ snapshot.resources.triggers.some((item) => item.id === 'internal-amalgm-desktop-release-github-push'),
99
+ false,
100
+ );
101
+ assert.equal(
102
+ snapshot.resources.workflows.some((item) => item.id === 'internal-amalgm-desktop-release-workflow'),
103
+ false,
104
+ );
105
+ });
@@ -204,6 +204,7 @@ test('desktop release runner uses electron-builder compatible signing identity n
204
204
  assert.equal(env.AMALGM_DESKTOP_RELEASE_UPLOAD_URL, 'https://amalgm-desktop-releases.fly.dev/');
205
205
  assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_APP, 'amalgm-desktop-releases');
206
206
  assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_ROOT, '/data');
207
+ assert.equal(env.AMALGM_DESKTOP_THIN, '1');
207
208
  });
208
209
 
209
210
  test('desktop release runner omits blank certificate file secrets', () => {
@@ -342,3 +343,24 @@ test('desktop release runner requires all updater artifacts', () => {
342
343
  fs.rmSync(tempRoot, { recursive: true, force: true });
343
344
  }
344
345
  });
346
+
347
+ test('desktop release runner accepts thin updater artifacts', () => {
348
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-thin-artifacts-test-'));
349
+ const version = '0.1.124000001-preview.1';
350
+ const files = [
351
+ `amalgm-preview-thin-${version}-arm64.dmg`,
352
+ `amalgm-preview-thin-${version}-arm64.dmg.blockmap`,
353
+ `amalgm-preview-thin-${version}-arm64.zip`,
354
+ `amalgm-preview-thin-${version}-arm64.zip.blockmap`,
355
+ 'preview-mac.yml',
356
+ ];
357
+
358
+ try {
359
+ for (const file of files) fs.writeFileSync(path.join(tempRoot, file), '');
360
+ const artifacts = desktopReleaseArtifactPaths(tempRoot, 'preview', version)
361
+ .map((filePath) => path.basename(filePath));
362
+ assert.deepEqual(artifacts, files);
363
+ } finally {
364
+ fs.rmSync(tempRoot, { recursive: true, force: true });
365
+ }
366
+ });