amalgm 0.0.0 → 0.0.1

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 (107) hide show
  1. package/README.md +37 -1
  2. package/bin/amalgm.js +8 -2
  3. package/lib/auth-store.js +223 -0
  4. package/lib/cli.js +1000 -0
  5. package/lib/paths.js +30 -0
  6. package/lib/supervisor.js +467 -0
  7. package/lib/tunnel-chat.js +328 -0
  8. package/lib/tunnel-events.js +499 -0
  9. package/package.json +29 -3
  10. package/runtime/README.md +4 -0
  11. package/runtime/lib/chatInput.js +306 -0
  12. package/runtime/lib/harnesses.js +988 -0
  13. package/runtime/lib/local/amalgmStore.js +128 -0
  14. package/runtime/lib/local/credentialResolver.js +425 -0
  15. package/runtime/lib/mcpApps/registry.js +619 -0
  16. package/runtime/package.json +5 -0
  17. package/runtime/scripts/amalgm-mcp/agents/rest.js +165 -0
  18. package/runtime/scripts/amalgm-mcp/agents/store.js +153 -0
  19. package/runtime/scripts/amalgm-mcp/agents/talk.js +1156 -0
  20. package/runtime/scripts/amalgm-mcp/agents/tools.js +210 -0
  21. package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
  22. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
  23. package/runtime/scripts/amalgm-mcp/artifacts/store.js +141 -0
  24. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
  25. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
  26. package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
  27. package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
  28. package/runtime/scripts/amalgm-mcp/config.js +138 -0
  29. package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
  30. package/runtime/scripts/amalgm-mcp/deps.js +40 -0
  31. package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
  32. package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
  33. package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
  34. package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
  35. package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
  36. package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
  37. package/runtime/scripts/amalgm-mcp/events/store.js +98 -0
  38. package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
  39. package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
  40. package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
  41. package/runtime/scripts/amalgm-mcp/index.js +100 -0
  42. package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
  43. package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
  44. package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
  45. package/runtime/scripts/amalgm-mcp/lib/prefs.js +393 -0
  46. package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
  47. package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
  48. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
  49. package/runtime/scripts/amalgm-mcp/local/rest.js +80 -0
  50. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +151 -0
  51. package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
  52. package/runtime/scripts/amalgm-mcp/server/http.js +335 -0
  53. package/runtime/scripts/amalgm-mcp/server/mcp.js +116 -0
  54. package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
  55. package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
  56. package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
  57. package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
  58. package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
  59. package/runtime/scripts/amalgm-mcp/tasks/store.js +139 -0
  60. package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
  61. package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/rest.js +389 -0
  63. package/runtime/scripts/chat-core/adapters/claude.js +163 -0
  64. package/runtime/scripts/chat-core/adapters/codex.js +313 -0
  65. package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
  66. package/runtime/scripts/chat-core/auth.js +177 -0
  67. package/runtime/scripts/chat-core/contract.js +326 -0
  68. package/runtime/scripts/chat-core/credentials/store.js +212 -0
  69. package/runtime/scripts/chat-core/egress.js +87 -0
  70. package/runtime/scripts/chat-core/engine.js +195 -0
  71. package/runtime/scripts/chat-core/event-schema.js +231 -0
  72. package/runtime/scripts/chat-core/events.js +190 -0
  73. package/runtime/scripts/chat-core/index.js +11 -0
  74. package/runtime/scripts/chat-core/input.js +50 -0
  75. package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
  76. package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
  77. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
  78. package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
  79. package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
  80. package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
  81. package/runtime/scripts/chat-core/parts.js +253 -0
  82. package/runtime/scripts/chat-core/recorder.js +65 -0
  83. package/runtime/scripts/chat-core/runtime.js +86 -0
  84. package/runtime/scripts/chat-core/server.js +163 -0
  85. package/runtime/scripts/chat-core/sse.js +196 -0
  86. package/runtime/scripts/chat-core/stores.js +100 -0
  87. package/runtime/scripts/chat-core/tool-display.js +149 -0
  88. package/runtime/scripts/chat-core/tool-shape.js +143 -0
  89. package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
  90. package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
  91. package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
  92. package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
  93. package/runtime/scripts/chat-core/usage.js +343 -0
  94. package/runtime/scripts/chat-server/config.js +110 -0
  95. package/runtime/scripts/chat-server/db.js +529 -0
  96. package/runtime/scripts/chat-server/index.js +33 -0
  97. package/runtime/scripts/chat-server/model-catalog.js +327 -0
  98. package/runtime/scripts/chat-server.js +75 -0
  99. package/runtime/scripts/credential-adapter.js +129 -0
  100. package/runtime/scripts/fs-watcher.js +888 -0
  101. package/runtime/scripts/local-gateway.js +852 -0
  102. package/runtime/scripts/platform-context.txt +246 -0
  103. package/runtime/scripts/port-monitor.js +175 -0
  104. package/runtime/scripts/proxy-token-store.js +162 -0
  105. package/runtime/scripts/runtime-auth.js +163 -0
  106. package/runtime/scripts/test-claude-code-models.js +87 -0
  107. package/runtime/tsconfig.json +15 -0
