@stage-labs/metro 0.1.0-beta.292 → 0.1.0-beta.294

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 (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +7 -3
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +21 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
  25. package/runtime/node_modules/@metro-labs/http/package.json +1 -0
  26. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
  27. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
  28. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
  29. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/sdk-runner/runner.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.292",
3
+ "version": "0.1.0-beta.294",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.292",
3
+ "version": "0.1.0-beta.294",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -59,7 +59,10 @@ function decide(policy, name, group) {
59
59
  const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
60
60
 
61
61
  function connectorCall(snap, toolName) {
62
- for (const [server, entry] of Object.entries(isRecord(snap.connectors) ? snap.connectors : {})) {
62
+ const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
63
+ const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
64
+ if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
65
+ for (const [server, entry] of entries) {
63
66
  if (!isRecord(entry)) continue;
64
67
  const name = text(entry.name);
65
68
  const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
@@ -20,7 +20,6 @@ export interface ConnectorGate {
20
20
  }
21
21
 
22
22
  let gates: ConnectorGate[] = [];
23
- let managed = new Set<string>();
24
23
 
25
24
  const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
26
25
 
@@ -31,21 +30,21 @@ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | unde
31
30
  return before !== after;
32
31
  }
33
32
 
34
- export function registerConnectors(rows: readonly GateRow[], managedIds: readonly string[] = [], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
35
- const nextManaged = new Set(managedIds);
36
- revokeToolPrompts((tool) => {
37
- const found = connectorToolOf(tool);
38
- if (found === undefined || (!isOrgRelayId(found.gate.id) && !managed.has(found.gate.id) && !nextManaged.has(found.gate.id))) return false;
39
- return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
40
- });
41
- managed = nextManaged;
42
- gates = rows.map((row) => ({
33
+ export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
34
+ const next = rows.map((row) => ({
43
35
  id: row.id,
44
36
  name: row.name,
45
37
  server: keys.get(row.id) ?? '',
46
38
  policy: row.config.policy ?? {},
47
39
  tools: row.config.toolGroups ?? {},
48
40
  }));
41
+ revokeToolPrompts((tool) => {
42
+ const before = connectorToolOf(tool);
43
+ const after = connectorToolOf(tool, next);
44
+ if (before?.gate.id !== after?.gate.id) return true;
45
+ return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
46
+ });
47
+ gates = next;
49
48
  const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
50
49
  row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
51
50
  );
@@ -63,7 +62,7 @@ const promptFor = (connectorId: string, tool: string) => (asked: string): boolea
63
62
 
64
63
  export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
65
64
  const gate = gates.find((g) => g.id === connectorId);
66
- if (gate === undefined) return null;
65
+ if (gate === undefined) return 'This connector is not available.';
67
66
  settlePrompts(promptFor(connectorId, tool), args);
68
67
  const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
69
68
  if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
@@ -92,19 +91,24 @@ export function requestConnectorBlocker(): () => BlockedReason {
92
91
  };
93
92
  }
94
93
 
95
- function prefixesOf(gate: ConnectorGate): string[] {
96
- const prefixes = [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`];
97
- return isOrgRelayId(gate.id) ? prefixes : [...prefixes, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
94
+ function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
95
+ const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
96
+ return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
98
97
  }
99
98
 
100
- export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
99
+ export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
101
100
  if (toolName.startsWith('mcp__metro__')) return undefined;
102
- for (const gate of gates)
103
- for (const prefix of prefixesOf(gate))
104
- if (toolName.startsWith(prefix) && toolName.length > prefix.length) {
105
- const called = toolName.slice(prefix.length);
106
- const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
107
- return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
108
- }
109
- return undefined;
101
+ for (const gate of entries)
102
+ for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
103
+ if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
104
+ return legacyToolOf(toolName, entries);
105
+ }
106
+
107
+ function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
108
+ if (!toolName.startsWith('mcp__metro_box_')) return undefined;
109
+ const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
110
+ const gate = legacy.length === 1 ? legacy[0] : undefined;
111
+ if (gate === undefined) return undefined;
112
+ const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
113
+ return called === '' ? undefined : resolvedTool(gate, called);
110
114
  }
@@ -62,7 +62,7 @@ function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
62
62
  }
63
63
 
64
64
  function callFailure(error: unknown, action: string): ApiError {
65
- if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The destination already has credentials, a name conflict or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
65
+ if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The mapped destination already has credentials or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
66
66
  if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
67
67
  return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
68
68
  }
@@ -71,7 +71,9 @@ function callFailure(error: unknown, action: string): ApiError {
71
71
 
72
72
  function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
73
73
  const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
74
- return delivered?.state === 'ready' && delivered.name === record.name && samePolicy(delivered.policy, record.policy);
74
+ if (delivered?.state !== 'ready' || delivered.name !== record.name) return false;
75
+ const layers = delivered.policyLayers;
76
+ return layers === undefined ? samePolicy(delivered.policy, record.policy) : samePolicy(layers.organization, record.policy) && samePolicy(layers.agent, {});
75
77
  }
76
78
 
77
79
  export class ConnectorMigrations {
@@ -210,7 +212,8 @@ export class ConnectorMigrations {
210
212
 
211
213
  private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
212
214
  const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
213
- if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
215
+ const localIdle = this.localIdle(record.sourceId);
216
+ if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
214
217
  this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
215
218
  return true;
216
219
  }
@@ -300,6 +303,7 @@ export class ConnectorMigrations {
300
303
  private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
301
304
  this.check(context, record);
302
305
  if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
306
+ localMigrationIdle(record.sourceId, this.dir, true);
303
307
  if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
304
308
  if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
305
309
  this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
@@ -3,7 +3,7 @@ import type { FetchLike } from '@metro-labs/core/stations/oauth';
3
3
  import { agentsDir } from '../agents/files.js';
4
4
  import type { AgentKey } from './agent-key.js';
5
5
  import { migrationRecord, migrationRecords } from './migration-state.js';
6
- import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
6
+ import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
7
7
 
8
8
  const locked = (phase: string): boolean => phase !== 'rolled_back';
9
9
  const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
@@ -26,17 +26,19 @@ export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
26
26
  assertMigrationOutcomeKnown(id, dir);
27
27
  }
28
28
 
29
- export function assertLocalMigrationEdit(id: string, name: string | undefined, dir = agentsDir()): void {
30
- if (migrationRecords(dir).some((record) => locked(record.phase) && (record.sourceId === id || record.name === name))) throw refused();
29
+ export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
30
+ if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw refused();
31
31
  }
32
32
 
33
33
  export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
34
34
  const records = migrationRecords(dir).filter((record) => locked(record.phase));
35
- return rows.filter((row) => !records.some((record) => record.sourceId === row.id || record.name === row.name));
35
+ return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
36
36
  }
37
37
 
38
38
  export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
39
- return migrationOperationsIdle(id, dir, !allowUncertain && migrationSource(id, dir) !== null);
39
+ const record = migrationSource(id, dir);
40
+ if (record !== null && retired(record)) drainMigrationListeners(id, dir);
41
+ return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
40
42
  }
41
43
 
42
44
  export interface LocalMigrationOperation extends MigrationOperation {
@@ -45,11 +47,11 @@ export interface LocalMigrationOperation extends MigrationOperation {
45
47
  close: () => void;
46
48
  }
47
49
 
48
- export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false, name?: string): LocalMigrationOperation {
50
+ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
49
51
  let closed = false;
50
52
  const check = (): void => {
51
53
  if (closed) throw refused();
52
- if (edit) assertLocalMigrationEdit(id, name, dir);
54
+ if (edit) assertLocalMigrationEdit(id, dir);
53
55
  else assertLocalMigrationUse(id, dir);
54
56
  };
55
57
  check();
@@ -61,14 +63,15 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
61
63
  return {
62
64
  check,
63
65
  dispatch,
66
+ passiveListener: tracked.passiveListener,
64
67
  persistedRefresh: tracked.persistedRefresh,
65
68
  fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
66
69
  close: () => { closed = true; tracked.close(); },
67
70
  };
68
71
  }
69
72
 
70
- export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false, name?: string): Promise<T> {
71
- const operation = beginLocalMigrationOperation(id, dir, edit, name);
73
+ export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
74
+ const operation = beginLocalMigrationOperation(id, dir, edit);
72
75
  try { return await run(operation); }
73
76
  finally { operation.close(); }
74
77
  }
@@ -17,13 +17,20 @@ interface RequestOperation {
17
17
  refresh: boolean;
18
18
  }
19
19
 
20
+ interface PassiveListener {
21
+ idle: () => boolean;
22
+ stop: () => void;
23
+ }
24
+
20
25
  export interface MigrationOperation {
26
+ passiveListener: (listener: PassiveListener) => void;
21
27
  dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
22
28
  close: () => void;
23
29
  persistedRefresh: () => void;
24
30
  }
25
31
 
26
32
  const leases = new Map<string, Set<string>>();
33
+ const listeners = new Map<string, PassiveListener>();
27
34
  const requests = new Map<string, RequestOperation>();
28
35
  const held = new Map<string, RequestOperation>();
29
36
  const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
@@ -103,6 +110,17 @@ export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()):
103
110
  if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
104
111
  }
105
112
 
113
+ export function drainMigrationListeners(source: string, dir: string): void {
114
+ const active = [...(leases.get(keyOf(source, dir)) ?? [])];
115
+ if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
116
+ if (readPending(source, dir).some((id) => requests.has(id))) return;
117
+ for (const lease of active) {
118
+ const listener = listeners.get(lease);
119
+ listeners.delete(lease);
120
+ listener?.stop();
121
+ }
122
+ }
123
+
106
124
  export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
107
125
  if (leases.has(keyOf(source, dir))) return false;
108
126
  return !protectedSource || readPending(source, dir).length === 0;
@@ -181,6 +199,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
181
199
  leases.set(key, active);
182
200
  let closed = false;
183
201
  return {
202
+ passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
184
203
  dispatch: (send, options = {}) => {
185
204
  if (closed) return Promise.reject(uncertain());
186
205
  return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
@@ -191,6 +210,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
191
210
  close: () => {
192
211
  if (closed) return;
193
212
  closed = true;
213
+ listeners.delete(lease);
194
214
  for (const request of held.values()) if (request.lease === lease) {
195
215
  finish(request, false);
196
216
  held.delete(request.id);
@@ -201,13 +201,11 @@ function previewRows(dir: string): MigrationConnector[] {
201
201
  const rows = readRows(dir).map(previewRow);
202
202
  const seen = new Map<string, MigrationConnector>();
203
203
  for (const row of rows) {
204
- for (const key of [`id:${row.id}`, `name:${row.name}`]) {
205
- const prior = seen.get(key);
206
- if (prior !== undefined) {
207
- for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
208
- }
209
- seen.set(key, row);
204
+ const prior = seen.get(row.id);
205
+ if (prior !== undefined) {
206
+ for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
210
207
  }
208
+ seen.set(row.id, row);
211
209
  }
212
210
  return rows;
213
211
  }
@@ -51,7 +51,7 @@ export async function startOAuth(
51
51
  returnTo,
52
52
  }, operation.fetch);
53
53
  const row = await deps.createPendingConnector({
54
- name: bodyField(body, 'name'),
54
+ name,
55
55
  url: bodyField(body, 'url'),
56
56
  clientId,
57
57
  clientSecret,
@@ -69,7 +69,7 @@ export async function startOAuth(
69
69
  'connector-api: stored unconnected, sending the user to sign in',
70
70
  );
71
71
  sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
72
- }, true, name);
72
+ }, true);
73
73
  }
74
74
 
75
75
  export async function handleConnect(
@@ -1,7 +1,7 @@
1
1
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
2
2
  import type { ToolGroup } from '@metro-labs/core/stations/types';
3
3
  import { ApiError } from '@metro-labs/http/api-error';
4
- import type { DeliveredConnector } from '@metro-labs/http/connector-delivery';
4
+ import { validConnectorToolName, type DeliveredConnector } from '@metro-labs/http/connector-delivery';
5
5
  import { MetroApiRefusal } from './metro-api.js';
6
6
  import { listRemoteTools } from './tools.js';
7
7
 
@@ -106,8 +106,8 @@ export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Pro
106
106
  if (failure.error !== null) throw failure.error;
107
107
  guard.current();
108
108
  guard.signal.throwIfAborted();
109
- if (tools.length > MAX_TOOLS) throw orgUnavailable();
110
- return Object.fromEntries(tools.filter((tool) => /^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)).map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
109
+ if (tools.length > MAX_TOOLS || tools.some((tool) => !validConnectorToolName(tool.name))) throw orgUnavailable();
110
+ return Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
111
111
  } catch (error) {
112
112
  throw orgRuntimeError(error);
113
113
  }
@@ -3,7 +3,7 @@ import { agentsDir } from '../agents/files.js';
3
3
  import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
4
4
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
5
5
  import type { ToolGroup } from '@metro-labs/core/stations/types';
6
- import type { DeliveredConnector } from '@metro-labs/http/connector-delivery';
6
+ import { effectiveConnectorPolicy, type DeliveredConnector } from '@metro-labs/http/connector-delivery';
7
7
  import type { ApiError } from '@metro-labs/http/api-error';
8
8
  import { requestConnectorBlocker, type GateRow } from './gates.js';
9
9
  import { publishOrgConnectors } from './publication.js';
@@ -49,7 +49,7 @@ interface Session {
49
49
 
50
50
  const stampOf = (row: RuntimeRow): string => JSON.stringify([
51
51
  row.relayId, row.relayName, row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
52
- Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)),
52
+ Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)), row.policyLayers,
53
53
  ]);
54
54
 
55
55
  const rowError = (row: DeliveredConnector | undefined): ApiError | null => {
@@ -94,7 +94,7 @@ export class OrgConnectorRuntime {
94
94
  private publish(rows: RuntimeRow[]): void {
95
95
  const metadata: GateRow[] = rows.filter((row) => row.state === 'ready').map((row) => ({
96
96
  id: row.relayId, name: row.relayName,
97
- config: { policy: structuredClone(row.policy), toolGroups: { ...this.entries.get(row.id)?.groups } },
97
+ config: { policy: effectiveConnectorPolicy(row, this.entries.get(row.id)?.groups ?? {}), toolGroups: { ...this.entries.get(row.id)?.groups } },
98
98
  }));
99
99
  const text = JSON.stringify(metadata);
100
100
  if (text === this.published) return;
@@ -230,15 +230,21 @@ export class OrgConnectorRuntime {
230
230
  private discover(entry: Entry): Promise<void> | null {
231
231
  if (entry.scan !== null) return entry.scan;
232
232
  if (performance.now() < entry.nextScan) {
233
- if (entry.error !== null) throw entry.error;
233
+ if (entry.error !== null) {
234
+ log.debug({ stage: 'discovery_backoff', status: entry.error.status }, 'organization connectors: request unavailable');
235
+ throw entry.error;
236
+ }
234
237
  return null;
235
238
  }
236
- if (this.scans >= 4) throw orgUnavailable();
239
+ if (this.scans >= 4) {
240
+ log.debug({ stage: 'discovery_capacity', active: this.scans }, 'organization connectors: request unavailable');
241
+ throw orgUnavailable();
242
+ }
237
243
  this.scans += 1;
238
244
  entry.scan = this.scan(entry).catch((error: unknown) => {
239
245
  entry.error = orgRuntimeError(error);
240
246
  entry.nextScan = performance.now() + 30_000;
241
- log.debug({ status: entry.error.status }, 'organization connectors: tool discovery failed');
247
+ log.debug({ stage: 'tool_discovery', status: entry.error.status }, 'organization connectors: tool discovery failed');
242
248
  throw entry.error;
243
249
  }).finally(() => {
244
250
  this.scans -= 1;
@@ -11,6 +11,7 @@ import type { RelaySession } from './relay-session.js';
11
11
  import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
12
12
  import { localMigrationIdle, migrationRuntimeMapping } from './migration-guard.js';
13
13
  import { publishConnectors } from './publication.js';
14
+ import { orgRuntimeError, orgUnavailable } from './org-runtime-discovery.js';
14
15
 
15
16
  type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
16
17
  type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
@@ -163,10 +164,22 @@ export class OrgConnectorControl {
163
164
  this.clear();
164
165
  }
165
166
 
166
- open(id: string): RelaySession {
167
+ async open(id: string): Promise<RelaySession> {
167
168
  if (this.runtime === null && !syncOptedIn(this.dir)) throw new ApiError('Organization connectors are not available on this agent.', 503);
168
169
  this.reconcile(true);
169
- if (this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
170
- return this.runtime.open(id);
170
+ const runtime = this.runtime;
171
+ const sync = this.sync;
172
+ if (runtime === null || sync === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
173
+ const state = sync.status().state;
174
+ try {
175
+ await sync.recover();
176
+ this.reconcile(false);
177
+ if (this.runtime !== runtime || this.sync !== sync) throw orgUnavailable();
178
+ } catch (error) {
179
+ const safe = orgRuntimeError(error);
180
+ log.debug({ stage: 'receipt_open', state, status: safe.status }, 'organization connectors: request unavailable');
181
+ throw safe;
182
+ }
183
+ return runtime.open(id);
171
184
  }
172
185
  }
@@ -13,7 +13,7 @@ function control(): OrgConnectorControl {
13
13
  return active;
14
14
  }
15
15
 
16
- export function orgConnectorRelaySession(id: string): RelaySession | null {
16
+ export function orgConnectorRelaySession(id: string): Promise<RelaySession> | null {
17
17
  return isOrgRelayId(id) || migrationSource(id)?.phase === 'active' ? control().open(id) : null;
18
18
  }
19
19
 
@@ -47,6 +47,7 @@ export class OrgConnectorSync {
47
47
  private wallDeadline = 0;
48
48
  private generation = 0;
49
49
  private running: Promise<void> | null = null;
50
+ private recovery: Promise<void> | null = null;
50
51
  private expiry: ReturnType<typeof setTimeout> | null = null;
51
52
  private readonly monotonic: () => number;
52
53
  private readonly listeners = new Set<() => void>();
@@ -153,6 +154,12 @@ export class OrgConnectorSync {
153
154
  return this.running;
154
155
  }
155
156
 
157
+ recover(): Promise<void> {
158
+ if (this.read() !== null) return Promise.resolve();
159
+ this.recovery ??= this.syncFresh().finally(() => { this.recovery = null; });
160
+ return this.recovery;
161
+ }
162
+
156
163
  async syncFresh(refresh?: RefreshRequest): Promise<void> {
157
164
  if (this.running !== null) {
158
165
  await this.running.catch(() => {
@@ -167,7 +174,7 @@ export class OrgConnectorSync {
167
174
  const context = { ...identity.context, requestId };
168
175
  const started = this.deps.api.now();
169
176
  const clock = this.monotonic();
170
- const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
177
+ const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors?policy_layers=1', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
171
178
  this.validate(this.identity());
172
179
  if (generation !== this.generation || this.token !== identity.token) throw invalid();
173
180
  const snapshot = openConnectorDelivery(identity.key.sealing, context, response.sealed);
@@ -33,21 +33,48 @@ export function serverKey(name: string): string {
33
33
  return slug === '' ? 'connector' : slug;
34
34
  }
35
35
 
36
- export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
36
+ function legacyOwners(rows: PluginRow[], published: PluginRow[]): Map<string, string> {
37
+ const visible = new Set(published.map((row) => row.id));
37
38
  const taken = new Map<string, number>();
38
- const keys = new Map<string, string>();
39
- for (const row of rows.filter((row) => !isOrgRelayId(row.id))) {
40
- const wanted = serverKey(row.name);
41
- const seen = taken.get(wanted) ?? 0;
42
- taken.set(wanted, seen + 1);
43
- keys.set(row.id, seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`);
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);
44
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
+ function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
60
+ let key = wanted;
61
+ let suffix = 1;
62
+ while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
63
+ return key;
64
+ }
65
+
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));
45
68
  const reserved = new Set(keys.values());
46
- for (const row of rows.filter((row) => isOrgRelayId(row.id)).sort((a, b) => a.id.localeCompare(b.id))) {
47
- const wanted = `org-${serverKey(row.name).slice(0, 35)}-${Buffer.from(row.id.slice(4)).toString('hex')}`;
48
- let key = wanted;
49
- let suffix = 1;
50
- while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
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]) {
72
+ 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);
51
78
  reserved.add(key);
52
79
  keys.set(row.id, key);
53
80
  }
@@ -1,5 +1,6 @@
1
1
  import { registerConnectors, type GateRow } from './gates.js';
2
- import { serverKeysOf, syncPluginServers } from './plugin-sync.js';
2
+ import { syncPluginServers } from './plugin-sync.js';
3
+ import { persistentServerKeys } from './server-keys.js';
3
4
  import { isOrgRelayId } from './relay-id.js';
4
5
  import { migrationRecords } from './migration-state.js';
5
6
 
@@ -17,14 +18,7 @@ function metadata(rows: readonly GateRow[]): GateRow[] {
17
18
  }));
