@stage-labs/metro 0.1.0-beta.317 → 0.1.0-beta.319

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 (33) hide show
  1. package/dist/claude-env.js +9 -2
  2. package/package.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/crontab.ts +2 -8
  4. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +1 -15
  5. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -9
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +0 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/memory-routine.ts +1 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -2
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +26 -4
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/cache-ttl.ts +43 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +5 -5
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +7 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/raw-get-stream.ts +1 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +13 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -2
  17. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +14 -1
  18. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +24 -13
  19. package/runtime/runtime.json +1 -1
  20. package/runtime/sdk-runner/runner.json +1 -1
  21. package/runtime/sdk-runner/src/link.ts +1 -1
  22. package/runtime/sdk-runner/src/rules.ts +1 -1
  23. package/skills/METRO.md +4 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +0 -39
  25. package/runtime/node_modules/@metro-labs/daemon/src/vault/api.ts +0 -45
  26. package/runtime/node_modules/@metro-labs/daemon/src/vault/config.ts +0 -51
  27. package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +0 -9
  28. package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +0 -96
  29. package/runtime/node_modules/@metro-labs/daemon/src/vault/install.ts +0 -56
  30. package/runtime/node_modules/@metro-labs/daemon/src/vault/paths.ts +0 -26
  31. package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +0 -147
  32. package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +0 -129
  33. package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +0 -44
@@ -9,6 +9,8 @@ const PROBE_MS = 3_000;
9
9
  const AUTH_STATUS_MS = 20_000;
10
10
  const RM_PROMPT_OFF = 'CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT';
11
11
  const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
12
+ const CACHE_TTLS = ['CLAUDE_CODE_PROMPT_CACHE_TTL', 'CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL'];
13
+ const LONG_CACHE = '1h';
12
14
  export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
13
15
  export const NO_UPDATES = { DISABLE_AUTOUPDATER: '1' };
14
16
  const set = (env, name) => (env[name] ?? '').trim() !== '';
@@ -30,6 +32,10 @@ export function gatewayEnv(base, agentKey, port) {
30
32
  };
31
33
  }
32
34
  export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
35
+ export function cacheTtlEnv(env) {
36
+ const missing = CACHE_TTLS.filter((name) => !set(env, name));
37
+ return missing.length === 0 ? env : { ...env, ...Object.fromEntries(missing.map((name) => [name, LONG_CACHE])) };
38
+ }
33
39
  export const rmPromptEnv = (env) => set(env, RM_PROMPT_OFF) ? env : { ...env, [RM_PROMPT_OFF]: '1' };
34
40
  export const keepInSession = (env) => set(env, AGENT_VIEW_OFF) ? env : { ...env, [AGENT_VIEW_OFF]: '1' };
