@stage-labs/metro 0.1.0-beta.186 → 0.1.0-beta.187
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 +17 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +62 -5
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/workspace.ts +124 -0
- 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.187",
|
|
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.187",
|
|
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",
|
|
@@ -2,13 +2,15 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
5
|
+
import { bodyField, readJsonBody, requireAdmin, sessionRoute } from '@metro-labs/http/api-http';
|
|
6
6
|
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
11
|
|
|
11
12
|
const PATH = '/api/agent-user';
|
|
13
|
+
const WORKSPACE = '/api/agent-user/workspace';
|
|
12
14
|
const EXIT_DELAY_MS = 500;
|
|
13
15
|
|
|
14
16
|
export interface AgentUserApiDeps {
|
|
@@ -52,8 +54,21 @@ function setWanted(dir: string, enabled: boolean): void {
|
|
|
52
54
|
forgetAgentUser();
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
async function workspaceAnswer(req: IncomingMessage, dir: string): Promise<unknown> {
|
|
58
|
+
const user = agentUser(dir);
|
|
59
|
+
if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
|
|
60
|
+
if (req.method === 'GET') return { user: user.name, home: user.home, entries: listWorkspace(user) };
|
|
61
|
+
const started = startMove(bodyField(await readJsonBody(req), 'names'), user);
|
|
62
|
+
return { started, entries: listWorkspace(user) };
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
|
|
56
|
-
|
|
66
|
+
const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'] };
|
|
67
|
+
return sessionRoute(req, res, { methods, admin: ['POST'], label: 'agent-user-api' }, async (session, path) => {
|
|
68
|
+
if (path === WORKSPACE) {
|
|
69
|
+
requireAdmin(session);
|
|
70
|
+
return workspaceAnswer(req, (deps.agents ?? agentsDir)());
|
|
71
|
+
}
|
|
57
72
|
if (req.method === 'GET') return agentUserStatus(deps);
|
|
58
73
|
const enabled = bodyField(await readJsonBody(req), 'enabled');
|
|
59
74
|
if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { agentsDir } from '../agents/files.js';
|
|
8
8
|
import { copyIntoHome } from './home-fs.js';
|
|
9
|
-
import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, wantedAgentUser, type AgentUser } from './user.js';
|
|
9
|
+
import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, lookupUser, wantedAgentUser, type AgentUser } from './user.js';
|
|
10
10
|
import { watchAgentView } from './view.js';
|
|
11
11
|
|
|
12
12
|
const INSTALL_MS = 10 * 60_000;
|
|
@@ -50,6 +50,17 @@ function moveProject(user: AgentUser, from: string): void {
|
|
|
50
50
|
if (existsSync(old) && absent(next)) run('mv', ['-T', old, next]);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const stamp = (now: Date): string => now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
|
|
54
|
+
|
|
55
|
+
const kindIs = (path: string, want: 'dir' | 'file'): boolean => {
|
|
56
|
+
try {
|
|
57
|
+
const st = lstatSync(path);
|
|
58
|
+
return want === 'dir' ? st.isDirectory() : st.isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
53
64
|
function migrateClaude(user: AgentUser, dir: string): void {
|
|
54
65
|
const marker = join(dir, MIGRATED);
|
|
55
66
|
if (existsSync(marker)) return;
|
|
@@ -58,13 +69,14 @@ function migrateClaude(user: AgentUser, dir: string): void {
|
|
|
58
69
|
for (const name of ['.claude', '.claude.json']) {
|
|
59
70
|
const from = join(home, name);
|
|
60
71
|
const to = join(user.home, name);
|
|
61
|
-
if (!existsSync(from)
|
|
72
|
+
if (!existsSync(from)) continue;
|
|
73
|
+
if (!absent(to)) run('mv', ['-T', to, `${to}.before-root-${stamp(new Date())}`]);
|
|
62
74
|
run('cp', ['-aT', from, to]);
|
|
63
75
|
run('chown', ['-hR', `${user.name}:${String(user.gid)}`, to]);
|
|
64
76
|
copied.push(name);
|
|
65
77
|
}
|
|
66
78
|
if (copied.includes('.claude')) moveProject(user, home);
|
|
67
|
-
|
|
79
|
+
writeFileSync(marker, `${user.name}\n`, { mode: 0o600 });
|
|
68
80
|
log.info({ user: user.name, copied }, 'agent-user: moved the Claude Code folder and login to the agent user once; root keeps its copy');
|
|
69
81
|
}
|
|
70
82
|
|
|
@@ -113,9 +125,54 @@ function stopRootSession(): void {
|
|
|
113
125
|
log.info('agent-user: stopped the Claude session root was running; it comes back as the agent user');
|
|
114
126
|
}
|
|
115
127
|
|
|
128
|
+
function stopAgentSessions(user: AgentUser): void {
|
|
129
|
+
spawnSync(...asUser(user, 'tmux', ['kill-server']), { stdio: 'ignore' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function takeBack(user: AgentUser, now: Date): string[] {
|
|
133
|
+
const home = homedir();
|
|
134
|
+
const taken: string[] = [];
|
|
135
|
+
for (const [name, kind] of [['.claude', 'dir'], ['.claude.json', 'file']] as const) {
|
|
136
|
+
const from = join(user.home, name);
|
|
137
|
+
if (!kindIs(from, kind)) continue;
|
|
138
|
+
const to = join(home, name);
|
|
139
|
+
if (!absent(to)) run('mv', ['-T', to, `${to}.before-agent-${stamp(now)}`]);
|
|
140
|
+
run('cp', ['-aT', from, to]);
|
|
141
|
+
run('chown', ['-hR', 'root:root', to]);
|
|
142
|
+
taken.push(name);
|
|
143
|
+
}
|
|
144
|
+
if (taken.includes('.claude')) {
|
|
145
|
+
const projects = join(home, '.claude', 'projects');
|
|
146
|
+
const old = join(projects, encodedCwd(user.home));
|
|
147
|
+
const next = join(projects, encodedCwd(home));
|
|
148
|
+
if (existsSync(old) && absent(next)) run('mv', ['-T', old, next]);
|
|
149
|
+
}
|
|
150
|
+
return taken;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function returnToRoot(dir = agentsDir(), now = new Date()): string[] | null {
|
|
154
|
+
const marker = join(dir, MIGRATED);
|
|
155
|
+
if (process.platform !== 'linux' || process.getuid?.() !== 0 || !existsSync(marker)) return null;
|
|
156
|
+
const name = readFileSync(marker, 'utf8').trim() || 'agent';
|
|
157
|
+
const user = lookupUser(name);
|
|
158
|
+
if (user === null) return null;
|
|
159
|
+
stopAgentSessions(user);
|
|
160
|
+
const taken = takeBack(user, now);
|
|
161
|
+
rmSync(marker);
|
|
162
|
+
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');
|
|
163
|
+
return taken;
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
|
|
117
167
|
const name = wantedAgentUser(dir);
|
|
118
|
-
if (name === null)
|
|
168
|
+
if (name === null) {
|
|
169
|
+
try {
|
|
170
|
+
returnToRoot(dir);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
log.error({ err: errMsg(err) }, 'agent-user: could not bring the Claude folder back to root; the agent user keeps it, nothing was deleted');
|
|
173
|
+
}
|
|
174
|
+
return 'off';
|
|
175
|
+
}
|
|
119
176
|
if (process.platform !== 'linux' || process.getuid?.() !== 0) {
|
|
120
177
|
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');
|
|
121
178
|
return 'unsupported';
|
|
@@ -21,7 +21,7 @@ export interface UserHost {
|
|
|
21
21
|
lookup: (name: string) => AgentUser | null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function lookupUser(name: string): AgentUser | null {
|
|
24
|
+
export function lookupUser(name: string): AgentUser | null {
|
|
25
25
|
const run = spawnSync('getent', ['passwd', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
26
26
|
if (run.error !== undefined || run.status !== 0) return null;
|
|
27
27
|
const [, , uid = '', gid = '', , home = ''] = run.stdout.trim().split(':');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
+
import type { AgentUser } from './user.js';
|
|
8
|
+
|
|
9
|
+
const SKIPPED = new Set(['snap', 'lost+found']);
|
|
10
|
+
const SIZE_MS = 4_000;
|
|
11
|
+
const NAMES_MAX = 100;
|
|
12
|
+
|
|
13
|
+
export type EntryState = 'here' | 'copying' | 'moved' | 'failed';
|
|
14
|
+
|
|
15
|
+
export interface WorkspaceEntry {
|
|
16
|
+
name: string;
|
|
17
|
+
kind: 'folder' | 'file' | 'link';
|
|
18
|
+
bytes: number | null;
|
|
19
|
+
state: EntryState;
|
|
20
|
+
error: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Job {
|
|
24
|
+
state: 'copying' | 'failed';
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const jobs = new Map<string, Job>();
|
|
29
|
+
|
|
30
|
+
const offered = (name: string): boolean => !name.startsWith('.') && !SKIPPED.has(name) && !name.includes('/');
|
|
31
|
+
|
|
32
|
+
function kindOf(path: string): WorkspaceEntry['kind'] | null {
|
|
33
|
+
try {
|
|
34
|
+
const stat = lstatSync(path);
|
|
35
|
+
if (stat.isSymbolicLink()) return 'link';
|
|
36
|
+
if (stat.isDirectory()) return 'folder';
|
|
37
|
+
return stat.isFile() ? 'file' : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sizeOf(path: string): number | null {
|
|
44
|
+
const run = spawnSync('du', ['-sk', path], { encoding: 'utf8', timeout: SIZE_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
45
|
+
const kb = Number(run.stdout.split('\t')[0]);
|
|
46
|
+
return run.status === 0 && Number.isFinite(kb) ? kb * 1024 : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const present = (path: string): boolean => kindOf(path) !== null;
|
|
50
|
+
|
|
51
|
+
function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' | 'error'> {
|
|
52
|
+
const job = jobs.get(name);
|
|
53
|
+
if (job !== undefined) return { state: job.state, error: job.error };
|
|
54
|
+
return { state: present(join(user.home, name)) ? 'moved' : 'here', error: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listWorkspace(user: AgentUser, from = homedir()): WorkspaceEntry[] {
|
|
58
|
+
return readdirSync(from)
|
|
59
|
+
.filter(offered)
|
|
60
|
+
.flatMap((name): WorkspaceEntry[] => {
|
|
61
|
+
const path = join(from, name);
|
|
62
|
+
const kind = kindOf(path);
|
|
63
|
+
if (kind === null) return [];
|
|
64
|
+
return [{ name, kind, bytes: kind === 'link' ? null : sizeOf(path), ...stateOf(name, user) }];
|
|
65
|
+
})
|
|
66
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function run(file: string, args: string[]): Promise<void> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(file, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
72
|
+
let said = '';
|
|
73
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
74
|
+
said = `${said}${chunk.toString('utf8')}`.slice(-1000);
|
|
75
|
+
});
|
|
76
|
+
child.on('error', reject);
|
|
77
|
+
child.on('close', (code) => {
|
|
78
|
+
if (code === 0) resolve();
|
|
79
|
+
else reject(new Error(`${file}: ${said.trim() || `exit ${String(code)}`}`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function moveOne(name: string, user: AgentUser, from: string): Promise<void> {
|
|
85
|
+
const staging = mkdtempSync(join(user.home, '..', '.metro-move-'));
|
|
86
|
+
try {
|
|
87
|
+
const staged = join(staging, name);
|
|
88
|
+
await run('cp', ['-a', '--', join(from, name), staged]);
|
|
89
|
+
await run('chown', ['-hR', `${String(user.uid)}:${String(user.gid)}`, staged]);
|
|
90
|
+
const target = join(user.home, name);
|
|
91
|
+
if (present(target)) throw new Error(`${target} already exists; move or remove it first`);
|
|
92
|
+
await run('mv', ['-T', '--', staged, target]);
|
|
93
|
+
jobs.delete(name);
|
|
94
|
+
log.info({ name, user: user.name }, 'agent-user: moved a folder from root\'s home to the agent user');
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(staging, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function wantedNames(names: unknown): string[] {
|
|
101
|
+
if (!Array.isArray(names) || names.length === 0 || names.length > NAMES_MAX || !names.every((n) => typeof n === 'string'))
|
|
102
|
+
throw new ApiError('names must be a list of entries from the list', 400);
|
|
103
|
+
return [...new Set(names)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUser): void {
|
|
107
|
+
if (!listed.has(name)) throw new ApiError(`${name} is not an entry metro offers to move`, 400);
|
|
108
|
+
if (jobs.get(name)?.state === 'copying') throw new ApiError(`${name} is already being moved`, 409);
|
|
109
|
+
if (present(join(user.home, name))) throw new ApiError(`${name} already exists in ${user.home}`, 409);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function startMove(names: unknown, user: AgentUser, from = homedir()): string[] {
|
|
113
|
+
const wanted = wantedNames(names);
|
|
114
|
+
const listed = new Set(readdirSync(from).filter(offered));
|
|
115
|
+
for (const name of wanted) assertMovable(name, listed, user);
|
|
116
|
+
for (const name of wanted) {
|
|
117
|
+
jobs.set(name, { state: 'copying', error: null });
|
|
118
|
+
moveOne(name, user, from).catch((err: unknown) => {
|
|
119
|
+
jobs.set(name, { state: 'failed', error: errMsg(err) });
|
|
120
|
+
log.warn({ name, err: errMsg(err) }, 'agent-user: could not move a folder to the agent user');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return wanted;
|
|
124
|
+
}
|
package/runtime/runtime.json
CHANGED