@stage-labs/metro 0.1.0-beta.290 → 0.1.0-beta.292

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 (23) 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/config.ts +2 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +10 -5
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +129 -36
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +6 -5
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +16 -6
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -28
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +25 -6
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +24 -4
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +2 -25
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -10
  14. package/runtime/node_modules/@metro-labs/http/package.json +4 -1
  15. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +3 -3
  16. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
  17. package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +1 -1
  18. package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +31 -29
  20. package/runtime/node_modules/@metro-labs/http/src/connector-public-url.ts +64 -0
  21. package/runtime/node_modules/@metro-labs/http/src/connector-resource.ts +108 -0
  22. package/runtime/runtime.json +1 -1
  23. package/runtime/sdk-runner/runner.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.290",
3
+ "version": "0.1.0-beta.292",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.290",
3
+ "version": "0.1.0-beta.292",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -44,6 +44,8 @@ function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
44
44
  clientId,
45
45
  tokenEndpoint,
46
46
  issuer: stringOf(raw.issuer),
47
+ ...(typeof raw.resource === 'string' ? { resource: raw.resource } : {}),
48
+ ...(typeof raw.tokenEndpointAuthMethod === 'string' ? { tokenEndpointAuthMethod: raw.tokenEndpointAuthMethod } : {}),
47
49
  ...(refreshToken === '' ? {} : { refreshToken }),
48
50
  ...(clientSecret === '' ? {} : { clientSecret }),
49
51
  ...(scope === '' ? {} : { scope }),
@@ -3,10 +3,11 @@ import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { parseId } from '@metro-labs/core/ids';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
6
+ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
6
7
  import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
7
8
 
8
9
  const PATH = '/api/connectors/migrations';
9
- const actions: readonly MigrationAction[] = ['prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
10
+ const actions: readonly MigrationAction[] = ['move', 'prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
10
11
  export type MigrationApiDeps = Pick<ConnectorMigrations, 'status' | 'run'>;
11
12
 
12
13
  interface MigrationInput {
@@ -15,6 +16,7 @@ interface MigrationInput {
15
16
  scope: string;
16
17
  operationId: string | null;
17
18
  uncertainty: string | null;
19
+ oauthResource?: string;
18
20
  }
19
21
 
20
22
  function operationOf(value: unknown): string | null {
@@ -34,13 +36,16 @@ function receiptOf(body: Record<string, unknown>, action: MigrationAction): stri
34
36
  }
35
37
 
36
38
  function inputOf(body: unknown): MigrationInput {
37
- if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
39
+ if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty', 'oauthResource'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
38
40
  throw new ApiError('A migration needs its connector, action and current enrollment scope.', 400);
39
41
  }
40
42
  const sourceId = parseId(body.sourceId);
41
43
  const action = actions.find((value) => value === body.action);
42
44
  if (sourceId === null || action === undefined) throw new ApiError('A migration needs a valid connector and action.', 400);
43
- return { sourceId, action, scope: body.scope, operationId: operationOf(body.operationId), uncertainty: receiptOf(body, action) };
45
+ return {
46
+ sourceId, action, scope: body.scope, operationId: operationOf(body.operationId), uncertainty: receiptOf(body, action),
47
+ ...(body.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(body.oauthResource) }),
48
+ };
44
49
  }
45
50
 
46
51
  export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse, deps: MigrationApiDeps): boolean {
@@ -49,7 +54,7 @@ export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse
49
54
  const current = deps.status();
50
55
  if (current.owner === null || current.owner !== session.subject) throw new ApiError('This agent belongs to another organization.', 403);
51
56
  if (req.method !== 'POST') return current;
52
- const { sourceId, action, scope, operationId, uncertainty } = inputOf(body);
53
- return deps.run(sourceId, action, scope, operationId, uncertainty);
57
+ const { sourceId, action, scope, operationId, uncertainty, oauthResource } = inputOf(body);
58
+ return deps.run(sourceId, action, scope, operationId, uncertainty, oauthResource);
54
59
  });
55
60
  }
