@stage-labs/metro 0.1.0-beta.244 → 0.1.0-beta.246

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.244",
3
+ "version": "0.1.0-beta.246",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.244",
3
+ "version": "0.1.0-beta.246",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -41,7 +41,7 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
41
41
  - Poll: `ask {"line": "…", "question": "Ship today?", "options": ["Yes", "No"]}`.
42
42
  - File: `create_upload {"name": "report.pdf"}`, run the `curl` line it returns in a shell, then `send {"line": "…", "attachments": [{"upload": "<upload_id>"}]}`. Files that are not images are refused over about 190 KiB.
43
43
 
44
- **Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows it in the chat as a card with a title and a description, and opens it full screen on a tap.
44
+ **Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows the frame itself in the chat (its `start` screen when it has screens), at image size, at most 400 x 400, clipped with a fade when taller. A tap opens it full screen. Its buttons do nothing in the chat: they work in the full view.
45
45
  - Send: `send {"line": "…", "frame": {"title": "Deploy", "description": "Version 2.4 is ready", "widget": {…}}}`. A frame goes with `send`, like a file: there is no separate tool. `title` and `description` are optional: they come from the first `Title` and text in the widget when missing. With `text` as well, the text goes first, and the `message_id` you get back is the frame's.
46
46
  - `widget` is OpenAI ChatKit widget JSON, with ChatKit's names and props (https://openai.github.io/chatkit-js/): a `Card`, `ListView` or `Basic` root, with `Row`, `Col`, `Box`, `Text`, `Title`, `Caption`, `Markdown`, `Badge`, `Icon`, `Image`, `Button`, `Divider`, `Spacer`, `Form`, `Input`, `Textarea`, `Select`, `DatePicker`, `Checkbox`, `RadioGroup` and `Table`. Images must be `https://`. Number spacing is in steps of 4px. At most 64K characters, 16 levels deep and 500 nodes. Stage shows a small notice for anything it does not know.
47
47
  - Example, the arguments of one `send`:
@@ -1,8 +1,9 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
+ import { resolve, sep } from 'node:path';
3
4
  import type { Readable } from 'node:stream';
4
5
  import { runningAsMetro } from '../metro-user/privilege.js';
5
- import { agentUser, asUser } from './user.js';
6
+ import { agentUser, asUser, type AgentUser } from './user.js';
6
7
 
