@stage-labs/metro 0.1.0-beta.41 → 0.1.0-beta.43

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.
@@ -0,0 +1,70 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { errMsg, log } from './log.js';
3
+ import { cors, sendJson, type AgentIdentity } from './api-http.js';
4
+ import { publicBaseOrDefault } from './attach-serve.js';
5
+ import { relayServersJson, type RelayServerEntry } from './connector-json.js';
6
+ import { agentIdForKey } from '../db/key-map.js';
7
+ import type { ConnectorSummary } from '../db/connectors.js';
8
+
9
+ const PREFIX = '/api/cli';
10
+
11
+ export interface LocalCliDeps {
12
+ agentName: (agentId: string) => string | null;
13
+ connectorEntries: (agentId: string) => Promise<RelayServerEntry[]>;
14
+ connectorSummaries: (agentId: string) => Promise<ConnectorSummary[]>;
15
+ }
16
+
17
+ export function keyOf(req: IncomingMessage): string {
18
+ const header = req.headers.authorization ?? '';
19
+ if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
20
+ const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
21
+ return query.get('token')?.trim() ?? '';
22
+ }
23
+
24
+ export function keyIdentity(req: IncomingMessage): AgentIdentity | null {
25
+ const key = keyOf(req);
26
+ const agentId = key === '' ? undefined : agentIdForKey(key);
27
+ return agentId === undefined ? null : { subject: 'agent-key', agentId };
28
+ }
29
+
30
+ async function answer(path: string, key: string, agentId: string, deps: LocalCliDeps): Promise<unknown> {
31
+ const agent = deps.agentName(agentId) ?? '';
32
+ if (path === `${PREFIX}/session`) return { email: 'this machine', subject: 'this machine', agent };
33
+ if (path === `${PREFIX}/connectors`) return { agent, connectors: await deps.connectorSummaries(agentId) };
34
+ return { json: relayServersJson(await deps.connectorEntries(agentId), publicBaseOrDefault(), key), agent };
35
+ }
36
+
37
+ export function handleLocalCliRequest(
38
+ req: IncomingMessage,
39
+ res: ServerResponse,
40
+ deps: LocalCliDeps,
41
+ ): boolean {
42
+ const path = (req.url ?? '').split('?')[0] ?? '';
43
+ if (path !== PREFIX && !path.startsWith(`${PREFIX}/`)) return false;
44
+ if (req.method === 'OPTIONS') {
45
+ res.writeHead(204, cors(req)).end();
46
+ return true;
47
+ }
48
+ if (![`${PREFIX}/mcp`, `${PREFIX}/session`, `${PREFIX}/connectors`].includes(path)) {
49
+ sendJson(req, res, 404, { error: 'not on a local daemon' });
50
+ return true;
51
+ }
52
+ if (req.method !== 'GET') {
53
+ sendJson(req, res, 405, { error: 'method not allowed' });
54
+ return true;
55
+ }
56
+ const who = keyIdentity(req);
57
+ if (who === null) {
58
+ sendJson(req, res, 401, { error: 'unauthorized' });
59
+ return true;
60
+ }
61
+ answer(path, keyOf(req), who.agentId, deps)
62
+ .then((body) => {
63
+ sendJson(req, res, 200, body);
64
+ })
65
+ .catch((err: unknown) => {
66
+ log.warn({ err: errMsg(err) }, 'local cli: request failed');
67
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'cli failed' });
68
+ });
69
+ return true;
70
+ }
@@ -10,10 +10,31 @@ import type { SessionApis } from './session-apis.js';
10
10
  import type { ModeInfo } from './mode-api.js';
11
11
  import type { ImportApiDeps } from './import-api.js';
12
12
  import { fetchAgentWithCode } from './agent-import.js';