@@ -2,23 +2,26 @@ import { newId } from '@metro-labs/core/ids';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
+ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
5
6
  import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
6
7
  import { migrationResponseOf, sealConnectorMigration, type MigrationResponse, type MigrationView as RemoteMigration } from '@metro-labs/http/connector-migration';
7
8
  import { agentsDir } from '../agents/files.js';
8
9
  import { readAgentKey, type AgentKey } from './agent-key.js';
9
- import { agentCall, METRO_API, type MetroApi } from './metro-api.js';
10
+ import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
10
11
  import { migrationSource } from './migration-preview.js';
11
- import { migrationPayload } from './migration-source.js';
12
+ import { MigrationBlocked, migrationPayload } from './migration-source.js';
12
13
  import { localMigrationIdle } from './migration-guard.js';
13
- import { migrationUncertaintyReceipt, resolveMigrationUncertainty } from './migration-operations.js';
14
- import { localRollbackMigrationSource } from './store.js';
14
+ import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
15
+ import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
15
16
  import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
16
17
  import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
17
18
 
18
- export type MigrationAction = 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
19
+ export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
19
20
  interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
20
21
  export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
21
22
  uncertainty: string | null;
23
+ move: boolean;
24
+ grantImported: boolean;
22
25
  }
23
26
  export interface MigrationStatus {
24
27
  scope: string;
@@ -43,11 +46,29 @@ interface MigrationDeps {
43
46
 
44
47
  const conflict = (message: string): ApiError => new ApiError(message, 409);
45
48
  const uncertain = (): ApiError => new ApiError('The migration request was not confirmed. Refresh and reconcile this connector before trying another action; an interrupted request may already have reached Metro.', 503);
46
- const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty });
47
- const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable' };
49
+ const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason, move, grantImported }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty, move: move === true, grantImported: grantImported === true });
50
+ const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable', refresh_uncertain: 'refresh_uncertain' };
48
51
  const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
49
52
  (a.read ?? 'allow') === (b.read ?? 'allow') && (a.write ?? 'allow') === (b.write ?? 'allow') && JSON.stringify(Object.entries(a.tools ?? {}).sort()) === JSON.stringify(Object.entries(b.tools ?? {}).sort());
50
53
 
54
+ const grantTransfer = (record: MigrationRecord): boolean => record.move === true && record.auth === 'oauth';
55
+ const upgrading = (record: MigrationRecord, remote: RemoteMigration): boolean => grantTransfer(record) && remote.grantImported !== true;
56
+ const canUpgrade = (record: MigrationRecord): boolean => record.move !== true && ['preparing', 'prepared'].includes(record.phase);
57
+
58
+ function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
59
+ if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
60
+ if (remote.sourceStamp === null || remote.sourceStamp === record.sourceStamp || upgrading(record, remote)) return;
61
+ throw conflict('The prepared destination no longer matches this local connector. Reconcile its current operation before continuing.');
62
+ }
63
+
64
+ function callFailure(error: unknown, action: string): ApiError {
65
+ if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The destination already has credentials, a name conflict or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
66
+ if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
67
+ return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
68
+ }
69
+ return uncertain();
70
+ }
71
+
51
72
  function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
52
73
  const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
53
74
  return delivered?.state === 'ready' && delivered.name === record.name && samePolicy(delivered.policy, record.policy);
@@ -74,7 +95,7 @@ export class ConnectorMigrations {
74
95
  const keyId = this.key()?.keyId;
75
96
  const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
76
97
  const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
77
- const reason = uncertainty === null ? row.reason : 'requests_uncertain';
98
+ const reason = uncertainty === null ? row.reason : migrationRefreshUncertain(row.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain';
78
99
  return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
79
100
  });
80
101
  return { scope, owner, agentId, enabled, enrolled, migrations };
@@ -103,25 +124,23 @@ export class ConnectorMigrations {
103
124
  }
104
125
 
105
126
  private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
106
- const payload = migrationPayload(migrationSource(record.sourceId, this.dir));
127
+ const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
107
128
  if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
