@stage-labs/metro 0.1.0-beta.189 → 0.1.0-beta.190
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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/api.ts +30 -16
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/git-links.ts +34 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +230 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/workspace.ts +76 -26
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.190",
|
|
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.
|
|
3
|
+
"version": "0.1.0-beta.190",
|
|
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",
|
|
@@ -7,10 +7,12 @@ import { log } from '@metro-labs/core/log';
|
|
|
7
7
|
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
8
8
|
import { agentsDir } from '../agents/files.js';
|
|
9
9
|
import { agentUser, CONFIG_FILE, forgetAgentUser, wantedAgentUser } from './user.js';
|
|
10
|
-
import { listWorkspace, startMove } from './workspace.js';
|
|
10
|
+
import { listWorkspace, modeOf, startMove } from './workspace.js';
|
|
11
|
+
import { changeSchedule, listSchedules } from './schedules.js';
|
|
11
12
|
|
|
12
13
|
const PATH = '/api/agent-user';
|
|
13
14
|
const WORKSPACE = '/api/agent-user/workspace';
|
|
15
|
+
const SCHEDULES = '/api/schedules';
|
|
14
16
|
const EXIT_DELAY_MS = 500;
|
|
15
17
|
const NAMES_BODY_MAX = 128 * 1024;
|
|
16
18
|
|
|
@@ -59,26 +61,38 @@ async function workspaceAnswer(req: IncomingMessage, dir: string): Promise<unkno
|
|
|
59
61
|
const user = agentUser(dir);
|
|
60
62
|
if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
|
|
61
63
|
if (req.method === 'GET') return { user: user.name, home: user.home, entries: listWorkspace(user) };
|
|
62
|
-
const
|
|
64
|
+
const body = await readJsonBody(req, NAMES_BODY_MAX);
|
|
65
|
+
const started = startMove(bodyField(body, 'names'), user, modeOf(bodyField(body, 'mode')));
|
|
63
66
|
return { started, entries: listWorkspace(user) };
|
|
64
67
|
}
|
|
65
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
|
+
|
|
66
89
|
export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
|
|
67
|
-
const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'] };
|
|
90
|
+
const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'], [SCHEDULES]: ['GET', 'POST'] };
|
|
68
91
|
return sessionRoute(req, res, { methods, admin: ['POST'], label: 'agent-user-api' }, async (session, path) => {
|
|
69
|
-
if (path === WORKSPACE) {
|
|
70
|
-
requireAdmin(session);
|
|
71
|
-
return workspaceAnswer(req, (deps.agents ?? agentsDir)());
|
|
72
|
-
}
|
|
73
|
-
if (req.method === 'GET') return agentUserStatus(deps);
|
|
74
|
-
const enabled = bodyField(await readJsonBody(req), 'enabled');
|
|
75
|
-
if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
|
|
76
|
-
const reason = unsupported((deps.host ?? realHost)());
|
|
77
|
-
if (enabled && reason !== null) throw new ApiError(reason, 400);
|
|
78
92
|
const dir = (deps.agents ?? agentsDir)();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
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);
|
|
83
97
|
});
|
|
84
98
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
|
@@ -8,6 +8,7 @@ import { agentsDir } from '../agents/files.js';
|
|
|
8
8
|
import { copyIntoHome } from './home-fs.js';
|
|
9
9
|
import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, lookupUser, wantedAgentUser, type AgentUser } from './user.js';
|
|
10
10
|
import { watchAgentView } from './view.js';
|
|
11
|
+
import { repairGitLinks } from './git-links.js';
|
|
11
12
|
|
|
12
13
|
const INSTALL_MS = 10 * 60_000;
|
|
13
14
|
const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
|
|
@@ -185,6 +186,9 @@ export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessE
|
|
|
185
186
|
await installClaude(user);
|
|
186
187
|
copyMarketplace(user, env);
|
|
187
188
|
watchAgentView();
|
|
189
|
+
repairGitLinks(user).catch((err: unknown) => {
|
|
190
|
+
log.warn({ err: errMsg(err) }, 'agent-user: could not repair git links');
|
|
191
|
+
});
|
|
188
192
|
return 'ready';
|
|
189
193
|
} catch (err) {
|
|
190
194
|
log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
8
|
+
import { log } from '@metro-labs/core/log';
|
|
9
|
+
import type { AgentUser } from './user.js';
|
|
10
|
+
|
|
11
|
+
const UNIT_DIR = '/etc/systemd/system';
|
|
12
|
+
const DROP_IN = '10-metro-agent.conf';
|
|
13
|
+
const OWN_UNITS = /^metro(-claude-\d+)?\.(service|scope)$/;
|
|
14
|
+
|
|
15
|
+
export type JobKind = 'timer' | 'cron-root' | 'cron-agent';
|
|
16
|
+
|
|
17
|
+
export interface ScheduledJob {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: JobKind;
|
|
20
|
+
name: string;
|
|
21
|
+
schedule: string;
|
|
22
|
+
command: string;
|
|
23
|
+
runsAs: string;
|
|
24
|
+
next: string | null;
|
|
25
|
+
last: string | null;
|
|
26
|
+
lastResult: string | null;
|
|
27
|
+
usesRoot: boolean;
|
|
28
|
+
converted: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Runner {
|
|
32
|
+
run: (file: string, args: string[], input?: string) => { status: number | null; stdout: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const realRunner: Runner = {
|
|
36
|
+
run: (file, args, input) => {
|
|
37
|
+
const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
|
|
38
|
+
return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ROOT_RE = /(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/;
|
|
43
|
+
export const usesRootHome = (text: string): boolean => ROOT_RE.test(text);
|
|
44
|
+
export const rehome = (text: string, home: string): string => text.replace(/(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/g, `$1${home}`);
|
|
45
|
+
|
|
46
|
+
const stamp = (micros: unknown): string | null =>
|
|
47
|
+
typeof micros === 'number' && micros > 0 ? new Date(micros / 1000).toISOString() : null;
|
|
48
|
+
|
|
49
|
+
function showProps(runner: Runner, unit: string, props: string[]): Record<string, string> {
|
|
50
|
+
const out = runner.run('systemctl', ['show', unit, '--no-pager', ...props.map((p) => `-p${p}`)]).stdout;
|
|
51
|
+
const found: Record<string, string> = {};
|
|
52
|
+
for (const line of out.split('\n')) {
|
|
53
|
+
const at = line.indexOf('=');
|
|
54
|
+
if (at > 0) found[line.slice(0, at)] = line.slice(at + 1);
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function execArgv(execStart: string): string[] {
|
|
60
|
+
const m = /argv\[\]=(.*?) ;/.exec(execStart);
|
|
61
|
+
return m?.[1] === undefined ? [] : m[1].split(' ').filter((a) => a !== '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const calendarOf = (timers: string): string =>
|
|
65
|
+
/OnCalendar=([^;]*?) ;/.exec(timers)?.[1]?.trim() ?? /(OnUnitActiveUSec|OnBootUSec|OnActiveUSec)=([^;]*?) ;/.exec(timers)?.[0]?.replace(/ ;$/, '') ?? '';
|
|
66
|
+
|
|
67
|
+
function listTimers(runner: Runner): Record<string, unknown>[] {
|
|
68
|
+
try {
|
|
69
|
+
const raw: unknown = JSON.parse(runner.run('systemctl', ['list-timers', '--all', '--output=json', '--no-pager']).stdout);
|
|
70
|
+
return Array.isArray(raw) ? raw.filter(isRecord) : [];
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
77
|
+
const orRoot = (user: string | undefined): string => (user === undefined || user === '' ? 'root' : user);
|
|
78
|
+
|
|
79
|
+
function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | null {
|
|
80
|
+
const unit = text(t.unit);
|
|
81
|
+
const service = text(t.activates);
|
|
82
|
+
if (unit === '' || service === '' || OWN_UNITS.test(service)) return null;
|
|
83
|
+
const timer = showProps(runner, unit, ['FragmentPath', 'TimersCalendar', 'TimersMonotonic']);
|
|
84
|
+
if (!text(timer.FragmentPath).startsWith(`${UNIT_DIR}/`)) return null;
|
|
85
|
+
const svc = showProps(runner, service, ['ExecStart', 'User', 'Result', 'DropInPaths', 'Environment', 'WorkingDirectory']);
|
|
86
|
+
const command = execArgv(text(svc.ExecStart)).join(' ');
|
|
87
|
+
return {
|
|
88
|
+
id: `timer:${unit}`,
|
|
89
|
+
kind: 'timer',
|
|
90
|
+
name: unit.replace(/\.timer$/, ''),
|
|
91
|
+
schedule: calendarOf(`${text(timer.TimersCalendar)} ${text(timer.TimersMonotonic)}`),
|
|
92
|
+
command,
|
|
93
|
+
runsAs: orRoot(svc.User),
|
|
94
|
+
next: stamp(t.next),
|
|
95
|
+
last: stamp(t.last),
|
|
96
|
+
lastResult: svc.Result ?? null,
|
|
97
|
+
usesRoot: usesRootHome(`${command} ${text(svc.Environment)} ${text(svc.WorkingDirectory)}`),
|
|
98
|
+
converted: text(svc.DropInPaths).includes(DROP_IN),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const timerJobs = (runner: Runner): ScheduledJob[] =>
|
|
103
|
+
listTimers(runner).flatMap((t) => {
|
|
104
|
+
const job = timerJob(runner, t);
|
|
105
|
+
return job === null ? [] : [job];
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export interface CronLine {
|
|
109
|
+
schedule: string;
|
|
110
|
+
command: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseCronLine(line: string): CronLine | null {
|
|
114
|
+
const text = line.trim();
|
|
115
|
+
if (text === '' || text.startsWith('#') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(text)) return null;
|
|
116
|
+
if (text.startsWith('@')) {
|
|
117
|
+
const [at = '', ...rest] = text.split(/\s+/);
|
|
118
|
+
return rest.length === 0 ? null : { schedule: at, command: rest.join(' ') };
|
|
119
|
+
}
|
|
120
|
+
const parts = text.split(/\s+/);
|
|
121
|
+
if (parts.length < 6) return null;
|
|
122
|
+
return { schedule: parts.slice(0, 5).join(' '), command: parts.slice(5).join(' ') };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lineId = (kind: JobKind, line: string): string => `${kind}:${createHash('sha256').update(line.trim()).digest('hex').slice(0, 16)}`;
|
|
126
|
+
|
|
127
|
+
function crontabOf(runner: Runner, user: string): string[] {
|
|
128
|
+
const out = runner.run('crontab', ['-l', '-u', user]);
|
|
129
|
+
return out.status === 0 ? out.stdout.split('\n') : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cronJobs(runner: Runner, user: string, kind: JobKind): ScheduledJob[] {
|
|
133
|
+
return crontabOf(runner, user).flatMap((line): ScheduledJob[] => {
|
|
134
|
+
const parsed = parseCronLine(line);
|
|
135
|
+
if (parsed === null) return [];
|
|
136
|
+
return [{
|
|
137
|
+
id: lineId(kind, line),
|
|
138
|
+
kind,
|
|
139
|
+
name: parsed.command.split(/\s+/)[0]?.split('/').pop() ?? 'cron',
|
|
140
|
+
schedule: parsed.schedule,
|
|
141
|
+
command: parsed.command,
|
|
142
|
+
runsAs: user,
|
|
143
|
+
next: null,
|
|
144
|
+
last: null,
|
|
145
|
+
lastResult: null,
|
|
146
|
+
usesRoot: usesRootHome(parsed.command),
|
|
147
|
+
converted: false,
|
|
148
|
+
}];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function listSchedules(user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] {
|
|
153
|
+
return [...timerJobs(runner), ...cronJobs(runner, 'root', 'cron-root'), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const quote = (arg: string): string => `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
157
|
+
|
|
158
|
+
export function dropInText(user: AgentUser, argv: string[], environment: string): string {
|
|
159
|
+
const env = environment.split(' ').filter((pair) => pair.includes('=') && !pair.startsWith('HOME=') && !pair.startsWith('PATH='));
|
|
160
|
+
return [
|
|
161
|
+
'[Service]',
|
|
162
|
+
`User=${user.name}`,
|
|
163
|
+
`Group=${String(user.gid)}`,
|
|
164
|
+
`WorkingDirectory=${user.home}`,
|
|
165
|
+
`Environment=HOME=${user.home}`,
|
|
166
|
+
`Environment=PATH=${user.home}/.local/bin:/usr/local/bin:/usr/bin:/bin`,
|
|
167
|
+
...env.map((pair) => `Environment=${quote(rehome(pair, user.home))}`),
|
|
168
|
+
'ExecStart=',
|
|
169
|
+
`ExecStart=${argv.map((a) => quote(rehome(a, user.home))).join(' ')}`,
|
|
170
|
+
'',
|
|
171
|
+
].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void {
|
|
175
|
+
const unit = job.id.slice('timer:'.length);
|
|
176
|
+
const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
|
|
177
|
+
const svc = showProps(runner, service, ['ExecStart', 'Environment']);
|
|
178
|
+
const argv = execArgv(svc.ExecStart ?? '');
|
|
179
|
+
if (argv.length === 0) throw new ApiError('that timer runs no command metro can read', 400);
|
|
180
|
+
const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !existsSync(a));
|
|
181
|
+
if (missing !== undefined) throw new ApiError(`${missing} does not exist; move it to the agent first`, 409);
|
|
182
|
+
const dir = join(UNIT_DIR, `${service}.d`);
|
|
183
|
+
mkdirSync(dir, { recursive: true });
|
|
184
|
+
writeFileSync(join(dir, DROP_IN), dropInText(user, argv, svc.Environment ?? ''), { mode: 0o644 });
|
|
185
|
+
runner.run('systemctl', ['daemon-reload']);
|
|
186
|
+
log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
|
|
187
|
+
}
|
|
188
|
+
|
|
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
|
+
function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
|
|
198
|
+
const lines = crontabOf(runner, 'root');
|
|
199
|
+
const line = lines.find((l) => lineId('cron-root', l) === job.id);
|
|
200
|
+
if (line === undefined) throw new ApiError('that cron line is gone', 404);
|
|
201
|
+
mkdirSync(backups, { recursive: true });
|
|
202
|
+
writeFileSync(join(backups, `crontab-root.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`), lines.join('\n'), { mode: 0o600 });
|
|
203
|
+
const agentLines = crontabOf(runner, user.name).filter((l) => l !== '');
|
|
204
|
+
const moved = runner.run('crontab', ['-u', user.name, '-'], `${[...agentLines, rehome(line, user.home)].join('\n')}\n`);
|
|
205
|
+
if (moved.status !== 0) throw new ApiError("the agent's crontab refused the line", 500);
|
|
206
|
+
const kept = lines.filter((l) => l !== line && l !== '');
|
|
207
|
+
runner.run('crontab', ['-u', 'root', '-'], `${kept.join('\n')}\n`);
|
|
208
|
+
log.info({ user: user.name }, "schedules: a root cron line moved to the agent's crontab");
|
|
209
|
+
}
|
|
210
|
+
|
|
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);
|
|
218
|
+
if (job.kind === 'timer') convertTimer(job, user, runner);
|
|
219
|
+
else if (job.kind === 'cron-root') convertCron(job, user, backups, runner);
|
|
220
|
+
else throw new ApiError('that job already runs as the agent', 400);
|
|
221
|
+
}
|
|
222
|
+
|
|
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);
|
|
226
|
+
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);
|
|
229
|
+
return listSchedules(user, runner);
|
|
230
|
+
}
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { lstatSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, lstatSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
6
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
-
import
|
|
7
|
+
import { repairGitLinks } from './git-links.js';
|
|
8
|
+
import { asUser, type AgentUser } from './user.js';
|
|
8
9
|
|
|
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
|
+
];
|
|
10
26
|
const SIZE_MS = 2_000;
|
|
11
27
|
const SIZES_BUDGET_MS = 6_000;
|
|
12
28
|
const NAMES_MAX = 1_000;
|
|
13
29
|
|
|
14
30
|
export type EntryState = 'here' | 'waiting' | 'copying' | 'moved' | 'failed';
|
|
31
|
+
export type MoveMode = 'copy' | 'move';
|
|
15
32
|
|
|
16
33
|
export interface WorkspaceEntry {
|
|
17
34
|
name: string;
|
|
18
35
|
kind: 'folder' | 'file' | 'link';
|
|
36
|
+
hidden: boolean;
|
|
19
37
|
bytes: number | null;
|
|
20
38
|
state: EntryState;
|
|
21
39
|
error: string | null;
|
|
@@ -29,8 +47,6 @@ interface Job {
|
|
|
29
47
|
const jobs = new Map<string, Job>();
|
|
30
48
|
let queue: Promise<void> = Promise.resolve();
|
|
31
49
|
|
|
32
|
-
const offered = (name: string): boolean => !name.startsWith('.') && !SKIPPED.has(name) && !name.includes('/');
|
|
33
|
-
|
|
34
50
|
function kindOf(path: string): WorkspaceEntry['kind'] | null {
|
|
35
51
|
try {
|
|
36
52
|
const stat = lstatSync(path);
|
|
@@ -42,13 +58,19 @@ function kindOf(path: string): WorkspaceEntry['kind'] | null {
|
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
const present = (path: string): boolean => kindOf(path) !== null;
|
|
62
|
+
|
|
45
63
|
function sizeOf(path: string): number | null {
|
|
46
64
|
const run = spawnSync('du', ['-sk', path], { encoding: 'utf8', timeout: SIZE_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
47
65
|
const kb = Number(run.stdout.split('\t')[0]);
|
|
48
66
|
return run.status === 0 && Number.isFinite(kb) ? kb * 1024 : null;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
|
|
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
|
+
}
|
|
52
74
|
|
|
53
75
|
function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' | 'error'> {
|
|
54
76
|
const job = jobs.get(name);
|
|
@@ -58,16 +80,13 @@ function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' |
|
|
|
58
80
|
|
|
59
81
|
export function listWorkspace(user: AgentUser, from = homedir(), now = Date.now): WorkspaceEntry[] {
|
|
60
82
|
const until = now() + SIZES_BUDGET_MS;
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const bytes = kind === 'file' ? lstatSync(path).size : kind === 'folder' && now() < until ? sizeOf(path) : null;
|
|
69
|
-
return [{ name, kind, bytes, ...stateOf(name, user) }];
|
|
70
|
-
});
|
|
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
|
+
});
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
function run(file: string, args: string[]): Promise<void> {
|
|
@@ -85,28 +104,58 @@ function run(file: string, args: string[]): Promise<void> {
|
|
|
85
104
|
});
|
|
86
105
|
}
|
|
87
106
|
|
|
88
|
-
|
|
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);
|
|
89
123
|
const staging = mkdtempSync(join(user.home, '..', '.metro-move-'));
|
|
124
|
+
const staged = join(staging, 'entry');
|
|
90
125
|
try {
|
|
91
|
-
|
|
92
|
-
await run('cp', ['-a', '--',
|
|
126
|
+
if (mode === 'move') await run('mv', ['-T', '--', source, staged]);
|
|
127
|
+
else await run('cp', ['-a', '--', source, staged]);
|
|
93
128
|
await run('chown', ['-hR', `${String(user.uid)}:${String(user.gid)}`, staged]);
|
|
94
|
-
|
|
129
|
+
makeParents(user, target);
|
|
95
130
|
if (present(target)) throw new Error(`${target} already exists; move or remove it first`);
|
|
96
131
|
await run('mv', ['-T', '--', staged, target]);
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (mode === 'move') await putBack(staged, source);
|
|
134
|
+
throw err;
|
|
99
135
|
} finally {
|
|
100
136
|
rmSync(staging, { recursive: true, force: true });
|
|
101
137
|
}
|
|
102
138
|
}
|
|
103
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
|
+
|
|
104
147
|
function wantedNames(names: unknown): string[] {
|
|
105
148
|
if (!Array.isArray(names) || names.length === 0 || names.length > NAMES_MAX || !names.every((n) => typeof n === 'string'))
|
|
106
149
|
throw new ApiError('names must be a list of entries from the list', 400);
|
|
107
150
|
return [...new Set(names)];
|
|
108
151
|
}
|
|
109
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
|
+
|
|
110
159
|
function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUser): void {
|
|
111
160
|
if (!listed.has(name)) throw new ApiError(`${name} is not an entry metro offers to move`, 400);
|
|
112
161
|
const job = jobs.get(name)?.state;
|
|
@@ -114,17 +163,18 @@ function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUse
|
|
|
114
163
|
if (present(join(user.home, name))) throw new ApiError(`${name} already exists in ${user.home}`, 409);
|
|
115
164
|
}
|
|
116
165
|
|
|
117
|
-
export function startMove(names: unknown, user: AgentUser, from = homedir()): string[] {
|
|
166
|
+
export function startMove(names: unknown, user: AgentUser, mode: MoveMode = 'copy', from = homedir()): string[] {
|
|
118
167
|
const wanted = wantedNames(names);
|
|
119
|
-
const listed = new Set(
|
|
168
|
+
const listed = new Set(candidates(from));
|
|
120
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);
|
|
121
171
|
for (const name of wanted) {
|
|
122
172
|
jobs.set(name, { state: 'waiting', error: null });
|
|
123
173
|
queue = queue.then(async () => {
|
|
124
174
|
jobs.set(name, { state: 'copying', error: null });
|
|
125
|
-
await moveOne(name, user, from).catch((err: unknown) => {
|
|
175
|
+
await moveOne(name, user, from, mode).catch((err: unknown) => {
|
|
126
176
|
jobs.set(name, { state: 'failed', error: errMsg(err) });
|
|
127
|
-
log.warn({ name, err: errMsg(err) }, 'agent-user: could not move
|
|
177
|
+
log.warn({ name, err: errMsg(err) }, 'agent-user: could not move an entry to the agent user');
|
|
128
178
|
});
|
|
129
179
|
});
|
|
130
180
|
}
|
package/runtime/runtime.json
CHANGED