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,436 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { OperatorError } from '../../../src/core/errors.js';
|
|
5
|
+
import { readDurableStateText, writeDurableStateText } from '../../../src/core/durable-state.js';
|
|
6
|
+
|
|
7
|
+
const MAX_TOKEN_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_STATE_BYTES = 128 * 1024;
|
|
9
|
+
const DEFAULT_REFRESH_SKEW_MS = 60_000;
|
|
10
|
+
const RETRY_REFRESH_MS = 10_000;
|
|
11
|
+
const MIN_REMAINING_MS = 5_000;
|
|
12
|
+
const MAX_SESSION_LIFETIME_MS = 30 * 60_000;
|
|
13
|
+
|
|
14
|
+
const LEGACY_TOKEN_OPTIONS = {
|
|
15
|
+
maxBytes: MAX_TOKEN_BYTES,
|
|
16
|
+
errorCode: 'RELAY_SESSION_TOKEN_FILE_INVALID',
|
|
17
|
+
invalidMessage: 'Relay session token file is invalid.'
|
|
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
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export class RelaySessionCredentialManager {
|
|
65
|
+
#stateFile ;
|
|
66
|
+
#legacyTokenFile ;
|
|
67
|
+
#rotateUrl ;
|
|
68
|
+
#protector ;
|
|
69
|
+
#refreshSkewMs ;
|
|
70
|
+
#fetch ;
|
|
71
|
+
#clock ;
|
|
72
|
+
#enrollment ;
|
|
73
|
+
#onBackgroundRefreshFailure ;
|
|
74
|
+
#timer = null;
|
|
75
|
+
#queue = Promise.resolve();
|
|
76
|
+
#stopped = false;
|
|
77
|
+
|
|
78
|
+
constructor(options ) {
|
|
79
|
+
this.#stateFile = path.join(path.resolve(options.stateDir), 'relay-session-credential.json');
|
|
80
|
+
this.#legacyTokenFile = path.resolve(options.legacyTokenFile);
|
|
81
|
+
this.#rotateUrl = validateRotateUrl(options.rotateUrl, Boolean(options.allowLoopbackInsecure));
|
|
82
|
+
this.#protector = options.protector;
|
|
83
|
+
if (this.#protector.scheme !== 'windows-dpapi-current-user') {
|
|
84
|
+
throw new OperatorError('RELAY_SESSION_PROTECTOR_INVALID', 'Relay session credentials require the Windows DPAPI CurrentUser protector.');
|
|
85
|
+
}
|
|
86
|
+
const refreshSkewMs = Number(options.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS);
|
|
87
|
+
if (!Number.isInteger(refreshSkewMs) || refreshSkewMs < 5_000 || refreshSkewMs > 5 * 60_000) {
|
|
88
|
+
throw new OperatorError('RELAY_SESSION_REFRESH_CONFIG_INVALID', 'Relay session refresh skew is invalid.');
|
|
89
|
+
}
|
|
90
|
+
this.#refreshSkewMs = refreshSkewMs;
|
|
91
|
+
this.#fetch = options.fetchImpl ?? fetch;
|
|
92
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
93
|
+
this.#enrollment = options.enrollment;
|
|
94
|
+
this.#onBackgroundRefreshFailure = options.onBackgroundRefreshFailure;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async forConnection() { return await this.#freshToken(); }
|
|
98
|
+
async forReset() {
|
|
99
|
+
const token = await this.#exclusive(async () => {
|
|
100
|
+
let current = await this.#loadOrMigrate(true);
|
|
101
|
+
if (current.metadata.expiresAtMs <= this.#clock().getTime()) current = await this.#enrollAndPersist();
|
|
102
|
+
return current.token;
|
|
103
|
+
});
|
|
104
|
+
this.stop();
|
|
105
|
+
return token;
|
|
106
|
+
}
|
|
107
|
+
async forRequest() {
|
|
108
|
+
return await this.#exclusive(async () => {
|
|
109
|
+
const current = await this.#loadOrMigrate(false);
|
|
110
|
+
const now = this.#clock().getTime();
|
|
111
|
+
if (current.metadata.expiresAtMs <= now) {
|
|
112
|
+
throw new OperatorError('RELAY_SESSION_REENROLL_REQUIRED', 'Relay session expired before the result could be submitted.', { retryable: false });
|
|
113
|
+
}
|
|
114
|
+
this.#schedule(current.metadata.expiresAtMs);
|
|
115
|
+
return current.token;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
stop() {
|
|
120
|
+
this.#stopped = true;
|
|
121
|
+
this.#enrollment?.stop();
|
|
122
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
123
|
+
this.#timer = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #freshToken() {
|
|
127
|
+
return await this.#exclusive(async () => {
|
|
128
|
+
let current = await this.#loadOrMigrate(true);
|
|
129
|
+
const now = this.#clock().getTime();
|
|
130
|
+
if (current.metadata.expiresAtMs <= now) {
|
|
131
|
+
if (!this.#enrollment) throw new OperatorError('RELAY_SESSION_REENROLL_REQUIRED', 'Relay session expired before it could be refreshed.', { retryable: false });
|
|
132
|
+
current = await this.#enrollAndPersist();
|
|
133
|
+
}
|
|
134
|
+
if (current.metadata.expiresAtMs - now <= this.#refreshSkewMs) {
|
|
135
|
+
try {
|
|
136
|
+
current = await this.#rotate(current);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof OperatorError && error.code === 'RELAY_SESSION_REENROLL_REQUIRED' && this.#enrollment) {
|
|
139
|
+
current = await this.#enrollAndPersist();
|
|
140
|
+
this.#schedule(current.metadata.expiresAtMs);
|
|
141
|
+
return current.token;
|
|
142
|
+
}
|
|
143
|
+
const remaining = current.metadata.expiresAtMs - this.#clock().getTime();
|
|
144
|
+
if (!(error instanceof OperatorError) || !error.retryable || remaining <= MIN_REMAINING_MS) throw error;
|
|
145
|
+
this.#scheduleRetry(current.metadata.expiresAtMs);
|
|
146
|
+
return current.token;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.#schedule(current.metadata.expiresAtMs);
|
|
150
|
+
return current.token;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async #loadOrMigrate(allowEnrollment ) {
|
|
155
|
+
try { return await this.#loadProtected(); }
|
|
156
|
+
catch (error) {
|
|
157
|
+
if ((error ).code !== 'ENOENT') throw error;
|
|
158
|
+
}
|
|
159
|
+
let token ;
|
|
160
|
+
try { token = await readRelaySessionTokenFile(this.#legacyTokenFile); }
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (allowEnrollment && this.#enrollment && error instanceof OperatorError && error.code === 'RELAY_SESSION_TOKEN_FILE_MISSING') {
|
|
163
|
+
return await this.#enrollAndPersist();
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
const metadata = parseTokenMetadata(token);
|
|
168
|
+
await this.#persist(token, metadata);
|
|
169
|
+
await fs.rm(this.#legacyTokenFile, { force: true });
|
|
170
|
+
return { token, metadata };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async #enrollAndPersist() {
|
|
174
|
+
if (!this.#enrollment) throw new OperatorError('RELAY_SESSION_REENROLL_REQUIRED', 'Relay session requires device enrollment.', { retryable: false });
|
|
175
|
+
const token = validateTokenText(await this.#enrollment.enroll());
|
|
176
|
+
const metadata = parseTokenMetadata(token);
|
|
177
|
+
if (metadata.expiresAtMs <= this.#clock().getTime()) throw new OperatorError('DEVICE_ENROLLMENT_SESSION_INVALID', 'Device enrollment returned an expired session credential.');
|
|
178
|
+
await this.#persist(token, metadata);
|
|
179
|
+
return { token, metadata };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async #loadProtected() {
|
|
183
|
+
const text = await readDurableStateText(this.#stateFile, {
|
|
184
|
+
maxBytes: MAX_STATE_BYTES,
|
|
185
|
+
errorCode: 'RELAY_SESSION_CREDENTIAL_INVALID',
|
|
186
|
+
invalidMessage: 'Protected relay session credential state is invalid.'
|
|
187
|
+
});
|
|
188
|
+
let stored ;
|
|
189
|
+
try { stored = validateStoredCredential(JSON.parse(text)); }
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error instanceof OperatorError) throw error;
|
|
192
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential state could not be parsed.');
|
|
193
|
+
}
|
|
194
|
+
const ciphertext = decodeCiphertext(stored.protection.ciphertextBase64);
|
|
195
|
+
let plaintext ;
|
|
196
|
+
try {
|
|
197
|
+
plaintext = await this.#protector.unprotect(ciphertext);
|
|
198
|
+
const token = validateTokenText(plaintext.toString('utf8'));
|
|
199
|
+
const metadata = parseTokenMetadata(token);
|
|
200
|
+
if (metadata.expiresAt !== stored.expiresAt) throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session expiry metadata does not match its token.');
|
|
201
|
+
return { token, metadata };
|
|
202
|
+
} finally {
|
|
203
|
+
ciphertext.fill(0);
|
|
204
|
+
plaintext?.fill(0);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async #rotate(current ) {
|
|
208
|
+
let response ;
|
|
209
|
+
try {
|
|
210
|
+
response = await this.#fetch(this.#rotateUrl, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
redirect: 'error',
|
|
213
|
+
headers: { authorization: `Bearer ${current.token}` },
|
|
214
|
+
signal: AbortSignal.timeout(15_000)
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
throw new OperatorError('RELAY_SESSION_REFRESH_FAILED', `Relay session refresh request failed: ${error instanceof Error ? error.message : String(error)}`, { retryable: true });
|
|
218
|
+
}
|
|
219
|
+
let body = null;
|
|
220
|
+
try { body = await response.json(); } catch { /* safe generic handling below */ }
|
|
221
|
+
if (response.status !== 200) {
|
|
222
|
+
const code = String(body?.error?.code ?? 'RELAY_SESSION_REFRESH_FAILED');
|
|
223
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
224
|
+
if (response.status === 401) throw new OperatorError('RELAY_SESSION_REENROLL_REQUIRED', 'Relay session authority is no longer refreshable.', { retryable: false });
|
|
225
|
+
throw new OperatorError(code, `Relay session refresh failed with HTTP ${response.status}.`, { retryable });
|
|
226
|
+
}
|
|
227
|
+
const token = validateTokenText(body?.session?.token);
|
|
228
|
+
const metadata = parseTokenMetadata(token);
|
|
229
|
+
if (token === current.token || metadata.jti === current.metadata.jti || metadata.expiresAtMs <= this.#clock().getTime()) {
|
|
230
|
+
throw new OperatorError('RELAY_SESSION_REFRESH_INVALID', 'Relay returned an invalid replacement session.', { retryable: false });
|
|
231
|
+
}
|
|
232
|
+
await this.#persist(token, metadata);
|
|
233
|
+
return { token, metadata };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async #persist(token , metadata ) {
|
|
237
|
+
const plaintext = Buffer.from(token, 'utf8');
|
|
238
|
+
let ciphertext ;
|
|
239
|
+
try {
|
|
240
|
+
ciphertext = await this.#protector.protect(plaintext);
|
|
241
|
+
const stored = {
|
|
242
|
+
version: 1,
|
|
243
|
+
expiresAt: metadata.expiresAt,
|
|
244
|
+
protection: {
|
|
245
|
+
scheme: 'windows-dpapi-current-user',
|
|
246
|
+
ciphertextBase64: ciphertext.toString('base64')
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
await writeDurableStateText(this.#stateFile, JSON.stringify(stored, null, 2), {
|
|
250
|
+
maxBytes: MAX_STATE_BYTES,
|
|
251
|
+
errorCode: 'RELAY_SESSION_CREDENTIAL_INVALID',
|
|
252
|
+
invalidMessage: 'Protected relay session credential state is invalid.'
|
|
253
|
+
});
|
|
254
|
+
} finally {
|
|
255
|
+
plaintext.fill(0);
|
|
256
|
+
ciphertext?.fill(0);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#schedule(expiresAtMs ) {
|
|
261
|
+
if (this.#stopped) return;
|
|
262
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
263
|
+
const delay = Math.max(1_000, expiresAtMs - this.#clock().getTime() - this.#refreshSkewMs);
|
|
264
|
+
this.#timer = setTimeout(() => { void this.#refreshFromTimer(); }, delay);
|
|
265
|
+
this.#timer.unref();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#scheduleRetry(expiresAtMs ) {
|
|
269
|
+
if (this.#stopped) return;
|
|
270
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
271
|
+
const remaining = expiresAtMs - this.#clock().getTime() - MIN_REMAINING_MS;
|
|
272
|
+
if (remaining <= 0) return;
|
|
273
|
+
const delay = Math.max(1_000, Math.min(RETRY_REFRESH_MS, remaining));
|
|
274
|
+
this.#timer = setTimeout(() => { void this.#refreshFromTimer(); }, delay);
|
|
275
|
+
this.#timer.unref();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async #refreshFromTimer() {
|
|
279
|
+
try { await this.#freshToken(); }
|
|
280
|
+
catch {
|
|
281
|
+
if (this.#stopped) return;
|
|
282
|
+
try { this.#onBackgroundRefreshFailure?.(); } catch { /* liveness callback is best-effort */ }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async #exclusive (operation ) {
|
|
287
|
+
let release ;
|
|
288
|
+
const previous = this.#queue;
|
|
289
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
290
|
+
await previous;
|
|
291
|
+
try { return await operation(); }
|
|
292
|
+
finally { release(); }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function readRelaySessionTokenFile(fileInput ) {
|
|
297
|
+
const file = path.resolve(fileInput);
|
|
298
|
+
let raw ;
|
|
299
|
+
try { raw = await readDurableStateText(file, LEGACY_TOKEN_OPTIONS); }
|
|
300
|
+
catch (error) {
|
|
301
|
+
if ((error ).code === 'ENOENT') {
|
|
302
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_FILE_MISSING', 'Relay session token file is missing.', { retryable: true });
|
|
303
|
+
}
|
|
304
|
+
if (error instanceof OperatorError) throw error; throw new OperatorError('RELAY_SESSION_TOKEN_FILE_INVALID', 'Relay session token file could not be read.', {
|
|
305
|
+
retryable: false,
|
|
306
|
+
details: { cause: String(error) }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return validateTokenText(raw.trim());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function validateStoredCredential(input ) {
|
|
313
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
314
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential state is invalid.');
|
|
315
|
+
}
|
|
316
|
+
const raw = input ;
|
|
317
|
+
if (raw.version !== 1 || !raw.protection || typeof raw.protection !== 'object' || Array.isArray(raw.protection)) {
|
|
318
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential state is invalid.');
|
|
319
|
+
}
|
|
320
|
+
const protection = raw.protection ;
|
|
321
|
+
if (protection.scheme !== 'windows-dpapi-current-user') {
|
|
322
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential scheme is invalid.');
|
|
323
|
+
}
|
|
324
|
+
const expiresAt = validIso(String(raw.expiresAt ?? ''), 'credential expiry');
|
|
325
|
+
const ciphertextBase64 = String(protection.ciphertextBase64 ?? '');
|
|
326
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(ciphertextBase64) || ciphertextBase64.length > MAX_STATE_BYTES) {
|
|
327
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential ciphertext is invalid.');
|
|
328
|
+
}
|
|
329
|
+
return { version: 1, expiresAt, protection: { scheme: 'windows-dpapi-current-user', ciphertextBase64 } };
|
|
330
|
+
}
|
|
331
|
+
function decodeCiphertext(value ) {
|
|
332
|
+
let decoded ;
|
|
333
|
+
try { decoded = Buffer.from(value, 'base64'); }
|
|
334
|
+
catch { throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential ciphertext is invalid.'); }
|
|
335
|
+
if (decoded.length < 1 || decoded.length > MAX_STATE_BYTES) {
|
|
336
|
+
decoded.fill(0);
|
|
337
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', 'Protected relay session credential ciphertext is invalid.');
|
|
338
|
+
}
|
|
339
|
+
return decoded;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function validateTokenText(input ) {
|
|
343
|
+
const token = String(input ?? '').trim();
|
|
344
|
+
const bytes = Buffer.byteLength(token, 'utf8');
|
|
345
|
+
if (bytes < 16 || bytes > MAX_TOKEN_BYTES || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) {
|
|
346
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session credential is invalid.', { retryable: true });
|
|
347
|
+
}
|
|
348
|
+
return token;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parseTokenMetadata(tokenInput ) {
|
|
352
|
+
const token = validateTokenText(tokenInput);
|
|
353
|
+
const payloadPart = token.split('.', 1)[0] ;
|
|
354
|
+
let payload ;
|
|
355
|
+
try { payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')); }
|
|
356
|
+
catch { throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session credential payload is invalid.', { retryable: true }); }
|
|
357
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
358
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session credential payload is invalid.', { retryable: true });
|
|
359
|
+
}
|
|
360
|
+
const raw = payload ;
|
|
361
|
+
const jti = String(raw.jti ?? '').toLowerCase();
|
|
362
|
+
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(jti)) {
|
|
363
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session credential JTI is invalid.', { retryable: true });
|
|
364
|
+
}
|
|
365
|
+
const expiresAt = validIso(String(raw.expiresAt ?? ''), 'token expiry');
|
|
366
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
367
|
+
const issuedAt = validIso(String(raw.issuedAt ?? ''), 'token issue time');
|
|
368
|
+
const issuedAtMs = Date.parse(issuedAt);
|
|
369
|
+
assertRelaySessionLifetime(issuedAtMs, expiresAtMs);
|
|
370
|
+
return { jti, expiresAt, expiresAtMs };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function assertRelaySessionLifetime(issuedAtMs , expiresAtMs ) {
|
|
374
|
+
if (!Number.isFinite(issuedAtMs) || !Number.isFinite(expiresAtMs) || expiresAtMs <= issuedAtMs || expiresAtMs - issuedAtMs > MAX_SESSION_LIFETIME_MS) {
|
|
375
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session credential lifetime is invalid.', { retryable: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function validIso(value , label ) {
|
|
380
|
+
const time = Date.parse(value);
|
|
381
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
|
|
382
|
+
throw new OperatorError('RELAY_SESSION_CREDENTIAL_INVALID', `Relay session ${label} is invalid.`);
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateRotateUrl(input , allowLoopbackInsecure ) {
|
|
388
|
+
let url ;
|
|
389
|
+
try { url = new URL(input); }
|
|
390
|
+
catch { throw new OperatorError('RELAY_SESSION_ROTATE_URL_INVALID', 'Relay session rotation URL is invalid.'); }
|
|
391
|
+
if (url.username || url.password || url.hash || url.search || url.pathname !== '/v1/device-session/rotate') {
|
|
392
|
+
throw new OperatorError('RELAY_SESSION_ROTATE_URL_INVALID', 'Relay session rotation URL must use the fixed credential-free endpoint.');
|
|
393
|
+
}
|
|
394
|
+
if (url.protocol === 'https:') return url.toString();
|
|
395
|
+
if (url.protocol === 'http:' && allowLoopbackInsecure && isLoopback(url.hostname)) return url.toString();
|
|
396
|
+
throw new OperatorError('RELAY_SESSION_ROTATE_TLS_REQUIRED', 'Relay session rotation requires HTTPS except for explicit loopback development.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isLoopback(host ) {
|
|
400
|
+
const value = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
401
|
+
return value === '127.0.0.1' || value === '::1' || value === 'localhost';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function deriveRelaySessionRotateUrl(relayUrlInput , resultUrlInput , allowLoopbackInsecure = false) {
|
|
405
|
+
let relay ;
|
|
406
|
+
try { relay = new URL(relayUrlInput); }
|
|
407
|
+
catch { throw new OperatorError('RELAY_URL_INVALID', 'Relay URL is invalid.'); }
|
|
408
|
+
if (relay.username || relay.password || relay.hash || relay.search || !['ws:', 'wss:'].includes(relay.protocol)) {
|
|
409
|
+
throw new OperatorError('RELAY_URL_INVALID', 'Relay URL must be credential-free ws:// or wss://.');
|
|
410
|
+
}
|
|
411
|
+
const expected = new URL(relay.toString());
|
|
412
|
+
expected.protocol = relay.protocol === 'wss:' ? 'https:' : 'http:';
|
|
413
|
+
if (resultUrlInput) {
|
|
414
|
+
let result ;
|
|
415
|
+
try { result = new URL(resultUrlInput); }
|
|
416
|
+
catch { throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL is invalid.'); }
|
|
417
|
+
if (result.username || result.password || result.hash || result.search || result.pathname !== '/v1/device-result') {
|
|
418
|
+
throw new OperatorError('RELAY_RESULT_URL_INVALID', 'Relay result URL authority is invalid.');
|
|
419
|
+
}
|
|
420
|
+
const loopback = allowLoopbackInsecure && isLoopback(relay.hostname) && isLoopback(result.hostname);
|
|
421
|
+
if (!loopback && (result.protocol !== 'https:' || expected.protocol !== 'https:' || result.origin !== expected.origin)) {
|
|
422
|
+
throw new OperatorError('RELAY_RESULT_AUTHORITY_MISMATCH', 'Relay session rotation must stay on the relay-authorized HTTPS origin.');
|
|
423
|
+
}
|
|
424
|
+
result.pathname = '/v1/device-session/rotate';
|
|
425
|
+
return result.toString();
|
|
426
|
+
}
|
|
427
|
+
expected.pathname = '/v1/device-session/rotate';
|
|
428
|
+
expected.search = ''; expected.hash = '';
|
|
429
|
+
return expected.toString();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function deriveRelayDeviceResetUrl(relayUrlInput , resultUrlInput , allowLoopbackInsecure = false) {
|
|
433
|
+
const url = new URL(deriveRelaySessionRotateUrl(relayUrlInput, resultUrlInput, allowLoopbackInsecure));
|
|
434
|
+
url.pathname = '/v1/device-self/reset';
|
|
435
|
+
return url.toString();
|
|
436
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const REQUIRED_RELAY_URL = 'wss://operator.splcart.in/device';
|
|
6
|
+
const REQUIRED_RESULT_URL = 'https://operator.splcart.in/v1/device-result';
|
|
7
|
+
const remoteDir = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const nativeRoot = path.resolve(remoteDir, '..', '..', '..', '..', 'native');
|
|
9
|
+
const expectedHelpers = {
|
|
10
|
+
OPERATOR_WINDOWS_DPAPI_PATH: path.join(nativeRoot, 'operator-windows-dpapi.exe'),
|
|
11
|
+
OPERATOR_WINDOWS_UIA_PATH: path.join(nativeRoot, 'operator-windows-uia.exe'),
|
|
12
|
+
OPERATOR_WINDOWS_PATH_LEASE_PATH: path.join(nativeRoot, 'operator-windows-path-lease.exe')
|
|
13
|
+
} ;
|
|
14
|
+
|
|
15
|
+
if (!process.env.OPERATOR_AGENT_TOKEN || process.env.OPERATOR_AGENT_TOKEN.length < 32) {
|
|
16
|
+
throw new Error('Operator remote requires an ephemeral local agent token from the trusted launcher.');
|
|
17
|
+
}
|
|
18
|
+
if (!process.env.OPERATOR_RECOVERY_TOKEN || process.env.OPERATOR_RECOVERY_TOKEN.length < 32) {
|
|
19
|
+
throw new Error('Operator remote requires an ephemeral recovery token from the trusted launcher.');
|
|
20
|
+
}
|
|
21
|
+
if (!process.env.OPERATOR_ALLOWED_ROOTS) {
|
|
22
|
+
throw new Error('Operator remote requires an explicit authorized root from the trusted launcher.');
|
|
23
|
+
}
|
|
24
|
+
if (process.env.OPERATOR_ALLOWED_ROOTS.includes(path.win32.delimiter)) {
|
|
25
|
+
throw new Error(`Operator remote authorized root cannot contain the Windows path-list delimiter '${path.win32.delimiter}'.`);
|
|
26
|
+
}
|
|
27
|
+
for (const [name, expected] of Object.entries(expectedHelpers)) {
|
|
28
|
+
const configured = process.env[name];
|
|
29
|
+
if (!configured || !sameWindowsPath(configured, expected)) {
|
|
30
|
+
throw new Error(`${name} must reference the native helper bundled with the verified npm payload.`);
|
|
31
|
+
}
|
|
32
|
+
process.env[name] = expected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// npm/npx parent environment is caller-controlled. Resolve Windows executable and
|
|
36
|
+
// profile roots through the bundled native helper before importing any provider.
|
|
37
|
+
const roots = trustedWindowsRoots(expectedHelpers.OPERATOR_WINDOWS_PATH_LEASE_PATH);
|
|
38
|
+
const trustedPath = [
|
|
39
|
+
path.dirname(process.execPath),
|
|
40
|
+
roots.SYSTEM,
|
|
41
|
+
roots.WINDOWS,
|
|
42
|
+
path.join(roots.SYSTEM, 'Wbem'),
|
|
43
|
+
path.join(roots.PROGRAMFILES, 'Git', 'cmd'),
|
|
44
|
+
path.join(roots.PROGRAMFILES_X86, 'Git', 'cmd'),
|
|
45
|
+
path.join(roots.PROGRAMFILES, 'nodejs'),
|
|
46
|
+
path.join(roots.PROGRAMFILES_X86, 'nodejs'),
|
|
47
|
+
path.join(roots.PROGRAMFILES, 'Docker', 'Docker', 'resources', 'bin')
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const key of [
|
|
51
|
+
'PATH', 'Path', 'SYSTEMROOT', 'WINDIR', 'PROGRAMFILES', 'PROGRAMFILES(X86)',
|
|
52
|
+
'PROGRAMW6432', 'USERPROFILE', 'HOME', 'HOMEDRIVE', 'HOMEPATH',
|
|
53
|
+
'LOCALAPPDATA', 'APPDATA', 'PROGRAMDATA', 'TEMP', 'TMP',
|
|
54
|
+
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_RUNTIME_DIR'
|
|
55
|
+
]) delete process.env[key];
|
|
56
|
+
process.env.SYSTEMROOT = roots.WINDOWS;
|
|
57
|
+
process.env.WINDIR = roots.WINDOWS;
|
|
58
|
+
process.env.PROGRAMFILES = roots.PROGRAMFILES;
|
|
59
|
+
process.env['PROGRAMFILES(X86)'] = roots.PROGRAMFILES_X86;
|
|
60
|
+
process.env.PROGRAMW6432 = roots.PROGRAMFILES;
|
|
61
|
+
process.env.USERPROFILE = roots.USERPROFILE;
|
|
62
|
+
process.env.HOME = roots.USERPROFILE;
|
|
63
|
+
const profileRoot = path.win32.parse(roots.USERPROFILE).root;
|
|
64
|
+
process.env.HOMEDRIVE = profileRoot.slice(0, -1);
|
|
65
|
+
process.env.HOMEPATH = roots.USERPROFILE.slice(Math.max(0, profileRoot.length - 1));
|
|
66
|
+
process.env.LOCALAPPDATA = roots.LOCALAPPDATA;
|
|
67
|
+
process.env.APPDATA = roots.APPDATA;
|
|
68
|
+
process.env.PROGRAMDATA = roots.PROGRAMDATA;
|
|
69
|
+
process.env.TEMP = path.join(roots.LOCALAPPDATA, 'Temp');
|
|
70
|
+
process.env.TMP = process.env.TEMP;
|
|
71
|
+
process.env.PATH = [...new Set(trustedPath.map((entry) => path.win32.resolve(entry)))].join(';');
|
|
72
|
+
process.env.PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
73
|
+
|
|
74
|
+
// The public npm command is intentionally pinned to the production relay authority.
|
|
75
|
+
process.env.OPERATOR_RELAY_URL = REQUIRED_RELAY_URL;
|
|
76
|
+
process.env.OPERATOR_RELAY_RESULT_URL = REQUIRED_RESULT_URL;
|
|
77
|
+
process.env.OPERATOR_RELAY_REQUIRED = '1';
|
|
78
|
+
delete process.env.OPERATOR_RELAY_ALLOW_INSECURE_LOOPBACK;
|
|
79
|
+
|
|
80
|
+
await import('./main.js');
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
function trustedWindowsRoots(helper ) {
|
|
94
|
+
let output ;
|
|
95
|
+
try {
|
|
96
|
+
output = execFileSync(helper, ['system-roots'], {
|
|
97
|
+
encoding: 'utf8', windowsHide: true, timeout: 5_000, maxBuffer: 32 * 1024, env: {}
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error('Operator could not derive trusted Windows known folders from its native helper.');
|
|
101
|
+
}
|
|
102
|
+
const required = new Set ([
|
|
103
|
+
'WINDOWS', 'SYSTEM', 'PROGRAMFILES', 'PROGRAMFILES_X86',
|
|
104
|
+
'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'PROGRAMDATA'
|
|
105
|
+
]);
|
|
106
|
+
const parsed = new Map ();
|
|
107
|
+
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
|
108
|
+
const separator = line.indexOf('=');
|
|
109
|
+
if (separator < 1) throw new Error('Operator native known-folder response is invalid.');
|
|
110
|
+
const key = line.slice(0, separator);
|
|
111
|
+
const value = line.slice(separator + 1);
|
|
112
|
+
if (!required.has(key ) || parsed.has(key)) {
|
|
113
|
+
throw new Error('Operator native known-folder response is invalid.');
|
|
114
|
+
}
|
|
115
|
+
if (!path.win32.isAbsolute(value) || /[\0\r\n;]/.test(value)) {
|
|
116
|
+
throw new Error(`Operator native known folder ${key} is invalid.`);
|
|
117
|
+
}
|
|
118
|
+
parsed.set(key, path.win32.normalize(value));
|
|
119
|
+
}
|
|
120
|
+
for (const key of required) if (!parsed.has(key)) throw new Error(`Operator native known folder ${key} is missing.`);
|
|
121
|
+
const roots = Object.fromEntries(parsed) ;
|
|
122
|
+
if (!sameWindowsPath(roots.SYSTEM, path.win32.join(roots.WINDOWS, 'System32'))) {
|
|
123
|
+
throw new Error('Operator native Windows system root relationship is invalid.');
|
|
124
|
+
}
|
|
125
|
+
return roots;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sameWindowsPath(left , right ) {
|
|
129
|
+
return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase();
|
|
130
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { OperatorRuntime } from '../../../src/core/runtime.js';
|
|
2
|
+
import { FilesystemProvider, GitProvider, GitCheckpointProvider, GitWriteProvider, ProcessProvider, ProjectInspectProvider, ProjectCommandProvider, ProjectTransactionProvider, DockerProvider, PostgresProvider, VsCodeProvider, SystemInspectProvider, ManagedBrowserProvider, WindowsUiaProvider } from '../../../src/capabilities/index.js';
|
|
3
|
+
import { ProviderLearningStore } from '../../../src/core/provider-learning.js';
|
|
4
|
+
|
|
5
|
+
export function createRuntime(config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
) {
|
|
23
|
+
return new OperatorRuntime(config.stateDir ? { learning: new ProviderLearningStore(config.stateDir) } : {})
|
|
24
|
+
.register(new SystemInspectProvider())
|
|
25
|
+
.register(new FilesystemProvider({ allowedRoots: config.allowedRoots, windowsPathLeaseExecutable: config.windowsPathLeasePath }))
|
|
26
|
+
.register(new ProjectInspectProvider({ allowedRoots: config.allowedRoots }))
|
|
27
|
+
.register(new ProjectCommandProvider({
|
|
28
|
+
allowedRoots: config.allowedRoots,
|
|
29
|
+
allowedExecutables: config.allowedExecutables,
|
|
30
|
+
registryPath: config.projectCommandRegistryPath
|
|
31
|
+
}))
|
|
32
|
+
.register(new ProjectTransactionProvider({
|
|
33
|
+
allowedRoots: config.allowedRoots,
|
|
34
|
+
allowedExecutables: config.allowedExecutables,
|
|
35
|
+
registryPath: config.projectCommandRegistryPath
|
|
36
|
+
}))
|
|
37
|
+
.register(new DockerProvider({
|
|
38
|
+
allowedRoots: config.allowedRoots,
|
|
39
|
+
dockerExecutable: config.dockerExecutable
|
|
40
|
+
}))
|
|
41
|
+
.register(new PostgresProvider({
|
|
42
|
+
allowedRoots: config.allowedRoots,
|
|
43
|
+
registryPath: config.postgresProfileRegistryPath,
|
|
44
|
+
psqlExecutable: config.psqlExecutable
|
|
45
|
+
}))
|
|
46
|
+
.register(new VsCodeProvider({
|
|
47
|
+
allowedRoots: config.allowedRoots,
|
|
48
|
+
codeExecutable: config.vscodeExecutable,
|
|
49
|
+
dataDir: config.vscodeDataDir
|
|
50
|
+
}))
|
|
51
|
+
.register(new GitProvider({ allowedRoots: config.allowedRoots }))
|
|
52
|
+
.register(new GitCheckpointProvider({ allowedRoots: config.allowedRoots }))
|
|
53
|
+
.register(new GitWriteProvider({ allowedRoots: config.allowedRoots }))
|
|
54
|
+
.register(new ProcessProvider({
|
|
55
|
+
allowedRoots: config.allowedRoots,
|
|
56
|
+
allowedExecutables: config.terminalAllowedExecutables ?? [],
|
|
57
|
+
requiredRisk: 'destructive'
|
|
58
|
+
}))
|
|
59
|
+
.register(new ManagedBrowserProvider({
|
|
60
|
+
endpoint: config.cdpEndpoint,
|
|
61
|
+
autoLaunch: config.browserAutoLaunch,
|
|
62
|
+
executablePath: config.browserPath,
|
|
63
|
+
dataDir: config.browserDataDir
|
|
64
|
+
}))
|
|
65
|
+
.register(new WindowsUiaProvider({ binaryPath: config.windowsUiaPath }));
|
|
66
|
+
}
|