mecord-connect 1.0.0
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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { OperatorError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
import { readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
6
|
+
|
|
7
|
+
const MAX_STREAMS = 10_000;
|
|
8
|
+
const MAX_RESULTS_PER_STREAM = 10_000;
|
|
9
|
+
const MAX_RESULT_BYTES = 256 * 1024;
|
|
10
|
+
const DEFAULT_RETENTION_MS = 24 * 60 * 60_000;
|
|
11
|
+
const MAX_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export class RelayResultStore {
|
|
30
|
+
#file ;
|
|
31
|
+
#queue = Promise.resolve();
|
|
32
|
+
#clock ;
|
|
33
|
+
#retentionMs ;
|
|
34
|
+
|
|
35
|
+
constructor(stateDir , options = {}) {
|
|
36
|
+
this.#file = path.join(path.resolve(stateDir), 'relay-results.json');
|
|
37
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
38
|
+
this.#retentionMs = boundedRetention(options.retentionMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async put(deviceIdInput , seqInput , deliveryIdInput , resultInput , idempotencyKeyInput , replayAuthorityInput ) {
|
|
42
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
43
|
+
const seq = validSeq(seqInput);
|
|
44
|
+
const deliveryId = validUuid(deliveryIdInput, 'deliveryId');
|
|
45
|
+
const idempotencyKey = idempotencyKeyInput === undefined ? undefined : validIdempotencyKey(idempotencyKeyInput);
|
|
46
|
+
const replayAuthority = replayAuthorityInput === undefined ? undefined : safeReplayAuthority(replayAuthorityInput, deviceId);
|
|
47
|
+
if (replayAuthority && !idempotencyKey) throw new OperatorError('RELAY_RESULT_AUTHORITY_INVALID', 'Replay authority requires an idempotency key.');
|
|
48
|
+
const result = safeResult(resultInput);
|
|
49
|
+
const resultSha256 = hashResult(result);
|
|
50
|
+
return await this.#mutate((state) => {
|
|
51
|
+
pruneExpired(state, this.#clock().getTime(), this.#retentionMs);
|
|
52
|
+
let stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
53
|
+
if (!stream) {
|
|
54
|
+
if (state.streams.length >= MAX_STREAMS) throw new OperatorError('RELAY_RESULT_STREAM_LIMIT', 'Relay result stream limit reached.');
|
|
55
|
+
stream = { deviceId, results: [] };
|
|
56
|
+
state.streams.push(stream);
|
|
57
|
+
state.streams.sort((a, b) => a.deviceId.localeCompare(b.deviceId));
|
|
58
|
+
}
|
|
59
|
+
const existing = stream.results.find((entry) => entry.seq === seq || entry.deliveryId === deliveryId);
|
|
60
|
+
if (existing) {
|
|
61
|
+
if (existing.seq !== seq || existing.deliveryId !== deliveryId || existing.resultSha256 !== resultSha256) {
|
|
62
|
+
throw new OperatorError('RELAY_RESULT_CONFLICT', 'A different result is already stored for this delivery sequence or ID.');
|
|
63
|
+
}
|
|
64
|
+
if (idempotencyKey && existing.idempotencyKey && existing.idempotencyKey !== idempotencyKey) {
|
|
65
|
+
throw new OperatorError('RELAY_RESULT_CONFLICT', 'Stored result replay authority does not match this delivery.');
|
|
66
|
+
}
|
|
67
|
+
if (replayAuthority && existing.replayAuthority && !sameReplayAuthority(existing.replayAuthority, replayAuthority)) {
|
|
68
|
+
throw new OperatorError('RELAY_RESULT_CONFLICT', 'Stored result account authority does not match this delivery.');
|
|
69
|
+
}
|
|
70
|
+
if (idempotencyKey && !existing.idempotencyKey && !existing.consumedAt) existing.idempotencyKey = idempotencyKey;
|
|
71
|
+
if (replayAuthority && !existing.replayAuthority && !existing.consumedAt) existing.replayAuthority = replayAuthority;
|
|
72
|
+
return { result: clone(existing), duplicate: true };
|
|
73
|
+
}
|
|
74
|
+
if (stream.results.length >= MAX_RESULTS_PER_STREAM) throw new OperatorError('RELAY_RESULT_LIMIT', 'Relay result retention limit reached for this device.');
|
|
75
|
+
const stored = { seq, deliveryId, result, resultSha256, idempotencyKey, replayAuthority, recordedAt: this.#clock().toISOString() };
|
|
76
|
+
stream.results.push(stored);
|
|
77
|
+
stream.results.sort((a, b) => a.seq - b.seq);
|
|
78
|
+
return { result: clone(stored), duplicate: false };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async get(deviceIdInput , seqInput ) {
|
|
83
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
84
|
+
const seq = validSeq(seqInput);
|
|
85
|
+
const state = await this.#read();
|
|
86
|
+
const result = state.streams.find((stream) => stream.deviceId === deviceId)?.results.find((entry) => entry.seq === seq);
|
|
87
|
+
if (!result || result.consumedAt || isExpired(result, this.#clock().getTime(), this.#retentionMs)) return null;
|
|
88
|
+
return clone(result);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async findByIdempotencyKey(idempotencyKeyInput ) {
|
|
92
|
+
const idempotencyKey = validIdempotencyKey(idempotencyKeyInput);
|
|
93
|
+
return await this.#mutate((state) => {
|
|
94
|
+
pruneExpired(state, this.#clock().getTime(), this.#retentionMs);
|
|
95
|
+
let found = null;
|
|
96
|
+
for (const stream of state.streams) {
|
|
97
|
+
const match = stream.results.find((entry) => !entry.consumedAt && entry.idempotencyKey === idempotencyKey);
|
|
98
|
+
if (!match) continue;
|
|
99
|
+
if (found) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result replay authority is duplicated.');
|
|
100
|
+
found = { deviceId: stream.deviceId, result: clone(match) };
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async consume(deviceIdInput , seqInput , deliveryIdInput ) {
|
|
107
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
108
|
+
const seq = validSeq(seqInput);
|
|
109
|
+
const deliveryId = validUuid(deliveryIdInput, 'deliveryId');
|
|
110
|
+
return await this.#mutate((state) => {
|
|
111
|
+
pruneExpired(state, this.#clock().getTime(), this.#retentionMs);
|
|
112
|
+
const entry = state.streams.find((stream) => stream.deviceId === deviceId)?.results.find((candidate) => candidate.seq === seq);
|
|
113
|
+
if (!entry || entry.consumedAt) return null;
|
|
114
|
+
if (entry.deliveryId !== deliveryId) throw new OperatorError('RELAY_RESULT_DELIVERY_MISMATCH', 'Stored result does not match the requested delivery ID.');
|
|
115
|
+
if (!entry.result) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Available relay result is missing its payload.');
|
|
116
|
+
const consumed = clone(entry);
|
|
117
|
+
entry.result = undefined;
|
|
118
|
+
entry.idempotencyKey = undefined;
|
|
119
|
+
entry.replayAuthority = undefined;
|
|
120
|
+
entry.consumedAt = this.#clock().toISOString();
|
|
121
|
+
return consumed;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async removeExact(deviceIdInput , seqInput , deliveryIdInput ) {
|
|
126
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
127
|
+
const seq = validSeq(seqInput);
|
|
128
|
+
const deliveryId = validUuid(deliveryIdInput, 'deliveryId');
|
|
129
|
+
return await this.#mutate((state) => {
|
|
130
|
+
const stream = state.streams.find((item) => item.deviceId === deviceId);
|
|
131
|
+
if (!stream) return false;
|
|
132
|
+
const before = stream.results.length;
|
|
133
|
+
stream.results = stream.results.filter((entry) => !(entry.seq === seq && entry.deliveryId === deliveryId));
|
|
134
|
+
if (stream.results.length === 0) state.streams = state.streams.filter((item) => item !== stream);
|
|
135
|
+
return stream.results.length !== before;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async pruneExpired() {
|
|
140
|
+
return await this.#mutate((state) => pruneExpired(state, this.#clock().getTime(), this.#retentionMs));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async has(deviceIdInput , seqInput , deliveryIdInput ) {
|
|
144
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
145
|
+
const seq = validSeq(seqInput);
|
|
146
|
+
const deliveryId = validUuid(deliveryIdInput, 'deliveryId');
|
|
147
|
+
const result = await this.get(deviceId, seq);
|
|
148
|
+
return Boolean(result && result.deliveryId === deliveryId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async purgeDevice(deviceIdInput ) {
|
|
152
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
153
|
+
return await this.#mutate((state) => {
|
|
154
|
+
const stream = state.streams.find((item) => item.deviceId === deviceId);
|
|
155
|
+
if (!stream) return 0;
|
|
156
|
+
const removed = stream.results.length;
|
|
157
|
+
state.streams = state.streams.filter((item) => item.deviceId !== deviceId);
|
|
158
|
+
return removed;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #read() {
|
|
163
|
+
try {
|
|
164
|
+
const text = await readDurableStateText(this.#file, {
|
|
165
|
+
maxBytes: 256 * 1024 * 1024,
|
|
166
|
+
errorCode: 'RELAY_RESULT_STATE_CORRUPT',
|
|
167
|
+
invalidMessage: 'Relay result state is invalid.'
|
|
168
|
+
});
|
|
169
|
+
return validateState(JSON.parse(text));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if ((error ).code === 'ENOENT') return { version: 1, streams: [] };
|
|
172
|
+
if (error instanceof OperatorError) throw error;
|
|
173
|
+
throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result state could not be read.');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async #write(stateInput ) {
|
|
178
|
+
const state = validateState(stateInput);
|
|
179
|
+
await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
|
|
180
|
+
maxBytes: 256 * 1024 * 1024,
|
|
181
|
+
errorCode: 'RELAY_RESULT_STATE_CORRUPT',
|
|
182
|
+
invalidMessage: 'Relay result state is invalid.'
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async #mutate (mutator ) {
|
|
187
|
+
let release ;
|
|
188
|
+
const previous = this.#queue;
|
|
189
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
190
|
+
await previous;
|
|
191
|
+
try {
|
|
192
|
+
const state = await this.#read();
|
|
193
|
+
const value = await mutator(state);
|
|
194
|
+
await this.#write(state);
|
|
195
|
+
return value;
|
|
196
|
+
} finally {
|
|
197
|
+
release();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function boundedRetention(value ) {
|
|
203
|
+
if (value === undefined) return DEFAULT_RETENTION_MS;
|
|
204
|
+
if (!Number.isFinite(value) || value < 60_000 || value > MAX_RETENTION_MS) {
|
|
205
|
+
throw new OperatorError('RELAY_RESULT_RETENTION_INVALID', `Relay result retention must be between 60000 and ${MAX_RETENTION_MS} ms.`);
|
|
206
|
+
}
|
|
207
|
+
return Math.trunc(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isExpired(result , now , retentionMs ) {
|
|
211
|
+
return Date.parse(result.recordedAt) <= now - retentionMs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pruneExpired(state , now , retentionMs ) {
|
|
215
|
+
let removed = 0;
|
|
216
|
+
for (const stream of state.streams) {
|
|
217
|
+
const kept = stream.results.filter((entry) => !isExpired(entry, now, retentionMs));
|
|
218
|
+
removed += stream.results.length - kept.length;
|
|
219
|
+
stream.results = kept;
|
|
220
|
+
}
|
|
221
|
+
state.streams = state.streams.filter((stream) => stream.results.length > 0);
|
|
222
|
+
return removed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function safeResult(input ) {
|
|
226
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_RESULT_INVALID', 'Relay result must be a JSON object.');
|
|
227
|
+
let text ;
|
|
228
|
+
try { text = JSON.stringify(input); } catch { throw new OperatorError('RELAY_RESULT_INVALID', 'Relay result is not JSON serializable.'); }
|
|
229
|
+
if (!text || Buffer.byteLength(text, 'utf8') > MAX_RESULT_BYTES) throw new OperatorError('RELAY_RESULT_TOO_LARGE', 'Relay result exceeds the bounded size.');
|
|
230
|
+
const parsed = JSON.parse(text);
|
|
231
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new OperatorError('RELAY_RESULT_INVALID', 'Relay result must remain a JSON object after serialization.');
|
|
232
|
+
return parsed ;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hashResult(result ) {
|
|
236
|
+
return crypto.createHash('sha256').update(JSON.stringify(result)).digest('hex');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validateState(input ) {
|
|
240
|
+
if (!input || typeof input !== 'object' || input.version !== 1 || !Array.isArray(input.streams) || input.streams.length > MAX_STREAMS) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result state structure is invalid.');
|
|
241
|
+
const devices = new Set ();
|
|
242
|
+
const replayKeys = new Set ();
|
|
243
|
+
const streams = input.streams.map((raw) => {
|
|
244
|
+
const deviceId = validUuid(raw.deviceId, 'deviceId');
|
|
245
|
+
if (devices.has(deviceId)) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result state contains duplicate device streams.');
|
|
246
|
+
devices.add(deviceId);
|
|
247
|
+
if (!Array.isArray(raw.results) || raw.results.length > MAX_RESULTS_PER_STREAM) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result stream exceeds the retention limit.');
|
|
248
|
+
const seqs = new Set ();
|
|
249
|
+
const ids = new Set ();
|
|
250
|
+
const results = raw.results.map((entry) => {
|
|
251
|
+
const seq = validSeq(entry.seq);
|
|
252
|
+
const deliveryId = validUuid(entry.deliveryId, 'deliveryId');
|
|
253
|
+
if (seqs.has(seq) || ids.has(deliveryId)) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result state contains duplicate sequence or delivery ID.');
|
|
254
|
+
seqs.add(seq); ids.add(deliveryId);
|
|
255
|
+
const resultSha256 = String(entry.resultSha256 ?? '');
|
|
256
|
+
if (!/^[0-9a-f]{64}$/.test(resultSha256)) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result hash is invalid.');
|
|
257
|
+
const idempotencyKey = entry.idempotencyKey === undefined ? undefined : validIdempotencyKey(String(entry.idempotencyKey));
|
|
258
|
+
const replayAuthority = entry.replayAuthority === undefined ? undefined : safeReplayAuthority(entry.replayAuthority, deviceId);
|
|
259
|
+
if (replayAuthority && !idempotencyKey) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay replay authority requires an idempotency key.');
|
|
260
|
+
if (idempotencyKey) {
|
|
261
|
+
if (replayKeys.has(idempotencyKey)) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result state contains duplicate replay authority.');
|
|
262
|
+
replayKeys.add(idempotencyKey);
|
|
263
|
+
}
|
|
264
|
+
const recordedAt = validIso(String(entry.recordedAt ?? ''));
|
|
265
|
+
const consumedAt = entry.consumedAt === undefined ? undefined : validIso(String(entry.consumedAt));
|
|
266
|
+
let result ;
|
|
267
|
+
if (consumedAt) {
|
|
268
|
+
if (entry.result !== undefined || idempotencyKey || replayAuthority) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Consumed relay result must not retain payload or replay authority.');
|
|
269
|
+
} else {
|
|
270
|
+
try { result = safeResult(entry.result); }
|
|
271
|
+
catch { throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Available relay result payload is invalid.'); }
|
|
272
|
+
if (resultSha256 !== hashResult(result)) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result hash does not match its stored result.');
|
|
273
|
+
}
|
|
274
|
+
return { seq, deliveryId, result, resultSha256, idempotencyKey, replayAuthority, recordedAt, consumedAt } ;
|
|
275
|
+
}).sort((a, b) => a.seq - b.seq);
|
|
276
|
+
return { deviceId, results };
|
|
277
|
+
});
|
|
278
|
+
return { version: 1, streams };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clone(result ) {
|
|
282
|
+
return { ...result, result: result.result === undefined ? undefined : structuredClone(result.result), replayAuthority: result.replayAuthority ? { ...result.replayAuthority } : undefined };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function safeReplayAuthority(input , expectedDeviceId ) {
|
|
286
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_RESULT_AUTHORITY_INVALID', 'Relay result replay authority is invalid.');
|
|
287
|
+
const raw = input ;
|
|
288
|
+
const accountId = validUuid(String(raw.accountId ?? ''), 'authority accountId');
|
|
289
|
+
const deviceId = validUuid(String(raw.deviceId ?? ''), 'authority deviceId');
|
|
290
|
+
const generation = Number(raw.generation);
|
|
291
|
+
if (deviceId !== expectedDeviceId || !Number.isSafeInteger(generation) || generation < 1) throw new OperatorError('RELAY_RESULT_AUTHORITY_INVALID', 'Relay result replay authority is invalid.');
|
|
292
|
+
return { accountId, deviceId, generation };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function sameReplayAuthority(a , b ) {
|
|
296
|
+
return a.accountId === b.accountId && a.deviceId === b.deviceId && a.generation === b.generation;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validIdempotencyKey(input ) {
|
|
300
|
+
const value = String(input ?? '').toLowerCase();
|
|
301
|
+
if (!/^[0-9a-f]{64}$/.test(value)) throw new OperatorError('RELAY_IDEMPOTENCY_INVALID', 'Relay idempotency key is invalid.');
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validSeq(input ) {
|
|
306
|
+
const seq = Number(input);
|
|
307
|
+
if (!Number.isSafeInteger(seq) || seq < 1) throw new OperatorError('RELAY_RESULT_SEQUENCE_INVALID', 'Relay result sequence must be a positive safe integer.');
|
|
308
|
+
return seq;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function validUuid(input , label ) {
|
|
312
|
+
const value = String(input ?? '');
|
|
313
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new OperatorError('RELAY_RESULT_ID_INVALID', `${label} must be a UUID.`);
|
|
314
|
+
return value.toLowerCase();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function validIso(input ) {
|
|
318
|
+
const time = Date.parse(input);
|
|
319
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== input) throw new OperatorError('RELAY_RESULT_STATE_CORRUPT', 'Relay result timestamp must be ISO format.');
|
|
320
|
+
return input;
|
|
321
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { OperatorError } from './errors.js';
|
|
5
|
+
import { createDurableStateBytes, readDurableStateBytes } from './durable-state.js';
|
|
6
|
+
|
|
7
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_ARTIFACTS = 32;
|
|
9
|
+
const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
10
|
+
const PRODUCT = 'operator-runtime';
|
|
11
|
+
const UPDATE_STAGE_OPTIONS = {
|
|
12
|
+
maxBytes: MAX_ARTIFACT_BYTES,
|
|
13
|
+
errorCode: 'UPDATE_STAGE_INVALID',
|
|
14
|
+
invalidMessage: 'Staged update artifact is invalid.'
|
|
15
|
+
} ;
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export class ReleaseUpdateVerifier {
|
|
44
|
+
#publicKeyPem ;
|
|
45
|
+
|
|
46
|
+
constructor(publicKeyPem ) {
|
|
47
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
48
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new OperatorError('UPDATE_KEY_INVALID', 'Release verification key must be Ed25519.');
|
|
49
|
+
this.#publicKeyPem = key.export({ format: 'pem', type: 'spki' }).toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
verifySignedManifest(input , options ) {
|
|
53
|
+
const manifest = validateManifest(input.manifest);
|
|
54
|
+
if (manifest.channel !== options.channel) throw new OperatorError('UPDATE_CHANNEL_MISMATCH', 'Release manifest channel does not match the configured update channel.');
|
|
55
|
+
if (manifest.channel === 'stable' && manifest.version.includes('-')) throw new OperatorError('UPDATE_CHANNEL_MISMATCH', 'Stable channel manifests cannot publish prerelease versions.');
|
|
56
|
+
const current = validVersion(options.currentVersion);
|
|
57
|
+
if (compareVersions(manifest.version, current) <= 0) throw new OperatorError('UPDATE_NOT_NEWER', 'Release manifest version is not newer than the installed version.');
|
|
58
|
+
const now = options.now ?? new Date();
|
|
59
|
+
const publishedMs = Date.parse(manifest.publishedAt);
|
|
60
|
+
if (publishedMs > now.getTime() + 5 * 60_000) throw new OperatorError('UPDATE_MANIFEST_FUTURE', 'Release manifest publication time is too far in the future.');
|
|
61
|
+
const signature = String(input.signature ?? '');
|
|
62
|
+
if (!/^[A-Za-z0-9_-]{40,256}$/.test(signature)) throw new OperatorError('UPDATE_SIGNATURE_INVALID', 'Release manifest signature is invalid.');
|
|
63
|
+
const bytes = encodeManifest(manifest);
|
|
64
|
+
const ok = crypto.verify(null, bytes, this.#publicKeyPem, Buffer.from(signature, 'base64url'));
|
|
65
|
+
if (!ok) throw new OperatorError('UPDATE_SIGNATURE_INVALID', 'Release manifest signature could not be verified.');
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
selectArtifact(manifestInput , target ) {
|
|
70
|
+
const manifest = validateManifest(manifestInput);
|
|
71
|
+
const kinds = target.kinds ?? ['msixbundle', 'msix', 'zip'];
|
|
72
|
+
for (const kind of kinds) {
|
|
73
|
+
const matches = manifest.artifacts.filter((artifact) => artifact.platform === target.platform && artifact.arch === target.arch && artifact.kind === kind);
|
|
74
|
+
if (matches.length > 1) throw new OperatorError('UPDATE_ARTIFACT_AMBIGUOUS', 'Release manifest contains multiple matching artifacts.');
|
|
75
|
+
if (matches.length === 1) return { ...matches[0] };
|
|
76
|
+
}
|
|
77
|
+
throw new OperatorError('UPDATE_ARTIFACT_NOT_FOUND', 'Release manifest has no artifact for this platform and architecture.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async verifyAndStageArtifact(artifactInput , bytesInput , stateDir , version ) {
|
|
81
|
+
const artifact = validateArtifact(artifactInput);
|
|
82
|
+
const versionText = validVersion(version);
|
|
83
|
+
const bytes = Buffer.from(bytesInput);
|
|
84
|
+
if (bytes.byteLength !== artifact.sizeBytes) throw new OperatorError('UPDATE_SIZE_MISMATCH', 'Downloaded update size does not match the signed release manifest.');
|
|
85
|
+
if (bytes.byteLength > MAX_ARTIFACT_BYTES) throw new OperatorError('UPDATE_ARTIFACT_TOO_LARGE', 'Update artifact exceeds the maximum allowed size.');
|
|
86
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
87
|
+
if (sha256 !== artifact.sha256) throw new OperatorError('UPDATE_HASH_MISMATCH', 'Downloaded update hash does not match the signed release manifest.');
|
|
88
|
+
|
|
89
|
+
const root = path.resolve(stateDir);
|
|
90
|
+
const updatesDir = path.join(root, 'updates');
|
|
91
|
+
const stagingDir = path.join(updatesDir, versionText);
|
|
92
|
+
await ensureRealUpdateDirectory(root, true);
|
|
93
|
+
await ensureRealUpdateDirectory(updatesDir);
|
|
94
|
+
await ensureRealUpdateDirectory(stagingDir);
|
|
95
|
+
const extension = artifact.kind === 'msixbundle' ? '.msixbundle' : artifact.kind === 'msix' ? '.msix' : '.zip';
|
|
96
|
+
const finalPath = path.join(stagingDir, `operator-${artifact.platform}-${artifact.arch}${extension}`);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const existing = await readDurableStateBytes(finalPath, UPDATE_STAGE_OPTIONS);
|
|
100
|
+
const existingSha = crypto.createHash('sha256').update(existing).digest('hex');
|
|
101
|
+
if (existing.byteLength === bytes.byteLength && existingSha === sha256) return { path: finalPath, sha256, sizeBytes: bytes.byteLength };
|
|
102
|
+
throw new OperatorError('UPDATE_STAGE_CONFLICT', 'A different artifact is already staged for this release target.');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof OperatorError) throw error;
|
|
105
|
+
if ((error ).code !== 'ENOENT') throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await createDurableStateBytes(finalPath, bytes, UPDATE_STAGE_OPTIONS);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error ).code === 'EEXIST') {
|
|
112
|
+
throw new OperatorError('UPDATE_STAGE_CONFLICT', 'An update artifact appeared concurrently at the staging path.');
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const committed = await readDurableStateBytes(finalPath, UPDATE_STAGE_OPTIONS);
|
|
118
|
+
const committedSha = crypto.createHash('sha256').update(committed).digest('hex');
|
|
119
|
+
if (committed.byteLength !== bytes.byteLength || committedSha !== sha256) {
|
|
120
|
+
throw new OperatorError('UPDATE_STAGE_INVALID', 'Staged update artifact failed post-write verification.');
|
|
121
|
+
}
|
|
122
|
+
return { path: finalPath, sha256, sizeBytes: bytes.byteLength };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function signReleaseManifest(manifestInput , privateKeyPem ) {
|
|
127
|
+
const manifest = validateManifest(manifestInput);
|
|
128
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
129
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new OperatorError('UPDATE_KEY_INVALID', 'Release signing key must be Ed25519.');
|
|
130
|
+
return {
|
|
131
|
+
manifest,
|
|
132
|
+
signature: crypto.sign(null, encodeManifest(manifest), key).toString('base64url')
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function encodeManifest(manifestInput ) {
|
|
137
|
+
const manifest = validateManifest(manifestInput);
|
|
138
|
+
const canonical = {
|
|
139
|
+
schemaVersion: 1,
|
|
140
|
+
product: PRODUCT,
|
|
141
|
+
channel: manifest.channel,
|
|
142
|
+
version: manifest.version,
|
|
143
|
+
publishedAt: manifest.publishedAt,
|
|
144
|
+
artifacts: manifest.artifacts.map((artifact) => ({
|
|
145
|
+
platform: artifact.platform,
|
|
146
|
+
arch: artifact.arch,
|
|
147
|
+
kind: artifact.kind,
|
|
148
|
+
url: artifact.url,
|
|
149
|
+
sha256: artifact.sha256,
|
|
150
|
+
sizeBytes: artifact.sizeBytes
|
|
151
|
+
}))
|
|
152
|
+
};
|
|
153
|
+
const bytes = Buffer.from(JSON.stringify(canonical), 'utf8');
|
|
154
|
+
if (bytes.byteLength > MAX_MANIFEST_BYTES) throw new OperatorError('UPDATE_MANIFEST_TOO_LARGE', 'Release manifest exceeds the maximum allowed size.');
|
|
155
|
+
return bytes;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateManifest(input ) {
|
|
159
|
+
if (!input || typeof input !== 'object' || input.schemaVersion !== 1 || input.product !== PRODUCT) throw new OperatorError('UPDATE_MANIFEST_INVALID', 'Release manifest schema or product is invalid.');
|
|
160
|
+
const channel = input.channel === 'stable' ? 'stable' : input.channel === 'beta' ? 'beta' : null;
|
|
161
|
+
if (!channel) throw new OperatorError('UPDATE_MANIFEST_INVALID', 'Release channel is invalid.');
|
|
162
|
+
const version = validVersion(String(input.version ?? ''));
|
|
163
|
+
const publishedAt = validIso(String(input.publishedAt ?? ''));
|
|
164
|
+
if (!Array.isArray(input.artifacts) || input.artifacts.length < 1 || input.artifacts.length > MAX_ARTIFACTS) throw new OperatorError('UPDATE_MANIFEST_INVALID', `Release manifest must contain 1-${MAX_ARTIFACTS} artifacts.`);
|
|
165
|
+
const artifacts = input.artifacts.map(validateArtifact);
|
|
166
|
+
const seen = new Set ();
|
|
167
|
+
for (const artifact of artifacts) {
|
|
168
|
+
const key = `${artifact.platform}:${artifact.arch}:${artifact.kind}`;
|
|
169
|
+
if (seen.has(key)) throw new OperatorError('UPDATE_MANIFEST_INVALID', `Duplicate release artifact ${key}.`);
|
|
170
|
+
seen.add(key);
|
|
171
|
+
}
|
|
172
|
+
artifacts.sort((a, b) => `${a.platform}:${a.arch}:${a.kind}`.localeCompare(`${b.platform}:${b.arch}:${b.kind}`));
|
|
173
|
+
return { schemaVersion: 1, product: PRODUCT, channel, version, publishedAt, artifacts };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateArtifact(input ) {
|
|
177
|
+
if (!input || typeof input !== 'object') throw new OperatorError('UPDATE_ARTIFACT_INVALID', 'Release artifact is invalid.');
|
|
178
|
+
const platform = input.platform === 'win32' || input.platform === 'linux' || input.platform === 'darwin' ? input.platform : null;
|
|
179
|
+
const arch = input.arch === 'x64' || input.arch === 'arm64' ? input.arch : null;
|
|
180
|
+
const kind = input.kind === 'msix' || input.kind === 'msixbundle' || input.kind === 'zip' ? input.kind : null;
|
|
181
|
+
if (!platform || !arch || !kind) throw new OperatorError('UPDATE_ARTIFACT_INVALID', 'Release artifact platform, architecture, or kind is invalid.');
|
|
182
|
+
let url ;
|
|
183
|
+
try { url = new URL(String(input.url)); } catch { throw new OperatorError('UPDATE_URL_INVALID', 'Release artifact URL is invalid.'); }
|
|
184
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new OperatorError('UPDATE_URL_INVALID', 'Release artifact URL must use HTTPS and must not contain credentials.');
|
|
185
|
+
const sha256 = String(input.sha256 ?? '').toLowerCase();
|
|
186
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new OperatorError('UPDATE_HASH_INVALID', 'Release artifact SHA-256 is invalid.');
|
|
187
|
+
const sizeBytes = Number(input.sizeBytes);
|
|
188
|
+
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1 || sizeBytes > MAX_ARTIFACT_BYTES) throw new OperatorError('UPDATE_SIZE_INVALID', 'Release artifact size is outside the allowed range.');
|
|
189
|
+
return { platform, arch, kind, url: url.toString(), sha256, sizeBytes };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validVersion(value ) {
|
|
193
|
+
const text = String(value ?? '');
|
|
194
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.test(text) || text.length > 128) {
|
|
195
|
+
throw new OperatorError('UPDATE_VERSION_INVALID', 'Release version must be a bounded semantic version.');
|
|
196
|
+
}
|
|
197
|
+
const prerelease = text.split('-', 2)[1];
|
|
198
|
+
if (prerelease) {
|
|
199
|
+
for (const identifier of prerelease.split('.')) {
|
|
200
|
+
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
|
|
201
|
+
throw new OperatorError('UPDATE_VERSION_INVALID', 'Numeric prerelease identifiers must not contain leading zeroes.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function compareVersions(aInput , bInput ) {
|
|
209
|
+
const a = parseVersion(validVersion(aInput));
|
|
210
|
+
const b = parseVersion(validVersion(bInput));
|
|
211
|
+
for (let i = 0; i < 3; i += 1) {
|
|
212
|
+
if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i] ? 1 : -1;
|
|
213
|
+
}
|
|
214
|
+
if (a.pre.length === 0 && b.pre.length === 0) return 0;
|
|
215
|
+
if (a.pre.length === 0) return 1;
|
|
216
|
+
if (b.pre.length === 0) return -1;
|
|
217
|
+
const length = Math.max(a.pre.length, b.pre.length);
|
|
218
|
+
for (let i = 0; i < length; i += 1) {
|
|
219
|
+
const left = a.pre[i]; const right = b.pre[i];
|
|
220
|
+
if (left === undefined) return -1;
|
|
221
|
+
if (right === undefined) return 1;
|
|
222
|
+
if (left === right) continue;
|
|
223
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
224
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
225
|
+
if (leftNumeric && rightNumeric) return BigInt(left) > BigInt(right) ? 1 : -1;
|
|
226
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
227
|
+
return left > right ? 1 : -1;
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseVersion(value ) {
|
|
233
|
+
const [core, prerelease] = value.split('-', 2);
|
|
234
|
+
return { nums: core.split('.').map((part) => BigInt(part)), pre: prerelease ? prerelease.split('.') : [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validIso(value ) {
|
|
238
|
+
const time = Date.parse(value);
|
|
239
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) throw new OperatorError('UPDATE_TIME_INVALID', 'Release publication time must be an ISO timestamp.');
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureRealUpdateDirectory(directory , recursive = false) {
|
|
244
|
+
try {
|
|
245
|
+
await fs.mkdir(directory, { recursive, mode: 0o700 });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if ((error ).code !== 'EEXIST') throw error;
|
|
248
|
+
}
|
|
249
|
+
const stat = await fs.lstat(directory);
|
|
250
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
251
|
+
throw new OperatorError('UPDATE_STAGE_INVALID', 'Update staging directories must be real directories, not links or special files.');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
|
|
2
|
+
import { OperatorError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const WEIGHTS = {
|
|
6
|
+
reliability: 0.24,
|
|
7
|
+
latency: 0.13,
|
|
8
|
+
determinism: 0.19,
|
|
9
|
+
security: 0.17,
|
|
10
|
+
reversibility: 0.10,
|
|
11
|
+
informationQuality: 0.12,
|
|
12
|
+
interactionCost: 0.05
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function normalize(score ) {
|
|
16
|
+
if (!Number.isFinite(score)) return 0;
|
|
17
|
+
return Math.max(0, Math.min(1, score));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function weightedScore(score ) {
|
|
21
|
+
return Object.entries(WEIGHTS).reduce((total, [key, weight]) => {
|
|
22
|
+
const raw = score[key ];
|
|
23
|
+
const value = key === 'latency' || key === 'interactionCost' ? 1 - normalize(raw) : normalize(raw);
|
|
24
|
+
return total + value * weight;
|
|
25
|
+
}, 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CapabilityRouter {
|
|
29
|
+
#providers = [];
|
|
30
|
+
#learning ;
|
|
31
|
+
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.#learning = options.learning;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
register(provider ) {
|
|
37
|
+
this.#providers.push(provider);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async supports(action ) {
|
|
41
|
+
for (const provider of this.#providers) {
|
|
42
|
+
if (await provider.supports(action)) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async advertises(action ) {
|
|
48
|
+
for (const provider of this.#providers) {
|
|
49
|
+
if (!await provider.supports(action)) continue;
|
|
50
|
+
if (!provider.advertises || await provider.advertises(action)) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async rank(action , learningContext = 'global') {
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const provider of this.#providers) {
|
|
58
|
+
if (await provider.supports(action)) {
|
|
59
|
+
const baseScore = weightedScore(await provider.score(action));
|
|
60
|
+
const learnedAdjustment = this.#learning ? await this.#learning.adjustment(action.capability, provider.name, learningContext) : 0;
|
|
61
|
+
candidates.push({ provider, baseScore, learnedAdjustment, score: normalize(baseScore + learnedAdjustment) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
candidates.sort((a, b) => b.score - a.score || b.baseScore - a.baseScore || a.provider.name.localeCompare(b.provider.name));
|
|
65
|
+
return candidates;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async recordOutcome(capability , providerName , outcome , metadata = {}) {
|
|
69
|
+
if (!this.#learning) return false;
|
|
70
|
+
if (!this.#providers.some((provider) => provider.name === providerName)) return false;
|
|
71
|
+
await this.#learning.record(capability, providerName, outcome, metadata);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async resolveRisk(action ) {
|
|
76
|
+
const risks = new Set ();
|
|
77
|
+
for (const provider of this.#providers) {
|
|
78
|
+
if (await provider.supports(action) && provider.resolveRisk) risks.add(await provider.resolveRisk(action));
|
|
79
|
+
}
|
|
80
|
+
if (risks.size !== 1) throw new OperatorError('CAPABILITY_RISK_UNRESOLVED', `Capability ${action.capability} does not have one trusted dynamic risk.`);
|
|
81
|
+
return [...risks][0] ;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async select(action , learningContext = 'global') {
|
|
85
|
+
const ranked = await this.rank(action, learningContext);
|
|
86
|
+
if (!ranked[0]) {
|
|
87
|
+
throw new OperatorError('CAPABILITY_UNAVAILABLE', `No provider can execute ${action.capability}.`);
|
|
88
|
+
}
|
|
89
|
+
return ranked[0].provider;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async closeAll() {
|
|
93
|
+
const providers = [...this.#providers].reverse();
|
|
94
|
+
await Promise.allSettled(providers.map(async (provider) => provider.close?.()));
|
|
95
|
+
}
|
|
96
|
+
}
|