@stage-labs/metro 0.1.0-beta.201 → 0.1.0-beta.202

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 (163) hide show
  1. package/dist/claude.js +4 -15
  2. package/dist/cli.js +5 -3
  3. package/dist/hold.js +1 -2
  4. package/dist/runtime-install.js +4 -5
  5. package/dist/runtime.js +1 -4
  6. package/dist/serve.js +4 -5
  7. package/dist/service.js +82 -21
  8. package/dist/update.js +1 -1
  9. package/package.json +2 -2
  10. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  11. package/runtime/node_modules/@metro-labs/core/src/events.ts +4 -2
  12. package/runtime/node_modules/@metro-labs/core/src/lines.ts +12 -25
  13. package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +45 -22
  14. package/runtime/node_modules/@metro-labs/core/src/stations/attachments.ts +0 -1
  15. package/runtime/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +8 -80
  16. package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +3 -5
  17. package/runtime/node_modules/@metro-labs/core/src/stations/train-events.ts +4 -9
  18. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +9 -39
  19. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +9 -28
  21. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +0 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules-api.ts +3 -3
  23. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +8 -116
  24. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +11 -35
  25. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +7 -9
  26. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +1 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +1 -1
  29. package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +1 -1
  30. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -3
  31. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +2 -2
  32. package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +3 -29
  33. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +5 -8
  34. package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +1 -1
  35. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  36. package/runtime/node_modules/@metro-labs/daemon/src/boot/connect-hint.ts +3 -6
  37. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +25 -3
  38. package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +2 -3
  39. package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +2 -2
  40. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +1 -1
  41. package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +6 -56
  42. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +3 -3
  43. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +1 -1
  44. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
  45. package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +1 -1
  46. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +7 -7
  47. package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +4 -4
  48. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +1 -1
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +2 -4
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -22
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -2
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +6 -8
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -6
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +1 -1
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -3
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +28 -1
  57. package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +1 -1
  58. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
  59. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +1 -1
  60. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +1 -1
  61. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +1 -1
  62. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +3 -2
  63. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +1 -1
  64. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +2 -2
  65. package/runtime/node_modules/@metro-labs/daemon/src/gateway/eventstream.ts +1 -1
  66. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +3 -3
  67. package/runtime/node_modules/@metro-labs/daemon/src/gateway/frames.ts +1 -1
  68. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -3
  69. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +6 -6
  70. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +7 -7
  71. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +1 -1
  72. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -2
  73. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +1 -1
  74. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -31
  75. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +1 -1
  76. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -1
  77. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +2 -2
  78. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
  79. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +1 -2
  80. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +2 -2
  81. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +5 -6
  82. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +3 -3
  83. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +2 -2
  84. package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +2 -2
  85. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +2 -2
  86. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +37 -0
  87. package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +2 -2
  88. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +1 -1
  89. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -4
  90. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -8
  91. package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +2 -2
  92. package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -2
  93. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +2 -2
  94. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +6 -7
  95. package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor-io.ts +1 -1
  96. package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor.ts +2 -2
  97. package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -2
  98. package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +1 -1
  99. package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +5 -44
  100. package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +4 -4
  101. package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +6 -7
  102. package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +12 -13
  103. package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +5 -22
  104. package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +1 -19
  105. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +7 -35
  106. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +5 -10
  107. package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +1 -1
  108. package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +1 -2
  109. package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +1 -1
  110. package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +11 -12
  111. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -5
  112. package/runtime/node_modules/@metro-labs/http/src/mode-api.ts +0 -1
  113. package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +1 -15
  114. package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +0 -6
  115. package/runtime/node_modules/@metro-labs/outlook/src/format.ts +0 -1
  116. package/runtime/node_modules/@metro-labs/outlook/src/state.ts +1 -1
  117. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +4 -22
  118. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -2
  119. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +1 -3
  120. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +1 -1
  121. package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +1 -2
  122. package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +1 -1
  123. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +4 -37
  124. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +7 -23
  125. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +1 -4
  126. package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +4 -9
  127. package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +3 -3
  128. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +0 -11
  129. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +2 -12
  130. package/runtime/node_modules/@metro-labs/threema/src/files.ts +4 -15
  131. package/runtime/node_modules/@metro-labs/threema/src/format.ts +2 -7
  132. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -2
  133. package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +5 -26
  134. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +5 -6
  135. package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +1 -3
  136. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +1 -1
  137. package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +1 -12
  138. package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +1 -1
  139. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +14 -5
  140. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +1 -1
  141. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +31 -78
  142. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -4
  143. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +1 -6
  144. package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +1 -1
  145. package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +1 -1
  146. package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +1 -1
  147. package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +2 -2
  148. package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +0 -2
  149. package/runtime/runtime.json +1 -1
  150. package/runtime/node_modules/@metro-labs/core/src/identity.ts +0 -7
  151. package/runtime/node_modules/@metro-labs/daemon/src/approvals/needs.ts +0 -2
  152. package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +0 -12
  153. package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +0 -13
  154. package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +0 -1
  155. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -29
  156. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/api.ts +0 -43
  157. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/move.ts +0 -165
  158. package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +0 -1
  159. package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +0 -22
  160. package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +0 -1
  161. package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +0 -1
  162. package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +0 -22
  163. package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +0 -1
