@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.303

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 (38) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
  4. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  5. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -34
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
  16. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
  17. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
  19. package/runtime/runtime.json +1 -1
  20. package/runtime/sdk-runner/runner.json +1 -1
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
  35. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
  36. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
  37. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
  38. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
@@ -5,7 +5,6 @@ import { join } from 'node:path';
5
5
  import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { claudeDir } from '../claude/files.js';
7
7
  import { loopbackBase } from '../files/attach-serve.js';
8
- import { isOrgRelayId } from './relay-id.js';
9
8
 
10
9
  const MARKER = join('bin', 'metro-plugin.mjs');
11
10
  const FILE = '.mcp.json';
@@ -33,29 +32,6 @@ export function serverKey(name: string): string {
33
32
  return slug === '' ? 'connector' : slug;
34
33
  }
35
34
 
36
- function legacyOwners(rows: PluginRow[], published: PluginRow[]): Map<string, string> {
37
- const visible = new Set(published.map((row) => row.id));
38
- const taken = new Map<string, number>();
39
- const owners = new Map<string, string>();
40
- const reserved = new Set<string>();
41
- for (const row of rows.filter((entry) => !isOrgRelayId(entry.id))) {
42
- const slug = serverKey(row.name);
43
- const seen = taken.get(slug) ?? 0;
44
- taken.set(slug, seen + 1);
45
- const key = seen === 0 ? slug : `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
46
- reserved.add(key);
47
- if (visible.has(row.id)) owners.set(key, row.id);
48
- }
49
- for (const row of rows.filter((entry) => isOrgRelayId(entry.id) && visible.has(entry.id)).sort((a, b) => a.id.localeCompare(b.id))) {
50
- const key = availableKey(orgKey(row), reserved);
51
- reserved.add(key);
52
- owners.set(key, row.id);
53
- }
54
- return new Map([...owners].map(([key, id]) => [id, key]));
55
- }
56
-
57
- const orgKey = (row: PluginRow): string => `org-${serverKey(row.name).slice(0, 35)}-${Buffer.from(row.id.slice(4)).toString('hex')}`;
58
-
59
35
  function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
60
36
  let key = wanted;
61
37
  let suffix = 1;
@@ -63,18 +39,12 @@ function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
63
39
  return key;
64
40
  }
65
41
 
66
- export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>, published: PluginRow[] = rows): Map<string, string> {
67
- const keys = new Map(saved ?? legacyOwners(rows, published));
42
+ export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>): Map<string, string> {
43
+ const keys = new Map(saved);
68
44
  const reserved = new Set(keys.values());
69
- const local = rows.filter((row) => !isOrgRelayId(row.id));
70
- const organization = rows.filter((row) => isOrgRelayId(row.id)).sort((a, b) => a.id.localeCompare(b.id));
71
- for (const row of [...local, ...organization]) {
45
+ for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
72
46
  if (keys.has(row.id)) continue;
73
- const slug = serverKey(row.name);
74
- const wanted = isOrgRelayId(row.id)
75
- ? orgKey(row)
76
- : reserved.has(slug) ? `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}` : slug;
77
- const key = availableKey(wanted, reserved);
47
+ const key = availableKey(serverKey(row.name), reserved);
78
48
  reserved.add(key);
79
49
  keys.set(row.id, key);
80
50
  }
@@ -1,11 +1,12 @@
1
1
  import { registerConnectors, type GateRow } from './gates.js';
2
2
  import { syncPluginServers } from './plugin-sync.js';
3
3
  import { persistentServerKeys } from './server-keys.js';
4
- import { isOrgRelayId } from './relay-id.js';
5
- import { migrationRecords } from './migration-state.js';
6
4
 
7
- let local: GateRow[] = [];
8
5
  let organization: GateRow[] | null = null;
6
+ const BOOT = Date.now();
7
+ const READY_WAIT_MS = 30_000;
8
+
9
+ export const connectorsReady = (now = Date.now()): boolean => organization !== null || now - BOOT >= READY_WAIT_MS;
9
10
 
10
11
  function metadata(rows: readonly GateRow[]): GateRow[] {
11
12
  return rows.map((row) => structuredClone({
@@ -18,41 +19,19 @@ function metadata(rows: readonly GateRow[]): GateRow[] {
18
19
  }));
19
20
  }
20
21
 
21
- function publishedRows(records: ReturnType<typeof migrationRecords>, organization: GateRow[]): GateRow[] {
22
- const retired = new Set(records.filter((record) => !['preparing', 'prepared'].includes(record.phase)).map((record) => record.sourceId));
23
- const aliases = new Map(organization.filter((row) => records.some((record) => record.phase === 'active' && record.sourceId === row.id && record.name === row.name)).map((row) => [row.id, row]));
24
- const rows = local.flatMap((row) => {
25
- const alias = aliases.get(row.id);
26
- return alias !== undefined ? [alias] : retired.has(row.id) ? [] : [row];
27
- });
28
- rows.push(...organization.filter((row) => isOrgRelayId(row.id) && !records.some((record) => row.id === `org-${record.destinationId ?? ''}`)));
29
- return rows;
30
- }
31
-
32
22
  export function publishConnectors(): void {
33
- let rows: GateRow[];
23
+ if (organization === null) return;
34
24
  let keys: Map<string, string>;
35
25
  try {
36
- const records = migrationRecords().filter((record) => record.phase !== 'rolled_back');
37
- if (organization === null && records.some((record) => record.phase === 'active')) return;
38
- const org = organization ?? [];
39
- rows = publishedRows(records, org);
40
- const active = new Set(records.filter((record) => record.phase === 'active').map((record) => record.sourceId));
41
- const reserved = [...rows, ...local.filter((row) => active.has(row.id))];
42
- keys = persistentServerKeys([...local, ...org.filter((row) => isOrgRelayId(row.id))], undefined, reserved);
26
+ keys = persistentServerKeys(organization);
43
27
  }
44
28
  catch (error) {
45
29
  registerConnectors([]);
46
30
  syncPluginServers([]);
47
31
  throw error;
48
32
  }
49
- registerConnectors(rows, keys);
50
- syncPluginServers(rows, { keys });
51
- }
52
-
53
- export function publishLocalConnectors(rows: readonly GateRow[]): void {
54
- local = metadata(rows.filter((row) => !isOrgRelayId(row.id)));
55
- publishConnectors();
33
+ registerConnectors(organization, keys);
34
+ syncPluginServers(organization, { keys });
56
35
  }
57
36
 
58
37
  export function publishOrgConnectors(rows: readonly GateRow[]): void {
@@ -7,5 +7,4 @@ export interface RelaySession {
7
7
  blocker: () => BlockedReason;
8
8
  signal: AbortSignal;
9
9
  close: () => void;
10
- dispatch?: (send: () => Promise<Response>, effectful?: boolean) => Promise<Response>;
11
10
  }
@@ -1,28 +1,16 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { whyUnreachable } from '@metro-labs/http/connector-reach';
3
- import { noteHealth } from './health.js';
4
- import type { RelayTarget } from './relay-target.js';
5
2
  import type { RelaySession } from './relay-session.js';
6
- import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
7
- import { isOrgRelayId } from './relay-id.js';
8
3
  import { exchangeOrg } from './org-relay.js';
9
- import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
10
- import { agentsDir } from '../agents/files.js';
11
- import { migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
12
4
  import { ApiError } from '@metro-labs/http/api-error';
13
- import { errMsg, log } from '@metro-labs/core/log';
14
- import { answerBlocked, mergeBlocked, screenCalls, type BlockedReason } from './relay-policy.js';
5
+ import { log } from '@metro-labs/core/log';
6
+ import { answerBlocked, mergeBlocked } from './relay-policy.js';
15
7
 
16
8
  export interface RelayApiDeps {
17
- migrationDir?: string;
18
- target: (connectorId: string, force: boolean) => Promise<RelayTarget>;
19
9
  identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
20
- signedOut: (connectorId: string) => void;
21
- blocked?: BlockedReason;
22
- open?: (connectorId: string) => RelaySession | Promise<RelaySession> | null;
10
+ open: (connectorId: string) => RelaySession | Promise<RelaySession>;
23
11
  }
24
12
 
25
- const ID_PATH_RE = /^\/relay\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
13
+ const ID_PATH_RE = /^\/relay\/(org-[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
26
14
  const METHODS = new Set(['POST', 'GET', 'DELETE']);
27
15
  const RELAY_BODY_MAX = 8 * 1024 * 1024;
28
16
  const FIRST_BYTE_MS = 120_000;
@@ -43,8 +31,7 @@ const PASS_RES = [
43
31
  const keepaliveMs = (): number =>
44
32
  Number(process.env.METRO_RELAY_KEEPALIVE_MS) || 25_000;
45
33
 
46
- const reconnectUrl = (id: string): string =>
47
- `https://metro.box/#/connector/${id}`;
34
+ const isRelaySse = (contentType: string | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
48
35
 
49
36
  function answer(
50
37
  res: ServerResponse,
@@ -56,13 +43,6 @@ function answer(
56
43
  res.end(JSON.stringify(body));
57
44
  }
58
45
 
59
- const signinAnswer = (res: ServerResponse, id: string): void => {
60
- answer(res, 424, {
61
- error: 'this connector needs signing in again',
62
- reconnect: reconnectUrl(id),
63
- });
64
- };
65
-
66
46
  function closeIfBodyUnread(req: IncomingMessage, res: ServerResponse): void {
67
47
  if (req.readableEnded) return;
68
48
  const drop = (): void => {
@@ -108,10 +88,9 @@ async function forward(
108
88
  target: { url: string; headers: Record<string, string> },
109
89
  body: Uint8Array<ArrayBuffer> | null,
110
90
  signal: AbortSignal,
111
- sensitive = false,
112
91
  ): Promise<Response> {
113
92
  const firstByte = setTimeout(() => {
114
- log.warn(sensitive ? {} : { url: target.url }, 'relay: upstream sent no response in time');
93
+ log.warn('relay: upstream sent no response in time');
115
94
  }, FIRST_BYTE_MS);
116
95
  firstByte.unref?.();
117
96
  try {
@@ -132,12 +111,12 @@ interface Keepalive {
132
111
  stop: () => void;
133
112
  }
134
113
 
135
- function startKeepalive(res: ServerResponse, listener?: PassiveSseListener): Keepalive {
114
+ function startKeepalive(res: ServerResponse): Keepalive {
136
115
  let timer: ReturnType<typeof setTimeout> | undefined;
137
116
  const arm = (): void => {
138
117
  timer = setTimeout(() => {
139
118
  try {
140
- if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
119
+ res.write(': keepalive\n\n');
141
120
  } catch {
142
121
  log.debug('relay: keepalive write failed');
143
122
  }
@@ -173,17 +152,13 @@ async function pumpBody(
173
152
  res: ServerResponse,
174
153
  body: ReadableStream<Uint8Array>,
175
154
  touch: () => void,
176
- listener?: PassiveSseListener,
177
155
  ): Promise<void> {
178
156
  const reader = body.getReader();
179
157
  for (;;) {
180
158
  const { done, value } = await reader.read();
181
159
  if (done) return;
182
- listener?.observe(value);
183
160
  touch();
184
- if (listener !== undefined) listener.forwarding = true;
185
161
  if (!res.write(value)) await drainedOrClosed(res);
186
- if (listener !== undefined) listener.forwarding = false;
187
162
  }
188
163
  }
189
164
 
@@ -196,7 +171,7 @@ function passedHeaders(upstream: Response): Record<string, string> {
196
171
  return headers;
197
172
  }
198
173
 
199
- async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[], listener?: PassiveSseListener): Promise<void> {
174
+ async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
200
175
  const headers = passedHeaders(upstream);
201
176
  if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
202
177
  await mergeBlocked(res, upstream, headers, answers);
@@ -210,46 +185,38 @@ async function pipe(res: ServerResponse, upstream: Response, answers: Record<str
210
185
  res.end();
211
186
  return;
212
187
  }
213
- const keepalive = sse ? startKeepalive(res, listener) : null;
188
+ const keepalive = sse ? startKeepalive(res) : null;
214
189
  try {
215
- await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
190
+ await pumpBody(res, upstream.body, () => keepalive?.touch());
216
191
  } finally {
217
192
  keepalive?.stop();
218
193
  }
219
194
  res.end();
220
195
  }
221
196
 
222
- const authFailed = (status: number): boolean =>
223
- status === 401 || status === 403;
224
-
225
- type Exchanged =
226
- | { kind: 'response'; upstream: Response }
227
- | { kind: 'missing' }
228
- | { kind: 'signin' };
197
+ async function deliver(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
198
+ if (upstream.status >= 300 && upstream.status < 400) {
199
+ await upstream.body?.cancel();
200
+ answer(res, 502, { error: 'the connector redirected; metro does not follow' });
201
+ return;
202
+ }
203
+ await pipe(res, upstream, answers);
204
+ }
229
205
 
230
- async function exchange(
206
+ async function relayOnce(
231
207
  req: IncomingMessage,
208
+ res: ServerResponse,
232
209
  connectorId: string,
233
210
  deps: RelayApiDeps,
234
- body: Uint8Array<ArrayBuffer> | null,
235
211
  signal: AbortSignal,
236
- dispatch: MigrationOperation['dispatch'],
237
- ): Promise<Exchanged> {
238
- assertLocalMigrationUse(connectorId, deps.migrationDir);
239
- const target = await deps.target(connectorId, false);
240
- assertLocalMigrationUse(connectorId, deps.migrationDir);
241
- if (target.kind !== 'ok') return { kind: target.kind };
242
- let upstream = await dispatch(() => forward(req, target, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
243
- if (!authFailed(upstream.status)) return { kind: 'response', upstream };
244
- await upstream.body?.cancel();
245
- assertLocalMigrationUse(connectorId, deps.migrationDir);
246
- const fresh = await deps.target(connectorId, true);
247
- assertLocalMigrationUse(connectorId, deps.migrationDir);
248
- if (fresh.kind !== 'ok') return { kind: 'signin' };
249
- upstream = await dispatch(() => forward(req, fresh, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
250
- if (!authFailed(upstream.status)) return { kind: 'response', upstream };
251
- await upstream.body?.cancel();
252
- return { kind: 'signin' };
212
+ ): Promise<void> {
213
+ const read = req.method === 'POST' ? await readCapped(req) : null;
214
+ const session = await deps.open(connectorId);
215
+ try {
216
+ const out = await exchangeOrg(connectorId, session, read, signal, (target, body, guard) => forward(req, target, body, guard));
217
+ if (out.kind === 'blocked') answerBlocked(res, out.screened);
218
+ else await deliver(res, out.upstream, out.answers);
219
+ } finally { session.close(); }
253
220
  }
254
221
 
255
222
  async function relayExchange(
@@ -268,7 +235,7 @@ async function relayExchange(
268
235
  res.socket?.removeListener('close', bail);
269
236
  });
270
237
  try {
271
- await relayOnce(req, res, connectorId, deps, control);
238
+ await relayOnce(req, res, connectorId, deps, control.signal);
272
239
  } catch (err) {
273
240
  if (!control.signal.aborted) throw err;
274
241
  log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
@@ -276,71 +243,6 @@ async function relayExchange(
276
243
  }
277
244
  }
278
245
 
279
- async function deliver(
280
- res: ServerResponse,
281
- connectorId: string,
282
- deps: RelayApiDeps,
283
- out: Exchanged,
284
- answers: Record<string, unknown>[],
285
- listener?: PassiveSseListener,
286
- ): Promise<void> {
287
- if (out.kind === 'missing') {
288
- answer(res, 404, { error: 'no such connector' });
289
- return;
290
- }
291
- if (out.kind === 'signin') {
292
- noteHealth(connectorId, false, 'the sign-in has expired, connect it again');
293
- deps.signedOut(connectorId);
294
- signinAnswer(res, connectorId);
295
- return;
296
- }
297
- if (out.upstream.status >= 300 && out.upstream.status < 400) {
298
- await out.upstream.body?.cancel();
299
- noteHealth(connectorId, false, 'the connector redirected; metro does not follow');
300
- answer(res, 502, { error: 'the connector redirected; metro does not follow' });
301
- return;
302
- }
303
- const { status } = out.upstream;
304
- noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
305
- await pipe(res, out.upstream, answers, listener);
306
- }
307
-
308
- async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
309
- const session = await deps.open?.(id);
310
- if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
311
- try {
312
- const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
313
- const send = () => forward(req, target, body, guard, true);
314
- return session.dispatch?.(send, migrationRequestEffectful(req.method, body)) ?? send();
315
- });
316
- if (out.kind === 'blocked') answerBlocked(res, out.screened);
317
- else await deliver(res, id, deps, out, out.answers);
318
- } finally { session.close(); }
319
- }
320
-
321
- async function relayOnce(
322
- req: IncomingMessage,
323
- res: ServerResponse,
324
- connectorId: string,
325
- deps: RelayApiDeps,
326
- control: AbortController,
327
- ): Promise<void> {
328
- const { signal } = control;
329
- const read = req.method === 'POST' ? await readCapped(req) : null;
330
- const dir = deps.migrationDir ?? agentsDir();
331
- if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
332
- await withLocalMigrationOperation(connectorId, dir, async (operation) => {
333
- const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
334
- if (screened?.forward === null) {
335
- answerBlocked(res, screened);
336
- return;
337
- }
338
- const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
339
- const listener = out.kind === 'response' ? passiveRelayListener(req, res, out.upstream, operation, () => { control.abort(); }) : undefined;
340
- await deliver(res, connectorId, deps, out, screened?.answers ?? [], listener);
341
- });
342
- }
343
-
344
246
  function dispatch(
345
247
  req: IncomingMessage,
346
248
  res: ServerResponse,
@@ -354,19 +256,12 @@ function dispatch(
354
256
  }
355
257
  relayExchange(req, res, connectorId, deps)
356
258
  .catch((err: unknown) => {
357
- if (err instanceof ApiError) {
358
- answer(res, err.status, { error: err.message });
359
- if (!res.writableEnded) res.end();
360
- } else if (isOrgRelayId(connectorId)) {
259
+ if (err instanceof ApiError) answer(res, err.status, { error: err.message });
260
+ else {
361
261
  log.warn({ connector: connectorId }, 'organization relay: request failed');
362
262
  answer(res, 503, { error: 'The organization connector is unavailable. Try again.' });
363
- if (!res.writableEnded) res.end();
364
- } else {
365
- log.warn({ err: errMsg(err), connector: connectorId }, 'relay: failed');
366
- noteHealth(connectorId, false, `metro could not reach the connector: ${whyUnreachable(err)}`);
367
- answer(res, 502, { error: `metro could not reach the connector: ${whyUnreachable(err)}` });
368
- if (!res.writableEnded) res.end();
369
263
  }
264
+ if (!res.writableEnded) res.end();
370
265
  closeIfBodyUnread(req, res);
371
266
  });
372
267
  }
@@ -33,14 +33,27 @@ function parseKeys(text: string): Map<string, string> {
33
33
  return keys;
34
34
  }
35
35
 
36
- export function persistentServerKeys(rows: PluginRow[], dir = agentsDir(), published: PluginRow[] = rows): Map<string, string> {
37
- const path = join(dir, 'connector-server-keys.json');
38
- const marker = join(dir, 'connector-server-keys.initialized');
39
- const saved = readKeys(path, marker);
40
- const keys = serverKeysOf(rows, saved, published);
41
- if (keys.size > 0 && keys.size !== saved?.size) {
42
- if (saved === undefined) writeSecure(marker, '1\n');
43
- writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
44
- }
36
+ const sameKeys = (a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean =>
37
+ a.size === b.size && [...a].every(([id, key]) => b.get(id) === key);
38
+
39
+ function serverKeyFiles(dir = agentsDir()): { path: string; marker: string } {
40
+ return { path: join(dir, 'connector-server-keys.json'), marker: join(dir, 'connector-server-keys.initialized') };
41
+ }
42
+
43
+ export function readServerKeys(dir = agentsDir()): Map<string, string> | undefined {
44
+ const { path, marker } = serverKeyFiles(dir);
45
+ return readKeys(path, marker);
46
+ }
47
+
48
+ export function writeServerKeys(keys: ReadonlyMap<string, string>, dir = agentsDir()): void {
49
+ const { path, marker } = serverKeyFiles(dir);
50
+ if (!existsSync(marker)) writeSecure(marker, '1\n');
51
+ writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
52
+ }
53
+
54
+ export function persistentServerKeys(rows: PluginRow[], dir = agentsDir()): Map<string, string> {
55
+ const saved = readServerKeys(dir);
56
+ const keys = serverKeysOf(rows, saved);
57
+ if (saved === undefined ? keys.size > 0 : !sameKeys(saved, keys)) writeServerKeys(keys, dir);
45
58
  return keys;
46
59
  }
@@ -6,7 +6,6 @@ import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail
6
6
  import { detachWithGmailRevocation } from './gmail-detach.js';
7
7
  import { recentSenders } from '../agents/senders.js';
8
8
  import { forwardTrainCall } from '../stations/train-call.js';
9
- import { publishLocalConnectors } from '../connectors/publication.js';
10
9
  import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
11
10
  import type { AgentApiDeps } from '../agents/api.js';
12
11
  import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
@@ -14,32 +13,9 @@ import type { SessionApis } from './session-apis.js';
14
13
  import type { ModeInfo } from '@metro-labs/http/mode-api';
15
14
  import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/bundle.js';
16
15
  import { METRO_VERSION } from '@metro-labs/core/version';
17
- import type { ConnectorApiDeps } from '../connectors/api.js';
18
- import { connectorMigrationPreview } from '../connectors/migration-preview.js';
19
- import { ConnectorMigrations } from '../connectors/migration-control.js';
20
- import { migrationExportableRows } from '../connectors/migration-guard.js';
21
- import { ConnectorTransfers } from '../connectors/transfer-api.js';
22
- import { copyConnectorRows } from '../connectors/transfer-store.js';
23
16
  import { authenticate } from '../mcp/request-identity.js';
24
17
  import type { RelayApiDeps } from '../connectors/relay.js';
25
- import {
26
- readLocalConnectors,
27
- localImportConnectors,
28
- localCreateConnector,
29
- localCreatePendingConnector,
30
- localDeleteConnector,
31
- localDisconnectConnector,
32
- localGetConnector,
33
- localListConnectors,
34
- localReconnectConnector,
35
- localRelayTarget,
36
- localRenameConnector,
37
- localVerifyConnector,
38
- localMarkSignedOut,
39
- localConnectorTools,
40
- localSetConnectorPolicy,
41
- } from '../connectors/store.js';
42
- import { blockedReason } from '../connectors/gates.js';
18
+ import { connectorGates } from '../connectors/gates.js';
43
19
  import {
44
20
  setLocalOwner,
45
21
  localAttachAccount,
@@ -117,43 +93,17 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
117
93
  };
118
94
  }
119
95
 
120
- const allConnectorIds = (): string[] => readLocalConnectors().map((c) => c.id);
121
-
122
96
  function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
123
- const all = allConnectorIds();
97
+ const all = connectorGates().map((gate) => gate.id);
124
98
  return Promise.resolve(new Map(ids.map((id) => [id, all] as const)));
125
99
  }
126
100
 
127
- const connectorApi: ConnectorApiDeps = {
128
- migrationPreview: () => connectorMigrationPreview(localOwner()),
129
- listConnectors: async () => {
130
- const rows = await localListConnectors();
131
- publishLocalConnectors(readLocalConnectors());
132
- return rows;
133
- },
134
- createConnector: localCreateConnector,
135
- verifyConnector: localVerifyConnector,
136
- connectorTools: localConnectorTools,
137
- disconnectConnector: localDisconnectConnector,
138
- renameConnector: localRenameConnector,
139
- deleteConnector: localDeleteConnector,
140
- setConnectorPolicy: (id, policy) => localSetConnectorPolicy(id, policy),
141
- createPendingConnector: localCreatePendingConnector,
142
- reconnectConnector: localReconnectConnector,
143
- getConnector: localGetConnector,
144
- };
145
-
146
101
  const relayApi: RelayApiDeps = {
147
- target: (connectorId, force) => localRelayTarget(connectorId, force),
148
102
  open: orgConnectorRelaySession,
149
103
  identify: (req) => {
150
104
  const who = authenticate(req);
151
105
  return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
152
106
  },
153
- signedOut: (id) => {
154
- localMarkSignedOut(id);
155
- },
156
- blocked: blockedReason,
157
107
  };
158
108
 
159
109
 
@@ -164,18 +114,17 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
164
114
  const bundle: AgentBundle = {
165
115
  version: 1,
166
116
  agent: { id: file.id, name: file.name ?? '', stations: file.stations.filter((a) => a.station !== 'gmail' || a.config.managed !== true) },
167
- connectors: migrationExportableRows(readLocalConnectors()).map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
117
+ connectors: [],
168
118
  };
169
119
  return Promise.resolve(bundle);
170
120
  },
171
121
  restore: async (bundle, mode) => {
172
122
  const made = await localImportAgent(loadedAgentOf(bundle), undefined, mode);
173
- const connectors = localImportConnectors(bundle.connectors, undefined, mode);
174
123
  for (const station of new Set(bundle.agent.stations.map((a) => a.station)))
175
124
  await deps.syncStations(station).catch((err: unknown) => {
176
125
  log.warn({ station, err: errMsg(err) }, 'restore: station reload failed, the change lands at the next boot');
177
126
  });
178
- return { id: made.id, name: made.name, stations: made.stations, connectors };
127
+ return { id: made.id, name: made.name, stations: made.stations, connectors: 0 };
179
128
  },
180
129
  };
181
130
  }
@@ -206,10 +155,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
206
155
  return {
207
156
  agentApi: agentApi(deps),
208
157
  bundleApi: bundleApi(deps),
209
- connectorApi,
210
158
  connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
211
- connectorMigrationApi: new ConnectorMigrations(),
212
- connectorTransfers: new ConnectorTransfers({ read: () => migrationExportableRows(readLocalConnectors()), copy: copyConnectorRows }),
213
159
  enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
214
160
  relayApi,
215
161
  claudeApi: { liveEvents: deps.liveEvents },
@@ -2,14 +2,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { handleSessionApiRequest } from './session.js';
3
3
  import { handleApprovalsRequest } from '../approvals/api.js';
4
4
  import { handleAgentApiRequest, type AgentApiDeps } from '../agents/api.js';
5
- import {
6
- handleConnectorApiRequest,
7
- type ConnectorApiDeps,
8
- } from '../connectors/api.js';
9
5
  import type { RelayApiDeps } from '../connectors/relay.js';
10
- import type { ConnectorTransfers } from '../connectors/transfer-api.js';
11
6
  import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
12
- import { handleMigrationRequest, type MigrationApiDeps } from '../connectors/migration-api.js';
13
7
  import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
14
8
  import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
15
9
  import type { ModeInfo } from '@metro-labs/http/mode-api';
@@ -38,11 +32,8 @@ export interface SessionApis {
38
32
  gateway?: GatewayDeps;
39
33
  terminalApi?: TerminalApiDeps;
40
34
  claudeApi?: ClaudeApiDeps;
41
- connectorApi?: ConnectorApiDeps;
42
- connectorTransfers?: ConnectorTransfers;
43
35
  enrollApi?: EnrollApiDeps;
44
36
  connectorSyncApi?: ConnectorSyncApiDeps;
45
- connectorMigrationApi?: MigrationApiDeps;
46
37
  relayApi?: RelayApiDeps;
47
38
  mode?: () => ModeInfo;
48
39
  }
@@ -59,11 +50,8 @@ export function handleSessionApis(
59
50
  () => handleSessionApiRequest(req, res),
60
51
  () => handleRunEventsRequest(req, res),
61
52
  () => handleApprovalsRequest(req, res),
62
- ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
63
53
  ...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
64
54
  ...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
65
- ...when(apis.connectorMigrationApi, (d) => handleMigrationRequest(req, res, d)),
66
- ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
67
55
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
68
56
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
69
57
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
@@ -39,13 +39,6 @@ export interface LoadedAccount {
39
39
  config: Record<string, unknown>;
40
40
  }
41
41
 
42
- export interface LoadedConnector {
43
- id: string;
44
- name: string;
45
- url: string;
46
- config: Record<string, unknown>;
47
- }
48
-
49
42
  export interface LoadedAgent {
50
43
  id: string;
51
44
  name: string;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.302"
2
+ "version": "0.1.0-beta.303"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.302",
2
+ "version": "0.1.0-beta.303",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",