7
8
  export interface AgentStat {
8
9
  size: number;
@@ -23,11 +24,72 @@ export interface AgentDirent {
23
24
 
24
25
  const MAX_OUTPUT = 256 * 1024 * 1024;
25
26
 
27
+ const codeOf = (err: unknown): unknown => (err as { code?: unknown } | null)?.code;
28
+
26
29
  const refused = (err: unknown): boolean => {
27
- const code = (err as { code?: unknown } | null)?.code;
30
+ const code = codeOf(err);
28
31
  return (code === 'EACCES' || code === 'EPERM') && runningAsMetro();
29
32
  };
30
33
 
34
+ const realHome = new Map<string, string>();
35
+
36
+ function homeOf(user: AgentUser): string {
37
+ let real = realHome.get(user.home);
38
+ if (real === undefined) {
39
+ try {
40
+ real = fs.realpathSync(user.home);
41
+ } catch {
42
+ real = resolve(user.home);
43
+ }
44
+ realHome.set(user.home, real);
45
+ }
46
+ return real;
47
+ }
48
+
49
+ const within = (home: string, path: string): boolean => path === home || path.startsWith(`${home}${sep}`);
50
+
51
+ function realOf(path: string, fd: number | undefined): string | null {
52
+ if (fd !== undefined) return fs.readlinkSync(`/proc/self/fd/${String(fd)}`);
53
+ try {
54
+ return fs.realpathSync(path);
55
+ } catch (err) {
56
+ if (codeOf(err) === 'ENOENT' || codeOf(err) === 'ENOTDIR') return null;
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ export function escapesHome(path: string, user: AgentUser, fd?: number): boolean {
62
+ const home = homeOf(user);
63
+ const asked = resolve(path);
64
+ if (!within(resolve(user.home), asked) && !within(home, asked)) return false;
65
+ const real = realOf(path, fd);
66
+ return real !== null && !within(home, real);
67
+ }
68
+
69
+ const confinedUser = (): AgentUser | null => (runningAsMetro() ? agentUser() : null);
70
+
71
+ function outside(path: string): Error {
72
+ const err = new Error(`${path} leads out of the agent's home, so metro reads it as the agent`) as Error & { code: string };
73
+ err.code = 'EACCES';
74
+ return err;
75
+ }
76
+
77
+ function guardPath(path: string, fd?: number): void {
78
+ const user = confinedUser();
79
+ if (user !== null && escapesHome(path, user, fd)) throw outside(path);
80
+ }
81
+
82
+ function openGuarded(path: string): number {
83
+ const fd = fs.openSync(path, 'r');
84
+ try {
85
+ guardPath(path, fd);
86
+ return fd;
87
+ } catch (err) {
88
+ fs.closeSync(fd);
89
+ throw err;
90
+ }
91
+ }
92
+
31
93
  function asAgentSh(script: string, args: string[]): Buffer {
32
94
  const [file, argv] = asUser(agentUser(), 'sh', ['-c', script, 'metro', ...args]);
33
95
  const run = spawnSync(file, argv, { maxBuffer: MAX_OUTPUT, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -51,6 +113,7 @@ function viaAgent<T>(native: () => T, fallback: () => T): T {
51
113
 
52
114
  export function existsSync(path: string): boolean {
53
115
  try {
116
+ guardPath(path);
54
117
  fs.accessSync(path, fs.constants.F_OK);
55
118
  return true;
56
119
  } catch (err) {
@@ -78,19 +141,42 @@ function agentStat(path: string, follow: boolean): AgentStat {
78
141
  return { size: Number(size), mtime: new Date(ms), mtimeMs: ms, mode: parseInt(mode, 16), ...kindTests(kind) };
79
142
  }
80
143
 
81
- export const statSync = (path: string): AgentStat => viaAgent(() => fs.statSync(path), () => agentStat(path, true));
144
+ export const statSync = (path: string): AgentStat =>
145
+ viaAgent(
146
+ () => {
147
+ guardPath(path);
148
+ return fs.statSync(path);
149
+ },
150
+ () => agentStat(path, true),
151
+ );
82
152
 
83
153
  export const realpathSync = (path: string): string =>
84
- viaAgent(() => fs.realpathSync(path), () => asAgentSh('realpath -e -- "$1"', [path]).toString('utf8').trim());
154
+ viaAgent(
155
+ () => {
156
+ guardPath(path);
157
+ return fs.realpathSync(path);
158
+ },
159
+ () => asAgentSh('realpath -e -- "$1"', [path]).toString('utf8').trim(),
160
+ );
85
161
 
86
162
  export function readFileSync(path: string, encoding: 'utf8'): string {
87
- return viaAgent(() => fs.readFileSync(path, encoding), () => asAgentSh('cat -- "$1"', [path]).toString('utf8'));
163
+ return viaAgent(
164
+ () => {
165
+ const fd = openGuarded(path);
166
+ try {
167
+ return fs.readFileSync(fd, encoding);
168
+ } finally {
169
+ fs.closeSync(fd);
170
+ }
171
+ },
172
+ () => asAgentSh('cat -- "$1"', [path]).toString('utf8'),
173
+ );
88
174
  }
89
175
 
90
176
  export function readRange(path: string, start: number, length: number): Buffer {
91
177
  return viaAgent(
92
178
  () => {
93
- const fd = fs.openSync(path, 'r');
179
+ const fd = openGuarded(path);
94
180
  try {
95
181
  const buffer = Buffer.alloc(length);
96
182
  return buffer.subarray(0, fs.readSync(fd, buffer, 0, length, start));
@@ -108,7 +194,10 @@ export function readdirNames(path: string): string[] {
108
194
 
109
195
  export function readdirEntries(path: string): AgentDirent[] {
110
196
  return viaAgent(
111
- () => fs.readdirSync(path, { withFileTypes: true }),
197
+ () => {
198
+ guardPath(path);
199
+ return fs.readdirSync(path, { withFileTypes: true });
200
+ },
112
201
  () =>
113
202
  asAgentSh('find -H "$1" -mindepth 1 -maxdepth 1 -printf "%y\\t%f\\0"', [path])
114
203
  .toString('utf8')
@@ -123,8 +212,7 @@ export function readdirEntries(path: string): AgentDirent[] {
123
212
 
124
213
  export function createReadStream(path: string): Readable {
125
214
  try {
126
- fs.accessSync(path, fs.constants.R_OK);
127
- return fs.createReadStream(path);
215
+ return fs.createReadStream(path, { fd: openGuarded(path) });
128
216
  } catch (err) {
129
217
  if (!refused(err)) return fs.createReadStream(path);
130
218
  const [file, argv] = asUser(agentUser(), 'cat', ['--', path]);
@@ -132,3 +220,27 @@ export function createReadStream(path: string): Readable {
132
220
  return child.stdout;
133
221
  }
134
222
  }
223
+
224
+ export function readAsAgent(path: string, maxBytes: number): Buffer | null {
225
+ if (!runningAsMetro() || agentUser() === null) {
226
+ try {
227
+ const st = fs.statSync(path);
228
+ if (!st.isFile()) return null;
229
+ const start = Math.max(0, st.size - maxBytes);
230
+ const fd = fs.openSync(path, 'r');
231
+ try {
232
+ const buffer = Buffer.alloc(st.size - start);
233
+ return buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, start));
234
+ } finally {
235
+ fs.closeSync(fd);
236
+ }
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+ try {
242
+ return asAgentSh('[ -f "$1" ] && tail -c "$2" -- "$1"', [path, String(maxBytes)]);
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, readFileSync, readRange, statSync } from './agent-fs.js';
2
+ import { readAsAgent } from './agent-fs.js';
3
3
  import { ApiError } from '@metro-labs/http/api-error';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { listSchedules, realRunner, showProps, type Runner, type ScheduledJob } from './schedules.js';
@@ -18,14 +18,10 @@ export interface JobDetail extends ScheduledJob {
18
18
  const SCRIPT_MAX = 64 * 1024;
19
19
 
20
20
  function textFile(path: string): string | null {
21
- try {
22
- const st = statSync(path);
23
- if (!st.isFile() || st.size > SCRIPT_MAX) return null;
24
- const text = readFileSync(path, 'utf8');
25
- return text.includes('\u0000') ? null : text;
26
- } catch {
27
- return null;
28
- }
21
+ const bytes = readAsAgent(path, SCRIPT_MAX + 1);
22
+ if (bytes === null || bytes.length > SCRIPT_MAX) return null;
23
+ const text = bytes.toString('utf8');
24
+ return text.includes('\u0000') ? null : text;
29
25
  }
30
26
 
31
27
  export function scriptOf(command: string): { path: string; text: string } | null {
@@ -50,10 +46,9 @@ export function logFileOf(command: string): string | null {
50
46
  return m?.[1] ?? null;
51
47
  }
52
48
 
53
- function tail(path: string): string {
54
- const size = statSync(path).size;
55
- const start = Math.max(0, size - TAIL_BYTES);
56
- return readRange(path, start, size - start).toString('utf8').split('\n').slice(-LOG_LINES).join('\n');
49
+ function tail(path: string | null): string {
50
+ const bytes = path === null ? null : readAsAgent(path, TAIL_BYTES);
51
+ return bytes === null ? '' : bytes.toString('utf8').split('\n').slice(-LOG_LINES).join('\n');
57
52
  }
58
53
 
59
54
  function timerDetail(job: ScheduledJob, runner: Runner): JobDetail {
@@ -70,11 +65,10 @@ function timerDetail(job: ScheduledJob, runner: Runner): JobDetail {
70
65
 
71
66
  function cronDetail(job: ScheduledJob): JobDetail {
72
67
  const file = logFileOf(job.command);
73
- const readable = file !== null && existsSync(file) && statSync(file).isFile();
74
68
  return {
75
69
  ...job,
76
70
  definition: `${job.schedule} ${job.command}`,
77
- logs: readable ? tail(file) : '',
71
+ logs: tail(file),
78
72
  logSource: file,
79
73
  script: scriptOf(job.command),
80
74
  };
@@ -165,6 +165,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
165
165
  '47439ecbe661ca70471a6839ab30a963714c296ad8c14ca23c6eebc3929038cc',
166
166
  'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
167
  '08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
168
+ 'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
168
169
  ]);
169
170
  const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
170
171
 
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
+ import { cameThroughProxy } from '@metro-labs/http/api-http';
3
4
  import { agentIdForKey } from '../agents/keys.js';
4
5
  import {
5
6
  assertBedrockReady,
@@ -301,7 +302,8 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
301
302
  res.writeHead(200).end();
302
303
  return true;
303
304
  }
304
- if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
305
+ if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
306
+ else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
305
307
  else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
306
308
  return true;
307
309
  }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import type { IncomingMessage } from 'node:http';
3
+ import { cameThroughProxy } from '@metro-labs/http/api-http';
3
4
  import { agentIdForKey } from '../agents/keys.js';
4
5
 
5
6
  export interface RequestIdentity {
@@ -33,6 +34,7 @@ export function extractToken(req: IncomingMessage): string | undefined {
33
34
  }
34
35
 
35
36
  export function authenticate(req: IncomingMessage): RequestIdentity | null {
37
+ if (cameThroughProxy(req)) return null;
36
38
  const token = extractToken(req);
37
39
  if (!token) return null;
38
40
  const agentId = agentIdForKey(token);
@@ -70,10 +70,11 @@ export const COMMON_TOOLS: ToolDef[] = [
70
70
  frame: {
71
71
  type: 'object',
72
72
  description:
73
- 'Stage (XMTP) only: a frame, a small interactive view that Stage shows as a card (title and ' +
74
- 'description) and opens full screen. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
73
+ 'Stage (XMTP) only: a frame, a small interactive view. Stage shows it in the chat at image size ' +
74
+ '(400 x 400 at most, its start screen, clipped with a fade) and opens it full screen on a tap; its ' +
75
+ 'buttons work only there. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
75
76
  'or Basic root, 64K characters at most); a missing `title` or `description` is taken from the ' +
76
- 'widget. Or give `screens` instead of `widget` for several screens in one frame. A tap comes back ' +
77
+ 'widget. Or give `screens` instead of `widget` for several screens in one frame. A tap there comes back ' +
77
78
  'to you as a reply to the frame: "Frame action: <type> <payload ' +
78
79
  "JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
79
80
  'skill lists the widget nodes, with an example.',
@@ -2,6 +2,7 @@ import {
2
2
  assertAttachmentSize,
3
3
  assertContentLength,
4
4
  guessMime,
5
+ MAX_ATTACHMENT_BYTES,
5
6
  } from '@metro-labs/core/stations/attachments';
6
7
  import {
7
8
  assertInlineTotal,
@@ -105,6 +106,49 @@ async function fromPath(
105
106
  };
106
107
  }
107
108
 
109
+ export const curlArgs = (url: string): string[] => [
110
+ 'curl', '-sS', '-f', '-L', '--max-redirs', '5', '--proto', '=http,https', '--proto-redir', '=http,https',
111
+ '--max-time', String(FETCH_TIMEOUT_MS / 1000), '--max-filesize', String(MAX_ATTACHMENT_BYTES),
112
+ '-w', '%{stderr}\n%{content_type}', '--', url,
113
+ ];
114
+
115
+ interface CurlSaid {
116
+ type: string;
117
+ why: string;
118
+ }
119
+
120
+ function curlSaid(text: string): CurlSaid {
121
+ const lines = text.trimEnd().split('\n');
122
+ const type = lines.pop()?.split(';')[0]?.trim() ?? '';
123
+ return { type, why: lines.join(' ').replace(/^curl: \(\d+\)\s*/, '').trim() };
124
+ }
125
+
126
+ async function fromUrlAs(
127
+ user: AgentUser,
128
+ a: CanonicalAttachment,
129
+ url: string,
130
+ ): Promise<ResolvedAttachment> {
131
+ const proc = Bun.spawn(agentCommand(curlArgs(url)), { stdout: 'pipe', stderr: 'pipe' });
132
+ const name = a.name ?? basenameOf(url) ?? 'attachment';
133
+ const said = new Response(proc.stderr).text().catch((err: unknown) => {
134
+ log.debug({ err: String(err) }, 'send: could not read what curl said');
135
+ return '';
136
+ });
137
+ let saved: Awaited<ReturnType<typeof streamToTemp>>;
138
+ try {
139
+ saved = await streamToTemp(proc.stdout, name);
140
+ } catch (err) {
141
+ proc.kill();
142
+ throw err;
143
+ }
144
+ const { type, why } = curlSaid(await said);
145
+ if ((await proc.exited) !== 0) {
146
+ await removeInlineTemp(saved.dir);
147
+ throw new Error(`attachment fetch failed for '${url}' (fetched as the ${user.name} user): ${why || 'no answer'}`);
148
+ }
149
+ return { path: saved.path, mime: a.mime ?? (type || guessMime(url)), name, bytes: saved.bytes, temp: saved.dir };
150
+ }
151
+
108
152
  async function fromUrl(
109
153
  a: CanonicalAttachment,
110
154
  url: string,
@@ -113,6 +157,8 @@ async function fromUrl(
113
157
  throw new Error(
114
158
  `attachment url '${url}' is not an http(s) url; use \`path\` for a local file`,
115
159
  );
160
+ const user = agentUser();
161
+ if (user !== null) return fromUrlAs(user, a, url);
116
162
  const res = await fetch(url, {
117
163
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
118
164
  });
@@ -19,6 +19,11 @@ export function setBearerSessions(fn: BearerSessions | null): void {
19
19
  bearerSessions = fn;
20
20
  }
21
21
 
22
+ const PROXY_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'tailscale-funnel-request'] as const;
23
+
24
+ export const cameThroughProxy = (req: IncomingMessage): boolean =>
25
+ PROXY_HEADERS.some((name) => req.headers[name] !== undefined);
26
+
22
27
  export function requireAdmin(session: ApiSession): void {
23
28
  if (session.role !== 'admin') throw new ApiError('this needs the admin role in your organization', 403);
24
29
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.244"
2
+ "version": "0.1.0-beta.246"
3
3
  }