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

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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files-api.ts +12 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +117 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +57 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +9 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +4 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/sender-cards.ts +12 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -2
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +6 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/vault/api.ts +45 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/vault/config.ts +51 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +39 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +96 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/vault/install.ts +56 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/vault/paths.ts +26 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +148 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +130 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +51 -0
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +26 -10
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +28 -0
  25. package/runtime/runtime.json +1 -1
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.197",
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.197",
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);
@@ -0,0 +1,57 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { log } from '@metro-labs/core/log';
4
+ import { writeHomeText } from './home-fs.js';
5
+ import { listSchedules, realRunner, showProps, type Runner } from './schedules.js';
6
+ import type { AgentUser } from './user.js';
7
+
8
+ const UNIT_DIR = '/etc/systemd/system';
9
+ const DROP_IN = '11-metro-vault.conf';
10
+ const BEGIN = '# metro vault: begin';
11
+ const END = '# metro vault: end';
12
+
13
+ export const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
14
+
15
+ export function withCronEnv(lines: string[], env: Record<string, string>): string[] {
16
+ const start = lines.indexOf(BEGIN);
17
+ const stop = lines.indexOf(END);
18
+ const rest = start >= 0 && stop > start ? [...lines.slice(0, start), ...lines.slice(stop + 1)] : lines;
19
+ const kept = rest.filter((l) => l !== '');
20
+ const block = Object.entries(env).map(([k, v]) => `${k}=${v}`);
21
+ return block.length === 0 ? kept : [BEGIN, ...block, END, ...kept];
22
+ }
23
+
24
+ function setCronEnv(user: AgentUser, env: Record<string, string>, runner: Runner): void {
25
+ const out = runner.run('crontab', ['-l', '-u', user.name]);
26
+ const lines = out.status === 0 ? out.stdout.split('\n') : [];
27
+ const next = withCronEnv(lines, env);
28
+ if (next.join('\n') === lines.filter((l) => l !== '').join('\n')) return;
29
+ runner.run('crontab', ['-u', user.name, '-'], next.length === 0 ? '' : `${next.join('\n')}\n`);
30
+ }
31
+
32
+ function setTimerEnv(user: AgentUser, on: boolean, runner: Runner): void {
33
+ let changed = false;
34
+ for (const job of listSchedules(user, runner)) {
35
+ if (job.kind !== 'timer' || job.runsAs !== user.name) continue;
36
+ const unit = job.id.slice('timer:'.length);
37
+ const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
38
+ const file = join(UNIT_DIR, `${service}.d`, DROP_IN);
39
+ if (on) {
40
+ mkdirSync(join(UNIT_DIR, `${service}.d`), { recursive: true });
41
+ writeFileSync(file, `[Service]\nEnvironmentFile=-${jobEnvFile(user)}\n`, { mode: 0o644 });
42
+ changed = true;
43
+ } else if (existsSync(file)) {
44
+ rmSync(file, { force: true });
45
+ changed = true;
46
+ }
47
+ }
48
+ if (changed) runner.run('systemctl', ['daemon-reload']);
49
+ }
50
+
51
+ export function exportJobEnv(user: AgentUser, env: Record<string, string>, runner: Runner = realRunner): void {
52
+ const on = Object.keys(env).length > 0;
53
+ writeHomeText(jobEnvFile(user), Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), 0o644);
54
+ setCronEnv(user, env, runner);
55
+ setTimerEnv(user, on, runner);
56
+ log.info({ on, keys: Object.keys(env).length }, "vault: the agent's scheduled jobs use the vault settings");
57
+ }
@@ -42,6 +42,14 @@ export function forgetAgentUser(): void {
42
42
  cached = null;
43
43
  }
44
44
 
45
+ let extraEnv: Record<string, string> = {};
46
+
47
+ export function setAgentExtraEnv(env: Record<string, string>): void {
48
+ extraEnv = { ...env };
49
+ }
50
+
51
+ export const agentExtraEnv = (): Record<string, string> => ({ ...extraEnv });
52
+
45
53
  export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): Record<string, string> {
46
54
  return {
47
55
  HOME: user.home,
@@ -51,6 +59,7 @@ export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): R
51
59
  LANG: process.env.LANG ?? 'C.UTF-8',
52
60
  TERM: process.env.TERM ?? 'xterm-256color',
53
61
  PATH: AGENT_PATH.map((p) => (p.startsWith('/') ? p : join(user.home, p))).join(':'),
62
+ ...extraEnv,
54
63
  ...extra,
55
64
  };
56
65
  }
@@ -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,
@@ -25,6 +25,7 @@ import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.
25
25
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
26
26
  import { provisionAgentUser } from '../agent-user/provision.js';
27
27
  import { convertRootJobs } from '../agent-user/schedules.js';
28
+ import { applyVault } from '../vault/index.js';
28
29
  import { agentUser as currentAgentUser } from '../agent-user/user.js';
29
30
  import { ensureServiceOomPolicy } from '../claude/memory.js';
30
31
  import { unwatchSession, watchSession } from '../claude/session.js';
