@stage-labs/metro 0.1.0-beta.291 → 0.1.0-beta.293

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 (29) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +1 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +107 -30
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +17 -13
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +36 -6
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -13
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +18 -2
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +20 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +19 -10
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +21 -24
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
  24. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  25. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
  26. package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
  27. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +23 -16
  28. package/runtime/runtime.json +1 -1
  29. 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.291",
3
+ "version": "0.1.0-beta.293",
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.291",
3
+ "version": "0.1.0-beta.293",
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",
@@ -59,7 +59,10 @@ function decide(policy, name, group) {
59
59
  const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
60
60
 
61
61
  function connectorCall(snap, toolName) {
62
- for (const [server, entry] of Object.entries(isRecord(snap.connectors) ? snap.connectors : {})) {
62
+ const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
63
+ const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
64
+ if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
65
+ for (const [server, entry] of entries) {
63
66
  if (!isRecord(entry)) continue;
64
67
  const name = text(entry.name);
65
68
  const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
@@ -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 }),
@@ -20,7 +20,6 @@ export interface ConnectorGate {
20
20
  }
21
21
 
22
22
  let gates: ConnectorGate[] = [];
23
- let managed = new Set<string>();
24
23
 
25
24
  const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
26
25
 
@@ -31,21 +30,21 @@ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | unde
31
30
  return before !== after;
32
31
  }
33
32
 
34
- export function registerConnectors(rows: readonly GateRow[], managedIds: readonly string[] = [], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
35
- const nextManaged = new Set(managedIds);
36
- revokeToolPrompts((tool) => {
37
- const found = connectorToolOf(tool);
38
- if (found === undefined || (!isOrgRelayId(found.gate.id) && !managed.has(found.gate.id) && !nextManaged.has(found.gate.id))) return false;
39
- return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
40
- });
41
- managed = nextManaged;
42
- gates = rows.map((row) => ({
33
+ export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
34
+ const next = rows.map((row) => ({
43
35
  id: row.id,
44
36
  name: row.name,
45
37
  server: keys.get(row.id) ?? '',
46
38
  policy: row.config.policy ?? {},
47
39
  tools: row.config.toolGroups ?? {},
48
40
  }));
41
+ revokeToolPrompts((tool) => {
42
+ const before = connectorToolOf(tool);
43
+ const after = connectorToolOf(tool, next);
44
+ if (before?.gate.id !== after?.gate.id) return true;
45
+ return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
46
+ });
47
+ gates = next;
49
48
  const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
50
49
  row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
51
50
  );
@@ -63,7 +62,7 @@ const promptFor = (connectorId: string, tool: string) => (asked: string): boolea
63
62
 
64
63
  export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
65
64
  const gate = gates.find((g) => g.id === connectorId);
66
- if (gate === undefined) return null;
65
+ if (gate === undefined) return 'This connector is not available.';
67
66
  settlePrompts(promptFor(connectorId, tool), args);
68
67
  const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
69
68
  if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
@@ -92,19 +91,24 @@ export function requestConnectorBlocker(): () => BlockedReason {
92
91
  };
93
92
  }
94
93
 
95
- function prefixesOf(gate: ConnectorGate): string[] {
96
- const prefixes = [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`];
97
- return isOrgRelayId(gate.id) ? prefixes : [...prefixes, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
94
+ function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
95
+ const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
96
+ return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
98
97
  }
99
98
 
100
- export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
99
+ export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
101
100
  if (toolName.startsWith('mcp__metro__')) return undefined;
102
- for (const gate of gates)
103
- for (const prefix of prefixesOf(gate))
104
- if (toolName.startsWith(prefix) && toolName.length > prefix.length) {
105
- const called = toolName.slice(prefix.length);
106
- const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
107
- return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
108
- }
109
- return undefined;
101
+ for (const gate of entries)
102
+ for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
103
+ if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
104
+ return legacyToolOf(toolName, entries);
105
+ }
106
+
107
+ function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
108
+ if (!toolName.startsWith('mcp__metro_box_')) return undefined;
109
+ const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
110
+ const gate = legacy.length === 1 ? legacy[0] : undefined;
111
+ if (gate === undefined) return undefined;
112
+ const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
113
+ return called === '' ? undefined : resolvedTool(gate, called);
110
114
  }
@@ -7,7 +7,7 @@ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
7
7
  import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
8
8
 
9
9
  const PATH = '/api/connectors/migrations';
10
- const actions: readonly MigrationAction[] = ['prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
10
+ const actions: readonly MigrationAction[] = ['move', 'prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
11
11
  export type MigrationApiDeps = Pick<ConnectorMigrations, 'status' | 'run'>;
12
12
 
13
13
  interface MigrationInput {
@@ -9,17 +9,19 @@ import { agentsDir } from '../agents/files.js';
9
9
  import { readAgentKey, type AgentKey } from './agent-key.js';
10
10
  import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
11
11
  import { migrationSource } from './migration-preview.js';
12
- import { migrationPayload } from './migration-source.js';
12
+ import { MigrationBlocked, migrationPayload } from './migration-source.js';
13
13
  import { localMigrationIdle } from './migration-guard.js';
14
- import { migrationUncertaintyReceipt, resolveMigrationUncertainty } from './migration-operations.js';
15
- import { localRollbackMigrationSource } from './store.js';
14
+ import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
15
+ import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
16
16
  import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
17
17
  import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
18
18
 
19
- export type MigrationAction = 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
19
+ export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
20
20
  interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
21
21
  export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
22
22
  uncertainty: string | null;
23
+ move: boolean;
24
+ grantImported: boolean;
23
25
  }
24
26
  export interface MigrationStatus {
25
27
  scope: string;
@@ -44,12 +46,23 @@ interface MigrationDeps {
44
46
 
45
47
  const conflict = (message: string): ApiError => new ApiError(message, 409);
46
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);
47
- const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty });
48
- 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' };
49
51
  const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
50
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());
51
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
+
52
64
  function callFailure(error: unknown, action: string): ApiError {
65
+ if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The mapped destination already has credentials or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
53
66
  if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
54
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);
55
68
  }
@@ -82,7 +95,7 @@ export class ConnectorMigrations {
82
95
  const keyId = this.key()?.keyId;
83
96
  const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
84
97
  const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
85
- 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';
86
99
  return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
87
100
  });
88
101
  return { scope, owner, agentId, enabled, enrolled, migrations };
@@ -111,12 +124,12 @@ export class ConnectorMigrations {
111
124
  }
112
125
 
113
126
  private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
114
- const payload = migrationPayload(migrationSource(record.sourceId, this.dir), record.oauthResource);
127
+ const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
115
128
  if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
116
129
  return payload;
117
130
  }
118
131
 
119
- 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> {
120
133
  this.check(context, record);
121
134
  let response: MigrationResponse;
122
135
  try {
@@ -127,9 +140,7 @@ export class ConnectorMigrations {
127
140
  throw callFailure(error, action);
128
141
  }
129
142
  this.check(context, record);
130
- const remote = response.migration;
131
- if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
132
- 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);
133
144
  return response;
134
145
  }
135
146
 
@@ -137,14 +148,14 @@ export class ConnectorMigrations {
137
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))) {
138
149
  return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
139
150
  }
140
- 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] });
141
152
  }
142
153
 
143
- private create(context: Context, id: string, oauthResource?: string): MigrationRecord {
154
+ private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
144
155
  const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
145
156
  return this.save(context, {
146
157
  id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
147
- 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,
148
159
  sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
149
160
  ...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
150
161
  });
@@ -154,25 +165,54 @@ export class ConnectorMigrations {
154
165
  const payload = this.source(record);
155
166
  const begun = await this.call(context, record, 'begin');
156
167
  record = this.accept(context, record, begun.migration);
157
- if (begun.migration.phase === 'prepared') {
168
+ if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
158
169
  this.save(context, { ...record, phase: 'prepared' });
159
170
  return;
160
171
  }
161
- if (begun.migration.phase !== 'preparing' || begun.receiving === undefined) throw uncertain();
172
+ if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
162
173
  const receiving = begun.receiving;
163
174
  const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
164
175
  for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
165
176
  const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
166
- const { migration } = await this.call(context, record, 'prepare', sealed);
177
+ const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
167
178
  record = this.accept(context, record, migration);
168
179
  if (migration.phase !== 'prepared') throw uncertain();
169
180
  this.save(context, { ...record, phase: 'prepared' });
170
181
  }
171
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
+
172
211
  private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
173
212
  const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
174
- if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
175
- this.save(context, { ...record, reason: uncertain ? 'requests_uncertain' : 'waiting_for_local' });
213
+ const localIdle = this.localIdle(record.sourceId);
214
+ if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
215
+ this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
176
216
  return true;
177
217
  }
178
218
 
@@ -202,7 +242,15 @@ export class ConnectorMigrations {
202
242
  }
203
243
 
204
244
  private async rollback(context: Context, record: MigrationRecord): Promise<void> {
205
- this.source(record);
245
+ if (record.phase === 'recovering') return this.finishRecovery(context, record);
246
+ 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.');
247
+ const begun = await this.call(context, record, 'begin');
248
+ if (begun.migration.grantImported === true) {
249
+ record = this.accept(context, record, begun.migration);
250
+ this.save(context, { ...record, phase: begun.migration.phase === 'active' ? 'cutover' : 'prepared', reason: 'finish_forward' });
251
+ return;
252
+ }
253
+ if (record.phase !== 'freezing') this.source(record);
206
254
  record = this.save(context, { ...record, phase: 'rollback' });
207
255
  if (this.waiting(context, record, true)) return;
208
256
  const { migration } = await this.call(context, record, 'rollback');
@@ -211,15 +259,23 @@ export class ConnectorMigrations {
211
259
  return;
212
260
  }
213
261
  this.check(context, record);
214
- if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, record.auth === 'oauth');
215
- else localRollbackMigrationSource(record.sourceId, record.auth === 'oauth', this.dir);
216
- this.save(context, { ...record, phase: 'rolled_back', reason: record.auth === 'oauth' ? 'sign_in' : null });
262
+ this.restore(context, record);
263
+
264
+ }
265
+
266
+ private restore(context: Context, record: MigrationRecord): void {
267
+ const signOut = record.auth === 'oauth' && record.move !== true;
268
+ if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, signOut);
269
+ else localRollbackMigrationSource(record.sourceId, signOut, this.dir);
270
+ this.save(context, { ...record, phase: 'rolled_back', reason: signOut ? 'sign_in' : null });
217
271
  }
218
272
 
219
273
  private async reconcile(context: Context, record: MigrationRecord): Promise<void> {
274
+ if (record.phase === 'recovering') return this.finishRecovery(context, record);
275
+ if (record.phase === 'rollback') return this.rollback(context, record);
276
+ if (record.move === true && record.phase !== 'active' && record.phase !== 'rolled_back') return this.move(context, record);
220
277
  if (record.phase === 'preparing') return this.prepare(context, record);
221
278
  if (record.phase === 'cutover') return this.cutover(context, record);
222
- if (record.phase === 'rollback') return this.rollback(context, record);
223
279
  if (record.phase === 'rolled_back') return;
224
280
  const { migration } = await this.call(context, record, 'status');
225
281
  this.accept(context, record, migration);
@@ -228,25 +284,46 @@ export class ConnectorMigrations {
228
284
  private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
229
285
  const record = migrationRecord(sourceId, this.dir);
230
286
  if (record !== null) this.check(context, record);
231
- if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource);
287
+ if (['prepare', 'move'].includes(action) && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource, action === 'move');
288
+ if (action === 'move' && record !== null && canUpgrade(record)) return this.save(context, { ...record, move: true, phase: 'freezing' });
232
289
  if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
233
290
  return record;
234
291
  }
235
292
 
236
293
  private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
294
+ if (action === 'move') return this.move(context, record);
237
295
  if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
238
296
  if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
239
297
  if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
240
298
  return this.reconcile(context, record);
241
299
  }
242
300
 
243
- private resolve(context: Context, record: MigrationRecord, receipt: string | null): void {
301
+ private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
244
302
  this.check(context, record);
245
303
  if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
304
+ localMigrationIdle(record.sourceId, this.dir, true);
305
+ if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
246
306
  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.');
247
307
  this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
248
308
  }
249
309
 
310
+ private async recoverRefresh(context: Context, record: MigrationRecord, receipt: string): Promise<void> {
311
+ 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.');
312
+ const { migration } = await this.call(context, record, 'rollback');
313
+ if (migration.phase !== 'rolled_back') throw uncertain();
314
+ record = this.save(context, { ...record, phase: 'recovering', recoveryReceipt: receipt });
315
+ await this.finishRecovery(context, record);
316
+ }
317
+
318
+ private async finishRecovery(context: Context, record: MigrationRecord): Promise<void> {
319
+ const { migration } = await this.call(context, record, 'rollback');
320
+ if (migration.phase !== 'rolled_back' || record.recoveryReceipt === undefined) throw uncertain();
321
+ localRollbackMigrationSource(record.sourceId, true, this.dir, true);
322
+ const receipt = migrationUncertaintyReceipt(record.sourceId, this.dir);
323
+ if (receipt !== null && !resolveMigrationUncertainty(record.sourceId, record.recoveryReceipt, this.dir)) throw uncertain();
324
+ this.save(context, { ...record, phase: 'rolled_back', recoveryReceipt: undefined, reason: 'sign_in' });
325
+ }
326
+
250
327
  private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
251
328
  if (oauthResource === undefined) return;
252
329
  const resource = publicOAuthResource(oauthResource);
@@ -262,14 +339,14 @@ export class ConnectorMigrations {
262
339
 
263
340
  async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
264
341
  if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
265
- const context = this.context(scope, action === 'prepare' || action === 'cutover');
342
+ const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
266
343
  this.validateIntent(sourceId, action, operationId, uncertainty);
267
344
  this.validateResource(sourceId, action, oauthResource);
268
345
  this.running = true;
269
346
  try {
270
347
  const record = this.obtain(context, sourceId, action, oauthResource);
271
348
  if (record !== null) {
272
- if (action === 'resolve') this.resolve(context, record, uncertainty);
349
+ if (action === 'resolve') await this.resolve(context, record, uncertainty);
273
350
  else await this.perform(context, record, action);
274
351
  }
275
352
  this.check(context);
@@ -277,7 +354,7 @@ export class ConnectorMigrations {
277
354
  } catch (error) {
278
355
  const record = migrationRecord(sourceId, this.dir);
279
356
  if (record !== null) {
280
- try { this.save(context, { ...record, reason: 'unavailable' }); } catch {
357
+ try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
281
358
  log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
282
359
  }
283
360
  }
@@ -3,10 +3,10 @@ import type { FetchLike } from '@metro-labs/core/stations/oauth';
3
3
  import { agentsDir } from '../agents/files.js';
4
4
  import type { AgentKey } from './agent-key.js';
5
5
  import { migrationRecord, migrationRecords } from './migration-state.js';
6
- import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
6
+ import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, 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,27 +16,29 @@ 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
 
29
- export function assertLocalMigrationEdit(id: string, name: string | undefined, dir = agentsDir()): void {
30
- if (migrationRecords(dir).some((record) => locked(record.phase) && (record.sourceId === id || record.name === name))) throw refused();
29
+ export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
30
+ if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw refused();
31
31
  }
32
32
 
33
33
  export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
34
34
  const records = migrationRecords(dir).filter((record) => locked(record.phase));
35
- return rows.filter((row) => !records.some((record) => record.sourceId === row.id || record.name === row.name));
35
+ return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
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
+ const record = migrationSource(id, dir);
40
+ if (record !== null && retired(record)) drainMigrationListeners(id, dir);
41
+ return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
40
42
  }
41
43
 
42
44
  export interface LocalMigrationOperation extends MigrationOperation {
@@ -45,11 +47,11 @@ export interface LocalMigrationOperation extends MigrationOperation {
45
47
  close: () => void;
46
48
  }
47
49
 
48
- export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false, name?: string): LocalMigrationOperation {
50
+ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
49
51
  let closed = false;
50
52
  const check = (): void => {
51
53
  if (closed) throw refused();
52
- if (edit) assertLocalMigrationEdit(id, name, dir);
54
+ if (edit) assertLocalMigrationEdit(id, dir);
53
55
  else assertLocalMigrationUse(id, dir);
54
56
  };
55
57
  check();
@@ -61,13 +63,15 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
61
63
  return {
62
64
  check,
63
65
  dispatch,
66
+ passiveListener: tracked.passiveListener,
67
+ persistedRefresh: tracked.persistedRefresh,
64
68
  fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
65
69
  close: () => { closed = true; tracked.close(); },
66
70
  };
67
71
  }
68
72
 
69
- export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false, name?: string): Promise<T> {
70
- const operation = beginLocalMigrationOperation(id, dir, edit, name);
73
+ export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
74
+ const operation = beginLocalMigrationOperation(id, dir, edit);
71
75
  try { return await run(operation); }
72
76
  finally { operation.close(); }
73
77
  }
@@ -14,14 +14,23 @@ interface RequestOperation {
14
14
  lease: string;
15
15
  durable: boolean;
16
16
  abandoned: boolean;
17
+ refresh: boolean;
18
+ }
19
+
20
+ interface PassiveListener {
21
+ idle: () => boolean;
22
+ stop: () => void;
17
23
  }
18
24
 
19
25
  export interface MigrationOperation {
20
- dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean }) => Promise<Response>;
26
+ passiveListener: (listener: PassiveListener) => void;
27
+ dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
21
28
  close: () => void;
29
+ persistedRefresh: () => void;
22
30
  }
23
31
 
24
32
  const leases = new Map<string, Set<string>>();
33
+ const listeners = new Map<string, PassiveListener>();
25
34
  const requests = new Map<string, RequestOperation>();
26
35
  const held = new Map<string, RequestOperation>();
27
36
  const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
@@ -57,7 +66,7 @@ function pendingOf(text: string): string[] {
57
66
  const value: unknown = JSON.parse(text);
58
67
  if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.pending) || value.pending.length > 10_000) throw unavailable();
59
68
  const ids: unknown[] = value.pending;
60
- if (!ids.every((id): id is string => typeof id === 'string' && /^[a-f0-9]{32}$/.test(id))) throw unavailable();
69
+ if (!ids.every((id): id is string => typeof id === 'string' && /^r?[a-f0-9]{32}$/.test(id))) throw unavailable();
61
70
  if (new Set(ids).size !== ids.length) throw unavailable();
62
71
  return ids;
63
72
  }
@@ -101,6 +110,17 @@ export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()):
101
110
  if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
102
111
  }
103
112
 
113
+ export function drainMigrationListeners(source: string, dir: string): void {
114
+ const active = [...(leases.get(keyOf(source, dir)) ?? [])];
115
+ if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
116
+ if (readPending(source, dir).some((id) => requests.has(id))) return;
117
+ for (const lease of active) {
118
+ const listener = listeners.get(lease);
119
+ listeners.delete(lease);
120
+ listener?.stop();
121
+ }
122
+ }
123
+
104
124
  export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
105
125
  if (leases.has(keyOf(source, dir))) return false;
106
126
  return !protectedSource || readPending(source, dir).length === 0;
@@ -114,7 +134,8 @@ export function resolveMigrationUncertainty(source: string, expectedReceipt: str
114
134
  return true;
115
135
  }
116
136
 
117
- function finish(request: RequestOperation, completed: boolean): void {
137
+ function finish(request: RequestOperation, completed: boolean, persisted = false): void {
138
+ if (request.refresh && completed && !persisted) return;
118
139
  if (request.abandoned) return;
119
140
  requests.delete(request.id);
120
141
  if (completed) held.delete(request.id);
@@ -153,10 +174,10 @@ function observed(response: Response, request: RequestOperation): Response {
153
174
  return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
154
175
  }
155
176
 
156
- async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, send: () => Promise<Response>): Promise<Response> {
177
+ async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, refresh: boolean, send: () => Promise<Response>): Promise<Response> {
157
178
  if (protectedSource) assertMigrationOutcomeKnown(source, dir);
158
179
  if (!effectful) return send();
159
- const request: RequestOperation = { id: randomBytes(16).toString('hex'), source, dir, lease, durable: false, abandoned: false };
180
+ const request: RequestOperation = { id: `${refresh ? 'r' : ''}${randomBytes(16).toString('hex')}`, source, dir, lease, durable: false, abandoned: false, refresh };
160
181
  if (protectedSource) {
161
182
  const pending = readPending(source, dir);
162
183
  if (pending.length >= 10_000) throw unavailable();
@@ -178,13 +199,18 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
178
199
  leases.set(key, active);
179
200
  let closed = false;
180
201
  return {
202
+ passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
181
203
  dispatch: (send, options = {}) => {
182
204
  if (closed) return Promise.reject(uncertain());
183
- return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, send);
205
+ return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
206
+ },
207
+ persistedRefresh: () => {
208
+ for (const request of held.values()) if (request.lease === lease && request.refresh) finish(request, true, true);
184
209
  },
185
210
  close: () => {
186
211
  if (closed) return;
187
212
  closed = true;
213
+ listeners.delete(lease);
188
214
  for (const request of held.values()) if (request.lease === lease) {
189
215
  finish(request, false);
190
216
  held.delete(request.id);
@@ -194,3 +220,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
194
220
  },
195
221
  };
196
222
  }
223
+
224
+ export function migrationRefreshUncertain(source: string, dir = agentsDir()): boolean {
225
+ return readPending(source, dir).some((id) => id.startsWith('r') && !requests.has(id));
226
+ }