@stage-labs/metro 0.1.0-beta.286 → 0.1.0-beta.288
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/STAGE.md +11 -0
- package/runtime/marketplace/plugin/bin/policy.mjs +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +148 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +14 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -72
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +101 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +40 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +92 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +200 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +4 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +61 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +111 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +246 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +33 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +10 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +25 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -20
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +16 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-schema.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +10 -2
- package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +110 -0
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +6 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +115 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +54 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-discovery.ts → http/src/connector-discovery.ts} +31 -14
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +73 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/verify.ts → http/src/connector-mcp.ts} +16 -16
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-client.ts → http/src/connector-oauth.ts} +36 -13
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +53 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/url.ts → http/src/connector-url.ts} +1 -1
- package/runtime/node_modules/@metro-labs/http/src/ed25519.ts +56 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- /package/runtime/node_modules/@metro-labs/{daemon/src/connectors/reach.ts → http/src/connector-reach.ts} +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
|
+
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
4
|
+
import type { DeliveredConnector } from '@metro-labs/http/connector-delivery';
|
|
5
|
+
import type { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
+
import { requestConnectorBlocker, type GateRow } from './gates.js';
|
|
7
|
+
import { publishOrgConnectors } from './publication.js';
|
|
8
|
+
import { isOrgRelayId, orgRelayId } from './relay-id.js';
|
|
9
|
+
import type { RelaySession } from './relay-session.js';
|
|
10
|
+
import type { RelayTarget } from './relay-target.js';
|
|
11
|
+
import type { OrgConnectorSync } from './org-sync.js';
|
|
12
|
+
import { discoverOrgGroups, orgMissing, orgRuntimeError, orgSignin, orgUnavailable } from './org-runtime-discovery.js';
|
|
13
|
+
|
|
14
|
+
interface RuntimeDeps {
|
|
15
|
+
enabled?: () => boolean;
|
|
16
|
+
publish?: (rows: readonly GateRow[]) => void;
|
|
17
|
+
fetch?: FetchLike;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Entry {
|
|
21
|
+
id: string;
|
|
22
|
+
epoch: number;
|
|
23
|
+
stamp: string;
|
|
24
|
+
controller: AbortController;
|
|
25
|
+
groups: Record<string, ToolGroup>;
|
|
26
|
+
scan: Promise<void> | null;
|
|
27
|
+
nextScan: number;
|
|
28
|
+
error: ApiError | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Session {
|
|
32
|
+
entry: Entry;
|
|
33
|
+
controller: AbortController;
|
|
34
|
+
revision: string | null;
|
|
35
|
+
error: ApiError | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stampOf = (row: DeliveredConnector): string => JSON.stringify([
|
|
39
|
+
row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
|
|
40
|
+
Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const rowError = (row: DeliveredConnector | undefined): ApiError | null => {
|
|
44
|
+
if (row === undefined) return orgMissing();
|
|
45
|
+
if (row.state === 'signin') return orgSignin();
|
|
46
|
+
return row.state === 'ready' ? null : orgUnavailable();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const targetOf = (row: DeliveredConnector): RelayTarget => ({ kind: 'ok', url: row.url, headers: { ...row.headers } });
|
|
50
|
+
|
|
51
|
+
export class OrgConnectorRuntime {
|
|
52
|
+
private readonly entries = new Map<string, Entry>();
|
|
53
|
+
private readonly sessions = new Set<Session>();
|
|
54
|
+
private readonly unsubscribe: () => void;
|
|
55
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
56
|
+
private published = '';
|
|
57
|
+
private stopped = false;
|
|
58
|
+
private scans = 0;
|
|
59
|
+
|
|
60
|
+
constructor(private readonly sync: OrgConnectorSync, private readonly deps: RuntimeDeps = {}) {
|
|
61
|
+
this.unsubscribe = sync.onChange(() => { this.update(); });
|
|
62
|
+
this.timer = setInterval(() => {
|
|
63
|
+
try {
|
|
64
|
+
this.update();
|
|
65
|
+
} catch {
|
|
66
|
+
log.warn('organization connectors: runtime metadata could not be updated');
|
|
67
|
+
}
|
|
68
|
+
}, 1000);
|
|
69
|
+
this.timer.unref();
|
|
70
|
+
this.update();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private enabled(): boolean {
|
|
74
|
+
return !this.stopped && (this.deps.enabled?.() ?? process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private abort(session: Session, error: ApiError): void {
|
|
78
|
+
session.error ??= error;
|
|
79
|
+
session.controller.abort();
|
|
80
|
+
this.sessions.delete(session);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private publish(rows: DeliveredConnector[]): void {
|
|
84
|
+
const metadata: GateRow[] = rows.filter((row) => row.state === 'ready').map((row) => ({
|
|
85
|
+
id: orgRelayId(row.id), name: row.name,
|
|
86
|
+
config: { policy: structuredClone(row.policy), toolGroups: { ...this.entries.get(row.id)?.groups } },
|
|
87
|
+
}));
|
|
88
|
+
const text = JSON.stringify(metadata);
|
|
89
|
+
if (text === this.published) return;
|
|
90
|
+
try {
|
|
91
|
+
(this.deps.publish ?? publishOrgConnectors)(metadata);
|
|
92
|
+
this.published = text;
|
|
93
|
+
} catch {
|
|
94
|
+
this.published = '';
|
|
95
|
+
for (const session of this.sessions) this.abort(session, orgUnavailable());
|
|
96
|
+
throw orgUnavailable();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private invalidateEntries(rows: DeliveredConnector[], epoch: number, available: boolean): void {
|
|
101
|
+
for (const [id, entry] of this.entries) {
|
|
102
|
+
const row = rows.find((value) => value.id === id);
|
|
103
|
+
const valid = available && entry.epoch === epoch;
|
|
104
|
+
if (valid && row?.state === 'ready' && entry.stamp === stampOf(row)) continue;
|
|
105
|
+
const error = valid ? rowError(row) ?? orgUnavailable() : orgUnavailable();
|
|
106
|
+
entry.controller.abort();
|
|
107
|
+
this.entries.delete(id);
|
|
108
|
+
this.abortEntry(entry, error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private abortEntry(entry: Entry, error: ApiError): void {
|
|
113
|
+
for (const session of this.sessions) if (session.entry === entry) this.abort(session, error);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private update(): DeliveredConnector[] | null {
|
|
117
|
+
const snapshot = this.enabled() ? this.sync.read() : null;
|
|
118
|
+
const rows = snapshot?.connectors ?? [];
|
|
119
|
+
const epoch = this.sync.epoch();
|
|
120
|
+
this.invalidateEntries(rows, epoch, snapshot !== null);
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
if (row.state !== 'ready' || this.entries.has(row.id)) continue;
|
|
123
|
+
this.entries.set(row.id, { id: row.id, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
|
|
124
|
+
}
|
|
125
|
+
this.publish(rows);
|
|
126
|
+
return snapshot === null ? null : rows;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private currentEntry(entry: Entry): DeliveredConnector {
|
|
130
|
+
const rows = this.update();
|
|
131
|
+
if (rows === null || entry.epoch !== this.sync.epoch()) throw orgUnavailable();
|
|
132
|
+
const row = rows.find((value) => value.id === entry.id);
|
|
133
|
+
if (row === undefined) throw orgMissing();
|
|
134
|
+
const error = rowError(row);
|
|
135
|
+
if (error !== null) throw error;
|
|
136
|
+
if (entry.controller.signal.aborted || this.entries.get(entry.id) !== entry) throw orgUnavailable();
|
|
137
|
+
return row;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private currentSession(session: Session): DeliveredConnector {
|
|
141
|
+
if (session.error !== null) throw session.error;
|
|
142
|
+
try {
|
|
143
|
+
return this.currentEntry(session.entry);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw session.error ?? orgRuntimeError(error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async fresh(entry: Entry, refresh?: { connectorId: string; revision: string }): Promise<DeliveredConnector> {
|
|
150
|
+
this.currentEntry(entry);
|
|
151
|
+
try {
|
|
152
|
+
await this.sync.syncFresh(refresh);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw orgRuntimeError(error);
|
|
155
|
+
}
|
|
156
|
+
return this.currentEntry(entry);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async scan(entry: Entry): Promise<void> {
|
|
160
|
+
const row = this.currentEntry(entry);
|
|
161
|
+
const signal = AbortSignal.any([entry.controller.signal, AbortSignal.timeout(45_000)]);
|
|
162
|
+
const groups = await discoverOrgGroups(row.url, {
|
|
163
|
+
fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
|
|
164
|
+
current: () => this.currentEntry(entry), signal, fetch: this.deps.fetch ?? fetch,
|
|
165
|
+
});
|
|
166
|
+
this.currentEntry(entry);
|
|
167
|
+
if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
|
|
168
|
+
for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
|
|
169
|
+
}
|
|
170
|
+
entry.groups = groups;
|
|
171
|
+
entry.error = null;
|
|
172
|
+
entry.nextScan = performance.now() + 60_000;
|
|
173
|
+
this.update();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private discover(entry: Entry): Promise<void> | null {
|
|
177
|
+
if (entry.scan !== null) return entry.scan;
|
|
178
|
+
if (performance.now() < entry.nextScan) {
|
|
179
|
+
if (entry.error !== null) throw entry.error;
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (this.scans >= 4) throw orgUnavailable();
|
|
183
|
+
this.scans += 1;
|
|
184
|
+
entry.scan = this.scan(entry).catch((error: unknown) => {
|
|
185
|
+
entry.error = orgRuntimeError(error);
|
|
186
|
+
entry.nextScan = performance.now() + 30_000;
|
|
187
|
+
log.debug({ status: entry.error.status }, 'organization connectors: tool discovery failed');
|
|
188
|
+
throw entry.error;
|
|
189
|
+
}).finally(() => {
|
|
190
|
+
this.scans -= 1;
|
|
191
|
+
entry.scan = null;
|
|
192
|
+
});
|
|
193
|
+
return entry.scan;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async target(session: Session, force: boolean): Promise<RelayTarget> {
|
|
197
|
+
this.currentSession(session);
|
|
198
|
+
if (force && session.revision === null) throw orgUnavailable();
|
|
199
|
+
const refresh = force && session.revision !== null ? { connectorId: session.entry.id, revision: session.revision } : undefined;
|
|
200
|
+
await this.fresh(session.entry, refresh);
|
|
201
|
+
this.currentSession(session);
|
|
202
|
+
const discovery = this.discover(session.entry);
|
|
203
|
+
if (discovery !== null) {
|
|
204
|
+
await discovery;
|
|
205
|
+
this.currentSession(session);
|
|
206
|
+
await this.fresh(session.entry);
|
|
207
|
+
}
|
|
208
|
+
return targetOf(this.currentSession(session));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
open(id: string): RelaySession {
|
|
212
|
+
if (!isOrgRelayId(id)) throw orgMissing();
|
|
213
|
+
const rows = this.update();
|
|
214
|
+
if (rows === null) throw orgUnavailable();
|
|
215
|
+
const rawId = id.slice(4);
|
|
216
|
+
const error = rowError(rows.find((row) => row.id === rawId));
|
|
217
|
+
if (error !== null) throw error;
|
|
218
|
+
const entry = this.entries.get(rawId);
|
|
219
|
+
if (entry === undefined) throw orgUnavailable();
|
|
220
|
+
const session: Session = { entry, controller: new AbortController(), revision: null, error: null };
|
|
221
|
+
this.sessions.add(session);
|
|
222
|
+
const blocker = requestConnectorBlocker();
|
|
223
|
+
return {
|
|
224
|
+
target: (force) => this.target(session, force),
|
|
225
|
+
current: () => {
|
|
226
|
+
const row = this.currentSession(session);
|
|
227
|
+
session.revision = row.revision;
|
|
228
|
+
return targetOf(row);
|
|
229
|
+
},
|
|
230
|
+
blocker: () => {
|
|
231
|
+
this.currentSession(session);
|
|
232
|
+
return blocker();
|
|
233
|
+
},
|
|
234
|
+
signal: session.controller.signal,
|
|
235
|
+
close: () => { this.abort(session, orgUnavailable()); },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
stop(): void {
|
|
240
|
+
if (this.stopped) return;
|
|
241
|
+
this.stopped = true;
|
|
242
|
+
this.unsubscribe();
|
|
243
|
+
clearInterval(this.timer);
|
|
244
|
+
this.update();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { METRO_API, type MetroApi } from './metro-api.js';
|
|
4
|
+
import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
|
|
5
|
+
import { OrgConnectorRuntime } from './org-runtime.js';
|
|
6
|
+
import { isOrgRelayId } from './relay-id.js';
|
|
7
|
+
import type { RelaySession } from './relay-session.js';
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 60_000;
|
|
10
|
+
let active: OrgConnectorSync | null = null;
|
|
11
|
+
let runtime: OrgConnectorRuntime | null = null;
|
|
12
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
13
|
+
|
|
14
|
+
const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
15
|
+
|
|
16
|
+
export function orgConnectorRelaySession(id: string): RelaySession | null {
|
|
17
|
+
if (!isOrgRelayId(id)) return null;
|
|
18
|
+
if (!liveEnabled() || runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
19
|
+
return runtime.open(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function invalidateOrgConnectorSync(): void {
|
|
23
|
+
active?.invalidate();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function stopOrgConnectorSync(): void {
|
|
27
|
+
if (timer !== null) clearInterval(timer);
|
|
28
|
+
timer = null;
|
|
29
|
+
runtime?.stop();
|
|
30
|
+
runtime = null;
|
|
31
|
+
active?.invalidate();
|
|
32
|
+
active = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function watchOrgConnectorSync(deps: Pick<OrgSyncDeps, 'owner' | 'key'> & { api?: MetroApi }): void {
|
|
36
|
+
stopOrgConnectorSync();
|
|
37
|
+
const enabled = (): boolean => process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
|
|
38
|
+
if (!enabled()) return;
|
|
39
|
+
const sync = new OrgConnectorSync({ ...deps, enabled, api: deps.api ?? METRO_API });
|
|
40
|
+
active = sync;
|
|
41
|
+
if (liveEnabled()) runtime = new OrgConnectorRuntime(sync, { enabled: liveEnabled });
|
|
42
|
+
const tick = (): void => {
|
|
43
|
+
sync.sync().catch((err: unknown) => {
|
|
44
|
+
log.warn({ status: err instanceof ApiError ? err.status : 503 }, 'organization connectors: memory sync failed; cached credentials were cleared');
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
tick();
|
|
48
|
+
timer = setInterval(tick, POLL_MS);
|
|
49
|
+
timer.unref();
|
|
50
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { AGENT_CLOCK_SKEW_MS } from '@metro-labs/http/agent-signature';
|
|
5
|
+
import { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, openConnectorDelivery, type DeliveryContext, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
6
|
+
import type { AgentKey } from './agent-key.js';
|
|
7
|
+
import { agentCall, type MetroApi } from './metro-api.js';
|
|
8
|
+
|
|
9
|
+
interface Identity {
|
|
10
|
+
key: AgentKey;
|
|
11
|
+
context: Omit<DeliveryContext, 'requestId'>;
|
|
12
|
+
token: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OrgSyncDeps {
|
|
16
|
+
enabled: () => boolean;
|
|
17
|
+
owner: () => string | null;
|
|
18
|
+
key: () => AgentKey | null;
|
|
19
|
+
api: MetroApi;
|
|
20
|
+
monotonic?: () => number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RefreshRequest {
|
|
24
|
+
connectorId: string;
|
|
25
|
+
revision: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const invalid = (): ApiError => new ApiError('Metro received an invalid or expired organization connector snapshot.', 503);
|
|
29
|
+
|
|
30
|
+
function lease(snapshot: DeliverySnapshot, started: number, elapsed: number, now: number): number {
|
|
31
|
+
const duration = snapshot.expiresAt - snapshot.issuedAt;
|
|
32
|
+
if (duration <= 0 || duration > CONNECTOR_DELIVERY_LEASE_MS || snapshot.issuedAt > now + AGENT_CLOCK_SKEW_MS || snapshot.issuedAt < started - AGENT_CLOCK_SKEW_MS) throw invalid();
|
|
33
|
+
const expiries = snapshot.connectors.flatMap((row) => row.state === 'ready' && row.expiresAt !== undefined ? [row.expiresAt] : []);
|
|
34
|
+
const remaining = Math.min(duration, ...expiries.map((at) => at - snapshot.issuedAt)) - elapsed;
|
|
35
|
+
if (remaining <= 0) throw invalid();
|
|
36
|
+
return remaining;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class OrgConnectorSync {
|
|
40
|
+
private snapshot: DeliverySnapshot | null = null;
|
|
41
|
+
private token: string | null = null;
|
|
42
|
+
private deadline = 0;
|
|
43
|
+
private wallDeadline = 0;
|
|
44
|
+
private generation = 0;
|
|
45
|
+
private running: Promise<void> | null = null;
|
|
46
|
+
private expiry: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
private readonly monotonic: () => number;
|
|
48
|
+
private readonly listeners = new Set<() => void>();
|
|
49
|
+
|
|
50
|
+
constructor(private readonly deps: OrgSyncDeps) {
|
|
51
|
+
this.monotonic = deps.monotonic ?? (() => performance.now());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onChange(listener: () => void): () => void {
|
|
55
|
+
this.listeners.add(listener);
|
|
56
|
+
return () => { this.listeners.delete(listener); };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
epoch(): number {
|
|
60
|
+
return this.generation;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private notify(): void {
|
|
64
|
+
for (const listener of [...this.listeners]) {
|
|
65
|
+
try {
|
|
66
|
+
listener();
|
|
67
|
+
} catch {
|
|
68
|
+
log.warn('organization connectors: a memory change listener failed');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private identity(): Identity | null {
|
|
74
|
+
if (!this.deps.enabled()) return null;
|
|
75
|
+
const owner = this.deps.owner();
|
|
76
|
+
const key = this.deps.key();
|
|
77
|
+
const enrollment = key?.enrollment;
|
|
78
|
+
if (owner === null || key === null || enrollment === null || enrollment === undefined || enrollment.organization !== owner) return null;
|
|
79
|
+
const context = { owner, agent: enrollment.server, keyId: key.keyId };
|
|
80
|
+
return { key, context, token: JSON.stringify(context) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private forget(): void {
|
|
84
|
+
this.snapshot = null;
|
|
85
|
+
this.deadline = 0;
|
|
86
|
+
this.wallDeadline = 0;
|
|
87
|
+
if (this.expiry !== null) clearTimeout(this.expiry);
|
|
88
|
+
this.expiry = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
invalidate(): void {
|
|
92
|
+
this.generation += 1;
|
|
93
|
+
this.token = null;
|
|
94
|
+
this.forget();
|
|
95
|
+
this.notify();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private validate(identity: Identity | null): void {
|
|
99
|
+
if (this.token === null) return;
|
|
100
|
+
if (identity === null || identity.token !== this.token || (this.snapshot !== null && (this.monotonic() >= this.deadline || this.deps.api.now() >= this.wallDeadline))) this.invalidate();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
read(): DeliverySnapshot | null {
|
|
104
|
+
if (this.token === null) return null;
|
|
105
|
+
try {
|
|
106
|
+
this.validate(this.identity());
|
|
107
|
+
} catch {
|
|
108
|
+
this.invalidate();
|
|
109
|
+
log.warn('organization connectors: memory could not be checked and was cleared');
|
|
110
|
+
}
|
|
111
|
+
return this.snapshot === null ? null : structuredClone(this.snapshot);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sync(refresh?: RefreshRequest): Promise<void> {
|
|
115
|
+
let identity: Identity | null;
|
|
116
|
+
try {
|
|
117
|
+
identity = this.identity();
|
|
118
|
+
this.validate(identity);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.invalidate();
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
if (identity === null) return;
|
|
124
|
+
if (this.running !== null) return refresh === undefined ? this.running : this.running.then(() => this.sync(refresh));
|
|
125
|
+
this.token = identity.token;
|
|
126
|
+
const generation = this.generation;
|
|
127
|
+
this.running = this.fetch(identity, refresh, generation).catch((err: unknown) => {
|
|
128
|
+
if (generation === this.generation) this.invalidate();
|
|
129
|
+
throw err;
|
|
130
|
+
}).finally(() => { this.running = null; });
|
|
131
|
+
return this.running;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async syncFresh(refresh?: RefreshRequest): Promise<void> {
|
|
135
|
+
if (this.running !== null) {
|
|
136
|
+
await this.running.catch(() => {
|
|
137
|
+
log.debug('organization connectors: a previous sync failed before a fresh check');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return this.sync(refresh);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async fetch(identity: Identity, refresh: RefreshRequest | undefined, generation: number): Promise<void> {
|
|
144
|
+
const requestId = randomBytes(32).toString('base64url');
|
|
145
|
+
const context = { ...identity.context, requestId };
|
|
146
|
+
const started = this.deps.api.now();
|
|
147
|
+
const clock = this.monotonic();
|
|
148
|
+
const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000 });
|
|
149
|
+
this.validate(this.identity());
|
|
150
|
+
if (generation !== this.generation || this.token !== identity.token) throw invalid();
|
|
151
|
+
const snapshot = openConnectorDelivery(identity.key.sealing, context, response.sealed);
|
|
152
|
+
const remaining = lease(snapshot, started, this.monotonic() - clock, this.deps.api.now());
|
|
153
|
+
this.forget();
|
|
154
|
+
this.snapshot = snapshot;
|
|
155
|
+
this.deadline = this.monotonic() + remaining;
|
|
156
|
+
this.wallDeadline = this.deps.api.now() + remaining;
|
|
157
|
+
this.expiry = setTimeout(() => { this.invalidate(); }, remaining);
|
|
158
|
+
this.expiry.unref();
|
|
159
|
+
this.notify();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -5,6 +5,7 @@ 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';
|
|
8
9
|
|
|
9
10
|
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
10
11
|
const FILE = '.mcp.json';
|
|
@@ -35,12 +36,21 @@ export function serverKey(name: string): string {
|
|
|
35
36
|
export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
|
|
36
37
|
const taken = new Map<string, number>();
|
|
37
38
|
const keys = new Map<string, string>();
|
|
38
|
-
for (const row of rows) {
|
|
39
|
+
for (const row of rows.filter((row) => !isOrgRelayId(row.id))) {
|
|
39
40
|
const wanted = serverKey(row.name);
|
|
40
41
|
const seen = taken.get(wanted) ?? 0;
|
|
41
42
|
taken.set(wanted, seen + 1);
|
|
42
43
|
keys.set(row.id, seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`);
|
|
43
44
|
}
|
|
45
|
+
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++)}`;
|
|
51
|
+
reserved.add(key);
|
|
52
|
+
keys.set(row.id, key);
|
|
53
|
+
}
|
|
44
54
|
return keys;
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { registerConnectors, type GateRow } from './gates.js';
|
|
2
|
+
import { syncPluginServers } from './plugin-sync.js';
|
|
3
|
+
import { isOrgRelayId } from './relay-id.js';
|
|
4
|
+
|
|
5
|
+
let local: GateRow[] = [];
|
|
6
|
+
let organization: GateRow[] = [];
|
|
7
|
+
|
|
8
|
+
function metadata(rows: readonly GateRow[]): GateRow[] {
|
|
9
|
+
return rows.map((row) => structuredClone({
|
|
10
|
+
id: row.id,
|
|
11
|
+
name: row.name,
|
|
12
|
+
config: {
|
|
13
|
+
...(row.config.policy === undefined ? {} : { policy: row.config.policy }),
|
|
14
|
+
...(row.config.toolGroups === undefined ? {} : { toolGroups: row.config.toolGroups }),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function publishConnectors(): void {
|
|
20
|
+
const rows = [...local, ...organization];
|
|
21
|
+
registerConnectors(rows);
|
|
22
|
+
syncPluginServers(rows);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function publishLocalConnectors(rows: readonly GateRow[]): void {
|
|
26
|
+
local = metadata(rows.filter((row) => !isOrgRelayId(row.id)));
|
|
27
|
+
publishConnectors();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function publishOrgConnectors(rows: readonly GateRow[]): void {
|
|
31
|
+
organization = metadata(rows.filter((row) => isOrgRelayId(row.id)));
|
|
32
|
+
publishConnectors();
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BlockedReason } from './relay-policy.js';
|
|
2
|
+
import type { RelayTarget } from './relay-target.js';
|
|
3
|
+
|
|
4
|
+
export interface RelaySession {
|
|
5
|
+
target: (force: boolean) => Promise<RelayTarget>;
|
|
6
|
+
current: () => RelayTarget;
|
|
7
|
+
blocker: () => BlockedReason;
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
close: () => void;
|
|
10
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { whyUnreachable } from '
|
|
2
|
+
import { whyUnreachable } from '@metro-labs/http/connector-reach';
|
|
3
3
|
import { noteHealth } from './health.js';
|
|
4
|
-
import type { RelayTarget } from './
|
|
4
|
+
import type { RelayTarget } from './relay-target.js';
|
|
5
|
+
import type { RelaySession } from './relay-session.js';
|
|
6
|
+
import { isOrgRelayId } from './relay-id.js';
|
|
7
|
+
import { exchangeOrg } from './org-relay.js';
|
|
5
8
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
9
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
10
|
import { answerBlocked, mergeBlocked, screenCalls, type BlockedReason } from './relay-policy.js';
|
|
@@ -11,9 +14,10 @@ export interface RelayApiDeps {
|
|
|
11
14
|
identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
|
|
12
15
|
signedOut: (connectorId: string) => void;
|
|
13
16
|
blocked?: BlockedReason;
|
|
17
|
+
open?: (connectorId: string) => RelaySession | null;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
20
|
+
const ID_PATH_RE = /^\/relay\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
17
21
|
const METHODS = new Set(['POST', 'GET', 'DELETE']);
|
|
18
22
|
const RELAY_BODY_MAX = 8 * 1024 * 1024;
|
|
19
23
|
const FIRST_BYTE_MS = 120_000;
|
|
@@ -99,9 +103,10 @@ async function forward(
|
|
|
99
103
|
target: { url: string; headers: Record<string, string> },
|
|
100
104
|
body: Uint8Array<ArrayBuffer> | null,
|
|
101
105
|
signal: AbortSignal,
|
|
106
|
+
sensitive = false,
|
|
102
107
|
): Promise<Response> {
|
|
103
108
|
const firstByte = setTimeout(() => {
|
|
104
|
-
log.warn({ url: target.url }, 'relay: upstream sent no response in time');
|
|
109
|
+
log.warn(sensitive ? {} : { url: target.url }, 'relay: upstream sent no response in time');
|
|
105
110
|
}, FIRST_BYTE_MS);
|
|
106
111
|
firstByte.unref?.();
|
|
107
112
|
try {
|
|
@@ -284,6 +289,16 @@ async function deliver(
|
|
|
284
289
|
await pipe(res, out.upstream, answers);
|
|
285
290
|
}
|
|
286
291
|
|
|
292
|
+
async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
|
|
293
|
+
const session = deps.open?.(id);
|
|
294
|
+
if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
|
|
295
|
+
try {
|
|
296
|
+
const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => forward(req, target, body, guard, true));
|
|
297
|
+
if (out.kind === 'blocked') answerBlocked(res, out.screened);
|
|
298
|
+
else await deliver(res, id, deps, out, out.answers);
|
|
299
|
+
} finally { session.close(); }
|
|
300
|
+
}
|
|
301
|
+
|
|
287
302
|
async function relayOnce(
|
|
288
303
|
req: IncomingMessage,
|
|
289
304
|
res: ServerResponse,
|
|
@@ -292,6 +307,7 @@ async function relayOnce(
|
|
|
292
307
|
signal: AbortSignal,
|
|
293
308
|
): Promise<void> {
|
|
294
309
|
const read = req.method === 'POST' ? await readCapped(req) : null;
|
|
310
|
+
if (isOrgRelayId(connectorId)) return relayOrg(req, res, connectorId, deps, read, signal);
|
|
295
311
|
const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
|
|
296
312
|
if (screened?.forward === null) {
|
|
297
313
|
answerBlocked(res, screened);
|
|
@@ -316,6 +332,11 @@ function dispatch(
|
|
|
316
332
|
.catch((err: unknown) => {
|
|
317
333
|
if (err instanceof ApiError) {
|
|
318
334
|
answer(res, err.status, { error: err.message });
|
|
335
|
+
if (!res.writableEnded) res.end();
|
|
336
|
+
} else if (isOrgRelayId(connectorId)) {
|
|
337
|
+
log.warn({ connector: connectorId }, 'organization relay: request failed');
|
|
338
|
+
answer(res, 503, { error: 'The organization connector is unavailable. Try again.' });
|
|
339
|
+
if (!res.writableEnded) res.end();
|
|
319
340
|
} else {
|
|
320
341
|
log.warn({ err: errMsg(err), connector: connectorId }, 'relay: failed');
|
|
321
342
|
noteHealth(connectorId, false, `metro could not reach the connector: ${whyUnreachable(err)}`);
|