@@ -164,6 +165,8 @@ async function startClaude(): Promise<void> {
164
165
  if (agentUser === 'ready') {
165
166
  const switched = convertRootJobs(currentAgentUser());
166
167
  if (switched > 0) log.info({ switched }, "schedules: jobs that pointed into root's home now run as the agent user");
168
+ const vault = (await applyVault()).status;
169
+ if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
167
170
  }
168
171
  ensureMetroPlugin()
169
172
  .then((outcome) => {
@@ -12,13 +12,13 @@ import { claudeDir, listClaudeProjects } from './files.js';
12
12
  import { claudeAccount, claudeInstalled } from './login.js';
13
13
  import { trustFolder } from './onboarding.js';
14
14
  import { inSessionScope } from './memory.js';
15
- import { agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
15
+ import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
16
16
 
17
17
  function sessionEnv(): Record<string, string> {
18
18
  const user = agentUser();
19
19
  if (user === null) return {};
20
20
  const port = process.env.METRO_WEBHOOK_PORT?.trim() ?? '';
21
- return { METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
21
+ return { ...agentExtraEnv(), METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
22
22
  }
23
23
 
24
24
  export const SESSION_NAME = 'metro';
@@ -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,8 @@ 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,
200
+ vaultApi: true,
199
201
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
200
202
  machineApi: {},
201
203
  modelApi: {},
@@ -18,6 +18,8 @@ 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';
22
+ import { handleVaultRequest } from '../vault/api.js';
21
23
 
22
24
  export interface SessionApis {
23
25
  agentApi?: AgentApiDeps;
@@ -25,6 +27,8 @@ export interface SessionApis {
25
27
  updateApi?: UpdateApiDeps;
26
28
  controlApi?: ControlApiDeps;
27
29
  schedulesApi?: true;
30
+ filesApi?: true;
31
+ vaultApi?: true;
28
32
  ownerApi?: OwnerApiDeps;
29
33
  machineApi?: MachineApiDeps;
30
34
  modelApi?: ModelApiDeps;
@@ -52,6 +56,8 @@ export function handleSessionApis(
52
56
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
53
57
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
54
58
  ...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
59
+ ...when(apis.filesApi, () => handleFilesRequest(req, res)),
60
+ ...when(apis.vaultApi, () => handleVaultRequest(req, res)),
55
61
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
56
62
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
57
63
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
@@ -0,0 +1,45 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { log } from '@metro-labs/core/log';
6
+ import { sessionRunning, stopSession } from '../claude/session.js';
7
+ import { agentUser } from '../agent-user/user.js';
8
+ import { applyVault, vaultStatus } from './index.js';
9
+ import { recentRequests } from './proxy.js';
10
+ import { addSecret, readVault, removeSecret, setVaultEnabled, updateSecret, type SecretInput } from './store.js';
11
+
12
+ const PATH = '/api/vault';
13
+
14
+ function view(): unknown {
15
+ return { ...vaultStatus(), secrets: readVault().secrets, recent: recentRequests().slice(0, 100) };
16
+ }
17
+
18
+ const inputOf = (body: unknown): SecretInput => (isRecord(body) ? { name: body.name, env: body.env, hosts: body.hosts, value: body.value } : {});
19
+
20
+ function change(body: unknown): void {
21
+ const action = bodyField(body, 'action');
22
+ const id = bodyField(body, 'id');
23
+ if (action === 'enable' || action === 'disable') {
24
+ if (agentUser() === null) throw new ApiError('the vault needs Claude Code running as its own user (a Linux box)', 409);
25
+ setVaultEnabled(action === 'enable');
26
+ } else if (action === 'add') addSecret(inputOf(body));
27
+ else if (action === 'update' && typeof id === 'string') updateSecret(id, inputOf(body));
28
+ else if (action === 'remove' && typeof id === 'string') removeSecret(id);
29
+ else throw new ApiError('action must be enable, disable, add, update or remove', 400);
30
+ }
31
+
32
+ async function answer(req: IncomingMessage): Promise<unknown> {
33
+ if (req.method === 'GET') return view();
34
+ change(await readJsonBody(req));
35
+ const { envChanged } = await applyVault();
36
+ if (envChanged && sessionRunning()) {
37
+ stopSession();
38
+ log.info('vault: the Claude session restarts to pick up the new environment');
39
+ }
40
+ return view();
41
+ }
42
+
43
+ export function handleVaultRequest(req: IncomingMessage, res: ServerResponse): boolean {
44
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: true, label: 'vault-api' }, () => answer(req));
45
+ }
@@ -0,0 +1,51 @@
1
+ import { caDir, ports, valueFile, vaultDir } from './paths.js';
2
+ import type { VaultSecret } from './store.js';
3
+ import { join } from 'node:path';
4
+
5
+ export const MANAGEMENT_KEY_ENV = 'IRON_MANAGEMENT_API_KEY';
6
+
7
+ function secretEntry(secret: VaultSecret, dir: string): Record<string, unknown> {
8
+ return {
9
+ source: { type: 'file', path: valueFile(secret.id, dir) },
10
+ replace: { proxy_value: secret.env, match_headers: [], match_query: true },
11
+ rules: secret.hosts.map((host) => ({ host })),
12
+ };
13
+ }
14
+
15
+ export function proxyConfig(secrets: VaultSecret[], dir = vaultDir()): Record<string, unknown> {
16
+ const p = ports();
17
+ const local = (port: number): string => `127.0.0.1:${String(port)}`;
18
+ return {
19
+ dns: { enabled: false },
20
+ proxy: {
21
+ tunnel_listen: local(p.tunnel),
22
+ http_listen: local(p.http),
23
+ https_listen: local(p.https),
24
+ upstream_response_header_timeout: '10m',
25
+ },
26
+ tls: { mode: 'mitm', ca_cert: join(caDir(dir), 'ca.crt'), ca_key: join(caDir(dir), 'ca.key') },
27
+ transforms: secrets.length === 0 ? [] : [{ name: 'secrets', config: { secrets: secrets.map((s) => secretEntry(s, dir)) } }],
28
+ metrics: { listen: local(p.metrics) },
29
+ management: { listen: local(p.management), api_key_env: MANAGEMENT_KEY_ENV },
30
+ log: { level: 'info' },
31
+ };
32
+ }
33
+
34
+ export function proxyEnvFor(caFile: string, bundle: string, secrets: VaultSecret[]): Record<string, string> {
35
+ const url = `http://127.0.0.1:${String(ports().tunnel)}`;
36
+ const skip = 'localhost,127.0.0.1,::1';
37
+ return {
38
+ HTTP_PROXY: url,
39
+ HTTPS_PROXY: url,
40
+ http_proxy: url,
41
+ https_proxy: url,
42
+ NO_PROXY: skip,
43
+ no_proxy: skip,
44
+ NODE_EXTRA_CA_CERTS: caFile,
45
+ SSL_CERT_FILE: bundle,
46
+ REQUESTS_CA_BUNDLE: bundle,
47
+ CURL_CA_BUNDLE: bundle,
48
+ GIT_SSL_CAINFO: bundle,
49
+ ...Object.fromEntries(secrets.map((s) => [s.env, s.env])),
50
+ };
51
+ }
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ const CHAIN = 'METRO_AGENT';
4
+ const TOOLS = ['iptables', 'ip6tables'] as const;
5
+
6
+ export interface FirewallRunner {
7
+ run: (file: string, args: string[]) => number | null;
8
+ }
9
+
10
+ const realRunner: FirewallRunner = { run: (file, args) => spawnSync(file, ['-w', ...args], { stdio: 'ignore' }).status };
11
+
12
+ export const chainRules = (): string[][] => [
13
+ ['-A', CHAIN, '-o', 'lo', '-j', 'ACCEPT'],
14
+ ['-A', CHAIN, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT'],
15
+ ['-A', CHAIN, '-p', 'udp', '--dport', '53', '-j', 'ACCEPT'],
16
+ ['-A', CHAIN, '-p', 'tcp', '--dport', '53', '-j', 'ACCEPT'],
17
+ ['-A', CHAIN, '-p', 'tcp', '-j', 'REJECT', '--reject-with', 'tcp-reset'],
18
+ ['-A', CHAIN, '-j', 'REJECT'],
19
+ ];
20
+
21
+ const jump = (uid: number): string[] => ['OUTPUT', '-m', 'owner', '--uid-owner', String(uid), '-j', CHAIN];
22
+
23
+ export function applyFirewall(uid: number, runner: FirewallRunner = realRunner): void {
24
+ for (const tool of TOOLS) {
25
+ runner.run(tool, ['-N', CHAIN]);
26
+ if (runner.run(tool, ['-F', CHAIN]) !== 0) throw new Error(`${tool} refused to prepare the ${CHAIN} chain`);
27
+ for (const rule of chainRules()) if (runner.run(tool, rule) !== 0) throw new Error(`${tool} refused a rule: ${rule.join(' ')}`);
28
+ if (runner.run(tool, ['-C', ...jump(uid)]) !== 0 && runner.run(tool, ['-I', ...jump(uid)]) !== 0)
29
+ throw new Error(`${tool} refused to send the agent's traffic to ${CHAIN}`);
30
+ }
31
+ }
32
+
33
+ export function removeFirewall(uid: number, runner: FirewallRunner = realRunner): void {
34
+ for (const tool of TOOLS) {
35
+ for (let i = 0; i < 10 && runner.run(tool, ['-C', ...jump(uid)]) === 0; i += 1) runner.run(tool, ['-D', ...jump(uid)]);
36
+ runner.run(tool, ['-F', CHAIN]);
37
+ runner.run(tool, ['-X', CHAIN]);
38
+ }
39
+ }
@@ -0,0 +1,96 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import { ensureSecureDir } from '@metro-labs/core/secure-fs';
4
+ import { agentExtraEnv, agentUser, asAgent, setAgentExtraEnv, type AgentUser } from '../agent-user/user.js';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { exportJobEnv } from '../agent-user/job-env.js';
7
+ import { proxyConfig, proxyEnvFor } from './config.js';
8
+ import { applyFirewall, removeFirewall } from './firewall.js';
9
+ import { ensureAuthority, ensureProxyBinary } from './install.js';
10
+ import { configFile, PROXY_VERSION, SYSTEM_BUNDLE, TRUSTED_CA, vaultDir } from './paths.js';
11
+ import { proxyError, proxyRunning, runProxy, stopProxy } from './proxy.js';
12
+ import { hasValue, readVault } from './store.js';
13
+ import { trustBrowsers, trustSystem, untrustSystem } from './trust.js';
14
+
15
+ export interface VaultStatus {
16
+ available: boolean;
17
+ enabled: boolean;
18
+ running: boolean;
19
+ version: string;
20
+ problem: string | null;
21
+ browsers: string | null;
22
+ }
23
+
24
+ const memory: { problem: string | null; browsers: string | null } = { problem: null, browsers: null };
25
+
26
+ function pushTmuxEnv(before: Record<string, string>, after: Record<string, string>): void {
27
+ const has = spawnSync(...asAgent('tmux', ['list-sessions']), { stdio: 'ignore' }).status === 0;
28
+ if (!has) return;
29
+ for (const key of Object.keys(before)) if (!(key in after)) spawnSync(...asAgent('tmux', ['set-environment', '-gu', key]), { stdio: 'ignore' });
30
+ for (const [key, value] of Object.entries(after)) spawnSync(...asAgent('tmux', ['set-environment', '-g', key, value]), { stdio: 'ignore' });
31
+ }
32
+
33
+ const sameEnv = (a: Record<string, string>, b: Record<string, string>): boolean =>
34
+ JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
35
+
36
+ function setEnv(next: Record<string, string>): boolean {
37
+ const before = agentExtraEnv();
38
+ if (sameEnv(before, next)) return false;
39
+ setAgentExtraEnv(next);
40
+ pushTmuxEnv(before, next);
41
+ return true;
42
+ }
43
+
44
+ function exportForJobs(user: AgentUser, env: Record<string, string>): void {
45
+ try {
46
+ exportJobEnv(user, env);
47
+ } catch (err) {
48
+ log.warn({ err: errMsg(err) }, 'vault: could not give the scheduled jobs the vault settings');
49
+ }
50
+ }
51
+
52
+ async function turnOn(user: AgentUser, dir: string): Promise<Record<string, string>> {
53
+ const bin = await ensureProxyBinary(dir);
54
+ const ca = ensureAuthority(bin, dir);
55
+ const secrets = readVault(dir).secrets.filter((s) => hasValue(s.id, dir));
56
+ ensureSecureDir(dir);
57
+ writeFileSync(configFile(dir), JSON.stringify(proxyConfig(secrets, dir), null, 2), { mode: 0o600 });
58
+ trustSystem(ca);
59
+ memory.browsers = trustBrowsers(user);
60
+ await runProxy(bin, configFile(dir));
61
+ applyFirewall(user.uid);
62
+ return proxyEnvFor(TRUSTED_CA, SYSTEM_BUNDLE, secrets);
63
+ }
64
+
65
+ function turnOff(user: AgentUser): void {
66
+ removeFirewall(user.uid);
67
+ stopProxy();
68
+ untrustSystem();
69
+ }
70
+
71
+ export async function applyVault(user: AgentUser | null = agentUser(), dir = vaultDir()): Promise<{ status: VaultStatus; envChanged: boolean }> {
72
+ if (user === null) return { status: vaultStatus(null, dir), envChanged: false };
73
+ const enabled = readVault(dir).enabled;
74
+ try {
75
+ const env = enabled ? await turnOn(user, dir) : (turnOff(user), {});
76
+ memory.problem = null;
77
+ const envChanged = setEnv(env);
78
+ if (envChanged || enabled) exportForJobs(user, env);
79
+ return { status: vaultStatus(user, dir), envChanged };
80
+ } catch (err) {
81
+ memory.problem = errMsg(err);
82
+ log.warn({ err: memory.problem }, 'vault: could not apply the vault');
83
+ return { status: vaultStatus(user, dir), envChanged: false };
84
+ }
85
+ }
86
+
87
+ export function vaultStatus(user: AgentUser | null = agentUser(), dir = vaultDir()): VaultStatus {
88
+ return {
89
+ available: user !== null,
90
+ enabled: user !== null && readVault(dir).enabled,
91
+ running: proxyRunning(),
92
+ version: PROXY_VERSION,
93
+ problem: memory.problem ?? (readVault(dir).enabled ? proxyError() : null),
94
+ browsers: memory.browsers,
95
+ };
96
+ }
@@ -0,0 +1,56 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmodSync, copyFileSync, existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ensureSecureDir } from '@metro-labs/core/secure-fs';
7
+ import { binDir, caDir, PROXY_VERSION, proxyBin, vaultDir } from './paths.js';
8
+
9
+ const RELEASES = `https://github.com/ironsh/iron-proxy/releases/download/v${PROXY_VERSION}`;
10
+
11
+ export const archiveName = (arch = process.arch): string => `iron-proxy_${PROXY_VERSION}_linux_${arch === 'arm64' ? 'arm64' : 'amd64'}.tar.gz`;
12
+
13
+ export function expectedSum(checksums: string, file: string): string | null {
14
+ const line = checksums.split('\n').find((l) => l.trim().endsWith(` ${file}`));
15
+ const sum = line?.trim().split(/\s+/)[0];
16
+ return sum !== undefined && /^[0-9a-f]{64}$/.test(sum) ? sum : null;
17
+ }
18
+
19
+ async function download(url: string): Promise<Buffer> {
20
+ const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) });
21
+ if (!res.ok) throw new Error(`${url} answered ${String(res.status)}`);
22
+ return Buffer.from(await res.arrayBuffer());
23
+ }
24
+
25
+ export async function ensureProxyBinary(dir = vaultDir()): Promise<string> {
26
+ const bin = proxyBin(dir);
27
+ if (existsSync(bin)) return bin;
28
+ const file = archiveName();
29
+ const [archive, checksums] = await Promise.all([download(`${RELEASES}/${file}`), download(`${RELEASES}/checksums.txt`)]);
30
+ const want = expectedSum(checksums.toString('utf8'), file);
31
+ const got = createHash('sha256').update(archive).digest('hex');
32
+ if (want === null || want !== got) throw new Error(`the iron-proxy download does not match its checksum (${file})`);
33
+ const work = mkdtempSync(join(tmpdir(), 'metro-iron-'));
34
+ try {
35
+ writeFileSync(join(work, file), archive);
36
+ const untar = spawnSync('tar', ['-xzf', file, 'iron-proxy'], { cwd: work, encoding: 'utf8' });
37
+ if (untar.status !== 0) throw new Error(`could not unpack iron-proxy: ${untar.stderr.trim()}`);
38
+ ensureSecureDir(binDir(dir));
39
+ copyFileSync(join(work, 'iron-proxy'), `${bin}.part`);
40
+ chmodSync(`${bin}.part`, 0o755);
41
+ renameSync(`${bin}.part`, bin);
42
+ } finally {
43
+ rmSync(work, { recursive: true, force: true });
44
+ }
45
+ return bin;
46
+ }
47
+
48
+ export function ensureAuthority(bin: string, dir = vaultDir()): string {
49
+ const cert = join(caDir(dir), 'ca.crt');
50
+ if (existsSync(cert) && existsSync(join(caDir(dir), 'ca.key'))) return cert;
51
+ ensureSecureDir(caDir(dir));
52
+ const made = spawnSync(bin, ['generate-ca', '-outdir', caDir(dir), '-name', 'Metro vault', '-expiry-hours', '87600', '-alg', 'rsa4096'], { encoding: 'utf8' });
53
+ if (made.status !== 0) throw new Error(`could not create the vault certificate: ${made.stderr.trim()}`);
54
+ chmodSync(join(caDir(dir), 'ca.key'), 0o600);
55
+ return cert;
56
+ }
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+ import { agentsDir } from '../agents/files.js';
3
+
4
+ export const PROXY_VERSION = '0.50.0';
5
+
6
+ const basePort = (): number => {
7
+ const raw = Number(process.env.METRO_VAULT_PORT);
8
+ return Number.isInteger(raw) && raw > 1024 && raw < 65000 ? raw : 8421;
9
+ };
10
+
11
+ export const ports = (): { tunnel: number; http: number; https: number; management: number; metrics: number } => {
12
+ const base = basePort();
13
+ return { tunnel: base, http: base + 1, https: base + 2, management: base + 3, metrics: base + 4 };
14
+ };
15
+
16
+ export const vaultDir = (dir = agentsDir()): string => join(dir, 'vault');
17
+ export const stateFile = (dir = vaultDir()): string => join(dir, 'vault.json');
18
+ export const valuesDir = (dir = vaultDir()): string => join(dir, 'values');
19
+ export const valueFile = (id: string, dir = vaultDir()): string => join(valuesDir(dir), id);
20
+ export const caDir = (dir = vaultDir()): string => join(dir, 'ca');
21
+ export const binDir = (dir = vaultDir()): string => join(dir, 'bin');
22
+ export const proxyBin = (dir = vaultDir()): string => join(binDir(dir), `iron-proxy-${PROXY_VERSION}`);
23
+ export const configFile = (dir = vaultDir()): string => join(dir, 'iron-proxy.json');
24
+
25
+ export const TRUSTED_CA = '/usr/local/share/ca-certificates/metro-vault.crt';
26
+ export const SYSTEM_BUNDLE = '/etc/ssl/certs/ca-certificates.crt';
@@ -0,0 +1,148 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { basename } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { errMsg, log } from '@metro-labs/core/log';
7
+ import { MANAGEMENT_KEY_ENV } from './config.js';
8
+ import { ports } from './paths.js';
9
+
10
+ export interface ProxyRequest {
11
+ at: string;
12
+ method: string;
13
+ host: string;
14
+ path: string;
15
+ status: number | null;
16
+ action: string;
17
+ swapped: string[];
18
+ }
19
+
20
+ const RECENT_MAX = 200;
21
+ const BACKOFF_MAX_MS = 30_000;
22
+
23
+ const recent: ProxyRequest[] = [];
24
+ const state: { child: ChildProcess | null; wanted: { bin: string; config: string } | null; key: string; backoff: number; lastError: string | null; startedAt: number } = {
25
+ child: null,
26
+ wanted: null,
27
+ key: randomBytes(24).toString('hex'),
28
+ backoff: 1000,
29
+ lastError: null,
30
+ startedAt: 0,
31
+ };
32
+
33
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
34
+
35
+ function swappedIds(transforms: unknown): string[] {
36
+ if (!Array.isArray(transforms)) return [];
37
+ return transforms.flatMap((t) => {
38
+ const swapped = isRecord(t) && isRecord(t.annotations) && Array.isArray(t.annotations.swapped) ? t.annotations.swapped : [];
39
+ return swapped.flatMap((s) => (isRecord(s) && typeof s.secret === 'string' ? [basename(s.secret)] : []));
40
+ });
41
+ }
42
+
43
+ export function requestOf(line: string): ProxyRequest | null {
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(line);
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (!isRecord(parsed) || parsed.msg !== 'request' || !isRecord(parsed.audit)) return null;
51
+ const audit = parsed.audit;
52
+ if (audit.method === 'CONNECT') return null;
53
+ return {
54
+ at: text(parsed.time),
55
+ method: text(audit.method),
56
+ host: text(audit.host),
57
+ path: text(audit.path),
58
+ status: typeof audit.status_code === 'number' ? audit.status_code : null,
59
+ action: text(audit.action),
60
+ swapped: swappedIds(parsed.request_transforms),
61
+ };
62
+ }
63
+
64
+ function noteLine(line: string): void {
65
+ const request = requestOf(line);
66
+ if (request !== null) {
67
+ recent.push(request);
68
+ if (recent.length > RECENT_MAX) recent.shift();
69
+ return;
70
+ }
71
+ if (/"level":"(WARN|ERROR)"/.test(line)) log.warn({ line: line.slice(0, 500) }, 'vault: iron-proxy');
72
+ }
73
+
74
+ function launch(): void {
75
+ const wanted = state.wanted;
76
+ if (wanted === null || state.child !== null) return;
77
+ const child = spawn(wanted.bin, ['-config', wanted.config], { stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', [MANAGEMENT_KEY_ENV]: state.key } });
78
+ state.child = child;
79
+ state.startedAt = Date.now();
80
+ for (const stream of [child.stdout, child.stderr]) if (stream !== null) createInterface({ input: stream }).on('line', noteLine);
81
+ child.on('error', (err) => {
82
+ state.lastError = errMsg(err);
83
+ });
84
+ child.on('exit', (code) => {
85
+ state.child = null;
86
+ if (state.wanted === null) return;
87
+ state.lastError = `iron-proxy stopped (exit ${String(code)})`;
88
+ log.warn({ code, retryInMs: state.backoff }, 'vault: iron-proxy stopped, starting it again');
89
+ const wait = state.backoff;
90
+ state.backoff = Date.now() - state.startedAt > 60_000 ? 1000 : Math.min(state.backoff * 2, BACKOFF_MAX_MS);
91
+ setTimeout(launch, wait).unref();
92
+ });
93
+ }
94
+
95
+ async function reload(): Promise<boolean> {
96
+ try {
97
+ const res = await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, {
98
+ method: 'POST',
99
+ headers: { authorization: `Bearer ${state.key}` },
100
+ signal: AbortSignal.timeout(10_000),
101
+ });
102
+ return res.ok;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ async function listening(): Promise<boolean> {
109
+ try {
110
+ await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, { method: 'GET', signal: AbortSignal.timeout(2_000) });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ async function waitListening(ms = 10_000): Promise<boolean> {
118
+ const until = Date.now() + ms;
119
+ while (Date.now() < until) {
120
+ if (await listening()) return true;
121
+ await new Promise((r) => setTimeout(r, 200));
122
+ }
123
+ return false;
124
+ }
125
+
126
+ export async function runProxy(bin: string, config: string): Promise<void> {
127
+ const fresh = state.child === null;
128
+ state.wanted = { bin, config };
129
+ if (fresh) launch();
130
+ else if (await reload()) {
131
+ state.lastError = null;
132
+ return;
133
+ } else {
134
+ state.child?.kill('SIGTERM');
135
+ await new Promise((r) => setTimeout(r, state.backoff + 500));
136
+ }
137
+ if (!(await waitListening())) throw new Error(state.lastError ?? 'iron-proxy did not start');
138
+ state.lastError = null;
139
+ }
140
+
141
+ export function stopProxy(): void {
142
+ state.wanted = null;
143
+ state.child?.kill('SIGTERM');
144
+ }
145
+
146
+ export const proxyRunning = (): boolean => state.child !== null;
147
+ export const proxyError = (): string | null => state.lastError;
148
+ export const recentRequests = (): ProxyRequest[] => [...recent].reverse();
@@ -0,0 +1,130 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { newId } from '@metro-labs/core/ids';
5
+ import { ensureSecureDir, readJson, writeSecure } from '@metro-labs/core/secure-fs';
6
+ import { stateFile, valueFile, valuesDir, vaultDir } from './paths.js';
7
+
8
+ export interface VaultSecret {
9
+ id: string;
10
+ name: string;
11
+ env: string;
12
+ hosts: string[];
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ }
16
+
17
+ export interface VaultState {
18
+ version: 1;
19
+ enabled: boolean;
20
+ secrets: VaultSecret[];
21
+ }
22
+
23
+ export interface SecretInput {
24
+ name?: unknown;
25
+ env?: unknown;
26
+ hosts?: unknown;
27
+ value?: unknown;
28
+ }
29
+
30
+ const ENV_RE = /^[A-Z][A-Z0-9_]{2,63}$/;
31
+ const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
32
+ const RESERVED_ENV = new Set(['HOME', 'PATH', 'USER', 'SHELL', 'LANG', 'TERM', 'LOGNAME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY']);
33
+ const MAX_SECRETS = 100;
34
+ const MAX_HOSTS = 20;
35
+ const MAX_VALUE = 16 * 1024;
36
+
37
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '');
38
+
39
+ function secretOf(raw: unknown): VaultSecret | null {
40
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !ENV_RE.test(str(raw.env))) return null;
41
+ const hosts = Array.isArray(raw.hosts) ? raw.hosts.filter((h): h is string => typeof h === 'string' && HOST_RE.test(h)) : [];
42
+ return { id: raw.id, name: str(raw.name) || str(raw.env), env: str(raw.env), hosts, createdAt: str(raw.createdAt), updatedAt: str(raw.updatedAt) };
43
+ }
44
+
45
+ export function readVault(dir = vaultDir()): VaultState {
46
+ const raw = readJson<unknown>(stateFile(dir), null);
47
+ if (!isRecord(raw)) return { version: 1, enabled: false, secrets: [] };
48
+ const secrets = Array.isArray(raw.secrets) ? raw.secrets.map(secretOf).filter((s): s is VaultSecret => s !== null) : [];
49
+ return { version: 1, enabled: raw.enabled === true, secrets };
50
+ }
51
+
52
+ export function writeVault(state: VaultState, dir = vaultDir()): void {
53
+ ensureSecureDir(dir);
54
+ writeSecure(stateFile(dir), `${JSON.stringify(state, null, 2)}\n`);
55
+ }
56
+
57
+ export function normalizeName(raw: unknown): string {
58
+ const name = str(raw).trim();
59
+ if (name === '' || name.length > 80) throw new ApiError('a name of 1 to 80 characters is required', 400);
60
+ return name;
61
+ }
62
+
63
+ export function normalizeEnv(raw: unknown): string {
64
+ const env = str(raw).trim();
65
+ if (!ENV_RE.test(env) || RESERVED_ENV.has(env))
66
+ throw new ApiError('the variable name must be capital letters, digits and _, 3 to 64 long, like OPENAI_API_KEY', 400);
67
+ return env;
68
+ }
69
+
70
+ export function normalizeHosts(raw: unknown): string[] {
71
+ if (!Array.isArray(raw)) throw new ApiError('hosts must be a list of websites, like api.openai.com', 400);
72
+ const hosts = [...new Set(raw.map((h) => str(h).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')).filter((h) => h !== ''))];
73
+ if (hosts.length === 0 || hosts.length > MAX_HOSTS) throw new ApiError(`a secret needs 1 to ${String(MAX_HOSTS)} websites`, 400);
74
+ const bad = hosts.find((h) => !HOST_RE.test(h));
75
+ if (bad !== undefined) throw new ApiError(`'${bad}' is not a website name; write it as api.example.com or *.example.com`, 400);
76
+ return hosts;
77
+ }
78
+
79
+ export function normalizeValue(raw: unknown): string {
80
+ const value = str(raw).trim();
81
+ if (value === '' || value.length > MAX_VALUE) throw new ApiError('a value of 1 to 16384 characters is required', 400);
82
+ return value;
83
+ }
84
+
85
+ function saveValue(id: string, value: string, dir: string): void {
86
+ ensureSecureDir(valuesDir(dir));
87
+ writeSecure(valueFile(id, dir), value);
88
+ }
89
+
90
+ export function addSecret(input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
91
+ const state = readVault(dir);
92
+ if (state.secrets.length >= MAX_SECRETS) throw new ApiError(`at most ${String(MAX_SECRETS)} secrets`, 400);
93
+ const env = normalizeEnv(input.env);
94
+ if (state.secrets.some((s) => s.env === env)) throw new ApiError(`${env} is already a secret`, 409);
95
+ const at = now.toISOString();
96
+ const secret: VaultSecret = { id: newId(), name: normalizeName(input.name ?? env), env, hosts: normalizeHosts(input.hosts), createdAt: at, updatedAt: at };
97
+ saveValue(secret.id, normalizeValue(input.value), dir);
98
+ writeVault({ ...state, secrets: [...state.secrets, secret] }, dir);
99
+ return secret;
100
+ }
101
+
102
+ export function updateSecret(id: string, input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
103
+ const state = readVault(dir);
104
+ const found = state.secrets.find((s) => s.id === id);
105
+ if (found === undefined) throw new ApiError('no such secret', 404);
106
+ const next: VaultSecret = {
107
+ ...found,
108
+ ...(input.name === undefined ? {} : { name: normalizeName(input.name) }),
109
+ ...(input.hosts === undefined ? {} : { hosts: normalizeHosts(input.hosts) }),
110
+ updatedAt: now.toISOString(),
111
+ };
112
+ if (input.value !== undefined) saveValue(id, normalizeValue(input.value), dir);
113
+ writeVault({ ...state, secrets: state.secrets.map((s) => (s.id === id ? next : s)) }, dir);
114
+ return next;
115
+ }
116
+
117
+ export function removeSecret(id: string, dir = vaultDir()): void {
118
+ const state = readVault(dir);
119
+ if (!state.secrets.some((s) => s.id === id)) throw new ApiError('no such secret', 404);
120
+ writeVault({ ...state, secrets: state.secrets.filter((s) => s.id !== id) }, dir);
121
+ rmSync(valueFile(id, dir), { force: true });
122
+ }
123
+
124
+ export function setVaultEnabled(enabled: boolean, dir = vaultDir()): VaultState {
125
+ const next = { ...readVault(dir), enabled };
126
+ writeVault(next, dir);
127
+ return next;
128
+ }
129
+
130
+ export const hasValue = (id: string, dir = vaultDir()): boolean => existsSync(valueFile(id, dir));
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { copyFileSync, chmodSync, existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { asUser, type AgentUser } from '../agent-user/user.js';
5
+ import { TRUSTED_CA } from './paths.js';
6
+
7
+ const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }).status === 0;
8
+
9
+ export function trustSystem(caFile: string): void {
10
+ const same = existsSync(TRUSTED_CA) && readFileSync(TRUSTED_CA, 'utf8') === readFileSync(caFile, 'utf8');
11
+ if (same) return;
12
+ copyFileSync(caFile, TRUSTED_CA);
13
+ chmodSync(TRUSTED_CA, 0o644);
14
+ const run = spawnSync('update-ca-certificates', [], { encoding: 'utf8' });
15
+ if (run.status !== 0) throw new Error(`update-ca-certificates failed: ${run.stderr.trim()}`);
16
+ }
17
+
18
+ export function untrustSystem(): void {
19
+ if (!existsSync(TRUSTED_CA)) return;
20
+ rmSync(TRUSTED_CA, { force: true });
21
+ spawnSync('update-ca-certificates', ['--fresh'], { stdio: 'ignore' });
22
+ }
23
+
24
+ const NSS_SCRIPT = [
25
+ 'set -e',
26
+ 'db="$HOME/.pki/nssdb"',
27
+ 'mkdir -p "$db"',
28
+ '[ -f "$db/cert9.db" ] || certutil -d "sql:$db" -N --empty-password',
29
+ 'certutil -d "sql:$db" -D -n "Metro vault" >/dev/null 2>&1 || true',
30
+ 'certutil -d "sql:$db" -A -t "C,," -n "Metro vault" -i "$1"',
31
+ ].join('\n');
32
+
33
+ function ensureCertutil(): boolean {
34
+ if (has('certutil')) return true;
35
+ if (!has('apt-get')) return false;
36
+ const run = spawnSync('apt-get', ['install', '-y', '-q', 'libnss3-tools'], { stdio: 'ignore', timeout: 180_000, env: { ...process.env, DEBIAN_FRONTEND: 'noninteractive' } });
37
+ return run.status === 0 && has('certutil');
38
+ }
39
+
40
+ export function trustBrowsers(user: AgentUser): string | null {
41
+ try {
42
+ if (!ensureCertutil()) return 'certutil is missing, so browsers the agent starts will not trust the vault (apt-get install libnss3-tools)';
43
+ const [file, argv] = asUser(user, 'sh', ['-c', NSS_SCRIPT, 'metro', TRUSTED_CA]);
44
+ const run = spawnSync(file, argv, { encoding: 'utf8', timeout: 30_000 });
45
+ if (run.status !== 0) return `browsers will not trust the vault: ${run.stderr.trim()}`;
46
+ return null;
47
+ } catch (err) {
48
+ log.warn({ err: errMsg(err) }, 'vault: could not add the certificate for browsers');
49
+ return errMsg(err);
50
+ }
51
+ }
@@ -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.197"
3
3
  }