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.
Files changed (82) hide show
  1. package/DISCLOSURE +47 -0
  2. package/LICENSE +64 -0
  3. package/README.md +44 -0
  4. package/bin/operator.mjs +10 -0
  5. package/package.json +58 -0
  6. package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
  7. package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
  8. package/runtime/app/apps/local-agent/src/cli.js +237 -0
  9. package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
  10. package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
  11. package/runtime/app/apps/local-agent/src/main.js +311 -0
  12. package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
  13. package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
  14. package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
  15. package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
  16. package/runtime/app/apps/local-agent/src/remote.js +130 -0
  17. package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
  18. package/runtime/app/apps/local-agent/src/server.js +578 -0
  19. package/runtime/app/package.json +4 -0
  20. package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
  21. package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
  22. package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
  23. package/runtime/app/src/capabilities/browser-cdp.js +384 -0
  24. package/runtime/app/src/capabilities/browser-managed.js +345 -0
  25. package/runtime/app/src/capabilities/docker.js +448 -0
  26. package/runtime/app/src/capabilities/filesystem.js +264 -0
  27. package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
  28. package/runtime/app/src/capabilities/git-write.js +321 -0
  29. package/runtime/app/src/capabilities/git.js +252 -0
  30. package/runtime/app/src/capabilities/index.js +16 -0
  31. package/runtime/app/src/capabilities/path-scope.js +86 -0
  32. package/runtime/app/src/capabilities/postgres.js +566 -0
  33. package/runtime/app/src/capabilities/process.js +258 -0
  34. package/runtime/app/src/capabilities/project-command.js +599 -0
  35. package/runtime/app/src/capabilities/project-transaction.js +215 -0
  36. package/runtime/app/src/capabilities/project.js +67 -0
  37. package/runtime/app/src/capabilities/system.js +40 -0
  38. package/runtime/app/src/capabilities/vscode.js +312 -0
  39. package/runtime/app/src/capabilities/windows-uia.js +331 -0
  40. package/runtime/app/src/core/account-device-registry.js +604 -0
  41. package/runtime/app/src/core/action-identity.js +44 -0
  42. package/runtime/app/src/core/audit.js +295 -0
  43. package/runtime/app/src/core/capability-policy.js +58 -0
  44. package/runtime/app/src/core/child-environment.js +29 -0
  45. package/runtime/app/src/core/device-enrollment.js +417 -0
  46. package/runtime/app/src/core/device-identity.js +436 -0
  47. package/runtime/app/src/core/device-registry.js +477 -0
  48. package/runtime/app/src/core/device-reset.js +178 -0
  49. package/runtime/app/src/core/device-routing.js +305 -0
  50. package/runtime/app/src/core/durable-state.js +306 -0
  51. package/runtime/app/src/core/errors.js +20 -0
  52. package/runtime/app/src/core/evidence.js +9 -0
  53. package/runtime/app/src/core/index.js +17 -0
  54. package/runtime/app/src/core/machine-state.js +160 -0
  55. package/runtime/app/src/core/network-authority.js +20 -0
  56. package/runtime/app/src/core/policy.js +67 -0
  57. package/runtime/app/src/core/product-identity.js +7 -0
  58. package/runtime/app/src/core/provenance.js +18 -0
  59. package/runtime/app/src/core/provider-learning.js +274 -0
  60. package/runtime/app/src/core/public-plugin-surface.js +25 -0
  61. package/runtime/app/src/core/public-restricted-data.js +105 -0
  62. package/runtime/app/src/core/rate-limit.js +118 -0
  63. package/runtime/app/src/core/relay-client.js +568 -0
  64. package/runtime/app/src/core/relay-delivery-store.js +512 -0
  65. package/runtime/app/src/core/relay-result-store.js +321 -0
  66. package/runtime/app/src/core/release-update.js +253 -0
  67. package/runtime/app/src/core/router.js +96 -0
  68. package/runtime/app/src/core/runtime.js +136 -0
  69. package/runtime/app/src/core/scoped-path-syntax.js +16 -0
  70. package/runtime/app/src/core/session-token.js +498 -0
  71. package/runtime/app/src/core/task-orchestrator.js +1148 -0
  72. package/runtime/app/src/core/task-store.js +517 -0
  73. package/runtime/app/src/core/task.js +142 -0
  74. package/runtime/app/src/core/trusted-executable.js +124 -0
  75. package/runtime/app/src/core/types.js +83 -0
  76. package/runtime/app/src/core/windows-path-lease.js +138 -0
  77. package/runtime/native/operator-windows-dpapi.exe +0 -0
  78. package/runtime/native/operator-windows-path-lease.exe +0 -0
  79. package/runtime/native/operator-windows-uia.exe +0 -0
  80. package/runtime/runtime-manifest.json +380 -0
  81. package/src/cli.mjs +345 -0
  82. package/src/publish-check.mjs +22 -0
