@stage-labs/metro 0.1.0-beta.304 → 0.1.0-beta.306
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/dist/cli.js +1 -1
- package/dist/memory.js +2 -3
- package/dist/runtime-install.js +3 -11
- package/dist/voice.js +1 -1
- package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
- package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +1 -32
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
- package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -17
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
2
|
-
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
3
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
-
import { validConnectorToolName, type DeliveredConnector } from '@metro-labs/http/connector-delivery';
|
|
5
|
-
import { MetroApiRefusal } from './metro-api.js';
|
|
6
|
-
import { listRemoteTools } from './tools.js';
|
|
7
|
-
|
|
8
|
-
export const orgUnavailable = (): ApiError => new ApiError('This organization connector is not available. Try again.', 503);
|
|
9
|
-
export const orgSignin = (): ApiError => new ApiError('This organization connector needs sign-in again.', 424);
|
|
10
|
-
export const orgMissing = (): ApiError => new ApiError('No such organization connector is assigned to this agent.', 404);
|
|
11
|
-
|
|
12
|
-
export function orgRuntimeError(error: unknown): ApiError {
|
|
13
|
-
if (error instanceof MetroApiRefusal) return error.upstreamStatus === 401 || error.upstreamStatus === 403 ? orgSignin() : orgUnavailable();
|
|
14
|
-
const status = error instanceof ApiError ? error.status : 503;
|
|
15
|
-
if (status === 401 || status === 403 || status === 424) return orgSignin();
|
|
16
|
-
if (status === 404) return orgMissing();
|
|
17
|
-
if (status === 502) return new ApiError('The organization connector could not complete the request.', 502);
|
|
18
|
-
return orgUnavailable();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface DiscoveryGuard {
|
|
22
|
-
fresh: (revision?: string) => Promise<DeliveredConnector>;
|
|
23
|
-
current: () => DeliveredConnector;
|
|
24
|
-
signal: AbortSignal;
|
|
25
|
-
fetch: FetchLike;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const MAX_BYTES = 2 * 1024 * 1024;
|
|
29
|
-
const MAX_TOOLS = 2000;
|
|
30
|
-
|
|
31
|
-
async function boundedResponse(response: Response, signal: AbortSignal): Promise<Response> {
|
|
32
|
-
const reader = response.body?.getReader();
|
|
33
|
-
if (reader === undefined) return response;
|
|
34
|
-
const chunks: Uint8Array[] = [];
|
|
35
|
-
let size = 0;
|
|
36
|
-
try {
|
|
37
|
-
for (let part = await reader.read(); !part.done; part = await reader.read()) {
|
|
38
|
-
signal.throwIfAborted();
|
|
39
|
-
size += part.value.length;
|
|
40
|
-
if (size > MAX_BYTES) throw orgUnavailable();
|
|
41
|
-
chunks.push(part.value);
|
|
42
|
-
}
|
|
43
|
-
return new Response(Buffer.concat(chunks), { status: response.status, headers: response.headers });
|
|
44
|
-
} finally {
|
|
45
|
-
await reader.cancel();
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function requestInit(row: DeliveredConnector, init: RequestInit | undefined, scanSignal: AbortSignal): RequestInit & { signal: AbortSignal } {
|
|
50
|
-
const headers = new Headers(init?.headers);
|
|
51
|
-
for (const [name, value] of Object.entries(row.headers)) headers.set(name, value);
|
|
52
|
-
const signals = [scanSignal, AbortSignal.timeout(15_000)];
|
|
53
|
-
if (init?.signal !== undefined && init.signal !== null) signals.push(init.signal);
|
|
54
|
-
return { ...init, headers, redirect: 'manual', signal: AbortSignal.any(signals) };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function discoveryResponse(response: Response, init: RequestInit | undefined, signal: AbortSignal): Promise<Response> {
|
|
58
|
-
if (response.status >= 300) throw new ApiError('The organization connector refused tool discovery.', 502);
|
|
59
|
-
if (init?.method === 'DELETE') {
|
|
60
|
-
await boundedResponse(response, signal);
|
|
61
|
-
return new Response(null, { status: response.status });
|
|
62
|
-
}
|
|
63
|
-
return boundedResponse(response, signal);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function guardedRequest(input: string, init: RequestInit | undefined, guard: DiscoveryGuard, budget: { refreshed: boolean }): Promise<Response> {
|
|
67
|
-
let revision: string | undefined;
|
|
68
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
69
|
-
guard.signal.throwIfAborted();
|
|
70
|
-
await guard.fresh(revision);
|
|
71
|
-
guard.signal.throwIfAborted();
|
|
72
|
-
const row = guard.current();
|
|
73
|
-
if (input !== new URL(row.url).toString()) throw orgUnavailable();
|
|
74
|
-
const options = requestInit(row, init, guard.signal);
|
|
75
|
-
const response = await guard.fetch(input, options);
|
|
76
|
-
try {
|
|
77
|
-
guard.current();
|
|
78
|
-
options.signal.throwIfAborted();
|
|
79
|
-
if (response.status === 401 || response.status === 403) {
|
|
80
|
-
if (row.auth !== 'oauth' || budget.refreshed) throw orgSignin();
|
|
81
|
-
budget.refreshed = true;
|
|
82
|
-
revision = row.revision;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
return await discoveryResponse(response, init, options.signal);
|
|
86
|
-
} finally {
|
|
87
|
-
if (!response.bodyUsed) await response.body?.cancel();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
throw orgSignin();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Promise<Record<string, ToolGroup>> {
|
|
94
|
-
try {
|
|
95
|
-
const failure: { error: ApiError | null } = { error: null };
|
|
96
|
-
const budget = { refreshed: false };
|
|
97
|
-
const send: FetchLike = async (input, init) => {
|
|
98
|
-
try {
|
|
99
|
-
return await guardedRequest(input, init, guard, budget);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
const safe = orgRuntimeError(error);
|
|
102
|
-
if (init?.method !== 'DELETE' || safe.status !== 502) failure.error = safe;
|
|
103
|
-
throw safe;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
const tools = await listRemoteTools(new URL(url), { kind: 'none' }, send);
|
|
107
|
-
if (failure.error !== null) throw failure.error;
|
|
108
|
-
guard.current();
|
|
109
|
-
guard.signal.throwIfAborted();
|
|
110
|
-
if (tools.length > MAX_TOOLS || tools.some((tool) => !validConnectorToolName(tool.name))) throw orgUnavailable();
|
|
111
|
-
return Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
|
|
112
|
-
} catch (error) {
|
|
113
|
-
throw orgRuntimeError(error);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { log } from '@metro-labs/core/log';
|
|
2
|
-
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
|
-
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
4
|
-
import { effectiveConnectorPolicy, type DeliveredConnector, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
5
|
-
import type { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
-
import { requestConnectorBlocker, type GateRow } from './gates.js';
|
|
7
|
-
import { publishOrgConnectors } from './publication.js';
|
|
8
|
-
import { orgRelayId } from './relay-id.js';
|
|
9
|
-
import type { RelaySession } from './relay-session.js';
|
|
10
|
-
import type { RelayTarget } from './relay-target.js';
|
|
11
|
-
import type { OrgConnectorSync } from './org-sync.js';
|
|
12
|
-
import { discoverOrgGroups, orgMissing, orgRuntimeError, orgSignin, orgUnavailable } from './org-runtime-discovery.js';
|
|
13
|
-
|
|
14
|
-
export interface RuntimeDeps {
|
|
15
|
-
publish?: (rows: readonly GateRow[]) => void;
|
|
16
|
-
fetch?: FetchLike;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface Entry {
|
|
20
|
-
id: string;
|
|
21
|
-
epoch: number;
|
|
22
|
-
stamp: string;
|
|
23
|
-
controller: AbortController;
|
|
24
|
-
groups: Record<string, ToolGroup>;
|
|
25
|
-
scan: Promise<void> | null;
|
|
26
|
-
nextScan: number;
|
|
27
|
-
error: ApiError | null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface Session {
|
|
31
|
-
entry: Entry;
|
|
32
|
-
controller: AbortController;
|
|
33
|
-
revision: string | null;
|
|
34
|
-
error: ApiError | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const stampOf = (row: DeliveredConnector): string => JSON.stringify([
|
|
38
|
-
row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
|
|
39
|
-
Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)), row.policyLayers,
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
const rowError = (row: DeliveredConnector | undefined): ApiError | null => {
|
|
43
|
-
if (row === undefined) return orgMissing();
|
|
44
|
-
if (row.state === 'signin') return orgSignin();
|
|
45
|
-
return row.state === 'ready' ? null : orgUnavailable();
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const targetOf = (row: DeliveredConnector): RelayTarget => ({ kind: 'ok', url: row.url, headers: { ...row.headers } });
|
|
49
|
-
|
|
50
|
-
const MAX_SCANS = 4;
|
|
51
|
-
|
|
52
|
-
export class OrgConnectorRuntime {
|
|
53
|
-
private readonly entries = new Map<string, Entry>();
|
|
54
|
-
private readonly sessions = new Set<Session>();
|
|
55
|
-
private readonly unsubscribe: () => void;
|
|
56
|
-
private readonly timer: ReturnType<typeof setInterval>;
|
|
57
|
-
private published = '';
|
|
58
|
-
private stopped = false;
|
|
59
|
-
private scans = 0;
|
|
60
|
-
private readonly waiting: (() => void)[] = [];
|
|
61
|
-
|
|
62
|
-
constructor(private readonly sync: OrgConnectorSync, private readonly deps: RuntimeDeps = {}) {
|
|
63
|
-
this.unsubscribe = sync.onChange(() => { this.update(); });
|
|
64
|
-
this.timer = setInterval(() => {
|
|
65
|
-
try {
|
|
66
|
-
this.update();
|
|
67
|
-
} catch {
|
|
68
|
-
log.warn('organization connectors: runtime metadata could not be updated');
|
|
69
|
-
}
|
|
70
|
-
}, 1000);
|
|
71
|
-
this.timer.unref();
|
|
72
|
-
this.update();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private abort(session: Session, error: ApiError): void {
|
|
76
|
-
session.error ??= error;
|
|
77
|
-
session.controller.abort();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private publish(rows: DeliveredConnector[]): void {
|
|
81
|
-
const metadata: GateRow[] = rows.map((row) => ({
|
|
82
|
-
id: orgRelayId(row.id), name: row.name,
|
|
83
|
-
config: { policy: effectiveConnectorPolicy(row, this.entries.get(row.id)?.groups ?? {}), toolGroups: { ...this.entries.get(row.id)?.groups } },
|
|
84
|
-
}));
|
|
85
|
-
const text = JSON.stringify(metadata);
|
|
86
|
-
if (text === this.published) return;
|
|
87
|
-
try {
|
|
88
|
-
(this.deps.publish ?? publishOrgConnectors)(metadata);
|
|
89
|
-
this.published = text;
|
|
90
|
-
} catch {
|
|
91
|
-
this.published = '';
|
|
92
|
-
for (const session of this.sessions) this.abort(session, orgUnavailable());
|
|
93
|
-
throw orgUnavailable();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private invalidateEntries(rows: DeliveredConnector[], epoch: number, available: boolean): void {
|
|
98
|
-
for (const [id, entry] of this.entries) {
|
|
99
|
-
const row = rows.find((value) => value.id === id);
|
|
100
|
-
const valid = available && entry.epoch === epoch;
|
|
101
|
-
if (valid && row?.state === 'ready' && entry.stamp === stampOf(row)) continue;
|
|
102
|
-
this.invalidateEntry(entry, valid ? rowError(row) ?? orgUnavailable() : orgUnavailable());
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
private invalidateEntry(entry: Entry, error: ApiError): void {
|
|
107
|
-
entry.controller.abort();
|
|
108
|
-
this.entries.delete(entry.id);
|
|
109
|
-
for (const session of this.sessions) if (session.entry === entry) this.abort(session, error);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private update(): DeliveredConnector[] | null {
|
|
113
|
-
try {
|
|
114
|
-
const snapshot = this.stopped ? null : this.sync.read();
|
|
115
|
-
const epoch = this.sync.epoch();
|
|
116
|
-
if (this.held(snapshot, epoch)) return null;
|
|
117
|
-
const rows = snapshot?.connectors ?? [];
|
|
118
|
-
this.invalidateEntries(rows, epoch, snapshot !== null);
|
|
119
|
-
this.track(rows, epoch);
|
|
120
|
-
this.publish(rows);
|
|
121
|
-
return snapshot === null ? null : rows;
|
|
122
|
-
} catch {
|
|
123
|
-
this.invalidateEntries([], this.sync.epoch(), false);
|
|
124
|
-
this.publish([]);
|
|
125
|
-
throw orgUnavailable();
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private track(rows: DeliveredConnector[], epoch: number): void {
|
|
130
|
-
for (const row of rows) {
|
|
131
|
-
if (row.state !== 'ready' || this.entries.has(row.id)) continue;
|
|
132
|
-
this.entries.set(row.id, { id: row.id, epoch, stamp: stampOf(row), controller: new AbortController(), groups: {}, scan: null, nextScan: 0, error: null });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
private held(snapshot: DeliverySnapshot | null, epoch: number): boolean {
|
|
137
|
-
if (snapshot !== null || this.stopped || !this.sync.lapsed()) return false;
|
|
138
|
-
this.invalidateEntries([], epoch, false);
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
private currentEntry(entry: Entry): DeliveredConnector {
|
|
143
|
-
const rows = this.update();
|
|
144
|
-
if (rows === null || entry.epoch !== this.sync.epoch()) throw orgUnavailable();
|
|
145
|
-
const row = rows.find((value) => value.id === entry.id);
|
|
146
|
-
if (row === undefined) throw orgMissing();
|
|
147
|
-
const error = rowError(row);
|
|
148
|
-
if (error !== null) throw error;
|
|
149
|
-
if (entry.controller.signal.aborted || this.entries.get(entry.id) !== entry) throw orgUnavailable();
|
|
150
|
-
return row;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private currentSession(session: Session): DeliveredConnector {
|
|
154
|
-
if (session.error !== null) throw session.error;
|
|
155
|
-
try {
|
|
156
|
-
return this.currentEntry(session.entry);
|
|
157
|
-
} catch (error) {
|
|
158
|
-
throw session.error ?? orgRuntimeError(error);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private async fresh(entry: Entry, refresh?: { connectorId: string; revision: string }): Promise<DeliveredConnector> {
|
|
163
|
-
this.currentEntry(entry);
|
|
164
|
-
try {
|
|
165
|
-
await this.sync.syncFresh(refresh);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
throw orgRuntimeError(error);
|
|
168
|
-
}
|
|
169
|
-
return this.currentEntry(entry);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async scan(entry: Entry): Promise<void> {
|
|
173
|
-
const row = this.currentEntry(entry);
|
|
174
|
-
const signal = AbortSignal.any([entry.controller.signal, AbortSignal.timeout(45_000)]);
|
|
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();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private discover(entry: Entry): Promise<void> | null {
|
|
191
|
-
if (entry.scan !== null) return entry.scan;
|
|
192
|
-
if (performance.now() < entry.nextScan) {
|
|
193
|
-
if (entry.error !== null) {
|
|
194
|
-
log.debug({ stage: 'discovery_backoff', status: entry.error.status }, 'organization connectors: request unavailable');
|
|
195
|
-
throw entry.error;
|
|
196
|
-
}
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
entry.scan = this.queued(entry).catch((error: unknown) => {
|
|
200
|
-
entry.error = orgRuntimeError(error);
|
|
201
|
-
entry.nextScan = performance.now() + 30_000;
|
|
202
|
-
log.debug({ stage: 'tool_discovery', status: entry.error.status }, 'organization connectors: tool discovery failed');
|
|
203
|
-
throw entry.error;
|
|
204
|
-
}).finally(() => {
|
|
205
|
-
entry.scan = null;
|
|
206
|
-
});
|
|
207
|
-
return entry.scan;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private async queued(entry: Entry): Promise<void> {
|
|
211
|
-
while (this.scans >= MAX_SCANS) await new Promise<void>((resolve) => { this.waiting.push(resolve); });
|
|
212
|
-
this.scans += 1;
|
|
213
|
-
try {
|
|
214
|
-
await this.scan(entry);
|
|
215
|
-
} finally {
|
|
216
|
-
this.scans -= 1;
|
|
217
|
-
this.waiting.shift()?.();
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private async target(session: Session, force: boolean): Promise<RelayTarget> {
|
|
222
|
-
this.currentSession(session);
|
|
223
|
-
if (force && session.revision === null) throw orgUnavailable();
|
|
224
|
-
const refresh = force && session.revision !== null ? { connectorId: session.entry.id, revision: session.revision } : undefined;
|
|
225
|
-
await this.fresh(session.entry, refresh);
|
|
226
|
-
this.currentSession(session);
|
|
227
|
-
const discovery = this.discover(session.entry);
|
|
228
|
-
if (discovery !== null) {
|
|
229
|
-
await discovery;
|
|
230
|
-
this.currentSession(session);
|
|
231
|
-
await this.fresh(session.entry);
|
|
232
|
-
}
|
|
233
|
-
return targetOf(this.currentSession(session));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
open(id: string): RelaySession {
|
|
237
|
-
const rows = this.update();
|
|
238
|
-
if (rows === null) throw orgUnavailable();
|
|
239
|
-
const found = rows.find((row) => orgRelayId(row.id) === id);
|
|
240
|
-
const error = rowError(found);
|
|
241
|
-
if (error !== null) throw error;
|
|
242
|
-
const entry = found === undefined ? undefined : this.entries.get(found.id);
|
|
243
|
-
if (entry === undefined) throw orgUnavailable();
|
|
244
|
-
const session: Session = { entry, controller: new AbortController(), revision: null, error: null };
|
|
245
|
-
this.sessions.add(session);
|
|
246
|
-
const blocker = requestConnectorBlocker();
|
|
247
|
-
return {
|
|
248
|
-
target: (force) => this.target(session, force),
|
|
249
|
-
current: () => {
|
|
250
|
-
const row = this.currentSession(session);
|
|
251
|
-
session.revision = row.revision;
|
|
252
|
-
return targetOf(row);
|
|
253
|
-
},
|
|
254
|
-
blocker: () => {
|
|
255
|
-
this.currentSession(session);
|
|
256
|
-
return blocker();
|
|
257
|
-
},
|
|
258
|
-
signal: session.controller.signal,
|
|
259
|
-
close: () => { this.abort(session, orgUnavailable()); this.sessions.delete(session); },
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
stop(keepPublished = false): void {
|
|
264
|
-
if (this.stopped) return;
|
|
265
|
-
this.stopped = true;
|
|
266
|
-
this.unsubscribe();
|
|
267
|
-
clearInterval(this.timer);
|
|
268
|
-
if (keepPublished) this.invalidateEntries([], this.sync.epoch(), false);
|
|
269
|
-
else this.update();
|
|
270
|
-
}
|
|
271
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
-
import { errMsg, 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, type RuntimeDeps } 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
|
-
import { publishOrgConnectors } from './publication.js';
|
|
12
|
-
import { orgRuntimeError, orgUnavailable } from './org-runtime-discovery.js';
|
|
13
|
-
|
|
14
|
-
type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & RuntimeDeps & { api?: MetroApi; dir?: string };
|
|
15
|
-
type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
|
|
16
|
-
const POLL_MS = 60_000;
|
|
17
|
-
const emptyReceipt = { lastReceivedAt: null, leaseExpiresAt: null, counts: null };
|
|
18
|
-
|
|
19
|
-
export class OrgConnectorControl {
|
|
20
|
-
private readonly dir: string;
|
|
21
|
-
private readonly api: MetroApi;
|
|
22
|
-
private sync: OrgConnectorSync | null = null;
|
|
23
|
-
private runtime: OrgConnectorRuntime | null = null;
|
|
24
|
-
private unpublished = false;
|
|
25
|
-
private scope: string | null = null;
|
|
26
|
-
private operation = randomBytes(16).toString('hex');
|
|
27
|
-
private timer: ReturnType<typeof setInterval> | null = null;
|
|
28
|
-
|
|
29
|
-
constructor(private readonly deps: ControlDeps) {
|
|
30
|
-
this.dir = deps.dir ?? agentsDir();
|
|
31
|
-
this.api = deps.api ?? METRO_API;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private context(): Context {
|
|
35
|
-
const owner = this.deps.owner();
|
|
36
|
-
const key = this.deps.key();
|
|
37
|
-
const enrollment = key?.enrollment;
|
|
38
|
-
const agentId = owner !== null && enrollment?.organization === owner ? enrollment.server : null;
|
|
39
|
-
const scope = connectorSyncScope(owner, key);
|
|
40
|
-
return { owner, agentId, scope, enrolled: agentId !== null };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private clear(keepPublished = false): void {
|
|
44
|
-
const runtime = this.runtime;
|
|
45
|
-
if (keepPublished) runtime?.stop(true);
|
|
46
|
-
this.sync?.invalidate();
|
|
47
|
-
this.sync = null;
|
|
48
|
-
this.runtime = null;
|
|
49
|
-
try {
|
|
50
|
-
runtime?.stop();
|
|
51
|
-
} catch {
|
|
52
|
-
log.warn('organization connectors: stopped runtime publication could not be cleared');
|
|
53
|
-
}
|
|
54
|
-
}
|
|
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
|
-
private reconcile(start: boolean): { context: Context; setting: ReturnType<typeof syncSetting> } {
|
|
67
|
-
const context = this.context();
|
|
68
|
-
if (this.scope !== null && this.scope !== context.scope) {
|
|
69
|
-
this.clear();
|
|
70
|
-
this.operation = randomBytes(16).toString('hex');
|
|
71
|
-
disableSavedSync(this.dir);
|
|
72
|
-
}
|
|
73
|
-
this.scope = context.scope;
|
|
74
|
-
const setting = syncSetting(this.dir, context.scope);
|
|
75
|
-
if (setting.source === 'scope_changed') disableSavedSync(this.dir);
|
|
76
|
-
if (!setting.enabled || !context.enrolled) {
|
|
77
|
-
this.clear();
|
|
78
|
-
this.unpublish();
|
|
79
|
-
} else if (start && this.sync === null) {
|
|
80
|
-
const scope = context.scope;
|
|
81
|
-
this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
|
|
82
|
-
}
|
|
83
|
-
if (start) this.startRuntime();
|
|
84
|
-
return { context, setting };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private startRuntime(): void {
|
|
88
|
-
if (this.sync === null || this.runtime !== null) return;
|
|
89
|
-
this.unpublished = false;
|
|
90
|
-
this.runtime = new OrgConnectorRuntime(this.sync, this.deps);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
status(): ConnectorSyncStatus {
|
|
94
|
-
try {
|
|
95
|
-
const { context, setting } = this.reconcile(false);
|
|
96
|
-
const receipt = this.sync?.status() ?? { state: 'waiting' as const, ...emptyReceipt };
|
|
97
|
-
const state = !setting.enabled ? 'off' : !context.enrolled ? 'not_enrolled' : receipt.state;
|
|
98
|
-
const scope = createHash('sha256').update(`${context.scope}:${this.operation}`).digest('hex');
|
|
99
|
-
return { ...context, ...setting, ...receipt, scope, state, liveEnabled: false };
|
|
100
|
-
} catch {
|
|
101
|
-
this.clear();
|
|
102
|
-
throw new ApiError('Metro could not read the connector sync status.', 503);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
set(enabled: boolean, scope: string): ConnectorSyncStatus {
|
|
107
|
-
const current = this.status();
|
|
108
|
-
if (scope !== current.scope) throw new ApiError('The organization or enrollment changed. Refresh the sync status before trying again.', 409);
|
|
109
|
-
if (enabled && !current.enrolled) throw new ApiError('Enroll this agent with its organization before enabling sync.', 409);
|
|
110
|
-
saveSyncSetting(this.dir, enabled, this.context().scope);
|
|
111
|
-
if (!enabled) this.clear();
|
|
112
|
-
else this.tick();
|
|
113
|
-
return this.status();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async poll(): Promise<void> {
|
|
117
|
-
if (this.sync === null && !syncOptedIn(this.dir)) {
|
|
118
|
-
this.unpublish();
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
this.reconcile(true);
|
|
123
|
-
} catch (err) {
|
|
124
|
-
this.clear();
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
await this.sync?.sync();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private tick(): void {
|
|
131
|
-
this.poll().catch(() => {
|
|
132
|
-
log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
start(): void {
|
|
137
|
-
if (this.timer !== null) return;
|
|
138
|
-
this.tick();
|
|
139
|
-
this.timer = setInterval(() => { this.tick(); }, POLL_MS);
|
|
140
|
-
this.timer.unref();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
invalidate(): void {
|
|
144
|
-
this.operation = randomBytes(16).toString('hex');
|
|
145
|
-
this.clear();
|
|
146
|
-
disableSavedSync(this.dir);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
stop(keepPublished = false): void {
|
|
150
|
-
if (this.timer !== null) clearInterval(this.timer);
|
|
151
|
-
this.timer = null;
|
|
152
|
-
this.clear(keepPublished);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async open(id: string): Promise<RelaySession> {
|
|
156
|
-
if (this.runtime === null && !syncOptedIn(this.dir)) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
157
|
-
this.reconcile(true);
|
|
158
|
-
const runtime = this.runtime;
|
|
159
|
-
const sync = this.sync;
|
|
160
|
-
if (runtime === null || sync === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
161
|
-
const state = sync.status().state;
|
|
162
|
-
try {
|
|
163
|
-
await sync.recover();
|
|
164
|
-
this.reconcile(false);
|
|
165
|
-
if (this.runtime !== runtime || this.sync !== sync) throw orgUnavailable();
|
|
166
|
-
} catch (error) {
|
|
167
|
-
const safe = orgRuntimeError(error);
|
|
168
|
-
log.debug({ stage: 'receipt_open', state, status: safe.status }, 'organization connectors: request unavailable');
|
|
169
|
-
throw safe;
|
|
170
|
-
}
|
|
171
|
-
return runtime.open(id);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
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,38 +0,0 @@
|
|
|
1
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
-
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
3
|
-
import { OrgConnectorControl } from './org-sync-control.js';
|
|
4
|
-
import type { RelaySession } from './relay-session.js';
|
|
5
|
-
|
|
6
|
-
let active: OrgConnectorControl | null = null;
|
|
7
|
-
|
|
8
|
-
function control(): OrgConnectorControl {
|
|
9
|
-
if (active === null) throw new ApiError('Organization connector sync is unavailable. Restart Metro before trying again.', 503);
|
|
10
|
-
return active;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function orgConnectorRelaySession(id: string): Promise<RelaySession> {
|
|
14
|
-
return control().open(id);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function orgConnectorSyncStatus(): ConnectorSyncStatus {
|
|
18
|
-
return control().status();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function setOrgConnectorSync(enabled: boolean, scope: string): ConnectorSyncStatus {
|
|
22
|
-
return control().set(enabled, scope);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function invalidateOrgConnectorSync(): void {
|
|
26
|
-
active?.invalidate();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function stopOrgConnectorSync(keepPublished = false): void {
|
|
30
|
-
active?.stop(keepPublished);
|
|
31
|
-
active = null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function watchOrgConnectorSync(deps: ConstructorParameters<typeof OrgConnectorControl>[0]): void {
|
|
35
|
-
stopOrgConnectorSync();
|
|
36
|
-
active = new OrgConnectorControl(deps);
|
|
37
|
-
active.start();
|
|
38
|
-
}
|