@stage-labs/metro 0.1.0-beta.182 → 0.1.0-beta.184
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 +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +113 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +136 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +92 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +87 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +9 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -10
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +29 -29
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +31 -9
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +6 -6
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +5 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +3 -2
- 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.184",
|
|
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.184",
|
|
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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
7
|
+
import { agentUser, asUser, type AgentUser } from './user.js';
|
|
8
|
+
|
|
9
|
+
const WRITE = [
|
|
10
|
+
'set -e',
|
|
11
|
+
'mkdir -p -- "$(dirname -- "$1")"',
|
|
12
|
+
'tmp=$(mktemp -- "$1.metro-XXXXXX")',
|
|
13
|
+
'trap \'rm -f -- "$tmp"\' EXIT',
|
|
14
|
+
'cat > "$tmp"',
|
|
15
|
+
'chmod -- "$2" "$tmp"',
|
|
16
|
+
'if [ -n "$3" ]; then touch -d "@$3" -- "$tmp"; fi',
|
|
17
|
+
'mv -f -- "$tmp" "$1"',
|
|
18
|
+
'trap - EXIT',
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
const octal = (mode: number): string => mode.toString(8);
|
|
22
|
+
const epoch = (mtime: Date | undefined): string => (mtime === undefined ? '' : String(Math.floor(mtime.getTime() / 1000)));
|
|
23
|
+
|
|
24
|
+
function runAs(user: AgentUser, script: string, args: string[], input?: string): void {
|
|
25
|
+
const [file, argv] = asUser(user, 'sh', ['-c', script, 'metro', ...args]);
|
|
26
|
+
const run = spawnSync(file, argv, { input, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
|
|
27
|
+
if (run.error !== undefined) throw run.error;
|
|
28
|
+
if (run.status !== 0) throw new Error(`as ${user.name}: ${run.stderr.trim() || `exit ${String(run.status)}`}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeHomeText(path: string, text: string, mode = 0o644, mtime?: Date, user = agentUser()): void {
|
|
32
|
+
if (user !== null) {
|
|
33
|
+
runAs(user, WRITE, [path, octal(mode), epoch(mtime)], text);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
writeAtomic(path, text, mode);
|
|
37
|
+
if (mtime !== undefined) utimesSync(path, mtime, mtime);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeHomeInPlace(path: string, text: string, mode = 0o644, user = agentUser()): void {
|
|
41
|
+
if (user !== null) {
|
|
42
|
+
runAs(user, 'mkdir -p -- "$(dirname -- "$1")"; cat > "$1"; chmod -- "$2" "$1"', [path, octal(mode)], text);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
writeFileSync(path, text, { mode });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function filesUnder(root: string, rel = ''): string[] {
|
|
50
|
+
return readdirSync(join(root, rel), { withFileTypes: true }).flatMap((entry) => {
|
|
51
|
+
const inner = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
52
|
+
if (entry.isDirectory()) return filesUnder(root, inner);
|
|
53
|
+
return entry.isFile() ? [inner] : [];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function copyIntoHome(from: string, to: string, user = agentUser()): number {
|
|
58
|
+
removeHome(to, true, user);
|
|
59
|
+
const files = filesUnder(from);
|
|
60
|
+
for (const rel of files) {
|
|
61
|
+
const source = join(from, rel);
|
|
62
|
+
writeHomeText(join(to, rel), readFileSync(source, 'utf8'), statSync(source).mode & 0o777, undefined, user);
|
|
63
|
+
}
|
|
64
|
+
return files.length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function removeHome(path: string, recursive = false, user = agentUser()): void {
|
|
68
|
+
if (user !== null) {
|
|
69
|
+
runAs(user, recursive ? 'rm -rf -- "$1"' : 'rm -f -- "$1"', [path]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
rmSync(path, { recursive, force: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function moveHome(from: string, to: string, user = agentUser()): void {
|
|
76
|
+
if (user !== null) {
|
|
77
|
+
runAs(user, 'mv -f -- "$1" "$2"', [from, to]);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
renameSync(from, to);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function receiveAs(user: AgentUser, path: string, source: Readable, mode: number): Promise<void> {
|
|
84
|
+
const [file, argv] = asUser(user, 'sh', ['-c', WRITE, 'metro', path, octal(mode), '']);
|
|
85
|
+
const child = spawn(file, argv, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
86
|
+
let stderr = '';
|
|
87
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
88
|
+
stderr += chunk.toString('utf8');
|
|
89
|
+
});
|
|
90
|
+
const closed = new Promise<number | null>((resolve, reject) => {
|
|
91
|
+
child.on('error', reject);
|
|
92
|
+
child.on('close', resolve);
|
|
93
|
+
});
|
|
94
|
+
await pipeline(source, child.stdin);
|
|
95
|
+
const status = await closed;
|
|
96
|
+
if (status !== 0) throw new Error(`as ${user.name}: ${stderr.trim() || `exit ${String(status)}`}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function receiveHomeFile(path: string, source: Readable, mode = 0o600, user = agentUser()): Promise<void> {
|
|
100
|
+
if (user !== null) {
|
|
101
|
+
await receiveAs(user, path, source, mode);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
105
|
+
const tmp = `${path}.metro-${String(process.pid)}.part`;
|
|
106
|
+
try {
|
|
107
|
+
await pipeline(source, createWriteStream(tmp, { mode }));
|
|
108
|
+
renameSync(tmp, path);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
rmSync(tmp, { force: true });
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
|
+
import { agentsDir } from '../agents/files.js';
|
|
8
|
+
import { copyIntoHome } from './home-fs.js';
|
|
9
|
+
import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, wantedAgentUser, type AgentUser } from './user.js';
|
|
10
|
+
import { watchAgentView } from './view.js';
|
|
11
|
+
|
|
12
|
+
const INSTALL_MS = 10 * 60_000;
|
|
13
|
+
const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
|
|
14
|
+
const MIGRATED = '.agent-user-migrated';
|
|
15
|
+
|
|
16
|
+
export type Provisioned = 'off' | 'unsupported' | 'failed' | 'ready';
|
|
17
|
+
|
|
18
|
+
function run(file: string, args: string[]): void {
|
|
19
|
+
const done = spawnSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
+
if (done.error !== undefined) throw done.error;
|
|
21
|
+
if (done.status !== 0) throw new Error(`${file} ${args.join(' ')}: ${done.stderr.trim() || `exit ${String(done.status)}`}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureUser(name: string): AgentUser | null {
|
|
25
|
+
if (agentUser() === null) {
|
|
26
|
+
run('useradd', ['--create-home', '--shell', '/bin/bash', name]);
|
|
27
|
+
log.info({ user: name }, 'agent-user: created the user Claude Code runs as');
|
|
28
|
+
forgetAgentUser();
|
|
29
|
+
}
|
|
30
|
+
const user = agentUser();
|
|
31
|
+
if (user !== null) run('chmod', ['700', user.home]);
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const absent = (path: string): boolean => {
|
|
36
|
+
try {
|
|
37
|
+
lstatSync(path);
|
|
38
|
+
return false;
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
|
|
45
|
+
|
|
46
|
+
function moveProject(user: AgentUser, from: string): void {
|
|
47
|
+
const projects = join(user.home, '.claude', 'projects');
|
|
48
|
+
const old = join(projects, encodedCwd(from));
|
|
49
|
+
const next = join(projects, encodedCwd(user.home));
|
|
50
|
+
if (existsSync(old) && absent(next)) run('mv', ['-T', old, next]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function migrateClaude(user: AgentUser, dir: string): void {
|
|
54
|
+
const marker = join(dir, MIGRATED);
|
|
55
|
+
if (existsSync(marker)) return;
|
|
56
|
+
const home = homedir();
|
|
57
|
+
const copied: string[] = [];
|
|
58
|
+
for (const name of ['.claude', '.claude.json']) {
|
|
59
|
+
const from = join(home, name);
|
|
60
|
+
const to = join(user.home, name);
|
|
61
|
+
if (!existsSync(from) || !absent(to)) continue;
|
|
62
|
+
run('cp', ['-aT', from, to]);
|
|
63
|
+
run('chown', ['-hR', `${user.name}:${String(user.gid)}`, to]);
|
|
64
|
+
copied.push(name);
|
|
65
|
+
}
|
|
66
|
+
if (copied.includes('.claude')) moveProject(user, home);
|
|
67
|
+
run('touch', [marker]);
|
|
68
|
+
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
|
+
}
|
|
70
|
+
|
|
71
|
+
function installClaude(user: AgentUser): Promise<void> {
|
|
72
|
+
if (existsSync(claudeBin(user))) return Promise.resolve();
|
|
73
|
+
const [file, args] = asUser(user, 'bash', ['-c', INSTALLER]);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(file, args, { stdio: ['ignore', 'ignore', 'pipe'], cwd: '/', timeout: INSTALL_MS });
|
|
76
|
+
let said = '';
|
|
77
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
78
|
+
said = `${said}${chunk.toString('utf8')}`.slice(-2000);
|
|
79
|
+
});
|
|
80
|
+
child.on('error', reject);
|
|
81
|
+
child.on('close', (code) => {
|
|
82
|
+
if (code === 0 && existsSync(claudeBin(user))) {
|
|
83
|
+
log.info({ user: user.name }, 'agent-user: installed Claude Code for the agent user');
|
|
84
|
+
resolve();
|
|
85
|
+
} else reject(new Error(`the Claude Code installer failed: ${said.trim() || `exit ${String(code)}`}`));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pluginVersion = (dir: string): string | null => {
|
|
91
|
+
try {
|
|
92
|
+
const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
93
|
+
return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
|
|
100
|
+
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
101
|
+
const from = join(store, 'marketplace');
|
|
102
|
+
if (store === '' || !existsSync(join(from, '.claude-plugin', 'marketplace.json'))) return;
|
|
103
|
+
const to = agentMarketplaceDir(user);
|
|
104
|
+
if (pluginVersion(from) !== null && pluginVersion(from) === pluginVersion(to)) return;
|
|
105
|
+
const files = copyIntoHome(from, to, user);
|
|
106
|
+
log.info({ files, version: pluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stopRootSession(): void {
|
|
110
|
+
const running = spawnSync('tmux', ['has-session', '-t', 'metro'], { stdio: 'ignore' });
|
|
111
|
+
if (running.error !== undefined || running.status !== 0) return;
|
|
112
|
+
spawnSync('tmux', ['kill-session', '-t', 'metro'], { stdio: 'ignore' });
|
|
113
|
+
log.info('agent-user: stopped the Claude session root was running; it comes back as the agent user');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
|
|
117
|
+
const name = wantedAgentUser(dir);
|
|
118
|
+
if (name === null) return 'off';
|
|
119
|
+
if (process.platform !== 'linux' || process.getuid?.() !== 0) {
|
|
120
|
+
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
|
+
return 'unsupported';
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const user = ensureUser(name);
|
|
125
|
+
if (user === null) throw new Error(`the user ${name} could not be found after creating it`);
|
|
126
|
+
stopRootSession();
|
|
127
|
+
migrateClaude(user, dir);
|
|
128
|
+
await installClaude(user);
|
|
129
|
+
copyMarketplace(user, env);
|
|
130
|
+
watchAgentView();
|
|
131
|
+
return 'ready';
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
|
|
134
|
+
return 'failed';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
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
|
+
|
|
7
|
+
export const CONFIG_FILE = 'agent-user.json';
|
|
8
|
+
const NAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
9
|
+
const AGENT_PATH = ['.local/bin', '/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'];
|
|
10
|
+
|
|
11
|
+
export interface AgentUser {
|
|
12
|
+
name: string;
|
|
13
|
+
uid: number;
|
|
14
|
+
gid: number;
|
|
15
|
+
home: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UserHost {
|
|
19
|
+
platform: string;
|
|
20
|
+
uid: number | undefined;
|
|
21
|
+
lookup: (name: string) => AgentUser | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lookupUser(name: string): AgentUser | null {
|
|
25
|
+
const run = spawnSync('getent', ['passwd', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
26
|
+
if (run.error !== undefined || run.status !== 0) return null;
|
|
27
|
+
const [, , uid = '', gid = '', , home = ''] = run.stdout.trim().split(':');
|
|
28
|
+
const u = Number(uid);
|
|
29
|
+
const g = Number(gid);
|
|
30
|
+
return Number.isInteger(u) && u > 0 && Number.isInteger(g) && home.startsWith('/') ? { name, uid: u, gid: g, home } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const realHost: UserHost = { platform: process.platform, uid: process.getuid?.(), lookup: lookupUser };
|
|
34
|
+
|
|
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
|
+
}
|
|
41
|
+
|
|
42
|
+
let cached: { key: string; user: AgentUser | null } | null = null;
|
|
43
|
+
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function forgetAgentUser(): void {
|
|
54
|
+
cached = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): Record<string, string> {
|
|
58
|
+
return {
|
|
59
|
+
HOME: user.home,
|
|
60
|
+
USER: user.name,
|
|
61
|
+
LOGNAME: user.name,
|
|
62
|
+
SHELL: '/bin/bash',
|
|
63
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
64
|
+
TERM: process.env.TERM ?? 'xterm-256color',
|
|
65
|
+
PATH: AGENT_PATH.map((p) => (p.startsWith('/') ? p : join(user.home, p))).join(':'),
|
|
66
|
+
...extra,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function asUser(user: AgentUser | null, file: string, args: readonly string[], extra: Record<string, string> = {}): [string, string[]] {
|
|
71
|
+
if (user === null) return [file, [...args]];
|
|
72
|
+
const env = Object.entries(agentEnv(user, extra)).map(([k, v]) => `${k}=${v}`);
|
|
73
|
+
return ['runuser', ['-u', user.name, '--', 'env', '-i', ...env, file, ...args]];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const asAgent = (file: string, args: readonly string[], extra: Record<string, string> = {}): [string, string[]] =>
|
|
77
|
+
asUser(agentUser(), file, args, extra);
|
|
78
|
+
|
|
79
|
+
export const agentCommand = (command: readonly string[], extra: Record<string, string> = {}): string[] => {
|
|
80
|
+
const [file = '', ...args] = command;
|
|
81
|
+
const [bin, argv] = asAgent(file, args, extra);
|
|
82
|
+
return [bin, ...argv];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function claudeBin(user = agentUser()): string {
|
|
86
|
+
return user === null ? 'claude' : join(user.home, '.local', 'bin', 'claude');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const claudeHome = (user = agentUser()): string | null => (user === null ? null : user.home);
|
|
90
|
+
|
|
91
|
+
export const agentMarketplaceDir = (user: AgentUser): string => join(user.home, '.metro', 'marketplace');
|
|
92
|
+
export const agentViewDir = (user: AgentUser): string => join(user.home, '.metro', 'agents');
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { removeHome, writeHomeText } from './home-fs.js';
|
|
7
|
+
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
8
|
+
|
|
9
|
+
const COPIED = ['policy.json', 'claude-setup.json', 'system-prompt.md'] as const;
|
|
10
|
+
const SYNC_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
const readText = (path: string): string | null => {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(path, 'utf8');
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const readRecord = (path: string): Record<string, unknown> | null => {
|
|
21
|
+
const text = readText(path);
|
|
22
|
+
if (text === null) return null;
|
|
23
|
+
try {
|
|
24
|
+
const parsed: unknown = JSON.parse(text);
|
|
25
|
+
return isRecord(parsed) ? parsed : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
|
|
32
|
+
|
|
33
|
+
function agentFile(dir: string): string | null {
|
|
34
|
+
const file = readRecord(join(dir, 'agent.json'));
|
|
35
|
+
if (file === null || typeof file.id !== 'string' || typeof file.key !== 'string') return null;
|
|
36
|
+
return json({ version: 1, id: file.id, key: file.key });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function modelFile(dir: string): string | null {
|
|
40
|
+
const cfg = readRecord(join(dir, 'model.json'));
|
|
41
|
+
if (cfg === null || !Array.isArray(cfg.connections)) return null;
|
|
42
|
+
const connections = cfg.connections.filter(isRecord).map((c) => ({ id: c.id, provider: c.provider, model: c.model }));
|
|
43
|
+
return json({ version: 2, route: cfg.route, connections });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function viewFiles(dir = agentsDir()): Map<string, string | null> {
|
|
47
|
+
const files = new Map<string, string | null>([
|
|
48
|
+
['agent.json', agentFile(dir)],
|
|
49
|
+
['model.json', modelFile(dir)],
|
|
50
|
+
]);
|
|
51
|
+
for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const written = new Map<string, string | null>();
|
|
56
|
+
|
|
57
|
+
export function syncAgentView(user: AgentUser | null = agentUser(), dir = agentsDir()): number {
|
|
58
|
+
if (user === null) return 0;
|
|
59
|
+
const target = agentViewDir(user);
|
|
60
|
+
let changed = 0;
|
|
61
|
+
for (const [name, text] of viewFiles(dir)) {
|
|
62
|
+
const path = join(target, name);
|
|
63
|
+
if (written.has(path) && written.get(path) === text) continue;
|
|
64
|
+
if (text === null) removeHome(path, false, user);
|
|
65
|
+
else writeHomeText(path, text, 0o600, undefined, user);
|
|
66
|
+
written.set(path, text);
|
|
67
|
+
changed += 1;
|
|
68
|
+
}
|
|
69
|
+
return changed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
73
|
+
|
|
74
|
+
export function watchAgentView(everyMs = SYNC_MS): void {
|
|
75
|
+
if (timer !== null) return;
|
|
76
|
+
const tick = (): void => {
|
|
77
|
+
try {
|
|
78
|
+
const changed = syncAgentView();
|
|
79
|
+
if (changed > 0) log.info({ changed }, 'agent-user: refreshed the Metro files the agent may read');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.warn({ err: errMsg(err) }, 'agent-user: could not refresh the Metro files the agent may read');
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
tick();
|
|
85
|
+
timer = setInterval(tick, everyMs);
|
|
86
|
+
timer.unref();
|
|
87
|
+
}
|
|
@@ -23,6 +23,7 @@ import { agentsDir, fileSource } from '../agents/files.js';
|
|
|
23
23
|
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
|
+
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
26
27
|
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
27
28
|
import { tryClaudeSetup } from '../claude/setup.js';
|
|
28
29
|
import { applyLocalOwner } from './local-owner.js';
|
|
@@ -147,6 +148,15 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
147
148
|
'dispatcher ready',
|
|
148
149
|
);
|
|
149
150
|
markDaemonReady();
|
|
151
|
+
startClaude().catch((err: unknown) => {
|
|
152
|
+
log.warn({ err: errMsg(err) }, 'claude: could not start the Claude Code side');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function startClaude(): Promise<void> {
|
|
157
|
+
const agentUser = await provisionAgentUser();
|
|
158
|
+
if (agentUser !== 'off') log.info({ agentUser }, 'agent-user: Claude Code runs as its own user');
|
|
159
|
+
if (agentUser === 'failed' || agentUser === 'unsupported') return;
|
|
150
160
|
ensureMetroPlugin()
|
|
151
161
|
.then((outcome) => {
|
|
152
162
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
@@ -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
|
|
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
|
-
|
|
71
|
+
headline(kind, name, p.mime, size) +
|
|
66
72
|
(p.url ? `Public URL: ${p.url}\n` : '') +
|
|
67
|
-
|
|
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
|
|
|
@@ -5,16 +5,15 @@ import {
|
|
|
5
5
|
openSync,
|
|
6
6
|
readdirSync,
|
|
7
7
|
readSync,
|
|
8
|
-
rmSync,
|
|
9
8
|
statSync,
|
|
10
|
-
utimesSync,
|
|
11
9
|
} from 'node:fs';
|
|
12
10
|
import { homedir } from 'node:os';
|
|
13
11
|
import { join } from 'node:path';
|
|
14
12
|
import { createInterface } from 'node:readline';
|
|
15
13
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
16
14
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
17
|
-
import {
|
|
15
|
+
import { removeHome, writeHomeText } from '../agent-user/home-fs.js';
|
|
16
|
+
import { agentUser } from '../agent-user/user.js';
|
|
18
17
|
|
|
19
18
|
export const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
20
19
|
export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
|
|
@@ -36,7 +35,8 @@ export function claudeDir(): string {
|
|
|
36
35
|
if (explicit !== undefined && explicit !== '') return explicit;
|
|
37
36
|
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
38
37
|
if (cfg !== undefined && cfg !== '') return cfg;
|
|
39
|
-
|
|
38
|
+
const user = agentUser();
|
|
39
|
+
return join(user === null ? homedir() : user.home, '.claude');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
|
|
@@ -284,8 +284,8 @@ export function deleteClaudeSession(project: string, session: string, dir = clau
|
|
|
284
284
|
const id = safeName(session, SESSION_RE, 'session id');
|
|
285
285
|
const file = join(root, `${id}.jsonl`);
|
|
286
286
|
if (!existsSync(file)) throw new ApiError('no such session', 404);
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
removeHome(file);
|
|
288
|
+
removeHome(join(root, id), true);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
export interface MemoryFile {
|
|
@@ -361,9 +361,7 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
|
|
|
361
361
|
if (Buffer.byteLength(text, 'utf8') > MEMORY_MAX)
|
|
362
362
|
throw new ApiError(`a memory file is at most ${String(MEMORY_MAX)} bytes`, 400);
|
|
363
363
|
const path = memoryPath(project, name, dir);
|
|
364
|
-
|
|
365
|
-
const stamp = stampOf(modifiedAt);
|
|
366
|
-
if (stamp !== null) utimesSync(path, stamp, stamp);
|
|
364
|
+
writeHomeText(path, text, MEMORY_MODE, stampOf(modifiedAt) ?? undefined);
|
|
367
365
|
const stat = statSync(path);
|
|
368
366
|
return { name, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
369
367
|
}
|
|
@@ -371,7 +369,7 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
|
|
|
371
369
|
export function deleteMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
372
370
|
const path = memoryPath(project, name, dir);
|
|
373
371
|
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
374
|
-
|
|
372
|
+
removeHome(path);
|
|
375
373
|
return name;
|
|
376
374
|
}
|
|
377
375
|
|