@stage-labs/metro 0.1.0-beta.289 → 0.1.0-beta.290
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/connectors/gates.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { closeSync, fsyncSync, openSync } from 'node:fs';
|
|
2
3
|
import { publishLocalConnectors } from './publication.js';
|
|
4
|
+
import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
5
|
+
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
6
|
import type { RelayTarget } from './relay-target.js';
|
|
4
7
|
export type { RelayTarget } from './relay-target.js';
|
|
5
8
|
import type { ToolPolicy } from '../policy/policy.js';
|
|
@@ -126,15 +129,16 @@ function rowOrThrow(id: string, dir: string): LocalConnectorRow {
|
|
|
126
129
|
return row;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
function replace(dir: string, row: LocalConnectorRow): Connector {
|
|
132
|
+
function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
|
|
133
|
+
if (!refresh) assertLocalMigrationEdit(row.id, row.name, dir);
|
|
130
134
|
writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
|
|
131
135
|
return connectorFromRow(row);
|
|
132
136
|
}
|
|
133
137
|
|
|
134
|
-
async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
|
|
138
|
+
async function oauthCapable(url: URL, auth: ConnectorAuth, send: FetchLike): Promise<boolean> {
|
|
135
139
|
if (auth.kind === 'oauth') return true;
|
|
136
140
|
if (auth.kind === 'header') return false;
|
|
137
|
-
return advertisesOAuth(url);
|
|
141
|
+
return advertisesOAuth(url, send);
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
export function localImportConnectors(
|
|
@@ -153,6 +157,7 @@ export function localImportConnectors(
|
|
|
153
157
|
url: row.url,
|
|
154
158
|
config: readConfig(row.config),
|
|
155
159
|
}));
|
|
160
|
+
for (const row of imported) assertLocalMigrationEdit(row.id, row.name, dir);
|
|
156
161
|
const ids = new Set(imported.map((r) => r.id));
|
|
157
162
|
const names = new Set(imported.map((r) => r.name));
|
|
158
163
|
writeRows(dir, [...current.filter((r) => !ids.has(r.id) && !names.has(r.name)), ...imported]);
|
|
@@ -173,6 +178,7 @@ function assertNameFree(name: string, exceptId: string | null, dir: string): voi
|
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
181
|
+
assertLocalMigrationEdit('', name, dir);
|
|
176
182
|
assertNameFree(name, null, dir);
|
|
177
183
|
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
|
|
178
184
|
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
@@ -184,14 +190,10 @@ export async function localCreateConnector(input: ConnectorInput, dir = agentsDi
|
|
|
184
190
|
const url = parseConnectorUrl(input.url);
|
|
185
191
|
const auth = connectorAuth(input.header, input.value);
|
|
186
192
|
const client = connectorClient(input.clientId, input.clientSecret);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
auth,
|
|
190
|
-
|
|
191
|
-
verified,
|
|
192
|
-
oauth: await oauthCapable(url, auth),
|
|
193
|
-
client,
|
|
194
|
-
});
|
|
193
|
+
return withLocalMigrationOperation('', dir, async (operation) => {
|
|
194
|
+
const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
|
|
195
|
+
return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
|
|
196
|
+
}, true, name);
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
|
|
@@ -208,9 +210,11 @@ export async function localReconnectConnector(
|
|
|
208
210
|
auth: OAuthAuth,
|
|
209
211
|
dir = agentsDir(),
|
|
210
212
|
): Promise<Connector> {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
return withLocalMigrationOperation(id, dir, async (operation) => {
|
|
214
|
+
const row = rowOrThrow(id, dir);
|
|
215
|
+
const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth, operation.fetch));
|
|
216
|
+
return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
|
|
217
|
+
}, true);
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
const signedOut = (row: LocalConnectorRow): LocalConnectorRow => ({ ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } });
|
|
@@ -234,30 +238,34 @@ async function freshAuth(row: LocalConnectorRow, dir: string): Promise<Connector
|
|
|
234
238
|
return refreshOnce(row, auth, dir);
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
function updateRow(dir: string, id: string, config: Partial<LocalConnectorRow['config']
|
|
241
|
+
function updateRow(dir: string, id: string, config: Partial<LocalConnectorRow['config']>, refresh = false): void {
|
|
242
|
+
if (!refresh && migrationSource(id, dir) !== null) return;
|
|
238
243
|
const now = readLocalConnectors(dir).find((r) => r.id === id);
|
|
239
|
-
if (now !== undefined) replace(dir, { ...now, config: { ...now.config, ...config } });
|
|
244
|
+
if (now !== undefined) replace(dir, { ...now, config: { ...now.config, ...config } }, refresh);
|
|
240
245
|
}
|
|
241
246
|
|
|
242
247
|
export async function localVerifyConnector(id: string, dir = agentsDir()): Promise<ConnectorCheck> {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
return withLocalMigrationOperation(id, dir, async (operation) => {
|
|
249
|
+
const row = rowOrThrow(id, dir);
|
|
250
|
+
try {
|
|
251
|
+
const url = parseConnectorUrl(row.url);
|
|
252
|
+
const auth = await freshAuth(row, dir);
|
|
253
|
+
const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
|
|
254
|
+
const oauth = row.config.oauth || (await oauthCapable(url, auth, operation.fetch));
|
|
255
|
+
updateRow(dir, row.id, { auth, verified, oauth });
|
|
256
|
+
return { id: row.id, name: row.name, ok: true, verified };
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (!(err instanceof ConnectorVerifyError)) throw err;
|
|
259
|
+
return { id: row.id, name: row.name, ok: false, reason: err.message };
|
|
260
|
+
}
|
|
261
|
+
});
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
const groupsOf = (tools: RemoteTool[]): Record<string, ToolGroup> =>
|
|
258
265
|
Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write'] as const));
|
|
259
266
|
|
|
260
267
|
function keepGroups(dir: string, id: string, tools: RemoteTool[]): void {
|
|
268
|
+
if (migrationSource(id, dir) !== null) return;
|
|
261
269
|
const now = readLocalConnectors(dir).find((r) => r.id === id);
|
|
262
270
|
const groups = groupsOf(tools);
|
|
263
271
|
if (now === undefined || JSON.stringify(now.config.toolGroups ?? {}) === JSON.stringify(groups)) return;
|
|
@@ -267,11 +275,12 @@ function keepGroups(dir: string, id: string, tools: RemoteTool[]): void {
|
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
export async function localConnectorTools(id: string, dir = agentsDir()): Promise<RemoteTool[]> {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
278
|
+
return withLocalMigrationOperation(id, dir, async (operation) => {
|
|
279
|
+
const row = rowOrThrow(id, dir);
|
|
280
|
+
const tools = await listRemoteTools(parseConnectorUrl(row.url), await freshAuth(row, dir), operation.fetch);
|
|
281
|
+
keepGroups(dir, id, tools);
|
|
282
|
+
return tools;
|
|
283
|
+
});
|
|
275
284
|
}
|
|
276
285
|
|
|
277
286
|
async function refreshGroups(id: string, dir: string): Promise<void> {
|
|
@@ -294,7 +303,7 @@ export async function localSetConnectorPolicy(id: string, policy: ToolPolicy, di
|
|
|
294
303
|
export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
|
|
295
304
|
const rows = readLocalConnectors(dir);
|
|
296
305
|
publishLocalConnectors(rows);
|
|
297
|
-
for (const row of rows) if (row.config.policy !== undefined) await refreshGroups(row.id, dir);
|
|
306
|
+
for (const row of rows) if (row.config.policy !== undefined && localMigrationEnabled(row.id, dir)) await refreshGroups(row.id, dir);
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
@@ -306,6 +315,7 @@ export async function localRenameConnector(id: string, raw: string, dir = agents
|
|
|
306
315
|
|
|
307
316
|
export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
|
|
308
317
|
const row = rowOrThrow(id, dir);
|
|
318
|
+
assertLocalMigrationEdit(id, row.name, dir);
|
|
309
319
|
writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
|
|
310
320
|
return Promise.resolve({ id: row.id, name: row.name });
|
|
311
321
|
}
|
|
@@ -313,17 +323,15 @@ export async function localDeleteConnector(id: string, dir = agentsDir()): Promi
|
|
|
313
323
|
const inflight = new Map<string, Promise<OAuthAuth>>();
|
|
314
324
|
|
|
315
325
|
function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
|
|
316
|
-
const
|
|
326
|
+
const key = JSON.stringify([dir, row.id]);
|
|
327
|
+
const running = inflight.get(key);
|
|
317
328
|
if (running !== undefined) return running;
|
|
318
|
-
const job =
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
inflight.delete(row.id);
|
|
325
|
-
});
|
|
326
|
-
inflight.set(row.id, job);
|
|
329
|
+
const job = withLocalMigrationOperation(row.id, dir, async (operation) => {
|
|
330
|
+
const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), operation.fetch);
|
|
331
|
+
updateRow(dir, row.id, { auth: fresh }, true);
|
|
332
|
+
return fresh;
|
|
333
|
+
}).finally(() => { inflight.delete(key); });
|
|
334
|
+
inflight.set(key, job);
|
|
327
335
|
return job;
|
|
328
336
|
}
|
|
329
337
|
|
|
@@ -366,10 +374,23 @@ export async function localRelayTarget(
|
|
|
366
374
|
force: boolean,
|
|
367
375
|
dir = agentsDir(),
|
|
368
376
|
): Promise<RelayTarget> {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
return withLocalMigrationOperation(connectorId, dir, async (operation) => {
|
|
378
|
+
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
379
|
+
if (row === undefined) return { kind: 'missing' };
|
|
380
|
+
parseConnectorUrl(row.url);
|
|
381
|
+
const auth = row.config.auth;
|
|
382
|
+
const target = auth.kind === 'oauth' ? await oauthTarget(row, auth, force, dir) : fixedTarget(row.url, auth, force);
|
|
383
|
+
operation.check();
|
|
384
|
+
return target;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir()): void {
|
|
389
|
+
if (migrationSource(id, dir)?.phase !== 'rollback' || !localMigrationIdle(id, dir)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
|
|
390
|
+
const row = rowOrThrow(id, dir);
|
|
391
|
+
if (signOutOAuth && row.config.auth.kind === 'oauth') replace(dir, signedOut(row), true);
|
|
392
|
+
if (signOutOAuth) for (const path of [filePath(dir), dir]) {
|
|
393
|
+
const fd = openSync(path, 'r');
|
|
394
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
395
|
+
}
|
|
375
396
|
}
|
|
@@ -46,7 +46,8 @@ async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, st
|
|
|
46
46
|
try {
|
|
47
47
|
const send = fetchImpl ?? fetch;
|
|
48
48
|
const res = await send(url.toString(), { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
|
|
49
|
-
await res.body?.
|
|
49
|
+
if (res.ok) await res.body?.pipeTo(new WritableStream<Uint8Array>());
|
|
50
|
+
else await res.body?.cancel();
|
|
50
51
|
} catch (err) {
|
|
51
52
|
log.debug(fetchImpl === undefined ? { host: url.hostname, err: errMsg(err) } : {}, 'connector-tools: the remote declined to end the session');
|
|
52
53
|
}
|
|
@@ -16,6 +16,8 @@ import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/b
|
|
|
16
16
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
17
17
|
import type { ConnectorApiDeps } from '../connectors/api.js';
|
|
18
18
|
import { connectorMigrationPreview } from '../connectors/migration-preview.js';
|
|
19
|
+
import { ConnectorMigrations } from '../connectors/migration-control.js';
|
|
20
|
+
import { migrationExportableRows } from '../connectors/migration-guard.js';
|
|
19
21
|
import { ConnectorTransfers } from '../connectors/transfer-api.js';
|
|
20
22
|
import { copyConnectorRows } from '../connectors/transfer-store.js';
|
|
21
23
|
import { authenticate } from '../mcp/request-identity.js';
|
|
@@ -162,7 +164,7 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
|
162
164
|
const bundle: AgentBundle = {
|
|
163
165
|
version: 1,
|
|
164
166
|
agent: { id: file.id, name: file.name ?? '', stations: file.stations.filter((a) => a.station !== 'gmail' || a.config.managed !== true) },
|
|
165
|
-
connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
|
|
167
|
+
connectors: migrationExportableRows(readLocalConnectors()).map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
|
|
166
168
|
};
|
|
167
169
|
return Promise.resolve(bundle);
|
|
168
170
|
},
|
|
@@ -206,7 +208,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
206
208
|
bundleApi: bundleApi(deps),
|
|
207
209
|
connectorApi,
|
|
208
210
|
connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
|
|
209
|
-
|
|
211
|
+
connectorMigrationApi: new ConnectorMigrations(),
|
|
212
|
+
connectorTransfers: new ConnectorTransfers({ read: () => migrationExportableRows(readLocalConnectors()), copy: copyConnectorRows }),
|
|
210
213
|
enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
|
|
211
214
|
relayApi,
|
|
212
215
|
claudeApi: { liveEvents: deps.liveEvents },
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
10
10
|
import type { ConnectorTransfers } from '../connectors/transfer-api.js';
|
|
11
11
|
import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
|
|
12
|
+
import { handleMigrationRequest, type MigrationApiDeps } from '../connectors/migration-api.js';
|
|
12
13
|
import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
|
|
13
14
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
14
15
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
@@ -41,6 +42,7 @@ export interface SessionApis {
|
|
|
41
42
|
connectorTransfers?: ConnectorTransfers;
|
|
42
43
|
enrollApi?: EnrollApiDeps;
|
|
43
44
|
connectorSyncApi?: ConnectorSyncApiDeps;
|
|
45
|
+
connectorMigrationApi?: MigrationApiDeps;
|
|
44
46
|
relayApi?: RelayApiDeps;
|
|
45
47
|
mode?: () => ModeInfo;
|
|
46
48
|
}
|
|
@@ -60,6 +62,7 @@ export function handleSessionApis(
|
|
|
60
62
|
...when(apis.connectorTransfers, (d) => d.handle(req, res)),
|
|
61
63
|
...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
|
|
62
64
|
...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
|
|
65
|
+
...when(apis.connectorMigrationApi, (d) => handleMigrationRequest(req, res, d)),
|
|
63
66
|
...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
|
|
64
67
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
65
68
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"./connector-discovery": "./src/connector-discovery.ts",
|
|
20
20
|
"./connector-oauth": "./src/connector-oauth.ts",
|
|
21
21
|
"./connector-input": "./src/connector-input.ts",
|
|
22
|
-
"./connector-delivery": "./src/connector-delivery.ts"
|
|
22
|
+
"./connector-delivery": "./src/connector-delivery.ts",
|
|
23
|
+
"./connector-migration": "./src/connector-migration.ts"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc",
|
|
@@ -1,54 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { KeyObject } from 'node:crypto';
|
|
2
|
+
import { CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliveryContextBytes, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot } from './connector-delivery-validation.js';
|
|
3
|
+
import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
|
|
4
4
|
|
|
5
5
|
export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
|
|
6
6
|
export { combineConnectorPolicies, connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
|
|
7
|
-
|
|
8
|
-
export interface SealedDelivery {
|
|
9
|
-
version: 1;
|
|
10
|
-
ephemeralKey: string;
|
|
11
|
-
iv: string;
|
|
12
|
-
tag: string;
|
|
13
|
-
ciphertext: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function cipherKey(privateKey: KeyObject, publicKey: KeyObject, context: Buffer, ephemeral: string, recipient: string): Buffer {
|
|
17
|
-
const shared = diffieHellman({ privateKey, publicKey });
|
|
18
|
-
if (!shared.some((byte) => byte !== 0)) throw new Error('Invalid connector delivery key.');
|
|
19
|
-
return Buffer.from(hkdfSync('sha256', shared, Buffer.concat([deliveryBytes(ephemeral, 32), deliveryBytes(recipient, 32)]), context, 32));
|
|
20
|
-
}
|
|
7
|
+
export type SealedDelivery = SealedTransfer;
|
|
21
8
|
|
|
22
9
|
export function sealConnectorDelivery(publicKey: string, context: DeliveryContext, snapshot: DeliverySnapshot): SealedDelivery {
|
|
23
|
-
const
|
|
24
|
-
const recipient = sealingKeyOf(publicKey);
|
|
25
|
-
if (recipient === null) throw new Error('Invalid connector delivery key.');
|
|
26
|
-
const bytes = Buffer.from(JSON.stringify(deliverySnapshotOf(snapshot)));
|
|
27
|
-
const pair = generateKeyPairSync('x25519');
|
|
28
|
-
const ephemeralKey = rawPublicKey(pair.publicKey);
|
|
29
|
-
const iv = randomBytes(12);
|
|
30
|
-
const cipher = createCipheriv('aes-256-gcm', cipherKey(pair.privateKey, recipient, aad, ephemeralKey, publicKey), iv);
|
|
31
|
-
cipher.setAAD(aad);
|
|
32
|
-
const ciphertext = Buffer.concat([cipher.update(bytes), cipher.final()]).toString('base64url');
|
|
33
|
-
const sealed: SealedDelivery = { version: 1, ephemeralKey, iv: iv.toString('base64url'), tag: cipher.getAuthTag().toString('base64url'), ciphertext };
|
|
10
|
+
const sealed = sealTransfer(publicKey, deliveryContextBytes(context), Buffer.from(JSON.stringify(deliverySnapshotOf(snapshot))));
|
|
34
11
|
if (Buffer.byteLength(JSON.stringify({ sealed })) > CONNECTOR_DELIVERY_MAX_BYTES) throw new Error('Connector delivery is too large.');
|
|
35
12
|
return sealed;
|
|
36
13
|
}
|
|
37
14
|
|
|
38
15
|
export function openConnectorDelivery(privateKey: KeyObject, context: DeliveryContext, envelope: unknown): DeliverySnapshot {
|
|
39
|
-
const aad = deliveryContextBytes(context);
|
|
40
|
-
if (privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'x25519') throw new Error('Invalid connector delivery key.');
|
|
41
16
|
if (Buffer.byteLength(JSON.stringify({ sealed: envelope })) > CONNECTOR_DELIVERY_MAX_BYTES) throw new Error('Connector delivery is too large.');
|
|
42
|
-
const
|
|
43
|
-
if (value.version !== 1 || typeof value.ephemeralKey !== 'string') throw new Error('Invalid connector delivery envelope.');
|
|
44
|
-
const ephemeral = sealingKeyOf(value.ephemeralKey);
|
|
45
|
-
if (ephemeral === null) throw new Error('Invalid connector delivery key.');
|
|
46
|
-
const ciphertext = deliveryBytes(value.ciphertext);
|
|
47
|
-
if (ciphertext.length > DELIVERY_PLAINTEXT_MAX) throw new Error('Connector delivery is too large.');
|
|
48
|
-
const key = cipherKey(privateKey, ephemeral, aad, value.ephemeralKey, rawPublicKey(createPublicKey(privateKey)));
|
|
49
|
-
const decipher = createDecipheriv('aes-256-gcm', key, deliveryBytes(value.iv, 12));
|
|
50
|
-
decipher.setAAD(aad);
|
|
51
|
-
decipher.setAuthTag(deliveryBytes(value.tag, 16));
|
|
52
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
17
|
+
const plaintext = openTransfer(privateKey, deliveryContextBytes(context), envelope, DELIVERY_PLAINTEXT_MAX);
|
|
53
18
|
return deliverySnapshotOf(JSON.parse(plaintext.toString('utf8')) as unknown);
|
|
54
19
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { KeyObject } from 'node:crypto';
|
|
2
|
+
import { parseId } from '@metro-labs/core/ids';
|
|
3
|
+
import { sealingKeyOf } from './agent-signature.js';
|
|
4
|
+
import { deliveryBytes, deliveryRecord } from './connector-delivery-validation.js';
|
|
5
|
+
import { connectorAuth, connectorClient, connectorName } from './connector-input.js';
|
|
6
|
+
import type { OAuthClient } from './connector-oauth.js';
|
|
7
|
+
import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
|
|
8
|
+
import { parseConnectorUrl } from './connector-url.js';
|
|
9
|
+
import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
|
|
10
|
+
import { isOrganizationId } from './workos-token.js';
|
|
11
|
+
|
|
12
|
+
export const CONNECTOR_MIGRATION_MAX_BYTES = 96 * 1024;
|
|
13
|
+
const PAYLOAD_MAX = 60 * 1024;
|
|
14
|
+
|
|
15
|
+
export interface MigrationContext {
|
|
16
|
+
owner: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
keyId: string;
|
|
19
|
+
operationId: string;
|
|
20
|
+
sourceId: string;
|
|
21
|
+
destinationId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MigrationPayload {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
auth: { kind: 'none' } | { kind: 'header'; name: string; value: string } | { kind: 'oauth' };
|
|
28
|
+
client: OAuthClient | null;
|
|
29
|
+
policy: ConnectorPolicy;
|
|
30
|
+
sourceStamp: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MigrationView {
|
|
34
|
+
operationId: string;
|
|
35
|
+
sourceId: string;
|
|
36
|
+
destinationId: string;
|
|
37
|
+
phase: 'preparing' | 'prepared' | 'active' | 'rolled_back';
|
|
38
|
+
readiness: 'pending' | 'ready' | 'signin' | 'changed' | 'unavailable';
|
|
39
|
+
name: string | null;
|
|
40
|
+
auth: 'none' | 'header' | 'oauth' | null;
|
|
41
|
+
policy: ConnectorPolicy | null;
|
|
42
|
+
sourceStamp: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MigrationResponse {
|
|
46
|
+
migration: MigrationView;
|
|
47
|
+
receiving?: { publicKey: string; context: MigrationContext };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type MigrationRequest = { action: 'begin' | 'status' | 'activate' | 'rollback'; operationId: string; sourceId: string } | { action: 'prepare'; operationId: string; sourceId: string; sealed: unknown };
|
|
51
|
+
|
|
52
|
+
const invalid = (): Error => new Error('Invalid connector migration.');
|
|
53
|
+
const idOf = (value: unknown): string => {
|
|
54
|
+
const id = parseId(value);
|
|
55
|
+
if (id === null) throw invalid();
|
|
56
|
+
return id;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function migrationRequestOf(raw: unknown): MigrationRequest {
|
|
60
|
+
const value = deliveryRecord(raw, ['action', 'operationId', 'sourceId', 'sealed']);
|
|
61
|
+
const operationId = idOf(value.operationId);
|
|
62
|
+
const sourceId = idOf(value.sourceId);
|
|
63
|
+
if (value.action === 'prepare' && Object.hasOwn(value, 'sealed')) return { action: 'prepare', operationId, sourceId, sealed: value.sealed };
|
|
64
|
+
if (Object.hasOwn(value, 'sealed')) throw invalid();
|
|
65
|
+
if (value.action !== 'begin' && value.action !== 'status' && value.action !== 'activate' && value.action !== 'rollback') throw invalid();
|
|
66
|
+
return { action: value.action, operationId, sourceId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contextOf(raw: unknown): MigrationContext {
|
|
70
|
+
const value = deliveryRecord(raw, ['owner', 'agent', 'keyId', 'operationId', 'sourceId', 'destinationId']);
|
|
71
|
+
if (typeof value.owner !== 'string' || !isOrganizationId(value.owner) || typeof value.keyId !== 'string') throw invalid();
|
|
72
|
+
deliveryBytes(value.keyId, 32);
|
|
73
|
+
return { owner: value.owner, agent: idOf(value.agent), keyId: value.keyId, operationId: idOf(value.operationId), sourceId: idOf(value.sourceId), destinationId: idOf(value.destinationId) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function contextBytes(context: MigrationContext): Buffer {
|
|
77
|
+
const value = contextOf(context);
|
|
78
|
+
return Buffer.from(JSON.stringify(['metro-connector-migration/v1', value.owner, value.agent, value.keyId, value.operationId, value.sourceId, value.destinationId]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function authOf(raw: unknown): MigrationPayload['auth'] {
|
|
82
|
+
const value = deliveryRecord(raw, ['kind', 'name', 'value']);
|
|
83
|
+
if (value.kind === 'header') {
|
|
84
|
+
const auth = connectorAuth(value.name, value.value);
|
|
85
|
+
if (auth.kind !== 'header' || auth.name !== value.name || auth.value !== value.value || ['__proto__', 'constructor', 'prototype'].includes(auth.name)) throw invalid();
|
|
86
|
+
return auth;
|
|
87
|
+
}
|
|
88
|
+
if ((value.kind !== 'none' && value.kind !== 'oauth') || Object.hasOwn(value, 'name') || Object.hasOwn(value, 'value')) throw invalid();
|
|
89
|
+
return { kind: value.kind };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clientOf(raw: unknown): OAuthClient | null {
|
|
93
|
+
if (raw === null) return null;
|
|
94
|
+
const value = deliveryRecord(raw, ['clientId', 'clientSecret']);
|
|
95
|
+
const client = connectorClient(value.clientId, value.clientSecret);
|
|
96
|
+
if (client === null || client.clientId !== value.clientId || client.clientSecret !== value.clientSecret) throw invalid();
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canonicalResource(raw: unknown, url: URL): boolean {
|
|
101
|
+
return raw === `${url.origin}${url.pathname}` || (url.pathname === '/' && raw === url.origin);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function migrationClient(auth: MigrationPayload['auth'], url: URL, input: Record<string, unknown>): OAuthClient | null {
|
|
105
|
+
const client = clientOf(input.client);
|
|
106
|
+
if (auth.kind !== 'oauth' && client !== null) throw invalid();
|
|
107
|
+
if (auth.kind === 'oauth' && (url.search !== '' || !['/', '/mcp'].includes(url.pathname) || !canonicalResource(input.url, url))) throw invalid();
|
|
108
|
+
return client;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function migrationPayloadOf(raw: unknown): MigrationPayload {
|
|
112
|
+
const value = deliveryRecord(raw, ['name', 'url', 'auth', 'client', 'policy', 'sourceStamp']);
|
|
113
|
+
if (typeof value.name !== 'string' || connectorName(value.name) !== value.name || typeof value.url !== 'string' || typeof value.sourceStamp !== 'string') throw invalid();
|
|
114
|
+
const url = parseConnectorUrl(value.url);
|
|
115
|
+
if (url.protocol !== 'https:' || value.url.length > 2048) throw invalid();
|
|
116
|
+
deliveryBytes(value.sourceStamp, 32);
|
|
117
|
+
const auth = authOf(value.auth);
|
|
118
|
+
const client = migrationClient(auth, url, value);
|
|
119
|
+
const payload = { name: value.name, url: value.url, auth, client, policy: connectorPolicyOf(value.policy), sourceStamp: value.sourceStamp };
|
|
120
|
+
if (Buffer.byteLength(JSON.stringify(payload)) > PAYLOAD_MAX) throw invalid();
|
|
121
|
+
return payload;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function phaseOf(value: unknown): MigrationView['phase'] {
|
|
125
|
+
if (value !== 'preparing' && value !== 'prepared' && value !== 'active' && value !== 'rolled_back') throw invalid();
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readinessOf(value: unknown): MigrationView['readiness'] {
|
|
130
|
+
if (value !== 'pending' && value !== 'ready' && value !== 'signin' && value !== 'changed' && value !== 'unavailable') throw invalid();
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function authKindOf(value: unknown): MigrationView['auth'] {
|
|
135
|
+
if (value !== null && value !== 'none' && value !== 'header' && value !== 'oauth') throw invalid();
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function nullableName(value: unknown): string | null {
|
|
140
|
+
if (value === null) return null;
|
|
141
|
+
if (typeof value !== 'string' || connectorName(value) !== value) throw invalid();
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nullableStamp(value: unknown): string | null {
|
|
146
|
+
if (value === null) return null;
|
|
147
|
+
if (typeof value !== 'string') throw invalid();
|
|
148
|
+
deliveryBytes(value, 32);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function viewOf(raw: unknown): MigrationView {
|
|
153
|
+
const view = deliveryRecord(raw, ['operationId', 'sourceId', 'destinationId', 'phase', 'readiness', 'name', 'auth', 'policy', 'sourceStamp']);
|
|
154
|
+
return { operationId: idOf(view.operationId), sourceId: idOf(view.sourceId), destinationId: idOf(view.destinationId), phase: phaseOf(view.phase), readiness: readinessOf(view.readiness), name: nullableName(view.name), auth: authKindOf(view.auth), policy: view.policy === null ? null : connectorPolicyOf(view.policy), sourceStamp: nullableStamp(view.sourceStamp) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function migrationResponseOf(raw: unknown): MigrationResponse {
|
|
158
|
+
const value = deliveryRecord(raw, ['migration', 'receiving']);
|
|
159
|
+
const migration = viewOf(value.migration);
|
|
160
|
+
if (!Object.hasOwn(value, 'receiving')) return { migration };
|
|
161
|
+
const receiving = deliveryRecord(value.receiving, ['publicKey', 'context']);
|
|
162
|
+
if (typeof receiving.publicKey !== 'string' || sealingKeyOf(receiving.publicKey) === null) throw invalid();
|
|
163
|
+
const context = contextOf(receiving.context);
|
|
164
|
+
if (context.operationId !== migration.operationId || context.sourceId !== migration.sourceId || context.destinationId !== migration.destinationId) throw invalid();
|
|
165
|
+
return { migration, receiving: { publicKey: receiving.publicKey, context } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sealConnectorMigration(publicKey: string, context: MigrationContext, payload: MigrationPayload): SealedTransfer {
|
|
169
|
+
return sealTransfer(publicKey, contextBytes(context), Buffer.from(JSON.stringify(migrationPayloadOf(payload))));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function openConnectorMigration(privateKey: KeyObject, context: MigrationContext, sealed: unknown): MigrationPayload {
|
|
173
|
+
if (Buffer.byteLength(JSON.stringify(sealed)) > CONNECTOR_MIGRATION_MAX_BYTES) throw invalid();
|
|
174
|
+
const plain = openTransfer(privateKey, contextBytes(context), sealed, PAYLOAD_MAX);
|
|
175
|
+
return migrationPayloadOf(JSON.parse(plain.toString('utf8')) as unknown);
|
|
176
|
+
}
|
|
@@ -40,6 +40,9 @@ const strictest = (...values: (ConnectorDecision | undefined)[]): ConnectorDecis
|
|
|
40
40
|
|
|
41
41
|
export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy): ConnectorPolicy {
|
|
42
42
|
const policies = [connectorPolicyOf(global), connectorPolicyOf(assignment)];
|
|
43
|
+
const neutral = (policy: ConnectorPolicy): boolean => (policy.read ?? 'allow') === 'allow' && (policy.write ?? 'allow') === 'allow' && Object.keys(policy.tools ?? {}).length === 0;
|
|
44
|
+
if (neutral(assignment)) return connectorPolicyOf(global);
|
|
45
|
+
if (neutral(global)) return connectorPolicyOf(assignment);
|
|
43
46
|
const out: ConnectorPolicy = {
|
|
44
47
|
read: strictest(...policies.map((policy) => policy.read)),
|
|
45
48
|
write: strictest(...policies.map((policy) => policy.write)),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, type KeyObject } from 'node:crypto';
|
|
2
|
+
import { rawPublicKey, sealingKeyOf } from './agent-signature.js';
|
|
3
|
+
import { deliveryBytes, deliveryRecord } from './connector-delivery-validation.js';
|
|
4
|
+
|
|
5
|
+
export interface SealedTransfer {
|
|
6
|
+
version: 1;
|
|
7
|
+
ephemeralKey: string;
|
|
8
|
+
iv: string;
|
|
9
|
+
tag: string;
|
|
10
|
+
ciphertext: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cipherKey(privateKey: KeyObject, publicKey: KeyObject, context: Buffer, ephemeral: string, recipient: string): Buffer {
|
|
14
|
+
const shared = diffieHellman({ privateKey, publicKey });
|
|
15
|
+
if (!shared.some((byte) => byte !== 0)) throw new Error('Invalid connector transfer key.');
|
|
16
|
+
return Buffer.from(hkdfSync('sha256', shared, Buffer.concat([deliveryBytes(ephemeral, 32), deliveryBytes(recipient, 32)]), context, 32));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sealTransfer(publicKey: string, aad: Buffer, bytes: Buffer): SealedTransfer {
|
|
20
|
+
const recipient = sealingKeyOf(publicKey);
|
|
21
|
+
if (recipient === null) throw new Error('Invalid connector transfer key.');
|
|
22
|
+
const pair = generateKeyPairSync('x25519');
|
|
23
|
+
const ephemeralKey = rawPublicKey(pair.publicKey);
|
|
24
|
+
const iv = randomBytes(12);
|
|
25
|
+
const cipher = createCipheriv('aes-256-gcm', cipherKey(pair.privateKey, recipient, aad, ephemeralKey, publicKey), iv);
|
|
26
|
+
cipher.setAAD(aad);
|
|
27
|
+
const ciphertext = Buffer.concat([cipher.update(bytes), cipher.final()]).toString('base64url');
|
|
28
|
+
return { version: 1, ephemeralKey, iv: iv.toString('base64url'), tag: cipher.getAuthTag().toString('base64url'), ciphertext };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function openTransfer(privateKey: KeyObject, aad: Buffer, envelope: unknown, maxBytes: number): Buffer {
|
|
32
|
+
if (privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'x25519') throw new Error('Invalid connector transfer key.');
|
|
33
|
+
const value = deliveryRecord(envelope, ['version', 'ephemeralKey', 'iv', 'tag', 'ciphertext']);
|
|
34
|
+
if (value.version !== 1 || typeof value.ephemeralKey !== 'string') throw new Error('Invalid connector transfer envelope.');
|
|
35
|
+
const ephemeral = sealingKeyOf(value.ephemeralKey);
|
|
36
|
+
if (ephemeral === null) throw new Error('Invalid connector transfer key.');
|
|
37
|
+
const ciphertext = deliveryBytes(value.ciphertext);
|
|
38
|
+
if (ciphertext.length > maxBytes) throw new Error('Connector transfer is too large.');
|
|
39
|
+
const key = cipherKey(privateKey, ephemeral, aad, value.ephemeralKey, rawPublicKey(createPublicKey(privateKey)));
|
|
40
|
+
const decipher = createDecipheriv('aes-256-gcm', key, deliveryBytes(value.iv, 12));
|
|
41
|
+
decipher.setAAD(aad);
|
|
42
|
+
decipher.setAuthTag(deliveryBytes(value.tag, 16));
|
|
43
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
44
|
+
}
|
package/runtime/runtime.json
CHANGED