@stage-labs/metro 0.1.0-beta.183 → 0.1.0-beta.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.183",
3
+ "version": "0.1.0-beta.185",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.183",
3
+ "version": "0.1.0-beta.185",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -0,0 +1,68 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { join } from 'node:path';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
6
+ import { log } from '@metro-labs/core/log';
7
+ import { writeSecure } from '@metro-labs/core/secure-fs';
8
+ import { agentsDir } from '../agents/files.js';
9
+ import { agentUser, CONFIG_FILE, forgetAgentUser, wantedAgentUser } from './user.js';
10
+
11
+ const PATH = '/api/agent-user';
12
+ const EXIT_DELAY_MS = 500;
13
+
14
+ export interface AgentUserApiDeps {
15
+ restart: () => void;
16
+ agents?: () => string;
17
+ host?: () => { platform: string; uid: number | undefined; cli: boolean };
18
+ }
19
+
20
+ export interface AgentUserStatus {
21
+ enabled: boolean;
22
+ user: string | null;
23
+ active: boolean;
24
+ supported: boolean;
25
+ reason: string | null;
26
+ }
27
+
28
+ const realHost = (): { platform: string; uid: number | undefined; cli: boolean } => ({
29
+ platform: process.platform,
30
+ uid: process.getuid?.(),
31
+ cli: (process.env.METRO_CLI_BIN?.trim() ?? '') !== '',
32
+ });
33
+
34
+ function unsupported(host: { platform: string; uid: number | undefined; cli: boolean }): string | null {
35
+ if (host.platform !== 'linux') return 'Claude Code can run as its own user on Linux only.';
36
+ if (host.uid !== 0) return 'Metro runs as a normal user here, so it cannot switch Claude Code to another user.';
37
+ if (!host.cli) return 'This daemon was not started by metro serve, so nothing would bring it back after the switch.';
38
+ return null;
39
+ }
40
+
41
+ export function agentUserStatus(deps: Pick<AgentUserApiDeps, 'agents' | 'host'> = {}): AgentUserStatus {
42
+ const dir = (deps.agents ?? agentsDir)();
43
+ const reason = unsupported((deps.host ?? realHost)());
44
+ const user = wantedAgentUser(dir);
45
+ return { enabled: user !== null, user, active: user !== null && agentUser(dir) !== null, supported: reason === null, reason };
46
+ }
47
+
48
+ function setWanted(dir: string, enabled: boolean): void {
49
+ const path = join(dir, CONFIG_FILE);
50
+ if (enabled) writeSecure(path, `${JSON.stringify({ user: 'agent' })}\n`);
51
+ else if (existsSync(path)) rmSync(path);
52
+ forgetAgentUser();
53
+ }
54
+
55
+ export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
56
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: ['POST'], label: 'agent-user-api' }, async (session) => {
57
+ if (req.method === 'GET') return agentUserStatus(deps);
58
+ const enabled = bodyField(await readJsonBody(req), 'enabled');
59
+ if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
60
+ const reason = unsupported((deps.host ?? realHost)());
61
+ if (enabled && reason !== null) throw new ApiError(reason, 400);
62
+ const dir = (deps.agents ?? agentsDir)();
63
+ setWanted(dir, enabled);
64
+ log.info({ enabled, subject: session.subject }, 'agent-user-api: switched from the page; restarting');
65
+ setTimeout(deps.restart, EXIT_DELAY_MS).unref();
66
+ return { ...agentUserStatus(deps), restarting: true };
67
+ });
68
+ }
@@ -123,11 +123,11 @@ export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessE
123
123
  try {
124
124
  const user = ensureUser(name);
125
125
  if (user === null) throw new Error(`the user ${name} could not be found after creating it`);
126
+ stopRootSession();
126
127
  migrateClaude(user, dir);
127
128
  await installClaude(user);
128
129
  copyMarketplace(user, env);
129
130
  watchAgentView();
130
- stopRootSession();
131
131
  return 'ready';
132
132
  } catch (err) {
133
133
  log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
@@ -70,7 +70,7 @@ export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): R
70
70
  export function asUser(user: AgentUser | null, file: string, args: readonly string[], extra: Record<string, string> = {}): [string, string[]] {
71
71
  if (user === null) return [file, [...args]];
72
72
  const env = Object.entries(agentEnv(user, extra)).map(([k, v]) => `${k}=${v}`);
73
- return ['runuser', ['-u', user.name, '--', 'env', '-i', ...env, file, ...args]];
73
+ return ['setpriv', [`--reuid=${String(user.uid)}`, `--regid=${String(user.gid)}`, '--init-groups', 'env', '-i', ...env, file, ...args]];
74
74
  }
75
75
 
76
76
  export const asAgent = (file: string, args: readonly string[], extra: Record<string, string> = {}): [string, string[]] =>
@@ -21,6 +21,7 @@ import {
21
21
  type PendingAtt,
22
22
  type PendingMsg,
23
23
  } from './pending.js';