108
129
  return payload;
109
130
  }
110
131
 
111
- private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
132
+ private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'handover' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
112
133
  this.check(context, record);
113
134
  let response: MigrationResponse;
114
135
  try {
115
136
  response = migrationResponseOf(await agentCall(this.api, context.key, '/api/agents/connector-migrations', {
116
137
  action, operationId: record.id, sourceId: record.sourceId, ...(sealed === undefined ? {} : { sealed }),
117
138
  }, { timeoutMs: 60_000 }));
118
- } catch {
119
- throw uncertain();
139
+ } catch (error) {
140
+ throw callFailure(error, action);
120
141
  }
121
142
  this.check(context, record);
122
- const remote = response.migration;
123
- if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
124
- if (remote.sourceStamp !== null && remote.sourceStamp !== record.sourceStamp) throw conflict('The prepared destination no longer matches this local connector. Roll back this preparation before trying again.');
143
+ checkResponse(record, response.migration);
125
144
  return response;
126
145
  }
127
146
 
@@ -129,15 +148,16 @@ export class ConnectorMigrations {
129
148
  if (remote.phase !== 'preparing' && remote.phase !== 'rolled_back' && (remote.name !== record.name || remote.auth !== record.auth || remote.policy === null || !samePolicy(record.policy, remote.policy))) {
130
149
  return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
131
150
  }
132
- return this.save(context, { ...record, destinationId: remote.destinationId, reason: reasons[remote.readiness] });
151
+ return this.save(context, { ...record, destinationId: remote.destinationId, grantImported: remote.grantImported === true, reason: reasons[remote.readiness] });
133
152
  }
134
153
 
135
- private create(context: Context, id: string): MigrationRecord {
136
- const payload = migrationPayload(migrationSource(id, this.dir));
154
+ private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
155
+ const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
137
156
  return this.save(context, {
138
157
  id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
139
- sourceId: id, name: payload.name, destinationId: null, phase: 'preparing', auth: payload.auth.kind,
158
+ sourceId: id, name: payload.name, destinationId: null, phase: move ? 'freezing' : 'preparing', move, auth: payload.auth.kind,
140
159
  sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
160
+ ...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
141
161
  });
142
162
  }
143
163
 
@@ -145,25 +165,53 @@ export class ConnectorMigrations {
145
165
  const payload = this.source(record);
146
166
  const begun = await this.call(context, record, 'begin');
147
167
  record = this.accept(context, record, begun.migration);
148
- if (begun.migration.phase === 'prepared') {
168
+ if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
149
169
  this.save(context, { ...record, phase: 'prepared' });
150
170
  return;
151
171
  }
152
- if (begun.migration.phase !== 'preparing' || begun.receiving === undefined) throw uncertain();
172
+ if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
153
173
  const receiving = begun.receiving;
154
174
  const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
155
175
  for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
156
176
  const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
157
- const { migration } = await this.call(context, record, 'prepare', sealed);
177
+ const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
158
178
  record = this.accept(context, record, migration);
159
179
  if (migration.phase !== 'prepared') throw uncertain();
160
180
  this.save(context, { ...record, phase: 'prepared' });
161
181
  }
162
182
 
183
+ private async freeze(context: Context, record: MigrationRecord): Promise<MigrationRecord | null> {
184
+ const until = Date.now() + 10_000;
185
+ while (!this.localIdle(record.sourceId) && migrationUncertaintyReceipt(record.sourceId, this.dir) === null && Date.now() < until) {
186
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
187
+ this.check(context, record);
188
+ }
189
+ if (this.waiting(context, record)) return null;
190
+ flushMigrationSource(this.dir);
191
+ const payload = migrationPayload(migrationSource(record.sourceId, this.dir, true), record.oauthResource, true);
192
+ return this.save(context, { ...record, sourceStamp: payload.sourceStamp, captured: true, phase: 'preparing', reason: null });
193
+ }
194
+
195
+ private async move(context: Context, record: MigrationRecord): Promise<void> {
196
+ if (record.phase === 'recovering') return this.finishRecovery(context, record);
197
+ if (['active', 'rolled_back'].includes(record.phase)) return this.reconcile(context, record);
198
+ if (record.phase === 'rollback') return this.rollback(context, record);
199
+ if (record.phase === 'freezing') {
200
+ const captured = await this.freeze(context, record);
201
+ if (captured === null) return;
202
+ record = captured;
203
+ }
204
+ if (record.phase === 'preparing') {
205
+ await this.prepare(context, record);
206
+ record = migrationRecord(record.sourceId, this.dir) ?? record;
207
+ }
208
+ if (record.phase === 'prepared' || record.phase === 'cutover') await this.cutover(context, record);
209
+ }
210
+
163
211
  private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
