@stage-labs/metro 0.1.0-beta.301 → 0.1.0-beta.303
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -34
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
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.303",
|
|
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",
|
|
@@ -34,7 +34,10 @@ function ensureUser(): AgentUser | null {
|
|
|
34
34
|
openMetroHome();
|
|
35
35
|
forgetAgentUser();
|
|
36
36
|
const user = agentUser();
|
|
37
|
-
if (user !== null)
|
|
37
|
+
if (user !== null) {
|
|
38
|
+
run(...asUser(user, 'chmod', ['711', user.home]));
|
|
39
|
+
run(...asUser(user, 'sh', ['-c', 'test ! -d "$1" || chmod go-w "$1"', 'sh', join(user.home, '.metro')]));
|
|
40
|
+
}
|
|
38
41
|
return user;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -5,7 +5,7 @@ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } fr
|
|
|
5
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { parseId } from '@metro-labs/core/ids';
|
|
8
|
-
import type { LoadedAccount, LoadedAgent
|
|
8
|
+
import type { LoadedAccount, LoadedAgent } from '../stations/materialize.js';
|
|
9
9
|
import { STATIONS } from '@metro-labs/core/station-names';
|
|
10
10
|
import { approversOf } from './files.js';
|
|
11
11
|
|
|
@@ -17,7 +17,7 @@ const STATION_NAMES = new Set<string>(STATIONS);
|
|
|
17
17
|
export interface AgentBundle {
|
|
18
18
|
version: 1;
|
|
19
19
|
agent: { id: string; name: string; stations: LoadedAccount[] };
|
|
20
|
-
connectors:
|
|
20
|
+
connectors: [];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
|
|
@@ -68,13 +68,6 @@ function stationOf(raw: unknown): LoadedAccount {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function connectorOf(raw: unknown): LoadedConnector {
|
|
72
|
-
if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
|
|
73
|
-
throw bad('a connector is missing its id, name or url');
|
|
74
|
-
if (!isRecord(raw.config)) throw bad('a connector has no config');
|
|
75
|
-
return { id: raw.id, name: raw.name, url: raw.url, config: raw.config };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
71
|
function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
79
72
|
if (!isRecord(raw)) throw bad('not a v1 agent bundle');
|
|
80
73
|
const { id, name, stations } = raw;
|
|
@@ -85,8 +78,7 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
|
85
78
|
|
|
86
79
|
export function parseBundle(raw: unknown): AgentBundle {
|
|
87
80
|
if (!isRecord(raw) || raw.version !== 1) throw bad('not a v1 agent bundle');
|
|
88
|
-
|
|
89
|
-
return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
|
|
81
|
+
return { version: 1, agent: agentOf(raw.agent), connectors: [] };
|
|
90
82
|
}
|
|
91
83
|
|
|
92
84
|
async function answer(req: IncomingMessage, deps: BundleApiDeps, path: string): Promise<unknown> {
|
|
@@ -21,10 +21,10 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import {
|
|
24
|
+
import { connectorsReady, publishConnectors } from '../connectors/publication.js';
|
|
25
|
+
import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
|
|
25
26
|
import { readAgentKey } from '../connectors/agent-key.js';
|
|
26
27
|
import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
27
|
-
import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
|
|
28
28
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
29
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
30
30
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
@@ -140,10 +140,8 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
140
140
|
async function main(): Promise<void> {
|
|
141
141
|
applyLocalOwner();
|
|
142
142
|
installBearerSessions(agentsDir(), localOwner);
|
|
143
|
+
adoptOrgServerKeys();
|
|
143
144
|
watchPolicySnapshot();
|
|
144
|
-
loadConnectorPolicies().catch((err: unknown) => {
|
|
145
|
-
log.warn({ err: errMsg(err) }, 'connector policy: loading the policies failed');
|
|
146
|
-
});
|
|
147
145
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
148
146
|
await materializeFrom(fileSource);
|
|
149
147
|
ensureStationDeps();
|
|
@@ -160,7 +158,6 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
160
158
|
);
|
|
161
159
|
metroMcp.startInbound();
|
|
162
160
|
startPromptExpiry();
|
|
163
|
-
publishLocalConnectors(readLocalConnectors());
|
|
164
161
|
startUploadReaper();
|
|
165
162
|
startAttachReaper();
|
|
166
163
|
watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
|
|
@@ -195,7 +192,7 @@ async function startClaude(): Promise<void> {
|
|
|
195
192
|
ensureMetroPlugin()
|
|
196
193
|
.then((outcome) => {
|
|
197
194
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
198
|
-
if (outcome !== 'skipped')
|
|
195
|
+
if (outcome !== 'skipped') publishConnectors();
|
|
199
196
|
})
|
|
200
197
|
.catch((err: unknown) => {
|
|
201
198
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
@@ -203,7 +200,7 @@ async function startClaude(): Promise<void> {
|
|
|
203
200
|
tryClaudeSetup();
|
|
204
201
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
205
202
|
tryMemoryJob(memoryRoutine());
|
|
206
|
-
watchSession();
|
|
203
|
+
watchSession({ connectorsReady: () => connectorsReady() });
|
|
207
204
|
}
|
|
208
205
|
|
|
209
206
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -49,6 +49,7 @@ export interface SessionDeps {
|
|
|
49
49
|
now?: () => number;
|
|
50
50
|
version?: string;
|
|
51
51
|
prepare?: (runner: HarnessRunner) => Promise<void>;
|
|
52
|
+
connectorsReady?: () => boolean;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface SessionStatus {
|
|
@@ -316,9 +317,10 @@ function strike(now: number): boolean {
|
|
|
316
317
|
return true;
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
|
|
320
|
+
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused' | 'waiting';
|
|
320
321
|
|
|
321
322
|
function holdsBack(deps: SessionDeps, now: number): Ensured | null {
|
|
323
|
+
if (deps.connectorsReady?.() === false) return 'waiting';
|
|
322
324
|
if (now < memory.pausedUntil) return 'paused';
|
|
323
325
|
if (sessionBlocked(deps) !== null) return 'blocked';
|
|
324
326
|
return strike(now) ? 'paused' : null;
|
|
@@ -2,7 +2,6 @@ import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
|
2
2
|
import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
|
|
3
3
|
import { serverKeysOf } from './plugin-sync.js';
|
|
4
4
|
import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
|
|
5
|
-
import { isOrgRelayId } from './relay-id.js';
|
|
6
5
|
import type { BlockedReason } from './relay-policy.js';
|
|
7
6
|
|
|
8
7
|
export interface GateRow {
|
|
@@ -101,14 +100,5 @@ export function connectorToolOf(toolName: string, entries: readonly ConnectorGat
|
|
|
101
100
|
for (const gate of entries)
|
|
102
101
|
for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
|
|
103
102
|
if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
|
|
104
|
-
return
|
|
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);
|
|
103
|
+
return undefined;
|
|
114
104
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { isOrgRelayId, orgRelayId } from './relay-id.js';
|
|
7
|
+
import { readServerKeys, writeServerKeys } from './server-keys.js';
|
|
8
|
+
|
|
9
|
+
interface Alias {
|
|
10
|
+
sourceId: string;
|
|
11
|
+
relayId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function recordsOf(dir: string): unknown[] {
|
|
15
|
+
let raw: unknown;
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(readFileSync(join(dir, 'connector-migrations.json'), 'utf8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (isRecord(err) && err.code === 'ENOENT') return [];
|
|
20
|
+
log.warn({ err: errMsg(err) }, 'connectors: the old migration records could not be read; server keys left as they are');
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
if (isRecord(raw) && raw.version === 1 && Array.isArray(raw.records)) return raw.records;
|
|
24
|
+
log.warn('connectors: the old migration records are not valid; server keys left as they are');
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function aliasOf(record: unknown): Alias[] {
|
|
29
|
+
if (!isRecord(record) || record.phase !== 'active' || typeof record.sourceId !== 'string' || typeof record.destinationId !== 'string') return [];
|
|
30
|
+
const relayId = orgRelayId(record.destinationId);
|
|
31
|
+
return isOrgRelayId(relayId) && record.sourceId !== relayId ? [{ sourceId: record.sourceId, relayId }] : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function adoptOrgServerKeys(dir = agentsDir()): number {
|
|
35
|
+
try {
|
|
36
|
+
const aliases = recordsOf(dir).flatMap(aliasOf);
|
|
37
|
+
if (aliases.length === 0) return 0;
|
|
38
|
+
const keys = readServerKeys(dir);
|
|
39
|
+
if (keys === undefined) return 0;
|
|
40
|
+
let moved = 0;
|
|
41
|
+
for (const { sourceId, relayId } of aliases) {
|
|
42
|
+
const key = keys.get(sourceId);
|
|
43
|
+
if (key === undefined || keys.has(relayId)) continue;
|
|
44
|
+
keys.delete(sourceId);
|
|
45
|
+
keys.set(relayId, key);
|
|
46
|
+
moved += 1;
|
|
47
|
+
}
|
|
48
|
+
if (moved > 0) {
|
|
49
|
+
writeServerKeys(keys, dir);
|
|
50
|
+
log.info({ moved }, 'connectors: server keys moved to organization connector ids, tool names unchanged');
|
|
51
|
+
}
|
|
52
|
+
return moved;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log.warn({ err: errMsg(err) }, 'connectors: the server keys could not be moved to organization connector ids');
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,35 +1,23 @@
|
|
|
1
1
|
import { log } from '@metro-labs/core/log';
|
|
2
|
-
import { agentsDir } from '../agents/files.js';
|
|
3
|
-
import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
4
2
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
5
3
|
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
6
4
|
import { effectiveConnectorPolicy, type DeliveredConnector, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
7
5
|
import type { ApiError } from '@metro-labs/http/api-error';
|
|
8
6
|
import { requestConnectorBlocker, type GateRow } from './gates.js';
|
|
9
7
|
import { publishOrgConnectors } from './publication.js';
|
|
10
|
-
import
|
|
11
|
-
import { isOrgRelayId, orgRelayId } from './relay-id.js';
|
|
8
|
+
import { orgRelayId } from './relay-id.js';
|
|
12
9
|
import type { RelaySession } from './relay-session.js';
|
|
13
10
|
import type { RelayTarget } from './relay-target.js';
|
|
14
11
|
import type { OrgConnectorSync } from './org-sync.js';
|
|
15
12
|
import { discoverOrgGroups, orgMissing, orgRuntimeError, orgSignin, orgUnavailable } from './org-runtime-discovery.js';
|
|
16
13
|
|
|
17
|
-
interface RuntimeDeps {
|
|
18
|
-
enabled?: () => boolean;
|
|
19
|
-
mapping?: () => MigrationRuntimeMapping;
|
|
20
|
-
migrationDir?: string;
|
|
14
|
+
export interface RuntimeDeps {
|
|
21
15
|
publish?: (rows: readonly GateRow[]) => void;
|
|
22
16
|
fetch?: FetchLike;
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
interface RuntimeRow extends DeliveredConnector {
|
|
26
|
-
relayId: string;
|
|
27
|
-
relayName: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
interface Entry {
|
|
31
20
|
id: string;
|
|
32
|
-
relayId: string;
|
|
33
21
|
epoch: number;
|
|
34
22
|
stamp: string;
|
|
35
23
|
controller: AbortController;
|
|
@@ -41,14 +29,13 @@ interface Entry {
|
|
|
41
29
|
|
|
42
30
|
interface Session {
|
|
43
31
|
entry: Entry;
|
|
44
|
-
operation: MigrationOperation | null;
|
|
45
32
|
controller: AbortController;
|
|
46
33
|
revision: string | null;
|
|
47
34
|
error: ApiError | null;
|
|
48
35
|
}
|
|
49
36
|
|
|
50
|
-
const stampOf = (row:
|
|
51
|
-
row.
|
|
37
|
+
const stampOf = (row: DeliveredConnector): string => JSON.stringify([
|
|
38
|
+
row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
|
|
52
39
|
Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)), row.policyLayers,
|
|
53
40
|
]);
|
|
54
41
|
|
|
@@ -85,18 +72,14 @@ export class OrgConnectorRuntime {
|
|
|
85
72
|
this.update();
|
|
86
73
|
}
|
|
87
74
|
|
|
88
|
-
private
|
|
89
|
-
return !this.stopped && (this.deps.enabled?.() ?? process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private abort(session: Session, error: ApiError, drain = false): void {
|
|
75
|
+
private abort(session: Session, error: ApiError): void {
|
|
93
76
|
session.error ??= error;
|
|
94
|
-
|
|
77
|
+
session.controller.abort();
|
|
95
78
|
}
|
|
96
79
|
|
|
97
|
-
private publish(rows:
|
|
80
|
+
private publish(rows: DeliveredConnector[]): void {
|
|
98
81
|
const metadata: GateRow[] = rows.map((row) => ({
|
|
99
|
-
id: row.
|
|
82
|
+
id: orgRelayId(row.id), name: row.name,
|
|
100
83
|
config: { policy: effectiveConnectorPolicy(row, this.entries.get(row.id)?.groups ?? {}), toolGroups: { ...this.entries.get(row.id)?.groups } },
|
|
101
84
|
}));
|
|
102
85
|
const text = JSON.stringify(metadata);
|
|
@@ -111,48 +94,27 @@ export class OrgConnectorRuntime {
|
|
|
111
94
|
}
|
|
112
95
|
}
|
|
113
96
|
|
|
114
|
-
private invalidateEntries(rows:
|
|
97
|
+
private invalidateEntries(rows: DeliveredConnector[], epoch: number, available: boolean): void {
|
|
115
98
|
for (const [id, entry] of this.entries) {
|
|
116
99
|
const row = rows.find((value) => value.id === id);
|
|
117
100
|
const valid = available && entry.epoch === epoch;
|
|
118
101
|
if (valid && row?.state === 'ready' && entry.stamp === stampOf(row)) continue;
|
|
119
|
-
|
|
120
|
-
this.invalidateEntry(entry, row, error);
|
|
102
|
+
this.invalidateEntry(entry, valid ? rowError(row) ?? orgUnavailable() : orgUnavailable());
|
|
121
103
|
}
|
|
122
104
|
}
|
|
123
105
|
|
|
124
|
-
private invalidateEntry(entry: Entry,
|
|
125
|
-
|
|
126
|
-
if (!drain) entry.controller.abort();
|
|
106
|
+
private invalidateEntry(entry: Entry, error: ApiError): void {
|
|
107
|
+
entry.controller.abort();
|
|
127
108
|
this.entries.delete(entry.id);
|
|
128
|
-
this.
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private abortEntry(entry: Entry, error: ApiError, drain = false): void {
|
|
132
|
-
for (const session of this.sessions) if (session.entry === entry) this.abort(session, error, drain);
|
|
109
|
+
for (const session of this.sessions) if (session.entry === entry) this.abort(session, error);
|
|
133
110
|
}
|
|
134
111
|
|
|
135
|
-
private
|
|
136
|
-
return rows.flatMap((row) => {
|
|
137
|
-
const alias = mapping.aliases.find((value) => value.destinationId === row.id);
|
|
138
|
-
if (alias !== undefined) return [{ ...row, relayId: alias.sourceId, relayName: alias.name }];
|
|
139
|
-
return this.enabled() && !mapping.reserved.includes(row.id) ? [{ ...row, relayId: orgRelayId(row.id), relayName: row.name }] : [];
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private mapping(): MigrationRuntimeMapping {
|
|
144
|
-
if (this.stopped) return { aliases: [], reserved: [] };
|
|
145
|
-
return this.deps.mapping?.() ?? { aliases: [], reserved: [] };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private update(): RuntimeRow[] | null {
|
|
112
|
+
private update(): DeliveredConnector[] | null {
|
|
149
113
|
try {
|
|
150
|
-
const
|
|
151
|
-
const wanted = this.enabled() || mapping.aliases.length > 0;
|
|
152
|
-
const snapshot = wanted ? this.sync.read() : null;
|
|
114
|
+
const snapshot = this.stopped ? null : this.sync.read();
|
|
153
115
|
const epoch = this.sync.epoch();
|
|
154
|
-
if (this.held(snapshot,
|
|
155
|
-
const rows =
|
|
116
|
+
if (this.held(snapshot, epoch)) return null;
|
|
117
|
+
const rows = snapshot?.connectors ?? [];
|
|
156
118
|
this.invalidateEntries(rows, epoch, snapshot !== null);
|
|
157
119
|
this.track(rows, epoch);
|
|
158
120
|
this.publish(rows);
|
|
@@ -164,27 +126,19 @@ export class OrgConnectorRuntime {
|
|
|
164
126
|
}
|
|
165
127
|
}
|
|
166
128
|
|
|
167
|
-
private track(rows:
|
|
129
|
+
private track(rows: DeliveredConnector[], epoch: number): void {
|
|
168
130
|
for (const row of rows) {
|
|
169
131
|
if (row.state !== 'ready' || this.entries.has(row.id)) continue;
|
|
170
|
-
this.entries.set(row.id, { id: row.id,
|
|
132
|
+
this.entries.set(row.id, { id: row.id, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
|
|
171
133
|
}
|
|
172
134
|
}
|
|
173
135
|
|
|
174
|
-
private held(snapshot: DeliverySnapshot | null,
|
|
175
|
-
if (snapshot !== null ||
|
|
136
|
+
private held(snapshot: DeliverySnapshot | null, epoch: number): boolean {
|
|
137
|
+
if (snapshot !== null || this.stopped || !this.sync.lapsed()) return false;
|
|
176
138
|
this.invalidateEntries([], epoch, false);
|
|
177
139
|
return true;
|
|
178
140
|
}
|
|
179
141
|
|
|
180
|
-
refresh(): void {
|
|
181
|
-
this.update();
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
idle(id?: string): boolean {
|
|
185
|
-
return ![...this.sessions].some((session) => id === undefined || session.entry.relayId === id);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
142
|
private currentEntry(entry: Entry): DeliveredConnector {
|
|
189
143
|
const rows = this.update();
|
|
190
144
|
if (rows === null || entry.epoch !== this.sync.epoch()) throw orgUnavailable();
|
|
@@ -193,7 +147,6 @@ export class OrgConnectorRuntime {
|
|
|
193
147
|
const error = rowError(row);
|
|
194
148
|
if (error !== null) throw error;
|
|
195
149
|
if (entry.controller.signal.aborted || this.entries.get(entry.id) !== entry) throw orgUnavailable();
|
|
196
|
-
if (!isOrgRelayId(entry.relayId)) assertMigrationOutcomeKnown(entry.relayId, this.deps.migrationDir);
|
|
197
150
|
return row;
|
|
198
151
|
}
|
|
199
152
|
|
|
@@ -216,30 +169,22 @@ export class OrgConnectorRuntime {
|
|
|
216
169
|
return this.currentEntry(entry);
|
|
217
170
|
}
|
|
218
171
|
|
|
219
|
-
private migrationOperation(entry: Entry): MigrationOperation | null {
|
|
220
|
-
return isOrgRelayId(entry.relayId) ? null : beginMigrationOperation(entry.relayId, this.deps.migrationDir ?? agentsDir(), true);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
172
|
private async scan(entry: Entry): Promise<void> {
|
|
224
173
|
const row = this.currentEntry(entry);
|
|
225
174
|
const signal = AbortSignal.any([entry.controller.signal, AbortSignal.timeout(45_000)]);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
entry.error = null;
|
|
240
|
-
entry.nextScan = performance.now() + 60_000;
|
|
241
|
-
this.update();
|
|
242
|
-
} finally { operation?.close(); }
|
|
175
|
+
const groups = await discoverOrgGroups(row.url, {
|
|
176
|
+
fresh: (revision) => this.fresh(entry, revision === undefined ? undefined : { connectorId: entry.id, revision }),
|
|
177
|
+
current: () => this.currentEntry(entry), signal,
|
|
178
|
+
fetch: this.deps.fetch ?? fetch,
|
|
179
|
+
});
|
|
180
|
+
this.currentEntry(entry);
|
|
181
|
+
if (JSON.stringify(groups) !== JSON.stringify(entry.groups)) {
|
|
182
|
+
for (const session of this.sessions) if (session.entry === entry && session.revision !== null) this.abort(session, orgUnavailable());
|
|
183
|
+
}
|
|
184
|
+
entry.groups = groups;
|
|
185
|
+
entry.error = null;
|
|
186
|
+
entry.nextScan = performance.now() + 60_000;
|
|
187
|
+
this.update();
|
|
243
188
|
}
|
|
244
189
|
|
|
245
190
|
private discover(entry: Entry): Promise<void> | null {
|
|
@@ -291,16 +236,15 @@ export class OrgConnectorRuntime {
|
|
|
291
236
|
open(id: string): RelaySession {
|
|
292
237
|
const rows = this.update();
|
|
293
238
|
if (rows === null) throw orgUnavailable();
|
|
294
|
-
const
|
|
295
|
-
const error = rowError(
|
|
239
|
+
const found = rows.find((row) => orgRelayId(row.id) === id);
|
|
240
|
+
const error = rowError(found);
|
|
296
241
|
if (error !== null) throw error;
|
|
297
|
-
const entry =
|
|
242
|
+
const entry = found === undefined ? undefined : this.entries.get(found.id);
|
|
298
243
|
if (entry === undefined) throw orgUnavailable();
|
|
299
|
-
const session: Session = { entry,
|
|
244
|
+
const session: Session = { entry, controller: new AbortController(), revision: null, error: null };
|
|
300
245
|
this.sessions.add(session);
|
|
301
246
|
const blocker = requestConnectorBlocker();
|
|
302
247
|
return {
|
|
303
|
-
dispatch: (send, effectful) => { this.currentSession(session); return session.operation?.dispatch(send, { effectful }) ?? send(); },
|
|
304
248
|
target: (force) => this.target(session, force),
|
|
305
249
|
current: () => {
|
|
306
250
|
const row = this.currentSession(session);
|
|
@@ -312,7 +256,7 @@ export class OrgConnectorRuntime {
|
|
|
312
256
|
return blocker();
|
|
313
257
|
},
|
|
314
258
|
signal: session.controller.signal,
|
|
315
|
-
close: () => { this.abort(session, orgUnavailable());
|
|
259
|
+
close: () => { this.abort(session, orgUnavailable()); this.sessions.delete(session); },
|
|
316
260
|
};
|
|
317
261
|
}
|
|
318
262
|
|
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
-
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
6
|
import { METRO_API, type MetroApi } from './metro-api.js';
|
|
7
7
|
import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
|
|
8
|
-
import { OrgConnectorRuntime } from './org-runtime.js';
|
|
8
|
+
import { OrgConnectorRuntime, type RuntimeDeps } from './org-runtime.js';
|
|
9
9
|
import { connectorSyncScope, disableSavedSync, saveSyncSetting, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
10
10
|
import type { RelaySession } from './relay-session.js';
|
|
11
|
-
import
|
|
12
|
-
import { localMigrationIdle, migrationRuntimeMapping } from './migration-guard.js';
|
|
13
|
-
import { publishConnectors } from './publication.js';
|
|
11
|
+
import { publishOrgConnectors } from './publication.js';
|
|
14
12
|
import { orgRuntimeError, orgUnavailable } from './org-runtime-discovery.js';
|
|
15
13
|
|
|
16
|
-
type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
|
|
14
|
+
type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & RuntimeDeps & { api?: MetroApi; dir?: string };
|
|
17
15
|
type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
|
|
18
16
|
const POLL_MS = 60_000;
|
|
19
|
-
const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
20
17
|
const emptyReceipt = { lastReceivedAt: null, leaseExpiresAt: null, counts: null };
|
|
21
18
|
|
|
22
19
|
export class OrgConnectorControl {
|
|
@@ -24,7 +21,7 @@ export class OrgConnectorControl {
|
|
|
24
21
|
private readonly api: MetroApi;
|
|
25
22
|
private sync: OrgConnectorSync | null = null;
|
|
26
23
|
private runtime: OrgConnectorRuntime | null = null;
|
|
27
|
-
private
|
|
24
|
+
private unpublished = false;
|
|
28
25
|
private scope: string | null = null;
|
|
29
26
|
private operation = randomBytes(16).toString('hex');
|
|
30
27
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -49,20 +46,23 @@ export class OrgConnectorControl {
|
|
|
49
46
|
this.sync?.invalidate();
|
|
50
47
|
this.sync = null;
|
|
51
48
|
this.runtime = null;
|
|
52
|
-
this.retire(runtime);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
private retire(runtime: OrgConnectorRuntime | null): void {
|
|
56
|
-
for (const previous of this.draining) if (previous.idle()) this.draining.delete(previous);
|
|
57
|
-
if (runtime === null) return;
|
|
58
|
-
if (!runtime.idle()) this.draining.add(runtime);
|
|
59
49
|
try {
|
|
60
|
-
runtime
|
|
50
|
+
runtime?.stop();
|
|
61
51
|
} catch {
|
|
62
52
|
log.warn('organization connectors: stopped runtime publication could not be cleared');
|
|
63
53
|
}
|
|
64
54
|
}
|
|
65
55
|
|
|
56
|
+
private unpublish(): void {
|
|
57
|
+
if (this.runtime !== null || this.unpublished) return;
|
|
58
|
+
try {
|
|
59
|
+
(this.deps.publish ?? publishOrgConnectors)([]);
|
|
60
|
+
this.unpublished = true;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log.warn({ err: errMsg(err) }, 'organization connectors: the connector list could not be cleared');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
66
|
private reconcile(start: boolean): { context: Context; setting: ReturnType<typeof syncSetting> } {
|
|
67
67
|
const context = this.context();
|
|
68
68
|
if (this.scope !== null && this.scope !== context.scope) {
|
|
@@ -73,8 +73,10 @@ export class OrgConnectorControl {
|
|
|
73
73
|
this.scope = context.scope;
|
|
74
74
|
const setting = syncSetting(this.dir, context.scope);
|
|
75
75
|
if (setting.source === 'scope_changed') disableSavedSync(this.dir);
|
|
76
|
-
if (!setting.enabled || !context.enrolled)
|
|
77
|
-
|
|
76
|
+
if (!setting.enabled || !context.enrolled) {
|
|
77
|
+
this.clear();
|
|
78
|
+
this.unpublish();
|
|
79
|
+
} else if (start && this.sync === null) {
|
|
78
80
|
const scope = context.scope;
|
|
79
81
|
this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
|
|
80
82
|
}
|
|
@@ -84,8 +86,8 @@ export class OrgConnectorControl {
|
|
|
84
86
|
|
|
85
87
|
private startRuntime(): void {
|
|
86
88
|
if (this.sync === null || this.runtime !== null) return;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
this.unpublished = false;
|
|
90
|
+
this.runtime = new OrgConnectorRuntime(this.sync, this.deps);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
status(): ConnectorSyncStatus {
|
|
@@ -94,7 +96,7 @@ export class OrgConnectorControl {
|
|
|
94
96
|
const receipt = this.sync?.status() ?? { state: 'waiting' as const, ...emptyReceipt };
|
|
95
97
|
const state = !setting.enabled ? 'off' : !context.enrolled ? 'not_enrolled' : receipt.state;
|
|
96
98
|
const scope = createHash('sha256').update(`${context.scope}:${this.operation}`).digest('hex');
|
|
97
|
-
return { ...context, ...setting, ...receipt, scope, state, liveEnabled:
|
|
99
|
+
return { ...context, ...setting, ...receipt, scope, state, liveEnabled: false };
|
|
98
100
|
} catch {
|
|
99
101
|
this.clear();
|
|
100
102
|
throw new ApiError('Metro could not read the connector sync status.', 503);
|
|
@@ -105,7 +107,6 @@ export class OrgConnectorControl {
|
|
|
105
107
|
const current = this.status();
|
|
106
108
|
if (scope !== current.scope) throw new ApiError('The organization or enrollment changed. Refresh the sync status before trying again.', 409);
|
|
107
109
|
if (enabled && !current.enrolled) throw new ApiError('Enroll this agent with its organization before enabling sync.', 409);
|
|
108
|
-
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);
|
|
109
110
|
saveSyncSetting(this.dir, enabled, this.context().scope);
|
|
110
111
|
if (!enabled) this.clear();
|
|
111
112
|
else this.tick();
|
|
@@ -113,7 +114,10 @@ export class OrgConnectorControl {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
async poll(): Promise<void> {
|
|
116
|
-
if (this.sync === null && !syncOptedIn(this.dir))
|
|
117
|
+
if (this.sync === null && !syncOptedIn(this.dir)) {
|
|
118
|
+
this.unpublish();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
117
121
|
try {
|
|
118
122
|
this.reconcile(true);
|
|
119
123
|
} catch (err) {
|
|
@@ -123,28 +127,6 @@ export class OrgConnectorControl {
|
|
|
123
127
|
await this.sync?.sync();
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
async fresh(): Promise<DeliverySnapshot> {
|
|
127
|
-
this.reconcile(true);
|
|
128
|
-
const sync = this.sync;
|
|
129
|
-
if (sync === null) throw new ApiError('Enable organization connector sync before moving this connector.', 409);
|
|
130
|
-
await sync.syncFresh();
|
|
131
|
-
this.reconcile(false);
|
|
132
|
-
const snapshot = this.sync === sync ? sync.read() : null;
|
|
133
|
-
if (snapshot === null) throw new ApiError('A fresh organization connector snapshot is unavailable.', 503);
|
|
134
|
-
return snapshot;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
refreshRuntime(): void {
|
|
138
|
-
this.reconcile(true);
|
|
139
|
-
this.runtime?.refresh();
|
|
140
|
-
publishConnectors();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
migrationIdle(id: string): boolean {
|
|
144
|
-
for (const runtime of this.draining) if (runtime.idle()) this.draining.delete(runtime);
|
|
145
|
-
return localMigrationIdle(id, this.dir) && (this.runtime?.idle(id) ?? true) && [...this.draining].every((runtime) => runtime.idle(id));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
130
|
private tick(): void {
|
|
149
131
|
this.poll().catch(() => {
|
|
150
132
|
log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
2
|
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
3
|
import { OrgConnectorControl } from './org-sync-control.js';
|
|
4
|
-
import { isOrgRelayId } from './relay-id.js';
|
|
5
4
|
import type { RelaySession } from './relay-session.js';
|
|
6
|
-
import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
7
|
-
import { migrationSource } from './migration-guard.js';
|
|
8
5
|
|
|
9
6
|
let active: OrgConnectorControl | null = null;
|
|
10
7
|
|
|
@@ -13,20 +10,8 @@ function control(): OrgConnectorControl {
|
|
|
13
10
|
return active;
|
|
14
11
|
}
|
|
15
12
|
|
|
16
|
-
export function orgConnectorRelaySession(id: string): Promise<RelaySession>
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function freshOrgConnectors(): Promise<DeliverySnapshot> {
|
|
21
|
-
return control().fresh();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function refreshOrgConnectorRuntime(): void {
|
|
25
|
-
control().refreshRuntime();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function orgConnectorMigrationIdle(id: string): boolean {
|
|
29
|
-
return control().migrationIdle(id);
|
|
13
|
+
export function orgConnectorRelaySession(id: string): Promise<RelaySession> {
|
|
14
|
+
return control().open(id);
|
|
30
15
|
}
|
|
31
16
|
|
|
32
17
|
export function orgConnectorSyncStatus(): ConnectorSyncStatus {
|