package/dist/claude.js CHANGED
@@ -21,11 +21,6 @@ export const claudeArgs = (extra, mcpConfig, mode = 'auto', prompt = null) => [
21
21
  ...extra,
22
22
  ];
23
23
  const set = (env, name) => (env[name] ?? '').trim() !== '';
24
- export function bypassSandboxEnv(env, mode, uid) {
25
- if (mode !== 'bypass' || uid !== 0 || env.IS_SANDBOX !== undefined)
26
- return env;
27
- return { ...env, IS_SANDBOX: '1' };
28
- }
29
24
  export function pinnedBy(env) {
30
25
  if (set(env, 'ANTHROPIC_BASE_URL'))
31
26
  return 'ANTHROPIC_BASE_URL';
@@ -49,7 +44,7 @@ export function credentialEnv(env, agentKey, signedIn) {
49
44
  return env;
50
45
  return { ...env, ANTHROPIC_AUTH_TOKEN: agentKey };
51
46
  }
52
- export function claudeSignedIn() {
47
+ function claudeSignedIn() {
53
48
  const run = spawnSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
54
49
  if (run.error !== undefined || typeof run.stdout !== 'string')
55
50
  return false;
@@ -93,7 +88,7 @@ async function verdict() {
93
88
  return picked;
94
89
  return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
95
90
  }
96
- export function runClaude(args, env) {
91
+ function runClaude(args, env) {
97
92
  return new Promise((resolve, reject) => {
98
93
  const leaveToChild = () => undefined;
99
94
  process.on('SIGINT', leaveToChild);
@@ -144,18 +139,12 @@ export async function launchClaude(extra) {
144
139
  const prompt = systemPrompt();
145
140
  if (prompt !== null)
146
141
  process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
147
- const sandbox = (env) => {
148
- const out = bypassSandboxEnv(env, mode, process.getuid?.());
149
- if (out !== env)
150
- process.stderr.write('metro claude: bypass permission mode under root, so IS_SANDBOX=1 is set for Claude Code (it refuses to skip permissions as root otherwise); this box is a dedicated single-tenant machine\n');
151
- return out;
152
- };
153
142
  try {
154
143
  if ('skip' in decision) {
155
144
  process.stderr.write(`metro claude: ${decision.skip}\n`);
156
- return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), sandbox(process.env));
145
+ return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), process.env);
157
146
  }
158
- return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), sandbox(gatewayLaunchEnv(decision.key, port)));
147
+ return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), gatewayLaunchEnv(decision.key, port));
159
148
  }