164
212
  const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
165
213
  if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
166
- this.save(context, { ...record, reason: uncertain ? 'requests_uncertain' : 'waiting_for_local' });
214
+ this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
167
215
  return true;
168
216
  }
169
217
 
@@ -193,7 +241,15 @@ export class ConnectorMigrations {
193
241
  }
194
242
 
195
243
  private async rollback(context: Context, record: MigrationRecord): Promise<void> {
196
- this.source(record);
244
+ if (record.phase === 'recovering') return this.finishRecovery(context, record);
245
+ if (record.grantImported === true) throw new MigrationBlocked('finish_forward', 'Central owns this OAuth grant. Finish forward or sign in centrally; the old local tokens cannot be restored.');
246
+ const begun = await this.call(context, record, 'begin');
247
+ if (begun.migration.grantImported === true) {
248
+ record = this.accept(context, record, begun.migration);
249
+ this.save(context, { ...record, phase: begun.migration.phase === 'active' ? 'cutover' : 'prepared', reason: 'finish_forward' });
250
+ return;
251
+ }
252
+ if (record.phase !== 'freezing') this.source(record);
197
253
  record = this.save(context, { ...record, phase: 'rollback' });
198
254
  if (this.waiting(context, record, true)) return;
199
255
  const { migration } = await this.call(context, record, 'rollback');
@@ -202,56 +258,93 @@ export class ConnectorMigrations {
202
258
  return;
203
259
  }
204
260
  this.check(context, record);
205
- if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, record.auth === 'oauth');
206
- else localRollbackMigrationSource(record.sourceId, record.auth === 'oauth', this.dir);
207
- this.save(context, { ...record, phase: 'rolled_back', reason: record.auth === 'oauth' ? 'sign_in' : null });
261
+ this.restore(context, record);
262
+
263
+ }
264
+
265
+ private restore(context: Context, record: MigrationRecord): void {
266
+ const signOut = record.auth === 'oauth' && record.move !== true;
267
+ if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, signOut);
268
+ else localRollbackMigrationSource(record.sourceId, signOut, this.dir);
269
+ this.save(context, { ...record, phase: 'rolled_back', reason: signOut ? 'sign_in' : null });
208
270
  }
209
271
 
210
272
  private async reconcile(context: Context, record: MigrationRecord): Promise<void> {
273
+ if (record.phase === 'recovering') return this.finishRecovery(context, record);
274
+ if (record.phase === 'rollback') return this.rollback(context, record);
275
+ if (record.move === true && record.phase !== 'active' && record.phase !== 'rolled_back') return this.move(context, record);
211
276
  if (record.phase === 'preparing') return this.prepare(context, record);
212
277
  if (record.phase === 'cutover') return this.cutover(context, record);
213
- if (record.phase === 'rollback') return this.rollback(context, record);
214
278
  if (record.phase === 'rolled_back') return;
215
279
  const { migration } = await this.call(context, record, 'status');
216
280
  this.accept(context, record, migration);
217
281
  }
218
282
 
219
- private obtain(context: Context, sourceId: string, action: MigrationAction): MigrationRecord | null {
283
+ private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
220
284
  const record = migrationRecord(sourceId, this.dir);
221
285
  if (record !== null) this.check(context, record);
222
- if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId);
286
+ if (['prepare', 'move'].includes(action) && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource, action === 'move');
287
+ if (action === 'move' && record !== null && canUpgrade(record)) return this.save(context, { ...record, move: true, phase: 'freezing' });
223
288
  if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