@@ -0,0 +1,333 @@
1
+ import path from 'node:path';
2
+ import { DeviceIdentityStore } from '../../../src/core/device-identity.js';
3
+ import { OperatorError } from '../../../src/core/errors.js';
4
+ import { RelayClient, } from '../../../src/core/relay-client.js';
5
+ import { RelayResultStore } from '../../../src/core/relay-result-store.js';
6
+
7
+
8
+ import { containsRestrictedData } from '../../../src/core/public-restricted-data.js';
9
+ import { readRelaySessionTokenFile, } from './relay-session-credentials.js';
10
+
11
+ const MAX_RESULT_BYTES = 256 * 1024;
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+ export class LocalAgentRelayRunner {
31
+ #client ;
32
+ #identity ;
33
+ #outbox ;
34
+ #sessionCredentials ;
35
+ #resultUrl ;
36
+ #localAgentBaseUrl ;
37
+ #localExecuteUrl ;
38
+ #agentToken ;
39
+
40
+ constructor(options ) {
41
+ this.#identity = options.identity;
42
+ this.#outbox = new RelayResultStore(path.join(path.resolve(options.stateDir), 'relay-outbox'));
43
+ const legacyFile = path.resolve(options.sessionTokenFile);
44
+ this.#sessionCredentials = options.sessionCredentials ?? {
45
+ forConnection: () => readRelaySessionTokenFile(legacyFile),
46
+ forRequest: () => readRelaySessionTokenFile(legacyFile),
47
+ stop: () => undefined
48
+ };
49
+ this.#resultUrl = validateResultUrl(options.resultUrl ?? deriveResultUrl(options.relayUrl), options.relayUrl, Boolean(options.allowLoopbackInsecure));
50
+ this.#localAgentBaseUrl = ensureHttpBase(options.localAgentBaseUrl);
51
+ this.#localExecuteUrl = new URL('/v1/execute', this.#localAgentBaseUrl).toString();
52
+ this.#agentToken = options.agentToken;
53
+ this.#client = new RelayClient({
54
+ stateDir: options.stateDir,
55
+ url: options.relayUrl,
56
+ identity: options.identity,
57
+ socketFactory: options.socketFactory,
58
+ allowLoopbackInsecureWs: Boolean(options.allowLoopbackInsecure),
59
+ getSessionToken: () => this.#sessionCredentials.forConnection(),
60
+ supportedCapabilities: options.supportedCapabilities,
61
+ getSupportedCapabilities: options.getSupportedCapabilities,
62
+ onDelivery: (delivery) => this.#handleDelivery(delivery),
63
+ onRecovery: (context) => this.#recoverStoredResult(context.delivery.seq, context.delivery.id, context.delivery),
64
+ onExpiredRecovery: (context) => this.#recoverStoredResult(context.processing.seq, context.processing.id),
65
+ onAcknowledged: (delivery) => this.#discardStoredResult(delivery.seq, delivery.id)
66
+ });
67
+ }
68
+
69
+ run() { return this.#client.run(); }
70
+ stop() { this.#client.stop(); this.#sessionCredentials.stop(); }
71
+ reconnect() { this.#client.reconnect(); }
72
+ state() { return this.#client.state(); }
73
+
74
+ async #handleDelivery(delivery ) {
75
+ const identity = await this.#identity.loadOrCreate();
76
+ const result = delivery.kind === 'action'
77
+ ? await this.#executeActionPayload(delivery.payload)
78
+ : delivery.kind === 'task'
79
+ ? await this.#executeTaskPayload(delivery.payload)
80
+ : { ok: false, error: { code: 'RELAY_DELIVERY_KIND_UNSUPPORTED', message: `Unsupported relay delivery kind ${delivery.kind}.` } };
81
+ const safe = boundedResult(result);
82
+ await this.#outbox.put(identity.deviceId, delivery.seq, delivery.id, safe);
83
+ await this.#submitResult(delivery.seq, delivery.id, safe);
84
+ }
85
+
86
+ async #recoverStoredResult(seq , deliveryId , delivery ) {
87
+ const identity = await this.#identity.loadOrCreate();
88
+ const stored = await this.#outbox.get(identity.deviceId, seq);
89
+ if (!stored || stored.deliveryId !== deliveryId) return delivery && canRetryUncertainRelayDelivery(delivery) ? 'retry' : 'stop';
90
+ await this.#submitResult(seq, deliveryId, stored.result);
91
+ return 'ack';
92
+ }
93
+
94
+ async #discardStoredResult(seq , deliveryId ) {
95
+ const identity = await this.#identity.loadOrCreate();
96
+ await this.#outbox.removeExact(identity.deviceId, seq, deliveryId);
97
+ }
98
+
99
+ async #executeActionPayload(payload ) {
100
+ const action = validateRemoteAction(payload.action);
101
+ const approvalAuthority = validateApprovalAuthority(payload.approvalAuthority);
102
+ const publicBoundary = payload.publicBoundary === true;
103
+ if (publicBoundary && containsRestrictedData(action.input)) return restrictedDataBlockedResult(action.capability);
104
+ const response = await fetch(this.#localExecuteUrl, {
105
+ redirect: 'error',
106
+ method: 'POST',
107
+ headers: {
108
+ 'content-type': 'application/json',
109
+ authorization: `Bearer ${this.#agentToken}`
110
+ },
111
+ body: JSON.stringify({ action, ...(approvalAuthority ? { approvalAuthority } : {}) })
112
+ });
113
+ let body ;
114
+ try { body = await response.json(); }
115
+ catch { throw new OperatorError('RELAY_LOCAL_RESULT_INVALID', 'Local agent returned a non-JSON execution response.', { retryable: false }); }
116
+ if (publicBoundary && containsRestrictedData(body)) return restrictedDataBlockedResult(action.capability);
117
+ if (![200, 409, 423].includes(response.status)) {
118
+ throw new OperatorError('RELAY_LOCAL_EXECUTION_UNCERTAIN', `Local agent returned HTTP ${response.status}; execution state cannot be safely inferred.`, { retryable: false });
119
+ }
120
+ return boundedResult(body);
121
+ }
122
+
123
+ async #executeTaskPayload(payload ) {
124
+ const task = validateRelayTaskRequest(payload.task);
125
+ if (task.operation === 'submit') {
126
+ return await this.#callTaskApi('/v1/tasks', 'POST', task.request);
127
+ }
128
+
129
+ const path = `/v1/tasks/${task.taskId}`;
130
+ const current = await this.#callTaskApi(path, 'GET');
131
+ if (!current.ok || task.operation === 'inspect') return current;
132
+ const state = current.task && typeof current.task === 'object' ? String((current.task ).state ?? '') : '';
133
+ if (task.operation === 'pause' && state === 'PAUSED') return current;
134
+ if (task.operation === 'cancel' && state === 'CANCELLED') return current;
135
+ if (task.operation === 'run' && ['VERIFIED', 'FAILED', 'CANCELLED'].includes(state)) return current;
136
+ if (task.operation === 'resume' && ['VERIFIED', 'FAILED'].includes(state)) return current;
137
+ return await this.#callTaskApi(`${path}/${task.operation}`, 'POST', {});
138
+ }
139
+
140
+ async #callTaskApi(pathname , method , body ) {
141
+ const response = await fetch(new URL(pathname, this.#localAgentBaseUrl), {
142
+ redirect: 'error', method,
143
+ headers: { ...(method === 'POST' ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${this.#agentToken}` },
144
+ ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {})
145
+ });
146
+ let bodyValue ;
147
+ try { bodyValue = await response.json(); }
148
+ catch { throw new OperatorError('RELAY_LOCAL_TASK_RESULT_INVALID', 'Local agent returned a non-JSON durable task response.', { retryable: false }); }
149
+ if (!bodyValue || typeof bodyValue !== 'object' || Array.isArray(bodyValue) || typeof (bodyValue ).ok !== 'boolean') {
150
+ throw new OperatorError('RELAY_LOCAL_TASK_RESULT_INVALID', 'Local agent returned a malformed durable task response.', { retryable: false });
151
+ }
152
+ if (![200, 202, 400, 403, 404, 409, 423, 503].includes(response.status)) {
153
+ throw new OperatorError('RELAY_LOCAL_TASK_UNCERTAIN', `Local task API returned HTTP ${response.status}; durable task state must be reconciled before retry.`, { retryable: true });
154
+ }
155
+ return boundedResult(bodyValue);
156
+ }
157
+
158
+ async #submitResult(seq , deliveryId , result ) {
159
+ const token = await this.#sessionCredentials.forRequest();
160
+ let response ;
161
+ try {
162
+ response = await fetch(this.#resultUrl, {
163
+ redirect: 'error',
164
+ method: 'POST',
165
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
166
+ body: JSON.stringify({ seq, deliveryId, result })
167
+ });
168
+ } catch (error) {
169
+ throw new OperatorError('RELAY_RESULT_SUBMIT_FAILED', `Relay result submission failed: ${error instanceof Error ? error.message : String(error)}`, { retryable: true });
170
+ }
171
+ if (response.status === 200) return;
172
+ let code = 'RELAY_RESULT_SUBMIT_FAILED';
173
+ try { code = String((await response.json() )?.error?.code ?? code); } catch { /* bounded generic failure */ }
174
+ const retryable = response.status === 401 || response.status === 408 || response.status === 429 || response.status >= 500;
175
+ throw new OperatorError(code, `Relay result service rejected result with HTTP ${response.status}.`, { retryable });
176
+ }
177
+
178
+
179
+ }
180
+
181
+ export { readRelaySessionTokenFile } from './relay-session-credentials.js';
182
+
183
+ export function canRetryUncertainRelayDelivery(delivery ) {
184
+ try {
185
+ if (delivery.kind === 'task') { validateRelayTaskRequest(delivery.payload.task); return true; }
186
+ if (delivery.kind === 'action') return validateRemoteAction(delivery.payload.action).risk === 'read';
187
+ return false;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+
194
+
195
+
196
+
197
+ function validateRelayTaskRequest(input ) {
198
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task payload must be an object.');
199
+ const raw = input ;
200
+ const operation = String(raw.operation ?? '');
201
+ if (operation === 'submit') {
202
+ if (!raw.request || typeof raw.request !== 'object' || Array.isArray(raw.request)) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task submit payload requires a request object.');
203
+ const request = structuredClone(raw.request );
204
+ request.requestId = validTaskUuid(request.requestId, 'requestId');
205
+ if (!request.goal || typeof request.goal !== 'object' || Array.isArray(request.goal)) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task submit payload requires a goal object.');
206
+ const text = JSON.stringify(request);
207
+ if (Buffer.byteLength(text, 'utf8') > 256 * 1024) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task submit payload exceeds the bounded size.');
208
+ return { operation: 'submit', request };
209
+ }
210
+ if (!['inspect', 'run', 'pause', 'resume', 'cancel'].includes(operation)) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task operation is invalid.');
211
+ if (raw.approvedActionId !== undefined) throw new OperatorError('RELAY_TASK_INVALID', 'Relay task control cannot carry local approval authority.');
212
+ return { operation: operation , taskId: validTaskUuid(raw.taskId, 'taskId') };
213
+ }
214
+
215
+ function validTaskUuid(input , label ) {
216
+ const value = String(input ?? '').toLowerCase();
217
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) throw new OperatorError('RELAY_TASK_INVALID', `Relay task ${label} must be a UUID.`);
218
+ return value;
219
+ }
220
+
221
+ function validateRemoteAction(input ) {
222
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_ACTION_INVALID', 'Relay action payload must contain an action object.');
223
+ const raw = input ;
224
+ const id = boundedName(raw.id, 'action id');
225
+ const capability = boundedName(raw.capability, 'capability');
226
+ const risk = raw.risk;
227
+ if (!['read', 'write', 'external', 'system', 'destructive'].includes(String(risk))) throw new OperatorError('RELAY_ACTION_INVALID', 'Relay action risk is invalid.');
228
+ if (!raw.input || typeof raw.input !== 'object' || Array.isArray(raw.input)) throw new OperatorError('RELAY_ACTION_INVALID', 'Relay action input must be an object.');
229
+ const inputText = JSON.stringify(raw.input);
230
+ if (Buffer.byteLength(inputText, 'utf8') > 128 * 1024) throw new OperatorError('RELAY_ACTION_INVALID', 'Relay action input exceeds the bounded size.');
231
+ if (!raw.provenance || typeof raw.provenance !== 'object' || Array.isArray(raw.provenance) || (raw.provenance ).kind !== 'chatgpt') {
232
+ throw new OperatorError('RELAY_ACTION_PROVENANCE_INVALID', 'Remote relay actions must carry ChatGPT instruction provenance.');
233
+ }
234
+ const taskId = raw.taskId === undefined ? undefined : boundedName(raw.taskId, 'task id');
235
+ const target = raw.target === undefined ? undefined : boundedText(raw.target, 'target', 2048);
236
+ return {
237
+ id,
238
+ capability,
239
+ risk: risk ,
240
+ input: structuredClone(raw.input ),
241
+ provenance: { kind: 'chatgpt', source: (raw.provenance ).source === undefined ? undefined : boundedText((raw.provenance ).source, 'provenance source', 512) },
242
+ taskId,
243
+ target
244
+ };
245
+ }
246
+
247
+ function validateApprovalAuthority(input ) {
248
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
249
+ throw new OperatorError('RELAY_APPROVAL_AUTHORITY_INVALID', 'Relay approval authority is invalid.');
250
+ }
251
+ const raw = input ;
252
+ const accountId = String(raw.accountId ?? '');
253
+ const deviceId = String(raw.deviceId ?? '');
254
+ const generation = Number(raw.generation);
255
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
256
+ if (!uuid.test(accountId) || !uuid.test(deviceId) || !Number.isSafeInteger(generation) || generation < 1) {
257
+ throw new OperatorError('RELAY_APPROVAL_AUTHORITY_INVALID', 'Relay approval authority is invalid.');
258
+ }
259
+ return { accountId: accountId.toLowerCase(), deviceId: deviceId.toLowerCase(), generation };
260
+ }
261
+
262
+ function restrictedDataBlockedResult(capability ) {
263
+ return {
264
+ ok: false,
265
+ capability,
266
+ provider: 'public-boundary',
267
+ error: {
268
+ code: 'RESTRICTED_DATA_BLOCKED',
269
+ message: 'The public plugin refused content that may contain restricted data.'
270
+ },
271
+ evidence: [],
272
+ durationMs: 0
273
+ };
274
+ }
275
+
276
+ function boundedResult(input ) {
277
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_LOCAL_RESULT_INVALID', 'Relay action result must be a JSON object.');
278
+ let text ;
279
+ try { text = JSON.stringify(input); } catch { throw new OperatorError('RELAY_LOCAL_RESULT_INVALID', 'Relay action result is not JSON serializable.'); }
280
+ if (!text || Buffer.byteLength(text, 'utf8') > MAX_RESULT_BYTES) throw new OperatorError('RELAY_LOCAL_RESULT_TOO_LARGE', 'Relay action result exceeds the bounded result size.');
281
+ return JSON.parse(text) ;
282
+ }
283
+
284
+ function boundedName(input , label ) {
285
+ const value = String(input ?? '');
286
+ if (!value || value.length > 256 || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value)) throw new OperatorError('RELAY_ACTION_INVALID', `Relay ${label} is invalid.`);
287
+ return value;
288
+ }
289
+
290
+ function boundedText(input , label , max ) {
291
+ const value = String(input ?? '');
292
+ if (!value || Buffer.byteLength(value, 'utf8') > max) throw new OperatorError('RELAY_ACTION_INVALID', `Relay ${label} is invalid.`);
293
+ return value;
294
+ }
295
+
296
+ function deriveResultUrl(relayUrlInput ) {
297
+ let relay ;
298
+ try { relay = new URL(relayUrlInput); } catch { throw new OperatorError('RELAY_URL_INVALID', 'Relay URL is invalid.'); }
299
+ relay.protocol = relay.protocol === 'wss:' ? 'https:' : relay.protocol === 'ws:' ? 'http:' : relay.protocol;
300
+ relay.pathname = '/v1/device-result'; relay.search = ''; relay.hash = '';
301
+ return relay.toString();
302
+ }
303
+
304
+ function validateResultUrl(input , relayUrlInput , allowLoopbackInsecure ) {
305
+ let url ;
306
+ let relay ;
307
+ try { url = new URL(input); } catch { throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL is invalid.'); }
308
+ try { relay = new URL(relayUrlInput); } catch { throw new OperatorError('RELAY_URL_INVALID', 'Relay URL is invalid.'); }
309
+ if (url.username || url.password || url.hash || url.search) throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL must not contain credentials, a query, or a fragment.');
310
+ if (url.pathname !== '/v1/device-result') throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL must use the fixed /v1/device-result endpoint.');
311
+
312
+ const localOverride = allowLoopbackInsecure && isLoopback(url.hostname) && isLoopback(relay.hostname);
313
+ if (localOverride && ['http:', 'https:'].includes(url.protocol)) return url.toString();
314
+
315
+ const expected = new URL(deriveResultUrl(relayUrlInput));
316
+ if (url.protocol === 'https:' && expected.protocol === 'https:' && url.origin === expected.origin && url.pathname === expected.pathname) {
317
+ return url.toString();
318
+ }
319
+ if (url.protocol !== 'https:') throw new OperatorError('RELAY_RESULT_TLS_REQUIRED', 'Relay results require HTTPS; insecure HTTP is allowed only for explicit loopback development.');
320
+ throw new OperatorError('RELAY_RESULT_AUTHORITY_MISMATCH', 'Relay result URL must remain on the relay-authorized HTTPS origin.');
321
+ }
322
+
323
+ function ensureHttpBase(input ) {
324
+ let url ;
325
+ try { url = new URL(input); } catch { throw new OperatorError('RELAY_LOCAL_AGENT_URL_INVALID', 'Local agent base URL is invalid.'); }
326
+ if (url.protocol !== 'http:' || !isLoopback(url.hostname) || url.username || url.password) throw new OperatorError('RELAY_LOCAL_AGENT_URL_INVALID', 'Relay runner may call only a loopback HTTP local-agent endpoint.');
327
+ return url.toString();
328
+ }
329
+
330
+ function isLoopback(host ) {
331
+ const value = host.replace(/^\[|\]$/g, '').toLowerCase();
332
+ return value === '127.0.0.1' || value === '::1' || value === 'localhost';
333
+ }
@@ -0,0 +1,156 @@
1
+ import crypto from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { DeviceIdentityStore } from '../../../src/core/device-identity.js';
4
+ import { answerPairingChallenge, } from '../../../src/core/device-registry.js';
5
+ import { enrollmentPollBinding } from '../../../src/core/device-enrollment.js';
6
+ import { OperatorError } from '../../../src/core/errors.js';
7
+
8
+ const REQUEST_TIMEOUT_MS = 15_000;
9
+ const DEFAULT_POLL_MS = 2_000;
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ export class RelayEnrollmentClient {
24
+ #identity ;
25
+ #base ;
26
+ #fetch ;
27
+ #pollIntervalMs ;
28
+ #onUserCode ;
29
+ #abort = new AbortController();
30
+
31
+ constructor(options ) {
32
+ this.#identity = options.identity;
33
+ this.#base = validateEnrollmentAuthority(options.relayUrl, options.resultUrl, Boolean(options.allowLoopbackInsecure));
34
+ this.#fetch = options.fetchImpl ?? fetch;
35
+ const pollMs = Number(options.pollIntervalMs ?? DEFAULT_POLL_MS);
36
+ if (!Number.isInteger(pollMs) || pollMs < 100 || pollMs > 30_000) {
37
+ throw new OperatorError('DEVICE_ENROLLMENT_POLL_CONFIG_INVALID', 'Device enrollment poll interval is invalid.');
38
+ }
39
+ this.#pollIntervalMs = pollMs;
40
+ this.#onUserCode = options.onUserCode;
41
+ }
42
+
43
+ stop() { this.#abort.abort(); }
44
+
45
+ async enroll() {
46
+ if (this.#abort.signal.aborted) throw new OperatorError('DEVICE_ENROLLMENT_STOPPED', 'Device enrollment was stopped.');
47
+ const device = await this.#identity.loadOrCreate();
48
+ const challenged = await this.#post('/v1/device-enrollment/challenge', { deviceId: device.deviceId });
49
+ if (challenged.status !== 200) throw enrollmentHttpError(challenged.status, challenged.body);
50
+ const challenge = challenged.body?.challenge ;
51
+ const pairing = await answerPairingChallenge(challenge, this.#identity);
52
+ const pollToken = crypto.randomBytes(32).toString('base64url');
53
+ const pollSignature = await this.#identity.sign(enrollmentPollBinding(pairing.challengeId, device.deviceId, pollToken));
54
+ const completed = await this.#post('/v1/device-enrollment/complete', { pairingResponse: pairing, pollToken, pollSignature });
55
+ if (completed.status !== 200) throw enrollmentHttpError(completed.status, completed.body);
56
+ const enrollmentId = validUuid(String(completed.body?.enrollment?.enrollmentId ?? ''), 'enrollmentId');
57
+ if (enrollmentId !== pairing.challengeId) throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Relay enrollment identity changed unexpectedly.');
58
+ const userCode = validUserCode(String(completed.body?.enrollment?.userCode ?? ''));
59
+ const expiresAt = validIso(String(completed.body?.enrollment?.expiresAt ?? ''), 'enrollment expiry');
60
+ await this.#onUserCode?.({ userCode, expiresAt });
61
+
62
+ while (Date.now() < Date.parse(expiresAt)) {
63
+ const polled = await this.#post('/v1/device-enrollment/poll', { enrollmentId, pollToken });
64
+ if (polled.status === 202) {
65
+ await delay(this.#pollIntervalMs, undefined, { signal: this.#abort.signal });
66
+ continue;
67
+ }
68
+ if (polled.status !== 200) throw enrollmentHttpError(polled.status, polled.body);
69
+ const token = String(polled.body?.session?.token ?? '');
70
+ if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) || Buffer.byteLength(token, 'utf8') > 16 * 1024) {
71
+ throw new OperatorError('DEVICE_ENROLLMENT_SESSION_INVALID', 'Relay enrollment returned an invalid session credential.');
72
+ }
73
+ return token;
74
+ }
75
+ throw new OperatorError('DEVICE_ENROLLMENT_EXPIRED', 'Device enrollment expired before account claim completed.');
76
+ }
77
+ async #post(pathname , payload ) {
78
+ const url = new URL(pathname, this.#base);
79
+ let response ;
80
+ try {
81
+ response = await this.#fetch(url, {
82
+ redirect: 'error',
83
+ method: 'POST',
84
+ headers: { 'content-type': 'application/json' },
85
+ body: JSON.stringify(payload),
86
+ signal: AbortSignal.any([this.#abort.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
87
+ });
88
+ } catch (error) {
89
+ if (this.#abort.signal.aborted) throw new OperatorError('DEVICE_ENROLLMENT_STOPPED', 'Device enrollment was stopped.');
90
+ throw new OperatorError('DEVICE_ENROLLMENT_REQUEST_FAILED', `Device enrollment request failed: ${error instanceof Error ? error.message : String(error)}`, { retryable: true });
91
+ }
92
+ let body = null;
93
+ try { body = await response.json(); } catch { /* handled below */ }
94
+ return { status: response.status, body };
95
+ }
96
+ }
97
+
98
+ function enrollmentHttpError(status , body ) {
99
+ const code = typeof body?.error?.code === 'string' ? body.error.code : 'DEVICE_ENROLLMENT_REQUEST_FAILED';
100
+ const retryable = status === 408 || status === 429 || status >= 500;
101
+ return new OperatorError(code, `Device enrollment service returned HTTP ${status}.`, { retryable });
102
+ }
103
+ function validateEnrollmentAuthority(relayInput , resultInput , allowLoopbackInsecure ) {
104
+ let relay ;
105
+ try { relay = new URL(relayInput); } catch { throw new OperatorError('RELAY_URL_INVALID', 'Relay URL is invalid.'); }
106
+ if (relay.username || relay.password || relay.search || relay.hash || !['ws:', 'wss:'].includes(relay.protocol)) {
107
+ throw new OperatorError('RELAY_URL_INVALID', 'Relay URL authority is invalid.');
108
+ }
109
+ const expected = new URL(relay.toString());
110
+ expected.protocol = relay.protocol === 'wss:' ? 'https:' : 'http:';
111
+ expected.pathname = '/'; expected.search = ''; expected.hash = '';
112
+ if (!resultInput) {
113
+ if (expected.protocol === 'https:' || (allowLoopbackInsecure && isLoopback(expected.hostname))) return expected;
114
+ throw new OperatorError('DEVICE_ENROLLMENT_TLS_REQUIRED', 'Device enrollment requires HTTPS except for explicit loopback development.');
115
+ }
116
+ let result ;
117
+ try { result = new URL(resultInput); } catch { throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL is invalid.'); }
118
+ if (result.username || result.password || result.search || result.hash || result.pathname !== '/v1/device-result') {
119
+ throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL authority is invalid.');
120
+ }
121
+ const loopback = allowLoopbackInsecure && isLoopback(relay.hostname) && isLoopback(result.hostname);
122
+ if (!loopback && (result.protocol !== 'https:' || expected.protocol !== 'https:' || result.origin !== expected.origin)) {
123
+ throw new OperatorError('DEVICE_ENROLLMENT_AUTHORITY_MISMATCH', 'Device enrollment must use the relay-authorized HTTPS origin.');
124
+ }
125
+ result.pathname = '/'; return result;
126
+ }
127
+
128
+ function validUuid(input , label ) {
129
+ const value = String(input ?? '').toLowerCase();
130
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
131
+ throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', `${label} must be a UUID.`);
132
+ }
133
+ return value;
134
+ }
135
+
136
+ function validUserCode(input ) {
137
+ const compact = String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
138
+ if (!/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/.test(compact)) {
139
+ throw new OperatorError('DEVICE_ENROLLMENT_CODE_INVALID', 'Relay enrollment returned an invalid pairing code.');
140
+ }
141
+ return `${compact.slice(0, 4)}-${compact.slice(4)}`;
142
+ }
143
+
144
+ function validIso(input , label ) {
145
+ const value = String(input ?? '');
146
+ const time = Date.parse(value);
147
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
148
+ throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', `${label} must be an ISO timestamp.`);
149
+ }
150
+ return value;
151
+ }
152
+
153
+ function isLoopback(host ) {
154
+ const value = host.replace(/^\[|\]$/g, '').toLowerCase();
155
+ return value === '127.0.0.1' || value === '::1' || value === 'localhost';
156
+ }