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