@@ -0,0 +1,852 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local Gateway
5
+ *
6
+ * One discoverable loopback entrypoint for the local Amalgm runtime. The
7
+ * individual services still run as separate processes, but clients only need
8
+ * to find this gateway port from ~/.amalgm/runtime-state.json.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const fs = require('fs');
13
+ const http = require('http');
14
+ const net = require('net');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const { URL } = require('url');
18
+ const { WebSocket, WebSocketServer } = require('ws');
19
+
20
+ const {
21
+ authorizeRuntimeHttp,
22
+ isAuthorizedRuntimeRequest,
23
+ runtimeAuthHeaders,
24
+ } = require('./runtime-auth');
25
+
26
+ function loadPty() {
27
+ const candidates = [
28
+ '@homebridge/node-pty-prebuilt-multiarch',
29
+ 'node-pty',
30
+ ];
31
+ for (const packageName of candidates) {
32
+ try {
33
+ return {
34
+ packageName,
35
+ module: require(packageName),
36
+ };
37
+ } catch {
38
+ // Try the next compatible PTY implementation.
39
+ }
40
+ }
41
+ throw new Error('No PTY implementation is installed. Reinstall Amalgm and try again.');
42
+ }
43
+
44
+ const pty = loadPty();
45
+
46
+ const AMALGM_DIR = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
47
+ const STATE_FILE = path.join(AMALGM_DIR, 'runtime-state.json');
48
+ const ARTIFACTS_FILE = path.join(AMALGM_DIR, 'artifacts.json');
49
+ const BIND_HOST = process.env.AMALGM_BIND_HOST || '127.0.0.1';
50
+ const OWNER = process.env.AMALGM_RUNTIME_SOURCE || 'local';
51
+ const VERSION = process.env.npm_package_version || process.env.AMALGM_RUNTIME_VERSION || '';
52
+ const DEFAULT_CWD = process.env.AMALGM_DEFAULT_CWD || os.homedir();
53
+ const PORT = Number.parseInt(process.env.AMALGM_GATEWAY_PORT || '28781', 10);
54
+ const RUNTIME_TOKEN_HEADER = 'x-amalgm-runtime-token';
55
+
56
+ const SERVICE_PORTS = {
57
+ gateway: PORT,
58
+ portMonitor: Number.parseInt(process.env.PORT_MONITOR_PORT || '8081', 10),
59
+ fsWatcher: Number.parseInt(process.env.FS_WATCHER_PORT || '8082', 10),
60
+ mcp: Number.parseInt(process.env.AMALGM_MCP_PORT || '8083', 10),
61
+ chat: Number.parseInt(process.env.CHAT_SERVER_PORT || '8084', 10),
62
+ };
63
+ const LOCAL_AGENT_APP_PORT = Number.parseInt(process.env.AMALGM_LOCAL_APP_PORT || '3456', 10);
64
+
65
+ const HOP_BY_HOP_HEADERS = new Set([
66
+ 'connection',
67
+ 'keep-alive',
68
+ 'proxy-authenticate',
69
+ 'proxy-authorization',
70
+ 'te',
71
+ 'trailer',
72
+ 'trailers',
73
+ 'transfer-encoding',
74
+ 'upgrade',
75
+ 'host',
76
+ 'content-length',
77
+ ]);
78
+
79
+ const WS_HANDSHAKE_HEADERS = new Set([
80
+ 'connection',
81
+ 'upgrade',
82
+ 'host',
83
+ 'sec-websocket-key',
84
+ 'sec-websocket-version',
85
+ 'sec-websocket-extensions',
86
+ 'sec-websocket-accept',
87
+ 'sec-websocket-protocol',
88
+ ]);
89
+
90
+ const CHAT_PREFIXES = [
91
+ '/chat',
92
+ '/raw-temp',
93
+ '/active',
94
+ '/stop',
95
+ '/destroy',
96
+ '/egress',
97
+ '/mcp-relay',
98
+ ];
99
+
100
+ const MCP_PREFIXES = [
101
+ '/mcp',
102
+ '/events',
103
+ '/tasks',
104
+ '/event-triggers',
105
+ '/workspace',
106
+ '/github',
107
+ '/fs',
108
+ '/mcp-connections',
109
+ '/local',
110
+ '/credentials',
111
+ '/email',
112
+ '/slack',
113
+ '/user-api-keys',
114
+ '/artifacts',
115
+ '/agents',
116
+ ];
117
+
118
+ const PORT_MONITOR_PREFIXES = [
119
+ '/ports',
120
+ '/port-monitor',
121
+ ];
122
+
123
+ const ptySessions = new Map();
124
+ const monitoredPreviewPorts = new Set();
125
+
126
+ function readArtifacts() {
127
+ const data = readJson(ARTIFACTS_FILE, { artifacts: [] });
128
+ return Array.isArray(data?.artifacts) ? data.artifacts : [];
129
+ }
130
+
131
+ function normalizeArtifactRef(artifact) {
132
+ return String(artifact?.artifactRef || artifact?.artifact_ref || '').trim();
133
+ }
134
+
135
+ function isArtifactRoutable(artifact) {
136
+ const port = Number(artifact?.port);
137
+ const connected = artifact?.dnsConnected !== false && artifact?.connected !== false;
138
+ const desiredRunning = (artifact?.desiredState || 'running') === 'running';
139
+ const active = artifact?.status !== 'stopped' && artifact?.status !== 'error';
140
+ return (
141
+ /^[a-z0-9]{8,24}$/.test(normalizeArtifactRef(artifact))
142
+ && Number.isInteger(port)
143
+ && port > 0
144
+ && port <= 65535
145
+ && connected
146
+ && desiredRunning
147
+ && active
148
+ );
149
+ }
150
+
151
+ function findArtifactByRef(artifactRef) {
152
+ return readArtifacts().find((artifact) =>
153
+ normalizeArtifactRef(artifact) === artifactRef && isArtifactRoutable(artifact),
154
+ ) || null;
155
+ }
156
+
157
+ function activeArtifactPorts() {
158
+ return new Set(readArtifacts().filter(isArtifactRoutable).map((artifact) => Number(artifact.port)));
159
+ }
160
+
161
+ function isInternalRuntimePort(port) {
162
+ return (
163
+ port === LOCAL_AGENT_APP_PORT
164
+ || Object.values(SERVICE_PORTS).some((value) => Number(value) === port)
165
+ );
166
+ }
167
+
168
+ function isAllowedPreviewPort(port) {
169
+ return (
170
+ Number.isInteger(port)
171
+ && port > 0
172
+ && port <= 65535
173
+ && !isInternalRuntimePort(port)
174
+ && (activeArtifactPorts().has(port) || monitoredPreviewPorts.has(port))
175
+ );
176
+ }
177
+
178
+ function refreshMonitoredPreviewPorts() {
179
+ const req = http.request(
180
+ {
181
+ host: '127.0.0.1',
182
+ port: SERVICE_PORTS.portMonitor,
183
+ method: 'GET',
184
+ path: '/api/ports',
185
+ headers: runtimeAuthHeaders({ accept: 'application/json' }),
186
+ timeout: 2000,
187
+ },
188
+ (res) => {
189
+ const chunks = [];
190
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
191
+ res.on('end', () => {
192
+ if ((res.statusCode || 500) < 200 || (res.statusCode || 500) >= 300) return;
193
+ let payload = null;
194
+ try {
195
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
196
+ } catch {
197
+ return;
198
+ }
199
+ const next = new Set();
200
+ for (const value of Array.isArray(payload?.ports) ? payload.ports : []) {
201
+ const port = Number(
202
+ typeof value === 'object' && value !== null && 'port' in value
203
+ ? value.port
204
+ : value,
205
+ );
206
+ if (
207
+ Number.isInteger(port)
208
+ && port > 0
209
+ && port <= 65535
210
+ && !isInternalRuntimePort(port)
211
+ ) {
212
+ next.add(port);
213
+ }
214
+ }
215
+ monitoredPreviewPorts.clear();
216
+ for (const port of next) monitoredPreviewPorts.add(port);
217
+ });
218
+ },
219
+ );
220
+ req.on('timeout', () => req.destroy());
221
+ req.on('error', () => {});
222
+ req.end();
223
+ }
224
+
225
+ function ensureDir(dir, mode = 0o700) {
226
+ fs.mkdirSync(dir, { recursive: true, mode });
227
+ try {
228
+ fs.chmodSync(dir, mode);
229
+ } catch {
230
+ // Best effort.
231
+ }
232
+ }
233
+
234
+ function writeJsonSecret(file, data) {
235
+ ensureDir(path.dirname(file));
236
+ const temp = `${file}.${process.pid}.tmp`;
237
+ fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
238
+ fs.renameSync(temp, file);
239
+ try {
240
+ fs.chmodSync(file, 0o600);
241
+ } catch {
242
+ // Best effort.
243
+ }
244
+ }
245
+
246
+ function readJson(file, fallback = null) {
247
+ try {
248
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
249
+ } catch {
250
+ return fallback;
251
+ }
252
+ }
253
+
254
+ function ensurePtySpawnHelperExecutable() {
255
+ try {
256
+ const nodePtyRoot = path.dirname(require.resolve(`${pty.packageName}/package.json`));
257
+ const candidates = [
258
+ path.join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
259
+ path.join(nodePtyRoot, 'build', 'Release', 'spawn-helper'),
260
+ ];
261
+ for (const helper of candidates) {
262
+ if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
263
+ }
264
+ } catch {
265
+ // Best effort. node-pty either does not need a helper on this platform or
266
+ // spawn will return the underlying error to the caller.
267
+ }
268
+ }
269
+
270
+ function writeRuntimeState(actualPort) {
271
+ const computer = readJson(path.join(AMALGM_DIR, 'computer.json'), null);
272
+ writeJsonSecret(STATE_FILE, {
273
+ schema_version: 1,
274
+ owner: OWNER,
275
+ source: OWNER,
276
+ pid: process.pid,
277
+ supervisor_pid: process.ppid,
278
+ version: VERSION,
279
+ package_version: VERSION,
280
+ runtime_dir: process.env.AMALGM_RUNTIME_DIR || process.cwd(),
281
+ local_only: process.env.AMALGM_LOCAL_ONLY === 'true',
282
+ bind_host: BIND_HOST,
283
+ gateway_port: actualPort,
284
+ gateway_url: `http://${BIND_HOST}:${actualPort}`,
285
+ ports: {
286
+ gateway: actualPort,
287
+ port_monitor: SERVICE_PORTS.portMonitor,
288
+ fs_watcher: SERVICE_PORTS.fsWatcher,
289
+ amalgm_mcp: SERVICE_PORTS.mcp,
290
+ chat_server: SERVICE_PORTS.chat,
291
+ },
292
+ computer_id: computer?.computer_id || process.env.AMALGM_COMPUTER_ID || '',
293
+ user_id: computer?.user_id || process.env.AMALGM_USER_ID || '',
294
+ updated_at: new Date().toISOString(),
295
+ });
296
+ }
297
+
298
+ function removeRuntimeStateForThisProcess() {
299
+ try {
300
+ const state = readJson(STATE_FILE, null);
301
+ if (state?.pid === process.pid) fs.unlinkSync(STATE_FILE);
302
+ } catch {
303
+ // noop
304
+ }
305
+ }
306
+
307
+ function sendJson(res, statusCode, payload) {
308
+ res.writeHead(statusCode, {
309
+ 'Content-Type': 'application/json',
310
+ 'Cache-Control': 'no-store',
311
+ });
312
+ res.end(JSON.stringify(payload));
313
+ }
314
+
315
+ async function readBody(req) {
316
+ const chunks = [];
317
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
318
+ return Buffer.concat(chunks);
319
+ }
320
+
321
+ async function readJsonBody(req) {
322
+ const body = await readBody(req);
323
+ if (body.length === 0) return {};
324
+ return JSON.parse(body.toString('utf8'));
325
+ }
326
+
327
+ function normalizeHeaders(headers = {}) {
328
+ const out = {};
329
+ for (const [key, value] of Object.entries(headers)) {
330
+ const lower = key.toLowerCase();
331
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue;
332
+ if (Array.isArray(value)) out[key] = value.join(', ');
333
+ else if (typeof value === 'string') out[key] = value;
334
+ else if (value != null) out[key] = String(value);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function normalizeExternalHeaders(headers = {}) {
340
+ const out = normalizeHeaders(headers);
341
+ for (const key of Object.keys(out)) {
342
+ const lower = key.toLowerCase();
343
+ if (lower === RUNTIME_TOKEN_HEADER) {
344
+ delete out[key];
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ function normalizeWsHeaders(headers = {}) {
351
+ const out = {};
352
+ for (const [key, value] of Object.entries(headers)) {
353
+ const lower = key.toLowerCase();
354
+ if (HOP_BY_HOP_HEADERS.has(lower) || WS_HANDSHAKE_HEADERS.has(lower)) continue;
355
+ if (Array.isArray(value)) out[key] = value.join(', ');
356
+ else if (typeof value === 'string') out[key] = value;
357
+ else if (value != null) out[key] = String(value);
358
+ }
359
+ return out;
360
+ }
361
+
362
+ function normalizeExternalWsHeaders(headers = {}) {
363
+ const out = normalizeWsHeaders(headers);
364
+ for (const key of Object.keys(out)) {
365
+ const lower = key.toLowerCase();
366
+ if (lower === RUNTIME_TOKEN_HEADER) {
367
+ delete out[key];
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+
373
+ function parseWsProtocols(value) {
374
+ const raw = Array.isArray(value) ? value.join(',') : String(value || '');
375
+ return raw.split(',').map((entry) => entry.trim()).filter(Boolean);
376
+ }
377
+
378
+ function forwardedHost(headers = {}) {
379
+ const value = headers['x-forwarded-host'] || headers['X-Forwarded-Host'];
380
+ if (Array.isArray(value)) return value[0] || '';
381
+ return typeof value === 'string' ? value : '';
382
+ }
383
+
384
+ function serviceForPath(pathname) {
385
+ if (pathname === '/healthz' || pathname === '/' || pathname === '/runtime-state') {
386
+ return null;
387
+ }
388
+ if (CHAT_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {
389
+ return { port: SERVICE_PORTS.chat, stripPrefix: '' };
390
+ }
391
+ if (MCP_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {
392
+ return { port: SERVICE_PORTS.mcp, stripPrefix: '' };
393
+ }
394
+ if (pathname === '/fs-watcher' || pathname.startsWith('/fs-watcher/')) {
395
+ return {
396
+ port: SERVICE_PORTS.fsWatcher,
397
+ stripPrefix: '/fs-watcher',
398
+ };
399
+ }
400
+ if (PORT_MONITOR_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {
401
+ return {
402
+ port: SERVICE_PORTS.portMonitor,
403
+ stripPrefix: pathname.startsWith('/port-monitor') ? '/port-monitor' : '/ports',
404
+ };
405
+ }
406
+ return null;
407
+ }
408
+
409
+ function forwardPathAfterPrefix(req, prefix) {
410
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
411
+ url.pathname = url.pathname.slice(prefix.length) || '/';
412
+ if (!url.pathname.startsWith('/')) url.pathname = `/${url.pathname}`;
413
+ return `${url.pathname}${url.search}`;
414
+ }
415
+
416
+ function externalProxyTargetForPath(pathname) {
417
+ const artifactMatch = pathname.match(/^\/__amalgm\/artifacts\/([a-z0-9]{8,24})(?:\/|$)/);
418
+ if (artifactMatch) {
419
+ const artifactRef = artifactMatch[1];
420
+ const artifact = findArtifactByRef(artifactRef);
421
+ if (!artifact) {
422
+ return { error: { status: 404, message: `Artifact is not routable: ${artifactRef}` } };
423
+ }
424
+ return {
425
+ port: Number(artifact.port),
426
+ prefix: `/__amalgm/artifacts/${artifactRef}`,
427
+ kind: 'artifact',
428
+ };
429
+ }
430
+
431
+ const previewMatch = pathname.match(/^\/__amalgm\/preview\/(\d{1,5})(?:\/|$)/);
432
+ if (previewMatch) {
433
+ const port = Number(previewMatch[1]);
434
+ if (!isAllowedPreviewPort(port)) {
435
+ return { error: { status: 403, message: `Preview port is not allowed: ${port}` } };
436
+ }
437
+ return {
438
+ port,
439
+ prefix: `/__amalgm/preview/${port}`,
440
+ kind: 'preview',
441
+ };
442
+ }
443
+
444
+ return null;
445
+ }
446
+
447
+ function proxiedPath(req, target) {
448
+ if (!target?.stripPrefix) return req.url || '/';
449
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
450
+ url.pathname = url.pathname.slice(target.stripPrefix.length) || '/';
451
+ return `${url.pathname}${url.search}`;
452
+ }
453
+
454
+ async function proxyHttp(req, res, target) {
455
+ const body = await readBody(req);
456
+ const upstreamHeaders = runtimeAuthHeaders({
457
+ ...normalizeHeaders(req.headers),
458
+ ...(body.length > 0 ? { 'Content-Length': String(body.length) } : {}),
459
+ host: `127.0.0.1:${target.port}`,
460
+ });
461
+
462
+ const upstream = http.request(
463
+ {
464
+ host: '127.0.0.1',
465
+ port: target.port,
466
+ method: req.method || 'GET',
467
+ path: proxiedPath(req, target),
468
+ headers: upstreamHeaders,
469
+ timeout: 60_000,
470
+ },
471
+ (upstreamRes) => {
472
+ res.writeHead(upstreamRes.statusCode || 502, normalizeHeaders(upstreamRes.headers));
473
+ upstreamRes.pipe(res);
474
+ },
475
+ );
476
+
477
+ upstream.on('timeout', () => upstream.destroy(new Error('Upstream request timed out')));
478
+ upstream.on('error', (err) => {
479
+ if (!res.headersSent) {
480
+ sendJson(res, 502, { error: 'Upstream request failed', detail: err.message });
481
+ return;
482
+ }
483
+ res.destroy(err);
484
+ });
485
+
486
+ upstream.end(body);
487
+ }
488
+
489
+ async function proxyExternalHttp(req, res, target) {
490
+ const body = await readBody(req);
491
+ const upstreamHeaders = {
492
+ ...normalizeExternalHeaders(req.headers),
493
+ ...(body.length > 0 ? { 'Content-Length': String(body.length) } : {}),
494
+ host: forwardedHost(req.headers) || `127.0.0.1:${target.port}`,
495
+ };
496
+
497
+ const upstream = http.request(
498
+ {
499
+ host: '127.0.0.1',
500
+ port: target.port,
501
+ method: req.method || 'GET',
502
+ path: forwardPathAfterPrefix(req, target.prefix),
503
+ headers: upstreamHeaders,
504
+ timeout: 60_000,
505
+ },
506
+ (upstreamRes) => {
507
+ res.writeHead(upstreamRes.statusCode || 502, normalizeHeaders(upstreamRes.headers));
508
+ upstreamRes.pipe(res);
509
+ },
510
+ );
511
+
512
+ upstream.on('timeout', () => upstream.destroy(new Error('Upstream request timed out')));
513
+ upstream.on('error', (err) => {
514
+ if (!res.headersSent) {
515
+ sendJson(res, 502, { error: 'External preview request failed', detail: err.message });
516
+ return;
517
+ }
518
+ res.destroy(err);
519
+ });
520
+
521
+ upstream.end(body);
522
+ }
523
+
524
+ function createPtySession(payload) {
525
+ const cols = Math.max(20, Math.min(400, Number(payload?.cols || 80)));
526
+ const rows = Math.max(5, Math.min(200, Number(payload?.rows || 24)));
527
+ const cwd = typeof payload?.cwd === 'string' && path.isAbsolute(payload.cwd) ? payload.cwd : DEFAULT_CWD;
528
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
529
+ const sessionId = `pty-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
530
+ ensurePtySpawnHelperExecutable();
531
+ const proc = pty.module.spawn(shell, [], {
532
+ name: 'xterm-256color',
533
+ cols,
534
+ rows,
535
+ cwd,
536
+ env: {
537
+ ...process.env,
538
+ TERM: 'xterm-256color',
539
+ LANG: process.env.LANG || 'en_US.UTF-8',
540
+ },
541
+ });
542
+
543
+ const session = {
544
+ id: sessionId,
545
+ proc,
546
+ clients: new Set(),
547
+ createdAt: Date.now(),
548
+ };
549
+ ptySessions.set(sessionId, session);
550
+ proc.onExit(({ exitCode }) => {
551
+ for (const ws of session.clients) {
552
+ try {
553
+ ws.send(JSON.stringify({ type: 'exit', exitCode }));
554
+ ws.close(1000, 'pty exited');
555
+ } catch {
556
+ // noop
557
+ }
558
+ }
559
+ ptySessions.delete(sessionId);
560
+ });
561
+
562
+ return session;
563
+ }
564
+
565
+ async function handlePtyHttp(req, res, pathname) {
566
+ if (req.method === 'POST' && pathname === '/pty/session') {
567
+ const payload = await readJsonBody(req);
568
+ const session = createPtySession(payload);
569
+ sendJson(res, 200, {
570
+ success: true,
571
+ sessionId: session.id,
572
+ wsPath: `/pty/${session.id}/connect`,
573
+ });
574
+ return;
575
+ }
576
+
577
+ if (req.method === 'POST' && pathname === '/pty/resize') {
578
+ const payload = await readJsonBody(req);
579
+ const sessionId = String(payload?.sessionId || '');
580
+ const session = ptySessions.get(sessionId);
581
+ if (!session) {
582
+ sendJson(res, 404, { error: 'PTY session not found' });
583
+ return;
584
+ }
585
+ const cols = Math.max(20, Math.min(400, Number(payload?.cols || 80)));
586
+ const rows = Math.max(5, Math.min(200, Number(payload?.rows || 24)));
587
+ session.proc.resize(cols, rows);
588
+ sendJson(res, 200, { success: true });
589
+ return;
590
+ }
591
+
592
+ if (req.method === 'DELETE' && pathname === '/pty/session') {
593
+ const payload = await readJsonBody(req);
594
+ const sessionId = String(payload?.sessionId || '');
595
+ const session = ptySessions.get(sessionId);
596
+ if (session) {
597
+ try {
598
+ session.proc.kill();
599
+ } catch {
600
+ // noop
601
+ }
602
+ ptySessions.delete(sessionId);
603
+ }
604
+ sendJson(res, 200, { success: true });
605
+ return;
606
+ }
607
+
608
+ sendJson(res, 404, { error: 'Unknown PTY route' });
609
+ }
610
+
611
+ function bridgePtyWebSocket(req, ws, pathname) {
612
+ if (!isAuthorizedRuntimeRequest(req)) {
613
+ ws.close(4401, 'Unauthorized');
614
+ return;
615
+ }
616
+
617
+ const match = pathname.match(/^\/pty\/([^/]+)\/connect$/);
618
+ const session = match ? ptySessions.get(match[1]) : null;
619
+ if (!session) {
620
+ ws.close(4404, 'PTY session not found');
621
+ return;
622
+ }
623
+
624
+ session.clients.add(ws);
625
+ const disposable = session.proc.onData((data) => {
626
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
627
+ });
628
+
629
+ ws.on('message', (data) => {
630
+ try {
631
+ session.proc.write(Buffer.isBuffer(data) ? data.toString('utf8') : String(data));
632
+ } catch {
633
+ // noop
634
+ }
635
+ });
636
+
637
+ ws.on('close', () => {
638
+ session.clients.delete(ws);
639
+ disposable.dispose();
640
+ if (session.clients.size === 0) {
641
+ // Keep the terminal briefly alive across tab reloads, then clean it up.
642
+ setTimeout(() => {
643
+ if (session.clients.size > 0 || !ptySessions.has(session.id)) return;
644
+ try {
645
+ session.proc.kill();
646
+ } catch {
647
+ // noop
648
+ }
649
+ ptySessions.delete(session.id);
650
+ }, 30_000).unref?.();
651
+ }
652
+ });
653
+ }
654
+
655
+ function proxyWebSocket(req, client, target, pathOverride) {
656
+ if (!isAuthorizedRuntimeRequest(req)) {
657
+ client.close(4401, 'Unauthorized');
658
+ return;
659
+ }
660
+
661
+ const targetPath = pathOverride || proxiedPath(req, target);
662
+ const upstream = new WebSocket(`ws://127.0.0.1:${target.port}${targetPath}`, {
663
+ headers: runtimeAuthHeaders(normalizeWsHeaders(req.headers)),
664
+ });
665
+
666
+ client.on('message', (data, isBinary) => {
667
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary: isBinary });
668
+ });
669
+ upstream.on('message', (data, isBinary) => {
670
+ if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary });
671
+ });
672
+ client.on('close', (code, reason) => {
673
+ try {
674
+ upstream.close(code, reason.toString('utf8'));
675
+ } catch {
676
+ // noop
677
+ }
678
+ });
679
+ upstream.on('close', (code, reason) => {
680
+ try {
681
+ client.close(code, reason.toString('utf8'));
682
+ } catch {
683
+ // noop
684
+ }
685
+ });
686
+ upstream.on('error', (err) => {
687
+ try {
688
+ client.close(1011, err.message);
689
+ } catch {
690
+ // noop
691
+ }
692
+ });
693
+ }
694
+
695
+ function proxyExternalWebSocket(req, client, target) {
696
+ if (!isAuthorizedRuntimeRequest(req)) {
697
+ client.close(4401, 'Unauthorized');
698
+ return;
699
+ }
700
+
701
+ const targetPath = forwardPathAfterPrefix(req, target.prefix);
702
+ const protocols = parseWsProtocols(req.headers['sec-websocket-protocol']);
703
+ const upstream = protocols.length > 0
704
+ ? new WebSocket(`ws://127.0.0.1:${target.port}${targetPath}`, protocols, {
705
+ headers: normalizeExternalWsHeaders(req.headers),
706
+ })
707
+ : new WebSocket(`ws://127.0.0.1:${target.port}${targetPath}`, {
708
+ headers: normalizeExternalWsHeaders(req.headers),
709
+ });
710
+
711
+ client.on('message', (data, isBinary) => {
712
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary: isBinary });
713
+ });
714
+ upstream.on('message', (data, isBinary) => {
715
+ if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary });
716
+ });
717
+ client.on('close', (code, reason) => {
718
+ try {
719
+ upstream.close(code, reason.toString('utf8'));
720
+ } catch {
721
+ // noop
722
+ }
723
+ });
724
+ upstream.on('close', (code, reason) => {
725
+ try {
726
+ client.close(code, reason.toString('utf8'));
727
+ } catch {
728
+ // noop
729
+ }
730
+ });
731
+ upstream.on('error', (err) => {
732
+ try {
733
+ client.close(1011, err.message);
734
+ } catch {
735
+ // noop
736
+ }
737
+ });
738
+ }
739
+
740
+ const server = http.createServer(async (req, res) => {
741
+ const url = new URL(req.url || '/', `http://${BIND_HOST}:${PORT}`);
742
+ try {
743
+ if (url.pathname === '/healthz' || url.pathname === '/') {
744
+ sendJson(res, 200, {
745
+ status: 'ok',
746
+ service: 'local-gateway',
747
+ owner: OWNER,
748
+ pid: process.pid,
749
+ ports: SERVICE_PORTS,
750
+ });
751
+ return;
752
+ }
753
+
754
+ if (!authorizeRuntimeHttp(req, res, { exposeHeaders: 'Mcp-Session-Id' })) return;
755
+
756
+ if (url.pathname === '/runtime-state') {
757
+ sendJson(res, 200, readJson(STATE_FILE, {}));
758
+ return;
759
+ }
760
+
761
+ if (url.pathname === '/pty/session' || url.pathname === '/pty/resize') {
762
+ await handlePtyHttp(req, res, url.pathname);
763
+ return;
764
+ }
765
+
766
+ const externalTarget = externalProxyTargetForPath(url.pathname);
767
+ if (externalTarget?.error) {
768
+ sendJson(res, externalTarget.error.status, { error: externalTarget.error.message });
769
+ return;
770
+ }
771
+ if (externalTarget) {
772
+ await proxyExternalHttp(req, res, externalTarget);
773
+ return;
774
+ }
775
+
776
+ const target = serviceForPath(url.pathname);
777
+ if (!target) {
778
+ sendJson(res, 404, { error: 'Unknown local gateway route' });
779
+ return;
780
+ }
781
+
782
+ await proxyHttp(req, res, target);
783
+ } catch (err) {
784
+ const message = err instanceof Error ? err.message : String(err);
785
+ if (!res.headersSent) {
786
+ sendJson(res, 500, { error: 'Local gateway request failed', detail: message });
787
+ return;
788
+ }
789
+ res.destroy(err);
790
+ }
791
+ });
792
+
793
+ const wss = new WebSocketServer({ noServer: true });
794
+ server.on('upgrade', (req, socket, head) => {
795
+ const url = new URL(req.url || '/', `http://${BIND_HOST}:${PORT}`);
796
+ wss.handleUpgrade(req, socket, head, (ws) => {
797
+ const externalTarget = externalProxyTargetForPath(url.pathname);
798
+ if (externalTarget?.error) {
799
+ ws.close(externalTarget.error.status === 403 ? 1008 : 1011, externalTarget.error.message);
800
+ return;
801
+ }
802
+ if (externalTarget) {
803
+ proxyExternalWebSocket(req, ws, externalTarget);
804
+ return;
805
+ }
806
+ if (/^\/pty\/[^/]+\/connect$/.test(url.pathname)) {
807
+ bridgePtyWebSocket(req, ws, url.pathname);
808
+ return;
809
+ }
810
+ if (url.pathname === '/ws/fs' || url.pathname === '/fs-watcher/ws') {
811
+ proxyWebSocket(req, ws, { port: SERVICE_PORTS.fsWatcher, stripPrefix: '' }, '/');
812
+ return;
813
+ }
814
+ const target = serviceForPath(url.pathname);
815
+ if (!target) {
816
+ ws.close(1008, 'Unknown local gateway route');
817
+ return;
818
+ }
819
+ proxyWebSocket(req, ws, target);
820
+ });
821
+ });
822
+
823
+ server.on('error', (err) => {
824
+ console.error('[local-gateway] fatal:', err.message);
825
+ process.exit(1);
826
+ });
827
+
828
+ server.listen({ host: BIND_HOST, port: PORT }, () => {
829
+ const address = server.address();
830
+ const actualPort = typeof address === 'object' && address ? address.port : PORT;
831
+ SERVICE_PORTS.gateway = actualPort;
832
+ writeRuntimeState(actualPort);
833
+ refreshMonitoredPreviewPorts();
834
+ const previewPortRefreshTimer = setInterval(refreshMonitoredPreviewPorts, 2000);
835
+ if (typeof previewPortRefreshTimer.unref === 'function') previewPortRefreshTimer.unref();
836
+ console.log(`[local-gateway] listening on http://${BIND_HOST}:${actualPort}`);
837
+ });
838
+
839
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
840
+ process.once(signal, () => {
841
+ removeRuntimeStateForThisProcess();
842
+ for (const session of ptySessions.values()) {
843
+ try {
844
+ session.proc.kill();
845
+ } catch {
846
+ // noop
847
+ }
848
+ }
849
+ server.close(() => process.exit(0));
850
+ setTimeout(() => process.exit(0), 1000).unref?.();
851
+ });
852
+ }