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.
Files changed (39) hide show
  1. package/bin/amalgm.js +73 -0
  2. package/lib/auth-store.js +16 -2
  3. package/lib/cli.js +48 -7
  4. package/lib/identity-adopt.js +43 -0
  5. package/lib/layout.js +17 -10
  6. package/lib/migrate-layout.js +1 -1
  7. package/lib/state-migration.js +23 -1
  8. package/package.json +2 -2
  9. package/runtime/scripts/amalgm-mcp/agents/store.js +8 -2
  10. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +116 -11
  11. package/runtime/scripts/amalgm-mcp/browser/backend.js +5 -2
  12. package/runtime/scripts/amalgm-mcp/browser/cli.js +2 -1
  13. package/runtime/scripts/amalgm-mcp/config.js +26 -5
  14. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +119 -53
  15. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +2 -2
  16. package/runtime/scripts/amalgm-mcp/events/npm-release-runner.js +4 -2
  17. package/runtime/scripts/amalgm-mcp/events/pr-check-runner.js +4 -2
  18. package/runtime/scripts/amalgm-mcp/fs/rest.js +20 -1
  19. package/runtime/scripts/amalgm-mcp/lib/layout.js +17 -10
  20. package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +2 -2
  21. package/runtime/scripts/amalgm-mcp/project-context/store.js +27 -5
  22. package/runtime/scripts/amalgm-mcp/tests/agents-source-of-truth.test.js +13 -0
  23. package/runtime/scripts/amalgm-mcp/tests/apps-supervisor.test.js +190 -0
  24. package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +2 -3
  25. package/runtime/scripts/amalgm-mcp/tests/config-migration.test.js +37 -1
  26. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +51 -0
  27. package/runtime/scripts/amalgm-mcp/tests/files-resource.test.js +25 -0
  28. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +59 -0
  29. package/runtime/scripts/amalgm-mcp/toolbox/runner.js +6 -1
  30. package/runtime/scripts/chat-core/contract.js +2 -2
  31. package/runtime/scripts/chat-core/credentials/store.js +2 -1
  32. package/runtime/scripts/chat-core/recorder.js +2 -1
  33. package/runtime/scripts/chat-core/tooling/native-binaries.js +2 -1
  34. package/runtime/scripts/chat-server/config.js +2 -2
  35. package/runtime/scripts/credential-adapter.js +2 -2
  36. package/runtime/scripts/lib/runtime-paths.js +61 -1
  37. package/runtime/scripts/local-gateway.js +2 -1
  38. package/runtime/scripts/proxy-token-store.js +7 -4
  39. 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, home', () => {
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.slice(0, 3), [
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 = { ...process.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 ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR']) {
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 || 'https://amalgm-api-proxy-v2.fly.dev',
486
- amalgmDir: options.amalgmDir || AMALGM_DIR || path.join(process.env.HOME || '.', '.amalgm'),
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 || path.join(os.homedir(), '.amalgm'), '.credentials');
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 = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
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 process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
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 = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
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 = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
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
- return process.env.AMALGM_DIR || require('../amalgm-mcp/lib/layout').resolveAmalgmHome(process.env);
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 = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
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');