amalgm 0.1.153 → 0.1.154
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 +1 -1
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +36 -77
- package/runtime/scripts/amalgm-mcp/agent-bundles/export.js +5 -5
- package/runtime/scripts/amalgm-mcp/agent-bundles/graph.js +1 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/install.js +60 -0
- package/runtime/scripts/amalgm-mcp/apps/manifest.js +135 -0
- package/runtime/scripts/amalgm-mcp/apps/portable.js +334 -0
- package/runtime/scripts/amalgm-mcp/apps/rest.js +9 -1
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +63 -16
- package/runtime/scripts/amalgm-mcp/apps/tools.js +37 -10
- package/runtime/scripts/amalgm-mcp/tests/apps-portable.test.js +127 -0
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +71 -47
- package/runtime/scripts/amalgm-mcp/tests/platform-context.test.js +15 -0
- package/runtime/scripts/chat-core/tool-shape.js +1 -1
- package/runtime/scripts/platform-context.txt +33 -10
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +0 -160
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
4
|
+
require('node:path').join(require('node:os').tmpdir(), 'amalgm-apps-portable-'),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const net = require('node:net');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
|
|
14
|
+
const { APP_MANIFEST_FILE } = require('../apps/manifest');
|
|
15
|
+
const { inspectPortableApp, packPortableApp } = require('../apps/portable');
|
|
16
|
+
const { loadApps } = require('../apps/store');
|
|
17
|
+
const { deleteApp, registerApp } = require('../apps/supervisor');
|
|
18
|
+
|
|
19
|
+
function makeApp() {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-portable-app-'));
|
|
21
|
+
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
22
|
+
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(dir, 'src', 'server.js'), [
|
|
24
|
+
"const http = require('http');",
|
|
25
|
+
"http.createServer((_request, response) => response.end('ok')).listen(Number(process.env.PORT), '0.0.0.0');",
|
|
26
|
+
'',
|
|
27
|
+
].join('\n'));
|
|
28
|
+
fs.writeFileSync(path.join(dir, 'data', 'private.json'), '{"user":"state"}\n');
|
|
29
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=never-share\n');
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeManifest(dir, overrides = {}) {
|
|
34
|
+
const manifest = {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
name: 'Portable Test App',
|
|
37
|
+
description: 'Exercises the portable app boundary',
|
|
38
|
+
buildCommand: null,
|
|
39
|
+
startCommand: 'node src/server.js',
|
|
40
|
+
toolIds: ['portable.test-tool'],
|
|
41
|
+
package: {
|
|
42
|
+
include: ['src/**'],
|
|
43
|
+
exclude: [],
|
|
44
|
+
},
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
fs.writeFileSync(path.join(dir, APP_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
48
|
+
return manifest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function availablePort() {
|
|
52
|
+
const server = net.createServer();
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
server.once('error', reject);
|
|
55
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
56
|
+
});
|
|
57
|
+
const port = server.address().port;
|
|
58
|
+
await new Promise((resolve) => server.close(resolve));
|
|
59
|
+
return port;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('portable package uses the manifest allowlist and always carries the manifest', () => {
|
|
63
|
+
const dir = makeApp();
|
|
64
|
+
writeManifest(dir);
|
|
65
|
+
|
|
66
|
+
const packed = packPortableApp(dir);
|
|
67
|
+
assert.deepEqual(packed.files.map((file) => file.path), [APP_MANIFEST_FILE, 'src/server.js']);
|
|
68
|
+
assert.equal(packed.files.some((file) => file.path === 'data/private.json'), false);
|
|
69
|
+
assert.equal(packed.files.some((file) => file.path === '.env'), false);
|
|
70
|
+
assert.deepEqual(packed.manifest.toolIds, ['portable.test-tool']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('portable package rejects invalid manifests and stale include patterns', () => {
|
|
74
|
+
const dir = makeApp();
|
|
75
|
+
writeManifest(dir, { package: { include: ['missing/**'], exclude: [] } });
|
|
76
|
+
assert.throws(() => inspectPortableApp(dir), /pattern matched no files: missing\/\*\*/);
|
|
77
|
+
|
|
78
|
+
writeManifest(dir, { package: { include: ['src/**'], exclude: [APP_MANIFEST_FILE] } });
|
|
79
|
+
assert.throws(() => inspectPortableApp(dir), /cannot be excluded/);
|
|
80
|
+
|
|
81
|
+
writeManifest(dir, { schemaVersion: 99 });
|
|
82
|
+
assert.throws(() => inspectPortableApp(dir), /schemaVersion must be 1/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('portable package reports selected symlinks without following them', () => {
|
|
86
|
+
const dir = makeApp();
|
|
87
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-portable-outside-'));
|
|
88
|
+
fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside\n');
|
|
89
|
+
fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
|
|
90
|
+
writeManifest(dir, { package: { include: ['src/**', 'linked/**'], exclude: [] } });
|
|
91
|
+
|
|
92
|
+
const packed = packPortableApp(dir);
|
|
93
|
+
assert.equal(packed.files.some((file) => file.path.includes('secret.txt')), false);
|
|
94
|
+
assert.deepEqual(packed.excluded.find((entry) => entry.path === 'linked'), {
|
|
95
|
+
path: 'linked',
|
|
96
|
+
reason: 'symlink',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('registration rejects non-manifest inputs before persisting an app', async () => {
|
|
101
|
+
const dir = makeApp();
|
|
102
|
+
await assert.rejects(() => registerApp({ cwd: dir }), /amalgm\.app\.json is required/);
|
|
103
|
+
assert.equal(loadApps().apps.length, 0);
|
|
104
|
+
|
|
105
|
+
writeManifest(dir);
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => registerApp({ cwd: dir, start_command: 'node src/server.js' }),
|
|
108
|
+
/reads start_command from amalgm\.app\.json/,
|
|
109
|
+
);
|
|
110
|
+
assert.equal(loadApps().apps.length, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('registration validates once, stores manifest values, and starts the app', async () => {
|
|
114
|
+
const dir = makeApp();
|
|
115
|
+
writeManifest(dir);
|
|
116
|
+
|
|
117
|
+
const app = await registerApp({ cwd: dir, port: await availablePort(), connect_dns: false });
|
|
118
|
+
try {
|
|
119
|
+
assert.equal(app.name, 'Portable Test App');
|
|
120
|
+
assert.equal(app.startCommand, 'node src/server.js');
|
|
121
|
+
assert.deepEqual(app.toolIds, ['portable.test-tool']);
|
|
122
|
+
assert.equal(app.status, 'running');
|
|
123
|
+
assert.equal(app.dnsConnected, false);
|
|
124
|
+
} finally {
|
|
125
|
+
await deleteApp(app.id);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
@@ -12,9 +12,9 @@ const test = require('node:test');
|
|
|
12
12
|
|
|
13
13
|
const { createAutomation, getAutomation, listAutomations } = require('../automations/store');
|
|
14
14
|
const { loadApps, saveApps } = require('../apps/store');
|
|
15
|
+
const { inspectPortableApp, packPortableApp, unpackAppFiles } = require('../apps/portable');
|
|
15
16
|
const { exportAutomationEntry, installAutomationEntry } = require('../agent-bundles/automation-entries');
|
|
16
17
|
const { exportAppEntry, materializeAppEntry } = require('../agent-bundles/app-entries');
|
|
17
|
-
const { packAppFiles, unpackAppFiles } = require('../agent-bundles/app-files');
|
|
18
18
|
const { createBundle, installBundle, validateBundle } = require('../agent-bundles/bundles');
|
|
19
19
|
|
|
20
20
|
function makeTempDir(prefix) {
|
|
@@ -32,19 +32,49 @@ function writeSampleApp(dir) {
|
|
|
32
32
|
fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
33
33
|
fs.writeFileSync(path.join(dir, 'logo.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]));
|
|
34
34
|
fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\necho run\n', { mode: 0o755 });
|
|
35
|
+
writeAppManifest(dir, { include: ['**'] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeAppManifest(dir, overrides = {}) {
|
|
39
|
+
const include = overrides.include || fs.readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => !['amalgm.app.json', '.env', '.git', 'node_modules'].includes(entry.name))
|
|
41
|
+
.map((entry) => (entry.isDirectory() ? `${entry.name}/**` : entry.name));
|
|
42
|
+
const manifest = {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
name: overrides.name || 'Sample App',
|
|
45
|
+
description: overrides.description || 'A sample app',
|
|
46
|
+
buildCommand: Object.prototype.hasOwnProperty.call(overrides, 'buildCommand')
|
|
47
|
+
? overrides.buildCommand
|
|
48
|
+
: 'npm install && npm run build',
|
|
49
|
+
startCommand: overrides.startCommand || 'npm run start -- --port {port}',
|
|
50
|
+
toolIds: overrides.toolIds || [],
|
|
51
|
+
package: { include, exclude: overrides.exclude || [] },
|
|
52
|
+
};
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'amalgm.app.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
54
|
+
return manifest;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
function registerSampleApp(cwd, overrides = {}) {
|
|
58
|
+
const manifest = writeAppManifest(cwd, {
|
|
59
|
+
name: overrides.name,
|
|
60
|
+
description: overrides.description,
|
|
61
|
+
buildCommand: Object.prototype.hasOwnProperty.call(overrides, 'buildCommand')
|
|
62
|
+
? overrides.buildCommand
|
|
63
|
+
: 'npm install && npm run build',
|
|
64
|
+
startCommand: overrides.startCommand,
|
|
65
|
+
toolIds: overrides.toolIds,
|
|
66
|
+
});
|
|
38
67
|
const data = loadApps();
|
|
39
68
|
const app = {
|
|
40
69
|
id: 'app-sample-test',
|
|
41
70
|
kind: 'app',
|
|
42
|
-
name:
|
|
43
|
-
description:
|
|
71
|
+
name: manifest.name,
|
|
72
|
+
description: manifest.description,
|
|
44
73
|
cwd,
|
|
45
74
|
port: 43210,
|
|
46
|
-
buildCommand:
|
|
47
|
-
startCommand:
|
|
75
|
+
buildCommand: manifest.buildCommand,
|
|
76
|
+
startCommand: manifest.startCommand,
|
|
77
|
+
toolIds: manifest.toolIds,
|
|
48
78
|
appRef: 'sampleref0001',
|
|
49
79
|
publicUrl: 'https://sampleref0001.apps.amalgm.ai/',
|
|
50
80
|
dnsConnected: true,
|
|
@@ -124,23 +154,23 @@ test('automation entry install rejects entries without workflow text', () => {
|
|
|
124
154
|
);
|
|
125
155
|
});
|
|
126
156
|
|
|
127
|
-
test('app
|
|
157
|
+
test('portable app packaging follows the manifest and applies platform exclusions', () => {
|
|
128
158
|
const dir = makeTempDir('bundle-app-src-');
|
|
129
159
|
writeSampleApp(dir);
|
|
130
160
|
|
|
131
|
-
const packed =
|
|
161
|
+
const packed = packPortableApp(dir);
|
|
132
162
|
const paths = packed.files.map((file) => file.path);
|
|
133
163
|
|
|
134
|
-
assert.deepEqual(paths, ['logo.bin', 'package.json', 'run.sh', 'src/index.js']);
|
|
164
|
+
assert.deepEqual(paths, ['amalgm.app.json', 'logo.bin', 'package.json', 'run.sh', 'src/index.js']);
|
|
135
165
|
assert.equal(packed.files.find((file) => file.path === 'logo.bin').encoding, 'base64');
|
|
136
166
|
assert.equal(packed.files.find((file) => file.path === 'run.sh').executable, true);
|
|
137
|
-
assert.equal(packed.
|
|
138
|
-
assert.equal(packed.
|
|
139
|
-
assert.equal(packed.
|
|
167
|
+
assert.equal(packed.excluded.some((item) => item.path === '.env'), true);
|
|
168
|
+
assert.equal(packed.excluded.some((item) => item.path === 'node_modules/'), true);
|
|
169
|
+
assert.equal(packed.excluded.some((item) => item.path === '.git/'), true);
|
|
140
170
|
|
|
141
171
|
const target = makeTempDir('bundle-app-dst-');
|
|
142
172
|
const result = unpackAppFiles(packed.files, target);
|
|
143
|
-
assert.equal(result.written,
|
|
173
|
+
assert.equal(result.written, 5);
|
|
144
174
|
assert.equal(fs.readFileSync(path.join(target, 'src', 'index.js'), 'utf8'), 'console.log("hi");\n');
|
|
145
175
|
assert.equal(fs.existsSync(path.join(target, '.env')), false);
|
|
146
176
|
assert.equal(Boolean(fs.statSync(path.join(target, 'run.sh')).mode & 0o100), true);
|
|
@@ -154,7 +184,7 @@ test('unpacking rejects paths that escape the install directory', () => {
|
|
|
154
184
|
const target = makeTempDir('bundle-app-escape-');
|
|
155
185
|
assert.throws(
|
|
156
186
|
() => unpackAppFiles([{ path: '../evil.txt', encoding: 'utf8', content: 'nope' }], target),
|
|
157
|
-
/
|
|
187
|
+
/Invalid app file path/,
|
|
158
188
|
);
|
|
159
189
|
});
|
|
160
190
|
|
|
@@ -166,59 +196,38 @@ test('app entry export carries the portable record and install materializes a ru
|
|
|
166
196
|
const { entry } = exportAppEntry(app.id);
|
|
167
197
|
assert.equal(entry.app.name, 'Sample App');
|
|
168
198
|
assert.equal(entry.app.startCommand, 'npm run start -- --port {port}');
|
|
169
|
-
assert.equal(entry.app.buildCommand, 'npm run build');
|
|
199
|
+
assert.equal(entry.app.buildCommand, 'npm install && npm run build');
|
|
200
|
+
assert.deepEqual(entry.manifest, inspectPortableApp(dir).manifest);
|
|
170
201
|
assert.equal('cwd' in entry.app, false);
|
|
171
202
|
assert.equal('port' in entry.app, false);
|
|
172
203
|
assert.equal('appRef' in entry.app, false);
|
|
173
|
-
assert.equal(entry.files.length,
|
|
204
|
+
assert.equal(entry.files.length, 5);
|
|
174
205
|
|
|
175
206
|
const rootDir = makeTempDir('bundle-app-install-root-');
|
|
176
207
|
const registerInput = materializeAppEntry(entry, { rootDir });
|
|
177
|
-
assert.
|
|
178
|
-
assert.equal(registerInput.build_command, 'npm install && npm run build');
|
|
208
|
+
assert.deepEqual(Object.keys(registerInput).sort(), ['autostart', 'cwd', 'keep_alive']);
|
|
179
209
|
assert.equal(registerInput.cwd.startsWith(rootDir), true);
|
|
180
210
|
assert.equal(fs.readFileSync(path.join(registerInput.cwd, 'package.json'), 'utf8'), JSON.stringify({ name: 'sample-app' }));
|
|
211
|
+
assert.deepEqual(inspectPortableApp(registerInput.cwd).manifest, entry.manifest);
|
|
181
212
|
|
|
182
213
|
// A second install of the same entry gets its own directory.
|
|
183
214
|
const secondInput = materializeAppEntry(entry, { rootDir });
|
|
184
215
|
assert.notEqual(secondInput.cwd, registerInput.cwd);
|
|
185
216
|
});
|
|
186
217
|
|
|
187
|
-
test('materializeAppEntry
|
|
218
|
+
test('materializeAppEntry preserves explicit lifecycle commands without package-manager guessing', () => {
|
|
188
219
|
const rootDir = makeTempDir('bundle-app-bootstrap-root-');
|
|
189
|
-
|
|
190
220
|
const buildDir = makeTempDir('bundle-app-bootstrap-src-');
|
|
191
221
|
fs.writeFileSync(path.join(buildDir, 'package.json'), JSON.stringify({ name: 'bootstrap-build' }));
|
|
192
222
|
fs.writeFileSync(path.join(buildDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-build', lockfileVersion: 3 }));
|
|
193
223
|
fs.writeFileSync(path.join(buildDir, 'server.js'), 'console.log("serve");\n');
|
|
194
224
|
const buildApp = registerSampleApp(buildDir, {
|
|
195
|
-
buildCommand: 'npm run build',
|
|
225
|
+
buildCommand: 'npm ci && npm run build',
|
|
196
226
|
startCommand: 'node server.js',
|
|
197
227
|
});
|
|
198
228
|
const buildEntry = exportAppEntry(buildApp.id).entry;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const preinstallDir = makeTempDir('bundle-app-bootstrap-preinstall-src-');
|
|
202
|
-
fs.writeFileSync(path.join(preinstallDir, 'package.json'), JSON.stringify({ name: 'bootstrap-preinstall' }));
|
|
203
|
-
fs.writeFileSync(path.join(preinstallDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-preinstall', lockfileVersion: 3 }));
|
|
204
|
-
fs.writeFileSync(path.join(preinstallDir, 'server.js'), 'console.log("serve");\n');
|
|
205
|
-
const preinstallApp = registerSampleApp(preinstallDir, {
|
|
206
|
-
buildCommand: 'npm install && npm run build',
|
|
207
|
-
startCommand: 'node server.js',
|
|
208
|
-
});
|
|
209
|
-
const preinstallEntry = exportAppEntry(preinstallApp.id).entry;
|
|
210
|
-
assert.equal(materializeAppEntry(preinstallEntry, { rootDir }).build_command, 'npm install && npm run build');
|
|
211
|
-
|
|
212
|
-
const startOnlyDir = makeTempDir('bundle-app-bootstrap-startonly-src-');
|
|
213
|
-
fs.writeFileSync(path.join(startOnlyDir, 'package.json'), JSON.stringify({ name: 'bootstrap-startonly' }));
|
|
214
|
-
fs.writeFileSync(path.join(startOnlyDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-startonly', lockfileVersion: 3 }));
|
|
215
|
-
fs.writeFileSync(path.join(startOnlyDir, 'server.js'), 'console.log("serve");\n');
|
|
216
|
-
const startOnlyApp = registerSampleApp(startOnlyDir, {
|
|
217
|
-
buildCommand: null,
|
|
218
|
-
startCommand: 'node server.js',
|
|
219
|
-
});
|
|
220
|
-
const startOnlyEntry = exportAppEntry(startOnlyApp.id).entry;
|
|
221
|
-
assert.equal(materializeAppEntry(startOnlyEntry, { rootDir }).build_command, 'npm install');
|
|
229
|
+
const materialized = materializeAppEntry(buildEntry, { rootDir });
|
|
230
|
+
assert.equal(inspectPortableApp(materialized.cwd).manifest.buildCommand, 'npm ci && npm run build');
|
|
222
231
|
});
|
|
223
232
|
|
|
224
233
|
test('mixed bundle carries automations and apps with heads and installs everything', async () => {
|
|
@@ -254,9 +263,9 @@ test('mixed bundle carries automations and apps with heads and installs everythi
|
|
|
254
263
|
assert.equal('content' in appElement.data.files[0], false);
|
|
255
264
|
assert.equal(preview.automations.length, 1);
|
|
256
265
|
assert.equal(preview.apps.length, 1);
|
|
257
|
-
assert.equal(preview.apps[0].files,
|
|
258
|
-
assert.equal(bundle.apps[0].app.autostart,
|
|
259
|
-
assert.equal(bundle.apps[0].app.keepAlive,
|
|
266
|
+
assert.equal(preview.apps[0].files, 5);
|
|
267
|
+
assert.equal(bundle.apps[0].app.autostart, true);
|
|
268
|
+
assert.equal(bundle.apps[0].app.keepAlive, true);
|
|
260
269
|
|
|
261
270
|
// Round-trip through JSON like the storage path does.
|
|
262
271
|
const revived = JSON.parse(JSON.stringify(bundle));
|
|
@@ -294,6 +303,21 @@ test('legacy agent-only bundles still validate and empty bundles are rejected',
|
|
|
294
303
|
);
|
|
295
304
|
});
|
|
296
305
|
|
|
306
|
+
test('bundle validation rejects duplicate canonical apps and manifest drift', () => {
|
|
307
|
+
const dir = makeTempDir('bundle-app-invariants-');
|
|
308
|
+
writeSampleApp(dir);
|
|
309
|
+
const app = registerSampleApp(dir, { id: 'app-invariant-test' });
|
|
310
|
+
const { bundle } = createBundle({ appIds: [app.id] });
|
|
311
|
+
|
|
312
|
+
const duplicated = JSON.parse(JSON.stringify(bundle));
|
|
313
|
+
duplicated.apps.push(JSON.parse(JSON.stringify(duplicated.apps[0])));
|
|
314
|
+
assert.throws(() => validateBundle(duplicated), /duplicate app id/);
|
|
315
|
+
|
|
316
|
+
const drifted = JSON.parse(JSON.stringify(bundle));
|
|
317
|
+
drifted.apps[0].manifest.name = 'Misleading name';
|
|
318
|
+
assert.throws(() => validateBundle(drifted), /manifest summary does not match/);
|
|
319
|
+
});
|
|
320
|
+
|
|
297
321
|
test('standalone tool bundle carries the tool as a head and installs it', async () => {
|
|
298
322
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
299
323
|
defaultToolboxService.registerTool({
|
|
@@ -602,7 +626,7 @@ test('sharing an app pulls its owned tools, nested under the app in the graph',
|
|
|
602
626
|
return { id: 'app-installed-owned', ...input, status: 'running' };
|
|
603
627
|
},
|
|
604
628
|
});
|
|
605
|
-
assert.deepEqual(registered[0].
|
|
629
|
+
assert.deepEqual(inspectPortableApp(registered[0].cwd).manifest.toolIds, ['appowned.tool']);
|
|
606
630
|
const installedTool = result.installedTools.find((tool) => tool.id === 'appowned.tool');
|
|
607
631
|
assert.equal(installedTool.appId, 'app-installed-owned');
|
|
608
632
|
assert.equal(installedTool.source.cwd, result.installedApps[0].cwd);
|
|
@@ -56,3 +56,18 @@ test('platform context teaches rich media embeds with GIF video previews', () =>
|
|
|
56
56
|
assert.equal(text.includes('<name>.poster.png'), true);
|
|
57
57
|
assert.equal(text.includes('≤20MB'), true);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
test('platform context teaches the manifest-owned app registration boundary', () => {
|
|
61
|
+
const text = fs.readFileSync(CONTEXT_PATH, 'utf8');
|
|
62
|
+
|
|
63
|
+
for (const required of [
|
|
64
|
+
'amalgm.app.json',
|
|
65
|
+
'`apps_validate`',
|
|
66
|
+
'The manifest is the single source of truth',
|
|
67
|
+
'`package.include` is an allowlist',
|
|
68
|
+
'Amalgm does not guess a package manager',
|
|
69
|
+
'they are not portable and cannot be shared',
|
|
70
|
+
]) {
|
|
71
|
+
assert.equal(text.includes(required), true, `platform context should include ${required}`);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
@@ -113,7 +113,7 @@ function titleFromMcp(tool, input = {}) {
|
|
|
113
113
|
if (action === 'record_stop') return 'Saving browser recording';
|
|
114
114
|
if (action === 'record_list') return 'Listing browser recordings';
|
|
115
115
|
if (action === 'auth_list') return 'Listing browser logins';
|
|
116
|
-
if (name === 'apps_register') return input.
|
|
116
|
+
if (name === 'apps_register') return input.cwd ? `Registering app from ${compactText(input.cwd, 60)}` : 'Registering app';
|
|
117
117
|
if (name === 'apps_start') return 'Starting app';
|
|
118
118
|
if (name === 'apps_stop') return 'Stopping app';
|
|
119
119
|
if (name === 'apps_list') return 'Listing apps';
|
|
@@ -54,30 +54,53 @@ Apps are **user-hosted services**. They run on the user's connected computer, no
|
|
|
54
54
|
|
|
55
55
|
**Mental model:**
|
|
56
56
|
- **App** — runs on user compute and is exposed on `https://{app_ref}.apps.amalgm.ai`
|
|
57
|
-
- **App** — hyperscaler-hosted and lives on `*.apps.amalgm.ai`
|
|
58
57
|
|
|
59
58
|
At the moment, the app flow is the one you can directly control from the MCP.
|
|
60
59
|
|
|
61
60
|
**What an app requires:**
|
|
62
|
-
1.
|
|
63
|
-
2. It must run as a production service. Do not register or redeploy an app with a dev server or dev command (`npm run dev`, `vite`, `next dev`, and similar)
|
|
61
|
+
1. Its project root contains a valid `amalgm.app.json`.
|
|
62
|
+
2. It must run as a production service. Do not register or redeploy an app with a dev server or dev command (`npm run dev`, `vite`, `next dev`, and similar). Use a real production `startCommand` and a `buildCommand` when setup or compilation is required.
|
|
64
63
|
3. It is registered locally so Amalgm knows to start it on boot and keep it alive while the computer is on.
|
|
65
64
|
|
|
65
|
+
**Portable app manifest:**
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"schemaVersion": 1,
|
|
70
|
+
"name": "Design Canvas",
|
|
71
|
+
"description": "A local design workspace",
|
|
72
|
+
"buildCommand": "npm ci && npm run build",
|
|
73
|
+
"startCommand": "npm run start -- --host 0.0.0.0 --port {port}",
|
|
74
|
+
"toolIds": ["amalgm.design-canvas"],
|
|
75
|
+
"package": {
|
|
76
|
+
"include": ["package.json", "package-lock.json", "src/**", "public/**", "tool/**"],
|
|
77
|
+
"exclude": ["data/**", "exports/**"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The manifest is the single source of truth for portable app metadata, lifecycle commands, owned tools, and shared files. `package.include` is an allowlist; `package.exclude` subtracts from it. `amalgm.app.json` is always included automatically. Amalgm also excludes dependencies, VCS state, env files, credentials, logs, and symlinks. Put generated data and user content outside the include set. If a clean recipient needs dependencies, the declared `buildCommand` must install them; Amalgm does not guess a package manager.
|
|
83
|
+
|
|
84
|
+
Create or update the manifest before registration, then call `apps_validate` and fix every error before `apps_register`. Do not pass name, description, build/start commands, or tool ids to registration; Apps reads them from the manifest. Existing legacy registrations without a manifest can keep running, but they are not portable and cannot be shared.
|
|
85
|
+
|
|
66
86
|
**Tools:**
|
|
67
|
-
- `
|
|
87
|
+
- `apps_validate` — Dry-run the exact package that registration and sharing use. Reports selected files, exclusions, and total bytes without mutating anything.
|
|
88
|
+
- `apps_register` — Validate and register the manifest in an existing local project. Stores the record in `~/.amalgm/apps.json`, starts it, and connects DNS by default.
|
|
68
89
|
- `apps_list` — List registered local apps.
|
|
69
90
|
- `apps_routes` — Show app routes currently advertised by this computer.
|
|
70
91
|
- `apps_start` — Start a stopped app with its production start command.
|
|
71
92
|
- `apps_stop` — Stop a registered app and mark it intentionally stopped.
|
|
72
|
-
- `apps_redeploy` —
|
|
93
|
+
- `apps_redeploy` — Reread and validate the manifest, update the app record, rerun `buildCommand`, and restart the production process. It can also update port/lifecycle settings.
|
|
73
94
|
- `apps_connect_dns` — Reconnect an app to `*.apps.amalgm.ai` without changing the local registration.
|
|
74
95
|
- `apps_disconnect_dns` — Remove public routing without deleting the app or stopping the local process.
|
|
75
96
|
|
|
76
97
|
**How to think about app setup:**
|
|
77
98
|
- Prefer using the user's existing project directory instead of generating a new framework wrapper.
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
99
|
+
- Create `amalgm.app.json` with the smallest explicit include set that makes a clean copy build and run.
|
|
100
|
+
- If the project needs dependencies or a build step, declare the complete production `buildCommand`.
|
|
101
|
+
- Require a real production `startCommand` that binds to `PORT` or `{port}`.
|
|
102
|
+
- Do not use dev servers for apps. If the only available command is a dev command, stop and say the project is not app-ready yet.
|
|
103
|
+
- Run `apps_validate` before `apps_register` and after changing the manifest.
|
|
81
104
|
- If the framework needs an explicit host, `0.0.0.0` is a good default. The important thing is that the service reliably binds to the assigned port.
|
|
82
105
|
- Random app refs are fine. Do not block on naming.
|
|
83
106
|
|
|
@@ -164,8 +187,8 @@ Don't just answer questions — **build systems**. If the user's need is ongoing
|
|
|
164
187
|
|
|
165
188
|
Apps should be treated as **registered production services on user compute**, not as temporary dev servers.
|
|
166
189
|
|
|
167
|
-
- Use `apps_register` only
|
|
168
|
-
-
|
|
190
|
+
- Use `apps_register` only after `apps_validate` succeeds.
|
|
191
|
+
- Keep production `buildCommand` and `startCommand` in `amalgm.app.json`.
|
|
169
192
|
- Reject `npm run dev`, `vite`, `next dev`, and similar dev-only commands for apps unless the user explicitly asks for a temporary exception.
|
|
170
193
|
- Use `apps_redeploy` when code or commands change.
|
|
171
194
|
- Use `apps_start` and `apps_stop` to control the registered service lifecycle.
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Pack and unpack an app's file tree for portable bundles.
|
|
5
|
-
*
|
|
6
|
-
* Files travel inline in the bundle JSON (utf8 or base64), so packing is
|
|
7
|
-
* conservative: dependency dirs, VCS state, env files, and build outputs are
|
|
8
|
-
* excluded — the recipient's install re-runs the app's build command — and
|
|
9
|
-
* per-file/total size caps keep the bundle under the storage object limit.
|
|
10
|
-
* Everything skipped is reported so the share UI can surface it.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const fs = require('fs');
|
|
14
|
-
const path = require('path');
|
|
15
|
-
|
|
16
|
-
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
17
|
-
const DEFAULT_MAX_TOTAL_BYTES = 12 * 1024 * 1024;
|
|
18
|
-
|
|
19
|
-
const EXCLUDED_DIR_NAMES = new Set([
|
|
20
|
-
'node_modules',
|
|
21
|
-
'.git',
|
|
22
|
-
'.hg',
|
|
23
|
-
'.svn',
|
|
24
|
-
'.amalgm',
|
|
25
|
-
'.next',
|
|
26
|
-
'.nuxt',
|
|
27
|
-
'.turbo',
|
|
28
|
-
'.vercel',
|
|
29
|
-
'.cache',
|
|
30
|
-
'.parcel-cache',
|
|
31
|
-
'.venv',
|
|
32
|
-
'venv',
|
|
33
|
-
'__pycache__',
|
|
34
|
-
'dist',
|
|
35
|
-
'build',
|
|
36
|
-
'out',
|
|
37
|
-
'coverage',
|
|
38
|
-
'tmp',
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
|
-
const EXCLUDED_FILE_NAMES = new Set([
|
|
42
|
-
'.DS_Store',
|
|
43
|
-
'Thumbs.db',
|
|
44
|
-
'npm-debug.log',
|
|
45
|
-
'yarn-error.log',
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
function isExcludedFileName(name) {
|
|
49
|
-
if (EXCLUDED_FILE_NAMES.has(name)) return true;
|
|
50
|
-
// .env, .env.local, .env.production, … — never ship credentials.
|
|
51
|
-
if (name === '.env' || name.startsWith('.env.')) return true;
|
|
52
|
-
if (name.endsWith('.log')) return true;
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function isBinary(buffer) {
|
|
57
|
-
const sample = buffer.subarray(0, 8192);
|
|
58
|
-
if (sample.includes(0)) return true;
|
|
59
|
-
return !buffer.equals(Buffer.from(buffer.toString('utf8'), 'utf8'));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function toPosixPath(relativePath) {
|
|
63
|
-
return relativePath.split(path.sep).join('/');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function packAppFiles(rootDir, options = {}) {
|
|
67
|
-
const root = path.resolve(rootDir);
|
|
68
|
-
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
69
|
-
throw new Error(`App directory not found: ${root}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const maxFileBytes = options.maxFileBytes || DEFAULT_MAX_FILE_BYTES;
|
|
73
|
-
const maxTotalBytes = options.maxTotalBytes || DEFAULT_MAX_TOTAL_BYTES;
|
|
74
|
-
const files = [];
|
|
75
|
-
const skipped = [];
|
|
76
|
-
let totalBytes = 0;
|
|
77
|
-
|
|
78
|
-
function visit(dir) {
|
|
79
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
80
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
81
|
-
for (const dirent of entries) {
|
|
82
|
-
const absolute = path.join(dir, dirent.name);
|
|
83
|
-
const relative = toPosixPath(path.relative(root, absolute));
|
|
84
|
-
|
|
85
|
-
if (dirent.isSymbolicLink()) {
|
|
86
|
-
skipped.push({ path: relative, reason: 'symlink' });
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (dirent.isDirectory()) {
|
|
90
|
-
if (EXCLUDED_DIR_NAMES.has(dirent.name)) {
|
|
91
|
-
skipped.push({ path: `${relative}/`, reason: 'excluded' });
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
visit(absolute);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (!dirent.isFile()) continue;
|
|
98
|
-
if (isExcludedFileName(dirent.name)) {
|
|
99
|
-
skipped.push({ path: relative, reason: 'excluded' });
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const stat = fs.statSync(absolute);
|
|
104
|
-
if (stat.size > maxFileBytes) {
|
|
105
|
-
skipped.push({ path: relative, reason: 'file-too-large', size: stat.size });
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (totalBytes + stat.size > maxTotalBytes) {
|
|
109
|
-
skipped.push({ path: relative, reason: 'bundle-size-limit', size: stat.size });
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const buffer = fs.readFileSync(absolute);
|
|
114
|
-
const binary = isBinary(buffer);
|
|
115
|
-
totalBytes += stat.size;
|
|
116
|
-
files.push({
|
|
117
|
-
path: relative,
|
|
118
|
-
encoding: binary ? 'base64' : 'utf8',
|
|
119
|
-
content: binary ? buffer.toString('base64') : buffer.toString('utf8'),
|
|
120
|
-
size: stat.size,
|
|
121
|
-
...(stat.mode & 0o111 ? { executable: true } : {}),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
visit(root);
|
|
127
|
-
return { files, totalBytes, skipped };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function assertInsideTarget(targetRoot, relativePath) {
|
|
131
|
-
const normalized = String(relativePath || '');
|
|
132
|
-
const resolved = path.resolve(targetRoot, normalized);
|
|
133
|
-
if (resolved !== targetRoot && !resolved.startsWith(targetRoot + path.sep)) {
|
|
134
|
-
throw new Error(`App file escapes the install directory: ${normalized}`);
|
|
135
|
-
}
|
|
136
|
-
return resolved;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function unpackAppFiles(files, targetDir) {
|
|
140
|
-
const targetRoot = path.resolve(targetDir);
|
|
141
|
-
fs.mkdirSync(targetRoot, { recursive: true });
|
|
142
|
-
|
|
143
|
-
let written = 0;
|
|
144
|
-
for (const file of Array.isArray(files) ? files : []) {
|
|
145
|
-
if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') continue;
|
|
146
|
-
const destination = assertInsideTarget(targetRoot, file.path);
|
|
147
|
-
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
148
|
-
const buffer = file.encoding === 'base64'
|
|
149
|
-
? Buffer.from(file.content, 'base64')
|
|
150
|
-
: Buffer.from(file.content, 'utf8');
|
|
151
|
-
fs.writeFileSync(destination, buffer, { mode: file.executable ? 0o755 : 0o644 });
|
|
152
|
-
written += 1;
|
|
153
|
-
}
|
|
154
|
-
return { written, targetDir: targetRoot };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
module.exports = {
|
|
158
|
-
packAppFiles,
|
|
159
|
-
unpackAppFiles,
|
|
160
|
-
};
|