@stage-labs/metro 0.1.0-beta.33 → 0.1.0-beta.35
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/cli.js +13 -2
- package/dist/control.js +17 -2
- package/dist/runtime.js +20 -0
- package/dist/serve.js +12 -8
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +63 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +67 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +25 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +4 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +59 -1
- package/runtime/runtime.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { askSecret } from './prompt.js';
|
|
|
5
5
|
import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
|
|
6
6
|
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
7
|
import { tailEvents } from './tail.js';
|
|
8
|
-
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
|
|
8
|
+
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, runTokenState, writeRunToken, } from './runtime.js';
|
|
9
9
|
import { launchClaude } from './claude.js';
|
|
10
10
|
import { bedrock } from './bedrock.js';
|
|
11
11
|
import { installPlugin, syncPluginServers } from './plugin.js';
|
|
@@ -71,13 +71,24 @@ async function authorizeRuntime(agentId) {
|
|
|
71
71
|
process.stderr.write(`Authorized as '${claimed.label}'.\n`);
|
|
72
72
|
return claimed.token;
|
|
73
73
|
}
|
|
74
|
+
async function usableRunToken(agentId) {
|
|
75
|
+
const stored = readRunToken(agentId);
|
|
76
|
+
if (stored === null)
|
|
77
|
+
return authorizeRuntime(agentId);
|
|
78
|
+
const state = await runTokenState(stored);
|
|
79
|
+
if (state !== 'stale')
|
|
80
|
+
return stored;
|
|
81
|
+
process.stderr.write(`metro no longer recognises this machine as the holder of ${agentId}: ` +
|
|
82
|
+
'it was taken back, or another machine claimed it since. Authorize it again.\n');
|
|
83
|
+
return authorizeRuntime(agentId);
|
|
84
|
+
}
|
|
74
85
|
async function start(argv) {
|
|
75
86
|
const agentId = assertAgentId(argv[0]);
|
|
76
87
|
if (runningPid(agentId) !== null || lockedBy() !== null)
|
|
77
88
|
throw new Error('a metro daemon is already running on this machine. ' +
|
|
78
89
|
'Stop it first: metro stop');
|
|
79
90
|
const dir = runtimeDir();
|
|
80
|
-
const token =
|
|
91
|
+
const token = await usableRunToken(agentId);
|
|
81
92
|
const detached = argv.includes('--detach');
|
|
82
93
|
const plan = daemonPlan({ agentId, token, dir });
|
|
83
94
|
if (!detached)
|
package/dist/control.js
CHANGED
|
@@ -3,6 +3,12 @@ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, wri
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
const STATE = () => join(homedir(), '.metro');
|
|
6
|
+
export function serveStateDir() {
|
|
7
|
+
const xdg = process.env.XDG_CACHE_HOME?.trim();
|
|
8
|
+
const base = xdg === undefined || xdg === '' ? join(homedir(), '.cache') : xdg;
|
|
9
|
+
return join(base, 'metro', 'serve');
|
|
10
|
+
}
|
|
11
|
+
const serveLockPath = () => join(serveStateDir(), '.tail-lock');
|
|
6
12
|
const pidPath = (agentId) => join(STATE(), `run-${agentId}.pid`);
|
|
7
13
|
export const logPath = (agentId) => join(STATE(), 'logs', `${agentId}.log`);
|
|
8
14
|
function ensureStateDirs() {
|
|
@@ -83,10 +89,10 @@ function commandOf(pid) {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
const looksLikeMetro = (command) => command.includes('server.ts') || command.includes('metro');
|
|
86
|
-
|
|
92
|
+
function holderOf(lockFile) {
|
|
87
93
|
let pid;
|
|
88
94
|
try {
|
|
89
|
-
pid = Number(readFileSync(
|
|
95
|
+
pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
90
96
|
}
|
|
91
97
|
catch {
|
|
92
98
|
return null;
|
|
@@ -95,6 +101,8 @@ export function lockedBy() {
|
|
|
95
101
|
return null;
|
|
96
102
|
return looksLikeMetro(commandOf(pid)) ? pid : null;
|
|
97
103
|
}
|
|
104
|
+
export const lockedBy = () => holderOf(lockPath());
|
|
105
|
+
export const serveLockedBy = () => holderOf(serveLockPath());
|
|
98
106
|
function trackedAgents(agentId) {
|
|
99
107
|
const tracked = new Set();
|
|
100
108
|
if (agentId !== undefined)
|
|
@@ -125,10 +133,17 @@ export async function stopAll(agentId, waitMs = 10_000) {
|
|
|
125
133
|
}
|
|
126
134
|
const holder = lockedBy();
|
|
127
135
|
if (holder !== null && !seen.has(holder)) {
|
|
136
|
+
seen.add(holder);
|
|
128
137
|
await stopPid(holder, waitMs);
|
|
129
138
|
rmSync(lockPath(), { force: true });
|
|
130
139
|
stopped.push({ pid: holder, via: 'the machine lock' });
|
|
131
140
|
}
|
|
141
|
+
const served = serveLockedBy();
|
|
142
|
+
if (served !== null && !seen.has(served)) {
|
|
143
|
+
await stopPid(served, waitMs);
|
|
144
|
+
rmSync(serveLockPath(), { force: true });
|
|
145
|
+
stopped.push({ pid: served, via: 'metro serve' });
|
|
146
|
+
}
|
|
132
147
|
return stopped;
|
|
133
148
|
}
|
|
134
149
|
export function tail(agentId, follow) {
|
package/dist/runtime.js
CHANGED
|
@@ -57,6 +57,26 @@ export function writeRunToken(agentId, token) {
|
|
|
57
57
|
writeFileSync(path, JSON.stringify({ token, url: metroUrl() }, null, 2));
|
|
58
58
|
chmodSync(path, 0o600);
|
|
59
59
|
}
|
|
60
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
61
|
+
export async function runTokenState(token) {
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(`${metroUrl()}/api/run/config`, {
|
|
65
|
+
headers: { authorization: `Bearer ${token}` },
|
|
66
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
67
|
+
redirect: 'manual',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 'unreachable';
|
|
72
|
+
}
|
|
73
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
74
|
+
if (res.ok)
|
|
75
|
+
return 'ok';
|
|
76
|
+
return res.status === 401 || res.status === 403 || res.status === 409
|
|
77
|
+
? 'stale'
|
|
78
|
+
: 'unreachable';
|
|
79
|
+
}
|
|
60
80
|
export const localPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
|
|
61
81
|
export const localUrl = () => `http://127.0.0.1:${String(localPort())}`;
|
|
62
82
|
export function daemonPlan(opts) {
|
package/dist/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { serveLockedBy, serveStateDir } from './control.js';
|
|
4
4
|
import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
|
|
5
5
|
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
6
6
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
@@ -38,13 +38,13 @@ export function findCloudflared() {
|
|
|
38
38
|
const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
|
|
39
39
|
if (found.error !== undefined || found.status !== 0)
|
|
40
40
|
throw new Error('metro serve --tunnel needs cloudflared, which is not on PATH.\n' +
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
'Debian/Ubuntu (it is not in the distro repos; this adds Cloudflare\'s signed one):\n' +
|
|
42
|
+
' sudo mkdir -p --mode=0755 /usr/share/keyrings\n' +
|
|
43
|
+
' curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null\n' +
|
|
44
|
+
" echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list\n" +
|
|
45
|
+
' sudo apt-get update && sudo apt-get install cloudflared\n' +
|
|
46
|
+
'macOS: brew install cloudflared\n' +
|
|
47
|
+
'Others: https://pkg.cloudflare.com/');
|
|
48
48
|
}
|
|
49
49
|
export function servePlan(opts) {
|
|
50
50
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !SCRUBBED.has(key)));
|
|
@@ -65,6 +65,10 @@ export function servePlan(opts) {
|
|
|
65
65
|
}
|
|
66
66
|
export function serve(argv) {
|
|
67
67
|
const { port, tunnel } = parseServeArgs(argv);
|
|
68
|
+
const running = serveLockedBy();
|
|
69
|
+
if (running !== null)
|
|
70
|
+
throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
|
|
71
|
+
'Stop it first: metro stop');
|
|
68
72
|
if (tunnel)
|
|
69
73
|
findCloudflared();
|
|
70
74
|
const plan = servePlan({ dir: runtimeDir(), port, tunnel });
|
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.35",
|
|
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": {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ApiError } from './api-error.js';
|
|
2
|
+
import { carriesSecretsSafely, httpSource } from './runtime-source.js';
|
|
3
|
+
import type { LoadedAgent } from '../db/materialize.js';
|
|
4
|
+
|
|
5
|
+
const CLAIM_TIMEOUT_MS = 15_000;
|
|
6
|
+
const CODE_RE = /^ma_[A-Za-z0-9_-]{16}$/;
|
|
7
|
+
|
|
8
|
+
export function metroBaseUrl(): string {
|
|
9
|
+
const raw = (process.env.METRO_URL?.trim() ?? '').replace(/\/+$/, '');
|
|
10
|
+
const url = raw === '' ? 'https://mcp.metro.box' : raw;
|
|
11
|
+
if (!carriesSecretsSafely(url))
|
|
12
|
+
throw new ApiError(`refusing to read station credentials from ${url} in the clear`, 400);
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parsePairingCode(raw: unknown): string {
|
|
17
|
+
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
18
|
+
if (!CODE_RE.test(code))
|
|
19
|
+
throw new ApiError('paste the pairing code from the agent\'s page on metro.box', 400);
|
|
20
|
+
return code;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function claim(base: string, code: string, label: string): Promise<string> {
|
|
24
|
+
let res: Response;
|
|
25
|
+
try {
|
|
26
|
+
res = await fetch(`${base}/api/run/claim`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ code, label }),
|
|
30
|
+
signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS),
|
|
31
|
+
redirect: 'manual',
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ApiError(`could not reach ${base}`, 502);
|
|
35
|
+
}
|
|
36
|
+
const body: unknown = await res.json().catch(() => null);
|
|
37
|
+
const field = (key: string): unknown =>
|
|
38
|
+
typeof body === 'object' && body !== null ? (body as Record<string, unknown>)[key] : undefined;
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const reason = field('error');
|
|
41
|
+
throw new ApiError(
|
|
42
|
+
typeof reason === 'string' ? reason : `metro answered ${String(res.status)} for the code`,
|
|
43
|
+
res.status === 404 ? 400 : res.status,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const token = field('token');
|
|
47
|
+
if (typeof token !== 'string' || token === '')
|
|
48
|
+
throw new ApiError('metro returned no runtime token', 502);
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchAgentWithCode(
|
|
53
|
+
code: string,
|
|
54
|
+
label: string,
|
|
55
|
+
base = metroBaseUrl(),
|
|
56
|
+
): Promise<LoadedAgent> {
|
|
57
|
+
const token = await claim(base, code, label);
|
|
58
|
+
const agents = await httpSource({ url: base, token })();
|
|
59
|
+
const agent = agents[0];
|
|
60
|
+
if (agent === undefined || agents.length !== 1)
|
|
61
|
+
throw new ApiError('metro returned no agent for that code', 502);
|
|
62
|
+
return agent;
|
|
63
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import {
|
|
4
|
+
apiFailure,
|
|
5
|
+
apiSession,
|
|
6
|
+
bodyField,
|
|
7
|
+
cors,
|
|
8
|
+
readJsonBody,
|
|
9
|
+
sendJson,
|
|
10
|
+
} from './api-http.js';
|
|
11
|
+
import { parsePairingCode } from './agent-import.js';
|
|
12
|
+
import type { CreatedAgent } from '../db/agent-admin.js';
|
|
13
|
+
|
|
14
|
+
const PATH = '/api/agents/import';
|
|
15
|
+
|
|
16
|
+
export interface ImportedAgent extends CreatedAgent {
|
|
17
|
+
stations: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ImportApiDeps {
|
|
21
|
+
importAgent: (subject: string, code: string) => Promise<ImportedAgent>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function handle(
|
|
25
|
+
req: IncomingMessage,
|
|
26
|
+
res: ServerResponse,
|
|
27
|
+
deps: ImportApiDeps,
|
|
28
|
+
subject: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
const code = parsePairingCode(bodyField(await readJsonBody(req), 'code'));
|
|
32
|
+
const made = await deps.importAgent(subject, code);
|
|
33
|
+
log.info(
|
|
34
|
+
{ agent: made.name, id: made.id, stations: made.stations },
|
|
35
|
+
'import-api: agent imported from metro',
|
|
36
|
+
);
|
|
37
|
+
sendJson(req, res, 201, made);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
apiFailure(req, res, err, 'import-api');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function handleImportRequest(
|
|
44
|
+
req: IncomingMessage,
|
|
45
|
+
res: ServerResponse,
|
|
46
|
+
deps: ImportApiDeps,
|
|
47
|
+
): boolean {
|
|
48
|
+
if ((req.url ?? '').split('?')[0] !== PATH) return false;
|
|
49
|
+
if (req.method === 'OPTIONS') {
|
|
50
|
+
res.writeHead(204, cors(req)).end();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (req.method !== 'POST') {
|
|
54
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
const session = apiSession(req);
|
|
58
|
+
if (!session) {
|
|
59
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
handle(req, res, deps, session.subject).catch((err: unknown) => {
|
|
63
|
+
log.warn({ err: errMsg(err) }, 'import-api: unhandled error');
|
|
64
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'import failed' });
|
|
65
|
+
});
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
@@ -8,13 +8,17 @@ import type { AgentConnectorApiDeps } from './agent-connector-api.js';
|
|
|
8
8
|
import type { ProjectApiDeps } from './project-api.js';
|
|
9
9
|
import type { SessionApis } from './session-apis.js';
|
|
10
10
|
import type { ModeInfo } from './mode-api.js';
|
|
11
|
+
import type { ImportApiDeps } from './import-api.js';
|
|
12
|
+
import { fetchAgentWithCode } from './agent-import.js';
|
|
11
13
|
import {
|
|
14
|
+
assertLocalOwner,
|
|
12
15
|
claimLocalOwner,
|
|
13
16
|
LOCAL_PROJECT_ID,
|
|
14
17
|
localAttachAccount,
|
|
15
18
|
localCreateAgent,
|
|
16
19
|
localDeleteAgent,
|
|
17
20
|
localDetachAccount,
|
|
21
|
+
localImportAgent,
|
|
18
22
|
localListAgents,
|
|
19
23
|
localOwnedAgentOrThrow,
|
|
20
24
|
localOwner,
|
|
@@ -26,6 +30,7 @@ import type { StationName } from '../db/schema.js';
|
|
|
26
30
|
|
|
27
31
|
export interface LocalModeDeps {
|
|
28
32
|
syncStations: (station: StationName) => Promise<void>;
|
|
33
|
+
fetchAgent?: (code: string, label: string) => ReturnType<typeof fetchAgentWithCode>;
|
|
29
34
|
closeAgentSession: (id: string) => Promise<boolean>;
|
|
30
35
|
gatherAccounts: AgentApiDeps['gatherAccounts'];
|
|
31
36
|
capabilities: AgentApiDeps['capabilities'];
|
|
@@ -129,6 +134,25 @@ const agentConnectorApi: AgentConnectorApiDeps = {
|
|
|
129
134
|
Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
|
|
130
135
|
};
|
|
131
136
|
|
|
137
|
+
function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
138
|
+
const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
|
|
139
|
+
return {
|
|
140
|
+
importAgent: async (subject, code) => {
|
|
141
|
+
assertLocalOwner(subject);
|
|
142
|
+
const agent = await fetchAgent(code, hostname());
|
|
143
|
+
const made = await localImportAgent(subject, agent);
|
|
144
|
+
for (const station of new Set(agent.accounts.map((a) => a.station)))
|
|
145
|
+
await deps.syncStations(station).catch((err: unknown) => {
|
|
146
|
+
log.warn(
|
|
147
|
+
{ station, err: errMsg(err) },
|
|
148
|
+
'import: station reload failed, the change lands at the next boot',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
return made;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
export function localModeInfo(): ModeInfo {
|
|
133
157
|
return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID };
|
|
134
158
|
}
|
|
@@ -137,6 +161,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
137
161
|
return {
|
|
138
162
|
agentApi: agentApi(deps),
|
|
139
163
|
agentConnectorApi,
|
|
164
|
+
importApi: importApi(deps),
|
|
140
165
|
projectApi,
|
|
141
166
|
siwe: { ensureUser: claimLocalOwner },
|
|
142
167
|
mode: localModeInfo,
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
type AgentConnectorApiDeps,
|
|
18
18
|
} from './agent-connector-api.js';
|
|
19
19
|
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
|
+
import { handleImportRequest, type ImportApiDeps } from './import-api.js';
|
|
20
21
|
import type { ModeInfo } from './mode-api.js';
|
|
21
22
|
|
|
22
23
|
export interface SessionApis {
|
|
23
24
|
agentApi?: AgentApiDeps;
|
|
24
25
|
agentConnectorApi?: AgentConnectorApiDeps;
|
|
26
|
+
importApi?: ImportApiDeps;
|
|
25
27
|
connectorApi?: ConnectorApiDeps;
|
|
26
28
|
projectApi?: ProjectApiDeps;
|
|
27
29
|
runApi?: RunApiDeps;
|
|
@@ -35,7 +37,7 @@ export function handleSessionApis(
|
|
|
35
37
|
res: ServerResponse,
|
|
36
38
|
apis: SessionApis,
|
|
37
39
|
): boolean {
|
|
38
|
-
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi } = apis;
|
|
40
|
+
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
|
|
39
41
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
40
42
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
41
43
|
if (projectApi)
|
|
@@ -45,6 +47,7 @@ export function handleSessionApis(
|
|
|
45
47
|
() => handleCliPairRequest(req, res, connectorApi),
|
|
46
48
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
47
49
|
);
|
|
50
|
+
if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
|
|
48
51
|
if (agentConnectorApi)
|
|
49
52
|
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
50
53
|
if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
|
|
@@ -25,12 +25,13 @@ import {
|
|
|
25
25
|
AGENT_FILE,
|
|
26
26
|
agentsDir,
|
|
27
27
|
listAgentFiles,
|
|
28
|
+
parseAgentFile,
|
|
28
29
|
readAgentFile,
|
|
29
30
|
type AgentFile,
|
|
30
31
|
} from './file-source.js';
|
|
31
32
|
import { newId } from './ids.js';
|
|
32
33
|
import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
|
|
33
|
-
import { MOVABLE_STATIONS } from './materialize.js';
|
|
34
|
+
import { MOVABLE_STATIONS, type LoadedAgent } from './materialize.js';
|
|
34
35
|
import type { StationName } from './schema.js';
|
|
35
36
|
import { normalizeAddress } from './users.js';
|
|
36
37
|
|
|
@@ -188,6 +189,63 @@ export async function localDeleteAgent(
|
|
|
188
189
|
return Promise.resolve({ id, name: stored.file.name });
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
function assertUnclaimed(existing: Stored[], agent: LoadedAgent): void {
|
|
193
|
+
for (const { file } of existing) {
|
|
194
|
+
if (file.id === agent.id)
|
|
195
|
+
throw new AgentAdminError(`agent ${agent.id} is already on this machine`, 409);
|
|
196
|
+
if (agent.key !== null && file.key === agent.key)
|
|
197
|
+
throw new AgentAdminError('that agent key is already on this machine', 409);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function assertLocalOwner(subject: string, dir = agentsDir()): void {
|
|
202
|
+
if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function localImportAgent(
|
|
206
|
+
subject: string,
|
|
207
|
+
agent: LoadedAgent,
|
|
208
|
+
dir = agentsDir(),
|
|
209
|
+
): Promise<{ id: string; name: string; key: string; stations: number }> {
|
|
210
|
+
assertLocalOwner(subject, dir);
|
|
211
|
+
const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
|
|
212
|
+
if (immovable !== undefined)
|
|
213
|
+
throw new AgentAdminError(
|
|
214
|
+
`a ${immovable.station} endpoint needs a public url and cannot live on a local daemon`,
|
|
215
|
+
400,
|
|
216
|
+
);
|
|
217
|
+
if (agent.key === null)
|
|
218
|
+
throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
|
|
219
|
+
const folder = join(dir, agent.name);
|
|
220
|
+
const path = join(folder, AGENT_FILE);
|
|
221
|
+
if (existsSync(path))
|
|
222
|
+
throw new AgentAdminError(
|
|
223
|
+
`an agent named '${agent.name}' already exists on this machine`,
|
|
224
|
+
409,
|
|
225
|
+
);
|
|
226
|
+
assertUnclaimed(storedAgents(dir), agent);
|
|
227
|
+
const file = parseAgentFile(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
version: 1,
|
|
230
|
+
id: agent.id,
|
|
231
|
+
name: agent.name,
|
|
232
|
+
key: agent.key,
|
|
233
|
+
owner: localOwner(dir),
|
|
234
|
+
stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
|
|
235
|
+
}),
|
|
236
|
+
path,
|
|
237
|
+
);
|
|
238
|
+
ensureSecureDir(folder);
|
|
239
|
+
save({ path, file });
|
|
240
|
+
registerKey(agent.key, agent.id);
|
|
241
|
+
return Promise.resolve({
|
|
242
|
+
id: agent.id,
|
|
243
|
+
name: agent.name,
|
|
244
|
+
key: agent.key,
|
|
245
|
+
stations: agent.accounts.length,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
191
249
|
function removeIfEmpty(folder: string): void {
|
|
192
250
|
try {
|
|
193
251
|
rmdirSync(folder);
|
package/runtime/runtime.json
CHANGED