160
149
  finally {
161
150
  mcp?.cleanup();
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { update } from './update.js';
7
7
  import { serve } from './serve.js';
8
8
  import { service, serviceStopHint } from './service.js';
9
9
  import { currentVersion } from './version.js';
10
- const USAGE = `metro — run your agent on this machine
10
+ const USAGE = `metro: run your agent on this machine
11
11
 
12
12
  metro serve [--port <n>] [--owner <organization id>]
13
13
  run the daemon: the agent, its channels and its connectors live in
@@ -17,10 +17,12 @@ const USAGE = `metro — run your agent on this machine
17
17
  link it prints; --owner names the organization whose members may sign
18
18
  in, needed on the first start only; Stop on the page parks the daemon and keeps the
19
19
  address, so Start works from the page too
20
- metro service install [--port <n>] [--owner <organization id>]
20
+ metro service install [--port <n>] [--owner <organization id>] [--user metro]
21
21
  run metro serve as a service (systemd on Linux, launchd on macOS): it
22
22
  starts at boot and after a crash, so the page can stop, start and
23
- restart it with no shell; metro service uninstall and status as well
23
+ restart it with no shell; on Linux, root passes --user metro so Metro
24
+ runs as its own user and Claude Code as the user agent;
25
+ metro service uninstall and status as well
24
26
  metro stop stop metro on this machine, metro serve included
25
27
  metro tail [agent-id]
26
28
  follow this machine's inbound events, one JSON line each
package/dist/hold.js CHANGED
@@ -26,7 +26,7 @@ function sendJson(req, res, status, body) {
26
26
  });
27
27
  res.end(JSON.stringify(body));
28
28
  }
29
- export function holdRequest(req, res, info, onStart) {
29
+ function holdRequest(req, res, info, onStart) {
30
30
  const path = (req.url ?? '').split('?')[0] ?? '';
31
31
  if (req.method === 'OPTIONS') {
32
32
  res.writeHead(204, corsHeaders(req)).end();
@@ -36,7 +36,6 @@ export function holdRequest(req, res, info, onStart) {
36
36
  sendJson(req, res, 200, {
37
37
  mode: 'local',
38
38
  owner: info.owner,
39
- project: 'localdaemon',
40
39
  version: info.version,
41
40
  stopped: true,
42
41
  });
@@ -4,8 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { localStations } from './local.js';
6
6
  import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
7
- export const MANIFEST_FILE = 'stations.json';
8
- export const MARKETPLACE = 'marketplace';
7
+ const MANIFEST_FILE = 'stations.json';
8
+ const MARKETPLACE = 'marketplace';
9
9
  const METRO_SOURCES = join('node_modules', '@metro-labs');
10
10
  const INSTALL_TIMEOUT_MS = 15 * 60_000;
11
11
  export function runtimeStore() {
@@ -83,8 +83,7 @@ export function installDependencies(store, deps, bun, log) {
83
83
  export function prepareRuntime(opts = {}) {
84
84
  const sources = opts.sources ?? runtimeDir();
85
85
  const manifestPath = join(sources, MANIFEST_FILE);
86
- if (!existsSync(manifestPath))
87
- return { dir: sources, entry: daemonEntry(sources), trains: join(sources, 'trains'), manifest: null };
86
+ const manifest = readManifest(manifestPath);
88
87
  const store = opts.store ?? runtimeStore();
89
88
  const log = opts.log ??
90
89
  ((line) => {
@@ -92,6 +91,6 @@ export function prepareRuntime(opts = {}) {
92
91
  });
93
92
  mkdirSync(join(store, 'trains'), { recursive: true });
94
93
  syncSources(sources, store);
95
- installDependencies(store, dependenciesFor(readManifest(manifestPath), localStations(opts.agents)), opts.bun ?? findBun(), log);
94
+ installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log);
96
95
  return { dir: store, entry: daemonEntry(store), trains: join(store, 'trains'), manifest: manifestPath };
97
96
  }
package/dist/runtime.js CHANGED
@@ -4,10 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  const AGENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
6
6
  export const STORE_ENTRY = 'server.ts';
7
- export const PACKAGE_ENTRY = join('node_modules', '@metro-labs', 'daemon', 'src', 'server.ts');
8
- export const daemonEntry = (dir) => existsSync(join(dir, STORE_ENTRY))
9
- ? join(dir, STORE_ENTRY)
10
- : join(dir, PACKAGE_ENTRY);
7
+ export const daemonEntry = (dir) => join(dir, STORE_ENTRY);
11
8
  export class MissingRuntime extends Error {
12
9
  }
13
10
  export function runtimeDir() {
package/dist/serve.js CHANGED
@@ -72,7 +72,7 @@ export function requireOwner(args, dir = agentsDir()) {
72
72
  join(dir, '.owner') +
73
73
  ':\n metro serve --owner <org_…>');
74
74
  }
75
- export function readOwner(dir = agentsDir()) {
75
+ function readOwner(dir = agentsDir()) {
76
76
  try {
77
77
  const raw = readFileSync(join(dir, '.owner'), 'utf8').trim();
78
78
  return ORGANIZATION.test(raw) ? raw : null;
@@ -81,7 +81,7 @@ export function readOwner(dir = agentsDir()) {
81
81
  return null;
82
82
  }
83
83
  }
84
- export function holdInfo(port, owner, tailscaleBin) {
84
+ function holdInfo(port, owner, tailscaleBin) {
85
85
  return {
86
86
  port,
87
87
  host: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
@@ -133,9 +133,8 @@ export function servePlan(opts) {
133
133
  METRO_WEBHOOK_PORT: String(opts.port),
134
134
  METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
135
135
  METRO_TRAINS_DIR: opts.runtime.trains,
136
- ...(opts.runtime.manifest === null
137
- ? {}
138
- : { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
136
+ METRO_RUNTIME_STORE: opts.runtime.dir,
137
+ METRO_RUNTIME_MANIFEST: opts.runtime.manifest,
139
138
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
140
139
  METRO_TUNNEL: 'tailscale',
141
140
  METRO_TAILSCALE_BIN: opts.tailscaleBin,
package/dist/service.js CHANGED
@@ -3,12 +3,15 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, platform, userInfo } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { serveLockedBy } from './control.js';
6
- import { findBun } from './runtime.js';
6
+ import { findBun, runtimeDir } from './runtime.js';
7
7
  import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
8
- const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] | uninstall | status';
8
+ const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] [--user metro] | uninstall | status';
9
9
  const SERVICE = 'metro';
10
10
  const LABEL = 'box.metro.serve';
11
11
  const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
12
+ const OWN_PATHS = new Set(['METRO_AGENTS_DIR', 'METRO_RUNTIME_STORE']);
13
+ const METRO_USER = 'metro';
14
+ const USER_FLAG = /^--user=(.*)$/;
12
15
  const PLAIN_WORD = /^[A-Za-z0-9_/.:=+@,-]+$/;
13
16
  const carriedEnv = (env) => Object.entries(env)
14
17
  .filter((entry) => CARRIED.test(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '')
@@ -23,8 +26,7 @@ function unitWord(arg) {
23
26
  return escaped;
24
27
  return `"${escaped.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
25
28
  }