13
- import { forgetHostedConnectors, hostedConnectorsFor, type HostedConnector } from './hosted-connectors.js';
13
+ import type { ConnectorApiDeps } from './connector-api.js';
14
+ import { allowLocalConnectors } from './connector-url.js';
15
+ import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
16
+ import type { RelayApiDeps } from './relay.js';
17
+ import {
18
+ localAddConnector,
19
+ localAgentConnectors,
20
+ localConnectorNamesByIds,
21
+ localConnectorSummariesByIds,
22
+ localCreateConnector,
23
+ localCreatePendingConnector,
24
+ localDeleteConnector,
25
+ localDisconnectConnector,
26
+ localGetConnector,
27
+ localListConnectors,
28
+ localReconnectConnector,
29
+ localRelayTarget,
30
+ localRemoveConnector,
31
+ localRenameConnector,
32
+ localVerifyConnector,
33
+ } from '../db/local-connectors.js';
14
34
  import {
15
35
  assertLocalOwner,
16
36
  claimLocalOwner,
37
+ connectorIdsOfLocalAgent,
17
38
  LOCAL_PROJECT_ID,
18
39
  localAttachAccount,
19
40
  localCreateAgent,
@@ -26,6 +47,7 @@ import {
26
47
  localResetAgentKey,
27
48
  } from '../db/file-admin.js';
28
49
  import { normalizeAddress } from '../db/users.js';
50
+ import { listAgentFiles, readAgentFile } from '../db/file-source.js';
29
51
  import type { Project } from '../db/projects.js';
30
52
  import type { StationName } from '../db/schema.js';
31
53
 
@@ -124,32 +146,52 @@ const projectApi: ProjectApiDeps = {
124
146
  removeMember: () => notHere('members'),
125
147
  };
126
148
 
127
- async function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
128
- const pairs = await Promise.all(
129
- ids.map(async (id) => [id, (await hostedConnectorsFor(id)).map((c) => c.id)] as const),
130
- );
131
- return new Map(pairs);
132
- }
133
-
134
- async function connectorsOfOwner(subject: string): Promise<HostedConnector[]> {
135
- const agents = await localListAgents(subject, LOCAL_PROJECT_ID).catch(() => []);
136
- const lists = await Promise.all(agents.map((a) => hostedConnectorsFor(a.id)));
137
- const seen = new Map<string, HostedConnector>();
138
- for (const c of lists.flat()) seen.set(c.id, c);
139
- return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
149
+ function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
150
+ return Promise.resolve(new Map(ids.map((id) => [id, connectorIdsOfLocalAgent(id) ?? []] as const)));
140
151
  }
141
152
 
142
153
  const agentConnectorApi: AgentConnectorApiDeps = {
143
- agentConnectors: async (subject, id) => {
144
- const { agent } = await localOwnedAgentOrThrow(subject, id);
145
- return { ...agent, connectorIds: (await hostedConnectorsFor(id)).map((c) => c.id) };
146
- },
147
- addConnector: () => notHere('connectors'),
148
- removeConnector: () => notHere('connectors'),
154
+ agentConnectors: localAgentConnectors,
155
+ addConnector: localAddConnector,
156
+ removeConnector: localRemoveConnector,
149
157
  mintCode: () =>
150
158
  Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
151
159
  };
152
160
 
