@stage-labs/metro 0.1.0-beta.181 → 0.1.0-beta.183
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/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/approvals/needs.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -0
- 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 +25 -8
- 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/gates.ts +11 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +7 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +8 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +22 -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.183",
|
|
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.183",
|
|
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,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
|
+
migrateClaude(user, dir);
|
|
127
|
+
await installClaude(user);
|
|
128
|
+
copyMarketplace(user, env);
|
|
129
|
+
watchAgentView();
|
|
130
|
+
stopRootSession();
|
|
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
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export const NEEDS_APPROVAL = (where: string, tool: string): string =>
|
|
2
|
+
`Needs the owner's approval for ${where} (${tool}). Make this exact call from a background worker so Claude Code asks the owner in chat, and wait for the answer. An approval given only in the terminal does not count.`;
|
|
@@ -27,6 +27,27 @@ const HOUR_MS = 3_600_000;
|
|
|
27
27
|
|
|
28
28
|
const held = new Map<string, Held>();
|
|
29
29
|
|
|
30
|
+
interface Grant {
|
|
31
|
+
tool: string;
|
|
32
|
+
preview: string;
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GRANT_TTL_MS = 10 * 60_000;
|
|
37
|
+
const GRANTS_MAX = 200;
|
|
38
|
+
let grants: Grant[] = [];
|
|
39
|
+
|
|
40
|
+
function grant(entry: Held, now = Date.now()): void {
|
|
41
|
+
grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), { tool: entry.tool, preview: entry.preview, at: now }].slice(-GRANTS_MAX);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function takeGrant(toolMatches: (tool: string) => boolean, args: Record<string, unknown>, now = Date.now()): boolean {
|
|
45
|
+
const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && previewMatches(g.preview, args));
|
|
46
|
+
if (at < 0) return false;
|
|
47
|
+
grants = grants.filter((_, i) => i !== at);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
export function approvalTtlMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
31
52
|
const hours = Number(env.METRO_APPROVAL_TTL_H);
|
|
32
53
|
return (Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_TTL_H) * HOUR_MS;
|
|
@@ -82,6 +103,7 @@ export async function answerPrompt(
|
|
|
82
103
|
const entry = held.get(requestId);
|
|
83
104
|
if (entry === undefined) return undefined;
|
|
84
105
|
held.delete(requestId);
|
|
106
|
+
if (behavior === 'allow' && via !== 'expiry') grant(entry);
|
|
85
107
|
log.info({ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` }, 'approvals: a Claude Code permission prompt answered');
|
|
86
108
|
await entry.send(behavior);
|
|
87
109
|
await tellChat(entry, via, behavior);
|
|
@@ -123,4 +145,5 @@ export function startPromptExpiry(): () => void {
|
|
|
123
145
|
|
|
124
146
|
export function forgetAllPrompts(): void {
|
|
125
147
|
held.clear();
|
|
148
|
+
grants = [];
|
|
126
149
|
}
|
|
@@ -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');
|
|
@@ -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
|
|
|
@@ -4,8 +4,9 @@ import { markOnboardingDone } from './onboarding.js';
|
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
+
import { agentCommand, asAgent, claudeBin } from '../agent-user/user.js';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const LOGIN_ARGS = ['auth', 'login', '--claudeai'];
|
|
9
10
|
const STATUS_COMMAND = ['auth', 'status', '--json'];
|
|
10
11
|
const TTL_MS = 10 * 60_000;
|
|
11
12
|
const OUTPUT_MAX = 8_000;
|
|
@@ -94,7 +95,7 @@ function forgetClaudeChecks(): void {
|
|
|
94
95
|
|
|
95
96
|
export function claudeInstalled(): boolean {
|
|
96
97
|
return remembered(checks.installed, () => {
|
|
97
|
-
const run = spawnSync(
|
|
98
|
+
const run = spawnSync(...asAgent(claudeBin(), ['--version']), { stdio: 'ignore' });
|
|
98
99
|
return run.error === undefined && run.status === 0;
|
|
99
100
|
}, (c) => {
|
|
100
101
|
checks.installed = c;
|
|
@@ -113,7 +114,7 @@ export function claudeAccount(): ClaudeAccount {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
function readAccount(): ClaudeAccount {
|
|
116
|
-
const run = spawnSync(
|
|
117
|
+
const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
117
118
|
if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
|
|
118
119
|
try {
|
|
119
120
|
const parsed: unknown = JSON.parse(run.stdout);
|
|
@@ -133,7 +134,7 @@ function note(session: Live, chunk: string): void {
|
|
|
133
134
|
export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginView {
|
|
134
135
|
sweep(now);
|
|
135
136
|
if (live !== null && live.state === 'pending') return view(live);
|
|
136
|
-
const command = deps.command ??
|
|
137
|
+
const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], { TERM: 'xterm-256color' });
|
|
137
138
|
const session: Live = {
|
|
138
139
|
id: randomBytes(9).toString('base64url'),
|
|
139
140
|
startedAt: now,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, readFileSync, realpathSync
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { writeHomeText } from '../agent-user/home-fs.js';
|
|
6
|
+
import { agentUser } from '../agent-user/user.js';
|
|
5
7
|
|
|
6
8
|
const FILE = '.claude.json';
|
|
7
9
|
const FLAG = 'hasCompletedOnboarding';
|
|
@@ -12,6 +14,8 @@ const given = (value: string | undefined): string | undefined =>
|
|
|
12
14
|
value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
13
15
|
|
|
14
16
|
export function claudeConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
const user = agentUser();
|
|
18
|
+
if (user !== null) return join(user.home, FILE);
|
|
15
19
|
return join(given(env.CLAUDE_CONFIG_DIR) ?? given(env.HOME) ?? homedir(), FILE);
|
|
16
20
|
}
|
|
17
21
|
|
|
@@ -29,7 +33,7 @@ export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
|
|
|
29
33
|
const config = readConfig(path);
|
|
30
34
|
if (config === null) return 'unreadable';
|
|
31
35
|
if (config[FLAG] === true) return 'already';
|
|
32
|
-
|
|
36
|
+
writeHomeText(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, 0o600);
|
|
33
37
|
return 'marked';
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -51,6 +55,6 @@ export function trustFolder(dir: string, path = claudeConfigPath()): OnboardingM
|
|
|
51
55
|
const project = isRecord(projects[key]) ? projects[key] : {};
|
|
52
56
|
if (project[TRUST] === true) return 'already';
|
|
53
57
|
const next = { ...config, projects: { ...projects, [key]: { ...project, [TRUST]: true } } };
|
|
54
|
-
|
|
58
|
+
writeHomeText(path, `${JSON.stringify(next, null, 2)}\n`, 0o600);
|
|
55
59
|
return 'marked';
|
|
56
60
|
}
|
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { log } from '@metro-labs/core/log';
|
|
7
|
+
import { agentMarketplaceDir, agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
7
8
|
|
|
8
9
|
const MARKETPLACE = 'metro';
|
|
9
10
|
const PLUGIN = 'metro@metro';
|
|
@@ -25,13 +26,21 @@ export interface PluginInstallDeps {
|
|
|
25
26
|
|
|
26
27
|
export type PluginOutcome = 'skipped' | 'unchanged' | 'installed' | 'updated' | 'failed';
|
|
27
28
|
|
|
28
|
-
export function
|
|
29
|
+
export function storeMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
29
30
|
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
30
31
|
if (store === '') return null;
|
|
31
32
|
const dir = join(store, 'marketplace');
|
|
32
33
|
return existsSync(join(dir, '.claude-plugin', 'marketplace.json')) ? dir : null;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
export function stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
37
|
+
const store = storeMarketplaceDir(env);
|
|
38
|
+
const user = agentUser();
|
|
39
|
+
if (store === null || user === null) return store;
|
|
40
|
+
const copy = agentMarketplaceDir(user);
|
|
41
|
+
return existsSync(join(copy, '.claude-plugin', 'marketplace.json')) ? copy : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
export function stagedPluginVersion(dir: string): string | null {
|
|
36
45
|
try {
|
|
37
46
|
const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
@@ -45,13 +54,13 @@ const runner =
|
|
|
45
54
|
(bin: string): Run =>
|
|
46
55
|
(args) =>
|
|
47
56
|
new Promise((resolve) => {
|
|
48
|
-
execFile(bin, args, { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
57
|
+
execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
49
58
|
const status = err === null ? 0 : typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1;
|
|
50
59
|
resolve({ status, stdout, stderr });
|
|
51
60
|
});
|
|
52
61
|
});
|
|
53
62
|
|
|
54
|
-
export async function findClaude(candidates = ['claude', join(homedir(), '.local', 'bin', 'claude')]): Promise<Run | null> {
|
|
63
|
+
export async function findClaude(candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()]): Promise<Run | null> {
|
|
55
64
|
for (const bin of candidates) {
|
|
56
65
|
const run = runner(bin);
|
|
57
66
|
if ((await run(['--version'])).status === 0) return run;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { Transform, type Readable } from 'node:stream';
|
|
3
|
+
import { moveHome, receiveHomeFile, removeHome } from '../agent-user/home-fs.js';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -25,42 +26,41 @@ export function sessionFilePath(project: string, session: string, dir = claudeDi
|
|
|
25
26
|
|
|
26
27
|
const HEAD_BYTES = 64 * 1024;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
const out = createWriteStream(tmp, { mode: FILE_MODE });
|
|
30
|
-
const head: Buffer[] = [];
|
|
29
|
+
function watched(body: Readable, head: Buffer[]): Transform {
|
|
31
30
|
let total = 0;
|
|
32
31
|
let kept = 0;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
await new Promise<void>((resolve, reject) => {
|
|
48
|
-
out.once('error', reject);
|
|
49
|
-
out.end(resolve);
|
|
32
|
+
const check = new Transform({
|
|
33
|
+
transform(chunk: Buffer, _enc, done): void {
|
|
34
|
+
total += chunk.length;
|
|
35
|
+
if (total > SESSION_FILE_MAX) {
|
|
36
|
+
done(new ApiError(`a session file is at most ${String(SESSION_FILE_MAX)} bytes`, 413));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (kept < HEAD_BYTES) {
|
|
40
|
+
head.push(chunk.subarray(0, HEAD_BYTES - kept));
|
|
41
|
+
kept += chunk.length;
|
|
42
|
+
}
|
|
43
|
+
done(null, chunk);
|
|
44
|
+
},
|
|
50
45
|
});
|
|
51
|
-
return
|
|
46
|
+
return body.pipe(check);
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
export async function receiveSessionFile(project: string, session: string, body: Readable, dir = claudeDir()): Promise<SessionFile> {
|
|
55
50
|
const path = transcriptPath(project, session, dir);
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
const tmp = `${path}.metro-upload`;
|
|
52
|
+
const head: Buffer[] = [];
|
|
53
|
+
try {
|
|
54
|
+
await receiveHomeFile(tmp, watched(body, head), FILE_MODE);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
removeHome(tmp);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
if (!firstLineIsJson(Buffer.concat(head).toString('utf8'))) {
|
|
60
|
+
removeHome(tmp);
|
|
61
61
|
throw new ApiError('a session file is JSON lines, one Claude Code entry per line', 400);
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
moveHome(tmp, path);
|
|
64
64
|
return { id: safeName(session, SESSION_RE, 'session id'), bytes: statSync(path).size };
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -11,6 +11,14 @@ import { notReady, readModelConfig, routedConnection } from '../gateway/model-co
|
|
|
11
11
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
12
12
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
13
13
|
import { trustFolder } from './onboarding.js';
|
|
14
|
+
import { agentUser, agentViewDir, asAgent, claudeHome, wantedAgentUser } from '../agent-user/user.js';
|
|
15
|
+
|
|
16
|
+
function sessionEnv(): Record<string, string> {
|
|
17
|
+
const user = agentUser();
|
|
18
|
+
if (user === null) return {};
|
|
19
|
+
const port = process.env.METRO_WEBHOOK_PORT?.trim() ?? '';
|
|
20
|
+
return { METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
export const SESSION_NAME = 'metro';
|
|
16
24
|
const STATE_FILE = 'claude-session.json';
|
|
@@ -74,7 +82,7 @@ export function startedVersion(agents = agentsDir()): string | null {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
function tmuxOk(tmux: string, args: string[]): boolean {
|
|
77
|
-
const run = spawnSync(tmux, args, { stdio: 'ignore' });
|
|
85
|
+
const run = spawnSync(...asAgent(tmux, args), { stdio: 'ignore' });
|
|
78
86
|
return run.error === undefined && run.status === 0;
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -95,9 +103,12 @@ export function hasConversation(home: string, dir = claudeDir()): boolean {
|
|
|
95
103
|
|
|
96
104
|
function metroCommand(deps: SessionDeps, home: string): string[] {
|
|
97
105
|
const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
|
|
98
|
-
const
|
|
106
|
+
const runtime = agentUser() === null ? process.execPath : 'node';
|
|
107
|
+
const base = deps.metro ?? (bin === '' ? ['metro', 'claude'] : [runtime, bin, 'claude']);
|
|
99
108
|
const continues = (deps.continues ?? hasConversation)(home);
|
|
100
|
-
|
|
109
|
+
const env = Object.entries(sessionEnv()).map(([k, v]) => `${k}=${v}`);
|
|
110
|
+
const command = env.length === 0 || deps.metro !== undefined ? base : ['env', ...env, ...base];
|
|
111
|
+
return continues ? [...command, '-c'] : command;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
function credentialReady(deps: SessionDeps): string | null {
|
|
@@ -113,16 +124,21 @@ function credentialReady(deps: SessionDeps): string | null {
|
|
|
113
124
|
return 'Claude Code is not signed in and the Model page routes nowhere yet';
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
const agentUserMissing = (deps: SessionDeps, agents: string): boolean =>
|
|
128
|
+
deps.metro === undefined && wantedAgentUser(agents) !== null && agentUser(agents) === null;
|
|
129
|
+
|
|
116
130
|
export function sessionBlocked(deps: SessionDeps = {}): string | null {
|
|
117
131
|
const agents = deps.agents ?? agentsDir();
|
|
118
132
|
if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
|
|
133
|
+
if (agentUserMissing(deps, agents))
|
|
134
|
+
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)';
|
|
119
135
|
if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
|
|
120
136
|
if (!tmuxOk(deps.tmux ?? 'tmux', ['-V'])) return 'tmux is not installed on this machine';
|
|
121
137
|
return credentialReady(deps);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
function capturePane(tmux: string): string {
|
|
125
|
-
const run = spawnSync(tmux, ['capture-pane', '-p', '-t', SESSION_NAME], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
141
|
+
const run = spawnSync(...asAgent(tmux, ['capture-pane', '-p', '-t', SESSION_NAME]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
126
142
|
return run.error === undefined && run.status === 0 ? run.stdout : '';
|
|
127
143
|
}
|
|
128
144
|
|
|
@@ -130,7 +146,7 @@ function confirmChannels(tmux: string, until: number): void {
|
|
|
130
146
|
const tick = (): void => {
|
|
131
147
|
const pane = capturePane(tmux);
|
|
132
148
|
if (pane.includes(WARNING) && pane.includes('server:metro')) {
|
|
133
|
-
spawnSync(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter'], { stdio: 'ignore' });
|
|
149
|
+
spawnSync(...asAgent(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter']), { stdio: 'ignore' });
|
|
134
150
|
log.info('claude-session: confirmed the development channels dialog for server:metro');
|
|
135
151
|
return;
|
|
136
152
|
}
|
|
@@ -153,18 +169,19 @@ function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?
|
|
|
153
169
|
|
|
154
170
|
export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
155
171
|
const tmux = deps.tmux ?? 'tmux';
|
|
156
|
-
const home = deps.home ?? homedir();
|
|
172
|
+
const home = deps.home ?? claudeHome() ?? homedir();
|
|
157
173
|
const now = (deps.now ?? Date.now)();
|
|
158
174
|
const trusted = trustFolder(home);
|
|
159
175
|
const [command = 'metro', ...args] = metroCommand(deps, home);
|
|
160
|
-
const
|
|
176
|
+
const tmuxArgs = ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args];
|
|
177
|
+
const run = spawnSync(...asAgent(tmux, tmuxArgs), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: home });
|
|
161
178
|
recordStart(deps, tmux, now, run);
|
|
162
179
|
if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
|
|
163
180
|
return sessionStatus(deps);
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
export function stopSession(deps: SessionDeps = {}): SessionStatus {
|
|
167
|
-
spawnSync(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME], { stdio: 'ignore' });
|
|
184
|
+
spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
|
|
168
185
|
return sessionStatus(deps);
|
|
169
186
|
}
|
|
170
187
|
|
|
@@ -4,7 +4,7 @@ import { ApiError } from '@metro-labs/http/api-error';
|
|
|
4
4
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { errMsg } from '@metro-labs/core/log';
|
|
7
|
-
import {
|
|
7
|
+
import { writeHomeText } from '../agent-user/home-fs.js';
|
|
8
8
|
|
|
9
9
|
export const SETTINGS_MAX = 256 * 1024;
|
|
10
10
|
const USER_ID = 'user';
|
|
@@ -104,6 +104,6 @@ export function writeClaudeSettings(
|
|
|
104
104
|
assertSettingsJson(text);
|
|
105
105
|
if (seenAt !== undefined && seenAt !== target.modifiedAt)
|
|
106
106
|
throw new ApiError('that file changed on disk since you opened it; reload it before saving', 409);
|
|
107
|
-
|
|
107
|
+
writeHomeText(target.path, text, existsSync(target.path) ? statSync(target.path).mode & 0o777 : 0o644);
|
|
108
108
|
return entryOf(target.id, target.scope, target.label, target.path);
|
|
109
109
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync,
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { readJson, writeAtomic, writeJson } from '@metro-labs/core/secure-fs';
|
|
8
8
|
import { agentsDir } from '../agents/files.js';
|
|
9
|
+
import { writeHomeText } from '../agent-user/home-fs.js';
|
|
9
10
|
import { claudeDir } from './files.js';
|
|
10
11
|
import { stagedMarketplaceDir } from './plugin-install.js';
|
|
11
12
|
import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
|
|
@@ -137,13 +138,12 @@ const digest = (text: string): string => createHash('sha256').update(text).diges
|
|
|
137
138
|
|
|
138
139
|
export function placeFile(path: string, text: string, prior: ReadonlySet<string> = new Set()): Placed {
|
|
139
140
|
if (!existsSync(path)) {
|
|
140
|
-
|
|
141
|
-
writeFileSync(path, text, { mode: 0o644 });
|
|
141
|
+
writeHomeText(path, text, 0o644);
|
|
142
142
|
return 'written';
|
|
143
143
|
}
|
|
144
144
|
const current = readFileSync(path, 'utf8');
|
|
145
145
|
if (current === text || !prior.has(digest(current))) return 'present';
|
|
146
|
-
|
|
146
|
+
writeHomeText(path, text, 0o644);
|
|
147
147
|
return 'updated';
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -172,7 +172,7 @@ function mergeSettings(dir: string, change: (settings: Record<string, unknown>)
|
|
|
172
172
|
if (current === null) return 'unreadable';
|
|
173
173
|
const next = change(current);
|
|
174
174
|
if (existsSync(path) && JSON.stringify(next) === JSON.stringify(current)) return 'unchanged';
|
|
175
|
-
|
|
175
|
+
writeHomeText(path, `${JSON.stringify(next, null, 2)}\n`, existsSync(path) ? statSync(path).mode & 0o777 : 0o644);
|
|
176
176
|
return 'written';
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync,
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { claudeDir } from './files.js';
|
|
5
|
-
import {
|
|
5
|
+
import { removeHome, writeHomeText } from '../agent-user/home-fs.js';
|
|
6
6
|
|
|
7
7
|
export const SKILL_MAX = 256 * 1024;
|
|
8
8
|
export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -87,7 +87,7 @@ export function writeClaudeSkill(
|
|
|
87
87
|
assertText(text);
|
|
88
88
|
if (seenAt !== undefined && seenAt !== skill.updatedAt)
|
|
89
89
|
throw new ApiError('that skill changed on disk since you opened it; reload it before saving', 409);
|
|
90
|
-
|
|
90
|
+
writeHomeText(skill.path, text, DEFAULT_MODE);
|
|
91
91
|
return entryOf(skill.name, skill.path);
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -101,7 +101,7 @@ export function createClaudeSkill(name: string, text: string | undefined, dir =
|
|
|
101
101
|
if (existsSync(path)) throw new ApiError('a skill by that name already lives there', 409);
|
|
102
102
|
const body = text === undefined || text.trim() === '' ? skillTemplate(name) : text;
|
|
103
103
|
assertText(body);
|
|
104
|
-
|
|
104
|
+
writeHomeText(path, body, DEFAULT_MODE);
|
|
105
105
|
return entryOf(name, path);
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -109,6 +109,6 @@ export function deleteClaudeSkill(id: string, dir = claudeDir()): string {
|
|
|
109
109
|
const skill = found(id, dir);
|
|
110
110
|
const folder = dirname(skill.path);
|
|
111
111
|
if (basename(folder) !== skill.name) throw new ApiError('that skill does not live in its own folder', 409);
|
|
112
|
-
|
|
112
|
+
removeHome(folder, true);
|
|
113
113
|
return id;
|
|
114
114
|
}
|
|
@@ -5,6 +5,7 @@ import { ApiError } from '@metro-labs/http/api-error';
|
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { sessionRunning, stopSession, type SessionDeps } from './session.js';
|
|
8
|
+
import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
8
9
|
|
|
9
10
|
const LATEST_URL = 'https://registry.npmjs.org/@anthropic-ai/claude-code/latest';
|
|
10
11
|
const VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
|
|
@@ -27,7 +28,10 @@ export interface ClaudeVersion {
|
|
|
27
28
|
|
|
28
29
|
let cached: { latest: string | null; at: number } | null = null;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
function candidates(deps: VersionDeps): string[] {
|
|
32
|
+
if (deps.claude !== undefined) return [deps.claude];
|
|
33
|
+
return agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
const parse = (text: string): string | null => {
|
|
33
37
|
const m = VERSION_RE.exec(text);
|
|
@@ -35,7 +39,7 @@ const parse = (text: string): string | null => {
|
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
function probe(bin: string): string | null {
|
|
38
|
-
const run = spawnSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
|
+
const run = spawnSync(...asAgent(bin, ['--version']), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
39
43
|
return run.error === undefined && run.status === 0 && typeof run.stdout === 'string' ? parse(run.stdout) : null;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -84,7 +88,7 @@ export async function claudeVersion(deps: VersionDeps = {}): Promise<ClaudeVersi
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
function install(bin: string): void {
|
|
87
|
-
const run = spawnSync(bin, ['install', 'latest'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
|
|
91
|
+
const run = spawnSync(...asAgent(bin, ['install', 'latest']), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
|
|
88
92
|
if (run.error !== undefined) throw new ApiError(`claude install latest failed: ${errMsg(run.error)}`, 502);
|
|
89
93
|
if (run.status === 0) return;
|
|
90
94
|
const said = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().slice(-TAIL);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
2
2
|
import { policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
|
|
3
3
|
import { serverKeysOf } from './plugin-sync.js';
|
|
4
|
+
import { takeGrant } from '../approvals/pending.js';
|
|
5
|
+
import { NEEDS_APPROVAL } from '../approvals/needs.js';
|
|
4
6
|
|
|
5
7
|
interface GateRow {
|
|
6
8
|
id: string;
|
|
@@ -39,11 +41,18 @@ export const connectorGates = (): readonly ConnectorGate[] => gates;
|
|
|
39
41
|
|
|
40
42
|
export const toolGroupOf = (gate: ConnectorGate, tool: string): ToolGroup => (gate.tools[tool] === 'read' ? 'read' : 'write');
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
const promptFor = (connectorId: string, tool: string) => (asked: string): boolean => {
|
|
45
|
+
const found = connectorToolOf(asked);
|
|
46
|
+
return found?.gate.id === connectorId && found.tool === tool;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
|
|
43
50
|
const gate = gates.find((g) => g.id === connectorId);
|
|
44
51
|
if (gate === undefined) return null;
|
|
45
52
|
const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
|
|
46
|
-
|
|
53
|
+
if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
|
|
54
|
+
if (access === 'allow' || takeGrant(promptFor(connectorId, tool), args)) return null;
|
|
55
|
+
return NEEDS_APPROVAL(gate.name, tool);
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
function prefixesOf(gate: ConnectorGate): string[] {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { writeHomeInPlace } from '../agent-user/home-fs.js';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
5
|
import { claudeDir } from '../claude/files.js';
|
|
@@ -90,7 +91,7 @@ const readOrNull = (path: string): string | null => {
|
|
|
90
91
|
|
|
91
92
|
function writeIfChanged(path: string, text: string): boolean {
|
|
92
93
|
if (readOrNull(path) === text) return false;
|
|
93
|
-
|
|
94
|
+
writeHomeInPlace(path, text, 0o644);
|
|
94
95
|
return true;
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -2,7 +2,7 @@ import type { ServerResponse } from 'node:http';
|
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { log } from '@metro-labs/core/log';
|
|
4
4
|
|
|
5
|
-
export type BlockedReason = (connectorId: string, tool: string) => string | null;
|
|
5
|
+
export type BlockedReason = (connectorId: string, tool: string, args: Record<string, unknown>) => string | null;
|
|
6
6
|
|
|
7
7
|
export interface Screened {
|
|
8
8
|
forward: Uint8Array<ArrayBuffer> | null;
|
|
@@ -31,9 +31,14 @@ function blockedAnswer(message: Record<string, unknown>, reason: string): Record
|
|
|
31
31
|
return { jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: reason }] } };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function argsOf(message: unknown): Record<string, unknown> {
|
|
35
|
+
if (!isRecord(message) || !isRecord(message.params)) return {};
|
|
36
|
+
return isRecord(message.params.arguments) ? message.params.arguments : {};
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
function reasonFor(message: unknown, connectorId: string, blocked: BlockedReason): string | null {
|
|
35
40
|
const tool = toolOf(message);
|
|
36
|
-
return tool === null ? null : blocked(connectorId, tool);
|
|
41
|
+
return tool === null ? null : blocked(connectorId, tool, argsOf(message));
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function screenCalls(body: Uint8Array<ArrayBuffer>, connectorId: string, blocked: BlockedReason): Screened | null {
|
|
@@ -3,6 +3,9 @@ import type { ToolResult } from '@metro-labs/core/stations/types';
|
|
|
3
3
|
import { accountFromLine, knownAccounts } from '../agents/map.js';
|
|
4
4
|
import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
|
|
5
5
|
import { errResult } from './ctx.js';
|
|
6
|
+
import { takeGrant } from '../approvals/pending.js';
|
|
7
|
+
import { NEEDS_APPROVAL } from '../approvals/needs.js';
|
|
8
|
+
import { connectorToolOf } from '../connectors/gates.js';
|
|
6
9
|
import { profileScopeLine } from './profile-lookup.js';
|
|
7
10
|
import { stationOfAccount } from './read-tool.js';
|
|
8
11
|
import { stationForTool, toolGroupOf } from './tool-catalog.js';
|
|
@@ -48,10 +51,13 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
|
|
|
48
51
|
return verdict;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
const isChannelTool = (name: string) => (tool: string): boolean => tool.endsWith(`__${name}`) && connectorToolOf(tool) === undefined;
|
|
55
|
+
|
|
51
56
|
export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
|
|
52
57
|
const { access, target } = channelDecision(name, a);
|
|
53
|
-
if (access
|
|
54
|
-
return errResult(`Blocked by the owner's policy for ${target.station} (${name}).`);
|
|
58
|
+
if (access === 'allow' || target === undefined) return undefined;
|
|
59
|
+
if (access === 'deny') return errResult(`Blocked by the owner's policy for ${target.station} (${name}).`);
|
|
60
|
+
return takeGrant(isChannelTool(name), a) ? undefined : errResult(NEEDS_APPROVAL(target.station, name));
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export interface EffectivePolicy {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
writeInlineTemp,
|
|
13
13
|
} from './attach-inline.js';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { agentCommand, agentUser, type AgentUser } from '../agent-user/user.js';
|
|
15
16
|
import { log } from '@metro-labs/core/log';
|
|
16
17
|
import { readUpload, UPLOAD_TTL_MS } from '../files/upload-store.js';
|
|
17
18
|
import type { CanonicalAttachment } from '@metro-labs/core/stations/types';
|
|
@@ -56,11 +57,32 @@ function assertReadablePath(path: string): void {
|
|
|
56
57
|
);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
async function fromPathAs(
|
|
61
|
+
user: AgentUser,
|
|
62
|
+
a: CanonicalAttachment,
|
|
63
|
+
path: string,
|
|
64
|
+
): Promise<ResolvedAttachment> {
|
|
65
|
+
const proc = Bun.spawn(agentCommand(['cat', '--', path]), { stdout: 'pipe', stderr: 'pipe' });
|
|
66
|
+
const name = a.name ?? basenameOf(path) ?? 'attachment';
|
|
67
|
+
const saved = await streamToTemp(proc.stdout, name);
|
|
68
|
+
if ((await proc.exited) !== 0) {
|
|
69
|
+
await removeInlineTemp(saved.dir);
|
|
70
|
+
throw new Error(
|
|
71
|
+
`attachment path '${path}' cannot be read by the ${user.name} user Claude Code runs as. ` +
|
|
72
|
+
'`path` is read on the daemon machine with the agent\'s own rights; pass `upload` instead.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
assertAttachmentSize(saved.bytes);
|
|
76
|
+
return { path: saved.path, mime: a.mime ?? guessMime(path), name, bytes: saved.bytes, temp: saved.dir };
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
async function fromPath(
|
|
60
80
|
a: CanonicalAttachment,
|
|
61
81
|
path: string,
|
|
62
82
|
): Promise<ResolvedAttachment> {
|
|
63
83
|
assertReadablePath(path);
|
|
84
|
+
const user = agentUser();
|
|
85
|
+
if (user !== null) return fromPathAs(user, a, path);
|
|
64
86
|
const file = Bun.file(path);
|
|
65
87
|
if (!(await file.exists()))
|
|
66
88
|
throw new Error(
|
|
@@ -5,12 +5,13 @@ import { ApiError } from '@metro-labs/http/api-error';
|
|
|
5
5
|
import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
6
6
|
import { log } from '@metro-labs/core/log';
|
|
7
7
|
import { mintTerminalTicket } from './tickets.js';
|
|
8
|
+
import { agentCommand, asAgent, claudeHome } from '../agent-user/user.js';
|
|
8
9
|
|
|
9
10
|
const PREFIX = '/api/terminal';
|
|
10
11
|
const TICKETS = `${PREFIX}/tickets`;
|
|
11
12
|
const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
12
13
|
|
|
13
|
-
export const tmuxCommand = (session: string, home = homedir()): string[] => [
|
|
14
|
+
export const tmuxCommand = (session: string, home = claudeHome() ?? homedir()): string[] => agentCommand([
|
|
14
15
|
'tmux',
|
|
15
16
|
'new-session',
|
|
16
17
|
'-A',
|
|
@@ -34,7 +35,7 @@ export const tmuxCommand = (session: string, home = homedir()): string[] => [
|
|
|
34
35
|
'-as',
|
|
35
36
|
'terminal-features',
|
|
36
37
|
',xterm-256color:clipboard',
|
|
37
|
-
];
|
|
38
|
+
], { TERM: 'xterm-256color', COLORTERM: 'truecolor' });
|
|
38
39
|
|
|
39
40
|
export interface TerminalApiDeps {
|
|
40
41
|
command?: (session: string) => string[];
|
|
@@ -42,12 +43,12 @@ export interface TerminalApiDeps {
|
|
|
42
43
|
|
|
43
44
|
function tmuxAvailable(deps: TerminalApiDeps): boolean {
|
|
44
45
|
if (deps.command !== undefined) return true;
|
|
45
|
-
const run = spawnSync('tmux', ['-V'], { stdio: 'ignore' });
|
|
46
|
+
const run = spawnSync(...asAgent('tmux', ['-V']), { stdio: 'ignore' });
|
|
46
47
|
return run.error === undefined && run.status === 0;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function tmuxSessions(): string[] {
|
|
50
|
-
const run = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
|
+
const run = spawnSync(...asAgent('tmux', ['list-sessions', '-F', '#{session_name}']), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
52
|
if (run.error !== undefined || run.status !== 0) return [];
|
|
52
53
|
return run.stdout
|
|
53
54
|
.split('\n')
|
|
@@ -5,6 +5,7 @@ import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { takeTerminalTicket, type TerminalGrant } from './tickets.js';
|
|
8
|
+
import { asAgent } from '../agent-user/user.js';
|
|
8
9
|
|
|
9
10
|
const TICKET_PATH = /^\/api\/terminal\/([A-Za-z0-9_-]{43})$/;
|
|
10
11
|
const MAX_MESSAGE = 1024 * 1024;
|
|
@@ -31,8 +32,8 @@ export const resizeWindowArgs = (session: string, cols: number, rows: number): s
|
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
function sizeTmuxWindow(command: string[], session: string, cols: number, rows: number): void {
|
|
34
|
-
if (command
|
|
35
|
-
const child = spawn('tmux', resizeWindowArgs(session, cols, rows), { stdio: 'ignore' });
|
|
35
|
+
if (!command.includes('tmux')) return;
|
|
36
|
+
const child = spawn(...asAgent('tmux', resizeWindowArgs(session, cols, rows)), { stdio: 'ignore' });
|
|
36
37
|
child.on('error', (err) => {
|
|
37
38
|
log.warn({ err: errMsg(err) }, 'terminal: resize-window could not run');
|
|
38
39
|
});
|
package/runtime/runtime.json
CHANGED