24
+ import { agentUser } from '../agent-user/user.js';
24
25
 
25
26
  interface InboundDeps {
26
27
  mcp: Server;
@@ -106,11 +107,12 @@ export class InboundRelay {
106
107
  }
107
108
 
108
109
  private async surfaceMedia(ctx: MediaCtx, p: SavedMedia): Promise<void> {
109
- const note = await buildMediaNote(p, ctx.text ?? '');
110
+ const ownUser = agentUser() !== null;
111
+ const note = await buildMediaNote(p, ctx.text ?? '', !ownUser);
110
112
  if (!note) return;
111
113
  await this.surfaceNote(ctx, p, note, {
112
114
  ...(p.url ? { url: p.url } : {}),
113
- local_path: note.path,
115
+ ...(ownUser ? {} : { local_path: note.path }),
114
116
  });
115
117
  }
116
118
 
@@ -50,22 +50,27 @@ function howToRead(url: string | undefined, tooBig: boolean): string {
50
50
  return 'Fetch the Public URL to view it. The path is on the daemon host, so the Read tool works only for an agent running there.';
51
51
  }
52
52
 
53
+ const headline = (kind: string, name: string, mime: string | undefined, size: number): string =>
54
+ `[${kind} attachment received: ${name}${mime ? `, ${mime}` : ''}${size ? ` (${(size / 1024 / 1024).toFixed(2)} MB)` : ''}]\n`;
55
+
56
+ const whereItIs = (path: string, url: string | undefined, size: number, showPath: boolean): string =>
57
+ showPath ? `Daemon-host path: ${path}\n${howToRead(url, size > MAX_INLINE_BYTES)}` : 'Fetch the Public URL to get the file.';
58
+
53
59
  export async function buildMediaNote(
54
60
  p: SavedMedia,
55
61
  caption: string,
62
+ showPath = true,
56
63
  ): Promise<MediaNote | null> {
57
64
  const path = p.attachmentPath ?? p.localPath;
58
65
  if (!path) return null;
59
66
  const kind = p.kind ?? mediaKind(p.mime, p.name);
60
67
  const name = p.name ?? path.split('/').pop() ?? 'attachment';
61
68
  const size = await fileSize(path);
62
- const sizeNote = size ? ` (${(size / 1024 / 1024).toFixed(2)} MB)` : '';
63
69
  const content =
64
70
  (caption ? `${caption}\n` : '') +
65
- `[${kind} attachment received: ${name}${p.mime ? `, ${p.mime}` : ''}${sizeNote}]\n` +
71
+ headline(kind, name, p.mime, size) +
66
72
  (p.url ? `Public URL: ${p.url}\n` : '') +
67
- `Daemon-host path: ${path}\n` +
68
- howToRead(p.url, size > MAX_INLINE_BYTES);
73
+ whereItIs(path, p.url, size, showPath);
69
74
  return { content, kind, name, path };
70
75
  }
71
76
 
@@ -96,9 +96,14 @@ function realDir(dir: string): string {
96
96
  }
97
97
  }
98
98
 
99
+ const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
100
+
99
101
  export function hasConversation(home: string, dir = claudeDir()): boolean {
100
102
  const cwd = realDir(home);
101
- return listClaudeProjects(dir).some((project) => project.sessions > 0 && project.cwd !== null && realDir(project.cwd) === cwd);
103
+ const folder = encodedCwd(cwd);
104
+ return listClaudeProjects(dir).some(
105
+ (project) => project.sessions > 0 && (project.id === folder || (project.cwd !== null && realDir(project.cwd) === cwd)),
106
+ );
102
107
  }
103
108
 