224
289
  return record;
225
290
  }
226
291
 
227
292
  private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
293
+ if (action === 'move') return this.move(context, record);
228
294
  if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
229
295
  if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
230
296
  if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
231
297
  return this.reconcile(context, record);
232
298
  }
233
299
 
234
- private resolve(context: Context, record: MigrationRecord, receipt: string | null): void {
300
+ private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
235
301
  this.check(context, record);
236
302
  if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
303
+ if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
237
304
  if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
238
305
  this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
239
306
  }
240
307
 
308
+ private async recoverRefresh(context: Context, record: MigrationRecord, receipt: string): Promise<void> {
309
+ if (record.move !== true || !['freezing', 'rollback'].includes(record.phase) || !migrationOperationsIdle(record.sourceId, this.dir, false)) throw conflict('The uncertain local refresh must stop before aborting the handover.');
310
+ const { migration } = await this.call(context, record, 'rollback');
311
+ if (migration.phase !== 'rolled_back') throw uncertain();
312
+ record = this.save(context, { ...record, phase: 'recovering', recoveryReceipt: receipt });
313
+ await this.finishRecovery(context, record);
314
+ }
315
+
316
+ private async finishRecovery(context: Context, record: MigrationRecord): Promise<void> {
317
+ const { migration } = await this.call(context, record, 'rollback');
318
+ if (migration.phase !== 'rolled_back' || record.recoveryReceipt === undefined) throw uncertain();
319
+ localRollbackMigrationSource(record.sourceId, true, this.dir, true);
320
+ const receipt = migrationUncertaintyReceipt(record.sourceId, this.dir);
321
+ if (receipt !== null && !resolveMigrationUncertainty(record.sourceId, record.recoveryReceipt, this.dir)) throw uncertain();
322
+ this.save(context, { ...record, phase: 'rolled_back', recoveryReceipt: undefined, reason: 'sign_in' });
323
+ }
324
+
325
+ private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
326
+ if (oauthResource === undefined) return;
327
+ const resource = publicOAuthResource(oauthResource);
328
+ const record = migrationRecord(sourceId, this.dir);
329
+ if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return;
330
+ if (record?.oauthResource !== resource) throw conflict('The public OAuth resource is fixed for this preparation. Roll back, then prepare again to change it.');
331
+ }
332
+
241
333
  private validateIntent(sourceId: string, action: MigrationAction, operationId: string | null, receipt: string | null): void {
242
334
  if ((migrationRecord(sourceId, this.dir)?.id ?? null) !== operationId) throw conflict('This connector migration changed. Refresh before acting on its current operation.');
243
335
  if (action === 'resolve' && (receipt === null || receipt !== migrationUncertaintyReceipt(sourceId, this.dir))) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
244
336
  }
245
337
 