161
+ const connectorApi: ConnectorApiDeps = {
162
+ listConnectors: localListConnectors,
163
+ connectorSummariesByIds: (ids) => localConnectorSummariesByIds(ids),
164
+ connectorNamesByIds: (ids) => localConnectorNamesByIds(ids),
165
+ agentConnectors: localAgentConnectors,
166
+ fenceRuntime: () => Promise.resolve(),
167
+ createConnector: localCreateConnector,
168
+ verifyConnector: localVerifyConnector,
169
+ disconnectConnector: localDisconnectConnector,
170
+ renameConnector: localRenameConnector,
171
+ deleteConnector: localDeleteConnector,
172
+ createPendingConnector: localCreatePendingConnector,
173
+ reconnectConnector: localReconnectConnector,
174
+ getConnector: localGetConnector,
175
+ };
176
+
177
+ const relayApi: RelayApiDeps = {
178
+ target: (agentId, connectorId, force) => localRelayTarget(agentId, connectorId, force),
179
+ fence: () => Promise.resolve(),
180
+ identify: keyIdentity,
181
+ };
182
+
183
+ function agentNameOf(agentId: string): string | null {
184
+ return listAgentFiles()
185
+ .map((path) => readAgentFile(path))
186
+ .find((file) => file.id === agentId)?.name ?? null;
187
+ }
188
+
189
+ const localCli: LocalCliDeps = {
190
+ agentName: agentNameOf,
191
+ connectorEntries: (agentId) => localConnectorNamesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
192
+ connectorSummaries: (agentId) => localConnectorSummariesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
193
+ };
194
+
153
195
  function importApi(deps: LocalModeDeps): ImportApiDeps {
154
196
  const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
155
197
  return {
@@ -157,7 +199,6 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
157
199
  assertLocalOwner(subject);
158
200
  const agent = await fetchAgent(code, hostname());
159
201
  const made = await localImportAgent(subject, agent);
160
- forgetHostedConnectors();
161
202
  for (const station of new Set(agent.accounts.map((a) => a.station)))
162
203
  await deps.syncStations(station).catch((err: unknown) => {
163
204
  log.warn(
@@ -175,11 +216,15 @@ export function localModeInfo(): ModeInfo {
175
216
  }
176
217
 
177
218
  export function localSessionApis(deps: LocalModeDeps): SessionApis {
219
+ allowLocalConnectors(true);
178
220
  return {
179
221
  agentApi: agentApi(deps),
180
222
  agentConnectorApi,
181
223
  importApi: importApi(deps),
182
- localConnectors: { listConnectors: connectorsOfOwner },
224
+ connectorApi,
225
+ relayApi,
226
+ localCli,
227
+ claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
183
228
  projectApi,
184
229
  siwe: { ensureUser: claimLocalOwner },
185
230
  mode: localModeInfo,
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { RelayTarget } from '../db/connector-relay.js';
3
- import { agentIdentity, assertLease, type Fence } from './api-http.js';
3
+ import { agentIdentity, assertLease, type AgentIdentity, type Fence } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
5
  import { errMsg, log } from './log.js';
6
6
 
@@ -11,6 +11,7 @@ export interface RelayApiDeps {
11
11
  force: boolean,
12
12
  ) => Promise<RelayTarget>;
13
13
  fence: Fence;
14
+ identify?: (req: IncomingMessage) => AgentIdentity | null;
14
15
  }
15
16
 
16
17
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -263,7 +264,7 @@ function dispatch(
263
264
  connectorId: string,
264
265
  deps: RelayApiDeps,
265
266
  ): void {
266
- const who = agentIdentity(req);
267
+ const who = (deps.identify ?? agentIdentity)(req);
267
268
  if (who === null) {
268
269
  answer(res, 401, { error: 'unauthorized' });
269
270
  return;
@@ -18,17 +18,16 @@ import {
18
18
  } from './agent-connector-api.js';
19
19
  import type { SiweRouteDeps } from './siwe-routes.js';
20
20
  import { handleImportRequest, type ImportApiDeps } from './import-api.js';
21
- import {
22
- handleLocalConnectorsRequest,
23
- type LocalConnectorsDeps,
24
- } from './local-connectors-api.js';
21
+ import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
22
+ import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
25
23
  import type { ModeInfo } from './mode-api.js';
26
24
 
27
25
  export interface SessionApis {
28
26
  agentApi?: AgentApiDeps;
29
27
  agentConnectorApi?: AgentConnectorApiDeps;
30
28
  importApi?: ImportApiDeps;
31
- localConnectors?: LocalConnectorsDeps;
29
+ localCli?: LocalCliDeps;
30
+ claudeApi?: ClaudeApiDeps;
32
31
  connectorApi?: ConnectorApiDeps;
33
32
  projectApi?: ProjectApiDeps;
34
33
  runApi?: RunApiDeps;
@@ -43,8 +42,9 @@ export function handleSessionApis(
43
42
  apis: SessionApis,
44
43
  ): boolean {
45
44
  const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
46
- const { localConnectors } = apis;
45
+ const { localCli, claudeApi } = apis;
47
46
  const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
47
+ if (localCli) routes.push(() => handleLocalCliRequest(req, res, localCli));
48
48
  if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
49
49
  if (projectApi)
50
50
  routes.push(() => handleProjectApiRequest(req, res, projectApi));
@@ -54,8 +54,7 @@ export function handleSessionApis(
54
54
  () => handleConnectorApiRequest(req, res, connectorApi),
55
55
  );
56
56
  if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
57
- if (localConnectors)
58
- routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
57
+ if (claudeApi) routes.push(() => handleClaudeRequest(req, res, claudeApi));
59
58
  if (agentConnectorApi)
60
59
  routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
61
60
  if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
@@ -58,7 +58,15 @@ export interface DeletedConnector {
58
58
 
59
59
  type ConnectorRow = typeof connectors.$inferSelect;
60
60
 
61
- function toConnector(row: ConnectorRow): Connector {
61
+ export interface ConnectorLike {
62
+ id: string;
63
+ name: string;
64
+ url: string;
65
+ transport: ConnectorTransport;
66
+ config: unknown;
67
+ }
68
+
69
+ export function connectorFromRow(row: ConnectorLike): Connector {
62
70
  const config = readConfig(row.config);
63
71
  const auth = config.auth;
64
72
  return {
@@ -76,6 +84,8 @@ function toConnector(row: ConnectorRow): Connector {
76
84
  };
77
85
  }
78
86
 
87
+ const toConnector = (row: ConnectorRow): Connector => connectorFromRow(row);
88
+
79
89
  const missing = (): ConnectorError =>
80
90
  new ConnectorError('no such connector', 404);
81
91
 
@@ -193,7 +203,7 @@ async function insertConnector(
193
203
  return toConnector(row);
194
204
  }
195
205
 
196
- const UNVERIFIED = {
206
+ export const UNVERIFIED = {
197
207
  at: '',
198
208
  server: '',
199
209
  version: '',
@@ -153,7 +153,7 @@ export async function localCreateAgent(
153
153
  ensureSecureDir(folder);
154
154
  save({
155
155
  path: join(folder, AGENT_FILE),
156
- file: { version: 1, id, name, key, owner: localOwner(dir), stations: [] },
156
+ file: { version: 1, id, name, key, owner: localOwner(dir), stations: [], connectors: [] },
157
157
  });
158
158
  registerKey(key, id);
159
159
  return Promise.resolve({ id, name, key });
@@ -247,6 +247,7 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
247
247
  key: agent.key,
248
248
  owner,
249
249
  stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
250
+ connectors: [],
250
251
  }),
251
252
  path,
252
253
  );
@@ -257,6 +258,40 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
257
258
  }
258
259
  }
259
260
 
261
+ export function connectorIdsOfLocalAgent(agentId: string, dir = agentsDir()): string[] | null {
262
+ const found = storedAgents(dir).find((s) => s.file.id === agentId);
263
+ return found === undefined ? null : found.file.connectors;
264
+ }
265
+
266
+ export function localAgentConnectorIds(subject: string, agentId: string, dir = agentsDir()): string[] {
267
+ return ownedOrThrow(subject, agentId, dir).file.connectors;
268
+ }
269
+
270
+ export function localSetAgentConnectors(
271
+ subject: string,
272
+ agentId: string,
273
+ ids: string[],
274
+ dir = agentsDir(),
275
+ ): void {
276
+ const stored = ownedOrThrow(subject, agentId, dir);
277
+ stored.file.connectors = ids;
278
+ save(stored);
279
+ }
280
+
281
+ export function localAgentsWith(connectorId: string, dir = agentsDir()): { id: string; name: string }[] {
282
+ return storedAgents(dir)
283
+ .filter((s) => s.file.connectors.includes(connectorId))
284
+ .map((s) => ({ id: s.file.id, name: s.file.name }));
285
+ }
286
+
287
+ export function localDropConnectorEverywhere(connectorId: string, dir = agentsDir()): void {
288
+ for (const stored of storedAgents(dir)) {
289
+ if (!stored.file.connectors.includes(connectorId)) continue;
290
+ stored.file.connectors = stored.file.connectors.filter((id) => id !== connectorId);
291
+ save(stored);
292
+ }
293
+ }
294
+
260
295
  function removeIfEmpty(folder: string): void {
261
296
  try {
262
297
  rmdirSync(folder);
@@ -20,6 +20,7 @@ export interface AgentFile {
20
20
  key: string | null;
21
21
  owner: string | null;
22
22
  stations: LoadedAccount[];
23
+ connectors: string[];
23
24
  }
24
25
 
25
26
  export function agentsDir(): string {
@@ -71,6 +72,15 @@ function optionalMatch(
71
72
  return value;
72
73
  }
73
74
 
75
+ function connectorsOf(raw: unknown, path: string): string[] {
76
+ if (raw === undefined || raw === null) return [];
77
+ const list: unknown[] = Array.isArray(raw) ? raw.map((item: unknown) => item) : [];
78
+ const ids = list.filter((id): id is string => typeof id === 'string' && ID_RE.test(id));
79
+ if (!Array.isArray(raw) || ids.length !== list.length)
80
+ fail(path, 'connectors is not a list of 11-character ids');
81
+ return ids;
82
+ }
83
+
74
84
  export function parseAgentFile(raw: string, path: string): AgentFile {
75
85
  let parsed: unknown;
76
86
  try {
@@ -100,6 +110,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
100
110
  key,
101
111
  owner,
102
112
  stations: stations.map((s, i) => stationOf(s, path, i)),
113
+ connectors: connectorsOf(parsed.connectors, path),
103
114
  };
104
115
  }
105
116