@stage-labs/metro 0.1.0-beta.182 → 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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +113 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +136 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +92 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +87 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -10
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +5 -4
  10. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +7 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +12 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +29 -29
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +25 -8
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +2 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +6 -6
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +5 -5
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +7 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +3 -2
  19. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +22 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +5 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +3 -2
  22. 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.182",
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.182",
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
+ }
@@ -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 { writeAtomic } from '@metro-labs/core/secure-fs';
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
- return join(homedir(), '.claude');
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
- rmSync(file);
288
- rmSync(join(root, id), { recursive: true, force: true });
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
- writeAtomic(path, text, MEMORY_MODE);
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
- rmSync(path);
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 LOGIN_COMMAND = ['claude', 'auth', 'login', '--claudeai'];
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('claude', ['--version'], { stdio: 'ignore' });
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('claude', STATUS_COMMAND, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
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 ?? LOGIN_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, writeFileSync } from 'node:fs';
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
- writeFileSync(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, { mode: 0o600 });
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
- writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
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 stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
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 { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
- import type { Readable } from 'node:stream';
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
- async function spool(body: Readable, tmp: string): Promise<string> {
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
- for await (const chunk of body) {
34
- const buf = chunk as Buffer;
35
- total += buf.length;
36
- if (total > SESSION_FILE_MAX) {
37
- out.destroy();
38
- rmSync(tmp, { force: true });
39
- throw new ApiError(`a session file is at most ${String(SESSION_FILE_MAX)} bytes`, 413);
40
- }
41
- if (kept < HEAD_BYTES) {
42
- head.push(buf.subarray(0, HEAD_BYTES - kept));
43
- kept += buf.length;
44
- }
45
- if (!out.write(buf)) await new Promise<void>((r) => out.once('drain', r));
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 Buffer.concat(head).toString('utf8');
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
- mkdirSync(join(path, '..'), { recursive: true });
57
- const tmp = `${path}.metro-${String(process.pid)}`;
58
- const head = await spool(body, tmp);
59
- if (!firstLineIsJson(head)) {
60
- rmSync(tmp, { force: true });
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
- renameSync(tmp, path);
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 base = deps.metro ?? (bin === '' ? ['metro', 'claude'] : [process.execPath, bin, 'claude']);
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
- return continues ? [...base, '-c'] : base;
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 run = spawnSync(tmux, ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: home });
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 { writeAtomic } from '@metro-labs/core/secure-fs';
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
- writeAtomic(target.path, text);
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, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
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
- mkdirSync(dirname(path), { recursive: true });
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
- writeFileSync(path, text, { mode: 0o644 });
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
- writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
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, rmSync, statSync } from 'node:fs';
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 { writeAtomic } from '@metro-labs/core/secure-fs';
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
- writeAtomic(skill.path, text, DEFAULT_MODE);
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
- writeAtomic(path, body, DEFAULT_MODE);
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
- rmSync(folder, { recursive: true, force: true });
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
- const candidates = (deps: VersionDeps): string[] => (deps.claude === undefined ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [deps.claude]);
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,4 +1,5 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
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
- writeFileSync(path, text, { mode: 0o644 });
94
+ writeHomeInPlace(path, text, 0o644);
94
95
  return true;
95
96
  }
96
97
 
@@ -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[0] !== 'tmux') return;
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
  });
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.182"
2
+ "version": "0.1.0-beta.183"
3
3
  }