@milpa/comal 0.2.6 → 0.2.8

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.
@@ -0,0 +1,419 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { stat } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { StringDecoder } from 'node:string_decoder';
5
+ import { verifyCandidate } from './plugin-workbench.mjs';
6
+ import { operationName } from './plugin-catalog.mjs';
7
+ import { digest } from './operations.mjs';
8
+ import { childEnvironment } from './child-env.mjs';
9
+ import { validateCases } from './plugin-contract.mjs';
10
+
11
+ // The plugin workbench's runner: a candidate (plugin-workbench.mjs) is verified, then loaded,
12
+ // activated, checked and replayed in a separate Node process (plugin-candidate-child.mjs) in
13
+ // its own process group.
14
+ //
15
+ // What it guarantees: it contains a BROKEN candidate. A candidate that throws, hangs, exits,
16
+ // floods stdout or stderr, or leaves children in its process group cannot take the session
17
+ // down: the run ends at its deadline or at the first protocol violation, the whole group is
18
+ // killed (also after every successful run), and what the parent does with the child's output
19
+ // is bounded. runCandidate never rejects, and its report always serializes.
20
+ //
21
+ // What it is NOT: a sandbox. Candidate code runs as the user. It can read the user's files,
22
+ // read /proc/<ppid>/environ (the session's environment, secrets included), signal the parent,
23
+ // allocate memory off the V8 heap past the 512 MB heap cap, or start a process that calls
24
+ // setsid and so outlives the group kill. Only the candidate's own environment is stripped.
25
+ //
26
+ // Whose account the report is: `loaded`, `operations`, `checks`, `replays` and `cleanup` are
27
+ // what the child said, and candidate code shares the child's process, so it can forge any of
28
+ // it. They are evidence for a human, never proof for promotion. The parent computes on its own
29
+ // (report.parent_computed): the outcome when it is refused, timed_out, protocol_error or
30
+ // exited; exit, ms, pid, group_kill; fingerprint and dynamic (from verifyCandidate, run
31
+ // immediately before the spawn); and every `not_permitted` replay (the caller's judge decided
32
+ // it and nothing was forwarded). A replay reaches the child only when the judge returns true,
33
+ // and what is judged is the exact serialized request sent.
34
+ //
35
+ // What the child is given of the house: nothing live. `services` is plain data and `serviceFunctions` names,
36
+ // per service, the members that are functions in the house; the child stands each one in with a function
37
+ // that throws when called (hostServiceStandIns builds both from a runtime). When activation fails after
38
+ // asking for a service that is not there, or after calling such a function, the candidate was not judged:
39
+ // the outcome is `not_judged` with `unavailable_services`, never `activate_failed`.
40
+ //
41
+ // A run never outlives its caller's interest or this process: `signal` settles it at once as
42
+ // `aborted` (parent-computed) and kills the group, and every group still running is killed
43
+ // synchronously when this process exits (process 'exit') or the host calls killCandidateRuns
44
+ // on its termination paths.
45
+
46
+ const CHILD = fileURLToPath(new URL('./plugin-candidate-child.mjs', import.meta.url));
47
+ const HEAP_FLAG = '--max-old-space-size=512';
48
+ const PROTOCOL_LIMIT = 4 * 1024 * 1024; // raw bytes on the child's stdout, all told
49
+ const TEXT = 2000, STDERR_TAIL = 2000, MAX_OPERATIONS = 200, MAX_CHECKS = 200, MAX_NAMES = 32;
50
+ const MAX_DEPTH = 32, MAX_ITEMS = 1000, FIELD_BYTES = 1024 * 1024; // per schema or effects value
51
+ const OUTPUT_BYTES = 256 * 1024; // a replay's output, as JSON, when asked for
52
+ const OUTPUT_TOTAL = 2 * 1024 * 1024; // all asked-for outputs of a run, together
53
+ const MAX_CASES = 200, CASE_BYTES = 64 * 1024;
54
+ const CLOSE_GRACE_MS = 1000, EXIT_GRACE_MS = 2000;
55
+ const ENV_KEYS = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', ...(process.platform === 'win32' ? ['SystemRoot', 'TEMP', 'TMP', 'USERPROFILE'] : [])];
56
+ const TRUST = 'candidate_reported';
57
+ const PARENT_COMPUTED = Object.freeze(['outcome_when_refused_timed_out_protocol_error_exited', 'exit', 'ms', 'pid', 'group_kill', 'fingerprint', 'dynamic', 'not_permitted', 'outcome_when_aborted']);
58
+
59
+ // The candidate processes running now: pid → whether it leads its own process group.
60
+ const running = new Map();
61
+ let exitHook = false;
62
+ /** Kill every candidate process group still running, synchronously. Safe to call at any time. */
63
+ export function killCandidateRuns() {
64
+ for (const [pid, group] of running) {
65
+ try { process.kill(group ? -pid : pid, 'SIGKILL'); } catch {}
66
+ }
67
+ }
68
+ function track(pid, group) {
69
+ if (!Number.isInteger(pid)) return;
70
+ running.set(pid, group);
71
+ if (!exitHook) { exitHook = true; process.on('exit', killCandidateRuns); }
72
+ }
73
+
74
+ class Bound extends Error {}
75
+ // Text from the child is taken only when it is text; anything else is dropped, never stringified.
76
+ const text = value => typeof value === 'string' ? value.slice(0, TEXT) : typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
77
+ const message = error => text(error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error');
78
+ const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
79
+
80
+ // A JSON copy with at most MAX_DEPTH levels and MAX_ITEMS entries per array or object; a value
81
+ // beyond those, a cycle, or something that is not JSON throws Bound.
82
+ function jsonValue(value, depth = 0) {
83
+ if (depth > MAX_DEPTH) throw new Bound(`A value nests deeper than ${MAX_DEPTH} levels.`);
84
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
85
+ if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Bound('A number is not finite.'); return value; }
86
+ if (Array.isArray(value)) {
87
+ if (value.length > MAX_ITEMS) throw new Bound(`An array holds more than ${MAX_ITEMS} items.`);
88
+ return value.map(item => jsonValue(item, depth + 1));
89
+ }
90
+ if (typeof value === 'object') {
91
+ const keys = Object.keys(value);
92
+ if (keys.length > MAX_ITEMS) throw new Bound(`An object holds more than ${MAX_ITEMS} keys.`);
93
+ const out = {};
94
+ for (const key of keys) Object.defineProperty(out, key, { value: jsonValue(value[key], depth + 1), enumerable: true, writable: true, configurable: true });
95
+ return out;
96
+ }
97
+ throw new Bound('A value is not JSON.');
98
+ }
99
+ function field(value) {
100
+ if (value === undefined || value === null) return null;
101
+ const copy = jsonValue(value);
102
+ if (Buffer.byteLength(JSON.stringify(copy)) > FIELD_BYTES) throw new Bound(`A field is larger than ${FIELD_BYTES} bytes.`);
103
+ return copy;
104
+ }
105
+ function list(value, cap, what) {
106
+ if (value === undefined) return [];
107
+ if (!Array.isArray(value)) throw new Bound(`${what} is not a list.`);
108
+ if (value.length > cap) throw new Bound(`${what} holds more than ${cap} entries.`);
109
+ return value;
110
+ }
111
+
112
+ const operationsFrom = value => list(value, MAX_OPERATIONS, 'The operations list').map(op => {
113
+ if (!plain(op)) throw new Bound('An operation is not an object.');
114
+ return { name: text(op.name).slice(0, 128), description: text(op.description), input_schema: field(op.input_schema), output_schema: field(op.output_schema), effects: field(op.effects), audience: op.audience === 'human' ? 'human' : 'any' };
115
+ });
116
+ // The candidate's cases, as it reported them: bounded JSON, then the runtime's own case checks.
117
+ const casesFrom = value => validateCases(list(value, MAX_CASES, 'The cases list').map(item => {
118
+ const copy = jsonValue(item);
119
+ if (Buffer.byteLength(JSON.stringify(copy)) > CASE_BYTES) throw new Bound(`A case is larger than ${CASE_BYTES} bytes.`);
120
+ return copy;
121
+ }));
122
+ const checksFrom = value => list(value, MAX_CHECKS, 'The checks list').map(check => {
123
+ if (!plain(check)) throw new Bound('A check is not an object.');
124
+ return { name: text(check.name), ok: check.ok === true, ...(check.ok === true ? {} : { error: text(check.error) }) };
125
+ });
126
+ const namesFrom = value => list(value, MAX_NAMES, 'The missing services list').map(name => text(name).slice(0, 128));
127
+ // A code the candidate's error carried, when it looks like one.
128
+ const codeFrom = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : null;
129
+ // A replay's output, only when it was asked for: a bounded JSON copy, omitted above OUTPUT_BYTES, or
130
+ // once the run's outputs together would pass OUTPUT_TOTAL (budget.left, spent here).
131
+ function outputFrom(answer, budget) {
132
+ if (['too_large', 'budget'].includes(answer.output_omitted)) return { output_omitted: answer.output_omitted };
133
+ if (!answer.has_output) return { output_omitted: 'not_reported' };
134
+ const copy = jsonValue(answer.output), bytes = Buffer.byteLength(JSON.stringify(copy));
135
+ if (bytes > OUTPUT_BYTES) return { output_omitted: 'too_large' };
136
+ if (bytes > budget.left) return { output_omitted: 'budget' };
137
+ budget.left -= bytes;
138
+ return { output: copy };
139
+ }
140
+
141
+ // Only the names a plugin legitimately needs; provider secrets are filtered once more.
142
+ function candidateEnvironment(env = process.env) {
143
+ const picked = {};
144
+ for (const key of ENV_KEYS) if (env[key] !== undefined) picked[key] = env[key];
145
+ return childEnvironment(picked);
146
+ }
147
+
148
+ function emptyReport() {
149
+ return { outcome: null, trust: TRUST, parent_computed: [...PARENT_COMPUTED], exit: null, stderr_tail: '', ms: 0, pid: null, group_kill: process.platform !== 'win32', fingerprint: null, dynamic: [], packages: [], operations: [], cases: [], missing_services: [], checks: [], replays: [] };
150
+ }
151
+
152
+ // The last guard: a report that does not serialize degrades to protocol_error, keeping only
153
+ // what the parent computed itself.
154
+ function finished(report, started) {
155
+ report.ms = Math.round(performance.now() - started);
156
+ try { JSON.stringify(report); return report; }
157
+ catch (error) {
158
+ const safe = emptyReport();
159
+ for (const key of ['exit', 'ms', 'pid', 'group_kill', 'fingerprint', 'dynamic', 'packages']) try { safe[key] = jsonValue(report[key] ?? safe[key]); } catch {}
160
+ return Object.assign(safe, { outcome: 'protocol_error', error: `The report did not serialize: ${message(error)}` });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Load, activate, check and replay a candidate in a separate process. Resolves to a report:
166
+ * { outcome: 'loaded' | 'refused' | 'import_failed' | 'activate_failed' | 'timed_out' | 'exited' | 'protocol_error' | 'aborted',
167
+ * trust: 'candidate_reported', parent_computed, exit, stderr_tail, ms, pid, group_kill, fingerprint,
168
+ * dynamic, operations, missing_services, checks, replays, cleanup?, error?, code?, unavailable_services? }.
169
+ * The outcome can also be 'not_judged': activation needed something of the house this process cannot have.
170
+ * Never rejects. `signal` aborts the run: it settles at once as `aborted` and the group is killed.
171
+ * `spawn` is a seam for tests (the signature of child_process.spawn).
172
+ */
173
+ export async function runCandidate(candidate, options = {}) {
174
+ const started = performance.now();
175
+ const report = emptyReport();
176
+ try { await run(candidate, options ?? {}, report); }
177
+ catch (error) { Object.assign(report, { outcome: 'protocol_error', error: message(error) }); }
178
+ return finished(report, started);
179
+ }
180
+
181
+ async function run(candidate, { config = {}, services = {}, serviceFunctions = {}, replay = [], timeoutMs = 20000, judge, cwd, spawn = nodeSpawn, signal }, report) {
182
+ const aborted = () => Object.assign(report, { outcome: 'aborted', error: 'The run was aborted by its caller.' });
183
+ if (signal?.aborted) return aborted();
184
+ const refuse = (error, code) => Object.assign(report, { outcome: 'refused', error: message(error), code: code ?? (typeof error?.code === 'string' ? error.code : null) });
185
+
186
+ let verified;
187
+ try { verified = await verifyCandidate(candidate); }
188
+ catch (error) { return refuse(error); }
189
+ report.fingerprint = text(verified.fingerprint);
190
+ report.dynamic = verified.dynamic.slice(0, MAX_OPERATIONS).map(item => ({ file: text(item.file), construct: text(item.construct) }));
191
+ report.packages = (verified.packages ?? []).slice(0, MAX_OPERATIONS).map(item => ({ file: text(item.file), specifier: text(item.specifier) }));
192
+
193
+ let init;
194
+ const workdir = cwd ?? candidate.dir;
195
+ try {
196
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('timeoutMs must be a positive number.');
197
+ if (!Array.isArray(replay)) throw new Error('replay must be an array.');
198
+ init = JSON.stringify({ type: 'init', id: candidate.id, config, services, service_functions: serviceFunctions, deadline_ms: timeoutMs });
199
+ } catch (error) { return refuse(error, 'PLUGIN_CANDIDATE_OPTIONS'); }
200
+ let directory = null;
201
+ try { directory = await stat(workdir); } catch {}
202
+ if (!directory?.isDirectory()) return refuse(`The working directory does not exist or is not a directory: ${workdir}`, 'PLUGIN_CANDIDATE_OPTIONS');
203
+ if (signal?.aborted) return aborted();
204
+
205
+ await new Promise(resolve => {
206
+ let child, settled = false, closed = false, exited = false, resolved = false, declared = null;
207
+ let stdoutBytes = 0, buffer = '', pending = null, seq = 0;
208
+ let timer = null, deadlineLeft = timeoutMs, deadlineFrom = 0, closeTimer = null, endTimer = null;
209
+ const decoder = new StringDecoder('utf8'), errDecoder = new StringDecoder('utf8');
210
+ const streams = () => child ? [child.stdin, child.stdout, child.stderr] : [];
211
+ const started = () => streams().length === 3 && streams().every(Boolean);
212
+
213
+ const killGroup = () => {
214
+ if (!child?.pid) return;
215
+ try { if (report.group_kill) process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch {}
216
+ };
217
+ let onAbort = null;
218
+ const done = () => {
219
+ if (resolved) return; resolved = true;
220
+ clearTimeout(timer); clearTimeout(closeTimer); clearTimeout(endTimer);
221
+ if (onAbort) signal.removeEventListener('abort', onAbort);
222
+ if (child?.pid) running.delete(child.pid);
223
+ for (const stream of streams()) try { stream?.destroy(); } catch {}
224
+ resolve();
225
+ };
226
+ // Resolved once the outcome is set and the process has closed its stdio and exited
227
+ // (or there is no process to wait for).
228
+ const maybeDone = () => { if (settled && (!started() || !child.pid || (closed && exited))) done(); };
229
+ const settle = (outcome, extra = {}) => {
230
+ if (settled) return; settled = true;
231
+ clearTimeout(timer);
232
+ report.outcome = outcome; Object.assign(report, extra);
233
+ killGroup();
234
+ if (pending) { const answer = pending; pending = null; answer(null); }
235
+ endTimer = setTimeout(done, EXIT_GRACE_MS);
236
+ maybeDone();
237
+ };
238
+ const fail = error => settle('protocol_error', { error: message(error) });
239
+ const guarded = fn => (...args) => { try { return fn(...args); } catch (error) { fail(error); } };
240
+ // The deadline belongs to the candidate: it is paused while the caller's judge decides.
241
+ const resumeDeadline = () => { deadlineFrom = performance.now(); timer = setTimeout(guarded(() => settle('timed_out')), Math.max(0, deadlineLeft)); };
242
+ const pauseDeadline = () => { clearTimeout(timer); deadlineLeft -= performance.now() - deadlineFrom; };
243
+ const write = line => { if (!settled) child.stdin.write(`${line}\n`); };
244
+
245
+ const budget = { left: OUTPUT_TOTAL };
246
+ async function replays() {
247
+ for (const item of replay) {
248
+ if (settled) return;
249
+ const operation = operationName(candidate.id, text(item?.operation).slice(0, 128));
250
+ const wantOutput = item?.want_output === true;
251
+ let input;
252
+ try { input = JSON.stringify(item?.input ?? {}); } catch { report.replays.push({ operation, outcome: 'threw', error: 'The replay input is not JSON-serializable.' }); continue; }
253
+ let permitted = false;
254
+ if (typeof judge === 'function') {
255
+ pauseDeadline();
256
+ try { permitted = await judge({ operation, input: JSON.parse(input) }) === true; } catch { permitted = false; }
257
+ if (settled) return;
258
+ resumeDeadline();
259
+ }
260
+ if (!permitted) { report.replays.push({ operation, outcome: 'not_permitted' }); continue; }
261
+ const id = ++seq;
262
+ const answer = await new Promise(answered => {
263
+ pending = answered;
264
+ write(`{"type":"replay","seq":${id},"operation":${JSON.stringify(operation)},"input":${input},"remaining_ms":${Math.max(1, Math.floor(deadlineLeft - (performance.now() - deadlineFrom)))}${wantOutput ? ',"want_output":true' : ''}}`);
265
+ });
266
+ if (!answer) return;
267
+ if (answer.seq !== id || !['returned', 'threw'].includes(answer.outcome)) return fail('A replay answer does not match its request.');
268
+ const output = typeof answer.output_sha256 === 'string' && /^[0-9a-f]{64}$/.test(answer.output_sha256) ? answer.output_sha256 : null;
269
+ const code = codeFrom(answer.code);
270
+ report.replays.push(answer.outcome === 'returned' ? { operation, outcome: 'returned', ...(output ? { output_sha256: output } : {}), ...(wantOutput ? outputFrom(answer, budget) : {}) } : { operation, outcome: 'threw', error: text(answer.error), ...(code ? { code } : {}) });
271
+ }
272
+ write('{"type":"done"}');
273
+ }
274
+
275
+ const phases = { import: ['imported', 'import_failed'], activate: ['activated', 'activate_failed'], checks: ['checks'], ready: ['ready'], replay: ['replayed', 'closed'], over: [] };
276
+ let phase = 'import';
277
+ const handle = received => {
278
+ if (!plain(received) || !phases[phase].includes(received.type)) return fail(`Unexpected message from the candidate process: ${text(received?.type)}`);
279
+ switch (received.type) {
280
+ case 'imported': report.missing_services = namesFrom(received.missing_services); phase = 'activate'; break;
281
+ case 'import_failed': declared = { outcome: 'import_failed', error: text(received.error) }; phase = 'over'; break;
282
+ case 'activate_failed': {
283
+ report.operations = operationsFrom(received.operations);
284
+ const unavailable = namesFrom(received.unavailable_services).filter(Boolean);
285
+ declared = unavailable.length ? { outcome: 'not_judged', error: text(received.error), unavailable_services: unavailable } : { outcome: 'activate_failed', error: text(received.error) };
286
+ phase = 'over'; break;
287
+ }
288
+ case 'activated': report.operations = operationsFrom(received.operations); report.cases = casesFrom(received.cases); phase = 'checks'; break;
289
+ case 'checks': report.checks = checksFrom(received.checks); phase = 'ready'; break;
290
+ case 'ready': phase = 'replay'; replays().catch(fail); break;
291
+ case 'replayed': { if (!pending) return fail('A replay answer arrived unasked.'); const answer = pending; pending = null; answer({ seq: received.seq, outcome: received.outcome, error: received.error, code: received.code, output_sha256: received.output_sha256, has_output: Object.hasOwn(received, 'output'), output: received.output, output_omitted: received.output_omitted }); break; }
292
+ case 'closed': report.cleanup = plain(received.cleanup) ? { ok: received.cleanup.ok === true, ...(received.cleanup.ok === true ? {} : { error: text(received.cleanup.error) }) } : { ok: false, error: 'No cleanup report.' }; declared = { outcome: 'loaded' }; phase = 'over'; break;
293
+ }
294
+ };
295
+ const onStdout = chunk => {
296
+ if (settled) return;
297
+ stdoutBytes += chunk.length;
298
+ if (stdoutBytes > PROTOCOL_LIMIT) return fail('The candidate process wrote more than 4 MB to stdout.');
299
+ buffer += decoder.write(chunk);
300
+ for (let newline; !settled && (newline = buffer.indexOf('\n')) !== -1;) {
301
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
302
+ let received;
303
+ try { received = JSON.parse(line); } catch { return fail('The candidate process wrote a line that is not protocol JSON to stdout.'); }
304
+ handle(received);
305
+ }
306
+ };
307
+ const ended = () => { const { outcome = 'exited', ...extra } = declared ?? {}; settle(outcome, extra); };
308
+
309
+ try {
310
+ try {
311
+ child = spawn(process.execPath, [HEAP_FLAG, CHILD, candidate.module], { cwd: workdir, env: candidateEnvironment(), detached: report.group_kill, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
312
+ } catch (error) { Object.assign(report, { outcome: 'exited', error: message(error) }); settled = true; done(); return; }
313
+ if (!child || typeof child.on !== 'function') { Object.assign(report, { outcome: 'exited', error: 'spawn returned no child process.' }); settled = true; done(); return; }
314
+ let spawnError = null;
315
+ child.on('error', guarded(error => { spawnError = error; if (started()) settle('exited', { error: message(error) }); }));
316
+ report.pid = Number.isInteger(child.pid) ? child.pid : null;
317
+ track(child.pid, report.group_kill);
318
+ if (signal) {
319
+ onAbort = guarded(() => settle('aborted', { error: 'The run was aborted by its caller.' }));
320
+ if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true });
321
+ }
322
+ if (!started()) {
323
+ // No stdio: the spawn failed (e.g. EMFILE). Its error arrives on the next tick; report it.
324
+ setImmediate(() => settle('exited', { error: spawnError ? message(spawnError) : 'The candidate process started without stdio.' }));
325
+ return;
326
+ }
327
+ for (const stream of streams()) stream.on('error', () => {});
328
+ child.stdout.on('data', guarded(onStdout));
329
+ child.stderr.on('data', guarded(chunk => { report.stderr_tail = (report.stderr_tail + errDecoder.write(chunk)).slice(-STDERR_TAIL); }));
330
+ // Settle on close (stdio drained), not on exit: a final line may still be in the pipe at exit.
331
+ child.on('exit', guarded((code, signal) => {
332
+ exited = true; report.exit = { code: code ?? null, signal: signal ?? null };
333
+ killGroup(); // what stayed in the group goes now, so its copies of the pipes close too
334
+ if (!settled) closeTimer = setTimeout(guarded(ended), CLOSE_GRACE_MS);
335
+ maybeDone();
336
+ }));
337
+ child.on('close', guarded((code, signal) => {
338
+ closed = true;
339
+ if (!exited) { exited = true; report.exit = { code: code ?? null, signal: signal ?? null }; }
340
+ ended(); maybeDone();
341
+ }));
342
+ resumeDeadline();
343
+ write(init);
344
+ } catch (error) { fail(error); }
345
+ });
346
+ }
347
+
348
+ // A copy of plain data read through property descriptors only: no accessor runs, at any depth. Accessors,
349
+ // functions, symbols and undefined are left out; the same depth and size bounds as jsonValue apply (Bound).
350
+ function dataCopy(value, depth = 0) {
351
+ if (depth > MAX_DEPTH) throw new Bound(`A value nests deeper than ${MAX_DEPTH} levels.`);
352
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
353
+ if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Bound('A number is not finite.'); return value; }
354
+ if (typeof value !== 'object') return undefined;
355
+ const keys = Object.keys(value);
356
+ if (keys.length > MAX_ITEMS) throw new Bound(`A value holds more than ${MAX_ITEMS} entries.`);
357
+ const own = key => { const property = Object.getOwnPropertyDescriptor(value, key); return property && !('get' in property) && !('set' in property) ? dataCopy(property.value, depth + 1) : undefined; };
358
+ if (Array.isArray(value)) return keys.map(own).map(item => item === undefined ? null : item);
359
+ const out = {};
360
+ for (const key of keys) { const copy = own(key); if (copy !== undefined) Object.defineProperty(out, key, { value: copy, enumerable: true, writable: true, configurable: true }); }
361
+ return out;
362
+ }
363
+ /**
364
+ * The house's own services of a runtime as the child can be given them: { services, functions }. For each
365
+ * service the host provides (never one a plugin provides): its own enumerable members that are JSON data,
366
+ * copied, and the names of its members that are functions. Everything is read through property
367
+ * descriptors: an accessor is never run, at any depth, and is left out. A member larger than FIELD_BYTES,
368
+ * or too deep, is left out too.
369
+ */
370
+ export function hostServiceStandIns(runtime) {
371
+ const services = {}, functions = {};
372
+ const bounded = value => { const copy = dataCopy(value); if (copy !== undefined && Buffer.byteLength(JSON.stringify(copy)) > FIELD_BYTES) throw new Bound('too large'); return copy; };
373
+ let names = [];
374
+ try { names = runtime.status().services ?? []; } catch {}
375
+ for (const name of names.slice(0, MAX_NAMES)) {
376
+ let entry = null;
377
+ try { entry = runtime.service(name); } catch {}
378
+ if (!entry || entry.owner !== 'host') continue;
379
+ const value = entry.value;
380
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) { try { const copy = bounded(value); if (copy !== undefined) services[name] = copy; } catch {} continue; }
381
+ const data = {}, members = [];
382
+ for (const key of Object.keys(value).slice(0, MAX_OPERATIONS)) {
383
+ const property = Object.getOwnPropertyDescriptor(value, key);
384
+ if (!property || 'get' in property || 'set' in property) continue;
385
+ if (typeof property.value === 'function') { members.push(key); continue; }
386
+ try { const copy = bounded(property.value); if (copy !== undefined) data[key] = copy; } catch {}
387
+ }
388
+ services[name] = data;
389
+ if (members.length) functions[name] = members;
390
+ }
391
+ return { services, functions };
392
+ }
393
+
394
+ // An input-schema digest that never throws; a schema that cannot be read counts as changed.
395
+ function schemaDigest(schema) {
396
+ try { return digest(jsonValue(schema ?? null)); } catch { return Symbol('unreadable'); }
397
+ }
398
+
399
+ /** What a candidate changes in the published contract: by operation name and input-schema digest. Never throws. */
400
+ export function contractChanges(liveOperations = [], candidateOperations = []) {
401
+ const index = operations => {
402
+ const out = new Map();
403
+ if (!Array.isArray(operations)) return out;
404
+ for (const item of operations) {
405
+ try {
406
+ const op = item?.contract ?? item;
407
+ if (op && typeof op.name === 'string') out.set(op.name, schemaDigest(op.input_schema));
408
+ } catch {}
409
+ }
410
+ return out;
411
+ };
412
+ const live = index(liveOperations), next = index(candidateOperations);
413
+ const sort = values => [...values].sort();
414
+ return {
415
+ removed: sort([...live.keys()].filter(name => !next.has(name))),
416
+ changed: sort([...live.keys()].filter(name => next.has(name) && next.get(name) !== live.get(name))),
417
+ added: sort([...next.keys()].filter(name => !live.has(name))),
418
+ };
419
+ }
@@ -1,4 +1,4 @@
1
- import { open, realpath } from 'node:fs/promises';
1
+ import { open, realpath, readFile, rename, rm, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createHash, randomUUID } from 'node:crypto';
@@ -18,7 +18,7 @@ function fields(value, allowed, subject) {
18
18
  for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`${subject}: campo desconocido ${key}`);
19
19
  }
