@stage-labs/metro 0.1.0-beta.63 → 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.63",
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",
@@ -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';
@@ -191,6 +191,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
191
191
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
192
192
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
193
193
  machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
194
+ terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
194
195
  identity: { owner: localOwner },
195
196
  mode: localModeInfo,
196
197
  };
@@ -19,6 +19,7 @@ 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
21
  import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
22
+ import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
22
23
 
23
24
  export interface SessionApis {
24
25
  agentApi?: AgentApiDeps;
@@ -28,6 +29,7 @@ export interface SessionApis {
28
29
  serversApi?: ServersApiDeps;
29
30
  updateApi?: UpdateApiDeps;
30
31
  machineApi?: MachineApiDeps;
32
+ terminalApi?: TerminalApiDeps;
31
33
  localCli?: LocalCliDeps;
32
34
  claudeApi?: ClaudeApiDeps;
33
35
  connectorApi?: ConnectorApiDeps;
@@ -53,6 +55,7 @@ export function handleSessionApis(
53
55
  ...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
54
56
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
55
57
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
58
+ ...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
56
59
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
57
60
  ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
58
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.63"
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": {