26
- export function systemdUnit(host, exec, target) {
27
- const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
29
+ function unitText(service, target) {
28
30
  return [
29
31
  '[Unit]',
30
32
  'Description=Metro daemon (metro serve)',
@@ -32,25 +34,44 @@ export function systemdUnit(host, exec, target) {
32
34
  'Wants=network-online.target',
33
35
  '',
34
36
  '[Service]',
35
- `ExecStart=${exec.map(unitWord).join(' ')}`,
36
- 'Restart=always',
37
- 'RestartSec=2',
38
- 'OOMPolicy=continue',
39
- `WorkingDirectory=${unitWord(host.home)}`,
40
- ...env,
37
+ ...service,
41
38
  '',
42
39
  '[Install]',
43
40
  `WantedBy=${target}`,
44
41
  '',
45
42
  ].join('\n');
46
43
  }
44
+ const RESTART = ['Restart=always', 'RestartSec=2', 'OOMPolicy=continue'];
45
+ function systemdUnit(host, exec, target) {
46
+ const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
47
+ return unitText([`ExecStart=${exec.map(unitWord).join(' ')}`, ...RESTART, `WorkingDirectory=${unitWord(host.home)}`, ...env], target);
48
+ }
49
+ const npmPrefix = (home) => join(home, '.npm-global');
50
+ function runAsUnit(host, runAs, serveArgs) {
51
+ const prefix = npmPrefix(runAs.home);
52
+ const exec = [host.node, join(prefix, 'lib', 'node_modules', '@stage-labs', 'metro', 'dist', 'cli.js'), 'serve', ...serveArgs];
53
+ const env = carriedEnv(host.env)
54
+ .filter(([key]) => key.startsWith('METRO_') && !OWN_PATHS.has(key))
55
+ .map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
56
+ return unitText([
57
+ `User=${runAs.name}`,
58
+ `Group=${runAs.name}`,
59
+ `ExecStart=${exec.map(unitWord).join(' ')}`,
60
+ ...RESTART,
61
+ 'WorkingDirectory=/',
62
+ `Environment=HOME=${unitWord(runAs.home)}`,
63
+ `Environment=${unitWord(`PATH=${prefix}/bin:/usr/local/bin:/usr/bin:/bin`)}`,
64
+ `Environment=${unitWord(`NPM_CONFIG_PREFIX=${prefix}`)}`,
65
+ ...env,
66
+ ], 'multi-user.target');
67
+ }
47
68
  const xml = (text) => text
48
69
  .replaceAll('&', '&amp;')
49
70
  .replaceAll('<', '&lt;')
50
71
  .replaceAll('>', '&gt;')
51
72
  .replaceAll('"', '&quot;')
52
73
  .replaceAll("'", '&apos;');
53
- export function launchdPlist(host, exec, logFile) {
74
+ function launchdPlist(host, exec, logFile) {
54
75
  const env = carriedEnv(host.env).map(([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`);
55
76
  return [
56
77
  '<?xml version="1.0" encoding="UTF-8"?>',
@@ -76,7 +97,9 @@ export function launchdPlist(host, exec, logFile) {
76
97
  '',
77
98
  ].join('\n');
78
99
  }
79
- function systemdPlan(host, exec) {
100
+ const ROOT_HINT = `Metro running as root never starts Claude Code; install it as its own user: metro service install --user ${METRO_USER}`;
101
+ function systemdPlan(host, serveArgs, runAs) {
102
+ const exec = [host.node, host.cli, 'serve', ...serveArgs];
80
103
  const system = host.uid === 0;
81
104
  const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
82
105
  const file = system
@@ -85,14 +108,15 @@ function systemdPlan(host, exec) {
85
108
  return {
86
109
  kind: 'systemd',
87
110
  file,
88
- content: systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target'),
111
+ content: runAs === undefined ? systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target') : runAsUnit(host, runAs, serveArgs),
89
112
  dirs: [],
90
- install: [{ args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
113
+ install: [...(runAs === undefined ? [] : [{ args: ['bun', runAs.helper] }]), { args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
91
114
  uninstall: [{ args: [...ctl, 'disable', '--now', SERVICE], mayFail: true }],
92
115
  reload: [{ args: [...ctl, 'daemon-reload'], mayFail: true }],
93
116
  status: { args: [...ctl, 'is-active', SERVICE], mayFail: true },
94
117
  hints: [
95
118
  `Logs: journalctl ${system ? '' : '--user '}-u ${SERVICE} -f`,
119
+ ...(system && runAs === undefined ? [ROOT_HINT] : []),
96
120
  ...(system
97
121
  ? []
98
122
  : [`A user service stops with your login session; keep it up without one: loginctl enable-linger ${host.user}`]),
@@ -118,22 +142,52 @@ function launchdPlan(host, exec) {
118
142
  hints: [`Logs: tail -f ${logFile}`],
119
143
  };
120
144
  }
121
- export function servicePlan(host, serveArgs) {
145
+ export function servicePlan(host, serveArgs, runAs) {
122
146
  const exec = [host.node, host.cli, 'serve', ...serveArgs];
147
+ if (runAs !== undefined && (host.platform !== 'linux' || host.uid !== 0))
148
+ throw new Error('--user installs a system service for another user: run it as root on Linux');
123
149
  if (host.platform === 'linux')
124
- return systemdPlan(host, exec);
150
+ return systemdPlan(host, serveArgs, runAs);
125
151
  if (host.platform === 'darwin')
126
152
  return launchdPlan(host, exec);
127
153
  throw new Error(`metro service supports Linux (systemd) and macOS (launchd), not ${host.platform}`);
128
154
  }
129
- function install(serveArgs, deps) {
155
+ function splitUser(argv) {
156
+ const rest = [];
157
+ let user = null;
158
+ for (let i = 0; i < argv.length; i += 1) {
159
+ const arg = argv[i] ?? '';
160
+ const inline = USER_FLAG.exec(arg);
161
+ if (inline === null && arg !== '--user') {
162
+ rest.push(arg);
163
+ continue;
164
+ }
165
+ user = inline?.[1] ?? argv[i + 1] ?? '';
166
+ if (inline === null)
167
+ i += 1;
168
+ }
169
+ return { user, rest };
170
+ }
171
+ function runAsOf(name, deps) {
172
+ if (name === null)
173
+ return undefined;
174
+ if (name !== METRO_USER)
175
+ throw new Error(`the root helper and its sudo rules are written for the user ${METRO_USER}; pass --user ${METRO_USER}, not '${name}'`);
176
+ const account = deps.account(name);
177
+ if (account === null)
178
+ throw new Error(`there is no user ${name} on this machine; create it first (useradd --system --create-home --home-dir /var/lib/${name} ${name})`);
179
+ return { ...account, helper: deps.helperEntry() };
180
+ }
181
+ function install(argv, deps) {
182
+ const { user, rest: serveArgs } = splitUser(argv);
130
183
  const args = parseServeArgs(serveArgs);
131
- const plan = servicePlan(deps.host, serveArgs);
184
+ const runAs = runAsOf(user, deps);
185
+ const plan = servicePlan(deps.host, serveArgs, runAs);
132
186
  if (deps.exists(plan.file)) {
133
187
  deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}) and restarts on its own; metro service status says whether it is running. To change its arguments: metro service uninstall, then install again`);
134
188
  return 0;
135
189
  }
136
- deps.preflight(args);
190
+ deps.preflight(args, runAs === undefined ? undefined : join(runAs.home, '.metro', 'agents'));
137
191
  const pid = deps.running();
138
192
  if (pid !== null)
139
193
  throw new Error(`a metro serve is running on this machine (pid ${String(pid)}). Stop it first (metro stop), then install: the service takes over from there`);
@@ -196,6 +250,11 @@ export function serviceStopHint(host = realHost(), exists = existsSync) {
196
250
  : `launchctl bootout gui/${String(host.uid)}/${LABEL}`;
197
251
  return `metro runs as a ${plan.kind} service here (${plan.file}), so it starts again on its own in a moment. To keep it stopped: ${keep}`;
198
252
  }
253
+ function lookupAccount(name) {
254
+ const run = spawnSync('getent', ['passwd', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
255
+ const home = run.status === 0 ? (run.stdout.trim().split(':')[5] ?? '') : '';
256
+ return home.startsWith('/') ? { name, home } : null;
257
+ }
199
258
  function realDeps() {
200
259
  return {
201
260
  host: realHost(),
@@ -210,8 +269,10 @@ function realDeps() {
210
269
  return { status: code, output: `${result.stdout}${result.stderr}` };
211
270
  },
212
271
  running: serveLockedBy,
213
- preflight: (args) => {
214
- requireOwner(args);
272
+ account: lookupAccount,
273
+ helperEntry: () => join(runtimeDir(), 'node_modules', '@metro-labs', 'daemon', 'src', 'metro-user', 'install.ts'),
274
+ preflight: (args, agents) => {
275
+ requireOwner(args, agents);
215
276
  findBun();
216
277
  findTailscale();
217
278
  },
package/dist/update.js CHANGED
@@ -14,7 +14,7 @@ export const checkReport = (current, latest) => ({
14
14
  latest,
15
15
  newer: latest !== '' && isNewer(latest, current),
16
16
  });
17
- export async function updateCheck() {
17
+ async function updateCheck() {
18
18
  return checkReport(currentVersion(), await publishedVersion());
19
19
  }
20
20
  export async function update(argv = []) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.201",
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.",
3
+ "version": "0.1.0-beta.202",
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": {
7
7
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.201",
3
+ "version": "0.1.0-beta.202",
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",
@@ -1,6 +1,8 @@
1
1
  import { Line } from './lines.js';
2
+ import { errMsg, log } from './log.js';
2
3
 
3
- export { userSelf, daemonSelf } from './identity.js';
4
+ export const userSelf = (): Line =>
5
+ (process.env.METRO_FROM ?? 'metro://user') as Line;
4
6
 
5
7
  export type StructuredEvent =
6
8
  | { type: 'msg' }
@@ -98,7 +100,7 @@ export function publishEvent(event: MetroEvent): void {
98
100
  try {
99
101
  fn(event, seq);
100
102
  } catch (err) {
101
- console.error('[metro-bus] listener threw for busSeq', seq, err);
103
+ log.error({ busSeq: seq, err: errMsg(err) }, 'bus: a listener threw');
102
104
  }
103
105
  }
104
106
  }
@@ -11,15 +11,9 @@ function parseAccountScoped(
11
11
  validate?: (resource: string) => boolean,
12
12
  ): { accountId: string; resource: string } | null {
13
13
  const p = Line.parse(line);
14
- if (p?.station !== station) return null;
15
- let accountId: string, resource: string;
16
- if (p.path.length === 2 && p.path[0] !== undefined && p.path[1] !== undefined) {
17
- accountId = p.path[0];
18
- resource = p.path[1];
19
- } else if (p.path.length === 1 && p.path[0] !== undefined) {
20
- accountId = 'default';
21
- resource = p.path[0];
22
- } else return null;
14
+ if (p?.station !== station || p.path.length !== 2) return null;
15
+ const [accountId, resource] = p.path;
16
+ if (accountId === undefined || resource === undefined) return null;
23
17
  if (validate && !validate(resource)) return null;
24
18
  return { accountId, resource };
25
19
  }
@@ -30,28 +24,21 @@ const isThreemaResource = (s: string): boolean =>
30
24
  const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
31
25
  const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
32
26
 
33
- function splitTelegramAccount(path: string[]): { accountId: string; rest: string[] } {
34
- const first = path[0];
35
- if (path.length >= 2 && first !== undefined && !isSignedInt(first))
36
- return { accountId: first, rest: path.slice(1) };
37
- return { accountId: 'default', rest: path };
38
- }
27
+ const topicPart = (topicId: string | undefined): { topicId?: number } | null => {
28
+ if (topicId === undefined) return {};
29
+ return isSnowflake(topicId) ? { topicId: Number(topicId) } : null;
30
+ };
39
31
 
40
32
  function parseTelegramLine(
41
33
  line: Line | string,
42
34
  ): { accountId: string; chatId: number; topicId?: number } | null {
43
35
  const p = Line.parse(line);
44
36
  if (p?.station !== 'telegram-bot') return null;
45
- const { accountId, rest } = splitTelegramAccount(p.path);
46
- const [chatId, topicId] = rest;
47
- if (rest.length < 1 || rest.length > 2 || chatId === undefined || !isSignedInt(chatId))
48
- return null;
49
- if (topicId !== undefined && !isSnowflake(topicId)) return null;
50
- return {
51
- accountId,
52
- chatId: Number(chatId),
53
- ...(topicId !== undefined ? { topicId: Number(topicId) } : {}),
54
- };
37
+ const [accountId, chatId, topicId, ...extra] = p.path;
38
+ if (accountId === undefined || chatId === undefined || extra.length > 0) return null;
39
+ if (!isSignedInt(chatId)) return null;
40
+ const topic = topicPart(topicId);
41
+ return topic === null ? null : { accountId, chatId: Number(chatId), ...topic };
55
42
  }
56
43
 
57
44
  export const Line = {
@@ -1,13 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import { errMsg } from '../log.js';
3
5
  import { chmodIfExists } from '../secure-fs.js';
4
6
 
5
7
  export type Die = (msg: string) => never;
6
8
 
7
- export interface MakeLoaderOpts<T> {
9
+ export interface AccountStoreOpts<T> {
8
10
  prefix: string;
9
- file: string;
10
- validate: (raw: T[], die: Die) => void;
11
+ validate?: (raw: T[], die: Die) => void;
11
12
  }
12
13
 
13
14
  export interface Loader<T> {
@@ -15,8 +16,40 @@ export interface Loader<T> {
15
16
  loadAccounts: () => T[];
16
17
  }
17
18
 
19
+ const envName = (prefix: string): string =>
20
+ `${prefix.toUpperCase().replace(/-/g, '_')}_ACCOUNTS_FILE`;
21
+
22
+ const accountsFile = (prefix: string): string =>
23
+ process.env[envName(prefix)] ?? join(homedir(), '.metro', `${prefix}-accounts.json`);
24
+
25
+ function checkIds(raw: { id: string }[], die: Die): void {
26
+ const seen = new Set<string>();
27
+ for (const a of raw) {
28
+ if (!a.id) die('account missing id');
29
+ if (seen.has(a.id)) die(`duplicate account id '${a.id}'`);
30
+ seen.add(a.id);
31
+ }
32
+ }
33
+
34
+ function readList<T>(file: string, die: Die): T[] {
35
+ if (!existsSync(file))
36
+ return die(
37
+ `no accounts file ${file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
38
+ );
39
+ chmodIfExists(file);
40
+ let raw: unknown;
41
+ try {
42
+ raw = JSON.parse(readFileSync(file, 'utf8'));
43
+ } catch (e) {
44
+ return die(`bad ${file}: ${errMsg(e)}`);
45
+ }
46
+ if (!Array.isArray(raw) || raw.length === 0)
47
+ return die(`${file} must be a non-empty array`);
48
+ return raw as T[];
49
+ }
50
+
18
51
  export function makeAccountStore<T extends { id: string }>(
19
- opts: MakeLoaderOpts<T>,
52
+ opts: AccountStoreOpts<T>,
20
53
  ): Loader<T> {
21
54
  const die: Die = (msg) => {
22
55
  process.stderr.write(`${opts.prefix}: ${msg}\n`);
@@ -24,20 +57,9 @@ export function makeAccountStore<T extends { id: string }>(
24
57
  };
25
58
 
26
59
  function loadAccounts(): T[] {
27
- if (!existsSync(opts.file))
28
- return die(
29
- `no accounts file ${opts.file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
30
- );
31
- chmodIfExists(opts.file);
32
- let raw: T[];
33
- try {
34
- raw = JSON.parse(readFileSync(opts.file, 'utf8')) as T[];
35
- } catch (e) {
36
- return die(`bad ${opts.file}: ${errMsg(e)}`);
37
- }
38
- if (!Array.isArray(raw) || raw.length === 0)
39
- die(`${opts.file} must be a non-empty array`);
40
- opts.validate(raw, die);
60
+ const raw = readList<T>(accountsFile(opts.prefix), die);
61
+ checkIds(raw, die);
62
+ opts.validate?.(raw, die);
41
63
  return raw;
42
64
  }
43
65
 
@@ -49,12 +71,13 @@ export function resolveAccountId(
49
71
  args: { account?: string; line?: string },
50
72
  parseAccountId: (line: string) => string | undefined,
51
73
  ): string {
74
+ const have = [...accounts.keys()];
52
75
  let id = args.account;
53
76
  id ??= args.line ? parseAccountId(args.line) : undefined;
54
- id ??= (accounts.size === 1 ? [...accounts.keys()][0] : 'default') ?? 'default';
77
+ id ??= have.length === 1 ? have[0] : undefined;
78
+ if (id === undefined)
79
+ throw new Error(`name an account (have: ${have.join(', ')})`);
55
80
  if (!accounts.has(id))
56
- throw new Error(
57
- `unknown account '${id}' (have: ${[...accounts.keys()].join(', ')})`,
58
- );
81
+ throw new Error(`unknown account '${id}' (have: ${have.join(', ')})`);
59
82
  return id;
60
83
  }
@@ -190,7 +190,6 @@ export const toCanonical = (a: {
190
190
  }): Record<string, unknown> => ({
191
191
  kind: kindOf(a.mime, a.path),
192
192
  path: a.path,
193
- url: a.path,
194
193
  name: a.name,
195
194
  mime: a.mime,
196
195
  });