@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-status.mjs +106 -0
- package/src/agent.mjs +8 -2
- package/src/cli.mjs +65 -11
- package/src/finding-recorder.mjs +126 -0
- package/src/findings.mjs +63 -0
- package/src/house.mjs +16 -5
- package/src/i18n/cli-es.mjs +1 -1
- package/src/i18n/es.mjs +23 -1
- package/src/operations.mjs +10 -0
- package/src/permission-rules.mjs +3 -2
- package/src/permissions.mjs +75 -10
- package/src/plugin-candidate-child.mjs +204 -0
- package/src/plugin-candidate-runner.mjs +419 -0
- package/src/plugin-config.mjs +34 -5
- package/src/plugin-contract.mjs +309 -0
- package/src/plugin-controls.mjs +238 -32
- package/src/plugin-repair.mjs +981 -0
- package/src/plugin-trust.mjs +14 -4
- package/src/plugin-workbench.mjs +675 -0
- package/src/plugins.mjs +115 -24
- package/src/relay/flow.mjs +14 -14
- package/src/remote-permissions.mjs +7 -0
- package/src/scoped-policy.mjs +7 -1
- package/src/sessions.mjs +5 -1
- package/src/subagents.mjs +3 -3
- package/src/tools.mjs +17 -2
- package/src/tui/controller.mjs +2 -2
- package/src/tui/discovery.mjs +2 -2
- package/src/tui/screen.mjs +33 -14
- package/src/work-plan.mjs +77 -3
- package/src/workflows/chrome-page-task.mjs +8 -3
- package/src/workflows/chrome-repair.mjs +88 -0
- package/src/workflows/chrome-task.mjs +14 -5
package/src/house.mjs
CHANGED
|
@@ -17,6 +17,12 @@ const savedBroadAllow = (rules, name) => !rules?.decide?.(name, {})?.list?.match
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
// Refusals whose source is the human's own No to a permission question; the receipt says so.
|
|
21
|
+
const humanRefusals = new WeakSet();
|
|
22
|
+
// The first receipt that recorded an error object, and the invocation it would propagate to next. Only that
|
|
23
|
+
// direct parent, rethrowing it in the same book, says so (relayed_from); an unrelated later throw does not.
|
|
24
|
+
const receiptedErrors = new WeakMap();
|
|
25
|
+
|
|
20
26
|
// Textos de la tarjeta de confirmación: sólo los ve el humano.
|
|
21
27
|
registerMessages({
|
|
22
28
|
'Could not verify the summary': 'No se pudo verificar el resumen',
|
|
@@ -54,7 +60,9 @@ export class House {
|
|
|
54
60
|
#scopeMechanisms = new Map();
|
|
55
61
|
#revokedScopes = new Set();
|
|
56
62
|
constructor({ cwd, registry = new OperationRegistry(), record }) { this.cwd = cwd; this.registry = registry; this.hasRecorder = Boolean(record); this.record = record || (async () => {}); }
|
|
57
|
-
|
|
63
|
+
// The host may attach the finding recorder's swallowed count: () => number.
|
|
64
|
+
findingsUnrecorded = null;
|
|
65
|
+
snapshot(rows) { return houseSnapshot(rows, { active: this.#active, exclude: this.#context.getStore()?.id, findingsUnrecorded: this.findingsUnrecorded?.() }); }
|
|
58
66
|
stateContext() { return { active: new Set(this.#active), exclude: this.#context.getStore()?.id }; }
|
|
59
67
|
recoveryAdapter(sessionId,name) {return this.#recoveries.get(`${sessionId}:${name}`);}
|
|
60
68
|
registerRecovery(sessionId,name,handler) {const key=`${sessionId}:${name}`;this.#recoveries.set(key,handler);return ()=>{if(this.#recoveries.get(key)===handler)this.#recoveries.delete(key);};}
|
|
@@ -292,7 +300,7 @@ export class House {
|
|
|
292
300
|
const active = this.#context.getStore();
|
|
293
301
|
await this.emit(context, { type: 'house_decision', invocation_id: active?.id ?? null, contract_digest: active?.contractDigest ?? null, operation: name, arguments_digest: digest(input), allowed: Boolean(decision.allowed), reason: decision.reason, mode: context.policy.mode, ...(['permission_rule', 'confirm_always'].includes(decision.source) ? { permission_source: decision.source, ...(typeof decision.rule === 'string' ? { rule: decision.rule } : {}) } : {}) });
|
|
294
302
|
if (active) active.decisions.push(Boolean(decision.allowed));
|
|
295
|
-
if (!decision.allowed) {
|
|
303
|
+
if (!decision.allowed) { const error = hostRefusalFailure(`Permission denied: ${name}: ${decision.reason}`, 'HOUSE_REFUSED', 'permission', {type:'house_decision'}); if (decision.refused_by === 'human') humanRefusals.add(error); throw error; }
|
|
296
304
|
signal?.throwIfAborted(); return decision;
|
|
297
305
|
}
|
|
298
306
|
async execute(context, catalog, name, input, { signal, callId, work, beforeEffect, modelProposed = false, modelSurface } = {}) {
|
|
@@ -395,8 +403,11 @@ export class House {
|
|
|
395
403
|
});
|
|
396
404
|
} catch (error) {
|
|
397
405
|
const refusalCause=settleNativeRefusal(error,{...base,house:this.cwd,session_id:context.sessionId});
|
|
398
|
-
|
|
406
|
+
const keyed = error !== null && (typeof error === 'object' || typeof error === 'function'), first = keyed ? receiptedErrors.get(error) : undefined;
|
|
407
|
+
const relayedFrom = first && first.session_id === context.sessionId && first.next === id ? first.invocation_id : undefined;
|
|
408
|
+
await this.emit(context, { type: 'house_receipt', ...base, ...(refusalCause?{refusal_cause:refusalCause}:{}), ...(humanRefusals.has(error) ? { refused_by: 'human' } : {}), ...(relayedFrom ? { relayed_from: relayedFrom } : {}), status: entered ? 'failed' : 'refused', disposition: ['HOUSE_STALE', 'HOUSE_POSTCONDITION', 'HOUSE_AUTHORITY_CHANGED', 'HOUSE_SURFACE_CHANGED'].includes(error.code) ? 'repeat' : 'basket', judgment_scope: 'house_execution', code: error.code, error: error.message, effects_observed: entered ? 'unknown_or_partial' : 'none', observations: state.observations, asked: 'unjudged', ...(state.confirmCard ? { confirm_card: state.confirmCard } : {}) });
|
|
399
409
|
receiptRecorded = true;
|
|
410
|
+
if (keyed) receiptedErrors.set(error, { invocation_id: relayedFrom ?? id, session_id: context.sessionId, next: base.parent_invocation_id });
|
|
400
411
|
throw error;
|
|
401
412
|
} finally {
|
|
402
413
|
// Host-owned resources outlive the handler and its durable outcome. A
|
|
@@ -408,7 +419,7 @@ export class House {
|
|
|
408
419
|
}
|
|
409
420
|
|
|
410
421
|
// Derive closure from executor events, never from tool JSON saying verified=true.
|
|
411
|
-
export function houseSnapshot(rows, { active = new Set(), exclude } = {}) {
|
|
422
|
+
export function houseSnapshot(rows, { active = new Set(), exclude, findingsUnrecorded } = {}) {
|
|
412
423
|
const started = new Map(), receipts = [];
|
|
413
424
|
for (const row of rows) {
|
|
414
425
|
if (row.invocation_id === exclude && exclude) continue;
|
|
@@ -418,5 +429,5 @@ export function houseSnapshot(rows, { active = new Set(), exclude } = {}) {
|
|
|
418
429
|
const counts = Object.fromEntries(['returned', 'failed', 'refused'].map(status => [status, receipts.filter(r => r.status === status).length]));
|
|
419
430
|
const permissionRefusals = rows.filter(row => row.type === 'house_decision' && !row.allowed).length;
|
|
420
431
|
const unresolved = [...started.keys()].filter(id => !active.has(id)), running = [...started.keys()].filter(id => active.has(id));
|
|
421
|
-
return { scope: 'house_execution', asked: 'unjudged', status: unresolved.length ? 'interrupted' : running.length ? 'running' : counts.failed || counts.refused || permissionRefusals ? 'needs_review' : receipts.length ? 'recorded' : 'no_receipts', counts, permission_refusals: permissionRefusals, unresolved, running, receipts: receipts.slice(-40).map(({ output, ...row }) => row), receipt_total: receipts.length, integrity: 'local_journal_not_signed' };
|
|
432
|
+
return { scope: 'house_execution', asked: 'unjudged', status: unresolved.length ? 'interrupted' : running.length ? 'running' : counts.failed || counts.refused || permissionRefusals ? 'needs_review' : receipts.length ? 'recorded' : 'no_receipts', counts, permission_refusals: permissionRefusals, unresolved, running, receipts: receipts.slice(-40).map(({ output, ...row }) => row), receipt_total: receipts.length, ...(Number.isInteger(findingsUnrecorded) && findingsUnrecorded > 0 ? { findings_unrecorded: findingsUnrecorded } : {}), integrity: 'local_journal_not_signed' };
|
|
422
433
|
}
|
package/src/i18n/cli-es.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Original host messages for the optional Spanish interface. No user/model content.
|
|
2
2
|
export default Object.freeze({
|
|
3
|
-
"\nFrom inside a session (no flags needed):\n /config [set KEY VALUE|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVIDER [--key|--api-key-env VARIABLE] (hidden key field; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP
|
|
3
|
+
"\nFrom inside a session (no flags needed):\n /config [set KEY VALUE|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVIDER [--key|--api-key-env VARIABLE] (hidden key field; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…|repairs|acts|revert ID\n /chrome-workflow [auto|raw] · /autonomy [observe|assist|edit|autonomous]\n /permissions [MODE|allow|ask|deny|remove RULE [--sesion|--proyecto|--usuario]]\n": "\nDesde dentro de la sesión (sin banderas):\n /config [set CLAVE VALOR|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVEEDOR [--key|--api-key-env VARIABLE] (campo oculto para la llave; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…|repairs|acts|revert ID\n /chrome-workflow [auto|raw] · /autonomy [observar|asistir|editar|autonomo]\n /permissions [MODO|allow|ask|deny|remove REGLA [--sesion|--proyecto|--usuario]]\n",
|
|
4
4
|
"Permission bypass is fixed for this process. Restart Comal without --dangerously-skip-permissions to change modes.": "El bypass de permisos queda fijo en este proceso. Reinicia Comal sin --dangerously-skip-permissions para cambiar de modo.",
|
|
5
5
|
"Permission bypass active: prompts and classifier skipped for this process.": "Bypass de permisos activo: este proceso omite confirmaciones y clasificador.",
|
|
6
6
|
"\n --dangerously-skip-permissions Skip permission prompts/classifier for this process; startup only. Explicit denials and operation contracts still apply.\n": "\n --dangerously-skip-permissions Omite confirmaciones y clasificador en este proceso; sólo al arrancar. Se conservan denegaciones explícitas y contratos.\n",
|
package/src/i18n/es.mjs
CHANGED
|
@@ -160,6 +160,8 @@ export default Object.freeze({
|
|
|
160
160
|
"vision": "visión",
|
|
161
161
|
"connecting": "conectando",
|
|
162
162
|
"Enlarge the terminal to 24×10": "Amplía el terminal a 24×10",
|
|
163
|
+
"Permission: {v0}": "Permiso: {v0}",
|
|
164
|
+
"Waiting for your answer": "Esperando tu respuesta",
|
|
163
165
|
" {v0} interrupted message(s); not resent. R requeues the first.": " {v0} mensaje(s) interrumpidos; no se reenviaron. R reencola el primero.",
|
|
164
166
|
" Empty queue. Send a message while the agent works.": " Cola vacía. Envía un mensaje mientras el agente trabaja.",
|
|
165
167
|
" Running: ": " En ejecución: ",
|
|
@@ -482,6 +484,26 @@ export default Object.freeze({
|
|
|
482
484
|
"{run} run · {blocked} blocked · {done} done": "{run} en curso · {blocked} bloq. · {done} hechas",
|
|
483
485
|
"{q} questions · {p} plans": "{q} preguntas · {p} planes",
|
|
484
486
|
"/swarm opens": "/swarm lo abre",
|
|
485
|
-
"Comal is working · Esc to see the conversation": "Comal está trabajando · Esc para ver la conversación"
|
|
487
|
+
"Comal is working · Esc to see the conversation": "Comal está trabajando · Esc para ver la conversación",
|
|
488
|
+
"Plugin {plugin}: the repair changes what it promises. Sign only if you accept the new contract.": "Plugin {plugin}: la reparación cambia lo que promete. Firma sólo si aceptas el contrato nuevo.",
|
|
489
|
+
"Plugin {plugin}: the repair keeps what it promises, but it holds code the house cannot follow by reading it. Sign only if you reviewed that code.": "Plugin {plugin}: la reparación mantiene lo que promete, pero contiene código que la casa no puede seguir leyéndolo. Firma sólo si revisaste ese código.",
|
|
490
|
+
"Code the house cannot follow: {list}": "Código que la casa no puede seguir: {list}",
|
|
491
|
+
"Surface changed: {list}": "Superficie cambiada: {list}",
|
|
492
|
+
"Cases removed: {list}": "Casos eliminados: {list}",
|
|
493
|
+
"Cases changed: {list}": "Casos cambiados: {list}",
|
|
494
|
+
"Cases added: {list}": "Casos añadidos: {list}",
|
|
495
|
+
"The model's reason: {reason}": "Motivo del modelo: {reason}",
|
|
496
|
+
"Files in the patch: {list}": "Archivos del parche: {list}",
|
|
497
|
+
"Yes signs the act and the repaired plugin is published and checked; No (the default) leaves everything as it is.": "Sí firma el acta y el plugin reparado se publica y se comprueba; No (la opción por defecto) deja todo como está.",
|
|
498
|
+
"(none)": "(ninguno)",
|
|
499
|
+
"(none given)": "(no dio ninguno)",
|
|
500
|
+
"before: {value}": "antes: {value}",
|
|
501
|
+
"after: {value}": "después: {value}",
|
|
502
|
+
"{count} item(s) truncated; the full contracts before and after are in {before} and {after}.": "{count} elemento(s) recortado(s); los contratos completos de antes y después están en {before} y {after}.",
|
|
503
|
+
"An interrupted plugin repair could not be recovered ({error}); its marker was left in place and plugins.json was not changed. Review it with /plugins.": "No se pudo recuperar una reparación de plugin interrumpida ({error}); su marcador se dejó en su sitio y plugins.json no se cambió. Revísalo con /plugins.",
|
|
504
|
+
"The working directory {cwd} is inside a private Comal location ({location}). File tools (Read, Write, Edit, Grep…) are refused here: Comal keeps its configuration, credentials and session state out of the model's reach. Start Comal in your project directory, or choose another --state-dir.": "El directorio de trabajo {cwd} está dentro de una ubicación privada de Comal ({location}). Las herramientas de archivos (Read, Write, Edit, Grep…) se rechazan aquí: Comal mantiene su configuración, credenciales y estado de sesión fuera del alcance del modelo. Inicia Comal en el directorio de tu proyecto, o elige otro --state-dir.",
|
|
505
|
+
"A plugin repair was interrupted before it was verified; plugins.json was put back as it was before it.": "Una reparación de plugin se interrumpió antes de verificarse; plugins.json volvió a como estaba antes.",
|
|
506
|
+
"An unreadable plugin repair marker was removed; plugins.json was not changed.": "Se eliminó una marca ilegible de reparación de plugin; plugins.json no cambió.",
|
|
507
|
+
"A plugin repair was interrupted, but plugins.json was edited since; it was left as it is. Review it with /plugins.": "Una reparación de plugin se interrumpió, pero plugins.json se editó después; se dejó como está. Revísalo con /plugins."
|
|
486
508
|
|
|
487
509
|
});
|
package/src/operations.mjs
CHANGED
|
@@ -8,12 +8,22 @@ export const digest = value => createHash('sha256').update(JSON.stringify(canoni
|
|
|
8
8
|
export const immutable = value => { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(immutable); Object.freeze(value); } return value; };
|
|
9
9
|
const validatorOptions = { strict: false, allErrors: true, validateFormats: false };
|
|
10
10
|
|
|
11
|
+
// Repair by contract (plugin-repair.mjs): offered to the root agent only, never to a subagent's surface.
|
|
12
|
+
export const PLUGIN_REPAIR_OPERATION_NAMES = Object.freeze(['PluginRepairReproduce', 'PluginRepairOpen', 'PluginRepairTest', 'PluginRepairAccept', 'PluginRepairDiscard']);
|
|
13
|
+
|
|
11
14
|
// An effect ceiling describes declared reach, never proof of actual remote effects.
|
|
12
15
|
export function toolEffects(name) {
|
|
13
16
|
if (['ListDirectory', 'FileInfo'].includes(name)) return { workspace: 'read', external: 'none', process: 'none', authority: 'none' };
|
|
14
17
|
if (['Read', 'Glob', 'Grep', 'McpStatus', 'HouseContext', 'HouseSnapshot', 'OperationCatalog', 'ReadTable', 'PluginStatus', 'WorkSnapshot', 'AuditStatus', 'EvidenceRead'].includes(name)) return { workspace: 'read', external: 'none', process: 'none', authority: 'none' };
|
|
15
18
|
if (['Write', 'Edit'].includes(name)) return { workspace: 'write', external: 'none', process: 'none', authority: 'none' };
|
|
16
19
|
if (['WebSearch', 'WebFetch'].includes(name)) return { workspace: 'none', external: 'read', process: 'none', authority: 'none' };
|
|
20
|
+
// Repair by contract (plugin-repair.mjs). Reproduce calls a plugin operation, whose own effects are not
|
|
21
|
+
// known statically; Test runs candidate code in a child process; Accept writes plugins.json and the trust
|
|
22
|
+
// record and loads code in this process; Open and Discard create or remove the candidate's write root.
|
|
23
|
+
if (name === 'PluginRepairReproduce') return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
|
|
24
|
+
if (name === 'PluginRepairOpen' || name === 'PluginRepairDiscard') return { workspace: 'write', external: 'none', process: 'none', authority: 'candidate_write_root' };
|
|
25
|
+
if (name === 'PluginRepairTest') return { workspace: 'unknown', external: 'unknown', process: 'spawn', authority: 'none' };
|
|
26
|
+
if (name === 'PluginRepairAccept') return { workspace: 'write', external: 'unknown', process: 'unknown', authority: 'plugin_lifecycle' };
|
|
17
27
|
return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
|
|
18
28
|
}
|
|
19
29
|
|
package/src/permission-rules.mjs
CHANGED
|
@@ -268,11 +268,12 @@ export class PermissionRules {
|
|
|
268
268
|
return ruleCovers(parsed, name, input, { list, project: this.project || process.cwd(), groups: this.groups });
|
|
269
269
|
}
|
|
270
270
|
/** Precedencia: deny > ask > allow. Devuelve la regla decisiva o undefined. */
|
|
271
|
-
|
|
271
|
+
// context: { invocation } of the call being judged, passed to gate (it never changes deny or ask).
|
|
272
|
+
decide(name, input, context) {
|
|
272
273
|
const found = this.applicable().filter(rule => this.matches(rule, name, input));
|
|
273
274
|
const deny = found.find(rule => rule.list === 'deny'); if (deny) return deny;
|
|
274
275
|
const ask = found.find(rule => rule.list === 'ask'); if (ask) return ask;
|
|
275
|
-
return found.find(rule => rule.list === 'allow' && this.gate(name));
|
|
276
|
+
return found.find(rule => rule.list === 'allow' && this.gate(name, context));
|
|
276
277
|
}
|
|
277
278
|
/** Reemplaza las reglas que cumplen `remove` por `add`, en una sola escritura atómica. */
|
|
278
279
|
async update({ remove = () => false, add = [] } = {}) {
|
package/src/permissions.mjs
CHANGED
|
@@ -7,9 +7,18 @@ import { readOnlyBashCheck, chromeLabObservation, openRelayObservation, omarchyO
|
|
|
7
7
|
import { describeRule, suggestRule } from './permission-rules.mjs';
|
|
8
8
|
import { measureHumanWait } from './turn-timing.mjs';
|
|
9
9
|
import { realpath, stat } from 'node:fs/promises';
|
|
10
|
+
import { realpathSync } from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
|
|
14
|
+
// The same, synchronously, for the policy's construction.
|
|
15
|
+
function canonicalishSync(file) {
|
|
16
|
+
let ancestor = path.resolve(file), suffix = [];
|
|
17
|
+
while (true) {
|
|
18
|
+
try { return path.join(realpathSync(ancestor), ...suffix); }
|
|
19
|
+
catch { const parent = path.dirname(ancestor); if (parent === ancestor) return path.resolve(file); suffix.unshift(path.basename(ancestor)); ancestor = parent; }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
13
22
|
const within = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); };
|
|
14
23
|
|
|
15
24
|
/** realpath of the nearest existing ancestor plus the rest: a location not created yet still compares canonically. */
|
|
@@ -114,15 +123,22 @@ function normalizeAnswer(value, offer) {
|
|
|
114
123
|
return { allowed: Boolean(value), remember: null };
|
|
115
124
|
}
|
|
116
125
|
const CONFIRM_ALWAYS = 'confirm:"always"';
|
|
126
|
+
const humanRefusal = answer => answer.refused_by === 'human' ? { refused_by: 'human' } : {};
|
|
117
127
|
|
|
118
128
|
export class PermissionPolicy {
|
|
119
129
|
#mode;
|
|
120
130
|
// rules: almacén de reglas guardadas (permission-rules.mjs) con decide(name, input) → {list, rule, scope}.
|
|
121
131
|
// contractOf(name): contrato vivo de la operación (house.registry.get(name)?.contract); de ahí sale
|
|
122
132
|
// el techo duro confirm:'always', que sólo el host lee y el modelo no puede cambiar.
|
|
123
|
-
#workRoots = []; #private = []; #privateExtra;
|
|
124
|
-
constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [] } = {}) {
|
|
133
|
+
#workRoots = []; #private = []; #privateExtra; #candidateRoots = []; #ownWorkspace = null;
|
|
134
|
+
constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [], ownWorkspace } = {}) {
|
|
125
135
|
this.#privateExtra = [...privateExtra];
|
|
136
|
+
// Host-only (scoped-policy.mjs): the workspace the house created for a child inside one of the extra private
|
|
137
|
+
// locations (an owned Git worktree under the --state-dir). That tree, and only it, is not private for this
|
|
138
|
+
// policy; the rest of the location still is. Comal's standard private locations are never exempted.
|
|
139
|
+
if (typeof ownWorkspace === 'string' && ownWorkspace) this.#ownWorkspace = { root: canonicalishSync(ownWorkspace), locations: this.#privateExtra.flatMap(location => [path.resolve(location), canonicalishSync(location)]) };
|
|
140
|
+
// Private from the start, for every path the tools resolve (the --state-dir included, wherever it is).
|
|
141
|
+
for (const location of privateLocations({ extra: this.#privateExtra })) this.#private.push(location, canonicalishSync(location));
|
|
126
142
|
if (!PERMISSION_MODES.includes(mode) && !(dangerouslySkipPermissions === true && mode === BYPASS_PERMISSION_MODE)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto; bypass requires the startup flag.');
|
|
127
143
|
Object.defineProperty(this, 'dangerouslySkipPermissions', { value: dangerouslySkipPermissions === true, enumerable: true });
|
|
128
144
|
this.#mode = this.dangerouslySkipPermissions ? BYPASS_PERMISSION_MODE : mode;
|
|
@@ -147,9 +163,53 @@ export class PermissionPolicy {
|
|
|
147
163
|
return { added: true, path: canonical };
|
|
148
164
|
}
|
|
149
165
|
/** Host-only: another session was resumed in this process; its own /add-dir rows are restored next. */
|
|
150
|
-
clearWorkRoots() { this.#workRoots = []; }
|
|
166
|
+
clearWorkRoots() { this.#workRoots = []; this.#candidateRoots = []; }
|
|
167
|
+
/**
|
|
168
|
+
* Host-only (plugin-repair.mjs): the exact tree of an open plugin candidate, `<container>/<id>/<uuid>/tree`
|
|
169
|
+
* where container is `<stateDir>/plugin-candidates`. It is the one writable place under Comal state: inside
|
|
170
|
+
* it, and only there, the .comal/.harness rule and the state directory's own privacy (--state-dir) do not
|
|
171
|
+
* apply. Any other private Comal location still refuses it. Removed with removeCandidateRoot.
|
|
172
|
+
*/
|
|
173
|
+
async addCandidateRoot(dir, { container, env, home } = {}) {
|
|
174
|
+
if (typeof dir !== 'string' || typeof container !== 'string') throw new TypeError('addCandidateRoot requires the candidate tree and its container.');
|
|
175
|
+
const canonical = await realpath(dir), box = await realpath(container), info = await stat(canonical);
|
|
176
|
+
const parts = path.relative(box, canonical).split(path.sep);
|
|
177
|
+
if (!info.isDirectory() || parts.length !== 3 || parts[2] !== 'tree' || parts.some(part => !part || part === '..') || path.isAbsolute(path.relative(box, canonical))) throw new Error(`Not a plugin candidate tree: ${dir}`);
|
|
178
|
+
if (info.mode & 0o002) throw new Error(`The candidate tree is world-writable: ${canonical}`);
|
|
179
|
+
const stateDir = path.dirname(box), exempt = [];
|
|
180
|
+
for (const location of privateLocations({ env, home, extra: this.#privateExtra })) {
|
|
181
|
+
const real = await canonicalish(location);
|
|
182
|
+
if ([location, real].includes(stateDir)) { exempt.push(location, real); continue; } // the state directory itself (--state-dir)
|
|
183
|
+
if ([location, real].some(item => within(canonical, item) || within(item, canonical))) throw new Error(`The candidate tree touches a private Comal location (${location}).`);
|
|
184
|
+
}
|
|
185
|
+
this.#private = [];
|
|
186
|
+
for (const location of privateLocations({ env, home, extra: this.#privateExtra })) this.#private.push(location, await canonicalish(location));
|
|
187
|
+
if (!this.#candidateRoots.some(entry => entry.root === canonical)) { this.#candidateRoots.push({ root: canonical, exempt }); this.#workRoots.push(canonical); }
|
|
188
|
+
return { added: true, path: canonical };
|
|
189
|
+
}
|
|
190
|
+
removeCandidateRoot(dir) {
|
|
191
|
+
const entry = this.#candidateRoots.find(item => item.root === dir); if (!entry) return false;
|
|
192
|
+
this.#candidateRoots = this.#candidateRoots.filter(item => item !== entry); this.#workRoots = this.#workRoots.filter(root => root !== entry.root); return true;
|
|
193
|
+
}
|
|
194
|
+
/** The open candidate trees (canonical paths; a frozen copy). */
|
|
195
|
+
get candidateRoots() { return Object.freeze(this.#candidateRoots.map(entry => entry.root)); }
|
|
196
|
+
/** The open candidate tree holding `file` (canonical), or null. */
|
|
197
|
+
candidateRootOf(file) { return this.#candidateRoots.find(entry => within(entry.root, file))?.root ?? null; }
|
|
198
|
+
/** The private locations given at construction (the --state-dir and the other paths named by flags): a scoped policy takes them too. */
|
|
199
|
+
get privateExtra() { return Object.freeze([...this.#privateExtra]); }
|
|
200
|
+
/** The private Comal location that holds `file` (canonical), or null. An open candidate's tree is not inside one. */
|
|
201
|
+
privateLocationOf(file) {
|
|
202
|
+
const candidate = this.#candidateRoots.find(entry => within(entry.root, file));
|
|
203
|
+
return this.#private.find(location => within(location, file) && !candidate?.exempt.includes(location) && !this.#own(location, file)) ?? null;
|
|
204
|
+
}
|
|
205
|
+
/** The workspace the house gave this policy inside a private location (see the constructor), or undefined. */
|
|
206
|
+
get ownWorkspace() { return this.#ownWorkspace?.root; }
|
|
207
|
+
#own(location, file) { return Boolean(this.#ownWorkspace) && this.#ownWorkspace.locations.includes(location) && within(this.#ownWorkspace.root, file); }
|
|
151
208
|
/** A private Comal location or a path an active plugin protects: closed even under an added directory. */
|
|
152
|
-
isPrivate(file) {
|
|
209
|
+
isPrivate(file) {
|
|
210
|
+
const candidate = this.#candidateRoots.find(entry => within(entry.root, file));
|
|
211
|
+
return this.#private.some(location => within(location, file) && !candidate?.exempt.includes(location) && !this.#own(location, file)) || isProtectedPath(file);
|
|
212
|
+
}
|
|
153
213
|
get mode() { return this.#mode; }
|
|
154
214
|
set mode(mode) {
|
|
155
215
|
if (this.dangerouslySkipPermissions) throw new Error(BYPASS_LOCKED_MESSAGE);
|
|
@@ -179,16 +239,16 @@ export class PermissionPolicy {
|
|
|
179
239
|
signal?.throwIfAborted();
|
|
180
240
|
if (this.denied.has(name)) return { allowed: false, reason: 'Explicit denial rule' };
|
|
181
241
|
// Saved human rules: deny > ask > allow. Ask forces a question even in auto; the startup bypass skips it (deny still applies).
|
|
182
|
-
const rule = this.rules?.decide(name, input), ruled = rule ? `Permission ${describeRule(rule)}` : null, cite = rule ? { rule: rule.rule } : {};
|
|
242
|
+
const rule = this.rules?.decide(name, input, { invocation }), ruled = rule ? `Permission ${describeRule(rule)}` : null, cite = rule ? { rule: rule.rule } : {};
|
|
183
243
|
if (rule?.list === 'deny') return { allowed: false, reason: ruled, source: 'permission_rule', ...cite };
|
|
184
244
|
if (this.mode === BYPASS_PERMISSION_MODE) return { allowed: true, reason: 'Explicit startup --dangerously-skip-permissions; permission prompts and classifier skipped', source: 'startup_bypass' };
|
|
185
245
|
if (this.confirmAlways(name)) {
|
|
186
246
|
if (this.mode === 'plan') return { allowed: false, reason: `Plan mode: la operación declara ${CONFIRM_ALWAYS} y plan no la ejecuta`, source: 'confirm_always' };
|
|
187
247
|
if (!this.confirm) return { allowed: false, reason: `La operación declara ${CONFIRM_ALWAYS} y no hay un humano que confirme`, source: 'confirm_always' };
|
|
188
248
|
const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer;
|
|
189
|
-
return { allowed: answer.allowed, reason: `Confirmación obligatoria (${CONFIRM_ALWAYS}): ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'confirm_always' };
|
|
249
|
+
return { allowed: answer.allowed, reason: `Confirmación obligatoria (${CONFIRM_ALWAYS}): ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'confirm_always', ...humanRefusal(answer) };
|
|
190
250
|
}
|
|
191
|
-
if (rule?.list === 'ask') { const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer; return { allowed: answer.allowed, reason: `${ruled}: ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'permission_rule', ...cite }; }
|
|
251
|
+
if (rule?.list === 'ask') { const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer; return { allowed: answer.allowed, reason: `${ruled}: ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'permission_rule', ...cite, ...humanRefusal(answer) }; }
|
|
192
252
|
const granted = this.allowed.has(name) || rule?.list === 'allow';
|
|
193
253
|
const knownRead = READ_TOOLS.has(name) || granted && (chromeLabObservation(name) || openRelayObservation(name) || omarchyObservation(name));
|
|
194
254
|
if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegation: every child action retains the session permissions' };
|
|
@@ -203,7 +263,7 @@ export class PermissionPolicy {
|
|
|
203
263
|
const offer = this.rememberOffer(name, input), answer = await this.#question(name, input, signal, onWaiting, offer);
|
|
204
264
|
if (answer.stale) return answer;
|
|
205
265
|
const base = reason ?? (answer.allowed ? 'User confirmation' : 'No authorization for this tool');
|
|
206
|
-
return { allowed: answer.allowed, reason: answer.saved ? `${base}; saved ${describeRule(answer.saved)}` : answer.saveError ? `${base}; the rule was not saved: ${answer.saveError}` : base, ...(answer.saved ? { source: 'permission_rule', rule: answer.saved.rule } : {}) };
|
|
266
|
+
return { allowed: answer.allowed, reason: answer.saved ? `${base}; saved ${describeRule(answer.saved)}` : answer.saveError ? `${base}; the rule was not saved: ${answer.saveError}` : base, ...(answer.saved ? { source: 'permission_rule', rule: answer.saved.rule } : {}), ...humanRefusal(answer) };
|
|
207
267
|
}
|
|
208
268
|
/** Regla propuesta para «sí, siempre» o null (sin almacén, confirm:'always' o sin regla segura). */
|
|
209
269
|
rememberOffer(name, input) {
|
|
@@ -219,7 +279,9 @@ export class PermissionPolicy {
|
|
|
219
279
|
const slot=new Promise(resolve => {release=resolve;});
|
|
220
280
|
const tail=previous.catch(() => {}).then(() => slot);
|
|
221
281
|
confirmationQueues.set(confirm,tail);
|
|
222
|
-
|
|
282
|
+
// Why the card closed comes back out of band (onOutcome), never in confirm's value: callers of confirm
|
|
283
|
+
// test its truthiness, so it stays a boolean (or { allowed: true, remember } when «always» was offered).
|
|
284
|
+
const questionId=randomUUID();let allowed=false,displayed=false,outcome;
|
|
223
285
|
try {
|
|
224
286
|
await waitWithAbort(previous,signal);
|
|
225
287
|
signal?.throwIfAborted();
|
|
@@ -230,9 +292,12 @@ export class PermissionPolicy {
|
|
|
230
292
|
await onWaiting?.({type:'house_waiting',question_id:questionId});
|
|
231
293
|
displayed=true;signal?.throwIfAborted();
|
|
232
294
|
// The visible card is human time: excluded from the turn's Comal time.
|
|
233
|
-
const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal)),offer);
|
|
295
|
+
const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,onOutcome:why=>{outcome=why;},...(offer?{remember:offer}:{})}),signal)),offer);
|
|
234
296
|
signal?.throwIfAborted();
|
|
235
297
|
allowed=answer.allowed && this.confirm===confirm && (stamp===permissionStamp(this) || visible===houseStamp(this) && this.rules?.decide(name,input)?.list!=='deny');
|
|
298
|
+
// Only the human's plain No (outcome 'no') marks the refusal; edit before sending, a closed
|
|
299
|
+
// screen, an abort, an interrupt and a stale or changed authority do not.
|
|
300
|
+
if(!answer.allowed && outcome==='no')return { allowed:false, refused_by: 'human' };
|
|
236
301
|
if(!allowed || !answer.remember)return { allowed };
|
|
237
302
|
// Se guarda antes de liberar el turno, para que la siguiente pregunta ya la vea.
|
|
238
303
|
const scope=answer.remember==='user' ? 'user' : this.rules.scope;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The child side of the plugin workbench runner (plugin-candidate-runner.mjs). It runs in
|
|
2
|
+
// its own process and process group, started as
|
|
3
|
+
// node --max-old-space-size=512 plugin-candidate-child.mjs <candidate module>
|
|
4
|
+
// and speaks JSON lines: requests on stdin, reports on the real stdout. Candidate code
|
|
5
|
+
// writing through console.log or process.stdout.write is sent to stderr instead, so a noisy
|
|
6
|
+
// plugin is not a protocol error; bytes written straight to fd 1 still reach the parent and
|
|
7
|
+
// count against its protocol bound.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here is a security boundary against the candidate: candidate code runs in this
|
|
10
|
+
// same process and can write anything it likes, forged reports included. The parent treats
|
|
11
|
+
// every message as untrusted, bounded data; isolation protects the session, not the truth
|
|
12
|
+
// of the report.
|
|
13
|
+
//
|
|
14
|
+
// The recording context mirrors PluginRuntime#stage (src/plugins.mjs) and the manifest checks
|
|
15
|
+
// mirror PluginRuntime#definition: both are private to PluginRuntime, so the minimum is
|
|
16
|
+
// replicated here. Operations are built with the live Operation class, so schema
|
|
17
|
+
// compilation and input/output validation are the live ones.
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { writeSync } from 'node:fs';
|
|
21
|
+
import { Operation, immutable, toolEffects, digest } from './operations.mjs';
|
|
22
|
+
import { validateCases } from './plugin-contract.mjs';
|
|
23
|
+
|
|
24
|
+
// Reports are written synchronously to fd 1, so a process.exit right after one loses nothing.
|
|
25
|
+
const toStderr = process.stderr.write.bind(process.stderr);
|
|
26
|
+
process.stdout.write = (chunk, encoding, callback) => toStderr(chunk, encoding, callback);
|
|
27
|
+
function send(message) {
|
|
28
|
+
const bytes = Buffer.from(`${JSON.stringify(message)}\n`);
|
|
29
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
30
|
+
try { offset += writeSync(1, bytes, offset); } catch (error) { if (error.code !== 'EAGAIN') throw error; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TEXT = 2000, MAX_OPERATIONS = 200, MAX_CHECKS = 200, OUTPUT_BYTES = 256 * 1024, OUTPUT_TOTAL = 2 * 1024 * 1024;
|
|
35
|
+
const text = value => String(value ?? '').slice(0, TEXT);
|
|
36
|
+
const errorText = error => text(error instanceof Error ? error.message : error);
|
|
37
|
+
const jsonCopy = value => JSON.parse(JSON.stringify(value));
|
|
38
|
+
const idPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
39
|
+
// operationName in plugin-catalog.mjs; not imported, since that module loads the bundled plugins.
|
|
40
|
+
const published = (id, name) => name.startsWith('plugin__') ? name : `plugin__${id}__${name}`;
|
|
41
|
+
const HOOK_EVENTS = ['operation:result', 'operation:decision', 'skill:started', 'skill:completed'];
|
|
42
|
+
const OPERATION_KEYS = ['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', 'confirm', 'confirm_summary'];
|
|
43
|
+
|
|
44
|
+
// The checks of PluginRuntime#definition, and the id the candidate was opened for.
|
|
45
|
+
function checkDefinition(definition, id) {
|
|
46
|
+
if (!definition || typeof definition !== 'object' || !idPattern.test(definition.id) || definition.id.length > 24 || typeof definition.version !== 'string' || !definition.version || typeof definition.activate !== 'function') throw new Error('Invalid plugin manifest: requires id, version and activate.');
|
|
47
|
+
if (definition.id !== id) throw new Error(`The module's plugin id ${definition.id} is not the candidate's id ${id}.`);
|
|
48
|
+
for (const key of ['requires', 'provides']) {
|
|
49
|
+
const list = definition[key] ?? [];
|
|
50
|
+
if (!Array.isArray(list) || list.length > 32 || list.some(name => typeof name !== 'string' || !name || name.length > 128) || new Set(list).size !== list.length) throw new Error('Invalid plugin requires/provides.');
|
|
51
|
+
}
|
|
52
|
+
if (definition.tool_loading !== undefined && !['eager', 'deferred'].includes(definition.tool_loading)) throw new Error('tool_loading must be eager or deferred.');
|
|
53
|
+
if (definition.hook_timeout_ms !== undefined && (!Number.isInteger(definition.hook_timeout_ms) || definition.hook_timeout_ms < 10 || definition.hook_timeout_ms > 5000)) throw new Error('hook_timeout_ms requires 10–5000 ms.');
|
|
54
|
+
return { requires: [...(definition.requires ?? [])], provides: [...(definition.provides ?? [])], cases: validateCases(definition.cases) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What the candidate printed to stderr is part of the report: let it drain (briefly) before leaving.
|
|
58
|
+
async function leave(code) {
|
|
59
|
+
const until = performance.now() + 500;
|
|
60
|
+
while (process.stderr.writableLength > 0 && performance.now() < until) await new Promise(resolve => setTimeout(resolve, 5));
|
|
61
|
+
process.exit(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })[Symbol.asyncIterator]();
|
|
65
|
+
async function next() {
|
|
66
|
+
const { value, done } = await lines.next();
|
|
67
|
+
if (done) process.exit(0); // the parent is gone
|
|
68
|
+
return JSON.parse(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const init = await next();
|
|
73
|
+
if (init?.type !== 'init') throw new Error('Expected init.');
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
const remaining = () => Math.max(0, init.deadline_ms - (performance.now() - started));
|
|
76
|
+
const id = init.id, config = init.config ?? {};
|
|
77
|
+
// The house's own services, stood in: the plain data the parent passed, plus a function for each member
|
|
78
|
+
// that is a function in the house. Such a function exists (a plugin may look at it) and throws when it is
|
|
79
|
+
// called: this process has no house to answer it. What activation needed and could not have is told to
|
|
80
|
+
// the parent, which then says the candidate was not judged rather than that it failed.
|
|
81
|
+
const unavailable = new Set();
|
|
82
|
+
const notHere = what => Object.assign(new Error(`${what} is not available in the pre-flight: it runs in a separate process without the house. The house verifies the candidate when it is accepted.`), { code: 'PLUGIN_PREFLIGHT_UNAVAILABLE' });
|
|
83
|
+
const services = {};
|
|
84
|
+
for (const [name, value] of Object.entries(init.services ?? {})) {
|
|
85
|
+
const members = Array.isArray(init.service_functions?.[name]) ? init.service_functions[name].filter(key => typeof key === 'string') : [];
|
|
86
|
+
if (!members.length || value === null || typeof value !== 'object' || Array.isArray(value)) { services[name] = value; continue; }
|
|
87
|
+
const standIn = { ...value };
|
|
88
|
+
for (const key of members) Object.defineProperty(standIn, key, { enumerable: true, value: () => { if (staging) unavailable.add(`${name}.${key}()`); throw notHere(`${name}.${key}`); } });
|
|
89
|
+
services[name] = Object.freeze(standIn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let module, definition, manifest;
|
|
93
|
+
try {
|
|
94
|
+
module = await import(pathToFileURL(process.argv[2]).href);
|
|
95
|
+
definition = module.default;
|
|
96
|
+
manifest = checkDefinition(definition, id);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
send({ type: 'import_failed', error: errorText(error) });
|
|
99
|
+
return leave(0);
|
|
100
|
+
}
|
|
101
|
+
const missing = manifest.requires.filter(name => !Object.hasOwn(services, name));
|
|
102
|
+
send({ type: 'imported', missing_services: missing });
|
|
103
|
+
|
|
104
|
+
// The recording context: the surface of PluginRuntime#stage.
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const operations = new Map(), provided = new Set(), disposers = [];
|
|
107
|
+
let staging = true;
|
|
108
|
+
const declaring = () => { if (!staging) throw new Error('Capabilities are only declared during activate.'); };
|
|
109
|
+
const register = (spec, human = false) => {
|
|
110
|
+
declaring();
|
|
111
|
+
for (const key of Object.keys(spec)) if (!OPERATION_KEYS.includes(key)) throw new Error(`Contract reserved to the host: ${key}`);
|
|
112
|
+
if (Object.hasOwn(spec, 'confirm') && spec.confirm !== 'always') throw new Error("A plugin's confirm only admits 'always'.");
|
|
113
|
+
if (Object.hasOwn(spec, 'confirm_summary') && typeof spec.confirm_summary !== 'function') throw new Error("A plugin's confirm_summary must be a function.");
|
|
114
|
+
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(spec.name) || typeof spec.run !== 'function') throw new Error('Invalid plugin operation name/handler.');
|
|
115
|
+
const name = `plugin__${id}__${spec.name}`;
|
|
116
|
+
if (name.length > 64 || operations.has(name)) throw new Error(`Duplicate operation or name too long: ${name}`);
|
|
117
|
+
const handler = spec.run;
|
|
118
|
+
const operation = new Operation({ name, description: spec.description, input_schema: spec.input_schema, output_schema: spec.output_schema, effects: spec.effects || toolEffects('__unknown_plugin'), audience: human ? 'human' : 'any', surfaces: human ? ['cli', 'tui'] : ['cli', 'tui', 'agent'], permission: name, ...(spec.confirm ? { confirm: spec.confirm } : {}), run: (input, signal) => handler(immutable(jsonCopy(input)), Object.freeze({ signal })) });
|
|
119
|
+
operations.set(name, operation);
|
|
120
|
+
return name;
|
|
121
|
+
};
|
|
122
|
+
const context = Object.freeze({
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
config: immutable(jsonCopy(config)),
|
|
125
|
+
operation: spec => register(spec), command: spec => register(spec, true),
|
|
126
|
+
service: name => {
|
|
127
|
+
if (!manifest.requires.includes(name)) throw new Error(`Service not declared/available: ${name}`);
|
|
128
|
+
// Declared, and not one of the house's own: another plugin provides it, and none is loaded here.
|
|
129
|
+
if (!Object.hasOwn(services, name)) { if (staging) unavailable.add(name); throw notHere(`Service ${name}`); }
|
|
130
|
+
return services[name];
|
|
131
|
+
},
|
|
132
|
+
provide: name => { declaring(); if (!manifest.provides.includes(name) || provided.has(name)) throw new Error(`Service not declared/duplicate: ${name}`); provided.add(name); },
|
|
133
|
+
defer: disposer => { if (typeof disposer !== 'function') throw new Error('Cleanup requires a function.'); disposers.push(disposer); },
|
|
134
|
+
on: (event, listener) => { if (!HOOK_EVENTS.includes(event) || typeof listener !== 'function') throw new Error('Invalid plugin hook.'); },
|
|
135
|
+
guard: check => { declaring(); if (typeof check !== 'function') throw new Error('A guard requires a function.'); },
|
|
136
|
+
notify: async () => ({ status: 'unavailable' }),
|
|
137
|
+
});
|
|
138
|
+
const listed = () => [...operations.values()].slice(0, MAX_OPERATIONS).map(({ contract }) => ({ name: contract.name, description: text(contract.description), input_schema: contract.input_schema, output_schema: contract.output_schema, effects: contract.effects, audience: contract.audience }));
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (definition.config_schema) new Operation({ name: `${id}_config`, description: 'Plugin configuration', input_schema: definition.config_schema, run: () => '' }).validate(config);
|
|
142
|
+
await definition.activate(context, context.config);
|
|
143
|
+
for (const name of manifest.provides) if (!provided.has(name)) throw new Error(`The plugin did not publish the declared service: ${name}`);
|
|
144
|
+
validateCases(manifest.cases, [...operations.keys()].map(name => name.slice(`plugin__${id}__`.length)));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
send({ type: 'activate_failed', error: errorText(error), operations: listed(), ...(unavailable.size ? { unavailable_services: [...unavailable].slice(0, 32) } : {}) });
|
|
147
|
+
return leave(0);
|
|
148
|
+
}
|
|
149
|
+
staging = false;
|
|
150
|
+
send({ type: 'activated', operations: listed(), cases: manifest.cases });
|
|
151
|
+
|
|
152
|
+
// A handler run as the house runs it: input validated, a frozen copy in, output validated.
|
|
153
|
+
const invoke = async (name, input, budget) => {
|
|
154
|
+
const operation = operations.get(published(id, String(name)));
|
|
155
|
+
if (!operation) throw new Error(`Unknown operation: ${name}`);
|
|
156
|
+
operation.validate(input);
|
|
157
|
+
const output = await operation.run(input, AbortSignal.any([controller.signal, AbortSignal.timeout(Math.max(1, Math.floor(Number(budget) || 1)))]));
|
|
158
|
+
operation.validateOutput(output);
|
|
159
|
+
return output;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const checks = [];
|
|
163
|
+
const suite = module.checks ?? definition.checks;
|
|
164
|
+
if (suite !== undefined) {
|
|
165
|
+
if (!suite || typeof suite !== 'object' || Array.isArray(suite)) checks.push({ name: 'checks', ok: false, error: 'The checks export must be an object of functions.' });
|
|
166
|
+
else for (const [name, check] of Object.entries(suite).slice(0, MAX_CHECKS)) {
|
|
167
|
+
if (typeof check !== 'function') { checks.push({ name: text(name), ok: false, error: 'Not a function.' }); continue; }
|
|
168
|
+
try { await check((operation, input) => invoke(operation, input, remaining())); checks.push({ name: text(name), ok: true }); }
|
|
169
|
+
catch (error) { checks.push({ name: text(name), ok: false, error: errorText(error) }); }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
send({ type: 'checks', checks });
|
|
173
|
+
send({ type: 'ready' });
|
|
174
|
+
|
|
175
|
+
let outputLeft = OUTPUT_TOTAL; // the parent's budget for asked-for outputs, kept here too so stdout stays bounded
|
|
176
|
+
for (;;) {
|
|
177
|
+
const request = await next();
|
|
178
|
+
if (request?.type === 'replay') {
|
|
179
|
+
try {
|
|
180
|
+
const output = await invoke(request.operation, request.input, request.remaining_ms);
|
|
181
|
+
let rendered;
|
|
182
|
+
try { rendered = typeof output === 'string' ? output : JSON.stringify(output) ?? '(no output)'; }
|
|
183
|
+
catch { throw new Error('The output is not JSON-serializable.'); }
|
|
184
|
+
let asked = {};
|
|
185
|
+
if (request.want_output === true) {
|
|
186
|
+
const bytes = Buffer.byteLength(JSON.stringify(rendered));
|
|
187
|
+
asked = bytes > OUTPUT_BYTES ? { output_omitted: 'too_large' } : bytes > outputLeft ? { output_omitted: 'budget' } : (outputLeft -= bytes, { output: rendered });
|
|
188
|
+
}
|
|
189
|
+
send({ type: 'replayed', seq: request.seq, outcome: 'returned', output_sha256: digest(rendered), ...asked });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
send({ type: 'replayed', seq: request.seq, outcome: 'threw', error: errorText(error), ...(typeof error?.code === 'string' ? { code: error.code.slice(0, 128) } : {}) });
|
|
192
|
+
}
|
|
193
|
+
} else if (request?.type === 'done') {
|
|
194
|
+
// Retire as the runtime does: abort the scope, then run the cleanups in reverse.
|
|
195
|
+
controller.abort(new Error('Plugin retired.'));
|
|
196
|
+
const errors = [];
|
|
197
|
+
for (const disposer of disposers.reverse()) try { await disposer(); } catch (error) { errors.push(errorText(error)); }
|
|
198
|
+
send({ type: 'closed', cleanup: errors.length ? { ok: false, error: text(errors.join('; ')) } : { ok: true } });
|
|
199
|
+
return leave(0);
|
|
200
|
+
} else throw new Error('Unexpected request.');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch(error => { toStderr(`plugin-candidate-child: ${errorText(error)}\n`); process.exit(70); });
|