@stage-labs/metro 0.1.0-beta.190 → 0.1.0-beta.192

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.190",
3
+ "version": "0.1.0-beta.192",
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.190",
3
+ "version": "0.1.0-beta.192",
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,20 +1,16 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
2
+ import { existsSync, readFileSync } from 'node:fs';
4
3
  import { join } from 'node:path';
5
4
  import { errMsg, log } from '@metro-labs/core/log';
6
5
  import { isRecord } from '@metro-labs/core/is-record';
7
- import { agentsDir } from '../agents/files.js';
8
6
  import { copyIntoHome } from './home-fs.js';
9
- import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, lookupUser, wantedAgentUser, type AgentUser } from './user.js';
7
+ import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
10
8
  import { watchAgentView } from './view.js';
11
- import { repairGitLinks } from './git-links.js';
12
9
 
13
10
  const INSTALL_MS = 10 * 60_000;
14
11
  const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
15
- const MIGRATED = '.agent-user-migrated';
16
12
 
17
- export type Provisioned = 'off' | 'unsupported' | 'failed' | 'ready';
13
+ export type Provisioned = 'off' | 'failed' | 'ready';
18
14
 
19
15
  function run(file: string, args: string[]): void {
20
16
  const done = spawnSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -22,10 +18,10 @@ function run(file: string, args: string[]): void {
22
18
  if (done.status !== 0) throw new Error(`${file} ${args.join(' ')}: ${done.stderr.trim() || `exit ${String(done.status)}`}`);
23
19
  }
24
20
 
25
- function ensureUser(name: string): AgentUser | null {
21
+ function ensureUser(): AgentUser | null {
26
22
  if (agentUser() === null) {
27
- run('useradd', ['--create-home', '--shell', '/bin/bash', name]);
28
- log.info({ user: name }, 'agent-user: created the user Claude Code runs as');
23
+ run('useradd', ['--create-home', '--shell', '/bin/bash', AGENT_NAME]);
24
+ log.info({ user: AGENT_NAME }, 'agent-user: created the user Claude Code runs as');
29
25
  forgetAgentUser();
30
26
  }
31
27
  const user = agentUser();
@@ -33,54 +29,6 @@ function ensureUser(name: string): AgentUser | null {
33
29
  return user;
34
30
  }
35
31
 
36
- const absent = (path: string): boolean => {
37
- try {
38
- lstatSync(path);
39
- return false;
40
- } catch {
41
- return true;
42
- }
43
- };
44
-
45
- const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
46
-
47
- function moveProject(user: AgentUser, from: string): void {
48
- const projects = join(user.home, '.claude', 'projects');
49
- const old = join(projects, encodedCwd(from));
50
- const next = join(projects, encodedCwd(user.home));
51
- if (existsSync(old) && absent(next)) run('mv', ['-T', old, next]);
52
- }
53
-
54
- const stamp = (now: Date): string => now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
55
-
56
- const kindIs = (path: string, want: 'dir' | 'file'): boolean => {
57
- try {
58
- const st = lstatSync(path);
59
- return want === 'dir' ? st.isDirectory() : st.isFile();
60
- } catch {
61
- return false;
62
- }
63
- };
64
-
65
- function migrateClaude(user: AgentUser, dir: string): void {
66
- const marker = join(dir, MIGRATED);
67
- if (existsSync(marker)) return;
68
- const home = homedir();
69
- const copied: string[] = [];
70
- for (const name of ['.claude', '.claude.json']) {
71
- const from = join(home, name);
72
- const to = join(user.home, name);
73
- if (!existsSync(from)) continue;
74
- if (!absent(to)) run('mv', ['-T', to, `${to}.before-root-${stamp(new Date())}`]);
75
- run('cp', ['-aT', from, to]);
76
- run('chown', ['-hR', `${user.name}:${String(user.gid)}`, to]);
77
- copied.push(name);
78
- }
79
- if (copied.includes('.claude')) moveProject(user, home);
80
- writeFileSync(marker, `${user.name}\n`, { mode: 0o600 });
81
- log.info({ user: user.name, copied }, 'agent-user: moved the Claude Code folder and login to the agent user once; root keeps its copy');
82
- }
83
-
84
32
  function installClaude(user: AgentUser): Promise<void> {
85
33
  if (existsSync(claudeBin(user))) return Promise.resolve();
86
34
  const [file, args] = asUser(user, 'bash', ['-c', INSTALLER]);
@@ -119,79 +67,17 @@ function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
119
67
  log.info({ files, version: pluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
120
68
  }
121
69
 
122
- function stopRootSession(): void {
123
- const running = spawnSync('tmux', ['has-session', '-t', 'metro'], { stdio: 'ignore' });
124
- if (running.error !== undefined || running.status !== 0) return;
125
- spawnSync('tmux', ['kill-session', '-t', 'metro'], { stdio: 'ignore' });
126
- log.info('agent-user: stopped the Claude session root was running; it comes back as the agent user');
127
- }
128
-
129
- function stopAgentSessions(user: AgentUser): void {
130
- spawnSync(...asUser(user, 'tmux', ['kill-server']), { stdio: 'ignore' });
131
- }
132
-
133
- function takeBack(user: AgentUser, now: Date): string[] {
134
- const home = homedir();
135
- const taken: string[] = [];
136
- for (const [name, kind] of [['.claude', 'dir'], ['.claude.json', 'file']] as const) {
137
- const from = join(user.home, name);
138
- if (!kindIs(from, kind)) continue;
139
- const to = join(home, name);
140
- if (!absent(to)) run('mv', ['-T', to, `${to}.before-agent-${stamp(now)}`]);
141
- run('cp', ['-aT', from, to]);
142
- run('chown', ['-hR', 'root:root', to]);
143
- taken.push(name);
144
- }
145
- if (taken.includes('.claude')) {
146
- const projects = join(home, '.claude', 'projects');
147
- const old = join(projects, encodedCwd(user.home));
148
- const next = join(projects, encodedCwd(home));
149
- if (existsSync(old) && absent(next)) run('mv', ['-T', old, next]);
150
- }
151
- return taken;
152
- }
153
-
154
- export function returnToRoot(dir = agentsDir(), now = new Date()): string[] | null {
155
- const marker = join(dir, MIGRATED);
156
- if (process.platform !== 'linux' || process.getuid?.() !== 0 || !existsSync(marker)) return null;
157
- const name = readFileSync(marker, 'utf8').trim() || 'agent';
158
- const user = lookupUser(name);
159
- if (user === null) return null;
160
- stopAgentSessions(user);
161
- const taken = takeBack(user, now);
162
- rmSync(marker);
163
- log.info({ user: user.name, taken }, 'agent-user: switched off; the agent\'s Claude folder and login are root\'s again, root\'s older copy is kept beside it');
164
- return taken;
165
- }
166
-
167
- export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
168
- const name = wantedAgentUser(dir);
169
- if (name === null) {
170
- try {
171
- returnToRoot(dir);
172
- } catch (err) {
173
- log.error({ err: errMsg(err) }, 'agent-user: could not bring the Claude folder back to root; the agent user keeps it, nothing was deleted');
174
- }
175
- return 'off';
176
- }
177
- if (process.platform !== 'linux' || process.getuid?.() !== 0) {
178
- log.warn({ user: name }, 'agent-user: running Claude Code as its own user needs Linux and a daemon running as root; the Claude session stays stopped');
179
- return 'unsupported';
180
- }
70
+ export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
71
+ if (!agentUserExpected()) return 'off';
181
72
  try {
182
- const user = ensureUser(name);
183
- if (user === null) throw new Error(`the user ${name} could not be found after creating it`);
184
- stopRootSession();
185
- migrateClaude(user, dir);
73
+ const user = ensureUser();
74
+ if (user === null) throw new Error(`the user ${AGENT_NAME} could not be found after creating it`);
186
75
  await installClaude(user);
187
76
  copyMarketplace(user, env);
188
77
  watchAgentView();
189
- repairGitLinks(user).catch((err: unknown) => {
190
- log.warn({ err: errMsg(err) }, 'agent-user: could not repair git links');
191
- });
192
78
  return 'ready';
193
79
  } catch (err) {
194
- log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
80
+ log.error({ user: AGENT_NAME, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
195
81
  return 'failed';
196
82
  }
197
83
  }
@@ -0,0 +1,82 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { log } from '@metro-labs/core/log';
5
+ import { listSchedules, realRunner, showProps, type Runner, type ScheduledJob } from './schedules.js';
6
+ import { asUser, type AgentUser } from './user.js';
7
+
8
+ const LOG_LINES = 80;
9
+ const TAIL_BYTES = 64 * 1024;
10
+
11
+ export interface JobDetail extends ScheduledJob {
12
+ definition: string;
13
+ logs: string;
14
+ logSource: string | null;
15
+ }
16
+
17
+ function findJob(id: string, user: AgentUser | null, runner: Runner): ScheduledJob {
18
+ const job = listSchedules(user, runner).find((j) => j.id === id);
19
+ if (job === undefined) throw new ApiError('no such scheduled job', 404);
20
+ return job;
21
+ }
22
+
23
+ const serviceOf = (unit: string, runner: Runner): string => showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
24
+
25
+ export function logFileOf(command: string): string | null {
26
+ const m = /(?:^|\s)(?:>>|>)\s*(\/\S+)/.exec(command);
27
+ return m?.[1] ?? null;
28
+ }
29
+
30
+ function tail(path: string): string {
31
+ const size = statSync(path).size;
32
+ const start = Math.max(0, size - TAIL_BYTES);
33
+ const fd = openSync(path, 'r');
34
+ try {
35
+ const buffer = Buffer.alloc(size - start);
36
+ readSync(fd, buffer, 0, buffer.length, start);
37
+ return buffer.toString('utf8').split('\n').slice(-LOG_LINES).join('\n');
38
+ } finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+
43
+ function timerDetail(job: ScheduledJob, runner: Runner): JobDetail {
44
+ const unit = job.id.slice('timer:'.length);
45
+ const service = serviceOf(unit, runner);
46
+ return {
47
+ ...job,
48
+ definition: runner.run('systemctl', ['cat', unit, service, '--no-pager']).stdout,
49
+ logs: runner.run('journalctl', ['-u', service, '-n', String(LOG_LINES), '--no-pager', '-o', 'short-iso']).stdout,
50
+ logSource: `journalctl -u ${service}`,
51
+ };
52
+ }
53
+
54
+ function cronDetail(job: ScheduledJob): JobDetail {
55
+ const file = logFileOf(job.command);
56
+ const readable = file !== null && existsSync(file) && statSync(file).isFile();
57
+ return {
58
+ ...job,
59
+ definition: `${job.schedule} ${job.command}`,
60
+ logs: readable ? tail(file) : '',
61
+ logSource: file,
62
+ };
63
+ }
64
+
65
+ export function scheduleDetail(id: string, user: AgentUser | null, runner: Runner = realRunner): JobDetail {
66
+ const job = findJob(id, user, runner);
67
+ return job.kind === 'timer' ? timerDetail(job, runner) : cronDetail(job);
68
+ }
69
+
70
+ export function runNow(id: string, user: AgentUser | null, runner: Runner = realRunner): JobDetail {
71
+ const job = findJob(id, user, runner);
72
+ if (job.runsAs === 'root') throw new ApiError('this job still runs as root; switch it to the agent first', 409);
73
+ if (job.kind === 'timer') runner.run('systemctl', ['start', '--no-block', serviceOf(job.id.slice('timer:'.length), runner)]);
74
+ else {
75
+ if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
76
+ const [file, args] = asUser(user, 'sh', ['-c', job.command]);
77
+ const child = spawn(file, args, { detached: true, stdio: 'ignore', cwd: user.home });
78
+ child.unref();
79
+ }
80
+ log.info({ job: job.name }, 'schedules: run now, from the page');
81
+ return scheduleDetail(id, user, runner);
82
+ }
@@ -0,0 +1,27 @@
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 { listSchedules, retrySchedule } from './schedules.js';
5
+ import { runNow, scheduleDetail } from './schedule-detail.js';
6
+ import { agentUser } from './user.js';
7
+
8
+ const PATH = '/api/schedules';
9
+
10
+ const queryId = (req: IncomingMessage): string | null => new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('id');
11
+
12
+ async function answer(req: IncomingMessage): Promise<unknown> {
13
+ const user = agentUser();
14
+ if (req.method === 'GET') {
15
+ const id = queryId(req);
16
+ return id === null ? { agentUser: user?.name ?? null, jobs: listSchedules(user) } : { job: scheduleDetail(id, user) };
17
+ }
18
+ const body = await readJsonBody(req);
19
+ const id = bodyField(body, 'id');
20
+ if (typeof id !== 'string' || id === '') throw new ApiError('id is required', 400);
21
+ if (bodyField(body, 'action') === 'run') return { job: runNow(id, user) };
22
+ return { agentUser: user?.name ?? null, jobs: retrySchedule(id, user) };
23
+ }
24
+
25
+ export function handleSchedulesRequest(req: IncomingMessage, res: ServerResponse): boolean {
26
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: true, label: 'schedules-api' }, () => answer(req));
27
+ }
@@ -1,14 +1,15 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { ApiError } from '@metro-labs/http/api-error';
7
7
  import { isRecord } from '@metro-labs/core/is-record';
8
- import { log } from '@metro-labs/core/log';
8
+ import { errMsg, log } from '@metro-labs/core/log';
9
9
  import type { AgentUser } from './user.js';
10
10
 
11
11
  const UNIT_DIR = '/etc/systemd/system';
12
+ const defaultBackups = (): string => join(homedir(), '.metro', 'agents');
12
13
  const DROP_IN = '10-metro-agent.conf';
13
14
  const OWN_UNITS = /^metro(-claude-\d+)?\.(service|scope)$/;
14
15
 
@@ -26,13 +27,14 @@ export interface ScheduledJob {
26
27
  lastResult: string | null;
27
28
  usesRoot: boolean;
28
29
  converted: boolean;
30
+ problem?: string | null;
29
31
  }
30
32
 
31
33
  export interface Runner {
32
34
  run: (file: string, args: string[], input?: string) => { status: number | null; stdout: string };
33
35
  }
34
36
 
35
- const realRunner: Runner = {
37
+ export const realRunner: Runner = {
36
38
  run: (file, args, input) => {
37
39
  const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
38
40
  return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
@@ -46,7 +48,7 @@ export const rehome = (text: string, home: string): string => text.replace(/(^|[
46
48
  const stamp = (micros: unknown): string | null =>
47
49
  typeof micros === 'number' && micros > 0 ? new Date(micros / 1000).toISOString() : null;
48
50
 
49
- function showProps(runner: Runner, unit: string, props: string[]): Record<string, string> {
51
+ export function showProps(runner: Runner, unit: string, props: string[]): Record<string, string> {
50
52
  const out = runner.run('systemctl', ['show', unit, '--no-pager', ...props.map((p) => `-p${p}`)]).stdout;
51
53
  const found: Record<string, string> = {};
52
54
  for (const line of out.split('\n')) {
@@ -149,7 +151,7 @@ function cronJobs(runner: Runner, user: string, kind: JobKind): ScheduledJob[] {
149
151
  });
150
152
  }
151
153
 
152
- export function listSchedules(user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] {
154
+ function allJobs(user: AgentUser | null, runner: Runner): ScheduledJob[] {
153
155
  return [...timerJobs(runner), ...cronJobs(runner, 'root', 'cron-root'), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
154
156
  }
155
157
 
@@ -186,14 +188,6 @@ function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void
186
188
  log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
187
189
  }
188
190
 
189
- function restoreTimer(job: ScheduledJob, runner: Runner): void {
190
- const unit = job.id.slice('timer:'.length);
191
- const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
192
- rmSync(join(UNIT_DIR, `${service}.d`, DROP_IN), { force: true });
193
- runner.run('systemctl', ['daemon-reload']);
194
- log.info({ unit, service }, 'schedules: a timer runs as root again');
195
- }
196
-
197
191
  function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
198
192
  const lines = crontabOf(runner, 'root');
199
193
  const line = lines.find((l) => lineId('cron-root', l) === job.id);
@@ -208,23 +202,44 @@ function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner
208
202
  log.info({ user: user.name }, "schedules: a root cron line moved to the agent's crontab");
209
203
  }
210
204
 
211
- function toRoot(job: ScheduledJob, runner: Runner): void {
212
- if (job.kind !== 'timer' || !job.converted) throw new ApiError('only a timer metro switched can go back to root', 400);
213
- restoreTimer(job, runner);
214
- }
215
-
216
- function toAgent(job: ScheduledJob, user: AgentUser | null, backups: string, runner: Runner): void {
217
- if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
205
+ function toAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
218
206
  if (job.kind === 'timer') convertTimer(job, user, runner);
219
207
  else if (job.kind === 'cron-root') convertCron(job, user, backups, runner);
220
208
  else throw new ApiError('that job already runs as the agent', 400);
221
209
  }
222
210
 
223
- export function changeSchedule(id: string, action: unknown, user: AgentUser | null, backups = join(homedir(), '.metro', 'agents'), runner: Runner = realRunner): ScheduledJob[] {
224
- if (action !== 'agent' && action !== 'root') throw new ApiError('action must be agent or root', 400);
225
- const job = listSchedules(user, runner).find((j) => j.id === id);
211
+ const problems = new Map<string, string>();
212
+
213
+ const needsAgent = (job: ScheduledJob): boolean => job.kind !== 'cron-agent' && job.runsAs === 'root' && job.usesRoot;
214
+
215
+ function tryAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): boolean {
216
+ try {
217
+ toAgent(job, user, backups, runner);
218
+ problems.delete(job.id);
219
+ return true;
220
+ } catch (err) {
221
+ problems.set(job.id, errMsg(err));
222
+ log.warn({ job: job.name, err: errMsg(err) }, 'schedules: a job still pointing into /root could not be switched to the agent user');
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export function convertRootJobs(user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): number {
228
+ if (user === null) return 0;
229
+ const pending = allJobs(user, runner).filter(needsAgent);
230
+ return pending.filter((job) => tryAgent(job, user, backups, runner)).length;
231
+ }
232
+
233
+ export const listSchedules = (user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] =>
234
+ allJobs(user, runner)
235
+ .filter((job) => job.runsAs !== 'root' || job.usesRoot)
236
+ .map((job) => ({ ...job, problem: problems.get(job.id) ?? null }));
237
+
238
+ export function retrySchedule(id: string, user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): ScheduledJob[] {
239
+ if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
240
+ const job = allJobs(user, runner).find((j) => j.id === id);
226
241
  if (job === undefined) throw new ApiError('no such scheduled job', 404);
227
- if (action === 'root') toRoot(job, runner);
228
- else toAgent(job, user, backups, runner);
242
+ if (needsAgent(job)) toAgent(job, user, backups, runner);
243
+ problems.delete(id);
229
244
  return listSchedules(user, runner);
230
245
  }
@@ -1,11 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
- import { isRecord } from '@metro-labs/core/is-record';
4
- import { readJson } from '@metro-labs/core/secure-fs';
5
- import { agentsDir } from '../agents/files.js';
6
3
 
7
- export const CONFIG_FILE = 'agent-user.json';
8
- const NAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
4
+ export const AGENT_NAME = 'agent';
9
5
  const AGENT_PATH = ['.local/bin', '/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'];
10
6
 
11
7
  export interface AgentUser {
@@ -32,22 +28,14 @@ export function lookupUser(name: string): AgentUser | null {
32
28
 
33
29
  const realHost: UserHost = { platform: process.platform, uid: process.getuid?.(), lookup: lookupUser };
34
30
 
35
- export function wantedAgentUser(dir = agentsDir()): string | null {
36
- const raw = readJson<unknown>(join(dir, CONFIG_FILE), null);
37
- if (!isRecord(raw) || raw.enabled === false) return null;
38
- const name = typeof raw.user === 'string' ? raw.user : 'agent';
39
- return NAME_RE.test(name) && name !== 'root' ? name : null;
40
- }
31
+ export const agentUserExpected = (host: UserHost = realHost): boolean => host.platform === 'linux' && host.uid === 0;
41
32
 
42
- let cached: { key: string; user: AgentUser | null } | null = null;
33
+ let cached: AgentUser | null = null;
43
34
 
44
- export function agentUser(dir = agentsDir(), host: UserHost = realHost): AgentUser | null {
45
- const name = wantedAgentUser(dir);
46
- if (name === null || host.platform !== 'linux' || host.uid !== 0) return null;
47
- const key = `${dir}\n${name}`;
48
- if (cached?.key === key && cached.user !== null) return cached.user;
49
- cached = { key, user: host.lookup(name) };
50
- return cached.user;
35
+ export function agentUser(host: UserHost = realHost): AgentUser | null {
36
+ if (!agentUserExpected(host)) return null;
37
+ cached ??= host.lookup(AGENT_NAME);
38
+ return cached;
51
39
  }
52
40
 
53
41
  export function forgetAgentUser(): void {
@@ -24,6 +24,8 @@ import { syncPluginServers } from '../connectors/plugin-sync.js';
24
24
  import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
25
25
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
26
26
  import { provisionAgentUser } from '../agent-user/provision.js';
27
+ import { convertRootJobs } from '../agent-user/schedules.js';
28
+ import { agentUser as currentAgentUser } from '../agent-user/user.js';
27
29
  import { ensureServiceOomPolicy } from '../claude/memory.js';
28
30
  import { unwatchSession, watchSession } from '../claude/session.js';
29
31
  import { tryClaudeSetup } from '../claude/setup.js';
@@ -158,7 +160,11 @@ async function startClaude(): Promise<void> {
158
160
  ensureServiceOomPolicy();
159
161
  const agentUser = await provisionAgentUser();
160
162
  if (agentUser !== 'off') log.info({ agentUser }, 'agent-user: Claude Code runs as its own user');
161
- if (agentUser === 'failed' || agentUser === 'unsupported') return;
163
+ if (agentUser === 'failed') return;
164
+ if (agentUser === 'ready') {
165
+ const switched = convertRootJobs(currentAgentUser());
166
+ if (switched > 0) log.info({ switched }, "schedules: jobs that pointed into root's home now run as the agent user");
167
+ }
162
168
  ensureMetroPlugin()
163
169
  .then((outcome) => {
164
170
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
@@ -12,7 +12,7 @@ import { claudeDir, listClaudeProjects } from './files.js';
12
12
  import { claudeAccount, claudeInstalled } from './login.js';
13
13
  import { trustFolder } from './onboarding.js';
14
14
  import { inSessionScope } from './memory.js';
15
- import { agentUser, agentViewDir, asAgent, claudeHome, wantedAgentUser } from '../agent-user/user.js';
15
+ import { agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
16
16
 
17
17
  function sessionEnv(): Record<string, string> {
18
18
  const user = agentUser();
@@ -132,14 +132,14 @@ function credentialReady(deps: SessionDeps): string | null {
132
132
  return 'Claude Code is not signed in and the Model page routes nowhere yet';
133
133
  }
134
134
 
135
- const agentUserMissing = (deps: SessionDeps, agents: string): boolean =>
136
- deps.metro === undefined && wantedAgentUser(agents) !== null && agentUser(agents) === null;
135
+ const agentUserMissing = (deps: SessionDeps): boolean =>
136
+ deps.metro === undefined && agentUserExpected() && agentUser() === null;
137
137
 
138
138
  export function sessionBlocked(deps: SessionDeps = {}): string | null {
139
139
  const agents = deps.agents ?? agentsDir();
140
140
  if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
141
- if (agentUserMissing(deps, agents))
142
- return 'Claude Code is set to run as its own user, and that user is not ready (Linux and a daemon running as root are needed; see the log)';
141
+ if (agentUserMissing(deps))
142
+ return 'Claude Code runs as the user agent here, and that user is not ready yet; see the log';
143
143
  if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
144
144
  if (!tmuxOk(deps.tmux ?? 'tmux', ['-V'])) return 'tmux is not installed on this machine';
145
145
  return credentialReady(deps);
@@ -195,7 +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
+ schedulesApi: true,
199
199
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
200
200
  machineApi: {},
201
201
  modelApi: {},
@@ -17,14 +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
+ import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
21
21
 
22
22
  export interface SessionApis {
23
23
  agentApi?: AgentApiDeps;
24
24
  bundleApi?: BundleApiDeps;
25
25
  updateApi?: UpdateApiDeps;
26
26
  controlApi?: ControlApiDeps;
27
- agentUserApi?: AgentUserApiDeps;
27
+ schedulesApi?: true;
28
28
  ownerApi?: OwnerApiDeps;
29
29
  machineApi?: MachineApiDeps;
30
30
  modelApi?: ModelApiDeps;
@@ -51,7 +51,7 @@ export function handleSessionApis(
51
51
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
52
52
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
53
53
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
54
- ...when(apis.agentUserApi, (d) => handleAgentUserRequest(req, res, d)),
54
+ ...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
55
55
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
56
56
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
57
57
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.190"
2
+ "version": "0.1.0-beta.192"
3
3
  }
@@ -1,98 +0,0 @@
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, requireAdmin, 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
- import { listWorkspace, modeOf, startMove } from './workspace.js';
11
- import { changeSchedule, listSchedules } from './schedules.js';
12
-
13
- const PATH = '/api/agent-user';
14
- const WORKSPACE = '/api/agent-user/workspace';
15
- const SCHEDULES = '/api/schedules';
16
- const EXIT_DELAY_MS = 500;
17
- const NAMES_BODY_MAX = 128 * 1024;
18
-
19
- export interface AgentUserApiDeps {
20
- restart: () => void;
21
- agents?: () => string;
22
- host?: () => { platform: string; uid: number | undefined; cli: boolean };
23
- }
24
-
25
- export interface AgentUserStatus {
26
- enabled: boolean;
27
- user: string | null;
28
- active: boolean;
29
- supported: boolean;
30
- reason: string | null;
31
- }
32
-
33
- const realHost = (): { platform: string; uid: number | undefined; cli: boolean } => ({
34
- platform: process.platform,
35
- uid: process.getuid?.(),
36
- cli: (process.env.METRO_CLI_BIN?.trim() ?? '') !== '',
37
- });
38
-
39
- function unsupported(host: { platform: string; uid: number | undefined; cli: boolean }): string | null {
40
- if (host.platform !== 'linux') return 'Claude Code can run as its own user on Linux only.';
41
- if (host.uid !== 0) return 'Metro runs as a normal user here, so it cannot switch Claude Code to another user.';
42
- if (!host.cli) return 'This daemon was not started by metro serve, so nothing would bring it back after the switch.';
43
- return null;
44
- }
45
-
46
- export function agentUserStatus(deps: Pick<AgentUserApiDeps, 'agents' | 'host'> = {}): AgentUserStatus {
47
- const dir = (deps.agents ?? agentsDir)();
48
- const reason = unsupported((deps.host ?? realHost)());
49
- const user = wantedAgentUser(dir);
50
- return { enabled: user !== null, user, active: user !== null && agentUser(dir) !== null, supported: reason === null, reason };
51
- }
52
-
53
- function setWanted(dir: string, enabled: boolean): void {
54
- const path = join(dir, CONFIG_FILE);
55
- if (enabled) writeSecure(path, `${JSON.stringify({ user: 'agent' })}\n`);
56
- else if (existsSync(path)) rmSync(path);
57
- forgetAgentUser();
58
- }
59
-
60
- async function workspaceAnswer(req: IncomingMessage, dir: string): Promise<unknown> {
61
- const user = agentUser(dir);
62
- if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
63
- if (req.method === 'GET') return { user: user.name, home: user.home, entries: listWorkspace(user) };
64
- const body = await readJsonBody(req, NAMES_BODY_MAX);
65
- const started = startMove(bodyField(body, 'names'), user, modeOf(bodyField(body, 'mode')));
66
- return { started, entries: listWorkspace(user) };
67
- }
68
-
69
- async function schedulesAnswer(req: IncomingMessage, dir: string): Promise<unknown> {
70
- const user = agentUser(dir);
71
- if (req.method === 'GET') return { agentUser: user?.name ?? null, jobs: listSchedules(user) };
72
- const body = await readJsonBody(req);
73
- const id = bodyField(body, 'id');
74
- if (typeof id !== 'string' || id === '') throw new ApiError('id is required', 400);
75
- return { agentUser: user?.name ?? null, jobs: changeSchedule(id, bodyField(body, 'action'), user) };
76
- }
77
-
78
- async function switchAnswer(req: IncomingMessage, deps: AgentUserApiDeps, subject: string): Promise<unknown> {
79
- const enabled = bodyField(await readJsonBody(req), 'enabled');
80
- if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
81
- const reason = unsupported((deps.host ?? realHost)());
82
- if (enabled && reason !== null) throw new ApiError(reason, 400);
83
- setWanted((deps.agents ?? agentsDir)(), enabled);
84
- log.info({ enabled, subject }, 'agent-user-api: switched from the page; restarting');
85
- setTimeout(deps.restart, EXIT_DELAY_MS).unref();
86
- return { ...agentUserStatus(deps), restarting: true };
87
- }
88
-
89
- export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
90
- const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'], [SCHEDULES]: ['GET', 'POST'] };
91
- return sessionRoute(req, res, { methods, admin: ['POST'], label: 'agent-user-api' }, async (session, path) => {
92
- const dir = (deps.agents ?? agentsDir)();
93
- if (path !== PATH) requireAdmin(session);
94
- if (path === WORKSPACE) return workspaceAnswer(req, dir);
95
- if (path === SCHEDULES) return schedulesAnswer(req, dir);
96
- return req.method === 'GET' ? agentUserStatus(deps) : switchAnswer(req, deps, session.subject);
97
- });
98
- }
@@ -1,34 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { homedir } from 'node:os';
3
- import { errMsg, log } from '@metro-labs/core/log';
4
- import { asUser, type AgentUser } from './user.js';
5
-
6
- const REPAIR = [
7
- 'from="$1"; to="$2"',
8
- 'find "$to" -maxdepth 8 -name node_modules -prune -o -name .git -type f -print 2>/dev/null | while read -r f; do',
9
- ' grep -q "^gitdir: $from/" "$f" && sed -i "s#^gitdir: $from/#gitdir: $to/#" "$f" && echo "$f"',
10
- 'done',
11
- 'find "$to" -maxdepth 10 -name node_modules -prune -o -path "*/.git/worktrees/*/gitdir" -type f -print 2>/dev/null | while read -r g; do',
12
- ' grep -q "^$from/" "$g" && sed -i "s#^$from/#$to/#" "$g" && echo "$g"',
13
- 'done',
14
- ].join('\n');
15
-
16
- export function repairGitLinks(user: AgentUser, from = homedir()): Promise<number> {
17
- const [file, args] = asUser(user, 'sh', ['-c', REPAIR, 'metro', from, user.home]);
18
- return new Promise((resolve) => {
19
- const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'ignore'] });
20
- let out = '';
21
- child.stdout.on('data', (chunk: Buffer) => {
22
- out += chunk.toString('utf8');
23
- });
24
- child.on('error', (err) => {
25
- log.warn({ err: errMsg(err) }, 'agent-user: could not repair git links');
26
- resolve(0);
27
- });
28
- child.on('close', () => {
29
- const fixed = out.split('\n').filter((line) => line !== '').length;
30
- if (fixed > 0) log.info({ fixed }, "agent-user: repaired git links that still pointed into root's home");
31
- resolve(fixed);
32
- });
33
- });
34
- }
@@ -1,182 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync, lstatSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { ApiError } from '@metro-labs/http/api-error';
6
- import { errMsg, log } from '@metro-labs/core/log';
7
- import { repairGitLinks } from './git-links.js';
8
- import { asUser, type AgentUser } from './user.js';
9
-
10
- const SKIPPED = new Set(['snap', 'lost+found']);
11
- const SAFE_HIDDEN = [
12
- '.cache/huggingface/hub',
13
- '.cache/whisper',
14
- '.cache/torch',
15
- '.cache/ms-playwright',
16
- '.venv',
17
- '.venvs',
18
- '.virtualenvs',
19
- '.pyenv',
20
- '.bun',
21
- '.nvm',
22
- '.cargo',
23
- '.rustup',
24
- '.deno',
25
- ];
26
- const SIZE_MS = 2_000;
27
- const SIZES_BUDGET_MS = 6_000;
28
- const NAMES_MAX = 1_000;
29
-
30
- export type EntryState = 'here' | 'waiting' | 'copying' | 'moved' | 'failed';
31
- export type MoveMode = 'copy' | 'move';
32
-
33
- export interface WorkspaceEntry {
34
- name: string;
35
- kind: 'folder' | 'file' | 'link';
36
- hidden: boolean;
37
- bytes: number | null;
38
- state: EntryState;
39
- error: string | null;
40
- }
41
-
42
- interface Job {
43
- state: 'waiting' | 'copying' | 'failed';
44
- error: string | null;
45
- }
46
-
47
- const jobs = new Map<string, Job>();
48
- let queue: Promise<void> = Promise.resolve();
49
-
50
- function kindOf(path: string): WorkspaceEntry['kind'] | null {
51
- try {
52
- const stat = lstatSync(path);
53
- if (stat.isSymbolicLink()) return 'link';
54
- if (stat.isDirectory()) return 'folder';
55
- return stat.isFile() ? 'file' : null;
56
- } catch {
57
- return null;
58
- }
59
- }
60
-
61
- const present = (path: string): boolean => kindOf(path) !== null;
62
-
63
- function sizeOf(path: string): number | null {
64
- const run = spawnSync('du', ['-sk', path], { encoding: 'utf8', timeout: SIZE_MS, stdio: ['ignore', 'pipe', 'ignore'] });
65
- const kb = Number(run.stdout.split('\t')[0]);
66
- return run.status === 0 && Number.isFinite(kb) ? kb * 1024 : null;
67
- }
68
-
69
- export function candidates(from = homedir()): string[] {
70
- const visible = readdirSync(from).filter((name) => !name.startsWith('.') && !SKIPPED.has(name));
71
- const hidden = SAFE_HIDDEN.filter((name) => kindOf(join(from, name)) === 'folder');
72
- return [...visible.sort((a, b) => a.localeCompare(b)), ...hidden];
73
- }
74
-
75
- function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' | 'error'> {
76
- const job = jobs.get(name);
77
- if (job !== undefined) return { state: job.state, error: job.error };
78
- return { state: present(join(user.home, name)) ? 'moved' : 'here', error: null };
79
- }
80
-
81
- export function listWorkspace(user: AgentUser, from = homedir(), now = Date.now): WorkspaceEntry[] {
82
- const until = now() + SIZES_BUDGET_MS;
83
- return candidates(from).flatMap((name): WorkspaceEntry[] => {
84
- const path = join(from, name);
85
- const kind = kindOf(path);
86
- if (kind === null) return [];
87
- const bytes = kind === 'file' ? lstatSync(path).size : kind === 'folder' && now() < until ? sizeOf(path) : null;
88
- return [{ name, kind, hidden: name.startsWith('.'), bytes, ...stateOf(name, user) }];
89
- });
90
- }
91
-
92
- function run(file: string, args: string[]): Promise<void> {
93
- return new Promise((resolve, reject) => {
94
- const child = spawn(file, args, { stdio: ['ignore', 'ignore', 'pipe'] });
95
- let said = '';
96
- child.stderr.on('data', (chunk: Buffer) => {
97
- said = `${said}${chunk.toString('utf8')}`.slice(-1000);
98
- });
99
- child.on('error', reject);
100
- child.on('close', (code) => {
101
- if (code === 0) resolve();
102
- else reject(new Error(`${file}: ${said.trim() || `exit ${String(code)}`}`));
103
- });
104
- });
105
- }
106
-
107
- function makeParents(user: AgentUser, target: string): void {
108
- const parent = dirname(target);
109
- if (parent === user.home || existsSync(parent)) return;
110
- const [file, args] = asUser(user, 'mkdir', ['-p', '--', parent]);
111
- spawnSync(file, args, { stdio: 'ignore' });
112
- }
113
-
114
- const sameDisk = (a: string, b: string): boolean => statSync(a).dev === statSync(b).dev;
115
-
116
- async function putBack(staged: string, source: string): Promise<void> {
117
- if (present(staged) && !present(source)) await run('mv', ['-T', '--', staged, source]);
118
- }
119
-
120
- async function place(name: string, user: AgentUser, from: string, mode: MoveMode): Promise<void> {
121
- const source = join(from, name);
122
- const target = join(user.home, name);
123
- const staging = mkdtempSync(join(user.home, '..', '.metro-move-'));
124
- const staged = join(staging, 'entry');
125
- try {
126
- if (mode === 'move') await run('mv', ['-T', '--', source, staged]);
127
- else await run('cp', ['-a', '--', source, staged]);
128
- await run('chown', ['-hR', `${String(user.uid)}:${String(user.gid)}`, staged]);
129
- makeParents(user, target);
130
- if (present(target)) throw new Error(`${target} already exists; move or remove it first`);
131
- await run('mv', ['-T', '--', staged, target]);
132
- } catch (err) {
133
- if (mode === 'move') await putBack(staged, source);
134
- throw err;
135
- } finally {
136
- rmSync(staging, { recursive: true, force: true });
137
- }
138
- }
139
-
140
- async function moveOne(name: string, user: AgentUser, from: string, mode: MoveMode): Promise<void> {
141
- await place(name, user, from, mode);
142
- jobs.delete(name);
143
- await repairGitLinks(user, from);
144
- log.info({ name, mode, user: user.name }, "agent-user: an entry from root's home now belongs to the agent user");
145
- }
146
-
147
- function wantedNames(names: unknown): string[] {
148
- if (!Array.isArray(names) || names.length === 0 || names.length > NAMES_MAX || !names.every((n) => typeof n === 'string'))
149
- throw new ApiError('names must be a list of entries from the list', 400);
150
- return [...new Set(names)];
151
- }
152
-
153
- export function modeOf(raw: unknown): MoveMode {
154
- if (raw === undefined || raw === 'copy') return 'copy';
155
- if (raw === 'move') return 'move';
156
- throw new ApiError('mode must be copy or move', 400);
157
- }
158
-
159
- function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUser): void {
160
- if (!listed.has(name)) throw new ApiError(`${name} is not an entry metro offers to move`, 400);
161
- const job = jobs.get(name)?.state;
162
- if (job === 'copying' || job === 'waiting') throw new ApiError(`${name} is already being moved`, 409);
163
- if (present(join(user.home, name))) throw new ApiError(`${name} already exists in ${user.home}`, 409);
164
- }
165
-
166
- export function startMove(names: unknown, user: AgentUser, mode: MoveMode = 'copy', from = homedir()): string[] {
167
- const wanted = wantedNames(names);
168
- const listed = new Set(candidates(from));
169
- for (const name of wanted) assertMovable(name, listed, user);
170
- if (mode === 'move' && !sameDisk(from, dirname(user.home))) throw new ApiError(`${from} is on another disk than ${user.home}; copy instead`, 400);
171
- for (const name of wanted) {
172
- jobs.set(name, { state: 'waiting', error: null });
173
- queue = queue.then(async () => {
174
- jobs.set(name, { state: 'copying', error: null });
175
- await moveOne(name, user, from, mode).catch((err: unknown) => {
176
- jobs.set(name, { state: 'failed', error: errMsg(err) });
177
- log.warn({ name, err: errMsg(err) }, 'agent-user: could not move an entry to the agent user');
178
- });
179
- });
180
- }
181
- return wanted;
182
- }