@stage-labs/metro 0.1.0-beta.62 → 0.1.0-beta.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.62",
3
+ "version": "0.1.0-beta.64",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,7 +2,7 @@
2
2
  "name": "@metro-labs/mcp",
3
3
  "version": "0.1.0-beta.15",
4
4
  "private": true,
5
- "description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts \u2014 metro core is pure transport.",
5
+ "description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts — metro core is pure transport.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -51,10 +51,12 @@
51
51
  "pino-pretty": "^13.1.3",
52
52
  "postgres": "^3.4.5",
53
53
  "viem": "2.52.2",
54
+ "ws": "^8.18.0",
54
55
  "zod": "^3.23.8"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@stage-labs/config": "0.1.0-beta.2",
59
+ "@types/ws": "^8.18.0",
58
60
  "eslint": "^10.3.0",
59
61
  "knip": "^6.15.0",
60
62
  "madge": "^8.0.0",
@@ -25,7 +25,7 @@ function authorized(req: IncomingMessage, name: string): boolean {
25
25
  );
26
26
  }
27
27
 
28
- const DEFAULT_PUBLIC_BASE = 'https://mcp.metro.box';
28
+ const DEFAULT_PUBLIC_BASE = 'https://api.metro.box';
29
29
 
30
30
  export function publicBaseUrl(): string | null {
31
31
  const env = process.env.METRO_PUBLIC_URL?.trim();
@@ -28,6 +28,8 @@ import {
28
28
  import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
29
29
  import { webhookEntry } from '@metro-labs/webhook';
30
30
  import { handleIdentityRequest } from './identity-routes.js';
31
+ import { attachTerminalSockets } from './terminal-ws.js';
32
+ import { DEFAULT_TERMINAL_COMMAND } from './terminal-api.js';
31
33
  import { handleModeRequest } from './mode-api.js';
32
34
  import { handleUploadRequest } from './upload-api.js';
33
35
  import {
@@ -173,6 +175,8 @@ export async function startWebhookServer(
173
175
  if (!res.headersSent) res.writeHead(500).end();
174
176
  });
175
177
  });
