@milpa/comal 0.2.7 → 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.
@@ -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
- decide(name, input) {
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 = [] } = {}) {
@@ -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) { return this.#private.some(location => within(location, file)) || isProtectedPath(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
- const questionId=randomUUID();let allowed=false,displayed=false;
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); });