@stage-labs/metro 0.1.0-beta.183 → 0.1.0-beta.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/api.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +9 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +6 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +7 -1
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.184",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.184",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
6
|
+
import { log } from '@metro-labs/core/log';
|
|
7
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
8
|
+
import { agentsDir } from '../agents/files.js';
|
|
9
|
+
import { agentUser, CONFIG_FILE, forgetAgentUser, wantedAgentUser } from './user.js';
|
|
10
|
+
|
|
11
|
+
const PATH = '/api/agent-user';
|
|
12
|
+
const EXIT_DELAY_MS = 500;
|
|
13
|
+
|
|
14
|
+
export interface AgentUserApiDeps {
|
|
15
|
+
restart: () => void;
|
|
16
|
+
agents?: () => string;
|
|
17
|
+
host?: () => { platform: string; uid: number | undefined; cli: boolean };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentUserStatus {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
user: string | null;
|
|
23
|
+
active: boolean;
|
|
24
|
+
supported: boolean;
|
|
25
|
+
reason: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const realHost = (): { platform: string; uid: number | undefined; cli: boolean } => ({
|
|
29
|
+
platform: process.platform,
|
|
30
|
+
uid: process.getuid?.(),
|
|
31
|
+
cli: (process.env.METRO_CLI_BIN?.trim() ?? '') !== '',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function unsupported(host: { platform: string; uid: number | undefined; cli: boolean }): string | null {
|
|
35
|
+
if (host.platform !== 'linux') return 'Claude Code can run as its own user on Linux only.';
|
|
36
|
+
if (host.uid !== 0) return 'Metro runs as a normal user here, so it cannot switch Claude Code to another user.';
|
|
37
|
+
if (!host.cli) return 'This daemon was not started by metro serve, so nothing would bring it back after the switch.';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function agentUserStatus(deps: Pick<AgentUserApiDeps, 'agents' | 'host'> = {}): AgentUserStatus {
|
|
42
|
+
const dir = (deps.agents ?? agentsDir)();
|
|
43
|
+
const reason = unsupported((deps.host ?? realHost)());
|
|
44
|
+
const user = wantedAgentUser(dir);
|
|
45
|
+
return { enabled: user !== null, user, active: user !== null && agentUser(dir) !== null, supported: reason === null, reason };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setWanted(dir: string, enabled: boolean): void {
|
|
49
|
+
const path = join(dir, CONFIG_FILE);
|
|
50
|
+
if (enabled) writeSecure(path, `${JSON.stringify({ user: 'agent' })}\n`);
|
|
51
|
+
else if (existsSync(path)) rmSync(path);
|
|
52
|
+
forgetAgentUser();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
|
|
56
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: ['POST'], label: 'agent-user-api' }, async (session) => {
|
|
57
|
+
if (req.method === 'GET') return agentUserStatus(deps);
|
|
58
|
+
const enabled = bodyField(await readJsonBody(req), 'enabled');
|
|
59
|
+
if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
|
|
60
|
+
const reason = unsupported((deps.host ?? realHost)());
|
|
61
|
+
if (enabled && reason !== null) throw new ApiError(reason, 400);
|
|
62
|
+
const dir = (deps.agents ?? agentsDir)();
|
|
63
|
+
setWanted(dir, enabled);
|
|
64
|
+
log.info({ enabled, subject: session.subject }, 'agent-user-api: switched from the page; restarting');
|
|
65
|
+
setTimeout(deps.restart, EXIT_DELAY_MS).unref();
|
|
66
|
+
return { ...agentUserStatus(deps), restarting: true };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -123,11 +123,11 @@ export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessE
|
|
|
123
123
|
try {
|
|
124
124
|
const user = ensureUser(name);
|
|
125
125
|
if (user === null) throw new Error(`the user ${name} could not be found after creating it`);
|
|
126
|
+
stopRootSession();
|
|
126
127
|
migrateClaude(user, dir);
|
|
127
128
|
await installClaude(user);
|
|
128
129
|
copyMarketplace(user, env);
|
|
129
130
|
watchAgentView();
|
|
130
|
-
stopRootSession();
|
|
131
131
|
return 'ready';
|
|
132
132
|
} catch (err) {
|
|
133
133
|
log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type PendingAtt,
|
|
22
22
|
type PendingMsg,
|
|
23
23
|
} from './pending.js';
|
|
24
|
+
import { agentUser } from '../agent-user/user.js';
|
|
24
25
|
|
|
25
26
|
interface InboundDeps {
|
|
26
27
|
mcp: Server;
|
|
@@ -106,11 +107,12 @@ export class InboundRelay {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
private async surfaceMedia(ctx: MediaCtx, p: SavedMedia): Promise<void> {
|
|
109
|
-
const
|
|
110
|
+
const ownUser = agentUser() !== null;
|
|
111
|
+
const note = await buildMediaNote(p, ctx.text ?? '', !ownUser);
|
|
110
112
|
if (!note) return;
|
|
111
113
|
await this.surfaceNote(ctx, p, note, {
|
|
112
114
|
...(p.url ? { url: p.url } : {}),
|
|
113
|
-
local_path: note.path,
|
|
115
|
+
...(ownUser ? {} : { local_path: note.path }),
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -50,22 +50,27 @@ function howToRead(url: string | undefined, tooBig: boolean): string {
|
|
|
50
50
|
return 'Fetch the Public URL to view it. The path is on the daemon host, so the Read tool works only for an agent running there.';
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const headline = (kind: string, name: string, mime: string | undefined, size: number): string =>
|
|
54
|
+
`[${kind} attachment received: ${name}${mime ? `, ${mime}` : ''}${size ? ` (${(size / 1024 / 1024).toFixed(2)} MB)` : ''}]\n`;
|
|
55
|
+
|
|
56
|
+
const whereItIs = (path: string, url: string | undefined, size: number, showPath: boolean): string =>
|
|
57
|
+
showPath ? `Daemon-host path: ${path}\n${howToRead(url, size > MAX_INLINE_BYTES)}` : 'Fetch the Public URL to get the file.';
|
|
58
|
+
|
|
53
59
|
export async function buildMediaNote(
|
|
54
60
|
p: SavedMedia,
|
|
55
61
|
caption: string,
|
|
62
|
+
showPath = true,
|
|
56
63
|
): Promise<MediaNote | null> {
|
|
57
64
|
const path = p.attachmentPath ?? p.localPath;
|
|
58
65
|
if (!path) return null;
|
|
59
66
|
const kind = p.kind ?? mediaKind(p.mime, p.name);
|
|
60
67
|
const name = p.name ?? path.split('/').pop() ?? 'attachment';
|
|
61
68
|
const size = await fileSize(path);
|
|
62
|
-
const sizeNote = size ? ` (${(size / 1024 / 1024).toFixed(2)} MB)` : '';
|
|
63
69
|
const content =
|
|
64
70
|
(caption ? `${caption}\n` : '') +
|
|
65
|
-
|
|
71
|
+
headline(kind, name, p.mime, size) +
|
|
66
72
|
(p.url ? `Public URL: ${p.url}\n` : '') +
|
|
67
|
-
|
|
68
|
-
howToRead(p.url, size > MAX_INLINE_BYTES);
|
|
73
|
+
whereItIs(path, p.url, size, showPath);
|
|
69
74
|
return { content, kind, name, path };
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -96,9 +96,14 @@ function realDir(dir: string): string {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
|
|
100
|
+
|
|
99
101
|
export function hasConversation(home: string, dir = claudeDir()): boolean {
|
|
100
102
|
const cwd = realDir(home);
|
|
101
|
-
|
|
103
|
+
const folder = encodedCwd(cwd);
|
|
104
|
+
return listClaudeProjects(dir).some(
|
|
105
|
+
(project) => project.sessions > 0 && (project.id === folder || (project.cwd !== null && realDir(project.cwd) === cwd)),
|
|
106
|
+
);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function metroCommand(deps: SessionDeps, home: string): string[] {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { writeHomeInPlace } from '../agent-user/home-fs.js';
|
|
3
|
+
import { agentMarketplaceDir, agentUser } from '../agent-user/user.js';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
6
|
import { claudeDir } from '../claude/files.js';
|
|
@@ -70,7 +71,9 @@ function pluginRootsUnder(dir: string, depth: number, out: string[]): void {
|
|
|
70
71
|
|
|
71
72
|
export function stagedPluginDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
72
73
|
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
73
|
-
|
|
74
|
+
if (store === '') return null;
|
|
75
|
+
const user = agentUser();
|
|
76
|
+
return join(user === null ? join(store, 'marketplace') : agentMarketplaceDir(user), 'plugin');
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
export function installedPluginFiles(dir = claudeDir(), staged = stagedPluginDir()): string[] {
|
|
@@ -5,6 +5,7 @@ import { agentIdForAccount, accountFromLine, knownAccounts } from '../agents/map
|
|
|
5
5
|
import { attachmentUrl } from '../files/attach-serve.js';
|
|
6
6
|
import { stationByName } from '../stations/registry.js';
|
|
7
7
|
import { errResult, makeCtx, okJson, toErr } from './ctx.js';
|
|
8
|
+
import { agentUser } from '../agent-user/user.js';
|
|
8
9
|
|
|
9
10
|
const FILTERS: readonly (readonly [string, ReadFilter, string])[] = [
|
|
10
11
|
['query', 'query', 'query'],
|
|
@@ -45,7 +46,8 @@ function withUrls(result: unknown, agentId: string | undefined): unknown {
|
|
|
45
46
|
const attachments = files.map((f: unknown) => {
|
|
46
47
|
if (!isRecord(f) || typeof f.local_path !== 'string') return f;
|
|
47
48
|
const url = attachmentUrl(f.local_path, agentId);
|
|
48
|
-
|
|
49
|
+
const shown = agentUser() === null ? f : Object.fromEntries(Object.entries(f).filter(([key]) => key !== 'local_path'));
|
|
50
|
+
return url === null ? shown : { ...shown, url };
|
|
49
51
|
});
|
|
50
52
|
return { ...result, message: { ...result.message, attachments } };
|
|
51
53
|
}
|
|
@@ -195,6 +195,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
195
195
|
claudeApi: {},
|
|
196
196
|
updateApi: { restart: deps.restart },
|
|
197
197
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
198
|
+
agentUserApi: { restart: deps.restart },
|
|
198
199
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
199
200
|
machineApi: {},
|
|
200
201
|
modelApi: {},
|
|
@@ -17,12 +17,14 @@ import { handleMachineRequest, type MachineApiDeps } from '../server/machine.js'
|
|
|
17
17
|
import { handleModelRequest, type ModelApiDeps } from '../gateway/model-api.js';
|
|
18
18
|
import type { GatewayDeps } from '../gateway/gateway.js';
|
|
19
19
|
import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js';
|
|
20
|
+
import { handleAgentUserRequest, type AgentUserApiDeps } from '../agent-user/api.js';
|
|
20
21
|
|
|
21
22
|
export interface SessionApis {
|
|
22
23
|
agentApi?: AgentApiDeps;
|
|
23
24
|
bundleApi?: BundleApiDeps;
|
|
24
25
|
updateApi?: UpdateApiDeps;
|
|
25
26
|
controlApi?: ControlApiDeps;
|
|
27
|
+
agentUserApi?: AgentUserApiDeps;
|
|
26
28
|
ownerApi?: OwnerApiDeps;
|
|
27
29
|
machineApi?: MachineApiDeps;
|
|
28
30
|
modelApi?: ModelApiDeps;
|
|
@@ -49,6 +51,7 @@ export function handleSessionApis(
|
|
|
49
51
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
50
52
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
51
53
|
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
54
|
+
...when(apis.agentUserApi, (d) => handleAgentUserRequest(req, res, d)),
|
|
52
55
|
...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
|
|
53
56
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
54
57
|
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
@@ -64,7 +64,13 @@ async function fromPathAs(
|
|
|
64
64
|
): Promise<ResolvedAttachment> {
|
|
65
65
|
const proc = Bun.spawn(agentCommand(['cat', '--', path]), { stdout: 'pipe', stderr: 'pipe' });
|
|
66
66
|
const name = a.name ?? basenameOf(path) ?? 'attachment';
|
|
67
|
-
|
|
67
|
+
let saved: Awaited<ReturnType<typeof streamToTemp>>;
|
|
68
|
+
try {
|
|
69
|
+
saved = await streamToTemp(proc.stdout, name);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
proc.kill();
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
68
74
|
if ((await proc.exited) !== 0) {
|
|
69
75
|
await removeInlineTemp(saved.dir);
|
|
70
76
|
throw new Error(
|
package/runtime/runtime.json
CHANGED