20
20
 
21
- function pluginId(value) {
21
+ export function pluginId(value) {
22
22
  if (typeof value !== 'string' || value.length > 24 || !ID.test(value)) throw new Error('ID de plugin inválido: usa minúsculas, números y guiones (máximo 24 caracteres)');
23
23
  }
24
24
 
@@ -58,6 +58,12 @@ export async function readPluginConfig(file, { missing = false } = {}) {
58
58
  if (missing && error.code === 'ENOENT') return { file: absolute, hash: null, entries: [] };
59
59
  throw error;
60
60
  }
61
+ return parsePluginConfig(bytes, absolute);
62
+ }
63
+
64
+ /** The validation of readPluginConfig on bytes already in hand, as if they were the file `file`. */
65
+ export function parsePluginConfig(bytes, file) {
66
+ const absolute = path.resolve(file);
61
67
  let config;
62
68
  try { config = JSON.parse(bytes.toString('utf8')); }
63
69
  catch (error) { throw new Error(t('Invalid plugin JSON: {detail}', {detail: error.message})); }
@@ -66,8 +72,10 @@ export async function readPluginConfig(file, { missing = false } = {}) {
66
72
  if (!Array.isArray(config.plugins) || config.plugins.length > 32) throw new Error('plugins debe ser un arreglo con máximo 32 entradas');
67
73
  const seen = new Set();
68
74
  const entries = config.plugins.map(entry => {
69
- fields(entry, ['id', 'module', 'enabled', 'config'], 'Entrada de plugin');
75
+ fields(entry, ['id', 'module', 'enabled', 'config', 'repaired_from'], 'Entrada de plugin');
70
76
  pluginId(entry.id);
77
+ // repaired_from: the module a contract repair replaced (plugin-repair.mjs); a record for revert, never loaded.
78
+ if (entry.repaired_from !== undefined && (typeof entry.repaired_from !== 'string' || !entry.repaired_from || entry.repaired_from.length > 4096 || entry.repaired_from.includes('\0'))) throw new Error(`repaired_from debe ser una ruta: ${entry.id}`);
71
79
  if (seen.has(entry.id)) throw new Error(`ID de plugin duplicado: ${entry.id}`);
72
80
  seen.add(entry.id);
73
81
  if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') throw new Error(`enabled debe ser booleano: ${entry.id}`);
@@ -77,9 +85,30 @@ export async function readPluginConfig(file, { missing = false } = {}) {
77
85
  return { file: absolute, hash: hash(bytes), entries, ...(config.chrome_workflow ? { chrome_workflow: config.chrome_workflow } : {}) };
78
86
  }
79
87
 
80
- const MAX_CODE_FILES = 256;
88
+ /** The raw JSON of a plugins.json, validated by the real reader; { plugins: [] } when it does not exist. */
89
+ export async function readPluginConfigRaw(file) {
90
+ const source = await readPluginConfig(file, { missing: true }); // valida y acota
91
+ if (!source.hash) return { plugins: [] };
92
+ return JSON.parse(await readFile(file, 'utf8'));
93
+ }
94
+
95
+ /** Writes to a temporary file in the same directory, checks it with the real reader and publishes it with rename. */
96
+ export async function writePluginConfigRaw(file, value) {
97
+ await mkdir(path.dirname(file), { recursive: true });
98
+ const temporary = path.join(path.dirname(file), `.plugins-${randomUUID()}.json`);
99
+ try {
100
+ const handle = await open(temporary, 'wx', 0o644);
101
+ try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); }
102
+ const checked = await readPluginConfig(temporary);
103
+ await rename(temporary, file);
104
+ return checked;
105
+ } catch (error) { await rm(temporary, { force: true }); throw error; }
106
+ }
107
+
108
+ export const MAX_CODE_FILES = 256;
81
109
  // Literal specifiers only. Computed imports and packages are reported, never followed.
82
- const SPECIFIERS = [/\bfrom\s*['"]([^'"\n]+)['"]/g, /\bimport\s*['"]([^'"\n]+)['"]/g, /\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, /\bnew\s+URL\s*\(\s*['"]([^'"\n]+)['"]\s*,\s*import\.meta\.url\s*\)/g];
110
+ export const PLUGIN_SPECIFIERS = [/\bfrom\s*['"]([^'"\n]+)['"]/g, /\bimport\s*['"]([^'"\n]+)['"]/g, /\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, /\bnew\s+URL\s*\(\s*['"]([^'"\n]+)['"]\s*,\s*import\.meta\.url\s*\)/g];
111
+ const SPECIFIERS = PLUGIN_SPECIFIERS;
83
112
 
84
113
  /**
85
114
  * Fingerprint the plugin code without importing it: the entry module plus every