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,260 @@
|
|
|
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_JOURNAL_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_TREE_ENTRIES = 10_000;
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export class LocalDeviceResetCoordinator {
|
|
36
|
+
#stateDir ;
|
|
37
|
+
#journalFile ;
|
|
38
|
+
#identity ;
|
|
39
|
+
#resetUrl ;
|
|
40
|
+
#getResetToken ;
|
|
41
|
+
#stopRelay ;
|
|
42
|
+
#fetch ;
|
|
43
|
+
#clock ;
|
|
44
|
+
#queue = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
constructor(options ) {
|
|
47
|
+
this.#stateDir = path.resolve(options.stateDir);
|
|
48
|
+
this.#journalFile = path.join(this.#stateDir, 'local-device-reset.json');
|
|
49
|
+
this.#identity = options.identity;
|
|
50
|
+
this.#resetUrl = options.resetUrl === undefined ? undefined : validateResetUrl(options.resetUrl);
|
|
51
|
+
this.#getResetToken = options.getResetToken;
|
|
52
|
+
this.#stopRelay = options.stopRelay;
|
|
53
|
+
this.#fetch = options.fetchImpl ?? fetch;
|
|
54
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
55
|
+
if (this.#resetUrl && !this.#getResetToken) {
|
|
56
|
+
throw new OperatorError('DEVICE_RESET_CONFIG_INVALID', 'Hosted device reset requires a relay reset credential provider.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async reset() {
|
|
61
|
+
return await this.#exclusive(async () => {
|
|
62
|
+
const identity = await this.#identity.loadExisting();
|
|
63
|
+
const journal = await this.#readJournal();
|
|
64
|
+
if (!identity && journal?.phase === 'COMPLETE') {
|
|
65
|
+
return { status: 'complete', deviceId: journal.deviceId, hostedStatus: journal.hostedStatus, removedTargets: [] };
|
|
66
|
+
}
|
|
67
|
+
if (!identity && !journal) {
|
|
68
|
+
return { status: 'complete', hostedStatus: 'not-applicable', removedTargets: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deviceId = identity?.deviceId ?? journal .deviceId;
|
|
72
|
+
let activeJournal = journal?.deviceId === deviceId && journal.phase === 'HOSTED_REVOKED' ? journal : null;
|
|
73
|
+
if (!activeJournal) activeJournal = await this.#revokeHosted(deviceId);
|
|
74
|
+
const removedTargets = await this.#purgeLocalAuthority();
|
|
75
|
+
await this.#identity.erase();
|
|
76
|
+
const completed = {
|
|
77
|
+
...activeJournal,
|
|
78
|
+
phase: 'COMPLETE',
|
|
79
|
+
updatedAt: this.#clock().toISOString()
|
|
80
|
+
};
|
|
81
|
+
await this.#writeJournal(completed);
|
|
82
|
+
return { status: 'complete', deviceId, hostedStatus: completed.hostedStatus, removedTargets };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #revokeHosted(deviceId ) {
|
|
87
|
+
if (!this.#resetUrl) {
|
|
88
|
+
await this.#stopRelay?.();
|
|
89
|
+
const now = this.#clock().toISOString();
|
|
90
|
+
const journal = { version: 1, deviceId, phase: 'HOSTED_REVOKED', hostedStatus: 'not-configured', hostedCompletedAt: now, updatedAt: now };
|
|
91
|
+
await this.#writeJournal(journal);
|
|
92
|
+
return journal;
|
|
93
|
+
}
|
|
94
|
+
const token = await this.#getResetToken ();
|
|
95
|
+
await this.#stopRelay?.();
|
|
96
|
+
let response ;
|
|
97
|
+
try {
|
|
98
|
+
response = await this.#fetch(this.#resetUrl, { method: 'POST', redirect: 'error', headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new OperatorError('DEVICE_RESET_HOSTED_FAILED', `Hosted device reset request failed: ${error instanceof Error ? error.message : String(error)}`, { retryable: true });
|
|
101
|
+
}
|
|
102
|
+
let body = null;
|
|
103
|
+
try { body = await response.json(); } catch { /* handled below */ }
|
|
104
|
+
if (response.status !== 200 || body?.ok !== true || body?.reset?.status !== 'complete') {
|
|
105
|
+
const code = String(body?.error?.code ?? 'DEVICE_RESET_HOSTED_FAILED');
|
|
106
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
107
|
+
throw new OperatorError(code, `Hosted device reset failed with HTTP ${response.status}.`, { retryable });
|
|
108
|
+
}
|
|
109
|
+
const returnedDeviceId = validUuid(String(body.reset.deviceId ?? ''), 'hosted reset deviceId');
|
|
110
|
+
if (returnedDeviceId !== deviceId) throw new OperatorError('DEVICE_RESET_RECEIPT_MISMATCH', 'Hosted reset receipt belongs to a different device.');
|
|
111
|
+
const completedAt = validIso(String(body.reset.completedAt ?? ''), 'hosted reset completion');
|
|
112
|
+
const now = this.#clock().toISOString();
|
|
113
|
+
const journal = {
|
|
114
|
+
version: 1,
|
|
115
|
+
deviceId,
|
|
116
|
+
phase: 'HOSTED_REVOKED',
|
|
117
|
+
hostedStatus: 'revoked',
|
|
118
|
+
hostedCompletedAt: completedAt,
|
|
119
|
+
updatedAt: now
|
|
120
|
+
};
|
|
121
|
+
await this.#writeJournal(journal);
|
|
122
|
+
return journal;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async #purgeLocalAuthority() {
|
|
126
|
+
const relatives = authorityTargets();
|
|
127
|
+
for (const relative of relatives) await this.#assertSafeTarget(this.#target(relative));
|
|
128
|
+
const removed = [];
|
|
129
|
+
for (const relative of relatives) {
|
|
130
|
+
const target = this.#target(relative);
|
|
131
|
+
try {
|
|
132
|
+
await fs.lstat(target);
|
|
133
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
134
|
+
removed.push(relative);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error ).code !== 'ENOENT') throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#target(relative ) {
|
|
143
|
+
const target = path.resolve(this.#stateDir, relative);
|
|
144
|
+
const rel = path.relative(this.#stateDir, target);
|
|
145
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new OperatorError('DEVICE_RESET_PATH_INVALID', 'Device reset target escaped the Operator state directory.');
|
|
146
|
+
return target;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async #assertSafeTarget(target ) {
|
|
150
|
+
try {
|
|
151
|
+
const stat = await fs.lstat(target);
|
|
152
|
+
if (stat.isSymbolicLink()) throw new OperatorError('DEVICE_RESET_PATH_UNSAFE', 'Device reset refuses symbolic-link targets.');
|
|
153
|
+
if (stat.isDirectory()) await assertTreeHasNoSymlinks(target, 0);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error ).code === 'ENOENT') return;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async #readJournal() {
|
|
160
|
+
try {
|
|
161
|
+
const text = await readDurableStateText(this.#journalFile, {
|
|
162
|
+
maxBytes: MAX_JOURNAL_BYTES,
|
|
163
|
+
errorCode: 'DEVICE_RESET_LOCAL_STATE_INVALID',
|
|
164
|
+
invalidMessage: 'Local device reset state is invalid.'
|
|
165
|
+
});
|
|
166
|
+
return validateJournal(JSON.parse(text));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if ((error ).code === 'ENOENT') return null;
|
|
169
|
+
if (error instanceof OperatorError) throw error;
|
|
170
|
+
throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', 'Local device reset state could not be read.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async #writeJournal(journal ) {
|
|
175
|
+
await writeDurableStateText(this.#journalFile, JSON.stringify(validateJournal(journal), null, 2), {
|
|
176
|
+
maxBytes: MAX_JOURNAL_BYTES,
|
|
177
|
+
errorCode: 'DEVICE_RESET_LOCAL_STATE_INVALID',
|
|
178
|
+
invalidMessage: 'Local device reset state is invalid.'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async #exclusive (operation ) {
|
|
183
|
+
let release ;
|
|
184
|
+
const previous = this.#queue;
|
|
185
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
186
|
+
await previous;
|
|
187
|
+
try { return await operation(); } finally { release(); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function authorityTargets() {
|
|
191
|
+
return [
|
|
192
|
+
'relay-session-credential.json',
|
|
193
|
+
'relay-session.token',
|
|
194
|
+
'relay-client.json',
|
|
195
|
+
'device-sessions.json',
|
|
196
|
+
'device-registry.json',
|
|
197
|
+
'device-routing.json',
|
|
198
|
+
'approvals.json',
|
|
199
|
+
'provider-learning.json',
|
|
200
|
+
'relay-outbox'
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateJournal(input ) {
|
|
205
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', 'Local reset journal must be an object.');
|
|
206
|
+
const raw = input ;
|
|
207
|
+
if (raw.version !== 1) throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', 'Local reset journal version is invalid.');
|
|
208
|
+
const deviceId = validUuid(String(raw.deviceId ?? ''), 'deviceId');
|
|
209
|
+
const phase = raw.phase === 'HOSTED_REVOKED' || raw.phase === 'COMPLETE' ? raw.phase : null;
|
|
210
|
+
if (!phase) throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', 'Local reset journal phase is invalid.');
|
|
211
|
+
const hostedStatus = raw.hostedStatus === 'revoked' || raw.hostedStatus === 'not-configured' ? raw.hostedStatus : null;
|
|
212
|
+
if (!hostedStatus) throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', 'Local reset hosted status is invalid.');
|
|
213
|
+
const hostedCompletedAt = validIso(String(raw.hostedCompletedAt ?? ''), 'hostedCompletedAt');
|
|
214
|
+
const updatedAt = validIso(String(raw.updatedAt ?? ''), 'updatedAt');
|
|
215
|
+
return { version: 1, deviceId, phase, hostedStatus, hostedCompletedAt, updatedAt };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateResetUrl(input ) {
|
|
219
|
+
let url ;
|
|
220
|
+
try { url = new URL(input); }
|
|
221
|
+
catch { throw new OperatorError('DEVICE_RESET_URL_INVALID', 'Hosted device reset URL is invalid.'); }
|
|
222
|
+
if (url.username || url.password || url.hash || url.search || url.pathname !== '/v1/device-self/reset') {
|
|
223
|
+
throw new OperatorError('DEVICE_RESET_URL_INVALID', 'Hosted device reset URL must use the fixed credential-free endpoint.');
|
|
224
|
+
}
|
|
225
|
+
if (url.protocol === 'https:') return url.toString();
|
|
226
|
+
if (url.protocol === 'http:' && isLoopback(url.hostname)) return url.toString();
|
|
227
|
+
throw new OperatorError('DEVICE_RESET_TLS_REQUIRED', 'Hosted device reset requires HTTPS except for loopback development.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validUuid(value , label ) {
|
|
231
|
+
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)) {
|
|
232
|
+
throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', `${label} must be a UUID.`);
|
|
233
|
+
}
|
|
234
|
+
return value.toLowerCase();
|
|
235
|
+
}
|
|
236
|
+
function validIso(value , label ) {
|
|
237
|
+
const time = Date.parse(value);
|
|
238
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
|
|
239
|
+
throw new OperatorError('DEVICE_RESET_LOCAL_STATE_INVALID', `${label} must be an ISO timestamp.`);
|
|
240
|
+
}
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isLoopback(host ) {
|
|
245
|
+
const value = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
246
|
+
return value === '127.0.0.1' || value === '::1' || value === 'localhost';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function assertTreeHasNoSymlinks(dir , seen ) {
|
|
250
|
+
let count = seen;
|
|
251
|
+
for (const name of await fs.readdir(dir)) {
|
|
252
|
+
count += 1;
|
|
253
|
+
if (count > MAX_TREE_ENTRIES) throw new OperatorError('DEVICE_RESET_PATH_UNSAFE', 'Device reset tree exceeds the bounded entry count.');
|
|
254
|
+
const child = path.join(dir, name);
|
|
255
|
+
const stat = await fs.lstat(child);
|
|
256
|
+
if (stat.isSymbolicLink()) throw new OperatorError('DEVICE_RESET_PATH_UNSAFE', 'Device reset refuses symbolic links inside authority state.');
|
|
257
|
+
if (stat.isDirectory()) count = await assertTreeHasNoSymlinks(child, count);
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { OperatorError } from '../../../src/core/errors.js';
|
|
3
|
+
import { readDurableStateText, writeDurableStateText } from '../../../src/core/durable-state.js';
|
|
4
|
+
|
|
5
|
+
const MAX_REASON = 512;
|
|
6
|
+
const EMERGENCY_STOP_OPTIONS = {
|
|
7
|
+
maxBytes: 16 * 1024,
|
|
8
|
+
errorCode: 'EMERGENCY_STOP_STATE_INVALID',
|
|
9
|
+
invalidMessage: 'Emergency stop state is invalid.'
|
|
10
|
+
} ;
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export class EmergencyStopStore {
|
|
21
|
+
#file ;
|
|
22
|
+
#queue = Promise.resolve();
|
|
23
|
+
|
|
24
|
+
constructor(stateDir ) {
|
|
25
|
+
this.#file = path.join(path.resolve(stateDir), 'emergency-stop.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async status() {
|
|
29
|
+
return await this.#read();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async engage(reasonInput ) {
|
|
33
|
+
const reason = reasonInput === undefined ? undefined : validReason(reasonInput);
|
|
34
|
+
return await this.#mutate((current) => {
|
|
35
|
+
if (current.engaged) return current;
|
|
36
|
+
return {
|
|
37
|
+
version: 1,
|
|
38
|
+
engaged: true,
|
|
39
|
+
engagedAt: new Date().toISOString(),
|
|
40
|
+
reason
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async clear() {
|
|
46
|
+
return await this.#mutate(() => ({
|
|
47
|
+
version: 1,
|
|
48
|
+
engaged: false,
|
|
49
|
+
clearedAt: new Date().toISOString()
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async #read() {
|
|
54
|
+
let raw ;
|
|
55
|
+
try {
|
|
56
|
+
raw = await readDurableStateText(this.#file, EMERGENCY_STOP_OPTIONS);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if ((error ).code === 'ENOENT') return { version: 1, engaged: false };
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let parsed ;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
throw invalidState('Stored emergency stop state is not valid JSON.');
|
|
67
|
+
}
|
|
68
|
+
return validateState(parsed);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #write(stateInput ) {
|
|
72
|
+
const state = validateState(stateInput);
|
|
73
|
+
await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), EMERGENCY_STOP_OPTIONS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #mutate(mutator ) {
|
|
77
|
+
let release ;
|
|
78
|
+
const previous = this.#queue;
|
|
79
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
80
|
+
await previous;
|
|
81
|
+
try {
|
|
82
|
+
const next = mutator(await this.#read());
|
|
83
|
+
await this.#write(next);
|
|
84
|
+
return next;
|
|
85
|
+
} finally {
|
|
86
|
+
release();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validReason(value ) {
|
|
92
|
+
if (typeof value !== 'string') throw invalidState('Emergency stop reason is invalid.');
|
|
93
|
+
const reason = value.trim();
|
|
94
|
+
if (!reason || reason.length > MAX_REASON || /\0/.test(reason)) throw invalidState('Emergency stop reason is invalid.');
|
|
95
|
+
return reason;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateState(input ) {
|
|
99
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
100
|
+
throw invalidState('Emergency stop state structure is invalid.');
|
|
101
|
+
}
|
|
102
|
+
const raw = input ;
|
|
103
|
+
const allowed = new Set(['version', 'engaged', 'engagedAt', 'reason', 'clearedAt']);
|
|
104
|
+
if (Object.keys(raw).some((key) => !allowed.has(key))) {
|
|
105
|
+
throw invalidState('Emergency stop state contains unsupported fields.');
|
|
106
|
+
}
|
|
107
|
+
if (raw.version !== 1 || typeof raw.engaged !== 'boolean') {
|
|
108
|
+
throw invalidState('Emergency stop state structure is invalid.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const engagedAt = raw.engagedAt === undefined ? undefined : strictIso(raw.engagedAt, 'engagedAt');
|
|
112
|
+
const clearedAt = raw.clearedAt === undefined ? undefined : strictIso(raw.clearedAt, 'clearedAt');
|
|
113
|
+
const reason = raw.reason === undefined ? undefined : strictReason(raw.reason);
|
|
114
|
+
|
|
115
|
+
if (raw.engaged) {
|
|
116
|
+
if (!engagedAt) throw invalidState('Engaged emergency stop requires engagedAt.');
|
|
117
|
+
if (clearedAt) throw invalidState('Engaged emergency stop cannot retain clearedAt.');
|
|
118
|
+
} else if (engagedAt || reason) {
|
|
119
|
+
throw invalidState('Cleared emergency stop cannot retain engagement metadata.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { version: 1, engaged: raw.engaged, engagedAt, reason, clearedAt };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function strictReason(value ) {
|
|
126
|
+
if (typeof value !== 'string') throw invalidState('Emergency stop reason must be a string.');
|
|
127
|
+
return validReason(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function strictIso(value , field ) {
|
|
131
|
+
if (typeof value !== 'string') throw invalidState(`Emergency stop ${field} must be a string timestamp.`);
|
|
132
|
+
const time = Date.parse(value);
|
|
133
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
|
|
134
|
+
throw invalidState(`Emergency stop ${field} timestamp is invalid.`);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function invalidState(message ) {
|
|
140
|
+
return new OperatorError('EMERGENCY_STOP_STATE_INVALID', message);
|
|
141
|
+
}
|