178
+ if (apis.terminalApi !== undefined)
179
+ attachTerminalSockets(server, { command: apis.terminalApi.command ?? DEFAULT_TERMINAL_COMMAND });
176
180
  await new Promise<void>((resolve, reject) => {
177
181
  server.once('error', reject);
178
182
  const host = process.env.METRO_HTTP_HOST ?? '127.0.0.1';
@@ -190,6 +190,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
190
190
  localCli,
191
191
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
192
192
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
193
+ machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
194
+ terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
195
  identity: { owner: localOwner },
194
196
  mode: localModeInfo,
195
197
  };
@@ -0,0 +1,61 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { hostname } from 'node:os';
3
+ import { ApiError } from './api-error.js';
4
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
5
+ import { publicBaseUrl } from './attach-serve.js';
6
+ import { claudeDir } from './claude-files.js';
7
+ import { webhookPort } from './tunnel.js';
8
+ import { METRO_VERSION } from './version.js';
9
+ import { agentsDir } from '../db/file-source.js';
10
+ import { localOwner } from '../db/file-admin.js';
11
+
12
+ const PATH = '/api/server';
13
+
14
+ export interface MachineApiDeps {
15
+ authorize: (subject: string) => void;
16
+ startedAt?: string;
17
+ }
18
+
19
+ const bootedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
20
+
21
+ export function machineInfo(startedAt = bootedAt): Record<string, unknown> {
22
+ const store = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
23
+ return {
24
+ version: METRO_VERSION,
25
+ owner: localOwner(),
26
+ hostname: hostname(),
27
+ platform: process.platform,
28
+ arch: process.arch,
29
+ port: webhookPort(),
30
+ publicUrl: publicBaseUrl(),
31
+ uptimeSeconds: Math.round(process.uptime()),
32
+ startedAt,
33
+ bun: process.versions.bun ?? null,
34
+ agentsDir: agentsDir(),
35
+ claudeDir: claudeDir(),
36
+ runtimeStore: store === '' ? null : store,
37
+ };
38
+ }
39
+
40
+ export function handleMachineRequest(req: IncomingMessage, res: ServerResponse, deps: MachineApiDeps): boolean {
41
+ const path = (req.url ?? '').split('?')[0] ?? '';
42
+ if (path !== PATH) return false;
43
+ if (req.method === 'OPTIONS') {
44
+ res.writeHead(204, cors(req)).end();
45
+ return true;
46
+ }
47
+ if (req.method !== 'GET') {
48
+ sendJson(req, res, 405, { error: 'method not allowed' });
49
+ return true;
50
+ }
51
+ apiSession(req)
52
+ .then((session) => {
53
+ if (!session) throw new ApiError('unauthorized', 401);
54
+ deps.authorize(session.subject);
55
+ sendJson(req, res, 200, machineInfo(deps.startedAt));
56
+ })
57
+ .catch((err: unknown) => {
58
+ apiFailure(req, res, err, 'machine-api');
59
+ });
60
+ return true;
61
+ }
@@ -18,6 +18,8 @@ import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
18
18
  import { handleServersApiRequest, type ServersApiDeps } from './servers-api.js';
19
19
  import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
20
20
  import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
21
+ import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
22
+ import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
21
23
 
22
24
  export interface SessionApis {
23
25
  agentApi?: AgentApiDeps;
@@ -26,6 +28,8 @@ export interface SessionApis {
26
28
  vaultApi?: VaultApiDeps;
27
29
  serversApi?: ServersApiDeps;
28
30
  updateApi?: UpdateApiDeps;
31
+ machineApi?: MachineApiDeps;
32
+ terminalApi?: TerminalApiDeps;
29
33
  localCli?: LocalCliDeps;
30
34
  claudeApi?: ClaudeApiDeps;
31
35
  connectorApi?: ConnectorApiDeps;
@@ -50,6 +54,8 @@ export function handleSessionApis(
50
54
  ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
51
55
  ...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
52
56
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
57
+ ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
58
+ ...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
53
59
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
54
60
  ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
55
61
  ...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { ApiError } from './api-error.js';
4
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
5
+ import { log } from './log.js';
6
+ import { mintTerminalTicket } from './terminal-tickets.js';
7
+
8
+ const PREFIX = '/api/terminal';
9
+ const TICKETS = `${PREFIX}/tickets`;
10
+ export const TMUX_SESSION = 'metro';
11
+ export const DEFAULT_TERMINAL_COMMAND = ['tmux', 'new-session', '-A', '-s', TMUX_SESSION];
12
+
13
+ export interface TerminalApiDeps {
14
+ authorize: (subject: string) => void;
15
+ command?: string[];
16
+ }
17
+
18
+ function tmuxAvailable(command: string[]): boolean {
19
+ const bin = command[0] ?? '';
20
+ if (bin !== 'tmux') return true;
21
+ const run = spawnSync('tmux', ['-V'], { stdio: 'ignore' });
22
+ return run.error === undefined && run.status === 0;
23
+ }
24
+
25
+ export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse, deps: TerminalApiDeps): boolean {
26
+ const path = (req.url ?? '').split('?')[0] ?? '';
27
+ if (path !== PREFIX && path !== TICKETS) return false;
28
+ if (req.method === 'OPTIONS') {
29
+ res.writeHead(204, cors(req)).end();
30
+ return true;
31
+ }
32
+ const wanted = path === TICKETS ? 'POST' : 'GET';
33
+ if (req.method !== wanted) {
34
+ sendJson(req, res, 405, { error: 'method not allowed' });
35
+ return true;
36
+ }
37
+ apiSession(req)
38
+ .then((session) => {
39
+ if (!session) throw new ApiError('unauthorized', 401);
40
+ deps.authorize(session.subject);
41
+ const command = deps.command ?? DEFAULT_TERMINAL_COMMAND;
42
+ if (path === PREFIX) {
43
+ sendJson(req, res, 200, { available: tmuxAvailable(command), session: TMUX_SESSION, command });
44
+ return;
45
+ }
46
+ const minted = mintTerminalTicket(session.subject);
47
+ log.info({ subject: session.subject }, 'terminal: ticket minted');
48
+ sendJson(req, res, 200, { ...minted, path: `${PREFIX}/${minted.ticket}` });
49
+ })
50
+ .catch((err: unknown) => {
51
+ apiFailure(req, res, err, 'terminal-api');
52
+ });
53
+ return true;
54
+ }
@@ -0,0 +1,36 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const TTL_MS = 30_000;
4
+ const MAX_OPEN = 20;
5
+
6
+ interface Pending {
7
+ subject: string;
8
+ expiresAt: number;
9
+ }
10
+
11
+ const pending = new Map<string, Pending>();
12
+
13
+ function prune(now: number): void {
14
+ for (const [ticket, entry] of pending) if (entry.expiresAt <= now) pending.delete(ticket);
15
+ while (pending.size >= MAX_OPEN) {
16
+ const oldest = pending.keys().next().value;
17
+ if (oldest === undefined) break;
18
+ pending.delete(oldest);
19
+ }
20
+ }
21
+
22
+ export function mintTerminalTicket(subject: string, now = Date.now()): { ticket: string; expiresAt: number } {
23
+ prune(now);
24
+ const ticket = randomBytes(32).toString('base64url');
25
+ const expiresAt = now + TTL_MS;
26
+ pending.set(ticket, { subject, expiresAt });
27
+ return { ticket, expiresAt };
28
+ }
29
+
30
+ export function takeTerminalTicket(ticket: string, now = Date.now()): string | null {
31
+ const entry = pending.get(ticket);
32
+ pending.delete(ticket);
33
+ return entry !== undefined && entry.expiresAt > now ? entry.subject : null;
34
+ }
35
+
36
+ export const pendingTerminalTickets = (): number => pending.size;
@@ -0,0 +1,91 @@
1
+ import type { IncomingMessage, Server } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { WebSocketServer, type RawData, type WebSocket } from 'ws';
4
+ import { errMsg, log } from './log.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { takeTerminalTicket } from './terminal-tickets.js';
7
+
8
+ const TICKET_PATH = /^\/api\/terminal\/([A-Za-z0-9_-]{43})$/;
9
+ const MAX_MESSAGE = 1024 * 1024;
10
+ const DEFAULT_COLS = 120;
11
+ const DEFAULT_ROWS = 36;
12
+ const MAX_DIMENSION = 500;
13
+
14
+ export interface TerminalSocketDeps {
15
+ command: string[];
16
+ takeTicket?: (ticket: string) => string | null;
17
+ }
18
+
19
+ const rawBytes = (data: RawData): Buffer =>
20
+ Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
21
+
22
+ const dimension = (raw: unknown, fallback: number): number =>
23
+ typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : fallback;
24
+
25
+ function runTerminal(ws: WebSocket, command: string[], subject: string): void {
26
+ const terminal = new Bun.Terminal({
27
+ cols: DEFAULT_COLS,
28
+ rows: DEFAULT_ROWS,
29
+ data: (_term, chunk) => {
30
+ if (ws.readyState === ws.OPEN) ws.send(chunk);
31
+ },
32
+ });
33
+ const proc = Bun.spawn(command, {
34
+ terminal,
35
+ env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
36
+ });
37
+ log.info({ subject, pid: proc.pid, command: command[0] }, 'terminal: opened');
38
+ ws.on('message', (data, isBinary) => {
39
+ const bytes = rawBytes(data);
40
+ if (isBinary) {
41
+ terminal.write(bytes);
42
+ return;
43
+ }
44
+ try {
45
+ const control: unknown = JSON.parse(bytes.toString('utf8'));
46
+ if (isRecord(control) && 'cols' in control)
47
+ terminal.resize(dimension(control.cols, DEFAULT_COLS), dimension(control.rows, DEFAULT_ROWS));
48
+ } catch (err) {
49
+ log.warn({ err: errMsg(err) }, 'terminal: bad control frame');
50
+ }
51
+ });
52
+ ws.on('close', () => {
53
+ proc.kill();
54
+ terminal.close();
55
+ log.info({ subject, pid: proc.pid }, 'terminal: closed by the page');
56
+ });
57
+ proc.exited
58
+ .then((code) => {
59
+ if (ws.readyState === ws.OPEN) ws.close(1000, `exit ${String(code)}`);
60
+ terminal.close();
61
+ })
62
+ .catch((err: unknown) => {
63
+ log.warn({ err: errMsg(err) }, 'terminal: exit watch failed');
64
+ });
65
+ }
66
+
67
+ function refuse(socket: Duplex, status: number, text: string): void {
68
+ socket.write(`HTTP/1.1 ${String(status)} ${text}\r\nConnection: close\r\n\r\n`);
69
+ socket.destroy();
70
+ }
71
+
72
+ export function attachTerminalSockets(server: Server, deps: TerminalSocketDeps): void {
73
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE });
74
+ const take = deps.takeTicket ?? takeTerminalTicket;
75
+ server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
76
+ const path = (req.url ?? '').split('?')[0] ?? '';
77
+ const found = TICKET_PATH.exec(path);
78
+ if (!found) {
79
+ refuse(socket, 404, 'Not Found');
80
+ return;
81
+ }
82
+ const subject = take(found[1] ?? '');
83
+ if (subject === null) {
84
+ refuse(socket, 401, 'Unauthorized');
85
+ return;
86
+ }
87
+ wss.handleUpgrade(req, socket, head, (ws) => {
88
+ runTerminal(ws, deps.command, subject);
89
+ });
90
+ });
91
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.62"
2
+ "version": "0.1.0-beta.64"
3
3
  }
@@ -5,6 +5,7 @@
5
5
  "pino": "^9.5.0",
6
6
  "pino-pretty": "^13.1.3",
7
7
  "viem": "2.52.2",
8
+ "ws": "^8.18.0",
8
9
  "zod": "^3.23.8"
9
10
  },
10
11
  "stations": {