@stage-labs/metro 0.1.0-beta.195 → 0.1.0-beta.196

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.195",
3
+ "version": "0.1.0-beta.196",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.195",
3
+ "version": "0.1.0-beta.196",
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",
@@ -0,0 +1,12 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { sessionRoute } from '@metro-labs/http/api-http';
3
+ import { readAgentPath } from './files.js';
4
+ import { agentUser } from './user.js';
5
+
6
+ const PATH = '/api/files';
7
+
8
+ const queryPath = (req: IncomingMessage): string => new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('path') ?? '';
9
+
10
+ export function handleFilesRequest(req: IncomingMessage, res: ServerResponse): boolean {
11
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: true, label: 'files-api' }, () => Promise.resolve(readAgentPath(agentUser(), queryPath(req))));
12
+ }
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { asUser, type AgentUser } from './user.js';
7
+
8
+ export type EntryKind = 'folder' | 'file' | 'other';
9
+
10
+ export interface FileEntry {
11
+ name: string;
12
+ kind: EntryKind;
13
+ bytes: number;
14
+ modifiedAt: string;
15
+ }
16
+
17
+ export type FilesAnswer =
18
+ | { root: string; path: string; kind: 'folder'; entries: FileEntry[]; more: boolean }
19
+ | { root: string; path: string; kind: 'file'; bytes: number; modifiedAt: string; text: string | null; truncated: boolean };
20
+
21
+ const MAX_ENTRIES = 2000;
22
+ const PREVIEW_BYTES = 256 * 1024;
23
+ const SNIFF_BYTES = 8192;
24
+
25
+ const SCRIPT = [
26
+ 'if [ -d "$1" ]; then',
27
+ ' printf "D\\0"; find -H "$1" -mindepth 1 -maxdepth 1 -printf "%y\\0%s\\0%T@\\0%f\\0"',
28
+ 'elif [ -f "$1" ]; then',
29
+ ' printf "F\\0"; stat -L -c "%s %Y" -- "$1" | tr "\\n" "\\0"; head -c "$2" -- "$1"',
30
+ 'else',
31
+ ' echo "no such file or folder" >&2; exit 2',
32
+ 'fi',
33
+ ].join('\n');
34
+
35
+ export function segmentsOf(path: string): string[] {
36
+ const parts = path.split('/').filter((part) => part !== '');
37
+ if (parts.some((part) => part === '.' || part === '..' || part.includes('\0')))
38
+ throw new ApiError('that path is not inside the home folder', 400);
39
+ return parts;
40
+ }
41
+
42
+ const stamp = (seconds: number): string => (Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : '');
43
+
44
+ const kindOf = (letter: string): EntryKind => (letter === 'd' ? 'folder' : letter === 'f' ? 'file' : 'other');
45
+
46
+ const byKindThenName = (a: FileEntry, b: FileEntry): number =>
47
+ a.kind === 'folder' && b.kind !== 'folder' ? -1 : b.kind === 'folder' && a.kind !== 'folder' ? 1 : a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
48
+
49
+ function preview(bytes: Buffer, size: number): { text: string | null; truncated: boolean } {
50
+ const binary = bytes.subarray(0, SNIFF_BYTES).includes(0);
51
+ return { text: binary ? null : bytes.subarray(0, PREVIEW_BYTES).toString('utf8'), truncated: size > PREVIEW_BYTES };
52
+ }
53
+
54
+ function folderAnswer(root: string, path: string, entries: FileEntry[]): FilesAnswer {
55
+ const sorted = entries.sort(byKindThenName);
56
+ return { root, path, kind: 'folder', entries: sorted.slice(0, MAX_ENTRIES), more: sorted.length > MAX_ENTRIES };
57
+ }
58
+
59
+ function parseListing(out: Buffer): FileEntry[] {
60
+ const fields = out.toString('utf8').split('\0');
61
+ const entries: FileEntry[] = [];
62
+ for (let at = 0; at + 3 < fields.length; at += 4)
63
+ entries.push({ kind: kindOf(fields[at] ?? ''), bytes: Number(fields[at + 1]), modifiedAt: stamp(Number(fields[at + 2])), name: fields[at + 3] ?? '' });
64
+ return entries;
65
+ }
66
+
67
+ function parseFile(root: string, path: string, out: Buffer): FilesAnswer {
68
+ const end = out.indexOf(0);
69
+ const [size = '0', mtime = '0'] = out.subarray(0, end).toString('utf8').trim().split(' ');
70
+ const bytes = Number(size);
71
+ return { root, path, kind: 'file', bytes, modifiedAt: stamp(Number(mtime)), ...preview(out.subarray(end + 1), bytes) };
72
+ }
73
+
74
+ function readAsAgent(user: AgentUser, path: string): FilesAnswer {
75
+ const target = join(user.home, ...segmentsOf(path));
76
+ const [file, argv] = asUser(user, 'sh', ['-c', SCRIPT, 'metro', target, String(PREVIEW_BYTES + 1)]);
77
+ const run = spawnSync(file, argv, { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
78
+ if (run.error !== undefined) throw new ApiError(`could not read that path: ${run.error.message}`, 500);
79
+ if (run.status !== 0) throw new ApiError(run.stderr.toString('utf8').trim() || 'the agent cannot read that path', 404);
80
+ const out = run.stdout;
81
+ const body = out.subarray(2);
82
+ return out.subarray(0, 1).toString() === 'D' ? folderAnswer(user.home, path, parseListing(body)) : parseFile(user.home, path, body);
83
+ }
84
+
85
+ function readHead(target: string): Buffer {
86
+ const fd = openSync(target, 'r');
87
+ try {
88
+ const buffer = Buffer.alloc(PREVIEW_BYTES + 1);
89
+ return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
90
+ } finally {
91
+ closeSync(fd);
92
+ }
93
+ }
94
+
95
+ function readLocal(root: string, path: string): FilesAnswer {
96
+ const target = join(root, ...segmentsOf(path));
97
+ let stat;
98
+ try {
99
+ stat = statSync(target);
100
+ } catch {
101
+ throw new ApiError('no such file or folder', 404);
102
+ }
103
+ if (stat.isFile()) return { root, path, kind: 'file', bytes: stat.size, modifiedAt: stat.mtime.toISOString(), ...preview(readHead(target), stat.size) };
104
+ const entries = readdirSync(target, { withFileTypes: true }).map((entry): FileEntry => {
105
+ const inner = join(target, entry.name);
106
+ try {
107
+ const s = statSync(inner);
108
+ return { name: entry.name, kind: s.isDirectory() ? 'folder' : s.isFile() ? 'file' : 'other', bytes: s.size, modifiedAt: s.mtime.toISOString() };
109
+ } catch {
110
+ return { name: entry.name, kind: 'other', bytes: 0, modifiedAt: '' };
111
+ }
112
+ });
113
+ return folderAnswer(root, path, entries);
114
+ }
115
+
116
+ export const readAgentPath = (user: AgentUser | null, path: string, localRoot = homedir()): FilesAnswer =>
117
+ user === null ? readLocal(localRoot, path) : readAsAgent(user, path);
@@ -10,6 +10,7 @@ import { type AccountRef } from './account-attach.js';
10
10
  import { type AccountRoute } from './account-routes.js';
11
11
  import { handlePolicy, type SetPolicy } from './policy-route.js';
12
12
  import { LOOKUPS } from './account-lookups.js';
13
+ import { unconfirmedSenders } from './sender-cards.js';
13
14
  import { stationByName } from '../stations/registry.js';
14
15
  import type { StationName } from '@metro-labs/core/station-names';
15
16
  import {
@@ -243,6 +244,9 @@ async function handleAllowlist(
243
244
  const wanted = normalizeAllowlist(bodyField(body, 'allowlist'));
244
245
  const asked = isRecord(body) && 'approvers' in body ? body.approvers : approversForAccount(target.station, target.accountId);
245
246
  const approvers = normalizeApprovers(asked, wanted);
247
+ const before = new Set(approversForAccount(target.station, target.accountId).map((a) => a.toLowerCase()));
248
+ const unknown = await unconfirmedSenders(deps, target.station, target.accountId, approvers.filter((a) => !before.has(a.toLowerCase())));
249
+ if (unknown.length > 0) throw new ApiError(`${target.station} does not know ${unknown.join(', ')}. Check the id, or have them write to the agent once, then try again.`, 400);
246
250
  const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted, approvers);
247
251
  log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
248
252
  sendJson(req, res, 200, {
@@ -64,6 +64,18 @@ export async function senderCards(deps: SenderCardDeps, station: StationName, ac
64
64
  );
65
65
  }
66
66
 
67
+ const known = (profile: unknown): boolean =>
68
+ isRecord(profile) && ['name', 'display_name', 'about', 'avatar', 'address'].some((key) => filled(profile[key]) !== undefined);
69
+
70
+ export async function unconfirmedSenders(deps: SenderCardDeps, station: StationName, accountId: string, ids: string[]): Promise<string[]> {
71
+ if (stationByName(station)?.readsProfiles !== true || ids.length === 0) return [];
72
+ const seen = new Set(deps.recentSenders(station, accountId).map((s) => s.id.toLowerCase()));
73
+ const checks = await Promise.all(
74
+ ids.map(async (id) => (seen.has(id.toLowerCase()) || known(await lookup(deps, station, accountId, id)) ? null : id)),
75
+ );
76
+ return checks.filter((id): id is string => id !== null);
77
+ }
78
+
67
79
  export async function handleSenderCards(
68
80
  req: IncomingMessage,
69
81
  res: ServerResponse,
@@ -13,7 +13,7 @@ export const GET_PROFILE_TOOL: ToolDef = {
13
13
  '(`metro://<station>/<account>/user/<id>`) and get what their network says about them: ' +
14
14
  '{id, name, display_name, about, avatar, address}. XMTP answers the Basename ' +
15
15
  '(<label>.stage.base.eth), its name, description and avatar records, and the wallet address; ' +
16
- 'WhatsApp the about line and the picture; Telegram the handle, the name and the bio; Discord ' +
16
+ 'WhatsApp the phone number, the name they last used, the about line and the picture; Telegram the handle, the name and the bio; Discord ' +
17
17
  'the username, the global name and the avatar. Fields the network does not have are absent. ' +
18
18
  'The answer is cached for ten minutes, so ask when you want to know someone, not on every message.',
19
19
  inputSchema: {
@@ -196,6 +196,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
196
196
  updateApi: { restart: deps.restart },
197
197
  controlApi: { restart: deps.restart, stop: deps.stop },
198
198
  schedulesApi: true,
199
+ filesApi: true,
199
200
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
200
201
  machineApi: {},
201
202
  modelApi: {},
@@ -18,6 +18,7 @@ import { handleModelRequest, type ModelApiDeps } from '../gateway/model-api.js';
18
18
  import type { GatewayDeps } from '../gateway/gateway.js';
19
19
  import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js';
20
20
  import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
21
+ import { handleFilesRequest } from '../agent-user/files-api.js';
21
22
 
22
23
  export interface SessionApis {
23
24
  agentApi?: AgentApiDeps;
@@ -25,6 +26,7 @@ export interface SessionApis {
25
26
  updateApi?: UpdateApiDeps;
26
27
  controlApi?: ControlApiDeps;
27
28
  schedulesApi?: true;
29
+ filesApi?: true;
28
30
  ownerApi?: OwnerApiDeps;
29
31
  machineApi?: MachineApiDeps;
30
32
  modelApi?: ModelApiDeps;
@@ -52,6 +54,7 @@ export function handleSessionApis(
52
54
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
53
55
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
54
56
  ...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
57
+ ...when(apis.filesApi, () => handleFilesRequest(req, res)),
55
58
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
56
59
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
57
60
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
@@ -17,6 +17,7 @@ import type { ProfileChange } from '@metro-labs/core/stations/profile';
17
17
  import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
18
18
  import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
19
19
  import { baileysLogger } from './logger.js';
20
+ import { makeNameBook, phoneOf, type NameBook } from './names.js';
20
21
  import { useAccountAuthState } from './auth-state.js';
21
22
  import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
22
23
  import { makeOutbox, type Outbox } from './outbox.js';
@@ -68,6 +69,7 @@ interface State {
68
69
  keys: KeyCache;
69
70
  outbox: Outbox;
70
71
  acks: AckWatch;
72
+ names: NameBook;
71
73
  }
72
74
 
73
75
  type SendOpts = Parameters<WASocket['sendMessage']>[2];
@@ -94,9 +96,13 @@ function bindInbound(st: State, sock: WASocket): void {
94
96
  for (const m of messages) {
95
97
  if (m.key.fromMe) continue;
96
98
  const inbound = toInbound(st.account.id, m, selfRef(st, sock));
99
+ if (inbound) st.names.note(inbound.senderJid, inbound.pushName);
97
100
  if (inbound) st.handlers.onMessage(inbound, m);
98
101
  }
99
102
  });
103
+ sock.ev.on('contacts.upsert', (contacts) => {
104
+ for (const c of contacts) st.names.note(c.id, c.name ?? c.notify);
105
+ });
100
106
  sock.ev.on('messages.reaction', (events: ReactionEvent[]) => {
101
107
  if (!st.handlers) return;
102
108
  for (const event of events) {
@@ -270,6 +276,22 @@ function mediaContent(m: WAMedia): SendContent {
270
276
  };
271
277
  }
272
278
 
279
+ async function readProfile(st: State, jid: string): Promise<SenderProfile> {
280
+ const sock = await ready(st);
281
+ const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
282
+ const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
283
+ const picture = nonEmpty(avatar);
284
+ const phone = phoneOf(jid.endsWith('@lid') ? await sock.signalRepository.lidMapping.getPNForLID(jid).catch(() => null) : jid);
285
+ const name = st.names.get(jid);
286
+ return {
287
+ id: jid,
288
+ ...(name === undefined ? {} : { display_name: name }),
289
+ ...(phone === undefined ? {} : { name: phone, address: phone }),
290
+ ...(about === undefined ? {} : { about }),
291
+ ...(picture === undefined ? {} : { avatar: picture }),
292
+ };
293
+ }
294
+
273
295
  export function createClient(account: WhatsAppAccount): WAClient {
274
296
  const st: State = {
275
297
  account,
@@ -278,18 +300,12 @@ export function createClient(account: WhatsAppAccount): WAClient {
278
300
  keys: makeKeyCache(),
279
301
  outbox: makeOutbox(),
280
302
  acks: makeAckWatch(),
303
+ names: makeNameBook(),
281
304
  };
282
305
  resetGate(st);
283
- const senders = makeProfileCache<SenderProfile>(
284
- async (jid) => {
285
- const sock = await ready(st);
286
- const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
287
- const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
288
- const picture = nonEmpty(avatar);
289
- return { id: jid, ...(about === undefined ? {} : { about }), ...(picture === undefined ? {} : { avatar: picture }) };
290
- },
291
- { onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`) },
292
- );
306
+ const senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
307
+ onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
308
+ });
293
309
  return {
294
310
  account,
295
311
  self() {
@@ -0,0 +1,28 @@
1
+ const MAX_NAMES = 2000;
2
+
3
+ export interface NameBook {
4
+ note(jid: string | null | undefined, name: string | null | undefined): void;
5
+ get(jid: string): string | undefined;
6
+ }
7
+
8
+ export function makeNameBook(): NameBook {
9
+ const names = new Map<string, string>();
10
+ return {
11
+ note(jid, name) {
12
+ const clean = name?.trim() ?? '';
13
+ if (!jid || clean === '') return;
14
+ names.delete(jid);
15
+ names.set(jid, clean);
16
+ if (names.size > MAX_NAMES) {
17
+ const oldest = names.keys().next().value;
18
+ if (oldest !== undefined) names.delete(oldest);
19
+ }
20
+ },
21
+ get: (jid) => names.get(jid),
22
+ };
23
+ }
24
+
25
+ export const phoneOf = (jid: string | null | undefined): string | undefined => {
26
+ const digits = /^(\d{6,})(?::\d+)?@s\.whatsapp\.net$/.exec(jid ?? '')?.[1];
27
+ return digits === undefined ? undefined : `+${digits}`;
28
+ };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.195"
2
+ "version": "0.1.0-beta.196"
3
3
  }