@stage-labs/metro 0.1.0-beta.31 → 0.1.0-beta.32

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 (50) hide show
  1. package/dist/cli.js +5 -0
  2. package/dist/runtime.js +4 -1
  3. package/dist/serve.js +57 -0
  4. package/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +16 -15
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -9
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +8 -7
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +109 -71
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +16 -7
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +14 -14
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +2 -2
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +4 -0
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +9 -9
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  35. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  36. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +9 -9
  37. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +31 -31
  38. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +48 -39
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +7 -7
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -1
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +22 -12
  46. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  47. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  48. package/runtime/runtime.json +1 -1
  49. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  50. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
@@ -0,0 +1,89 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { log } from './log.js';
3
+ import {
4
+ apiFailure,
5
+ bodyField,
6
+ cors,
7
+ readJsonBody,
8
+ sendJson,
9
+ } from './api-http.js';
10
+ import { mintNonce, takeNonce } from './siwe-nonces.js';
11
+ import { verifySiweLogin } from './siwe-auth.js';
12
+ import { signSession } from './session.js';
13
+ import { sessionTtlFromEnv } from './session-config.js';
14
+ import { ensureUserWithProject } from '../db/projects.js';
15
+
16
+ const NONCE_PATH = '/auth/siwe/nonce';
17
+ const VERIFY_PATH = '/auth/siwe/verify';
18
+
19
+ export interface SiweRouteDeps {
20
+ ensureUser: (address: string) => Promise<string>;
21
+ }
22
+
23
+ const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserWithProject };
24
+
25
+ async function handleVerify(
26
+ req: IncomingMessage,
27
+ res: ServerResponse,
28
+ secret: string,
29
+ deps: SiweRouteDeps,
30
+ ): Promise<void> {
31
+ const body = await readJsonBody(req);
32
+ const message = bodyField(body, 'message');
33
+ const signature = bodyField(body, 'signature');
34
+ if (typeof message !== 'string' || typeof signature !== 'string') {
35
+ sendJson(req, res, 400, { error: 'message and signature are required' });
36
+ return;
37
+ }
38
+ const address = await verifySiweLogin({ message, signature }, { takeNonce });
39
+ const userId = await deps.ensureUser(address);
40
+ log.info({ userId, address }, 'siwe: session issued');
41
+ const session = signSession({ subject: address, agentIds: [] }, secret, {
42
+ ttlSec: sessionTtlFromEnv(),
43
+ });
44
+ sendJson(req, res, 200, { session, address });
45
+ }
46
+
47
+ const ROUTES: Record<string, string> = {
48
+ [NONCE_PATH]: 'GET',
49
+ [VERIFY_PATH]: 'POST',
50
+ };
51
+
52
+ function refused(
53
+ req: IncomingMessage,
54
+ res: ServerResponse,
55
+ path: string,
56
+ ): boolean {
57
+ if (req.method === 'OPTIONS') {
58
+ res.writeHead(204, cors(req)).end();
59
+ return true;
60
+ }
61
+ if (req.method !== ROUTES[path]) {
62
+ sendJson(req, res, 405, { error: 'method not allowed' });
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ export function handleSiweAuthRequest(
69
+ req: IncomingMessage,
70
+ res: ServerResponse,
71
+ deps: SiweRouteDeps = defaultDeps,
72
+ ): boolean {
73
+ const path = (req.url ?? '').split('?')[0] ?? '';
74
+ if (ROUTES[path] === undefined) return false;
75
+ if (refused(req, res, path)) return true;
76
+ const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
77
+ if (secret === '') {
78
+ sendJson(req, res, 503, { error: 'sign-in is not configured' });
79
+ return true;
80
+ }
81
+ if (path === NONCE_PATH) {
82
+ sendJson(req, res, 200, { nonce: mintNonce() });
83
+ return true;
84
+ }
85
+ handleVerify(req, res, secret, deps).catch((err: unknown) => {
86
+ apiFailure(req, res, err, 'siwe');
87
+ });
88
+ return true;
89
+ }
@@ -1,5 +1,4 @@
1
1
  import { mkdirSync, statSync, type FSWatcher } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { errMsg, log } from './log.js';
5
4
  import { daemonSelf } from './events.js';
@@ -22,9 +21,7 @@ import {
22
21
  startWatcher,
23
22
  type TrainState,
24
23
  } from './supervisor-io.js';
25
-
26
- export const TRAINS_DIR =
27
- process.env.METRO_TRAINS_DIR ?? join(homedir(), '.metro', 'trains');
24
+ import { trainsDir } from './paths.js';
28
25
 
29
26
  const DYLD_FALLBACK_ENV: Record<string, string> =
30
27
  process.platform === 'darwin'
@@ -45,7 +42,7 @@ export class TrainSupervisor {
45
42
  private watcher: FSWatcher | null = null;
46
43
  private reloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
47
44
 
48
- constructor(private dir: string = TRAINS_DIR) {}
45
+ constructor(private dir: string = trainsDir()) {}
49
46
 
50
47
  running(): string[] {
51
48
  return [...this.trains.keys()];
@@ -3,12 +3,11 @@ import { and, asc, eq, inArray } from 'drizzle-orm';
3
3
  import { ApiError } from '../daemon/api-error.js';
4
4
  import { getDb } from './client.js';
5
5
  import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
6
- import { newId, parseId } from './ids.js';
6
+ import { AGENT_NAME_RE, newId, parseId } from './ids.js';
7
7
  import { agents, stations } from './schema.js';
8
8
  import { isUniqueViolation } from './users.js';
9
9
  import { projectIdOrThrow } from './projects.js';
10
10
 
11
- export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
12
11
  const KEY_ATTEMPTS = 5;
13
12
 
14
13
  export class AgentAdminError extends ApiError {}
@@ -93,11 +92,11 @@ async function selectKeyRows(ids: string[]): Promise<KeyRow[]> {
93
92
  .where(inArray(agents.id, ids));
94
93
  }
95
94
 
96
- export async function listAgentsForEmail(
97
- email: string,
95
+ export async function listAgentsForUser(
96
+ subject: string,
98
97
  project: string,
99
98
  ): Promise<AgentSummary[]> {
100
- const projectId = await projectIdOrThrow(email, project);
99
+ const projectId = await projectIdOrThrow(subject, project);
101
100
  const rows = await getDb()
102
101
  .select({ id: agents.id, name: agents.name, projectId: agents.projectId })
103
102
  .from(agents)
@@ -123,14 +122,14 @@ async function insertAgent(
123
122
  return id;
124
123
  }
125
124
 
126
- export async function createAgentForEmail(
127
- email: string,
125
+ export async function createAgentForUser(
126
+ subject: string,
128
127
  project: string,
129
128
  rawName: string,
130
129
  ): Promise<CreatedAgent> {
131
130
  const name = normalizeAgentName(rawName);
132
131
  const key = newApiKey();
133
- const id = await insertAgent(await projectIdOrThrow(email, project), name, key);
132
+ const id = await insertAgent(await projectIdOrThrow(subject, project), name, key);
134
133
  if (servesEveryAgent()) registerKey(key, id);
135
134
  return { id, name, key };
136
135
  }
@@ -145,7 +144,7 @@ interface Deletable {
145
144
  }
146
145
 
147
146
  export async function ownedAgentOrThrow(
148
- email: string,
147
+ subject: string,
149
148
  id: string,
150
149
  ): Promise<Deletable> {
151
150
  const rows = await getDb().select().from(agents).where(eq(agents.id, id));
@@ -153,7 +152,7 @@ export async function ownedAgentOrThrow(
153
152
  const missing = new AgentAdminError('no such agent', 404);
154
153
  if (!row) throw missing;
155
154
  try {
156
- await projectIdOrThrow(email, row.projectId);
155
+ await projectIdOrThrow(subject, row.projectId);
157
156
  } catch {
158
157
  throw missing;
159
158
  }
@@ -179,21 +178,21 @@ async function writeNewKey(id: string, projectId: string): Promise<string> {
179
178
  throw new AgentAdminError('could not allocate a free api key', 500);
180
179
  }
181
180
 
182
- export async function resetAgentKeyForEmail(
183
- email: string,
181
+ export async function resetAgentKeyForUser(
182
+ subject: string,
184
183
  id: string,
185
184
  ): Promise<ResetAgentKey> {
186
- const { agent, projectId } = await ownedAgentOrThrow(email, id);
185
+ const { agent, projectId } = await ownedAgentOrThrow(subject, id);
187
186
  const key = await writeNewKey(agent.id, projectId);
188
187
  rotateAgentKey(agent.id, daemonServesAgent(agent.id) ? key : null);
189
188
  return { id: agent.id, name: agent.name, key };
190
189
  }
191
190
 
192
- export async function deleteAgentForEmail(
193
- email: string,
191
+ export async function deleteAgentForUser(
192
+ subject: string,
194
193
  id: string,
195
194
  ): Promise<DeletedAgent> {
196
- const { agent, projectId } = await ownedAgentOrThrow(email, id);
195
+ const { agent, projectId } = await ownedAgentOrThrow(subject, id);
197
196
  await getDb().transaction(async (tx) => {
198
197
  const attached = await tx
199
198
  .select({ id: stations.id })
@@ -86,11 +86,11 @@ export async function connectorIdsByAgent(
86
86
  return out;
87
87
  }
88
88
 
89
- export async function agentConnectorsForEmail(
90
- email: string,
89
+ export async function agentConnectorsForUser(
90
+ subject: string,
91
91
  agentId: string,
92
92
  ): Promise<AgentConnectors> {
93
- const { agent } = await ownedAgentOrThrow(email, agentId);
93
+ const { agent } = await ownedAgentOrThrow(subject, agentId);
94
94
  return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
95
95
  }
96
96
 
@@ -105,12 +105,12 @@ async function connectorNameIn(
105
105
  return rows[0]?.name ?? null;
106
106
  }
107
107
 
108
- export async function addConnectorToAgentForEmail(
109
- email: string,
108
+ export async function addConnectorToAgentForUser(
109
+ subject: string,
110
110
  agentId: string,
111
111
  connectorId: string,
112
112
  ): Promise<AgentConnectors> {
113
- const { agent, projectId } = await ownedAgentOrThrow(email, agentId);
113
+ const { agent, projectId } = await ownedAgentOrThrow(subject, agentId);
114
114
  const name = await connectorNameIn(projectId, connectorId);
115
115
  if (name === null) throw new ConnectorError('no such connector', 404);
116
116
  const clash = await collidingAgent([agent.id], name, connectorId);
@@ -125,12 +125,12 @@ export async function addConnectorToAgentForEmail(
125
125
  return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
126
126
  }
127
127
 
128
- export async function removeConnectorFromAgentForEmail(
129
- email: string,
128
+ export async function removeConnectorFromAgentForUser(
129
+ subject: string,
130
130
  agentId: string,
131
131
  connectorId: string,
132
132
  ): Promise<AgentConnectors> {
133
- const { agent } = await ownedAgentOrThrow(email, agentId);
133
+ const { agent } = await ownedAgentOrThrow(subject, agentId);
134
134
  await getDb()
135
135
  .delete(agentConnectors)
136
136
  .where(
@@ -80,7 +80,7 @@ const missing = (): ConnectorError =>
80
80
  new ConnectorError('no such connector', 404);
81
81
 
82
82
  async function ownedConnectorOrThrow(
83
- email: string,
83
+ subject: string,
84
84
  id: string,
85
85
  ): Promise<ConnectorRow> {
86
86
  const rows = await getDb()
@@ -90,7 +90,7 @@ async function ownedConnectorOrThrow(
90
90
  const row = rows[0];
91
91
  if (row === undefined) throw missing();
92
92
  try {
93
- await projectIdOrThrow(email, row.projectId);
93
+ await projectIdOrThrow(subject, row.projectId);
94
94
  } catch {
95
95
  throw missing();
96
96
  }
@@ -112,10 +112,10 @@ async function saveConfig(
112
112
  }
113
113
 
114
114
  async function connectorRowsFor(
115
- email: string,
115
+ subject: string,
116
116
  project: string,
117
117
  ): Promise<ConnectorRow[]> {
118
- const projectId = await projectIdOrThrow(email, project);
118
+ const projectId = await projectIdOrThrow(subject, project);
119
119
  return getDb()
120
120
  .select()
121
121
  .from(connectors)
@@ -123,11 +123,11 @@ async function connectorRowsFor(
123
123
  .orderBy(asc(connectors.id));
124
124
  }
125
125
 
126
- export async function listConnectorsForEmail(
127
- email: string,
126
+ export async function listConnectorsForUser(
127
+ subject: string,
128
128
  project: string,
129
129
  ): Promise<Connector[]> {
130
- return (await connectorRowsFor(email, project)).map(toConnector);
130
+ return (await connectorRowsFor(subject, project)).map(toConnector);
131
131
  }
132
132
 
133
133
  async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
@@ -145,11 +145,11 @@ export async function connectorNamesByIds(
145
145
  return (await rowsByIds(ids)).map((row) => ({ id: row.id, name: row.name }));
146
146
  }
147
147
 
148
- export async function getConnectorForEmail(
149
- email: string,
148
+ export async function getConnectorForUser(
149
+ subject: string,
150
150
  id: string,
151
151
  ): Promise<Connector> {
152
- return toConnector(await ownedConnectorOrThrow(email, id));
152
+ return toConnector(await ownedConnectorOrThrow(subject, id));
153
153
  }
154
154
 
155
155
  async function insertConnector(
@@ -185,14 +185,14 @@ const UNVERIFIED = {
185
185
  catalog: [],
186
186
  };
187
187
 
188
- export async function createPendingConnectorForEmail(
189
- email: string,
188
+ export async function createPendingConnectorForUser(
189
+ subject: string,
190
190
  project: string,
191
191
  input: { name: unknown; url: unknown },
192
192
  ): Promise<Connector> {
193
193
  const name = connectorName(input.name);
194
194
  const url = parseConnectorUrl(input.url);
195
- const projectId = await projectIdOrThrow(email, project);
195
+ const projectId = await projectIdOrThrow(subject, project);
196
196
  return insertConnector(projectId, name, url, {
197
197
  auth: { kind: 'none' },
198
198
  createdAt: new Date().toISOString(),
@@ -201,15 +201,15 @@ export async function createPendingConnectorForEmail(
201
201
  });
202
202
  }
203
203
 
204
- export async function createConnectorForEmail(
205
- email: string,
204
+ export async function createConnectorForUser(
205
+ subject: string,
206
206
  project: string,
207
207
  input: ConnectorInput,
208
208
  ): Promise<Connector> {
209
209
  const name = connectorName(input.name);
210
210
  const url = parseConnectorUrl(input.url);
211
211
  const auth = connectorAuth(input.header, input.value);
212
- const projectId = await projectIdOrThrow(email, project);
212
+ const projectId = await projectIdOrThrow(subject, project);
213
213
  const verified = stamp(await verifyRemoteMcp(url, auth));
214
214
  return insertConnector(projectId, name, url, {
215
215
  auth,
@@ -233,34 +233,34 @@ async function freshAuth(
233
233
  return refreshOAuth(auth, resource);
234
234
  }
235
235
 
236
- export async function reconnectConnectorForEmail(
237
- email: string,
236
+ export async function reconnectConnectorForUser(
237
+ subject: string,
238
238
  id: string,
239
239
  auth: OAuthAuth,
240
240
  ): Promise<Connector> {
241
- const row = await ownedConnectorOrThrow(email, id);
241
+ const row = await ownedConnectorOrThrow(subject, id);
242
242
  const config = readConfig(row.config);
243
243
  const url = parseConnectorUrl(row.url);
244
244
  const verified = stamp(await verifyRemoteMcp(url, auth));
245
245
  return saveConfig(row, { ...config, auth, verified, oauth: true });
246
246
  }
247
247
 
248
- export async function disconnectConnectorForEmail(
249
- email: string,
248
+ export async function disconnectConnectorForUser(
249
+ subject: string,
250
250
  id: string,
251
251
  ): Promise<Connector> {
252
- const row = await ownedConnectorOrThrow(email, id);
252
+ const row = await ownedConnectorOrThrow(subject, id);
253
253
  const config = readConfig(row.config);
254
254
  if (config.auth.kind !== 'oauth')
255
255
  throw new ConnectorError('that connector is not signed in', 400);
256
256
  return saveConfig(row, { ...config, auth: { kind: 'none' }, oauth: true });
257
257
  }
258
258
 
259
- export async function verifyConnectorForEmail(
260
- email: string,
259
+ export async function verifyConnectorForUser(
260
+ subject: string,
261
261
  id: string,
262
262
  ): Promise<ConnectorCheck> {
263
- const row = await ownedConnectorOrThrow(email, id);
263
+ const row = await ownedConnectorOrThrow(subject, id);
264
264
  const config = readConfig(row.config);
265
265
  try {
266
266
  const url = parseConnectorUrl(row.url);
@@ -275,13 +275,13 @@ export async function verifyConnectorForEmail(
275
275
  }
276
276
  }
277
277
 
278
- export async function renameConnectorForEmail(
279
- email: string,
278
+ export async function renameConnectorForUser(
279
+ subject: string,
280
280
  id: string,
281
281
  raw: string,
282
282
  ): Promise<Connector> {
283
283
  const name = connectorName(raw);
284
- const row = await ownedConnectorOrThrow(email, id);
284
+ const row = await ownedConnectorOrThrow(subject, id);
285
285
  await assertRenameFreeOfClash(row.id, name);
286
286
  const rows = await getDb()
287
287
  .update(connectors)
@@ -293,11 +293,11 @@ export async function renameConnectorForEmail(
293
293
  return toConnector(saved);
294
294
  }
295
295
 
296
- export async function deleteConnectorForEmail(
297
- email: string,
296
+ export async function deleteConnectorForUser(
297
+ subject: string,
298
298
  id: string,
299
299
  ): Promise<DeletedConnector> {
300
- const row = await ownedConnectorOrThrow(email, id);
300
+ const row = await ownedConnectorOrThrow(subject, id);
301
301
  const gone = await getDb()
302
302
  .delete(connectors)
303
303
  .where(eq(connectors.id, row.id))
@@ -0,0 +1,250 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ openSync,
5
+ readFileSync,
6
+ rmdirSync,
7
+ rmSync,
8
+ writeSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { ApiError } from '../daemon/api-error.js';
12
+ import { ensureSecureDir, writeSecure } from '../daemon/secure-fs.js';
13
+ import {
14
+ AgentAdminError,
15
+ newApiKey,
16
+ normalizeAgentName,
17
+ type AgentSummary,
18
+ type CreatedAgent,
19
+ type DeletedAgent,
20
+ type OwnedAgent,
21
+ type ResetAgentKey,
22
+ } from './agent-admin.js';
23
+ import type { AccountRef } from './account-attach.js';
24
+ import {
25
+ AGENT_FILE,
26
+ agentsDir,
27
+ listAgentFiles,
28
+ readAgentFile,
29
+ type AgentFile,
30
+ } from './file-source.js';
31
+ import { newId } from './ids.js';
32
+ import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
33
+ import { MOVABLE_STATIONS } from './materialize.js';
34
+ import type { StationName } from './schema.js';
35
+ import { normalizeAddress } from './users.js';
36
+
37
+ export const LOCAL_PROJECT_ID = 'localdaemon';
38
+ const OWNER_FILE = '.owner';
39
+
40
+ interface Stored {
41
+ path: string;
42
+ file: AgentFile;
43
+ }
44
+
45
+ const missing = (): AgentAdminError => new AgentAdminError('no such agent', 404);
46
+
47
+ export function localOwner(dir = agentsDir()): string | null {
48
+ try {
49
+ return normalizeAddress(readFileSync(join(dir, OWNER_FILE), 'utf8'));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function writeOwnerOnce(path: string, address: string): boolean {
56
+ let fd: number;
57
+ try {
58
+ fd = openSync(path, 'wx', 0o600);
59
+ } catch (err) {
60
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
61
+ throw err;
62
+ }
63
+ writeSync(fd, `${address}\n`);
64
+ closeSync(fd);
65
+ return true;
66
+ }
67
+
68
+ export function claimLocalOwner(raw: string, dir = agentsDir()): Promise<string> {
69
+ const address = normalizeAddress(raw);
70
+ if (address === null)
71
+ return Promise.reject(new ApiError('an Ethereum address is required', 400));
72
+ ensureSecureDir(dir);
73
+ if (writeOwnerOnce(join(dir, OWNER_FILE), address))
74
+ return Promise.resolve(address);
75
+ if (localOwner(dir) !== address)
76
+ return Promise.reject(
77
+ new ApiError('this machine belongs to another wallet', 403),
78
+ );
79
+ return Promise.resolve(address);
80
+ }
81
+
82
+ function isOwner(subject: string, dir: string): boolean {
83
+ const owner = localOwner(dir);
84
+ return owner !== null && owner === normalizeAddress(subject);
85
+ }
86
+
87
+ function storedAgents(dir: string): Stored[] {
88
+ return listAgentFiles(dir).map((path) => ({ path, file: readAgentFile(path) }));
89
+ }
90
+
91
+ function save(stored: Stored): void {
92
+ writeSecure(stored.path, `${JSON.stringify(stored.file, null, 2)}\n`);
93
+ }
94
+
95
+ function ownedOrThrow(subject: string, id: string, dir: string): Stored {
96
+ if (!isOwner(subject, dir)) throw missing();
97
+ const found = storedAgents(dir).find((s) => s.file.id === id);
98
+ if (found === undefined) throw missing();
99
+ return found;
100
+ }
101
+
102
+ export async function localOwnedAgentOrThrow(
103
+ subject: string,
104
+ id: string,
105
+ dir = agentsDir(),
106
+ ): Promise<{ agent: OwnedAgent }> {
107
+ const { file } = ownedOrThrow(subject, id, dir);
108
+ return Promise.resolve({ agent: { id: file.id, name: file.name } });
109
+ }
110
+
111
+ export async function localListAgents(
112
+ subject: string,
113
+ project: string,
114
+ dir = agentsDir(),
115
+ ): Promise<AgentSummary[]> {
116
+ if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
117
+ throw new AgentAdminError('no such project', 404);
118
+ return Promise.resolve(
119
+ storedAgents(dir)
120
+ .map(({ file }) => ({ id: file.id, name: file.name, owned: true, key: file.key }))
121
+ .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
122
+ );
123
+ }
124
+
125
+ function freshId(taken: Set<string>): string {
126
+ for (let attempt = 0; attempt < 5; attempt += 1) {
127
+ const id = newId();
128
+ if (!taken.has(id)) return id;
129
+ }
130
+ throw new AgentAdminError('could not allocate a free id', 500);
131
+ }
132
+
133
+ export async function localCreateAgent(
134
+ subject: string,
135
+ project: string,
136
+ rawName: string,
137
+ dir = agentsDir(),
138
+ ): Promise<CreatedAgent> {
139
+ if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
140
+ throw new AgentAdminError('no such project', 404);
141
+ const name = normalizeAgentName(rawName);
142
+ const folder = join(dir, name);
143
+ if (existsSync(join(folder, AGENT_FILE)))
144
+ throw new AgentAdminError(
145
+ `an agent named '${name}' already exists on this machine`,
146
+ 409,
147
+ );
148
+ const existing = storedAgents(dir);
149
+ const id = freshId(new Set(existing.map((s) => s.file.id)));
150
+ const key = newApiKey();
151
+ ensureSecureDir(folder);
152
+ save({
153
+ path: join(folder, AGENT_FILE),
154
+ file: { version: 1, id, name, key, owner: localOwner(dir), stations: [] },
155
+ });
156
+ registerKey(key, id);
157
+ return Promise.resolve({ id, name, key });
158
+ }
159
+
160
+ export async function localResetAgentKey(
161
+ subject: string,
162
+ id: string,
163
+ dir = agentsDir(),
164
+ ): Promise<ResetAgentKey> {
165
+ const stored = ownedOrThrow(subject, id, dir);
166
+ const key = newApiKey();
167
+ stored.file.key = key;
168
+ save(stored);
169
+ rotateAgentKey(id, key);
170
+ return Promise.resolve({ id, name: stored.file.name, key });
171
+ }
172
+
173
+ export async function localDeleteAgent(
174
+ subject: string,
175
+ id: string,
176
+ dir = agentsDir(),
177
+ ): Promise<DeletedAgent> {
178
+ const stored = ownedOrThrow(subject, id, dir);
179
+ const attached = stored.file.stations.length;
180
+ if (attached > 0)
181
+ throw new AgentAdminError(
182
+ `agent '${stored.file.name}' still has ${String(attached)} station account(s) attached — detach them first`,
183
+ 409,
184
+ );
185
+ rmSync(stored.path);
186
+ removeIfEmpty(join(stored.path, '..'));
187
+ unregisterAgentKey(id);
188
+ return Promise.resolve({ id, name: stored.file.name });
189
+ }
190
+
191
+ function removeIfEmpty(folder: string): void {
192
+ try {
193
+ rmdirSync(folder);
194
+ } catch {
195
+ return;
196
+ }
197
+ }
198
+
199
+ function assertTokenFree(all: Stored[], station: StationName, token: string): void {
200
+ const taken = all.some((s) =>
201
+ s.file.stations.some(
202
+ (a) => a.station === station && a.config.token === token,
203
+ ),
204
+ );
205
+ if (taken)
206
+ throw new AgentAdminError(
207
+ 'that bot token is already attached to an agent on this machine',
208
+ 409,
209
+ );
210
+ }
211
+
212
+ export async function localAttachAccount(
213
+ subject: string,
214
+ agentId: string,
215
+ station: StationName,
216
+ config: Record<string, unknown>,
217
+ dir = agentsDir(),
218
+ ): Promise<AccountRef> {
219
+ const stored = ownedOrThrow(subject, agentId, dir);
220
+ if (!MOVABLE_STATIONS.has(station))
221
+ throw new AgentAdminError(
222
+ `a ${station} endpoint needs a public url and cannot live on a local daemon`,
223
+ 400,
224
+ );
225
+ const token = config.token;
226
+ if (typeof token === 'string') assertTokenFree(storedAgents(dir), station, token);
227
+ const taken = new Set(stored.file.stations.map((a) => a.id));
228
+ const accountId = freshId(taken);
229
+ stored.file.stations.push({ station, id: accountId, allowlist: ['*'], config });
230
+ save(stored);
231
+ return Promise.resolve({ agentId, station, accountId });
232
+ }
233
+
234
+ export async function localDetachAccount(
235
+ subject: string,
236
+ agentId: string,
237
+ station: StationName,
238
+ accountId: string,
239
+ dir = agentsDir(),
240
+ ): Promise<AccountRef> {
241
+ const stored = ownedOrThrow(subject, agentId, dir);
242
+ const before = stored.file.stations.length;
243
+ stored.file.stations = stored.file.stations.filter(
244
+ (a) => !(a.station === station && a.id === accountId),
245
+ );
246
+ if (stored.file.stations.length === before)
247
+ throw new AgentAdminError('no such account on this agent', 404);
248
+ save(stored);
249
+ return Promise.resolve({ agentId, station, accountId });
250
+ }