@stage-labs/metro 0.1.0-beta.52 → 0.1.0-beta.54
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/dist/local.js +26 -0
- package/dist/runtime-install.js +84 -0
- package/dist/serve.js +8 -5
- package/package.json +1 -22
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +6 -13
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +3 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +13 -16
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +6 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +3 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-key.ts +33 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-registry.ts +13 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-routes.ts +54 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/{session-config.ts → return-to.ts} +0 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-deps.ts +82 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +9 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/signed-identity.ts +36 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +3 -33
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-types.ts +12 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/address.ts +6 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-scope.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +2 -12
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -16
- package/runtime/node_modules/@metro-labs/mcp/src/db/stations.ts +12 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +4 -14
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/accounts.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +2 -28
- package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/stations/attach.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +35 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +0 -21
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -96
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +0 -73
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +0 -43
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +0 -89
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +0 -45
package/dist/local.js
CHANGED
|
@@ -29,6 +29,32 @@ export function localAgents(dir = agentsDir()) {
|
|
|
29
29
|
}
|
|
30
30
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
31
|
}
|
|
32
|
+
export function localStations(dir = agentsDir()) {
|
|
33
|
+
if (!existsSync(dir))
|
|
34
|
+
return [];
|
|
35
|
+
const out = new Set();
|
|
36
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
if (!entry.isDirectory())
|
|
38
|
+
continue;
|
|
39
|
+
const path = join(dir, entry.name, 'agent.json');
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
|
+
if (!Array.isArray(file.stations))
|
|
45
|
+
continue;
|
|
46
|
+
for (const s of file.stations) {
|
|
47
|
+
const station = s.station;
|
|
48
|
+
if (typeof station === 'string')
|
|
49
|
+
out.add(station);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...out].sort();
|
|
57
|
+
}
|
|
32
58
|
export function pickLocalAgent(agents, wanted) {
|
|
33
59
|
if (wanted !== undefined && wanted !== '') {
|
|
34
60
|
const found = agents.find((a) => a.name === wanted || a.id === wanted);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { localStations } from './local.js';
|
|
6
|
+
import { SERVER_ENTRY, findBun, runtimeDir } from './runtime.js';
|
|
7
|
+
export const MANIFEST_FILE = 'stations.json';
|
|
8
|
+
const METRO_SOURCES = join('node_modules', '@metro-labs');
|
|
9
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
10
|
+
export function runtimeStore() {
|
|
11
|
+
const env = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
12
|
+
return env === '' ? join(homedir(), '.metro', 'runtime') : env;
|
|
13
|
+
}
|
|
14
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
15
|
+
const ranges = (raw) => isRecord(raw)
|
|
16
|
+
? Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === 'string'))
|
|
17
|
+
: {};
|
|
18
|
+
export function readManifest(path) {
|
|
19
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
20
|
+
if (!isRecord(raw))
|
|
21
|
+
throw new Error(`${path} is not a runtime manifest`);
|
|
22
|
+
const stations = isRecord(raw.stations) ? raw.stations : {};
|
|
23
|
+
return {
|
|
24
|
+
core: ranges(raw.core),
|
|
25
|
+
stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function dependenciesFor(manifest, stations) {
|
|
29
|
+
const out = { ...manifest.core };
|
|
30
|
+
for (const station of new Set(stations))
|
|
31
|
+
Object.assign(out, manifest.stations[station] ?? {});
|
|
32
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
|
|
33
|
+
}
|
|
34
|
+
const packageText = (deps) => `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
|
|
35
|
+
function readOrNull(path) {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(path, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function syncSources(sources, store) {
|
|
44
|
+
const stamp = readOrNull(join(sources, 'runtime.json'));
|
|
45
|
+
if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
|
|
46
|
+
return false;
|
|
47
|
+
rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
|
|
48
|
+
cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
|
|
49
|
+
if (stamp !== null)
|
|
50
|
+
writeFileSync(join(store, 'runtime.json'), stamp);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
export function installDependencies(store, deps, bun, log) {
|
|
54
|
+
const wanted = packageText(deps);
|
|
55
|
+
const marker = join(store, 'node_modules', '.metro-installed');
|
|
56
|
+
if (existsSync(marker) && readOrNull(join(store, 'package.json')) === wanted)
|
|
57
|
+
return false;
|
|
58
|
+
writeFileSync(join(store, 'package.json'), wanted);
|
|
59
|
+
log(`Installing the channel SDKs this machine needs into ${store}`);
|
|
60
|
+
const run = spawnSync(bun, ['install', '--no-summary', '--no-progress'], {
|
|
61
|
+
cwd: store,
|
|
62
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
63
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
64
|
+
});
|
|
65
|
+
if (run.error !== undefined || run.status !== 0)
|
|
66
|
+
throw new Error(`bun install failed in ${store}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
|
|
67
|
+
writeFileSync(marker, `${new Date().toISOString()}\n`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
export function prepareRuntime(opts = {}) {
|
|
71
|
+
const sources = opts.sources ?? runtimeDir();
|
|
72
|
+
const manifestPath = join(sources, MANIFEST_FILE);
|
|
73
|
+
if (!existsSync(manifestPath))
|
|
74
|
+
return { dir: sources, entry: join(sources, SERVER_ENTRY), trains: join(sources, 'trains'), manifest: null };
|
|
75
|
+
const store = opts.store ?? runtimeStore();
|
|
76
|
+
const log = opts.log ??
|
|
77
|
+
((line) => {
|
|
78
|
+
process.stderr.write(`${line}\n`);
|
|
79
|
+
});
|
|
80
|
+
mkdirSync(join(store, 'trains'), { recursive: true });
|
|
81
|
+
syncSources(sources, store);
|
|
82
|
+
installDependencies(store, dependenciesFor(readManifest(manifestPath), localStations(opts.agents)), opts.bun ?? findBun(), log);
|
|
83
|
+
return { dir: store, entry: join(store, SERVER_ENTRY), trains: join(store, 'trains'), manifest: manifestPath };
|
|
84
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -4,7 +4,8 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { agentsDir } from './local.js';
|
|
5
5
|
import { currentVersion } from './version.js';
|
|
6
6
|
import { serveLockedBy, serveStateDir } from './control.js';
|
|
7
|
-
import { findBun, localPort,
|
|
7
|
+
import { findBun, localPort, SERVER_ENTRY, spawnPlan } from './runtime.js';
|
|
8
|
+
import { prepareRuntime } from './runtime-install.js';
|
|
8
9
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
9
10
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
10
11
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
@@ -80,7 +81,7 @@ export function servePlan(opts) {
|
|
|
80
81
|
return {
|
|
81
82
|
command: findBun(),
|
|
82
83
|
args: [SERVER_ENTRY],
|
|
83
|
-
cwd: opts.dir,
|
|
84
|
+
cwd: opts.runtime.dir,
|
|
84
85
|
env: {
|
|
85
86
|
...env,
|
|
86
87
|
METRO_MODE: 'local',
|
|
@@ -88,7 +89,10 @@ export function servePlan(opts) {
|
|
|
88
89
|
METRO_CLI_BIN: process.argv[1] ?? '',
|
|
89
90
|
METRO_WEBHOOK_PORT: String(opts.port),
|
|
90
91
|
METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
91
|
-
METRO_TRAINS_DIR:
|
|
92
|
+
METRO_TRAINS_DIR: opts.runtime.trains,
|
|
93
|
+
...(opts.runtime.manifest === null
|
|
94
|
+
? {}
|
|
95
|
+
: { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
|
|
92
96
|
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
93
97
|
...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
|
|
94
98
|
...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
|
|
@@ -104,7 +108,6 @@ export function serve(argv) {
|
|
|
104
108
|
requireOwner(owner);
|
|
105
109
|
if (tunnel)
|
|
106
110
|
findCloudflared();
|
|
107
|
-
const dir = runtimeDir();
|
|
108
111
|
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
|
|
109
|
-
return spawnPlan(() => servePlan({
|
|
112
|
+
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner }));
|
|
110
113
|
}
|
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.54",
|
|
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": {
|
|
@@ -35,26 +35,5 @@
|
|
|
35
35
|
"knip": "^6.15.0",
|
|
36
36
|
"madge": "^8.0.0",
|
|
37
37
|
"typescript": "^5"
|
|
38
|
-
},
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"@discordjs/voice": "^0.19.2",
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
42
|
-
"@mtcute/bun": "0.30.1",
|
|
43
|
-
"@xmtp/content-type-primitives": "^3.0.1",
|
|
44
|
-
"@xmtp/content-type-reaction": "^2.0.2",
|
|
45
|
-
"@xmtp/content-type-remote-attachment": "^2.0.4",
|
|
46
|
-
"@xmtp/content-type-reply": "^2.0.3",
|
|
47
|
-
"@xmtp/content-type-wallet-send-calls": "^2.0.0",
|
|
48
|
-
"@xmtp/node-bindings": "1.9.1",
|
|
49
|
-
"@xmtp/node-sdk": "^6.0.0",
|
|
50
|
-
"baileys": "7.0.0-rc14",
|
|
51
|
-
"discord.js": "^14.26.4",
|
|
52
|
-
"drizzle-orm": "^0.44.0",
|
|
53
|
-
"pino": "^9.5.0",
|
|
54
|
-
"pino-pretty": "^13.1.3",
|
|
55
|
-
"postgres": "^3.4.5",
|
|
56
|
-
"prism-media": "^1.3.5",
|
|
57
|
-
"viem": "^2.52.2",
|
|
58
|
-
"zod": "^3.23.8"
|
|
59
38
|
}
|
|
60
39
|
}
|
|
@@ -259,20 +259,20 @@ function methodAllowed(tgt: Routable, method: string | undefined): boolean {
|
|
|
259
259
|
return ALLOWED[tgt.kind].includes(method ?? '');
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
function dispatch(
|
|
262
|
+
async function dispatch(
|
|
263
263
|
req: IncomingMessage,
|
|
264
264
|
res: ServerResponse,
|
|
265
265
|
deps: AgentApiDeps,
|
|
266
266
|
tgt: Routable,
|
|
267
267
|
): Promise<void> {
|
|
268
|
-
const session = apiSession(req);
|
|
268
|
+
const session = await apiSession(req);
|
|
269
269
|
if (!session) {
|
|
270
270
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
271
|
-
return
|
|
271
|
+
return;
|
|
272
272
|
}
|
|
273
273
|
if (tgt.kind === 'accounts')
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
await handleAccountRoute(req, res, deps, session, tgt.id, tgt.route);
|
|
275
|
+
else await routeAgent(req, res, deps, session, tgt);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export function handleAgentApiRequest(
|
|
@@ -122,12 +122,15 @@ export function handleAgentConnectorRequest(
|
|
|
122
122
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
123
123
|
return true;
|
|
124
124
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
apiSession(req)
|
|
126
|
+
.then((session) => {
|
|
127
|
+
if (!session) {
|
|
128
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
return route(req, res, deps, session, tgt);
|
|
132
|
+
})
|
|
133
|
+
.catch((err: unknown) => {
|
|
131
134
|
log.warn({ err: errMsg(err) }, 'agent-api: unhandled error');
|
|
132
135
|
if (!res.headersSent) sendJson(req, res, 500, { error: 'agent api failed' });
|
|
133
136
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from './api-error.js';
|
|
3
3
|
import { errMsg, log } from './log.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { signedIdentity } from './signed-identity.js';
|
|
5
|
+
import { identitySubject } from './identity-registry.js';
|
|
6
6
|
|
|
7
7
|
const BODY_MAX = 4 * 1024;
|
|
8
8
|
|
|
@@ -35,17 +35,10 @@ export function sendJson(
|
|
|
35
35
|
res.end(JSON.stringify(body));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function apiSession(req: IncomingMessage): ApiSession | null {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (token === '') return null;
|
|
43
|
-
try {
|
|
44
|
-
const { subject } = verifySession(token, secret);
|
|
45
|
-
return { subject: subject.toLowerCase() };
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
38
|
+
export async function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
|
|
39
|
+
const address = await signedIdentity(req);
|
|
40
|
+
const subject = address === null ? undefined : identitySubject(address);
|
|
41
|
+
return subject === undefined ? null : { subject };
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
export interface AgentIdentity {
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
} from '../stations/attachments.js';
|
|
8
8
|
import {
|
|
9
9
|
allowedAgents,
|
|
10
|
-
authConfigFromEnv,
|
|
11
10
|
authenticate,
|
|
12
11
|
extractToken,
|
|
13
12
|
} from '../mcp/request-identity.js';
|
|
@@ -21,7 +20,7 @@ function authorized(req: IncomingMessage, name: string): boolean {
|
|
|
21
20
|
const owner = attachmentOwner(name);
|
|
22
21
|
if (owner === undefined) return false;
|
|
23
22
|
if (grantAllows(name, owner, extractToken(req) ?? '')) return true;
|
|
24
|
-
return allowedAgents(authenticate(req
|
|
23
|
+
return allowedAgents(authenticate(req) ?? undefined).has(
|
|
25
24
|
owner,
|
|
26
25
|
);
|
|
27
26
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { ApiError } from './api-error.js';
|
|
3
3
|
import { errMsg, log } from './log.js';
|
|
4
|
-
import type { StationName } from '../db/
|
|
4
|
+
import type { StationName } from '../db/stations.js';
|
|
5
|
+
import { ensureStationDeps } from './runtime-deps.js';
|
|
5
6
|
import {
|
|
6
7
|
startInteractiveAttach,
|
|
7
8
|
type AttachDriver,
|
|
@@ -216,6 +217,7 @@ export class AttachSessions {
|
|
|
216
217
|
};
|
|
217
218
|
this.sessions.set(attachId, session);
|
|
218
219
|
try {
|
|
220
|
+
ensureStationDeps(station);
|
|
219
221
|
const started = await (this.deps.start ?? startInteractiveAttach)(
|
|
220
222
|
station,
|
|
221
223
|
input,
|
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
} from './http.js';
|
|
21
21
|
import { localAgentKey } from '../db/materialize.js';
|
|
22
22
|
import { fileSource } from '../db/file-source.js';
|
|
23
|
-
import { ensureLocalSessionSecret } from './local-secret.js';
|
|
24
23
|
import { applyLocalOwner } from './local-owner.js';
|
|
25
24
|
import { localOwner } from '../db/file-admin.js';
|
|
25
|
+
import { ensureStationDeps } from './runtime-deps.js';
|
|
26
26
|
import { localSessionApis } from './local-mode.js';
|
|
27
27
|
import type { ModeInfo } from './mode-api.js';
|
|
28
28
|
import type { SessionApis } from './session-apis.js';
|
|
@@ -39,13 +39,7 @@ import {
|
|
|
39
39
|
} from '../stations/registry.js';
|
|
40
40
|
import { prepareAccount } from '../stations/attach.js';
|
|
41
41
|
import { materializeFrom, reloadFrom } from '../db/materialize.js';
|
|
42
|
-
import {
|
|
43
|
-
deleteVaultForOwner,
|
|
44
|
-
getVaultForOwner,
|
|
45
|
-
listVaultForOwner,
|
|
46
|
-
putVaultForOwner,
|
|
47
|
-
} from '../db/vault.js';
|
|
48
|
-
import type { StationName } from '../db/schema.js';
|
|
42
|
+
import type { StationName } from '../db/stations.js';
|
|
49
43
|
import { startUploadReaper } from './upload-store.js';
|
|
50
44
|
|
|
51
45
|
installCrashGuard();
|
|
@@ -96,7 +90,10 @@ async function syncStations(station: StationName): Promise<void> {
|
|
|
96
90
|
const { removed } = await reloadFrom(fileSource);
|
|
97
91
|
if (stationByName(station)?.hasTrain === false) return;
|
|
98
92
|
if (removed.includes(station)) await supervisor.stopTrain(station);
|
|
99
|
-
else
|
|
93
|
+
else {
|
|
94
|
+
ensureStationDeps(station);
|
|
95
|
+
supervisor.requestReload(station);
|
|
96
|
+
}
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
const hostedMode = (): ModeInfo => ({
|
|
@@ -106,7 +103,7 @@ const hostedMode = (): ModeInfo => ({
|
|
|
106
103
|
version: METRO_VERSION,
|
|
107
104
|
});
|
|
108
105
|
|
|
109
|
-
function sessionApis(): SessionApis {
|
|
106
|
+
async function sessionApis(): Promise<SessionApis> {
|
|
110
107
|
if (isLocalMode())
|
|
111
108
|
return localSessionApis({
|
|
112
109
|
syncStations,
|
|
@@ -120,12 +117,13 @@ function sessionApis(): SessionApis {
|
|
|
120
117
|
liveness: agentLiveness,
|
|
121
118
|
prepareAccount,
|
|
122
119
|
});
|
|
120
|
+
const vault = await import('../db/vault.js');
|
|
123
121
|
return {
|
|
124
122
|
vaultApi: {
|
|
125
|
-
list: listVaultForOwner,
|
|
126
|
-
put: putVaultForOwner,
|
|
127
|
-
get: getVaultForOwner,
|
|
128
|
-
remove: deleteVaultForOwner,
|
|
123
|
+
list: vault.listVaultForOwner,
|
|
124
|
+
put: vault.putVaultForOwner,
|
|
125
|
+
get: vault.getVaultForOwner,
|
|
126
|
+
remove: vault.deleteVaultForOwner,
|
|
129
127
|
},
|
|
130
128
|
mode: hostedMode,
|
|
131
129
|
};
|
|
@@ -133,7 +131,6 @@ function sessionApis(): SessionApis {
|
|
|
133
131
|
|
|
134
132
|
async function main(): Promise<void> {
|
|
135
133
|
if (isLocalMode()) {
|
|
136
|
-
ensureLocalSessionSecret();
|
|
137
134
|
applyLocalOwner();
|
|
138
135
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
139
136
|
}
|
|
@@ -141,7 +138,7 @@ async function main(): Promise<void> {
|
|
|
141
138
|
const metroMcp = await createMetroMcp();
|
|
142
139
|
webhookServer = await startWebhookServer(
|
|
143
140
|
emit,
|
|
144
|
-
sessionApis(),
|
|
141
|
+
await sessionApis(),
|
|
145
142
|
metroMcp.httpHandler,
|
|
146
143
|
metroCall,
|
|
147
144
|
);
|
|
@@ -5,7 +5,7 @@ import { ApiError } from './api-error.js';
|
|
|
5
5
|
import { isRecord } from './is-record.js';
|
|
6
6
|
import { parseId } from '../db/ids.js';
|
|
7
7
|
import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../db/materialize.js';
|
|
8
|
-
import { STATIONS } from '../db/
|
|
8
|
+
import { STATIONS } from '../db/stations.js';
|
|
9
9
|
|
|
10
10
|
const AGENTS = '/api/agents';
|
|
11
11
|
const RESTORE_PATH = `${AGENTS}/restore`;
|
|
@@ -92,12 +92,11 @@ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
92
92
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
answer(req, deps, session.subject, path)
|
|
95
|
+
apiSession(req)
|
|
96
|
+
.then((session) => {
|
|
97
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
98
|
+
return answer(req, deps, session.subject, path);
|
|
99
|
+
})
|
|
101
100
|
.then((body) => {
|
|
102
101
|
sendJson(req, res, isRestore ? 201 : 200, body);
|
|
103
102
|
})
|
|
@@ -77,13 +77,9 @@ export function handleClaudeRequest(
|
|
|
77
77
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
78
78
|
return true;
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
Promise.resolve()
|
|
86
|
-
.then(() => {
|
|
80
|
+
apiSession(req)
|
|
81
|
+
.then((session) => {
|
|
82
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
87
83
|
deps.authorize(session.subject);
|
|
88
84
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
|
|
89
85
|
})
|
|
@@ -265,7 +265,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
|
|
|
265
265
|
rename: ['POST'],
|
|
266
266
|
};
|
|
267
267
|
|
|
268
|
-
function dispatch(
|
|
268
|
+
async function dispatch(
|
|
269
269
|
req: IncomingMessage,
|
|
270
270
|
res: ServerResponse,
|
|
271
271
|
deps: ConnectorApiDeps,
|
|
@@ -273,14 +273,14 @@ function dispatch(
|
|
|
273
273
|
): Promise<void> {
|
|
274
274
|
if (tgt.kind === 'callback') {
|
|
275
275
|
handleCallback(req, res, deps);
|
|
276
|
-
return
|
|
276
|
+
return;
|
|
277
277
|
}
|
|
278
|
-
const session = apiSession(req);
|
|
278
|
+
const session = await apiSession(req);
|
|
279
279
|
if (!session) {
|
|
280
280
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
281
|
-
return
|
|
281
|
+
return;
|
|
282
282
|
}
|
|
283
|
-
|
|
283
|
+
await route(req, res, deps, session, tgt);
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
export function handleConnectorApiRequest(
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from './tunnel.js';
|
|
28
28
|
import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
|
|
29
29
|
import { webhookEntry } from '@metro-labs/webhook';
|
|
30
|
-
import {
|
|
30
|
+
import { handleIdentityRequest } from './identity-routes.js';
|
|
31
31
|
import { handleModeRequest } from './mode-api.js';
|
|
32
32
|
import { handleUploadRequest } from './upload-api.js';
|
|
33
33
|
import {
|
|
@@ -328,7 +328,7 @@ function handleSignInRoutes(
|
|
|
328
328
|
res: ServerResponse,
|
|
329
329
|
apis: SessionApis,
|
|
330
330
|
): boolean {
|
|
331
|
-
if (
|
|
331
|
+
if (apis.identity !== undefined && handleIdentityRequest(req, res, apis.identity)) return true;
|
|
332
332
|
return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
|
|
333
333
|
}
|
|
334
334
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hkdfSync } from 'node:crypto';
|
|
2
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
3
|
+
import { mapHashToField } from '@noble/curves/abstract/modular';
|
|
4
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTION_KEY_TYPED_DATA = {
|
|
7
|
+
domain: { name: 'metro', version: '1' },
|
|
8
|
+
types: {
|
|
9
|
+
EncryptionKey: [
|
|
10
|
+
{ name: 'purpose', type: 'string' },
|
|
11
|
+
{ name: 'keyVersion', type: 'uint256' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
primaryType: 'EncryptionKey',
|
|
15
|
+
message: { purpose: 'encryption-key', keyVersion: 1n },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const KEY_SALT = 'metro';
|
|
19
|
+
const IDENTITY_INFO = 'vault-secp256k1';
|
|
20
|
+
const KEY_HASH_BYTES = 48;
|
|
21
|
+
|
|
22
|
+
const toHex = (bytes: Uint8Array): `0x${string}` =>
|
|
23
|
+
`0x${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
24
|
+
|
|
25
|
+
export function deriveIdentityKey(signature: `0x${string}`): `0x${string}` {
|
|
26
|
+
const ikm = Buffer.from(signature.slice(2), 'hex');
|
|
27
|
+
const hash = new Uint8Array(hkdfSync('sha256', ikm, KEY_SALT, IDENTITY_INFO, KEY_HASH_BYTES));
|
|
28
|
+
return toHex(mapHashToField(hash, secp256k1.CURVE.n));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function deriveIdentityAddress(signature: `0x${string}`): string {
|
|
32
|
+
return privateKeyToAccount(deriveIdentityKey(signature)).address.toLowerCase();
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const subjects = new Map<string, string>();
|
|
2
|
+
|
|
3
|
+
export function authorizeIdentity(address: string, subject: string): void {
|
|
4
|
+
subjects.set(address.toLowerCase(), subject.toLowerCase());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function identitySubject(address: string): string | undefined {
|
|
8
|
+
return subjects.get(address.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resetIdentities(): void {
|
|
12
|
+
subjects.clear();
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { verifyTypedData } from 'viem';
|
|
3
|
+
import { log } from './log.js';
|
|
4
|
+
import { ApiError } from './api-error.js';
|
|
5
|
+
import { apiFailure, bodyField, cors, readJsonBody, sendJson } from './api-http.js';
|
|
6
|
+
import { ENCRYPTION_KEY_TYPED_DATA, deriveIdentityAddress } from './identity-key.js';
|
|
7
|
+
import { authorizeIdentity } from './identity-registry.js';
|
|
8
|
+
|
|
9
|
+
const PATH = '/auth/identity';
|
|
10
|
+
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
11
|
+
|
|
12
|
+
export interface IdentityRouteDeps {
|
|
13
|
+
owner: () => string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function register(req: IncomingMessage, deps: IdentityRouteDeps): Promise<{ address: string; owner: string }> {
|
|
17
|
+
const signature = bodyField(await readJsonBody(req), 'signature');
|
|
18
|
+
if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature))
|
|
19
|
+
throw new ApiError('signature is required: the EncryptionKey signature of the owner wallet', 400);
|
|
20
|
+
const owner = deps.owner();
|
|
21
|
+
if (owner === null)
|
|
22
|
+
throw new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403);
|
|
23
|
+
const ok = await verifyTypedData({
|
|
24
|
+
...ENCRYPTION_KEY_TYPED_DATA,
|
|
25
|
+
address: owner as `0x${string}`,
|
|
26
|
+
signature: signature as `0x${string}`,
|
|
27
|
+
}).catch(() => false);
|
|
28
|
+
if (!ok) throw new ApiError('this machine belongs to another wallet', 403);
|
|
29
|
+
const address = deriveIdentityAddress(signature as `0x${string}`);
|
|
30
|
+
authorizeIdentity(address, owner);
|
|
31
|
+
log.info({ owner, identity: address }, 'identity: owner signed in');
|
|
32
|
+
return { address, owner };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function handleIdentityRequest(req: IncomingMessage, res: ServerResponse, deps: IdentityRouteDeps): boolean {
|
|
36
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
37
|
+
if (path !== PATH) return false;
|
|
38
|
+
if (req.method === 'OPTIONS') {
|
|
39
|
+
res.writeHead(204, cors(req)).end();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (req.method !== 'POST') {
|
|
43
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
register(req, deps)
|
|
47
|
+
.then((body) => {
|
|
48
|
+
sendJson(req, res, 200, body);
|
|
49
|
+
})
|
|
50
|
+
.catch((err: unknown) => {
|
|
51
|
+
apiFailure(req, res, err, 'identity');
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
@@ -44,11 +44,10 @@ import {
|
|
|
44
44
|
localOwnedAgentOrThrow,
|
|
45
45
|
localOwner,
|
|
46
46
|
localResetAgentKey,
|
|
47
|
-
ownerSignIn,
|
|
48
47
|
readLocalAgentFile,
|
|
49
48
|
} from '../db/file-admin.js';
|
|
50
49
|
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
51
|
-
import type { StationName } from '../db/
|
|
50
|
+
import type { StationName } from '../db/stations.js';
|
|
52
51
|
|
|
53
52
|
export interface LocalModeDeps {
|
|
54
53
|
syncStations: (station: StationName) => Promise<void>;
|
|
@@ -191,7 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
191
190
|
localCli,
|
|
192
191
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
193
192
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
194
|
-
|
|
193
|
+
identity: { owner: localOwner },
|
|
195
194
|
mode: localModeInfo,
|
|
196
195
|
};
|
|
197
196
|
}
|