amalgm 0.1.138 → 0.1.139
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/bin/amalgm.js +73 -0
- package/lib/auth-store.js +16 -2
- package/lib/cli.js +48 -7
- package/lib/identity-adopt.js +43 -0
- package/lib/layout.js +17 -10
- package/lib/migrate-layout.js +1 -1
- package/lib/state-migration.js +23 -1
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/agents/store.js +8 -2
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +116 -11
- package/runtime/scripts/amalgm-mcp/browser/backend.js +5 -2
- package/runtime/scripts/amalgm-mcp/browser/cli.js +2 -1
- package/runtime/scripts/amalgm-mcp/config.js +26 -5
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +119 -53
- package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +2 -2
- package/runtime/scripts/amalgm-mcp/events/npm-release-runner.js +4 -2
- package/runtime/scripts/amalgm-mcp/events/pr-check-runner.js +4 -2
- package/runtime/scripts/amalgm-mcp/fs/rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/lib/layout.js +17 -10
- package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +2 -2
- package/runtime/scripts/amalgm-mcp/project-context/store.js +27 -5
- package/runtime/scripts/amalgm-mcp/tests/agents-source-of-truth.test.js +13 -0
- package/runtime/scripts/amalgm-mcp/tests/apps-supervisor.test.js +190 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +2 -3
- package/runtime/scripts/amalgm-mcp/tests/config-migration.test.js +37 -1
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +51 -0
- package/runtime/scripts/amalgm-mcp/tests/files-resource.test.js +25 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +59 -0
- package/runtime/scripts/amalgm-mcp/toolbox/runner.js +6 -1
- package/runtime/scripts/chat-core/contract.js +2 -2
- package/runtime/scripts/chat-core/credentials/store.js +2 -1
- package/runtime/scripts/chat-core/recorder.js +2 -1
- package/runtime/scripts/chat-core/tooling/native-binaries.js +2 -1
- package/runtime/scripts/chat-server/config.js +2 -2
- package/runtime/scripts/credential-adapter.js +2 -2
- package/runtime/scripts/lib/runtime-paths.js +61 -1
- package/runtime/scripts/local-gateway.js +2 -1
- package/runtime/scripts/proxy-token-store.js +7 -4
- package/runtime/scripts/proxy-token-store.test.js +112 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const net = require('net');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawn, spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-supervisor-'));
|
|
13
|
+
process.env.AMALGM_DIR = path.join(tempRoot, 'user');
|
|
14
|
+
process.env.AMALGM_HOME = path.join(tempRoot, 'home');
|
|
15
|
+
process.env.AMALGM_RUNTIME_LABEL = 'test';
|
|
16
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
17
|
+
|
|
18
|
+
const { saveApps } = require('../apps/store');
|
|
19
|
+
const { startApp, stopApp } = require('../apps/supervisor');
|
|
20
|
+
|
|
21
|
+
function hasLsof() {
|
|
22
|
+
const result = spawnSync('lsof', ['-v'], { encoding: 'utf8' });
|
|
23
|
+
return !result.error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getFreePort() {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const server = net.createServer();
|
|
29
|
+
server.on('error', reject);
|
|
30
|
+
server.listen(0, '127.0.0.1', () => {
|
|
31
|
+
const port = server.address().port;
|
|
32
|
+
server.close(() => resolve(port));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function waitFor(predicate, label) {
|
|
38
|
+
const deadline = Date.now() + 5000;
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const tick = async () => {
|
|
41
|
+
try {
|
|
42
|
+
if (await predicate()) return resolve();
|
|
43
|
+
} catch {}
|
|
44
|
+
if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${label}`));
|
|
45
|
+
setTimeout(tick, 100);
|
|
46
|
+
};
|
|
47
|
+
tick();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requestBody(port) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const req = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: 1000 }, (res) => {
|
|
54
|
+
let body = '';
|
|
55
|
+
res.setEncoding('utf8');
|
|
56
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
57
|
+
res.on('end', () => resolve(body));
|
|
58
|
+
});
|
|
59
|
+
req.on('error', reject);
|
|
60
|
+
req.on('timeout', () => {
|
|
61
|
+
req.destroy(new Error('request timed out'));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isAlive(pid) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function killPid(pid) {
|
|
76
|
+
if (!pid || !isAlive(pid)) return;
|
|
77
|
+
try { process.kill(-pid, 'SIGTERM'); } catch {}
|
|
78
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeServerScript(dir, response) {
|
|
82
|
+
const file = path.join(dir, `${response}-server.js`);
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
file,
|
|
85
|
+
`require('http').createServer((req,res)=>res.end(${JSON.stringify(response)})).listen(Number(process.env.PORT || process.argv[2]), '0.0.0.0');\n`,
|
|
86
|
+
);
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function spawnServer(script, port, cwd, extraEnv = {}) {
|
|
91
|
+
const child = spawn(process.execPath, [script, String(port)], {
|
|
92
|
+
cwd,
|
|
93
|
+
detached: true,
|
|
94
|
+
env: { ...process.env, ...extraEnv },
|
|
95
|
+
stdio: 'ignore',
|
|
96
|
+
});
|
|
97
|
+
child.unref();
|
|
98
|
+
return child;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('startApp clears an app-owned orphan listener when the saved pid is gone', async (t) => {
|
|
102
|
+
if (!hasLsof()) {
|
|
103
|
+
t.skip('lsof is required for port-owner cleanup');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const appCwd = fs.mkdtempSync(path.join(tempRoot, 'owned-app-'));
|
|
108
|
+
const staleCwd = path.join(appCwd, '.next', 'standalone');
|
|
109
|
+
fs.mkdirSync(staleCwd, { recursive: true });
|
|
110
|
+
const freshScript = writeServerScript(appCwd, 'fresh');
|
|
111
|
+
const staleScript = writeServerScript(appCwd, 'stale');
|
|
112
|
+
const port = await getFreePort();
|
|
113
|
+
|
|
114
|
+
const stale = spawnServer(staleScript, port, staleCwd);
|
|
115
|
+
t.after(() => {
|
|
116
|
+
killPid(stale.pid);
|
|
117
|
+
stopApp('app-owned-orphan').catch(() => {});
|
|
118
|
+
});
|
|
119
|
+
await waitFor(async () => (await requestBody(port)) === 'stale', 'stale app listener');
|
|
120
|
+
|
|
121
|
+
saveApps({
|
|
122
|
+
version: 1,
|
|
123
|
+
apps: [{
|
|
124
|
+
id: 'app-owned-orphan',
|
|
125
|
+
kind: 'app',
|
|
126
|
+
name: 'Owned Orphan',
|
|
127
|
+
cwd: appCwd,
|
|
128
|
+
port,
|
|
129
|
+
startCommand: `"${process.execPath}" "${freshScript}"`,
|
|
130
|
+
appRef: 'ownedorphan',
|
|
131
|
+
publicUrl: 'https://ownedorphan.apps.amalgm.ai/',
|
|
132
|
+
dnsConnected: false,
|
|
133
|
+
autostart: true,
|
|
134
|
+
keepAlive: true,
|
|
135
|
+
desiredState: 'running',
|
|
136
|
+
status: 'restarting',
|
|
137
|
+
pid: null,
|
|
138
|
+
}],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const app = await startApp('app-owned-orphan');
|
|
142
|
+
assert.equal(app.status, 'running');
|
|
143
|
+
assert.equal(app.pid > 0, true);
|
|
144
|
+
await waitFor(() => !isAlive(stale.pid), 'stale listener exit');
|
|
145
|
+
await waitFor(async () => (await requestBody(port)) === 'fresh', 'fresh app listener');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('startApp refuses to kill an unknown listener on the requested port', async (t) => {
|
|
149
|
+
if (!hasLsof()) {
|
|
150
|
+
t.skip('lsof is required for port-owner cleanup');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const appCwd = fs.mkdtempSync(path.join(tempRoot, 'unknown-app-'));
|
|
155
|
+
const unrelatedCwd = fs.mkdtempSync(path.join(tempRoot, 'unrelated-'));
|
|
156
|
+
const unrelatedScript = writeServerScript(unrelatedCwd, 'unrelated');
|
|
157
|
+
const freshScript = writeServerScript(appCwd, 'fresh');
|
|
158
|
+
const port = await getFreePort();
|
|
159
|
+
|
|
160
|
+
const unrelated = spawnServer(unrelatedScript, port, unrelatedCwd);
|
|
161
|
+
t.after(() => killPid(unrelated.pid));
|
|
162
|
+
await waitFor(async () => (await requestBody(port)) === 'unrelated', 'unrelated listener');
|
|
163
|
+
|
|
164
|
+
saveApps({
|
|
165
|
+
version: 1,
|
|
166
|
+
apps: [{
|
|
167
|
+
id: 'app-unknown-port',
|
|
168
|
+
kind: 'app',
|
|
169
|
+
name: 'Unknown Port',
|
|
170
|
+
cwd: appCwd,
|
|
171
|
+
port,
|
|
172
|
+
startCommand: `"${process.execPath}" "${freshScript}"`,
|
|
173
|
+
appRef: 'unknownport',
|
|
174
|
+
publicUrl: 'https://unknownport.apps.amalgm.ai/',
|
|
175
|
+
dnsConnected: false,
|
|
176
|
+
autostart: true,
|
|
177
|
+
keepAlive: true,
|
|
178
|
+
desiredState: 'running',
|
|
179
|
+
status: 'restarting',
|
|
180
|
+
pid: null,
|
|
181
|
+
}],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => startApp('app-unknown-port'),
|
|
186
|
+
/refusing to kill non-app process/,
|
|
187
|
+
);
|
|
188
|
+
assert.equal(isAlive(unrelated.pid), true);
|
|
189
|
+
assert.equal(await requestBody(port), 'unrelated');
|
|
190
|
+
});
|
|
@@ -154,19 +154,18 @@ test('ownership routing: a session home is decided at first use and sticks', ()
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test('entrypoint routing: candidate order is state dir, derived label dir, AMALGM_DIR
|
|
157
|
+
test('entrypoint routing: candidate order is state dir, derived label dir, AMALGM_DIR — no legacy home fallback', () => {
|
|
158
158
|
withEnv({
|
|
159
159
|
AMALGM_RUNTIME_STATE_DIR: '/tmp/a-state',
|
|
160
160
|
AMALGM_DIR: '/tmp/a-dir',
|
|
161
161
|
AMALGM_RUNTIME_LABEL: 'main',
|
|
162
162
|
}, () => {
|
|
163
163
|
const candidates = engine.bridgeFileCandidates();
|
|
164
|
-
assert.deepEqual(candidates
|
|
164
|
+
assert.deepEqual(candidates, [
|
|
165
165
|
'/tmp/a-state/electron-browser-bridge.json',
|
|
166
166
|
'/tmp/a-dir/runtimes/main/electron-browser-bridge.json',
|
|
167
167
|
'/tmp/a-dir/electron-browser-bridge.json',
|
|
168
168
|
]);
|
|
169
|
-
assert.equal(candidates[3], path.join(os.homedir(), '.amalgm', 'electron-browser-bridge.json'));
|
|
170
169
|
});
|
|
171
170
|
});
|
|
172
171
|
|
|
@@ -6,7 +6,9 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
9
|
-
const ORIGINAL_ENV =
|
|
9
|
+
const ORIGINAL_ENV = Object.fromEntries(
|
|
10
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith('AMALGM_')),
|
|
11
|
+
);
|
|
10
12
|
const SCRIPT_DIR = path.resolve(__dirname, '..');
|
|
11
13
|
const TEST_TMP_DIR = path.join(__dirname, '.tmp');
|
|
12
14
|
|
|
@@ -83,6 +85,40 @@ test('runtime config backfills legacy cli homes after the runtime marker already
|
|
|
83
85
|
const marker = readJson(markerPath);
|
|
84
86
|
assert.ok(marker.copied.includes('cli-homes/'));
|
|
85
87
|
assert.ok(marker.completed_migrations.includes('cli-homes-merge-v1'));
|
|
88
|
+
assert.ok(marker.completed_migrations.includes('workspaces-merge-v1'));
|
|
89
|
+
} finally {
|
|
90
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('runtime config resolves missing AMALGM_DIR to the manifest user folder', () => {
|
|
95
|
+
fs.mkdirSync(TEST_TMP_DIR, { recursive: true });
|
|
96
|
+
const root = fs.mkdtempSync(path.join(TEST_TMP_DIR, 'amalgm-runtime-paths-'));
|
|
97
|
+
try {
|
|
98
|
+
resetEnv();
|
|
99
|
+
const home = path.join(root, '.amalgm');
|
|
100
|
+
const userDir = path.join(home, 'users', 'aayush@example.com');
|
|
101
|
+
fs.mkdirSync(userDir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(path.join(home, '.amalgm-root.json'), `${JSON.stringify({
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
installationId: 'test-install',
|
|
105
|
+
users: {},
|
|
106
|
+
})}\n`);
|
|
107
|
+
fs.writeFileSync(path.join(userDir, '.amalgm-user.json'), `${JSON.stringify({
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
userId: 'user-123',
|
|
110
|
+
canonicalEmail: 'aayush@example.com',
|
|
111
|
+
})}\n`);
|
|
112
|
+
process.env.HOME = root;
|
|
113
|
+
process.env.AMALGM_HOME = home;
|
|
114
|
+
process.env.AMALGM_RUNTIME_LABEL = 'main';
|
|
115
|
+
process.env.AMALGM_RUNTIME_USER_ID = 'user-123';
|
|
116
|
+
clearScriptModules();
|
|
117
|
+
|
|
118
|
+
const config = require('../config');
|
|
119
|
+
|
|
120
|
+
assert.equal(config.AMALGM_DIR, userDir);
|
|
121
|
+
assert.equal(config.LOCAL_DB_FILE, path.join(userDir, 'amalgm.db'));
|
|
86
122
|
} finally {
|
|
87
123
|
fs.rmSync(root, { recursive: true, force: true });
|
|
88
124
|
}
|
|
@@ -27,8 +27,10 @@ const {
|
|
|
27
27
|
envForBuild,
|
|
28
28
|
manualDesktopNotarizationEnabled,
|
|
29
29
|
notarytoolCredentialArgs,
|
|
30
|
+
notarytoolSubmitTransportArgs,
|
|
30
31
|
parseEnvFile,
|
|
31
32
|
parseUploadOffsetMismatch,
|
|
33
|
+
prepareNotarySubmissionFile,
|
|
32
34
|
publishDesktopRelease,
|
|
33
35
|
recreateZipFromAppBundle,
|
|
34
36
|
recoverableUploadOffset,
|
|
@@ -230,6 +232,33 @@ test('desktop release runner preserves explicit bundled desktop builds', () => {
|
|
|
230
232
|
);
|
|
231
233
|
|
|
232
234
|
assert.equal(env.AMALGM_DESKTOP_THIN, '0');
|
|
235
|
+
assert.equal(env.AMALGM_DESKTOP_RENDERER_DELIVERY, 'bundled-next');
|
|
236
|
+
assert.equal(env.AMALGM_DESKTOP_RUNTIME_DELIVERY, 'bundled-with-npm-handoff');
|
|
237
|
+
assert.equal(env.NEXT_IMAGE_UNOPTIMIZED, '1');
|
|
238
|
+
assert.equal(env.NEXT_PUBLIC_SUPABASE_URL, '');
|
|
239
|
+
assert.equal(env.NEXT_PUBLIC_SUPABASE_ANON_KEY, '');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('desktop release runner supports bundled UI with npm runtime', () => {
|
|
243
|
+
const env = envForBuild(
|
|
244
|
+
{
|
|
245
|
+
GH_TOKEN: 'token',
|
|
246
|
+
AMALGM_DESKTOP_RENDERER_DELIVERY: 'bundled-next',
|
|
247
|
+
AMALGM_DESKTOP_RUNTIME_DELIVERY: 'npm',
|
|
248
|
+
},
|
|
249
|
+
{ lane: 'preview', delivery: 'delivery-id' },
|
|
250
|
+
'0.1.124-canary.1',
|
|
251
|
+
'0.1.124000001-preview.1',
|
|
252
|
+
{ engineRef: 'engine', uiRef: 'ui' },
|
|
253
|
+
{ engineSha: 'engine-sha', uiSha: 'ui-sha' },
|
|
254
|
+
'https://preview.amalgm.ai',
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
assert.equal(env.AMALGM_DESKTOP_RENDERER_DELIVERY, 'bundled-next');
|
|
258
|
+
assert.equal(env.AMALGM_DESKTOP_RUNTIME_DELIVERY, 'npm');
|
|
259
|
+
assert.equal(env.NEXT_IMAGE_UNOPTIMIZED, '1');
|
|
260
|
+
assert.equal(env.NEXT_PUBLIC_SUPABASE_URL, '');
|
|
261
|
+
assert.equal(env.NEXT_PUBLIC_SUPABASE_ANON_KEY, '');
|
|
233
262
|
});
|
|
234
263
|
|
|
235
264
|
test('desktop release runner omits blank certificate file secrets', () => {
|
|
@@ -297,6 +326,9 @@ test('desktop release runner resolves manual notarization credentials and CLI id
|
|
|
297
326
|
credentialType: 'apple-id',
|
|
298
327
|
});
|
|
299
328
|
|
|
329
|
+
assert.deepEqual(notarytoolSubmitTransportArgs({}), ['--no-s3-acceleration']);
|
|
330
|
+
assert.deepEqual(notarytoolSubmitTransportArgs({ AMALGM_DESKTOP_NOTARY_S3_ACCELERATION: '1' }), []);
|
|
331
|
+
|
|
300
332
|
assert.equal(
|
|
301
333
|
codesignIdentityForCli({ CSC_NAME: 'amalgm, Inc. (C5S6UATV3L)' }),
|
|
302
334
|
'Developer ID Application: amalgm, Inc. (C5S6UATV3L)',
|
|
@@ -322,6 +354,25 @@ test('desktop release runner resolves manual notarization credentials and CLI id
|
|
|
322
354
|
}
|
|
323
355
|
});
|
|
324
356
|
|
|
357
|
+
test('desktop release runner submits notarization from a short temporary path', () => {
|
|
358
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm desktop notary source '));
|
|
359
|
+
const sourcePath = path.join(tempRoot, 'amalgm local ui.dmg');
|
|
360
|
+
fs.writeFileSync(sourcePath, 'signed-dmg');
|
|
361
|
+
|
|
362
|
+
const prepared = prepareNotarySubmissionFile(sourcePath);
|
|
363
|
+
try {
|
|
364
|
+
assert.equal(path.extname(prepared.filePath), '.dmg');
|
|
365
|
+
assert.equal(path.basename(prepared.filePath).includes(' '), false);
|
|
366
|
+
assert.equal(prepared.filePath.startsWith(os.tmpdir()), true);
|
|
367
|
+
assert.equal(fs.readFileSync(prepared.filePath, 'utf8'), 'signed-dmg');
|
|
368
|
+
} finally {
|
|
369
|
+
prepared.cleanup();
|
|
370
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
assert.equal(fs.existsSync(prepared.filePath), false);
|
|
374
|
+
});
|
|
375
|
+
|
|
325
376
|
test('desktop release runner resolves app-builder binary path from checkout node_modules or override', () => {
|
|
326
377
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-app-builder-'));
|
|
327
378
|
const engineDir = path.join(tempRoot, 'engine');
|
|
@@ -25,3 +25,28 @@ test('files resource omits dot-prefixed filesystem entries', (t) => {
|
|
|
25
25
|
);
|
|
26
26
|
assert.equal(snapshot.files.some((file) => file.name.startsWith('.')), false);
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test('expandHome: ~amalgm token resolves to the user data dir; legacy hidden uploads redirect', () => {
|
|
30
|
+
const { expandHome } = require('../fs/rest')._private;
|
|
31
|
+
const { AMALGM_DIR } = require('../config');
|
|
32
|
+
|
|
33
|
+
assert.equal(expandHome('~amalgm'), AMALGM_DIR);
|
|
34
|
+
assert.equal(expandHome('~amalgm/uploads/s1/a.png'), path.join(AMALGM_DIR, 'uploads', 's1', 'a.png'));
|
|
35
|
+
// Pre-2026-07 UI builds send old hidden-layout paths (uploads, notes-files,
|
|
36
|
+
// browser-sessions, ...); the whole prefix must land in the user data dir,
|
|
37
|
+
// never recreate ~/.amalgm on disk.
|
|
38
|
+
assert.equal(
|
|
39
|
+
expandHome('~/.amalgm/uploads/s1/a.png'),
|
|
40
|
+
path.join(AMALGM_DIR, 'uploads', 's1', 'a.png'),
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
expandHome('~/.amalgm/notes-files/n.png'),
|
|
44
|
+
path.join(AMALGM_DIR, 'notes-files', 'n.png'),
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
expandHome('~/.amalgm/browser-sessions/rec.webm'),
|
|
48
|
+
path.join(AMALGM_DIR, 'browser-sessions', 'rec.webm'),
|
|
49
|
+
);
|
|
50
|
+
// Ordinary home paths are untouched.
|
|
51
|
+
assert.equal(expandHome('~/Documents/x.txt'), path.join(os.homedir(), 'Documents', 'x.txt'));
|
|
52
|
+
});
|
|
@@ -416,3 +416,62 @@ test('watcher reconciles direct disk edits', async () => {
|
|
|
416
416
|
assert.notEqual(row.contextRevision, before, 'watcher updated the row');
|
|
417
417
|
assert.ok(row.prompt.projectState.includes('Watched edit.'));
|
|
418
418
|
});
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// 2026-07-03 regression: the "amalgm repos" project row flip-flopped between a
|
|
422
|
+
// managed workspace copy and the real home checkout. String inputs went
|
|
423
|
+
// through canonicalProjectPath but workspace-record (object) inputs did not,
|
|
424
|
+
// so every boot pass re-pinned projectPath to the raw row path.
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
test('workspace-record inputs canonicalize legacy managed paths to the home checkout', () => {
|
|
428
|
+
const legacyRoot = fs.mkdtempSync(path.join(tempRoot, 'legacy-ws-'));
|
|
429
|
+
const fakeHome = fs.mkdtempSync(path.join(tempRoot, 'home-'));
|
|
430
|
+
const realCheckout = path.join(fakeHome, 'my repo');
|
|
431
|
+
fs.mkdirSync(realCheckout, { recursive: true });
|
|
432
|
+
const managedCopy = path.join(legacyRoot, 'my repo');
|
|
433
|
+
fs.mkdirSync(managedCopy, { recursive: true });
|
|
434
|
+
|
|
435
|
+
const prevHome = process.env.HOME;
|
|
436
|
+
const prevLegacy = process.env.AMALGM_LEGACY_WORKSPACES_DIR;
|
|
437
|
+
process.env.HOME = fakeHome;
|
|
438
|
+
process.env.AMALGM_LEGACY_WORKSPACES_DIR = legacyRoot;
|
|
439
|
+
try {
|
|
440
|
+
const workspace = upsertWorkspace(
|
|
441
|
+
{ path: managedCopy, name: 'my repo', source: 'managed', managed: true },
|
|
442
|
+
{ source: 'test' },
|
|
443
|
+
);
|
|
444
|
+
const record = store.reconcileProjectContext(
|
|
445
|
+
{ id: workspace.id, path: managedCopy, name: 'my repo' },
|
|
446
|
+
{ source: 'test', force: true },
|
|
447
|
+
);
|
|
448
|
+
assert.equal(record.projectPath, path.resolve(realCheckout));
|
|
449
|
+
} finally {
|
|
450
|
+
process.env.HOME = prevHome;
|
|
451
|
+
if (prevLegacy === undefined) delete process.env.AMALGM_LEGACY_WORKSPACES_DIR;
|
|
452
|
+
else process.env.AMALGM_LEGACY_WORKSPACES_DIR = prevLegacy;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('resolveProject picks the deepest containing workspace for nested checkouts', () => {
|
|
457
|
+
const { projectPath: rootPath, workspace: rootWorkspace } = makeProject('nest-root');
|
|
458
|
+
const childPath = path.join(rootPath, 'child-repo');
|
|
459
|
+
fs.mkdirSync(childPath, { recursive: true });
|
|
460
|
+
const childWorkspace = upsertWorkspace(
|
|
461
|
+
{ path: childPath, name: 'child-repo', source: 'computer' },
|
|
462
|
+
{ source: 'test' },
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
assert.equal(store.resolveProject(path.join(childPath, 'src', 'x.ts')).id, childWorkspace.id);
|
|
466
|
+
assert.equal(store.resolveProject(rootPath).id, rootWorkspace.id);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('memory writes outside any workspace fail with an actionable error', () => {
|
|
470
|
+
const orphan = fs.mkdtempSync(path.join(tempRoot, 'orphan-'));
|
|
471
|
+
assert.throws(
|
|
472
|
+
() => store.appendChangeLog(orphan, '- test entry'),
|
|
473
|
+
(error) =>
|
|
474
|
+
/No registered Amalgm project contains/.test(error.message)
|
|
475
|
+
&& /Registered project roots/.test(error.message),
|
|
476
|
+
);
|
|
477
|
+
});
|
|
@@ -57,7 +57,12 @@ function collectOutput(child, resolve, reject, timeoutMs) {
|
|
|
57
57
|
|
|
58
58
|
function safeEnv(extraEnv) {
|
|
59
59
|
const env = {};
|
|
60
|
-
for (const key of [
|
|
60
|
+
for (const key of [
|
|
61
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR',
|
|
62
|
+
// Platform location contract: CLI tools resolve amalgm-owned state (apps.json,
|
|
63
|
+
// toolbox, workspaces) through these instead of guessing from HOME.
|
|
64
|
+
'AMALGM_DIR', 'AMALGM_HOME', 'AMALGM_WORKSPACES_DIR',
|
|
65
|
+
]) {
|
|
61
66
|
if (process.env[key]) env[key] = process.env[key];
|
|
62
67
|
}
|
|
63
68
|
if (extraEnv && typeof extraEnv === 'object' && !Array.isArray(extraEnv)) {
|
|
@@ -482,8 +482,8 @@ function createContract(payload, options = {}) {
|
|
|
482
482
|
sessionId,
|
|
483
483
|
localBaseUrl,
|
|
484
484
|
proxyToken: options.proxyToken || PROXY_TOKEN,
|
|
485
|
-
proxyBaseUrl: options.proxyBaseUrl || PROXY_BASE_URL ||
|
|
486
|
-
amalgmDir: options.amalgmDir || AMALGM_DIR
|
|
485
|
+
proxyBaseUrl: options.proxyBaseUrl || PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL,
|
|
486
|
+
amalgmDir: options.amalgmDir || AMALGM_DIR,
|
|
487
487
|
userId,
|
|
488
488
|
credentialId: payload.credentialId || payload.byokCredentialId || null,
|
|
489
489
|
modelId,
|
|
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { amalgmDir: resolveAmalgmDir } = require('../../lib/runtime-paths');
|
|
7
8
|
|
|
8
9
|
const STORE_VERSION = 1;
|
|
9
10
|
const CIPHER = 'aes-256-gcm';
|
|
@@ -18,7 +19,7 @@ const DEFAULT_BASE_URL_BY_PROVIDER = {
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
function credentialsPath(amalgmDir) {
|
|
21
|
-
return path.join(amalgmDir ||
|
|
22
|
+
return path.join(amalgmDir || resolveAmalgmDir(), '.credentials');
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
function machineSecret() {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { amalgmDir } = require('../lib/runtime-paths');
|
|
6
7
|
|
|
7
8
|
const SENSITIVE_KEY = /(api[-_]?key|authorization|auth|bearer|cookie|password|secret|credential|access[-_]?token|refresh[-_]?token|session[-_]?token|id[-_]?token)/i;
|
|
8
9
|
|
|
@@ -12,7 +13,7 @@ function recordEnabled() {
|
|
|
12
13
|
|
|
13
14
|
function recordPath() {
|
|
14
15
|
if (process.env.CHAT_CORE_RECORD_PATH) return process.env.CHAT_CORE_RECORD_PATH;
|
|
15
|
-
const root =
|
|
16
|
+
const root = amalgmDir();
|
|
16
17
|
const session = process.env.CHAT_CORE_RECORD_SESSION || new Date().toISOString().slice(0, 10);
|
|
17
18
|
return path.join(root, 'chat-core-recordings', `${session}.ndjson`);
|
|
18
19
|
}
|
|
@@ -4,9 +4,10 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
|
+
const { amalgmDir: resolveAmalgmDir } = require('../../lib/runtime-paths');
|
|
7
8
|
|
|
8
9
|
function amalgmDir() {
|
|
9
|
-
return
|
|
10
|
+
return resolveAmalgmDir();
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function nativeNodeModulesDirs() {
|
|
@@ -7,20 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
10
|
const {
|
|
12
11
|
DEFAULT_PROXY_BASE_URL,
|
|
13
12
|
ensureFreshProxyToken,
|
|
14
13
|
proxyBaseUrl,
|
|
15
14
|
readProxyToken,
|
|
16
15
|
} = require('../proxy-token-store');
|
|
16
|
+
const { amalgmDir } = require('../lib/runtime-paths');
|
|
17
17
|
const { runtimePort } = require('../../lib/runtime-manifest');
|
|
18
18
|
|
|
19
19
|
// ── Paths ──────────────────────────────────────────────────────────────────
|
|
20
20
|
// Set by the orchestrator (entrypoint.sh or electron/main.ts).
|
|
21
21
|
// The code never decides these — it just reads them.
|
|
22
22
|
|
|
23
|
-
const AMALGM_DIR =
|
|
23
|
+
const AMALGM_DIR = amalgmDir();
|
|
24
24
|
const DEFAULT_CWD = process.env.AMALGM_DEFAULT_CWD || '/workspace';
|
|
25
25
|
|
|
26
26
|
// ── Server ──────────────────────────────────────────────────────────────────
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
|
-
const os = require('os');
|
|
14
13
|
const path = require('path');
|
|
14
|
+
const { amalgmDir } = require('./lib/runtime-paths');
|
|
15
15
|
|
|
16
16
|
const VALID_HARNESS_IDS = ['claude_code', 'codex', 'opencode', 'pi'];
|
|
17
17
|
const VALID_AUTH_MODES = ['amalgm', 'provider_auth', 'byok'];
|
|
@@ -22,7 +22,7 @@ const SUPPORTED_AUTH_BY_HARNESS = {
|
|
|
22
22
|
pi: ['amalgm', 'byok', 'provider_auth'],
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
const AMALGM_DIR =
|
|
25
|
+
const AMALGM_DIR = amalgmDir();
|
|
26
26
|
const AUTH_MODES_FILE = path.join(AMALGM_DIR, 'auth-modes.json');
|
|
27
27
|
const AUTH_MODE_LEGACY_FILE = path.join(AMALGM_DIR, 'auth-mode');
|
|
28
28
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const fs = require('fs');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
|
|
6
7
|
function normalizeRuntimeLabel(value) {
|
|
@@ -10,8 +11,67 @@ function normalizeRuntimeLabel(value) {
|
|
|
10
11
|
return 'main';
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
function sanitizeScopeSegment(value, fallback = 'local') {
|
|
15
|
+
const clean = String(value || '')
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/[^A-Za-z0-9_.@-]/g, '_')
|
|
18
|
+
.replace(/^_+|_+$/g, '');
|
|
19
|
+
return clean || fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readJson(file, fallback = null) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function userIdFromRecord(record) {
|
|
31
|
+
return typeof record?.user_id === 'string' && record.user_id.trim()
|
|
32
|
+
? record.user_id.trim()
|
|
33
|
+
: '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function discoverUserScope(homeDir, label) {
|
|
37
|
+
const explicit = process.env.AMALGM_RUNTIME_USER_ID
|
|
38
|
+
|| process.env.AMALGM_USER_SCOPE
|
|
39
|
+
|| process.env.AMALGM_USER_ID;
|
|
40
|
+
if (explicit) return sanitizeScopeSegment(explicit);
|
|
41
|
+
|
|
42
|
+
const rootUser = userIdFromRecord(readJson(path.join(homeDir, 'computer.json'), null))
|
|
43
|
+
|| userIdFromRecord(readJson(path.join(homeDir, 'auth.json'), null));
|
|
44
|
+
if (rootUser) return sanitizeScopeSegment(rootUser);
|
|
45
|
+
|
|
46
|
+
const layout = require('../amalgm-mcp/lib/layout');
|
|
47
|
+
for (const entry of layout.listUserEntries(homeDir)) {
|
|
48
|
+
if (entry.manifest?.userId) return sanitizeScopeSegment(entry.manifest.userId);
|
|
49
|
+
if (entry.userId) return sanitizeScopeSegment(entry.userId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const usersDir = path.join(homeDir, 'users');
|
|
53
|
+
try {
|
|
54
|
+
for (const entry of fs.readdirSync(usersDir, { withFileTypes: true })) {
|
|
55
|
+
if (!entry.isDirectory()) continue;
|
|
56
|
+
const userDir = path.join(usersDir, entry.name);
|
|
57
|
+
const scopedUser = userIdFromRecord(readJson(path.join(userDir, 'computer.json'), null))
|
|
58
|
+
|| userIdFromRecord(readJson(path.join(userDir, 'auth.json'), null))
|
|
59
|
+
|| userIdFromRecord(readJson(path.join(userDir, label, 'computer.json'), null))
|
|
60
|
+
|| userIdFromRecord(readJson(path.join(userDir, label, 'auth.json'), null));
|
|
61
|
+
if (scopedUser) return sanitizeScopeSegment(scopedUser);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// No users yet.
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return 'local';
|
|
68
|
+
}
|
|
69
|
+
|
|
13
70
|
function amalgmDir() {
|
|
14
|
-
|
|
71
|
+
if (process.env.AMALGM_DIR) return process.env.AMALGM_DIR;
|
|
72
|
+
const layout = require('../amalgm-mcp/lib/layout');
|
|
73
|
+
const home = layout.resolveAmalgmHome(process.env);
|
|
74
|
+
return layout.resolveUserDir(home, discoverUserScope(home, runtimeLabel()));
|
|
15
75
|
}
|
|
16
76
|
|
|
17
77
|
function runtimeLabel() {
|
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
} = require('./runtime-auth');
|
|
25
25
|
const { runtimePort } = require('../lib/runtime-manifest');
|
|
26
26
|
const {
|
|
27
|
+
amalgmDir,
|
|
27
28
|
runtimeLogDir,
|
|
28
29
|
runtimeStateDir,
|
|
29
30
|
runtimeStateFile,
|
|
@@ -49,7 +50,7 @@ function loadPty() {
|
|
|
49
50
|
|
|
50
51
|
const pty = loadPty();
|
|
51
52
|
|
|
52
|
-
const AMALGM_DIR =
|
|
53
|
+
const AMALGM_DIR = amalgmDir();
|
|
53
54
|
const STATE_FILE = runtimeStateFile();
|
|
54
55
|
const RUNTIME_COMPUTER_FILE = path.join(runtimeStateDir(), 'computer.json');
|
|
55
56
|
const APPS_FILE = path.join(AMALGM_DIR, 'apps.json');
|