104
109
  function metroCommand(deps: SessionDeps, home: string): string[] {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { writeHomeInPlace } from '../agent-user/home-fs.js';
3
+ import { agentMarketplaceDir, agentUser } from '../agent-user/user.js';
3
4
  import { join } from 'node:path';
4
5
  import { errMsg, log } from '@metro-labs/core/log';
5
6
  import { claudeDir } from '../claude/files.js';
@@ -70,7 +71,9 @@ function pluginRootsUnder(dir: string, depth: number, out: string[]): void {
70
71
 
71
72
  export function stagedPluginDir(env: NodeJS.ProcessEnv = process.env): string | null {
72
73
  const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
73
- return store === '' ? null : join(store, 'marketplace', 'plugin');
74
+ if (store === '') return null;
75
+ const user = agentUser();
76
+ return join(user === null ? join(store, 'marketplace') : agentMarketplaceDir(user), 'plugin');
74
77
  }
75
78
 
76
79
  export function installedPluginFiles(dir = claudeDir(), staged = stagedPluginDir()): string[] {
@@ -5,7 +5,8 @@ import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { log } from '@metro-labs/core/log';
6
6
  import { openrouterModels, openrouterZdrModels } from './openrouter.js';
7
7
  import { anthropicModels, bedrockModels } from './provider-models.js';
8
- import { syncAvailableModelsQuietly } from '../claude/setup.js';
8
+ import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
9
+ import { sessionRunning, stopSession } from '../claude/session.js';
9
10
  import { forgetOne } from './usage.js';
10
11
  import {
11
12
  addConnection,
@@ -35,10 +36,19 @@ const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
35
36
 
36
37
  export type { ModelApiDeps } from './model-store.js';
37
38
 
39
+ function restartRunningSession(): boolean {
40
+ if (!sessionRunning()) return false;
41
+ stopSession();
42
+ return true;
43
+ }
44
+
38
45
  function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
46
+ const before = routeOf(store.read());
39
47
  store.write(next);
40
48
  syncAvailableModelsQuietly(deps.setup ?? {}, next);
41
49
  log.info(fields, note);
50
+ if (routeOf(next) !== before && (deps.restartSession ?? restartRunningSession)())
51
+ log.info({ was: before, now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
42
52
  return settingsBody(next);
43
53
  }
44
54
 
@@ -23,6 +23,7 @@ export interface ModelApiDeps {
23
23
  anthropicBase?: string;
24
24
  bedrockControlBase?: string;
25
25
  setup?: SetupDeps;
26
+ restartSession?: () => boolean;
26
27
  }
27
28
 
28
29
  export interface Store {
@@ -5,6 +5,7 @@ import { agentIdForAccount, accountFromLine, knownAccounts } from '../agents/map
5
5
  import { attachmentUrl } from '../files/attach-serve.js';
6
6
  import { stationByName } from '../stations/registry.js';
7
7
  import { errResult, makeCtx, okJson, toErr } from './ctx.js';
8
+ import { agentUser } from '../agent-user/user.js';
8
9
 
9
10
  const FILTERS: readonly (readonly [string, ReadFilter, string])[] = [
10
11
  ['query', 'query', 'query'],
@@ -45,7 +46,8 @@ function withUrls(result: unknown, agentId: string | undefined): unknown {
45
46
  const attachments = files.map((f: unknown) => {
46
47
  if (!isRecord(f) || typeof f.local_path !== 'string') return f;
47
48
  const url = attachmentUrl(f.local_path, agentId);
48
- return url === null ? f : { ...f, url };
49
+ const shown = agentUser() === null ? f : Object.fromEntries(Object.entries(f).filter(([key]) => key !== 'local_path'));
50
+ return url === null ? shown : { ...shown, url };
49
51
  });
50
52
  return { ...result, message: { ...result.message, attachments } };
51
53
  }
@@ -195,6 +195,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
195
195
  claudeApi: {},
196
196
  updateApi: { restart: deps.restart },
197
197
  controlApi: { restart: deps.restart, stop: deps.stop },
198
+ agentUserApi: { restart: deps.restart },
198
199
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
199
200
  machineApi: {},
200
201
  modelApi: {},
@@ -17,12 +17,14 @@ import { handleMachineRequest, type MachineApiDeps } from '../server/machine.js'
17
17
  import { handleModelRequest, type ModelApiDeps } from '../gateway/model-api.js';
18
18
  import type { GatewayDeps } from '../gateway/gateway.js';
19
19
  import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js';
20
+ import { handleAgentUserRequest, type AgentUserApiDeps } from '../agent-user/api.js';
20
21
 
21
22
  export interface SessionApis {
22
23
  agentApi?: AgentApiDeps;
23
24
  bundleApi?: BundleApiDeps;
24
25
  updateApi?: UpdateApiDeps;
25
26
  controlApi?: ControlApiDeps;
27
+ agentUserApi?: AgentUserApiDeps;
26
28
  ownerApi?: OwnerApiDeps;
27
29
  machineApi?: MachineApiDeps;
28
30
  modelApi?: ModelApiDeps;
@@ -49,6 +51,7 @@ export function handleSessionApis(
49
51
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
50
52
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
51
53
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
54
+ ...when(apis.agentUserApi, (d) => handleAgentUserRequest(req, res, d)),
52
55
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
53
56
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
54
57
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
@@ -64,7 +64,13 @@ async function fromPathAs(
64
64
  ): Promise<ResolvedAttachment> {
65
65
  const proc = Bun.spawn(agentCommand(['cat', '--', path]), { stdout: 'pipe', stderr: 'pipe' });
66
66
  const name = a.name ?? basenameOf(path) ?? 'attachment';
67
- const saved = await streamToTemp(proc.stdout, name);
67
+ let saved: Awaited<ReturnType<typeof streamToTemp>>;
68
+ try {
69
+ saved = await streamToTemp(proc.stdout, name);
70
+ } catch (err) {
71
+ proc.kill();
72
+ throw err;
73
+ }
68
74
  if ((await proc.exited) !== 0) {
69
75
  await removeInlineTemp(saved.dir);
70
76
  throw new Error(
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.183"
2
+ "version": "0.1.0-beta.185"
3
3
  }