18
19
  }
19
20
 
20
- export function publishConnectors(): void {
21
- let records: ReturnType<typeof migrationRecords>;
22
- try { records = migrationRecords().filter((record) => record.phase !== 'rolled_back'); }
23
- catch (error) {
24
- registerConnectors([], local.map((row) => row.id));
25
- syncPluginServers([]);
26
- throw error;
27
- }
21
+ function publishedRows(records: ReturnType<typeof migrationRecords>): GateRow[] {
28
22
  const retired = new Set(records.filter((record) => !['preparing', 'prepared'].includes(record.phase)).map((record) => record.sourceId));
29
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]));
30
24
  const rows = local.flatMap((row) => {
@@ -32,8 +26,25 @@ export function publishConnectors(): void {
32
26
  return alias !== undefined ? [alias] : retired.has(row.id) ? [] : [row];
33
27
  });
34
28
  rows.push(...organization.filter((row) => isOrgRelayId(row.id) && !records.some((record) => row.id === `org-${record.destinationId ?? ''}`)));
35
- const keys = serverKeysOf([...local, ...rows.filter((row) => isOrgRelayId(row.id))]);
36
- registerConnectors(rows, [...retired, ...aliases.keys()], keys);
29
+ return rows;
30
+ }
31
+
32
+ export function publishConnectors(): void {
33
+ let rows: GateRow[];
34
+ let keys: Map<string, string>;
35
+ try {
36
+ const records = migrationRecords().filter((record) => record.phase !== 'rolled_back');
37
+ rows = publishedRows(records);
38
+ const active = new Set(records.filter((record) => record.phase === 'active').map((record) => record.sourceId));
39
+ const reserved = [...rows, ...local.filter((row) => active.has(row.id))];
40
+ keys = persistentServerKeys([...local, ...organization.filter((row) => isOrgRelayId(row.id))], undefined, reserved);
41
+ }
42
+ catch (error) {
43
+ registerConnectors([]);
44
+ syncPluginServers([]);
45
+ throw error;
46
+ }
47
+ registerConnectors(rows, keys);
37
48
  syncPluginServers(rows, { keys });
38
49
  }
39
50