246
- async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null): Promise<MigrationStatus> {
338
+ async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
247
339
  if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
248
- const context = this.context(scope, action === 'prepare' || action === 'cutover');
340
+ const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
249
341
  this.validateIntent(sourceId, action, operationId, uncertainty);
342
+ this.validateResource(sourceId, action, oauthResource);
250
343
  this.running = true;
251
344
  try {
252
- const record = this.obtain(context, sourceId, action);
345
+ const record = this.obtain(context, sourceId, action, oauthResource);
253
346
  if (record !== null) {
254
- if (action === 'resolve') this.resolve(context, record, uncertainty);
347
+ if (action === 'resolve') await this.resolve(context, record, uncertainty);
255
348
  else await this.perform(context, record, action);
256
349
  }
257
350
  this.check(context);
@@ -259,7 +352,7 @@ export class ConnectorMigrations {
259
352
  } catch (error) {
260
353
  const record = migrationRecord(sourceId, this.dir);
261
354
  if (record !== null) {
262
- try { this.save(context, { ...record, reason: 'unavailable' }); } catch {
355
+ try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
263
356
  log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
264
357
  }
265
358
  }
@@ -6,7 +6,7 @@ import { migrationRecord, migrationRecords } from './migration-state.js';
6
6
  import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
7
7
 
8
8
  const locked = (phase: string): boolean => phase !== 'rolled_back';
9
- const retired = (phase: string): boolean => phase === 'cutover' || phase === 'active' || phase === 'rollback';
9
+ const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
10
10
  const refused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization.', 409);
11
11
 
12
12
  export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeof migrationRecords>[number] | null {
@@ -16,13 +16,13 @@ export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeo
16
16
 
17
17
  export function localMigrationEnabled(id: string, dir = agentsDir()): boolean {
18
18
  const record = migrationSource(id, dir);
19
- return record === null || !retired(record.phase);
19
+ return record === null || !retired(record);
20
20
  }
21
21
 
22
22
  export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
23
23
  const record = migrationSource(id, dir);
24
24
  if (record === null) return;
25
- if (retired(record.phase)) throw refused();
25
+ if (retired(record)) throw refused();
26
26
  assertMigrationOutcomeKnown(id, dir);
27
27
  }
28
28
 
@@ -35,8 +35,8 @@ export function migrationExportableRows<T extends { id: string; name: string }>(
35
35
  return rows.filter((row) => !records.some((record) => record.sourceId === row.id || record.name === row.name));
36
36
  }
37
37
 
38
- export function localMigrationIdle(id: string, dir = agentsDir()): boolean {
39
- return migrationOperationsIdle(id, dir, migrationSource(id, dir) !== null);
38
+ export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
39
+ return migrationOperationsIdle(id, dir, !allowUncertain && migrationSource(id, dir) !== null);
40
40
  }
41
41
 
42
42
  export interface LocalMigrationOperation extends MigrationOperation {
@@ -61,6 +61,7 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
61
61
  return {
62
62
  check,
63
63
  dispatch,
64
+ persistedRefresh: tracked.persistedRefresh,
64
65
  fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
65
66
  close: () => { closed = true; tracked.close(); },
66
67
  };
@@ -14,11 +14,13 @@ interface RequestOperation {
14
14
  lease: string;
15
15
  durable: boolean;
16
16
  abandoned: boolean;
17
+ refresh: boolean;
17
18
  }
18
19
 
19
20
  export interface MigrationOperation {
20
- dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean }) => Promise<Response>;
21
+ dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
21
22
  close: () => void;
23
+ persistedRefresh: () => void;
22
24
  }
23
25
 
24
26
  const leases = new Map<string, Set<string>>();
@@ -57,7 +59,7 @@ function pendingOf(text: string): string[] {
57
59
  const value: unknown = JSON.parse(text);
58
60
  if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.pending) || value.pending.length > 10_000) throw unavailable();
59
61
  const ids: unknown[] = value.pending;
60
- if (!ids.every((id): id is string => typeof id === 'string' && /^[a-f0-9]{32}$/.test(id))) throw unavailable();
62
+ if (!ids.every((id): id is string => typeof id === 'string' && /^r?[a-f0-9]{32}$/.test(id))) throw unavailable();
61
63
  if (new Set(ids).size !== ids.length) throw unavailable();
62
64
  return ids;
63
65
  }
@@ -114,7 +116,8 @@ export function resolveMigrationUncertainty(source: string, expectedReceipt: str
114
116
  return true;
115
117
  }
116
118
 
