@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +6 -4
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
  25. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  26. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
  27. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
  28. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
  29. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
  30. package/runtime/runtime.json +1 -1
  31. 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
- const verified = stamp(await verifyRemoteMcp(url, auth));
188
- return insert(dir, name, url, {
189
- auth,
190
- createdAt: new Date().toISOString(),
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
- const row = rowOrThrow(id, dir);
212
- const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth));
213
- return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
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']>): void {
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
- const row = rowOrThrow(id, dir);
244
- try {
245
- const url = parseConnectorUrl(row.url);
246
- const auth = await freshAuth(row, dir);
247
- const verified = stamp(await verifyRemoteMcp(url, auth));
248
- const oauth = row.config.oauth || (await oauthCapable(url, auth));
249
- updateRow(dir, row.id, { auth, verified, oauth });
250
- return { id: row.id, name: row.name, ok: true, verified };
251
- } catch (err) {
252
- if (!(err instanceof ConnectorVerifyError)) throw err;
253
- return { id: row.id, name: row.name, ok: false, reason: err.message };
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
- const row = rowOrThrow(id, dir);
271
- const url = parseConnectorUrl(row.url);
272
- const tools = await listRemoteTools(url, await freshAuth(row, dir));
273
- keepGroups(dir, id, tools);
274
- return tools;
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 running = inflight.get(row.id);
326
+ const key = JSON.stringify([dir, row.id]);
327
+ const running = inflight.get(key);
317
328
  if (running !== undefined) return running;
318
- const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
319
- .then((fresh) => {
320
- updateRow(dir, row.id, { auth: fresh });
321
- return fresh;
322
- })
323
- .finally(() => {
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
- const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
370
- if (row === undefined) return { kind: 'missing' };
371
- parseConnectorUrl(row.url);
372
- const auth = row.config.auth;
373
- if (auth.kind === 'oauth') return oauthTarget(row, auth, force, dir);
374
- return fixedTarget(row.url, auth, force);
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?.cancel();
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
- connectorTransfers: new ConnectorTransfers({ read: readLocalConnectors, copy: copyConnectorRows }),
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 { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, type KeyObject } from 'node:crypto';
2
- import { rawPublicKey, sealingKeyOf } from './agent-signature.js';
3
- import { CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliveryBytes, deliveryContextBytes, deliveryRecord, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot } from './connector-delivery-validation.js';
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 aad = deliveryContextBytes(context);
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 value = deliveryRecord(envelope, ['version', 'ephemeralKey', 'iv', 'tag', 'ciphertext']);
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
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.289"
2
+ "version": "0.1.0-beta.290"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.289",
2
+ "version": "0.1.0-beta.290",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",