@stage-labs/metro 0.1.0-beta.42 → 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.
package/dist/api.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { metroUrl, readRunTokens, readToken } from './store.js';
2
+ import { localAgents, localDaemonUp, localMcpServers, pickLocalAgent } from './local.js';
3
+ import { localUrl } from './runtime.js';
2
4
  export class NotSignedIn extends Error {
3
5
  }
4
6
  const TIMEOUT_MS = 20_000;
@@ -115,13 +117,28 @@ export async function claimRuntime(code, label) {
115
117
  throw new Error('metro returned an unexpected response');
116
118
  return { token: body.token, agent: body.agent, label: body.label };
117
119
  }
118
- export async function mcpServers() {
120
+ export async function mcpServers(wanted) {
121
+ if (await localDaemonUp()) {
122
+ const agents = localAgents();
123
+ if (agents.length > 0)
124
+ return localMcpServers(pickLocalAgent(agents, wanted));
125
+ }
119
126
  const body = await get('/api/cli/mcp');
120
127
  if (!isRecord(body) || typeof body.json !== 'string')
121
128
  throw new Error('metro returned an unexpected response');
122
129
  return body.json;
123
130
  }
124
- export async function whoisAuthorized() {
131
+ export async function whoisAuthorized(wanted) {
132
+ if (await localDaemonUp()) {
133
+ const agents = localAgents();
134
+ if (agents.length > 0) {
135
+ const agent = pickLocalAgent(agents, wanted);
136
+ return { email: 'this machine', agent: agent.name, where: `${localUrl()} (local)` };
137
+ }
138
+ }
139
+ return { ...(await hostedIdentity()), where: metroUrl() };
140
+ }
141
+ async function hostedIdentity() {
125
142
  const body = await get('/api/cli/session');
126
143
  if (!isRecord(body) ||
127
144
  typeof body.email !== 'string' ||
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { hostname } from 'node:os';
3
3
  import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } from './api.js';
4
4
  import { askSecret } from './prompt.js';
5
- import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
5
+ import { clearToken, credentialsPath, 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
8
  import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, runTokenState, writeRunToken, } from './runtime.js';
@@ -32,8 +32,11 @@ const USAGE = `metro — the command line for your MCP connectors
32
32
  metro login [code]
33
33
  authorize this machine for one agent with a code from its page
34
34
  metro logout forget this machine's sign-in
35
- metro whoami print the account and agent this machine may read
36
- metro mcp print the mcpServers block of the authorized agent's connectors
35
+ metro whoami [agent]
36
+ print the account and agent this machine may read
37
+ metro mcp [agent]
38
+ print the mcpServers block of the agent's connectors; with a local daemon
39
+ running, its agent's connectors (name one if several)
37
40
  metro plugin set up the Claude Code plugin (connector servers + /metro:login)
38
41
  metro claude [args...]
39
42
  open Claude Code with the metro channel; every argument is passed through
@@ -142,9 +145,9 @@ async function login(given) {
142
145
  process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
143
146
  'run /reload-plugins in any session already open.\n');
144
147
  }
145
- async function whoami() {
146
- const { email, agent } = await whoisAuthorized();
147
- process.stdout.write(`${email} · agent '${agent}' on ${metroUrl()}\n`);
148
+ async function whoami(wanted) {
149
+ const { email, agent, where } = await whoisAuthorized(wanted);
150
+ process.stdout.write(`${email} · agent '${agent}' on ${where}\n`);
148
151
  }
149
152
  const HELP = new Set([undefined, 'help', '--help', '-h']);
150
153
  const COMMANDS = {
@@ -158,7 +161,7 @@ const COMMANDS = {
158
161
  return Promise.resolve(0);
159
162
  },
160
163
  whoami: async () => {
161
- await whoami();
164
+ await whoami(process.argv[3]);
162
165
  return 0;
163
166
  },
164
167
  start: () => start(process.argv.slice(3)),
@@ -168,7 +171,7 @@ const COMMANDS = {
168
171
  logs: () => logs(process.argv.slice(3)),
169
172
  tail: () => tailEvents(process.argv.slice(3)),
170
173
  mcp: async () => {
171
- process.stdout.write(`${await mcpServers()}\n`);
174
+ process.stdout.write(`${await mcpServers(process.argv[3])}\n`);
172
175
  return 0;
173
176
  },
174
177
  plugin: installPlugin,
package/dist/local.js ADDED
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { localUrl } from './runtime.js';
5
+ const PROBE_MS = 3_000;
6
+ const READ_MS = 20_000;
7
+ export function agentsDir() {
8
+ const explicit = process.env.METRO_AGENTS_DIR?.trim();
9
+ return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
10
+ }
11
+ export function localAgents(dir = agentsDir()) {
12
+ if (!existsSync(dir))
13
+ return [];
14
+ const out = [];
15
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
16
+ if (!entry.isDirectory())
17
+ continue;
18
+ const path = join(dir, entry.name, 'agent.json');
19
+ if (!existsSync(path))
20
+ continue;
21
+ try {
22
+ const file = JSON.parse(readFileSync(path, 'utf8'));
23
+ if (typeof file.id === 'string' && typeof file.name === 'string' && typeof file.key === 'string')
24
+ out.push({ id: file.id, name: file.name, key: file.key });
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ }
30
+ return out.sort((a, b) => a.name.localeCompare(b.name));
31
+ }
32
+ export function pickLocalAgent(agents, wanted) {
33
+ if (wanted !== undefined && wanted !== '') {
34
+ const found = agents.find((a) => a.name === wanted || a.id === wanted);
35
+ if (found === undefined)
36
+ throw new Error(`no local agent named '${wanted}'`);
37
+ return found;
38
+ }
39
+ const only = agents[0];
40
+ if (only !== undefined && agents.length === 1)
41
+ return only;
42
+ throw new Error(agents.length === 0
43
+ ? 'no agent on this machine yet'
44
+ : `several agents on this machine — name one: ${agents.map((a) => a.name).join(', ')}`);
45
+ }
46
+ export async function localDaemonUp(base = localUrl()) {
47
+ try {
48
+ const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
49
+ const body = (await res.json());
50
+ return res.ok && body.mode === 'local';
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ export async function localMcpServers(agent, base = localUrl()) {
57
+ const res = await fetch(`${base}/api/cli/mcp`, {
58
+ headers: { authorization: `Bearer ${agent.key}` },
59
+ signal: AbortSignal.timeout(READ_MS),
60
+ });
61
+ if (!res.ok)
62
+ throw new Error(`the local daemon answered ${String(res.status)} for the connectors`);
63
+ const body = (await res.json());
64
+ if (typeof body.json !== 'string')
65
+ throw new Error('the local daemon returned an unexpected response');
66
+ return body.json;
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.42",
3
+ "version": "0.1.0-beta.43",
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": {
@@ -9,6 +9,12 @@ export class ConnectorNotMcp extends ConnectorVerifyError {}
9
9
  export const POLICY_MESSAGE =
10
10
  'Metro connects from its own server, so it cannot reach a URL on your machine. localhost and private addresses are not usable as connectors.';
11
11
 
12
+ let localAllowed = false;
13
+
14
+ export function allowLocalConnectors(on: boolean): void {
15
+ localAllowed = on;
16
+ }
17
+
12
18
  const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
13
19
  const PRIVATE_SUFFIX = /(?:^|\.)(?:localhost|local|internal|flycast)$/;
14
20
 
@@ -22,6 +28,19 @@ function hostAllowed(host: string): boolean {
22
28
  return !PRIVATE_SUFFIX.test(host);
23
29
  }
24
30
 
31
+ function schemeAllowed(url: URL): boolean {
32
+ return url.protocol === 'https:' || (localAllowed && url.protocol === 'http:');
33
+ }
34
+
35
+ function refuseShape(url: URL): void {
36
+ if (!schemeAllowed(url)) throw refused('a connector url must start with https://');
37
+ if (url.username !== '' || url.password !== '')
38
+ throw refused('a connector url must not carry a user:password');
39
+ if (url.hash !== '')
40
+ throw refused('a connector url must not carry a #fragment');
41
+ if (!localAllowed && !hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
42
+ }
43
+
25
44
  export function parseConnectorUrl(raw: unknown): URL {
26
45
  const text = typeof raw === 'string' ? raw.trim() : '';
27
46
  if (text === '') throw refused('a connector url is required');
@@ -31,13 +50,7 @@ export function parseConnectorUrl(raw: unknown): URL {
31
50
  } catch {
32
51
  throw refused('that is not a valid url');
33
52
  }
34
- if (url.protocol !== 'https:')
35
- throw refused('a connector url must start with https://');
36
- if (url.username !== '' || url.password !== '')
37
- throw refused('a connector url must not carry a user:password');
38
- if (url.hash !== '')
39
- throw refused('a connector url must not carry a #fragment');
40
- if (!hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
53
+ refuseShape(url);
41
54
  return url;
42
55
  }
43
56
 
@@ -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,14 @@ 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,
183
227
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
184
228
  projectApi,
185
229
  siwe: { ensureUser: claimLocalOwner },
@@ -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,10 +18,7 @@ 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';
25
22
  import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
26
23
  import type { ModeInfo } from './mode-api.js';
27
24
 
@@ -29,7 +26,7 @@ export interface SessionApis {
29
26
  agentApi?: AgentApiDeps;
30
27
  agentConnectorApi?: AgentConnectorApiDeps;
31
28
  importApi?: ImportApiDeps;
32
- localConnectors?: LocalConnectorsDeps;
29
+ localCli?: LocalCliDeps;
33
30
  claudeApi?: ClaudeApiDeps;
34
31
  connectorApi?: ConnectorApiDeps;
35
32
  projectApi?: ProjectApiDeps;
@@ -45,8 +42,9 @@ export function handleSessionApis(
45
42
  apis: SessionApis,
46
43
  ): boolean {
47
44
  const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
48
- const { localConnectors, claudeApi } = apis;
45
+ const { localCli, claudeApi } = apis;
49
46
  const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
47
+ if (localCli) routes.push(() => handleLocalCliRequest(req, res, localCli));
50
48
  if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
51
49
  if (projectApi)
52
50
  routes.push(() => handleProjectApiRequest(req, res, projectApi));
@@ -56,8 +54,6 @@ export function handleSessionApis(
56
54
  () => handleConnectorApiRequest(req, res, connectorApi),
57
55
  );
58
56
  if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
59
- if (localConnectors)
60
- routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
61
57
  if (claudeApi) routes.push(() => handleClaudeRequest(req, res, claudeApi));
62
58
  if (agentConnectorApi)
63
59
  routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
@@ -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
 
@@ -0,0 +1,312 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeSecure } from '../daemon/secure-fs.js';
3
+ import { errMsg, log } from '../daemon/log.js';
4
+ import {
5
+ connectorUrlText,
6
+ ConnectorVerifyError,
7
+ parseConnectorUrl,
8
+ verifyRemoteMcp,
9
+ type ConnectorAuth,
10
+ type OAuthAuth,
11
+ } from '../daemon/connector-verify.js';
12
+ import { oauthExpired, refreshOAuth } from '../daemon/connector-oauth.js';
13
+ import { advertisesOAuth } from '../daemon/oauth-discovery.js';
14
+ import {
15
+ ConnectorError,
16
+ connectorAuth,
17
+ connectorName,
18
+ readConfig,
19
+ signInState,
20
+ stamp,
21
+ type ConnectorConfig,
22
+ } from './connector-config.js';
23
+ import {
24
+ bearerHeaders,
25
+ fixedTarget,
26
+ unrefreshedTarget,
27
+ type RelayTarget,
28
+ } from './connector-relay.js';
29
+ import {
30
+ connectorFromRow,
31
+ UNVERIFIED,
32
+ type Connector,
33
+ type ConnectorCheck,
34
+ type ConnectorInput,
35
+ type ConnectorSummary,
36
+ type DeletedConnector,
37
+ } from './connectors.js';
38
+ import type { AgentConnectors } from './agent-connectors.js';
39
+ import { agentsDir } from './file-source.js';
40
+ import {
41
+ assertLocalOwner,
42
+ connectorIdsOfLocalAgent,
43
+ localAgentConnectorIds,
44
+ localAgentsWith,
45
+ localDropConnectorEverywhere,
46
+ localOwnedAgentOrThrow,
47
+ localSetAgentConnectors,
48
+ LOCAL_PROJECT_ID,
49
+ } from './file-admin.js';
50
+ import { newId } from './ids.js';
51
+
52
+ export interface LocalConnectorRow {
53
+ id: string;
54
+ name: string;
55
+ url: string;
56
+ transport: 'http';
57
+ config: ConnectorConfig;
58
+ }
59
+
60
+ const FILE = 'connectors.json';
61
+ const filePath = (dir: string): string => join(dir, FILE);
62
+
63
+ const isRow = (v: unknown): v is { id: string; name: string; url: string; config?: unknown } =>
64
+ typeof v === 'object' &&
65
+ v !== null &&
66
+ typeof (v as { id?: unknown }).id === 'string' &&
67
+ typeof (v as { name?: unknown }).name === 'string' &&
68
+ typeof (v as { url?: unknown }).url === 'string';
69
+
70
+ const asList = (value: unknown): unknown[] =>
71
+ Array.isArray(value) ? value.map((item: unknown) => item) : [];
72
+
73
+ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
74
+ const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
75
+ warn: 'connectors.json: malformed, ignoring',
76
+ });
77
+ return asList(raw.connectors).filter(isRow).map((r) => ({
78
+ id: r.id,
79
+ name: r.name,
80
+ url: r.url,
81
+ transport: 'http' as const,
82
+ config: readConfig(r.config),
83
+ }));
84
+ }
85
+
86
+ function writeRows(dir: string, rows: LocalConnectorRow[]): void {
87
+ writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
88
+ }
89
+
90
+ const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
91
+
92
+ function ownedRows(subject: string, project: string, dir: string): LocalConnectorRow[] {
93
+ assertLocalOwner(subject, dir);
94
+ if (project !== LOCAL_PROJECT_ID) throw new ConnectorError('no such project', 404);
95
+ return readLocalConnectors(dir);
96
+ }
97
+
98
+ function rowOrThrow(subject: string, id: string, dir: string): LocalConnectorRow {
99
+ assertLocalOwner(subject, dir);
100
+ const row = readLocalConnectors(dir).find((r) => r.id === id);
101
+ if (row === undefined) throw missing();
102
+ return row;
103
+ }
104
+
105
+ function replace(dir: string, row: LocalConnectorRow): Connector {
106
+ writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
107
+ return connectorFromRow(row);
108
+ }
109
+
110
+ async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
111
+ if (auth.kind === 'oauth') return true;
112
+ if (auth.kind === 'header') return false;
113
+ return advertisesOAuth(url);
114
+ }
115
+
116
+ export async function localListConnectors(
117
+ subject: string,
118
+ project: string,
119
+ dir = agentsDir(),
120
+ ): Promise<Connector[]> {
121
+ return Promise.resolve(ownedRows(subject, project, dir).map(connectorFromRow));
122
+ }
123
+
124
+ export async function localGetConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
125
+ return Promise.resolve(connectorFromRow(rowOrThrow(subject, id, dir)));
126
+ }
127
+
128
+ function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
129
+ const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), transport: 'http', config };
130
+ writeRows(dir, [...readLocalConnectors(dir), row]);
131
+ return connectorFromRow(row);
132
+ }
133
+
134
+ export async function localCreateConnector(
135
+ subject: string,
136
+ project: string,
137
+ input: ConnectorInput,
138
+ dir = agentsDir(),
139
+ ): Promise<Connector> {
140
+ ownedRows(subject, project, dir);
141
+ const name = connectorName(input.name);
142
+ const url = parseConnectorUrl(input.url);
143
+ const auth = connectorAuth(input.header, input.value);
144
+ const verified = stamp(await verifyRemoteMcp(url, auth));
145
+ return insert(dir, name, url, {
146
+ auth,
147
+ createdAt: new Date().toISOString(),
148
+ verified,
149
+ oauth: await oauthCapable(url, auth),
150
+ });
151
+ }
152
+
153
+ export async function localCreatePendingConnector(
154
+ subject: string,
155
+ project: string,
156
+ input: { name: unknown; url: unknown },
157
+ dir = agentsDir(),
158
+ ): Promise<Connector> {
159
+ ownedRows(subject, project, dir);
160
+ const name = connectorName(input.name);
161
+ const url = parseConnectorUrl(input.url);
162
+ return Promise.resolve(
163
+ insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true }),
164
+ );
165
+ }
166
+
167
+ export async function localReconnectConnector(
168
+ subject: string,
169
+ id: string,
170
+ auth: OAuthAuth,
171
+ dir = agentsDir(),
172
+ ): Promise<Connector> {
173
+ const row = rowOrThrow(subject, id, dir);
174
+ const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth));
175
+ return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
176
+ }
177
+
178
+ export async function localDisconnectConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
179
+ const row = rowOrThrow(subject, id, dir);
180
+ if (row.config.auth.kind !== 'oauth') throw new ConnectorError('that connector is not signed in', 400);
181
+ return Promise.resolve(replace(dir, { ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } }));
182
+ }
183
+
184
+ async function freshAuth(auth: ConnectorAuth, resource: string): Promise<ConnectorAuth> {
185
+ if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
186
+ return refreshOAuth(auth, resource);
187
+ }
188
+
189
+ export async function localVerifyConnector(subject: string, id: string, dir = agentsDir()): Promise<ConnectorCheck> {
190
+ const row = rowOrThrow(subject, id, dir);
191
+ try {
192
+ const url = parseConnectorUrl(row.url);
193
+ const auth = await freshAuth(row.config.auth, url.toString());
194
+ const verified = stamp(await verifyRemoteMcp(url, auth));
195
+ const oauth = row.config.oauth || (await oauthCapable(url, auth));
196
+ replace(dir, { ...row, config: { ...row.config, auth, verified, oauth } });
197
+ return { id: row.id, name: row.name, ok: true, verified };
198
+ } catch (err) {
199
+ if (!(err instanceof ConnectorVerifyError)) throw err;
200
+ return { id: row.id, name: row.name, ok: false, reason: err.message };
201
+ }
202
+ }
203
+
204
+ const nameClash = (name: string, agent: string): ConnectorError =>
205
+ new ConnectorError(`the agent '${agent}' already has a connector named '${name}'`, 409);
206
+
207
+ function clashFor(name: string, exceptId: string, agentIds: string[], dir: string): string | null {
208
+ const rows = readLocalConnectors(dir);
209
+ for (const agentId of agentIds) {
210
+ const held = connectorIdsOfLocalAgent(agentId, dir) ?? [];
211
+ const other = rows.find((r) => r.id !== exceptId && held.includes(r.id) && r.name === name);
212
+ if (other !== undefined) return localAgentsWith(other.id, dir).find((a) => a.id === agentId)?.name ?? agentId;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
218
+ const name = connectorName(raw);
219
+ const row = rowOrThrow(subject, id, dir);
220
+ const clash = clashFor(name, id, localAgentsWith(id, dir).map((a) => a.id), dir);
221
+ if (clash !== null) throw nameClash(name, clash);
222
+ return Promise.resolve(replace(dir, { ...row, name }));
223
+ }
224
+
225
+ export async function localDeleteConnector(subject: string, id: string, dir = agentsDir()): Promise<DeletedConnector> {
226
+ const row = rowOrThrow(subject, id, dir);
227
+ writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
228
+ localDropConnectorEverywhere(id, dir);
229
+ return Promise.resolve({ id: row.id, name: row.name });
230
+ }
231
+
232
+ const byIds = (ids: string[], dir: string): LocalConnectorRow[] =>
233
+ readLocalConnectors(dir).filter((r) => ids.includes(r.id));
234
+
235
+ export async function localConnectorSummariesByIds(ids: string[], dir = agentsDir()): Promise<ConnectorSummary[]> {
236
+ return Promise.resolve(
237
+ byIds(ids, dir).map((r) => ({ id: r.id, name: r.name, url: r.url, transport: r.transport, signIn: signInState(r.config) })),
238
+ );
239
+ }
240
+
241
+ export async function localConnectorNamesByIds(ids: string[], dir = agentsDir()): Promise<{ id: string; name: string }[]> {
242
+ return Promise.resolve(byIds(ids, dir).map((r) => ({ id: r.id, name: r.name })));
243
+ }
244
+
245
+ export async function localAgentConnectors(subject: string, agentId: string, dir = agentsDir()): Promise<AgentConnectors> {
246
+ const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
247
+ return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
248
+ }
249
+
250
+ export async function localAddConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
251
+ const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
252
+ const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
253
+ if (row === undefined) throw missing();
254
+ const held = localAgentConnectorIds(subject, agentId, dir);
255
+ if (!held.includes(connectorId)) {
256
+ const clash = clashFor(row.name, connectorId, [agentId], dir);
257
+ if (clash !== null) throw nameClash(row.name, clash);
258
+ localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
259
+ }
260
+ return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
261
+ }
262
+
263
+ export async function localRemoveConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
264
+ const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
265
+ const held = localAgentConnectorIds(subject, agentId, dir);
266
+ localSetAgentConnectors(subject, agentId, held.filter((id) => id !== connectorId), dir);
267
+ return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
268
+ }
269
+
270
+ const inflight = new Map<string, Promise<OAuthAuth>>();
271
+
272
+ function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
273
+ const running = inflight.get(row.id);
274
+ if (running !== undefined) return running;
275
+ const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
276
+ .then((fresh) => {
277
+ replace(dir, { ...row, config: { ...row.config, auth: fresh } });
278
+ return fresh;
279
+ })
280
+ .finally(() => {
281
+ inflight.delete(row.id);
282
+ });
283
+ inflight.set(row.id, job);
284
+ return job;
285
+ }
286
+
287
+ async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boolean, dir: string): Promise<RelayTarget> {
288
+ if (!force && !oauthExpired(auth)) return { kind: 'ok', url: row.url, headers: bearerHeaders(auth.accessToken) };
289
+ try {
290
+ const fresh = await refreshOnce(row, auth, dir);
291
+ return { kind: 'ok', url: row.url, headers: bearerHeaders(fresh.accessToken) };
292
+ } catch (err) {
293
+ log.warn({ id: row.id, err: errMsg(err) }, 'local relay: token refresh failed');
294
+ return unrefreshedTarget(row.url, auth, force);
295
+ }
296
+ }
297
+
298
+ export async function localRelayTarget(
299
+ agentId: string,
300
+ connectorId: string,
301
+ force: boolean,
302
+ dir = agentsDir(),
303
+ ): Promise<RelayTarget> {
304
+ const held = connectorIdsOfLocalAgent(agentId, dir);
305
+ if (!held?.includes(connectorId)) return { kind: 'missing' };
306
+ const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
307
+ if (row === undefined) return { kind: 'missing' };
308
+ parseConnectorUrl(row.url);
309
+ const auth = row.config.auth;
310
+ if (auth.kind === 'oauth') return oauthTarget(row, auth, force, dir);
311
+ return fixedTarget(row.url, auth, force);
312
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.42"
2
+ "version": "0.1.0-beta.43"
3
3
  }
@@ -1,118 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { errMsg, log } from './log.js';
4
- import { configDir } from './paths.js';
5
- import { metroBaseUrl } from './agent-import.js';
6
-
7
- const FETCH_TIMEOUT_MS = 15_000;
8
- const CACHE_MS = 60_000;
9
-
10
- export interface HostedConnector {
11
- id: string;
12
- name: string;
13
- url: string;
14
- transport: string;
15
- signIn: 'connected' | 'disconnected' | null;
16
- }
17
-
18
- export interface HostedCredential {
19
- token: string;
20
- url: string;
21
- }
22
-
23
- function readJsonFile(path: string): Record<string, unknown> | null {
24
- try {
25
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
26
- return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- function agentOfToken(token: string): string | null {
33
- const body = token.split('.')[1];
34
- if (body === undefined) return null;
35
- try {
36
- const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { agent?: unknown };
37
- return typeof claims.agent === 'string' ? claims.agent : null;
38
- } catch {
39
- return null;
40
- }
41
- }
42
-
43
- function credential(file: Record<string, unknown> | null): HostedCredential | null {
44
- if (file === null || typeof file.token !== 'string' || file.token === '') return null;
45
- return { token: file.token, url: typeof file.url === 'string' && file.url !== '' ? file.url : metroBaseUrl() };
46
- }
47
-
48
- export function hostedCredentialsFor(agentId: string, dir = configDir()): HostedCredential[] {
49
- const out: HostedCredential[] = [];
50
- const login = credential(readJsonFile(join(dir, 'credentials.json')));
51
- if (login !== null && agentOfToken(login.token) === agentId) out.push(login);
52
- const runtime = join(dir, `runtime-${agentId}.json`);
53
- if (existsSync(runtime)) {
54
- const cred = credential(readJsonFile(runtime));
55
- if (cred !== null) out.push(cred);
56
- }
57
- return out;
58
- }
59
-
60
- const isConnector = (v: unknown): v is HostedConnector =>
61
- typeof v === 'object' &&
62
- v !== null &&
63
- typeof (v as { id?: unknown }).id === 'string' &&
64
- typeof (v as { name?: unknown }).name === 'string';
65
-
66
- class Refused extends Error {}
67
-
68
- async function fetchHosted(cred: HostedCredential): Promise<HostedConnector[]> {
69
- const res = await fetch(`${cred.url}/api/cli/connectors`, {
70
- headers: { authorization: `Bearer ${cred.token}` },
71
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
72
- redirect: 'manual',
73
- });
74
- if (res.status === 401 || res.status === 403 || res.status === 409) {
75
- await res.arrayBuffer().catch(() => undefined);
76
- throw new Refused(`metro no longer accepts this credential (${String(res.status)})`);
77
- }
78
- if (!res.ok) throw new Error(`metro answered ${String(res.status)} for the connectors`);
79
- const body = (await res.json()) as { connectors?: unknown };
80
- return Array.isArray(body.connectors) ? body.connectors.filter(isConnector) : [];
81
- }
82
-
83
- async function firstAccepted(agentId: string, creds: HostedCredential[]): Promise<HostedConnector[] | null> {
84
- for (const cred of creds) {
85
- try {
86
- return await fetchHosted(cred);
87
- } catch (err) {
88
- if (!(err instanceof Refused)) throw err;
89
- log.warn({ agent: agentId, reason: errMsg(err) }, 'hosted connectors: credential refused, trying the next');
90
- }
91
- }
92
- return null;
93
- }
94
-
95
- const cache = new Map<string, { at: number; list: HostedConnector[] }>();
96
-
97
- export async function hostedConnectorsFor(agentId: string, dir = configDir()): Promise<HostedConnector[]> {
98
- const hit = cache.get(agentId);
99
- if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return hit.list;
100
- const creds = hostedCredentialsFor(agentId, dir);
101
- if (creds.length === 0) return [];
102
- try {
103
- const list = await firstAccepted(agentId, creds);
104
- if (list === null) {
105
- log.warn({ agent: agentId }, 'hosted connectors: every stored credential is stale; metro login again');
106
- return [];
107
- }
108
- cache.set(agentId, { at: Date.now(), list });
109
- return list;
110
- } catch (err) {
111
- log.warn({ agent: agentId, err: errMsg(err) }, 'hosted connectors: unavailable, listing none');
112
- return [];
113
- }
114
- }
115
-
116
- export function forgetHostedConnectors(): void {
117
- cache.clear();
118
- }
@@ -1,56 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { errMsg, log } from './log.js';
3
- import { apiSession, cors, sendJson } from './api-http.js';
4
- import type { HostedConnector } from './hosted-connectors.js';
5
-
6
- const PATH = '/api/connectors';
7
-
8
- export interface LocalConnectorsDeps {
9
- listConnectors: (subject: string) => Promise<HostedConnector[]>;
10
- }
11
-
12
- const payload = (c: HostedConnector): Record<string, unknown> => ({
13
- id: c.id,
14
- name: c.name,
15
- url: c.url,
16
- transport: c.transport,
17
- auth: c.signIn === null ? 'none' : 'oauth',
18
- header: null,
19
- signIn: c.signIn,
20
- verified: null,
21
- managed: 'metro.box',
22
- });
23
-
24
- export function handleLocalConnectorsRequest(
25
- req: IncomingMessage,
26
- res: ServerResponse,
27
- deps: LocalConnectorsDeps,
28
- ): boolean {
29
- const path = (req.url ?? '').split('?')[0] ?? '';
30
- if (path !== PATH && !path.startsWith(`${PATH}/`)) return false;
31
- if (req.method === 'OPTIONS') {
32
- res.writeHead(204, cors(req)).end();
33
- return true;
34
- }
35
- if (path !== PATH || req.method !== 'GET') {
36
- sendJson(req, res, path === PATH ? 405 : 404, {
37
- error: 'connectors are managed on metro.box, not on a local daemon',
38
- });
39
- return true;
40
- }
41
- const session = apiSession(req);
42
- if (!session) {
43
- sendJson(req, res, 401, { error: 'unauthorized' });
44
- return true;
45
- }
46
- deps
47
- .listConnectors(session.subject)
48
- .then((list) => {
49
- sendJson(req, res, 200, { connectors: list.map(payload) });
50
- })
51
- .catch((err: unknown) => {
52
- log.warn({ err: errMsg(err) }, 'local connectors: request failed');
53
- if (!res.headersSent) sendJson(req, res, 500, { error: 'connectors failed' });
54
- });
55
- return true;
56
- }