117
- function finish(request: RequestOperation, completed: boolean): void {
119
+ function finish(request: RequestOperation, completed: boolean, persisted = false): void {
120
+ if (request.refresh && completed && !persisted) return;
118
121
  if (request.abandoned) return;
119
122
  requests.delete(request.id);
120
123
  if (completed) held.delete(request.id);
@@ -153,10 +156,10 @@ function observed(response: Response, request: RequestOperation): Response {
153
156
  return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
154
157
  }
155
158
 
156
- async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, send: () => Promise<Response>): Promise<Response> {
159
+ async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, refresh: boolean, send: () => Promise<Response>): Promise<Response> {
157
160
  if (protectedSource) assertMigrationOutcomeKnown(source, dir);
158
161
  if (!effectful) return send();
159
- const request: RequestOperation = { id: randomBytes(16).toString('hex'), source, dir, lease, durable: false, abandoned: false };
162
+ const request: RequestOperation = { id: `${refresh ? 'r' : ''}${randomBytes(16).toString('hex')}`, source, dir, lease, durable: false, abandoned: false, refresh };
160
163
  if (protectedSource) {
161
164
  const pending = readPending(source, dir);
162
165
  if (pending.length >= 10_000) throw unavailable();
@@ -180,7 +183,10 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
180
183
  return {
181
184
  dispatch: (send, options = {}) => {
182
185
  if (closed) return Promise.reject(uncertain());
183
- return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, send);
186
+ return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
187
+ },
188
+ persistedRefresh: () => {
189
+ for (const request of held.values()) if (request.lease === lease && request.refresh) finish(request, true, true);
184
190
  },
185
191
  close: () => {
186
192
  if (closed) return;
@@ -194,3 +200,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
194
200
  },
195
201
  };
196
202
  }
203
+
204
+ export function migrationRefreshUncertain(source: string, dir = agentsDir()): boolean {
205
+ return readPending(source, dir).some((id) => id.startsWith('r') && !requests.has(id));
206
+ }
@@ -5,7 +5,7 @@ import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { ApiError } from '@metro-labs/http/api-error';
6
6
  import { bodyField } from '@metro-labs/http/api-http';
7
7
  import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/connector-input';
8
- import { parseConnectorUrl } from '@metro-labs/http/connector-url';
8
+ import { portableGrant } from '@metro-labs/http/connector-grant';
9
9
  import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
10
10
  import { readConfig } from './config.js';
11
11
  import type { LocalConnectorRow } from './store.js';
@@ -13,9 +13,10 @@ import { agentsDir } from '../agents/files.js';
13
13
  import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
14
14
  import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
15
15
  import { readAgentKey } from './agent-key.js';
16
+ import { MigrationBlocked } from './migration-source.js';
16
17
  import { migrationOrigin } from './migration-url.js';
17
18
 
18
- export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_private_resource';
19
+ export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_unsupported';
19
20
 
20
21
  export interface MigrationConnector {
21
22
  id: string;
@@ -29,6 +30,8 @@ export interface MigrationConnector {
29
30
 
30
31
  export interface ConnectorMigrationPreview {
31
32
  migrationSupported: true;
33
+ oauthResourceSupported: true;
34
+ moveAllSupported: true;
32
35
  enabled: boolean;
33
36
  enrolled: boolean;
34
37
  agentId: string | null;
@@ -53,19 +56,8 @@ function validClient(raw: unknown): boolean {
53
56
  return client !== null && client.clientId === raw.clientId && client.clientSecret === raw.clientSecret;
54
57
  }
55
58
 
56
- function oauthTokensValid(raw: Record<string, unknown>): boolean {
57
- const token = (value: unknown): boolean => typeof value === 'string' && /^[\x21-\x7e]+$/.test(value);
58
- return token(raw.accessToken) && (raw.refreshToken === undefined || token(raw.refreshToken));
59
- }
60
-
61
59
  function oauthAuth(raw: Record<string, unknown>): 'oauth' {
62
- if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt'])) invalid();
63
- if (!oauthTokensValid(raw) || ![raw.clientId, raw.tokenEndpoint, raw.issuer].every(nonempty)) invalid();
64
- if (!optionalStrings(raw, ['refreshToken', 'clientSecret', 'scope'])) invalid();
65
- if (raw.expiresAt !== undefined && (typeof raw.expiresAt !== 'number' || !Number.isFinite(raw.expiresAt) || raw.expiresAt < 0)) invalid();
66
- connectorClient(raw.clientId, raw.clientSecret);
67
- parseConnectorUrl(raw.tokenEndpoint);
68
- parseConnectorUrl(raw.issuer);
60
+ if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt', 'resource', 'tokenEndpointAuthMethod'])) invalid();
69
61
  return 'oauth';
70
62
  }
71
63
 
@@ -156,14 +148,17 @@ function rowIdentity(row: Record<string, unknown>, index: number): { id: string;
156
148
  return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
157
149
  }
158
150
 
159
- function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth'], origin: string | null): MigrationIssue[] {
160
- if (auth === 'oauth') {
161
- const issues: MigrationIssue[] = ['oauth_sign_in'];
162
- if (origin === null) return issues;
163
- const url = parseConnectorUrl(row.url);
164
- if (![url.origin, `${url.origin}/`, `${url.origin}/mcp`].includes(String(row.url))) issues.push('oauth_private_resource');
165
- return issues;
166
- }
151
+ function grantIssues(row: Record<string, unknown>): MigrationIssue[] {
152
+ const grant = bodyField(row.config, 'auth');
153
+ if (!isRecord(grant) || grant.kind !== 'oauth') return ['oauth_sign_in'];
154
+ try {
155
+ const portable = portableGrant(grant, String(row.url));
156
+ return portable.expiresAt !== undefined && portable.expiresAt <= Date.now() && portable.refreshToken === undefined ? ['oauth_sign_in'] : [];
157
+ } catch { return ['oauth_unsupported']; }
158
+ }
159
+
160
+ function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth']): MigrationIssue[] {
161
+ if (auth === 'oauth') return grantIssues(row);
167
162
  const client = bodyField(row.config, 'client');
168
163
  return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
169
164
  }
@@ -178,7 +173,7 @@ function previewRow(raw: unknown, index: number): MigrationConnector {
178
173
  if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
179
174
  if (origin === null) issues.push('unsupported_url');
180
175
  if (valid && !policySupported) issues.push('custom_policy');
181
- issues.push(...authIssues(row, auth, origin));
176
+ issues.push(...authIssues(row, auth));
182
177
  return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
183
178
  }
184
179
 
@@ -217,20 +212,26 @@ function previewRows(dir: string): MigrationConnector[] {
217
212
  return rows;
218
213
  }
219
214
 
220
- export function migrationSource(id: string, dir = agentsDir()): LocalConnectorRow {
215
+ function assertTransferable(source: MigrationConnector): void {
216
+ if (source.issues.includes('oauth_sign_in')) throw new MigrationBlocked('sign_in', 'No usable local OAuth grant is saved. Sign in locally before moving it.');
217
+ if (source.issues.includes('oauth_unsupported')) throw new MigrationBlocked('unsupported_grant', 'This OAuth grant has unsupported client authentication or provider binding. Keep it local or sign in separately.');
218
+ }
219
+
220
+ export function migrationSource(id: string, dir = agentsDir(), transfer = false): LocalConnectorRow {
221
221
  const source = previewRows(dir).find((row) => row.id === id);
222
222
  if (source === undefined) throw new ApiError('The local connector is missing.', 404);
223
- if (source.issues.some((issue) => issue !== 'oauth_sign_in')) throw new ApiError('This connector cannot be migrated without changing its settings or permissions. Resolve the preview blockers first.', 409);
223
+ if (source.issues.some((issue) => issue !== 'oauth_sign_in' && issue !== 'oauth_unsupported')) throw new ApiError('This connector cannot be migrated without changing its settings or permissions. Resolve the preview blockers first.', 409);
224
+ if (transfer) assertTransferable(source);
224
225
  const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
225
226
  if (!isRecord(raw) || typeof raw.url !== 'string') throw unavailable();
226
- return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), policy: source.policy } };
227
+ return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), oauth: source.auth === 'oauth', policy: source.policy } };
227
228
  }
228
229
 
229
230
  export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
230
231
  const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
231
232
  const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
232
- if (!enabled) return { migrationSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
233
+ if (!enabled) return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
233
234
  const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
234
235
  const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
235
- return { migrationSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
236
+ return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
236
237
  }