@stage-labs/metro 0.1.0-beta.288 → 0.1.0-beta.289
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +138 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +20 -31
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +34 -8
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +17 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.289",
|
|
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",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ConnectorSyncReceipt {
|
|
2
|
+
state: 'waiting' | 'received' | 'expired' | 'unavailable';
|
|
3
|
+
lastReceivedAt: number | null;
|
|
4
|
+
leaseExpiresAt: number | null;
|
|
5
|
+
counts: { total: number; ready: number; unavailable: number } | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
|
|
11
|
+
enrolled: boolean;
|
|
12
|
+
owner: string | null;
|
|
13
|
+
agentId: string | null;
|
|
14
|
+
scope: string;
|
|
15
|
+
liveEnabled: boolean;
|
|
16
|
+
state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
|
|
17
|
+
}
|
|
@@ -59,6 +59,7 @@ function refusal(status: number, value: unknown): ApiError {
|
|
|
59
59
|
interface AgentCallOptions {
|
|
60
60
|
answerMax?: number;
|
|
61
61
|
timeoutMs?: number;
|
|
62
|
+
signal?: AbortSignal;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
function signedCall(api: MetroApi, key: AgentKey, path: string, body: Record<string, unknown> | undefined): { url: URL; method: string; text: string; authorization: string } {
|
|
@@ -78,7 +79,7 @@ export async function agentCall(api: MetroApi, key: AgentKey, path: string, body
|
|
|
78
79
|
headers: { authorization, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
79
80
|
...(body === undefined ? {} : { body: text }),
|
|
80
81
|
redirect: 'error',
|
|
81
|
-
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
82
|
+
signal: AbortSignal.any([AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), ...(options.signal === undefined ? [] : [options.signal])]),
|
|
82
83
|
});
|
|
83
84
|
} catch {
|
|
84
85
|
throw unreachable();
|
|
@@ -8,6 +8,7 @@ import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/
|
|
|
8
8
|
import { parseConnectorUrl } from '@metro-labs/http/connector-url';
|
|
9
9
|
import { agentsDir } from '../agents/files.js';
|
|
10
10
|
import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
|
|
11
|
+
import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
11
12
|
import { readAgentKey } from './agent-key.js';
|
|
12
13
|
import { migrationOrigin } from './migration-url.js';
|
|
13
14
|
|
|
@@ -191,7 +192,7 @@ function previewRows(dir: string): MigrationConnector[] {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
|
|
194
|
-
const enabled =
|
|
195
|
+
const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
|
|
195
196
|
const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
196
197
|
if (!enabled) return { enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
|
|
197
198
|
const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
6
|
+
|
|
7
|
+
const PATH = '/api/connectors/sync';
|
|
8
|
+
|
|
9
|
+
export interface ConnectorSyncApiDeps {
|
|
10
|
+
status: () => ConnectorSyncStatus;
|
|
11
|
+
set: (enabled: boolean, scope: string) => ConnectorSyncStatus;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function handleConnectorSyncRequest(req: IncomingMessage, res: ServerResponse, deps: ConnectorSyncApiDeps): boolean {
|
|
15
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'PUT'] }, admin: true, label: 'connector-sync-api' }, async (session) => {
|
|
16
|
+
const body = req.method === 'PUT' ? await readJsonBody(req) : null;
|
|
17
|
+
const current = deps.status();
|
|
18
|
+
if (current.owner === null || current.owner !== session.subject) throw new ApiError('This agent belongs to another organization.', 403);
|
|
19
|
+
if (req.method !== 'PUT') return current;
|
|
20
|
+
if (!isRecord(body) || typeof body.enabled !== 'boolean' || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope) || Object.keys(body).some((key) => key !== 'enabled' && key !== 'scope')) {
|
|
21
|
+
throw new ApiError('Sync needs an enabled flag and the current enrollment scope.', 400);
|
|
22
|
+
}
|
|
23
|
+
return deps.set(body.enabled, body.scope);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
+
import { log } from '@metro-labs/core/log';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { METRO_API, type MetroApi } from './metro-api.js';
|
|
7
|
+
import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
|
|
8
|
+
import { OrgConnectorRuntime } from './org-runtime.js';
|
|
9
|
+
import { connectorSyncScope, disableSavedSync, saveSyncSetting, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
10
|
+
import type { RelaySession } from './relay-session.js';
|
|
11
|
+
|
|
12
|
+
type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
|
|
13
|
+
type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
|
|
14
|
+
const POLL_MS = 60_000;
|
|
15
|
+
const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
16
|
+
const emptyReceipt = { lastReceivedAt: null, leaseExpiresAt: null, counts: null };
|
|
17
|
+
|
|
18
|
+
export class OrgConnectorControl {
|
|
19
|
+
private readonly dir: string;
|
|
20
|
+
private readonly api: MetroApi;
|
|
21
|
+
private sync: OrgConnectorSync | null = null;
|
|
22
|
+
private runtime: OrgConnectorRuntime | null = null;
|
|
23
|
+
private scope: string | null = null;
|
|
24
|
+
private operation = randomBytes(16).toString('hex');
|
|
25
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly deps: ControlDeps) {
|
|
28
|
+
this.dir = deps.dir ?? agentsDir();
|
|
29
|
+
this.api = deps.api ?? METRO_API;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private context(): Context {
|
|
33
|
+
const owner = this.deps.owner();
|
|
34
|
+
const key = this.deps.key();
|
|
35
|
+
const enrollment = key?.enrollment;
|
|
36
|
+
const agentId = owner !== null && enrollment?.organization === owner ? enrollment.server : null;
|
|
37
|
+
const scope = connectorSyncScope(owner, key);
|
|
38
|
+
return { owner, agentId, scope, enrolled: agentId !== null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private clear(): void {
|
|
42
|
+
this.sync?.invalidate();
|
|
43
|
+
this.sync = null;
|
|
44
|
+
const runtime = this.runtime;
|
|
45
|
+
this.runtime = null;
|
|
46
|
+
try {
|
|
47
|
+
runtime?.stop();
|
|
48
|
+
} catch {
|
|
49
|
+
log.warn('organization connectors: stopped runtime publication could not be cleared');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private reconcile(start: boolean): { context: Context; setting: ReturnType<typeof syncSetting> } {
|
|
54
|
+
const context = this.context();
|
|
55
|
+
if (this.scope !== null && this.scope !== context.scope) {
|
|
56
|
+
this.clear();
|
|
57
|
+
this.operation = randomBytes(16).toString('hex');
|
|
58
|
+
disableSavedSync(this.dir);
|
|
59
|
+
}
|
|
60
|
+
this.scope = context.scope;
|
|
61
|
+
const setting = syncSetting(this.dir, context.scope);
|
|
62
|
+
if (setting.source === 'scope_changed') disableSavedSync(this.dir);
|
|
63
|
+
if (!setting.enabled || !context.enrolled) this.clear();
|
|
64
|
+
else if (start && this.sync === null) {
|
|
65
|
+
const scope = context.scope;
|
|
66
|
+
this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
|
|
67
|
+
if (liveEnabled()) this.runtime = new OrgConnectorRuntime(this.sync, { enabled: liveEnabled });
|
|
68
|
+
}
|
|
69
|
+
return { context, setting };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
status(): ConnectorSyncStatus {
|
|
73
|
+
try {
|
|
74
|
+
const { context, setting } = this.reconcile(false);
|
|
75
|
+
const receipt = this.sync?.status() ?? { state: 'waiting' as const, ...emptyReceipt };
|
|
76
|
+
const state = !setting.enabled ? 'off' : !context.enrolled ? 'not_enrolled' : receipt.state;
|
|
77
|
+
const scope = createHash('sha256').update(`${context.scope}:${this.operation}`).digest('hex');
|
|
78
|
+
return { ...context, ...setting, ...receipt, scope, state, liveEnabled: liveEnabled() };
|
|
79
|
+
} catch {
|
|
80
|
+
this.clear();
|
|
81
|
+
throw new ApiError('Metro could not read the connector sync status.', 503);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
set(enabled: boolean, scope: string): ConnectorSyncStatus {
|
|
86
|
+
const current = this.status();
|
|
87
|
+
if (scope !== current.scope) throw new ApiError('The organization or enrollment changed. Refresh the sync status before trying again.', 409);
|
|
88
|
+
if (enabled && !current.enrolled) throw new ApiError('Enroll this agent with its organization before enabling sync.', 409);
|
|
89
|
+
if (enabled && liveEnabled()) throw new ApiError('Live organization tools are enabled separately on this server. Disable that setting before enabling memory-only sync here.', 409);
|
|
90
|
+
saveSyncSetting(this.dir, enabled, this.context().scope);
|
|
91
|
+
if (!enabled) this.clear();
|
|
92
|
+
else this.tick();
|
|
93
|
+
return this.status();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async poll(): Promise<void> {
|
|
97
|
+
if (this.sync === null && !syncOptedIn(this.dir)) return;
|
|
98
|
+
try {
|
|
99
|
+
this.reconcile(true);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.clear();
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
await this.sync?.sync();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private tick(): void {
|
|
108
|
+
this.poll().catch(() => {
|
|
109
|
+
log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
start(): void {
|
|
114
|
+
if (this.timer !== null) return;
|
|
115
|
+
this.tick();
|
|
116
|
+
this.timer = setInterval(() => { this.tick(); }, POLL_MS);
|
|
117
|
+
this.timer.unref();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
invalidate(): void {
|
|
121
|
+
this.operation = randomBytes(16).toString('hex');
|
|
122
|
+
this.clear();
|
|
123
|
+
disableSavedSync(this.dir);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stop(): void {
|
|
127
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
128
|
+
this.timer = null;
|
|
129
|
+
this.clear();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
open(id: string): RelaySession {
|
|
133
|
+
if (this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
134
|
+
this.reconcile(false);
|
|
135
|
+
if (!liveEnabled() || this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
136
|
+
return this.runtime.open(id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AgentKey } from './agent-key.js';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
6
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
8
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
9
|
+
|
|
10
|
+
interface SavedSetting {
|
|
11
|
+
version: 1;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
scope: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Stored = SavedSetting | 'absent' | 'invalid';
|
|
17
|
+
const pathOf = (dir: string): string => join(dir, 'org-connector-sync.json');
|
|
18
|
+
|
|
19
|
+
function read(dir: string): Stored {
|
|
20
|
+
let raw: unknown;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(readFileSync(pathOf(dir), 'utf8')) as unknown;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return isRecord(err) && err.code === 'ENOENT' ? 'absent' : 'invalid';
|
|
25
|
+
}
|
|
26
|
+
if (!isRecord(raw) || raw.version !== 1 || typeof raw.enabled !== 'boolean' || typeof raw.scope !== 'string' || !/^[a-f0-9]{64}$/.test(raw.scope)) return 'invalid';
|
|
27
|
+
return { version: 1, enabled: raw.enabled, scope: raw.scope };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function connectorSyncScope(owner: string | null, key: AgentKey | null): string {
|
|
31
|
+
const agentId = owner !== null && key?.enrollment?.organization === owner ? key.enrollment.server : null;
|
|
32
|
+
return createHash('sha256').update(JSON.stringify([owner, agentId, key?.keyId ?? null, key?.sealingKey ?? null])).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function syncOptedIn(dir: string): boolean {
|
|
36
|
+
const saved = read(dir);
|
|
37
|
+
if (saved === 'absent') return process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
|
|
38
|
+
return saved !== 'invalid' && saved.enabled;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function syncSetting(dir: string, scope: string): Pick<ConnectorSyncStatus, 'enabled' | 'source'> {
|
|
42
|
+
const saved = read(dir);
|
|
43
|
+
if (saved === 'invalid') return { enabled: false, source: 'invalid' };
|
|
44
|
+
if (saved === 'absent') return process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true' ? { enabled: true, source: 'environment' } : { enabled: false, source: 'default' };
|
|
45
|
+
if (saved.scope !== scope) return { enabled: false, source: 'scope_changed' };
|
|
46
|
+
return { enabled: saved.enabled, source: 'saved' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveSyncSetting(dir: string, enabled: boolean, scope: string): void {
|
|
50
|
+
try {
|
|
51
|
+
writeSecure(pathOf(dir), `${JSON.stringify({ version: 1, enabled, scope })}\n`);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new ApiError('Metro could not save the connector sync setting.', 503);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function disableSavedSync(dir: string): void {
|
|
58
|
+
const saved = read(dir);
|
|
59
|
+
if (typeof saved !== 'string' && saved.enabled) saveSyncSetting(dir, false, saved.scope);
|
|
60
|
+
}
|
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
import { log } from '@metro-labs/core/log';
|
|
2
1
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { OrgConnectorRuntime } from './org-runtime.js';
|
|
2
|
+
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
+
import { OrgConnectorControl } from './org-sync-control.js';
|
|
6
4
|
import { isOrgRelayId } from './relay-id.js';
|
|
7
5
|
import type { RelaySession } from './relay-session.js';
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
let active: OrgConnectorSync | null = null;
|
|
11
|
-
let runtime: OrgConnectorRuntime | null = null;
|
|
12
|
-
let timer: ReturnType<typeof setInterval> | null = null;
|
|
7
|
+
let active: OrgConnectorControl | null = null;
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
function control(): OrgConnectorControl {
|
|
10
|
+
if (active === null) throw new ApiError('Organization connector sync is unavailable. Restart Metro before trying again.', 503);
|
|
11
|
+
return active;
|
|
12
|
+
}
|
|
15
13
|
|
|
16
14
|
export function orgConnectorRelaySession(id: string): RelaySession | null {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
return isOrgRelayId(id) ? control().open(id) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function orgConnectorSyncStatus(): ConnectorSyncStatus {
|
|
19
|
+
return control().status();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setOrgConnectorSync(enabled: boolean, scope: string): ConnectorSyncStatus {
|
|
23
|
+
return control().set(enabled, scope);
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function invalidateOrgConnectorSync(): void {
|
|
@@ -24,27 +28,12 @@ export function invalidateOrgConnectorSync(): void {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function stopOrgConnectorSync(): void {
|
|
27
|
-
|
|
28
|
-
timer = null;
|
|
29
|
-
runtime?.stop();
|
|
30
|
-
runtime = null;
|
|
31
|
-
active?.invalidate();
|
|
31
|
+
active?.stop();
|
|
32
32
|
active = null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function watchOrgConnectorSync(deps:
|
|
35
|
+
export function watchOrgConnectorSync(deps: ConstructorParameters<typeof OrgConnectorControl>[0]): void {
|
|
36
36
|
stopOrgConnectorSync();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const sync = new OrgConnectorSync({ ...deps, enabled, api: deps.api ?? METRO_API });
|
|
40
|
-
active = sync;
|
|
41
|
-
if (liveEnabled()) runtime = new OrgConnectorRuntime(sync, { enabled: liveEnabled });
|
|
42
|
-
const tick = (): void => {
|
|
43
|
-
sync.sync().catch((err: unknown) => {
|
|
44
|
-
log.warn({ status: err instanceof ApiError ? err.status : 503 }, 'organization connectors: memory sync failed; cached credentials were cleared');
|
|
45
|
-
});
|
|
46
|
-
};
|
|
47
|
-
tick();
|
|
48
|
-
timer = setInterval(tick, POLL_MS);
|
|
49
|
-
timer.unref();
|
|
37
|
+
active = new OrgConnectorControl(deps);
|
|
38
|
+
active.start();
|
|
50
39
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import type { ConnectorSyncReceipt } from '@metro-labs/core/connector-sync';
|
|
3
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
5
|
import { AGENT_CLOCK_SKEW_MS } from '@metro-labs/http/agent-signature';
|
|
5
6
|
import { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, openConnectorDelivery, type DeliveryContext, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
@@ -39,6 +40,9 @@ function lease(snapshot: DeliverySnapshot, started: number, elapsed: number, now
|
|
|
39
40
|
export class OrgConnectorSync {
|
|
40
41
|
private snapshot: DeliverySnapshot | null = null;
|
|
41
42
|
private token: string | null = null;
|
|
43
|
+
private controller: AbortController | null = null;
|
|
44
|
+
private receiptToken: string | null = null;
|
|
45
|
+
private receipt: ConnectorSyncReceipt = { state: 'waiting', lastReceivedAt: null, leaseExpiresAt: null, counts: null };
|
|
42
46
|
private deadline = 0;
|
|
43
47
|
private wallDeadline = 0;
|
|
44
48
|
private generation = 0;
|
|
@@ -77,7 +81,7 @@ export class OrgConnectorSync {
|
|
|
77
81
|
const enrollment = key?.enrollment;
|
|
78
82
|
if (owner === null || key === null || enrollment === null || enrollment === undefined || enrollment.organization !== owner) return null;
|
|
79
83
|
const context = { owner, agent: enrollment.server, keyId: key.keyId };
|
|
80
|
-
return { key, context, token: JSON.stringify(context) };
|
|
84
|
+
return { key, context, token: JSON.stringify([context, key.sealingKey]) };
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
private forget(): void {
|
|
@@ -88,8 +92,14 @@ export class OrgConnectorSync {
|
|
|
88
92
|
this.expiry = null;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
invalidate(): void {
|
|
95
|
+
invalidate(reason: 'waiting' | 'expired' | 'unavailable' = 'waiting'): void {
|
|
92
96
|
this.generation += 1;
|
|
97
|
+
this.controller?.abort();
|
|
98
|
+
this.controller = null;
|
|
99
|
+
if (reason === 'waiting') {
|
|
100
|
+
this.receiptToken = null;
|
|
101
|
+
this.receipt = { state: reason, lastReceivedAt: null, leaseExpiresAt: null, counts: null };
|
|
102
|
+
} else this.receipt = { ...this.receipt, state: reason, counts: null };
|
|
93
103
|
this.token = null;
|
|
94
104
|
this.forget();
|
|
95
105
|
this.notify();
|
|
@@ -97,7 +107,8 @@ export class OrgConnectorSync {
|
|
|
97
107
|
|
|
98
108
|
private validate(identity: Identity | null): void {
|
|
99
109
|
if (this.token === null) return;
|
|
100
|
-
if (identity === null || identity.token !== this.token
|
|
110
|
+
if (identity === null || identity.token !== this.token) this.invalidate();
|
|
111
|
+
else if (this.snapshot !== null && (this.monotonic() >= this.deadline || this.deps.api.now() >= this.wallDeadline)) this.invalidate('expired');
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
read(): DeliverySnapshot | null {
|
|
@@ -111,6 +122,16 @@ export class OrgConnectorSync {
|
|
|
111
122
|
return this.snapshot === null ? null : structuredClone(this.snapshot);
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
status(): ConnectorSyncReceipt {
|
|
126
|
+
this.read();
|
|
127
|
+
try {
|
|
128
|
+
if (this.receiptToken !== null && this.identity()?.token !== this.receiptToken) this.invalidate();
|
|
129
|
+
} catch {
|
|
130
|
+
this.invalidate();
|
|
131
|
+
}
|
|
132
|
+
return structuredClone(this.receipt);
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
async sync(refresh?: RefreshRequest): Promise<void> {
|
|
115
136
|
let identity: Identity | null;
|
|
116
137
|
try {
|
|
@@ -124,8 +145,9 @@ export class OrgConnectorSync {
|
|
|
124
145
|
if (this.running !== null) return refresh === undefined ? this.running : this.running.then(() => this.sync(refresh));
|
|
125
146
|
this.token = identity.token;
|
|
126
147
|
const generation = this.generation;
|
|
127
|
-
this.
|
|
128
|
-
|
|
148
|
+
this.controller = new AbortController();
|
|
149
|
+
this.running = this.fetch(identity, refresh, generation, this.controller.signal).catch((err: unknown) => {
|
|
150
|
+
if (generation === this.generation) this.invalidate('unavailable');
|
|
129
151
|
throw err;
|
|
130
152
|
}).finally(() => { this.running = null; });
|
|
131
153
|
return this.running;
|
|
@@ -140,12 +162,12 @@ export class OrgConnectorSync {
|
|
|
140
162
|
return this.sync(refresh);
|
|
141
163
|
}
|
|
142
164
|
|
|
143
|
-
private async fetch(identity: Identity, refresh: RefreshRequest | undefined, generation: number): Promise<void> {
|
|
165
|
+
private async fetch(identity: Identity, refresh: RefreshRequest | undefined, generation: number, signal: AbortSignal): Promise<void> {
|
|
144
166
|
const requestId = randomBytes(32).toString('base64url');
|
|
145
167
|
const context = { ...identity.context, requestId };
|
|
146
168
|
const started = this.deps.api.now();
|
|
147
169
|
const clock = this.monotonic();
|
|
148
|
-
const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000 });
|
|
170
|
+
const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
|
|
149
171
|
this.validate(this.identity());
|
|
150
172
|
if (generation !== this.generation || this.token !== identity.token) throw invalid();
|
|
151
173
|
const snapshot = openConnectorDelivery(identity.key.sealing, context, response.sealed);
|
|
@@ -154,7 +176,11 @@ export class OrgConnectorSync {
|
|
|
154
176
|
this.snapshot = snapshot;
|
|
155
177
|
this.deadline = this.monotonic() + remaining;
|
|
156
178
|
this.wallDeadline = this.deps.api.now() + remaining;
|
|
157
|
-
this.
|
|
179
|
+
this.receiptToken = identity.token;
|
|
180
|
+
const total = snapshot.connectors.length;
|
|
181
|
+
const ready = snapshot.connectors.filter((row) => row.state === 'ready').length;
|
|
182
|
+
this.receipt = { state: 'received', lastReceivedAt: this.deps.api.now(), leaseExpiresAt: this.wallDeadline, counts: { total, ready, unavailable: total - ready } };
|
|
183
|
+
this.expiry = setTimeout(() => { this.invalidate('expired'); }, remaining);
|
|
158
184
|
this.expiry.unref();
|
|
159
185
|
this.notify();
|
|
160
186
|
}
|
|
@@ -7,7 +7,7 @@ import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
|
7
7
|
import { recentSenders } from '../agents/senders.js';
|
|
8
8
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
9
9
|
import { publishLocalConnectors } from '../connectors/publication.js';
|
|
10
|
-
import { invalidateOrgConnectorSync, orgConnectorRelaySession } from '../connectors/org-sync-watch.js';
|
|
10
|
+
import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
11
11
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
12
12
|
import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
|
|
13
13
|
import type { SessionApis } from './session-apis.js';
|
|
@@ -205,6 +205,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
205
205
|
agentApi: agentApi(deps),
|
|
206
206
|
bundleApi: bundleApi(deps),
|
|
207
207
|
connectorApi,
|
|
208
|
+
connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
|
|
208
209
|
connectorTransfers: new ConnectorTransfers({ read: readLocalConnectors, copy: copyConnectorRows }),
|
|
209
210
|
enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
|
|
210
211
|
relayApi,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '../connectors/api.js';
|
|
9
9
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
10
10
|
import type { ConnectorTransfers } from '../connectors/transfer-api.js';
|
|
11
|
+
import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
|
|
11
12
|
import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
|
|
12
13
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
13
14
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
@@ -39,6 +40,7 @@ export interface SessionApis {
|
|
|
39
40
|
connectorApi?: ConnectorApiDeps;
|
|
40
41
|
connectorTransfers?: ConnectorTransfers;
|
|
41
42
|
enrollApi?: EnrollApiDeps;
|
|
43
|
+
connectorSyncApi?: ConnectorSyncApiDeps;
|
|
42
44
|
relayApi?: RelayApiDeps;
|
|
43
45
|
mode?: () => ModeInfo;
|
|
44
46
|
}
|
|
@@ -57,6 +59,7 @@ export function handleSessionApis(
|
|
|
57
59
|
() => handleApprovalsRequest(req, res),
|
|
58
60
|
...when(apis.connectorTransfers, (d) => d.handle(req, res)),
|
|
59
61
|
...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
|
|
62
|
+
...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
|
|
60
63
|
...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
|
|
61
64
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
62
65
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
package/runtime/runtime.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ConnectorSyncReceipt {
|
|
2
|
+
state: 'waiting' | 'received' | 'expired' | 'unavailable';
|
|
3
|
+
lastReceivedAt: number | null;
|
|
4
|
+
leaseExpiresAt: number | null;
|
|
5
|
+
counts: { total: number; ready: number; unavailable: number } | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
|
|
11
|
+
enrolled: boolean;
|
|
12
|
+
owner: string | null;
|
|
13
|
+
agentId: string | null;
|
|
14
|
+
scope: string;
|
|
15
|
+
liveEnabled: boolean;
|
|
16
|
+
state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
|
|
17
|
+
}
|