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

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.64",
3
+ "version": "0.1.0-beta.65",
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": {
@@ -29,7 +29,7 @@ import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
29
29
  import { webhookEntry } from '@metro-labs/webhook';
30
30
  import { handleIdentityRequest } from './identity-routes.js';
31
31
  import { attachTerminalSockets } from './terminal-ws.js';
32
- import { DEFAULT_TERMINAL_COMMAND } from './terminal-api.js';
32
+ import { tmuxCommand } from './terminal-api.js';
33
33
  import { handleModeRequest } from './mode-api.js';
34
34
  import { handleUploadRequest } from './upload-api.js';
35
35
  import {
@@ -176,7 +176,7 @@ export async function startWebhookServer(
176
176
  });
177
177
  });
178
178
  if (apis.terminalApi !== undefined)
179
- attachTerminalSockets(server, { command: apis.terminalApi.command ?? DEFAULT_TERMINAL_COMMAND });
179
+ attachTerminalSockets(server, { command: apis.terminalApi.command ?? tmuxCommand });
180
180
  await new Promise<void>((resolve, reject) => {
181
181
  server.once('error', reject);
182
182
  const host = process.env.METRO_HTTP_HOST ?? '127.0.0.1';
@@ -1,27 +1,44 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { ApiError } from './api-error.js';
4
- import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
4
+ import { apiFailure, apiSession, bodyField, cors, readJsonBody, sendJson } from './api-http.js';
5
5
  import { log } from './log.js';
6
6
  import { mintTerminalTicket } from './terminal-tickets.js';
7
7
 
8
8
  const PREFIX = '/api/terminal';
9
9
  const TICKETS = `${PREFIX}/tickets`;
10
10
  export const TMUX_SESSION = 'metro';
11
- export const DEFAULT_TERMINAL_COMMAND = ['tmux', 'new-session', '-A', '-s', TMUX_SESSION];
11
+ const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
12
+
13
+ export const tmuxCommand = (session: string): string[] => ['tmux', 'new-session', '-A', '-D', '-s', session];
12
14
 
13
15
  export interface TerminalApiDeps {
14
16
  authorize: (subject: string) => void;
15
- command?: string[];
17
+ command?: (session: string) => string[];
16
18
  }
17
19
 
18
- function tmuxAvailable(command: string[]): boolean {
19
- const bin = command[0] ?? '';
20
- if (bin !== 'tmux') return true;
20
+ function tmuxAvailable(deps: TerminalApiDeps): boolean {
21
+ if (deps.command !== undefined) return true;
21
22
  const run = spawnSync('tmux', ['-V'], { stdio: 'ignore' });
22
23
  return run.error === undefined && run.status === 0;
23
24
  }
24
25
 
26
+ export function tmuxSessions(): string[] {
27
+ const run = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
28
+ if (run.error !== undefined || run.status !== 0) return [];
29
+ return run.stdout
30
+ .split('\n')
31
+ .map((l) => l.trim())
32
+ .filter((l) => SESSION_RE.test(l));
33
+ }
34
+
35
+ export function sessionOf(raw: unknown): string {
36
+ if (raw === undefined || raw === null || raw === '') return TMUX_SESSION;
37
+ if (typeof raw !== 'string' || !SESSION_RE.test(raw))
38
+ throw new ApiError('a session name is 1 to 32 letters, digits, dots, dashes or underscores', 400);
39
+ return raw;
40
+ }
41
+
25
42
  export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse, deps: TerminalApiDeps): boolean {
26
43
  const path = (req.url ?? '').split('?')[0] ?? '';
27
44
  if (path !== PREFIX && path !== TICKETS) return false;
@@ -35,17 +52,17 @@ export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse,
35
52
  return true;
36
53
  }
37
54
  apiSession(req)
38
- .then((session) => {
55
+ .then(async (session) => {
39
56
  if (!session) throw new ApiError('unauthorized', 401);
40
57
  deps.authorize(session.subject);
41
- const command = deps.command ?? DEFAULT_TERMINAL_COMMAND;
42
58
  if (path === PREFIX) {
43
- sendJson(req, res, 200, { available: tmuxAvailable(command), session: TMUX_SESSION, command });
59
+ sendJson(req, res, 200, { available: tmuxAvailable(deps), session: TMUX_SESSION, sessions: tmuxSessions() });
44
60
  return;
45
61
  }
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}` });
62
+ const wanted = sessionOf(bodyField(await readJsonBody(req), 'session'));
63
+ const minted = mintTerminalTicket(session.subject, wanted);
64
+ log.info({ subject: session.subject, session: wanted }, 'terminal: ticket minted');
65
+ sendJson(req, res, 200, { ...minted, session: wanted, path: `${PREFIX}/${minted.ticket}` });
49
66
  })
50
67
  .catch((err: unknown) => {
51
68
  apiFailure(req, res, err, 'terminal-api');
@@ -3,8 +3,12 @@ import { randomBytes } from 'node:crypto';
3
3
  const TTL_MS = 30_000;
4
4
  const MAX_OPEN = 20;
5
5
 
6
- interface Pending {
6
+ export interface TerminalGrant {
7
7
  subject: string;
8
+ session: string;
9
+ }
10
+
11
+ interface Pending extends TerminalGrant {
8
12
  expiresAt: number;
9
13
  }
10
14
 
@@ -19,18 +23,18 @@ function prune(now: number): void {
19
23
  }
20
24
  }
21
25
 
22
- export function mintTerminalTicket(subject: string, now = Date.now()): { ticket: string; expiresAt: number } {
26
+ export function mintTerminalTicket(subject: string, session: string, now = Date.now()): { ticket: string; expiresAt: number } {
23
27
  prune(now);
24
28
  const ticket = randomBytes(32).toString('base64url');
25
29
  const expiresAt = now + TTL_MS;
26
- pending.set(ticket, { subject, expiresAt });
30
+ pending.set(ticket, { subject, session, expiresAt });
27
31
  return { ticket, expiresAt };
28
32
  }
29
33
 
30
- export function takeTerminalTicket(ticket: string, now = Date.now()): string | null {
34
+ export function takeTerminalTicket(ticket: string, now = Date.now()): TerminalGrant | null {
31
35
  const entry = pending.get(ticket);
32
36
  pending.delete(ticket);
33
- return entry !== undefined && entry.expiresAt > now ? entry.subject : null;
37
+ return entry !== undefined && entry.expiresAt > now ? { subject: entry.subject, session: entry.session } : null;
34
38
  }
35
39
 
36
40
  export const pendingTerminalTickets = (): number => pending.size;
@@ -3,7 +3,7 @@ import type { Duplex } from 'node:stream';
3
3
  import { WebSocketServer, type RawData, type WebSocket } from 'ws';
4
4
  import { errMsg, log } from './log.js';
5
5
  import { isRecord } from './is-record.js';
6
- import { takeTerminalTicket } from './terminal-tickets.js';
6
+ import { takeTerminalTicket, type TerminalGrant } from './terminal-tickets.js';
7
7
 
8
8
  const TICKET_PATH = /^\/api\/terminal\/([A-Za-z0-9_-]{43})$/;
9
9
  const MAX_MESSAGE = 1024 * 1024;
@@ -12,8 +12,8 @@ const DEFAULT_ROWS = 36;
12
12
  const MAX_DIMENSION = 500;
13
13
 
14
14
  export interface TerminalSocketDeps {
15
- command: string[];
16
- takeTicket?: (ticket: string) => string | null;
15
+ command: (session: string) => string[];
16
+ takeTicket?: (ticket: string) => TerminalGrant | null;
17
17
  }
18
18
 
19
19
  const rawBytes = (data: RawData): Buffer =>
@@ -79,13 +79,13 @@ export function attachTerminalSockets(server: Server, deps: TerminalSocketDeps):
79
79
  refuse(socket, 404, 'Not Found');
80
80
  return;
81
81
  }
82
- const subject = take(found[1] ?? '');
83
- if (subject === null) {
82
+ const grant = take(found[1] ?? '');
83
+ if (grant === null) {
84
84
  refuse(socket, 401, 'Unauthorized');
85
85
  return;
86
86
  }
87
87
  wss.handleUpgrade(req, socket, head, (ws) => {
88
- runTerminal(ws, deps.command, subject);
88
+ runTerminal(ws, deps.command(grant.session), grant.subject);
89
89
  });
90
90
  });
91
91
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.64"
2
+ "version": "0.1.0-beta.65"
3
3
  }