35
41
  export function credentialEnv(env, agentKey, signedIn) {
@@ -84,9 +90,10 @@ async function verdict() {
84
90
  function gatewayLaunchEnv(key, port, claude, say) {
85
91
  say(`inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)`);
86
92
  const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
87
- const routed = toolSearchEnv(pointed);
88
- if (routed !== pointed)
93
+ const searched = toolSearchEnv(pointed);
94
+ if (searched !== pointed)
89
95
  say('MCP tool search is on, so connector tools load when Claude needs them instead of all at start');
96
+ const routed = cacheTtlEnv(searched);
90
97
  const env = credentialEnv(routed, key, claudeSignedIn(claude));
91
98
  if (env !== routed)
92
99
  say("Claude Code has no login of its own here, so metro's key stands in as its credential; the organization's Models page decides where requests go");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.317",
3
+ "version": "0.1.0-beta.319",
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": {
@@ -14,11 +14,10 @@ function outsideBlock(lines: string[], begin: string, end: string): string[] {
14
14
  return [...lines.slice(0, start), ...lines.slice(stop + 1)];
15
15
  }
16
16
 
17
- export function withBlock(lines: string[], begin: string, end: string, block: string[], at: 'top' | 'end', owned: (line: string) => boolean = () => false): string[] {
17
+ export function withBlock(lines: string[], begin: string, end: string, block: string[], owned: (line: string) => boolean): string[] {
18
18
  const kept = outsideBlock(lines, begin, end).filter((l) => l !== '' && l !== begin && l !== end && !owned(l));
19
19
  if (block.length === 0) return kept;
20
- const wrapped = [begin, ...block, end];
21
- return at === 'top' ? [...wrapped, ...kept] : [...kept, ...wrapped];
20
+ return [...kept, begin, ...block, end];
22
21
  }
23
22
 
24
23
  const failure = (what: string, user: AgentUser, out: { status: number | null; stderr?: string }): Error =>
@@ -37,8 +36,3 @@ export function writeCrontab(user: AgentUser, runner: Runner, lines: string[], n
37
36
  if (out.status !== 0) throw failure('write', user, out);
38
37
  return true;
39
38
  }
40
-
41
- export function editCrontab(user: AgentUser, runner: Runner, change: (lines: string[]) => string[]): boolean {
42
- const lines = readCrontab(user, runner);
43
- return writeCrontab(user, runner, lines, change(lines));
44
- }
@@ -1,27 +1,13 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { mustHelper, rootHelper } from '../metro-user/privilege.js';
2
+ import { rootHelper } from '../metro-user/privilege.js';
5
3
  import { agentUser, asUser } from './user.js';
6
4
 
7
- const UNIT_DIR = '/etc/systemd/system';
8
-
9
5
  export interface RunResult {
10
6
  status: number | null;
11
7
  stdout: string;
12
8
  stderr?: string;
13
9
  }
14
10
 
15
- export function writeDropIn(service: string, name: string, text: string): void {
16
- mustHelper(['dropin-write', service, name], text);
17
- }
18
-
19
- export function removeDropIn(service: string, name: string): boolean {
20
- if (!existsSync(join(UNIT_DIR, `${service}.d`, name))) return false;
21
- mustHelper(['dropin-remove', service, name]);
22
- return true;
23
- }
24
-
25
11
  function agentCrontab(args: string[], input?: string): RunResult {
26
12
  const at = args.indexOf('-u');
27
13
  const rest = at < 0 ? args : args.filter((_, i) => i !== at && i !== at + 1);
@@ -44,14 +44,6 @@ export function forgetAgentUser(): void {
44
44
  cached = null;
45
45
  }
46
46
 
47
- let extraEnv: Record<string, string> = {};
48
-
49
- export function setAgentExtraEnv(env: Record<string, string>): void {
50
- extraEnv = { ...env };
51
- }
52
-
53
- export const agentExtraEnv = (): Record<string, string> => ({ ...extraEnv });
54
-
55
47
  export const agentPath = (user: AgentUser): string => AGENT_PATH.map((p) => (p.startsWith('/') ? p : join(user.home, p))).join(':');
56
48
 
57
49
  function agentEnv(user: AgentUser, extra: Record<string, string> = {}): Record<string, string> {
@@ -63,7 +55,6 @@ function agentEnv(user: AgentUser, extra: Record<string, string> = {}): Record<s
63
55
  LANG: process.env.LANG ?? 'C.UTF-8',
64
56
  TERM: process.env.TERM ?? 'xterm-256color',
65
57
  PATH: agentPath(user),
66
- ...extraEnv,
67
58
  ...extra,
68
59
  };
69
60
  }
@@ -28,7 +28,6 @@ import { holdServerApproval } from '../mcp/server-approvals.js';
28
28
  import { removeConnectorLeftovers } from '../connectors/leftovers.js';
29
29
  import { runningAsRoot } from '../metro-user/privilege.js';
30
30
  import { prepareAgentRunner, provisionAgentUser } from '../agent-user/provision.js';
31
- import { applyVault } from '../vault/index.js';
32
31
  import { blockImds } from '../metro-user/imds.js';
33
32
  import { followRelease } from '../metro-user/root-follow.js';
34
33
  import { unwatchSession, watchSession } from '../claude/session.js';
@@ -205,8 +204,6 @@ async function startClaude(): Promise<void> {
205
204
  if (agentUser === 'ready') {
206
205
  blockImds();
207
206
  followRelease();
208
- const vault = (await applyVault()).status;
209
- if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
210
207
  }
211
208
  tryClaudeSetup();
212
209
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
@@ -107,7 +107,7 @@ export function ensureMemoryJob(on: boolean, deps: MemoryJobDeps = {}): MemoryJo
107
107
  const launcher = launcherPath(user);
108
108
  if (scheduled) placeLauncher(launcher, launcherText(user, cli, port));
109
109
  const kept = marked(user, lines, on, scheduled);
110
- const next = withBlock(kept, BEGIN, END, scheduled ? [cronLine(user)] : [], 'end', (l) => l.includes(launcher));
110
+ const next = withBlock(kept, BEGIN, END, scheduled ? [cronLine(user)] : [], (l) => l.includes(launcher));
111
111
  const changed = writeCrontab(user, runner, lines, next);
112
112
  last = { state: stateOf(scheduled, own), job: own };
113
113
  if (changed) log.info({ ...last, rewritten: kept.filter((l, i) => l !== lines[i]) }, 'memory-routine: updated the memory job');
@@ -15,13 +15,13 @@ import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config
15
15
  import { effectiveSetup } from '../gateway/effective.js';
16
16
  import { inSessionScope } from './memory.js';
17
17
  import { runningAsRoot } from '../metro-user/privilege.js';
18
- import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
18
+ import { agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
19
19
 
20
20
  function sessionEnv(): Record<string, string> {
21
21
  const user = agentUser();
22
22
  if (user === null) return {};
23
23
  const port = process.env.METRO_WEBHOOK_PORT?.trim() ?? '';
24
- return { ...agentExtraEnv(), METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
24
+ return { METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
25
25
  }
26
26
 
27
27
  const SESSION_NAME = 'metro';
@@ -141,6 +141,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
141
141
  '587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
142
142
  '256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
143
143
  '9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
144
+ '8b6d7c5a9bc8d02d5e3ae5e5931b36aba9f3d0eb7fd38b371294c2abaa0c94ee',
144
145
  ]);
145
146
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
146
147
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
+ import { withoutCacheTtl, withoutTtlBeta } from './cache-ttl.js';
3
4
  import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
4
5
  import { errorFrame, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
5
6
  import type { Connection } from './model-config.js';
@@ -7,15 +8,23 @@ import { estimateTokens } from './subscription.js';
7
8
  import { UsageScanner } from './usage.js';
8
9
 
9
10
  const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
10
- const EXTRA_INPUT_RE = /^([A-Za-z0-9_]+)(?:\.[^:]*)?: Extra inputs are not permitted/;
11
+ const EXTRA_INPUT_RE = /^([A-Za-z0-9_.]+): Extra inputs are not permitted/;
12
+ const CACHE_TTL_PATH = '.cache_control.ttl';
11
13
  const MAX_REPAIRS = 4;
12
14
 
13
15
  export interface Adaptations {
14
16
  fields: Set<string>;
15
17
  dropBetas: boolean;
18
+ dropCacheTtl: boolean;
16
19
  }
17
20
 
18
- export const freshAdaptations = (): Adaptations => ({ fields: new Set(), dropBetas: false });
21
+ export const freshAdaptations = (): Adaptations => ({ fields: new Set(), dropBetas: false, dropCacheTtl: false });
22
+
23
+ export function forgetAdaptations(learned: Adaptations): void {
24
+ learned.fields.clear();
25
+ learned.dropBetas = false;
26
+ learned.dropCacheTtl = false;
27
+ }
19
28
 
20
29
  export interface BedrockUpstream {
21
30
  settings: Connection;
@@ -106,15 +115,28 @@ function callBedrock(up: BedrockUpstream, url: string, body: unknown, accept: st
106
115
  const without = (body: Record<string, unknown>, keys: Set<string>): Record<string, unknown> =>
107
116
  Object.fromEntries(Object.entries(body).filter(([key]) => !keys.has(key)));
108
117
 
118
+ function withBetas(body: Record<string, unknown>, betas: string[]): Attempt {
119
+ const rest = without(body, new Set(['anthropic_beta']));
120
+ return { body: betas.length > 0 ? { ...rest, anthropic_beta: betas } : rest, betas };
121
+ }
122
+
123
+ const withoutTtl = (attempt: Attempt): Attempt => withBetas(withoutCacheTtl(attempt.body), withoutTtlBeta(attempt.betas));
124
+
109
125
  function applyLearned(req: Rewritten, learned: Adaptations): Attempt {
110
126
  const dropped = new Set(learned.fields);
111
127
  if (learned.dropBetas) dropped.add('anthropic_beta');
112
- return { body: without(req.body, dropped), betas: learned.dropBetas ? [] : req.betas };
128
+ const attempt = { body: without(req.body, dropped), betas: learned.dropBetas ? [] : req.betas };
129
+ return learned.dropCacheTtl ? withoutTtl(attempt) : attempt;
113
130
  }
114
131
 
115
132
  function repair(attempt: Attempt, learned: Adaptations, message: string): Attempt | null {
116
133
  const named = EXTRA_INPUT_RE.exec(message)?.[1];
117
- if (named !== undefined && named in attempt.body) {
134
+ if (named !== undefined && named.endsWith(CACHE_TTL_PATH) && !learned.dropCacheTtl) {
135
+ learned.dropCacheTtl = true;
136
+ log.warn('gateway: Bedrock refused the one-hour cache; using the five-minute cache from now on');
137
+ return withoutTtl(attempt);
138
+ }
139
+ if (named !== undefined && !named.includes('.') && named in attempt.body) {
118
140
  learned.fields.add(named);
119
141
  log.warn({ field: named }, 'gateway: Bedrock refused an extra input; dropping it from every request');
120
142
  return { body: without(attempt.body, new Set([named])), betas: attempt.betas };
@@ -0,0 +1,43 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+
3
+ const TTL_BETA = 'extended-cache-ttl-';
4
+
5
+ export const withoutTtlBeta = (betas: string[]): string[] => betas.filter((beta) => !beta.startsWith(TTL_BETA));
6
+
7
+ function plainControl(block: unknown): unknown {
8
+ if (!isRecord(block) || !isRecord(block.cache_control) || !Object.hasOwn(block.cache_control, 'ttl')) return block;
9
+ const control = Object.fromEntries(Object.entries(block.cache_control).filter(([key]) => key !== 'ttl'));
10
+ return { ...block, cache_control: control };
11
+ }
12
+
13
+ function plainList(value: unknown): unknown {
14
+ if (!Array.isArray(value)) return value;
15
+ const out: unknown[] = value.map(plainControl);
16
+ return out.some((block, at) => block !== value[at]) ? out : value;
17
+ }
18
+
19
+ function plainMessage(message: unknown): unknown {
20
+ if (!isRecord(message)) return message;
21
+ const content = plainList(message.content);
22
+ return content === message.content ? message : { ...message, content };
23
+ }
24
+
25
+ export function withoutCacheTtl(body: Record<string, unknown>): Record<string, unknown> {
26
+ const out: Record<string, unknown> = { ...body };
27
+ let changed = false;
28
+ for (const key of ['tools', 'system']) {
29
+ const plain = plainList(body[key]);
30
+ if (plain === body[key]) continue;
31
+ out[key] = plain;
32
+ changed = true;
33
+ }
34
+ if (Array.isArray(body.messages)) {
35
+ const listed: unknown[] = body.messages;
36
+ const messages = listed.map(plainMessage);
37
+ if (messages.some((message, at) => message !== listed[at])) {
38
+ out.messages = messages;
39
+ changed = true;
40
+ }
41
+ }
42
+ return changed ? out : body;
43
+ }
@@ -6,6 +6,7 @@ import {
6
6
  bedrockBase,
7
7
  bedrockCount,
8
8
  bedrockMessages,
9
+ forgetAdaptations,
9
10
  freshAdaptations,
10
11
  type Adaptations,
11
12
  } from './bedrock.js';
@@ -44,7 +45,7 @@ import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Conn
44
45
  import { codexMessages, type CodexDeps } from './codex.js';
45
46
  import { countTokens } from './subscription.js';
46
47
  import { OPENROUTER_BASE } from './openrouter.js';
47
- import { noteRoutewayRefusal, refusedWithPage, ROUTEWAY_BASE, routewayHeaders, routewayTools } from './routeway.js';
48
+ import { noteRoutewayRefusal, refusedWithPage, ROUTEWAY_BASE, routewayBody, routewayHeaders } from './routeway.js';
48
49
  import { isRecord } from '@metro-labs/core/is-record';
49
50
  import { forgetServed, noteServed } from './served.js';
50
51
  import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
@@ -75,8 +76,7 @@ export function resetGatewayState(): void {
75
76
  forgetServed();
76
77
  forgetFallbackState();
77
78
  forgetUsage();
78
- learned.fields.clear();
79
- learned.dropBetas = false;
79
+ forgetAdaptations(learned);
80
80
  forgetLearnedThinking();
81
81
  }
82
82
 
@@ -190,8 +190,8 @@ async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shape
190
190
  const conn = route.connection;
191
191
  const watch = watchUpstream(res);
192
192
  const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
193
- const sent = routewayTools(body.sent);
194
- const fitted: Shaped = { raw: sent === body.sent ? body.raw : Buffer.from(JSON.stringify(sent)), sent, shaped: routewayTools(body.shaped), bind: false };
193
+ const sent = routewayBody(body.sent);
194
+ const fitted: Shaped = { raw: sent === body.sent ? body.raw : Buffer.from(JSON.stringify(sent)), sent, shaped: routewayBody(body.shaped), bind: false };
195
195
  const upstream = await shapedAttempt(send, routewayHeaders(req, conn.apiKey), fitted, route);
196
196
  noteRefusal(conn.label, route.model, upstream);
197
197
  await noteRoutewayRefusal(upstream);
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { log } from '@metro-labs/core/log';
4
+ import { withoutCacheTtl, withoutTtlBeta } from './cache-ttl.js';
4
5
  import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
5
6
 
6
7
  export const ROUTEWAY_BASE = 'https://api.routeway.ai';
@@ -10,6 +11,9 @@ const HANDED_OVER = new Set([402, 429]);
10
11
  export function routewayHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
11
12
  const headers = anthropicHeaders(req, apiKey);
12
13
  headers['user-agent'] ??= USER_AGENT;
14
+ const betas = withoutTtlBeta((headers['anthropic-beta'] ?? '').split(',').filter((beta) => beta !== ''));
15
+ if (betas.length > 0) headers['anthropic-beta'] = betas.join(',');
16
+ else delete headers['anthropic-beta'];
13
17
  return headers;
14
18
  }
15
19
 
@@ -65,13 +69,15 @@ function withProperties(tool: unknown): unknown {
65
69
  return full === tool.input_schema ? tool : { ...tool, input_schema: full };
66
70
  }
67
71
 
68
- export function routewayTools(body: Record<string, unknown>): Record<string, unknown> {
72
+ function routewayTools(body: Record<string, unknown>): Record<string, unknown> {
69
73
  const listed: unknown = body.tools;
70
74
  if (!Array.isArray(listed)) return body;
71
75
  const tools: unknown[] = listed.map(withProperties);
72
76
  return tools.some((tool, at) => tool !== listed[at]) ? { ...body, tools } : body;
73
77
  }
74
78
 
79
+ export const routewayBody = (body: Record<string, unknown>): Record<string, unknown> => withoutCacheTtl(routewayTools(body));
80
+
75
81
  export async function noteRoutewayRefusal(upstream: Response): Promise<void> {
76
82
  if (upstream.ok || !(upstream.headers.get('content-type') ?? '').includes('application/json')) return;
77
83
  const body: unknown = await upstream.clone().json().catch(() => null);
@@ -9,7 +9,7 @@ import type { BoundedEventStore } from './event-store.js';
9
9
 
10
10
  const STANDALONE_STREAM_ID = '_GET_stream';
11
11
  const KEEPALIVE_MS = 15_000;
12
- const RECONNECT_RETRY_MS = 15_000;
12
+ const RECONNECT_RETRY_MS = 1_000;
13
13
  const COMMENT = ':\n\n';
14
14
  const RETRY_DIRECTIVE = `retry: ${RECONNECT_RETRY_MS}\n\n`;
15
15
 
@@ -46,10 +46,16 @@ const noticeText = (tool: string, id: string, behavior: Behavior): string =>
46
46
 
47
47
  const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
48
48
 
49
+ const askedBy = (approval: McpApproval): string => {
50
+ if (approval.origin !== 'key') return '';
51
+ return approval.keyLabel === undefined ? ' (from an access key)' : ` (from an access key: ${approval.keyLabel})`;
52
+ };
53
+
49
54
  interface Held {
50
55
  id: string;
51
56
  tool: string;
52
57
  line: string | undefined;
58
+ tellAgent: boolean;
53
59
  answer: (decision: McpDecision) => Promise<AnswerOutcome>;
54
60
  publish: (event: MetroEvent) => void;
55
61
  }
@@ -60,6 +66,10 @@ async function deliver(held: Held, behavior: Behavior): Promise<void> {
60
66
  log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
61
67
  return;
62
68
  }
69
+ if (!held.tellAgent) {
70
+ log.info({ id: held.id }, 'mcp-server: the call came with an access key, not from this agent\'s session, so the agent is not told');
71
+ return;
72
+ }
63
73
  const line = noticeLineFor(held.line);
64
74
  if (line === undefined) {
65
75
  log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
@@ -91,15 +101,15 @@ export function holdServerApproval(approval: McpApproval, answer: Held['answer']
91
101
  const tool = `${approval.connector}.${approval.tool}`;
92
102
  const announced = announcedLine(approval.id);
93
103
  const line = announced ?? promptLineFor(approval.id);
94
- const held: Held = { id: approval.id, tool, line, answer, publish };
104
+ const held: Held = { id: approval.id, tool, line, tellAgent: approval.origin !== 'key', answer, publish };
95
105
  holdPrompt(
96
106
  { requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
97
107
  owner,
98
108
  sender(held),
99
109
  line === undefined ? undefined : async (text) => metroSend(line, text),
100
110
  );
101
- log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
102
- if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool, preview: approval.preview }));
111
+ log.info({ id: approval.id, tool, origin: approval.origin ?? null, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
112
+ if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool: `${tool}${askedBy(approval)}`, preview: approval.preview }));
103
113
  return true;
104
114
  }
105
115
 
@@ -15,7 +15,6 @@ import type { GatewayDeps } from '../gateway/gateway.js';
15
15
  import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js';
16
16
  import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
17
17
  import { handleFilesRequest } from '../agent-user/files-api.js';
18
- import { handleVaultRequest } from '../vault/api.js';
19
18
  import { handleVoiceRequest } from '../voice/api.js';
20
19
  import { handleRunEventsRequest } from '../monitor/run-events.js';
21
20
 
@@ -52,7 +51,6 @@ export function handleSessionApis(
52
51
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
53
52
  () => handleSchedulesRequest(req, res),
54
53
  () => handleFilesRequest(req, res),
55
- () => handleVaultRequest(req, res),
56
54
  () => handleVoiceRequest(req, res),
57
55
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
58
56
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
@@ -27,12 +27,18 @@ export interface McpCall {
27
27
  approval?: string;
28
28
  }
29
29
 
30
+ type McpApprovalOrigin = 'box' | 'key';
31
+
32
+ const KEY_LABEL_MAX = 64;
33
+
30
34
  export interface McpApproval {
31
35
  id: string;
32
36
  connector: string;
33
37
  tool: string;
34
38
  preview: string;
35
39
  requestedAt: number;
40
+ origin?: McpApprovalOrigin;
41
+ keyLabel?: string;
36
42
  }
37
43
 
38
44
  export type McpDecision = 'allow' | 'deny';
@@ -70,9 +76,16 @@ export function mcpTokenHashOf(raw: unknown): string {
70
76
  return raw.tokenHash;
71
77
  }
72
78
 
79
+ function originOf(raw: Record<string, unknown>): Pick<McpApproval, 'origin' | 'keyLabel'> {
80
+ if (raw.origin === 'box') return { origin: 'box' };
81
+ if (raw.origin !== 'key') return {};
82
+ const label = typeof raw.keyLabel === 'string' ? raw.keyLabel.replace(/\p{Cc}/gu, '').trim() : '';
83
+ return label === '' || label.length > KEY_LABEL_MAX ? { origin: 'key' } : { origin: 'key', keyLabel: label };
84
+ }
85
+
73
86
  function approvalOf(raw: unknown): McpApproval | null {
74
87
  if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
75
- return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
88
+ return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt, ...originOf(raw) };
76
89
  }
77
90
 
78
91
  export function mcpApprovalsOf(raw: unknown): McpApproval[] {
@@ -61,19 +61,30 @@ export const toolSearchDescription = (keys: string[] | null): string =>
61
61
  'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
62
62
  ].join('\n');
63
63
 
64
- export const TOOL_CALL_DESCRIPTION = [
65
- 'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
66
- `Shape: ${TOOL_CALL_EXAMPLE}`,
67
- `Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
68
- '- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
69
- '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
70
- '- denied or blocked: the owner refused or blocked this call. Do not retry it.',
71
- '- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
72
- '- unavailable: the connector or Metro could not be reached. Try again later.',
73
- '- vendor_error: the connector answered with its own error, shown as it came.',
74
- ].join('\n');
64
+ export type ApprovalPlace = 'session' | 'page' | 'owner';
75
65
 
76
- export function mcpServerTools(keys: string[] | null): McpServerTool[] {
66
+ const NEEDS_APPROVAL: Record<ApprovalPlace, string> = {
67
+ session: '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
68
+ page: '- needs_approval: the owner must approve it on this agent\'s page on metro.box. Tell the person what is waiting and ask them to approve it there, then call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
69
+ owner: '- needs_approval: the agent\'s owner was asked in chat and on its page on metro.box. Tell the person what is waiting; once it is approved, call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
70
+ };
71
+
72
+ const toolCallDescription = (place: ApprovalPlace): string =>
73
+ [
74
+ 'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
75
+ `Shape: ${TOOL_CALL_EXAMPLE}`,
76
+ `Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
77
+ '- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
78
+ NEEDS_APPROVAL[place],
79
+ '- denied or blocked: the owner refused or blocked this call. Do not retry it.',
80
+ '- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
81
+ '- unavailable: the connector or Metro could not be reached. Try again later.',
82
+ '- vendor_error: the connector answered with its own error, shown as it came.',
83
+ ].join('\n');
84
+
85
+ export const TOOL_CALL_DESCRIPTION = toolCallDescription('session');
86
+
87
+ export function mcpServerTools(keys: string[] | null, place: ApprovalPlace = 'session'): McpServerTool[] {
77
88
  return [
78
89
  {
79
90
  name: TOOL_SEARCH,
@@ -85,7 +96,7 @@ export function mcpServerTools(keys: string[] | null): McpServerTool[] {
85
96
  {
86
97
  name: TOOL_CALL,
87
98
  title: 'Call connector tools',
88
- description: TOOL_CALL_DESCRIPTION,
99
+ description: toolCallDescription(place),
89
100
  inputSchema: callSchema,
90
101
  annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
91
102
  },
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.317"
2
+ "version": "0.1.0-beta.319"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.317",
2
+ "version": "0.1.0-beta.319",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -12,7 +12,7 @@ const ANSWER = 'notifications/claude/channel/permission';
12
12
  const TOOLS_CHANGED = 'notifications/tools/list_changed';
13
13
  const MODEL = 'notifications/metro/model';
14
14
  const TOOL_TIMEOUT_MS = 30 * 60_000;
15
- const RECONNECT = { initialReconnectionDelay: 500, maxReconnectionDelay: 5_000, reconnectionDelayGrowFactor: 1.5, maxRetries: 60 };
15
+ const RECONNECT = { initialReconnectionDelay: 500, maxReconnectionDelay: 5_000, reconnectionDelayGrowFactor: 1.5, maxRetries: 900 };
16
16
  const GAVE_UP = 'Maximum reconnection attempts';
17
17
 
18
18
  export type Behavior = 'allow' | 'deny';
@@ -1,7 +1,7 @@
1
1
  import type { CallRoute, CallSource } from '@metro-labs/core/call';
2
2
 
3
3
  export const FRONT_RULES = `You are the orchestrator of this agent's persistent Agent SDK session. Typed chat, live call words (<call> blocks), and background worker reports share this conversation.
4
- - Be fast. When you can answer a chat message at once, make the reaction and the answer one response of parallel tool calls (react, then send), with no typing signal. Use typing only when the answer will take a while.
4
+ - Be fast. When you can answer a chat message at once, make the reaction and the answer one response of parallel tool calls (react and send together), with no typing signal. Use typing only when the answer will take a while.
5
5
  - Stay light: talk, decide and delegate. Real work goes to background workers (Agent tool, subagent_type "worker", run_in_background: true); keep answering while they run, and relay what they report.
6
6
  - Keep one implementation owner per task. Reuse that owner through the permitted coordination path; avoid chains of relay-only workers and duplicate implementers. Use focused checks while iterating, then the required full gate on the final changes. Reuse verified results only while their inputs are unchanged. A refused lock or an interrupted check is not a pass.
7
7
  - At the next safe tool boundary, answer pending live-call inputs before starting unrelated delegation or processing worker reports. Give a short, direct answer when possible; do not generate a long task prompt before replying to the caller. Never interrupt active writes or approvals, invent filler, or promise work is done before it is.
package/skills/METRO.md CHANGED
@@ -5,9 +5,11 @@ description: How this box's agent works on metro. Acknowledge, delegate, relay,
5
5
 
6
6
  You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard metro installs denies every other tool on this thread, so this is the shape of the work, not a preference.
7
7
 
8
- **Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. Exception: when the person asks you to close something ("mark as done"), skip the reaction and the acknowledgement: close it directly and confirm once when it is finished.
8
+ **Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. Exception: when the person asks you to close something ("mark as done"), skip the reaction and the acknowledgement: close it directly and confirm once when it is finished.
9
9
 
10
- **Typing.** Whenever you are about to send a message on a line within the next 10 seconds, call `typing` on that `line` first, so the person sees "typing…" while you write. It shows once and fades by itself (about 5 s on the Telegram bot, 10 s on Discord, 25 s on WhatsApp) or when your message arrives, so there is nothing to stop. Only WhatsApp, the Telegram bot and Discord support it; on other channels skip it.
10
+ **Be fast: one response, parallel tool calls.** Every response you make is a full model turn, so never spend one on a reaction or a typing signal alone. When you can answer at once, put `react` and `send` in the same response. When the work goes to a worker, put `react` and the `Agent` call in the same response. When you relay a finished result, put the `send` and the ✅ reaction in the same response.
11
+
12
+ **Typing.** Skip it when you answer at once: your message is the signal. Call `typing` on the `line` only when the answer will take a while, and only together with your other calls in the same response. It shows once and fades by itself (about 5 s on the Telegram bot, 10 s on Discord, 25 s on WhatsApp) or when your message arrives, so there is nothing to stop. Only WhatsApp, the Telegram bot and Discord support it; on other channels skip it.
11
13
 
12
14
  **Delegate everything that is work.** Reading files, running commands, searching, fetching pages, editing, and every connector tool: hand it to a subagent with the `Agent` tool, `subagent_type: "worker"`, always `run_in_background: true`, then keep answering chat while it runs and relay the result when the task notification arrives. A foreground subagent blinds you to chat and the guard refuses it. You may read image files yourself, so a screenshot someone sends is yours to look at.
13
15
 
@@ -1,39 +0,0 @@
1
- import { join } from 'node:path';
2
- import { log } from '@metro-labs/core/log';
3
- import { editCrontab, withBlock } from './crontab.js';
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
- import { removeDropIn, writeDropIn } from './unit-files.js';
8
-
9
- const DROP_IN = '11-metro-vault.conf';
10
- const BEGIN = '# metro vault: begin';
11
- const END = '# metro vault: end';
12
-
13
- const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
14
-
15
- export function withCronEnv(lines: string[], env: Record<string, string>): string[] {
16
- return withBlock(lines, BEGIN, END, Object.entries(env).map(([k, v]) => `${k}=${v}`), 'top');
17
- }
18
-
19
- function setTimerEnv(user: AgentUser, on: boolean, runner: Runner): void {
20
- let changed = false;
21
- for (const job of listSchedules(user, runner)) {
22
- if (job.kind !== 'timer' || job.runsAs !== user.name) continue;
23
- const unit = job.id.slice('timer:'.length);
24
- const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
25
- if (on) {
26
- writeDropIn(service, DROP_IN, `[Service]\nEnvironmentFile=-${jobEnvFile(user)}\n`);
27
- changed = true;
28
- } else if (removeDropIn(service, DROP_IN)) changed = true;
29
- }
30
- if (changed) runner.run('systemctl', ['daemon-reload']);
31
- }
32
-
33
- export function exportJobEnv(user: AgentUser, env: Record<string, string>, runner: Runner = realRunner): void {
34
- const on = Object.keys(env).length > 0;
35
- writeHomeText(jobEnvFile(user), Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), 0o644);
36
- editCrontab(user, runner, (lines) => withCronEnv(lines, env));
37
- setTimerEnv(user, on, runner);
38
- log.info({ on, keys: Object.keys(env).length }, "vault: the agent's scheduled jobs use the vault settings");
39
- }
@@ -1,45 +0,0 @@
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
- }
@@ -1,51 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
1
- import { mustHelper } from '../metro-user/privilege.js';
2
-
3
- export function applyFirewall(): void {
4
- mustHelper(['firewall-on']);
5
- }
6
-
7
- export function removeFirewall(): void {
8
- mustHelper(['firewall-off']);
9
- }
@@ -1,96 +0,0 @@
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();
62
- return proxyEnvFor(TRUSTED_CA, SYSTEM_BUNDLE, secrets);
63
- }
64
-
65
- function turnOff(): void {
66
- removeFirewall();
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(), {});
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
- }
@@ -1,56 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
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';
@@ -1,147 +0,0 @@
1
- import { spawn, type ChildProcess } from 'node:child_process';
2
- import { stringOf } from '@metro-labs/http/api-http';
3
- import { randomBytes } from 'node:crypto';
4
- import { basename } from 'node:path';
5
- import { createInterface } from 'node:readline';
6
- import { isRecord } from '@metro-labs/core/is-record';
7
- import { errMsg, log } from '@metro-labs/core/log';
8
- import { MANAGEMENT_KEY_ENV } from './config.js';
9
- import { ports } from './paths.js';
10
-
11
- export interface ProxyRequest {
12
- at: string;
13
- method: string;
14
- host: string;
15
- path: string;
16
- status: number | null;
17
- action: string;
18
- swapped: string[];
19
- }
20
-
21
- const RECENT_MAX = 200;
22
- const BACKOFF_MAX_MS = 30_000;
23
-
24
- const recent: ProxyRequest[] = [];
25
- const state: { child: ChildProcess | null; wanted: { bin: string; config: string } | null; key: string; backoff: number; lastError: string | null; startedAt: number } = {
26
- child: null,
27
- wanted: null,
28
- key: randomBytes(24).toString('hex'),
29
- backoff: 1000,
30
- lastError: null,
31
- startedAt: 0,
32
- };
33
-
34
- function swappedIds(transforms: unknown): string[] {
35
- if (!Array.isArray(transforms)) return [];
36
- return transforms.flatMap((t) => {
37
- const swapped = isRecord(t) && isRecord(t.annotations) && Array.isArray(t.annotations.swapped) ? t.annotations.swapped : [];
38
- return swapped.flatMap((s) => (isRecord(s) && typeof s.secret === 'string' ? [basename(s.secret)] : []));
39
- });
40
- }
41
-
42
- export function requestOf(line: string): ProxyRequest | null {
43
- let parsed: unknown;
44
- try {
45
- parsed = JSON.parse(line);
46
- } catch {
47
- return null;
48
- }
49
- if (!isRecord(parsed) || parsed.msg !== 'request' || !isRecord(parsed.audit)) return null;
50
- const audit = parsed.audit;
51
- if (audit.method === 'CONNECT') return null;
52
- return {
53
- at: stringOf(parsed.time),
54
- method: stringOf(audit.method),
55
- host: stringOf(audit.host),
56
- path: stringOf(audit.path),
57
- status: typeof audit.status_code === 'number' ? audit.status_code : null,
58
- action: stringOf(audit.action),
59
- swapped: swappedIds(parsed.request_transforms),
60
- };
61
- }
62
-
63
- function noteLine(line: string): void {
64
- const request = requestOf(line);
65
- if (request !== null) {
66
- recent.push(request);
67
- if (recent.length > RECENT_MAX) recent.shift();
68
- return;
69
- }
70
- if (/"level":"(WARN|ERROR)"/.test(line)) log.warn({ line: line.slice(0, 500) }, 'vault: iron-proxy');
71
- }
72
-
73
- function launch(): void {
74
- const wanted = state.wanted;
75
- if (wanted === null || state.child !== null) return;
76
- 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 } });
77
- state.child = child;
78
- state.startedAt = Date.now();
79
- for (const stream of [child.stdout, child.stderr]) if (stream !== null) createInterface({ input: stream }).on('line', noteLine);
80
- child.on('error', (err) => {
81
- state.lastError = errMsg(err);
82
- });
83
- child.on('exit', (code) => {
84
- state.child = null;
85
- if (state.wanted === null) return;
86
- state.lastError = `iron-proxy stopped (exit ${String(code)})`;
87
- log.warn({ code, retryInMs: state.backoff }, 'vault: iron-proxy stopped, starting it again');
88
- const wait = state.backoff;
89
- state.backoff = Date.now() - state.startedAt > 60_000 ? 1000 : Math.min(state.backoff * 2, BACKOFF_MAX_MS);
90
- setTimeout(launch, wait).unref();
91
- });
92
- }
93
-
94
- async function reload(): Promise<boolean> {
95
- try {
96
- const res = await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, {
97
- method: 'POST',
98
- headers: { authorization: `Bearer ${state.key}` },
99
- signal: AbortSignal.timeout(10_000),
100
- });
101
- return res.ok;
102
- } catch {
103
- return false;
104
- }
105
- }
106
-
107
- async function listening(): Promise<boolean> {
108
- try {
109
- await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, { method: 'GET', signal: AbortSignal.timeout(2_000) });
110
- return true;
111
- } catch {
112
- return false;
113
- }
114
- }
115
-
116
- async function waitListening(ms = 10_000): Promise<boolean> {
117
- const until = Date.now() + ms;
118
- while (Date.now() < until) {
119
- if (await listening()) return true;
120
- await new Promise((r) => setTimeout(r, 200));
121
- }
122
- return false;
123
- }
124
-
125
- export async function runProxy(bin: string, config: string): Promise<void> {
126
- const fresh = state.child === null;
127
- state.wanted = { bin, config };
128
- if (fresh) launch();
129
- else if (await reload()) {
130
- state.lastError = null;
131
- return;
132
- } else {
133
- state.child?.kill('SIGTERM');
134
- await new Promise((r) => setTimeout(r, state.backoff + 500));
135
- }
136
- if (!(await waitListening())) throw new Error(state.lastError ?? 'iron-proxy did not start');
137
- state.lastError = null;
138
- }
139
-
140
- export function stopProxy(): void {
141
- state.wanted = null;
142
- state.child?.kill('SIGTERM');
143
- }
144
-
145
- export const proxyRunning = (): boolean => state.child !== null;
146
- export const proxyError = (): string | null => state.lastError;
147
- export const recentRequests = (): ProxyRequest[] => [...recent].reverse();
@@ -1,129 +0,0 @@
1
- import { existsSync, rmSync } from 'node:fs';
2
- import { stringOf } from '@metro-labs/http/api-http';
3
- import { ApiError } from '@metro-labs/http/api-error';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
- import { newId } from '@metro-labs/core/ids';
6
- import { ensureSecureDir, readJson, writeSecure } from '@metro-labs/core/secure-fs';
7
- import { stateFile, valueFile, valuesDir, vaultDir } from './paths.js';
8
-
9
- export interface VaultSecret {
10
- id: string;
11
- name: string;
12
- env: string;
13
- hosts: string[];
14
- createdAt: string;
15
- updatedAt: string;
16
- }
17
-
18
- export interface VaultState {
19
- version: 1;
20
- enabled: boolean;
21
- secrets: VaultSecret[];
22
- }
23
-
24
- export interface SecretInput {
25
- name?: unknown;
26
- env?: unknown;
27
- hosts?: unknown;
28
- value?: unknown;
29
- }
30
-
31
- const ENV_RE = /^[A-Z][A-Z0-9_]{2,63}$/;
32
- const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
33
- const RESERVED_ENV = new Set(['HOME', 'PATH', 'USER', 'SHELL', 'LANG', 'TERM', 'LOGNAME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY']);
34
- const MAX_SECRETS = 100;
35
- const MAX_HOSTS = 20;
36
- const MAX_VALUE = 16 * 1024;
37
-
38
- function secretOf(raw: unknown): VaultSecret | null {
39
- if (!isRecord(raw) || typeof raw.id !== 'string' || !ENV_RE.test(stringOf(raw.env))) return null;
40
- const hosts = Array.isArray(raw.hosts) ? raw.hosts.filter((h): h is string => typeof h === 'string' && HOST_RE.test(h)) : [];
41
- return { id: raw.id, name: stringOf(raw.name) || stringOf(raw.env), env: stringOf(raw.env), hosts, createdAt: stringOf(raw.createdAt), updatedAt: stringOf(raw.updatedAt) };
42
- }
43
-
44
- export function readVault(dir = vaultDir()): VaultState {
45
- const raw = readJson<unknown>(stateFile(dir), null);
46
- if (!isRecord(raw)) return { version: 1, enabled: false, secrets: [] };
47
- const secrets = Array.isArray(raw.secrets) ? raw.secrets.map(secretOf).filter((s): s is VaultSecret => s !== null) : [];
48
- return { version: 1, enabled: raw.enabled === true, secrets };
49
- }
50
-
51
- function writeVault(state: VaultState, dir = vaultDir()): void {
52
- ensureSecureDir(dir);
53
- writeSecure(stateFile(dir), `${JSON.stringify(state, null, 2)}\n`);
54
- }
55
-
56
- function normalizeName(raw: unknown): string {
57
- const name = stringOf(raw).trim();
58
- if (name === '' || name.length > 80) throw new ApiError('a name of 1 to 80 characters is required', 400);
59
- return name;
60
- }
61
-
62
- function normalizeEnv(raw: unknown): string {
63
- const env = stringOf(raw).trim();
64
- if (!ENV_RE.test(env) || RESERVED_ENV.has(env))
65
- throw new ApiError('the variable name must be capital letters, digits and _, 3 to 64 long, like OPENAI_API_KEY', 400);
66
- return env;
67
- }
68
-
69
- function normalizeHosts(raw: unknown): string[] {
70
- if (!Array.isArray(raw)) throw new ApiError('hosts must be a list of websites, like api.openai.com', 400);
71
- const hosts = [...new Set(raw.map((h) => stringOf(h).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')).filter((h) => h !== ''))];
72
- if (hosts.length === 0 || hosts.length > MAX_HOSTS) throw new ApiError(`a secret needs 1 to ${String(MAX_HOSTS)} websites`, 400);
73
- const bad = hosts.find((h) => !HOST_RE.test(h));
74
- if (bad !== undefined) throw new ApiError(`'${bad}' is not a website name; write it as api.example.com or *.example.com`, 400);
75
- return hosts;
76
- }
77
-
78
- function normalizeValue(raw: unknown): string {
79
- const value = stringOf(raw).trim();
80
- if (value === '' || value.length > MAX_VALUE) throw new ApiError('a value of 1 to 16384 characters is required', 400);
81
- return value;
82
- }
83
-
84
- function saveValue(id: string, value: string, dir: string): void {
85
- ensureSecureDir(valuesDir(dir));
86
- writeSecure(valueFile(id, dir), value);
87
- }
88
-
89
- export function addSecret(input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
90
- const state = readVault(dir);
91
- if (state.secrets.length >= MAX_SECRETS) throw new ApiError(`at most ${String(MAX_SECRETS)} secrets`, 400);
92
- const env = normalizeEnv(input.env);
93
- if (state.secrets.some((s) => s.env === env)) throw new ApiError(`${env} is already a secret`, 409);
94
- const at = now.toISOString();
95
- const secret: VaultSecret = { id: newId(), name: normalizeName(input.name ?? env), env, hosts: normalizeHosts(input.hosts), createdAt: at, updatedAt: at };
96
- saveValue(secret.id, normalizeValue(input.value), dir);
97
- writeVault({ ...state, secrets: [...state.secrets, secret] }, dir);
98
- return secret;
99
- }
100
-
101
- export function updateSecret(id: string, input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
102
- const state = readVault(dir);
103
- const found = state.secrets.find((s) => s.id === id);
104
- if (found === undefined) throw new ApiError('no such secret', 404);
105
- const next: VaultSecret = {
106
- ...found,
107
- ...(input.name === undefined ? {} : { name: normalizeName(input.name) }),
108
- ...(input.hosts === undefined ? {} : { hosts: normalizeHosts(input.hosts) }),
109
- updatedAt: now.toISOString(),
110
- };
111
- if (input.value !== undefined) saveValue(id, normalizeValue(input.value), dir);
112
- writeVault({ ...state, secrets: state.secrets.map((s) => (s.id === id ? next : s)) }, dir);
113
- return next;
114
- }
115
-
116
- export function removeSecret(id: string, dir = vaultDir()): void {
117
- const state = readVault(dir);
118
- if (!state.secrets.some((s) => s.id === id)) throw new ApiError('no such secret', 404);
119
- writeVault({ ...state, secrets: state.secrets.filter((s) => s.id !== id) }, dir);
120
- rmSync(valueFile(id, dir), { force: true });
121
- }
122
-
123
- export function setVaultEnabled(enabled: boolean, dir = vaultDir()): VaultState {
124
- const next = { ...readVault(dir), enabled };
125
- writeVault(next, dir);
126
- return next;
127
- }
128
-
129
- export const hasValue = (id: string, dir = vaultDir()): boolean => existsSync(valueFile(id, dir));
@@ -1,44 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } 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
- import { mustHelper, rootHelper } from '../metro-user/privilege.js';
7
-
8
- const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }).status === 0;
9
-
10
- export function trustSystem(caFile: string): void {
11
- const same = existsSync(TRUSTED_CA) && readFileSync(TRUSTED_CA, 'utf8') === readFileSync(caFile, 'utf8');
12
- if (!same) mustHelper(['trust-ca']);
13
- }
14
-
15
- export function untrustSystem(): void {
16
- if (existsSync(TRUSTED_CA)) mustHelper(['untrust-ca']);
17
- }
18
-
19
- const NSS_SCRIPT = [
20
- 'set -e',
21
- 'db="$HOME/.pki/nssdb"',
22
- 'mkdir -p "$db"',
23
- '[ -f "$db/cert9.db" ] || certutil -d "sql:$db" -N --empty-password',
24
- 'certutil -d "sql:$db" -D -n "Metro vault" >/dev/null 2>&1 || true',
25
- 'certutil -d "sql:$db" -A -t "C,," -n "Metro vault" -i "$1"',
26
- ].join('\n');
27
-
28
- function ensureCertutil(): boolean {
29
- if (has('certutil')) return true;
30
- return rootHelper(['install-nss-tools']).status === 0 && has('certutil');
31
- }
32
-
33
- export function trustBrowsers(user: AgentUser): string | null {
34
- try {
35
- if (!ensureCertutil()) return 'certutil is missing, so browsers the agent starts will not trust the vault (apt-get install libnss3-tools)';
36
- const [file, argv] = asUser(user, 'sh', ['-c', NSS_SCRIPT, 'metro', TRUSTED_CA]);
37
- const run = spawnSync(file, argv, { encoding: 'utf8', timeout: 30_000 });
38
- if (run.status !== 0) return `browsers will not trust the vault: ${run.stderr.trim()}`;
39
- return null;
40
- } catch (err) {
41
- log.warn({ err: errMsg(err) }, 'vault: could not add the certificate for browsers');
42
- return errMsg(err);
43
- }
44
- }