@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +21 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
- package/runtime/node_modules/@metro-labs/http/package.json +1 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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
|
-
|
|
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[],
|
|
35
|
-
const
|
|
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
|
|
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
|
|
96
|
-
const
|
|
97
|
-
return
|
|
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
|
|
103
|
-
for (const prefix of
|
|
104
|
-
if (toolName.startsWith(prefix) && toolName.length > prefix.length)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
30
|
-
if (migrationRecords(dir).some((record) => locked(record.phase) &&
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
71
|
-
const operation = beginLocalMigrationOperation(id, dir, edit
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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)
|
|
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)
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
taken.
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|