@stage-labs/metro 0.1.0-beta.30 → 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 (59) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +23 -11
  4. package/dist/runtime.js +4 -1
  5. package/dist/serve.js +57 -0
  6. package/dist/store.js +26 -3
  7. package/package.json +1 -1
  8. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
  51. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  52. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  53. package/runtime/runtime.json +1 -1
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  59. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
@@ -0,0 +1,143 @@
1
+ import { and, asc, eq, inArray, ne } from 'drizzle-orm';
2
+ import { getDb } from './client.js';
3
+ import { newId } from './ids.js';
4
+ import { isUniqueViolation } from './users.js';
5
+ import { ownedAgentOrThrow } from './agent-admin.js';
6
+ import { ConnectorError } from './connector-config.js';
7
+ import { agentConnectors, agents, connectors } from './schema.js';
8
+
9
+ export interface AgentConnectors {
10
+ id: string;
11
+ name: string;
12
+ connectorIds: string[];
13
+ }
14
+
15
+ const nameClash = (name: string, agent: string): ConnectorError =>
16
+ new ConnectorError(
17
+ `the agent '${agent}' already has a connector named '${name}'`,
18
+ 409,
19
+ );
20
+
21
+ async function collidingAgent(
22
+ agentIds: string[],
23
+ name: string,
24
+ exceptConnectorId: string,
25
+ ): Promise<string | null> {
26
+ if (agentIds.length === 0) return null;
27
+ const rows = await getDb()
28
+ .select({ agent: agents.name })
29
+ .from(agentConnectors)
30
+ .innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
31
+ .innerJoin(agents, eq(agents.id, agentConnectors.agentId))
32
+ .where(
33
+ and(
34
+ inArray(agentConnectors.agentId, agentIds),
35
+ eq(connectors.name, name),
36
+ ne(connectors.id, exceptConnectorId),
37
+ ),
38
+ )
39
+ .limit(1);
40
+ return rows[0]?.agent ?? null;
41
+ }
42
+
43
+ export async function assertRenameFreeOfClash(
44
+ connectorId: string,
45
+ name: string,
46
+ ): Promise<void> {
47
+ const rows = await getDb()
48
+ .select({ agentId: agentConnectors.agentId })
49
+ .from(agentConnectors)
50
+ .where(eq(agentConnectors.connectorId, connectorId));
51
+ const clash = await collidingAgent(
52
+ rows.map((r) => r.agentId),
53
+ name,
54
+ connectorId,
55
+ );
56
+ if (clash !== null) throw nameClash(name, clash);
57
+ }
58
+
59
+ export async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
60
+ const rows = await getDb()
61
+ .select({ connectorId: agentConnectors.connectorId })
62
+ .from(agentConnectors)
63
+ .where(eq(agentConnectors.agentId, agentId))
64
+ .orderBy(asc(agentConnectors.id));
65
+ return rows.map((r) => r.connectorId);
66
+ }
67
+
68
+ export async function connectorIdsByAgent(
69
+ agentIds: string[],
70
+ ): Promise<Map<string, string[]>> {
71
+ const out = new Map<string, string[]>();
72
+ if (agentIds.length === 0) return out;
73
+ const rows = await getDb()
74
+ .select({
75
+ agentId: agentConnectors.agentId,
76
+ connectorId: agentConnectors.connectorId,
77
+ })
78
+ .from(agentConnectors)
79
+ .where(inArray(agentConnectors.agentId, agentIds))
80
+ .orderBy(asc(agentConnectors.id));
81
+ for (const row of rows) {
82
+ const list = out.get(row.agentId) ?? [];
83
+ list.push(row.connectorId);
84
+ out.set(row.agentId, list);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ export async function agentConnectorsForUser(
90
+ subject: string,
91
+ agentId: string,
92
+ ): Promise<AgentConnectors> {
93
+ const { agent } = await ownedAgentOrThrow(subject, agentId);
94
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
95
+ }
96
+
97
+ async function connectorNameIn(
98
+ projectId: string,
99
+ connectorId: string,
100
+ ): Promise<string | null> {
101
+ const rows = await getDb()
102
+ .select({ name: connectors.name })
103
+ .from(connectors)
104
+ .where(and(eq(connectors.id, connectorId), eq(connectors.projectId, projectId)));
105
+ return rows[0]?.name ?? null;
106
+ }
107
+
108
+ export async function addConnectorToAgentForUser(
109
+ subject: string,
110
+ agentId: string,
111
+ connectorId: string,
112
+ ): Promise<AgentConnectors> {
113
+ const { agent, projectId } = await ownedAgentOrThrow(subject, agentId);
114
+ const name = await connectorNameIn(projectId, connectorId);
115
+ if (name === null) throw new ConnectorError('no such connector', 404);
116
+ const clash = await collidingAgent([agent.id], name, connectorId);
117
+ if (clash !== null) throw nameClash(name, clash);
118
+ try {
119
+ await getDb()
120
+ .insert(agentConnectors)
121
+ .values({ id: newId(), agentId: agent.id, connectorId });
122
+ } catch (err) {
123
+ if (!isUniqueViolation(err)) throw err;
124
+ }
125
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
126
+ }
127
+
128
+ export async function removeConnectorFromAgentForUser(
129
+ subject: string,
130
+ agentId: string,
131
+ connectorId: string,
132
+ ): Promise<AgentConnectors> {
133
+ const { agent } = await ownedAgentOrThrow(subject, agentId);
134
+ await getDb()
135
+ .delete(agentConnectors)
136
+ .where(
137
+ and(
138
+ eq(agentConnectors.agentId, agent.id),
139
+ eq(agentConnectors.connectorId, connectorId),
140
+ ),
141
+ );
142
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
143
+ }
@@ -8,7 +8,7 @@ import {
8
8
  import { errMsg, log } from '../daemon/log.js';
9
9
  import { readConfig, type ConnectorConfig } from './connector-config.js';
10
10
  import { getDb } from './client.js';
11
- import { collectionItems, connectors } from './schema.js';
11
+ import { agentConnectors, connectors } from './schema.js';
12
12
 
13
13
  export type RelayTarget =
14
14
  | { kind: 'ok'; url: string; headers: Record<string, string> }
@@ -32,17 +32,17 @@ export function headerAuthHeaders(
32
32
  }
33
33
 
34
34
  async function memberRow(
35
- collectionId: string,
35
+ agentId: string,
36
36
  connectorId: string,
37
37
  ): Promise<ConnectorRow | undefined> {
38
38
  const rows = await getDb()
39
39
  .select({ connector: connectors })
40
- .from(collectionItems)
41
- .innerJoin(connectors, eq(collectionItems.connectorId, connectors.id))
40
+ .from(agentConnectors)
41
+ .innerJoin(connectors, eq(agentConnectors.connectorId, connectors.id))
42
42
  .where(
43
43
  and(
44
- eq(collectionItems.collectionId, collectionId),
45
- eq(collectionItems.connectorId, connectorId),
44
+ eq(agentConnectors.agentId, agentId),
45
+ eq(agentConnectors.connectorId, connectorId),
46
46
  ),
47
47
  );
48
48
  return rows[0]?.connector;
@@ -112,11 +112,11 @@ export function fixedTarget(
112
112
  }
113
113
 
114
114
  export async function relayTarget(
115
- collectionId: string,
115
+ agentId: string,
116
116
  connectorId: string,
117
117
  force: boolean,
118
118
  ): Promise<RelayTarget> {
119
- const row = await memberRow(collectionId, connectorId);
119
+ const row = await memberRow(agentId, connectorId);
120
120
  if (row === undefined) return { kind: 'missing' };
121
121
  parseConnectorUrl(row.url);
122
122
  const config = readConfig(row.config);
@@ -1,4 +1,4 @@
1
- import { assertRenameFreeOfClash } from './connector-collections.js';
1
+ import { assertRenameFreeOfClash } from './agent-connectors.js';
2
2
  import { projectIdOrThrow } from './projects.js';
3
3
  import { and, asc, eq, inArray } from 'drizzle-orm';
4
4
  import {
@@ -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
+ }