@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.
- package/README.md +1 -1
- package/package.json +1 -1
- 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 +21 -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/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 +1 -1
- package/src/tui/discovery.mjs +2 -2
- package/src/tui/screen.mjs +12 -9
- 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/plugin-controls.mjs
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { readPluginConfig } from './plugin-config.mjs';
|
|
1
|
+
import { readPluginConfig, readPluginConfigRaw as readRaw, writePluginConfigRaw as writeRaw } from './plugin-config.mjs';
|
|
5
2
|
import { parsePluginCommand } from './plugins.mjs';
|
|
6
3
|
import { PluginTrustStore, pluginTrustFile, trustVerdict, configDigest } from './plugin-trust.mjs';
|
|
7
4
|
import { PermissionRules, permissionRulesFile, parseGroup, ruleText } from './permission-rules.mjs';
|
|
8
5
|
import { bundledPlugins, bundledPlugin, pluginOperations, parseAssignments, validateBundledConfig, operationName } from './plugin-catalog.mjs';
|
|
9
6
|
import { t, registerMessages } from './i18n/index.mjs';
|
|
7
|
+
import { readRepairRecords, readActRecords, repairRecordFile, repairChain, markRepairReverted, overlaysDir, actsDir, realLocation } from './plugin-repair.mjs';
|
|
8
|
+
import { readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { inspectPluginModule } from './plugin-config.mjs';
|
|
11
|
+
import { defaultStateDir } from './compat.mjs';
|
|
12
|
+
import path from 'node:path';
|
|
10
13
|
|
|
11
14
|
registerMessages({
|
|
12
15
|
'Use /plugins {usage}.': 'Usa /plugins {usage}.',
|
|
13
16
|
'add ID [key=value…]': 'add ID [clave=valor…]',
|
|
14
17
|
'config ID [key=value…]': 'config ID [clave=valor…]',
|
|
15
18
|
'allow ID observe|all|none|OPERATION…': 'allow ID observe|all|none|OPERACIÓN…',
|
|
16
|
-
'Use /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERATION {JSON}].': 'Usa /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERACIÓN {JSON}].',
|
|
19
|
+
'Use /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERATION {JSON}|repairs|acts|revert ID].': 'Usa /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERACIÓN {JSON}|repairs|acts|revert ID].',
|
|
17
20
|
'Use /chrome-workflow [auto|raw].': 'Usa /chrome-workflow [auto|raw].',
|
|
18
21
|
'Plugin {id}: the remembered trust is no longer current ({reasons}); it stays in pending_trust. Review it with /plugins enable {id}.': 'Plugin {id}: la confianza recordada ya no es vigente ({reasons}); queda en pending_trust. Revísalo con /plugins enable {id}.',
|
|
19
22
|
'{id}: trust current · active · {grants}': '{id}: confianza vigente · activo · {grants}',
|
|
@@ -76,6 +79,46 @@ registerMessages({
|
|
|
76
79
|
'Chrome workflow: effective {effective} · saved in the project: {saved}': 'Chrome workflow: efectivo {effective} · guardado en el proyecto: {saved}',
|
|
77
80
|
'(nothing)': '(nada)',
|
|
78
81
|
'Chrome workflow {mode} saved for this project and applied to this session input.': 'Chrome workflow {mode} guardado para este proyecto y aplicado a la entrada de esta sesión.',
|
|
82
|
+
' · recorded by the house for a contract repair (/plugins repairs)': ' · registrada por la casa para una reparación por contrato (/plugins repairs)',
|
|
83
|
+
'Plugin {id}: the installed plugin changed since the repair (fingerprint {before} → {now}); the repaired copy is still the one in use. /plugins revert {id} tries the installed one.': 'Plugin {id}: el plugin instalado cambió desde la reparación (huella {before} → {now}); la copia reparada sigue siendo la que se usa. /plugins revert {id} prueba la instalada.',
|
|
84
|
+
'Plugin {id}: its contract cannot be stated ({reason}). It loaded and serves as before, but it cannot be repaired by contract until its author fixes that.': 'Plugin {id}: su contrato no se puede enunciar ({reason}). Se cargó y sirve como antes, pero no se puede reparar por contrato hasta que su autor lo corrija.',
|
|
85
|
+
'Repairs: {dir}': 'Reparaciones: {dir}',
|
|
86
|
+
'No accepted repairs in this project.': 'No hay reparaciones aceptadas en este proyecto.',
|
|
87
|
+
'Showing the {shown} most recent of {total}.': 'Se muestran las {shown} más recientes de {total}.',
|
|
88
|
+
'contract kept': 'contrato cumplido',
|
|
89
|
+
'signed act': 'acta firmada',
|
|
90
|
+
'in use': 'en uso',
|
|
91
|
+
'reverted {at}': 'revertida {at}',
|
|
92
|
+
'no longer in use': 'ya no en uso',
|
|
93
|
+
'unchanged since the repair': 'sin cambios desde la reparación',
|
|
94
|
+
'changed since the repair (fingerprint {before} → {now})': 'cambió desde la reparación (huella {before} → {now})',
|
|
95
|
+
'missing ({reason})': 'no disponible ({reason})',
|
|
96
|
+
' fingerprint {before} → {after} · {below}': ' huella {before} → {after} · {below}',
|
|
97
|
+
'live': 'viva',
|
|
98
|
+
'on top of repair {fingerprint}': 'sobre la reparación {fingerprint}',
|
|
99
|
+
'on top of the original': 'sobre el original',
|
|
100
|
+
'Plugin {id} has active consumers ({consumers}); disable them before reverting. Nothing was changed.': 'El plugin {id} tiene consumidores activos ({consumers}); desactívalos antes de revertir. No se cambió nada.',
|
|
101
|
+
'The original module of {id} ({module}) is missing or cannot be read ({reason}). Nothing was changed.': 'El módulo original de {id} ({module}) no está o no se puede leer ({reason}). No se cambió nada.',
|
|
102
|
+
'The revert of {id} failed while reloading ({reason}). {file} and the repair\'s trust were put back; the repair stays.': 'La reversión de {id} falló al recargar ({reason}). Se restauraron {file} y la confianza de la reparación; la reparación se queda.',
|
|
103
|
+
'The revert of {id} failed while reloading ({reason}). {file} was edited meanwhile and was left as it is; check it with /plugins.': 'La reversión de {id} falló al recargar ({reason}). {file} se editó mientras tanto y se dejó como está; revísalo con /plugins.',
|
|
104
|
+
' cases run: {cases}': ' casos ejecutados: {cases}',
|
|
105
|
+
' patch: {patch}': ' parche: {patch}',
|
|
106
|
+
' installed original: {state} · {module}': ' original instalado: {state} · {module}',
|
|
107
|
+
'Signed acts: {dir}': 'Actas firmadas: {dir}',
|
|
108
|
+
'No signed acts in this project.': 'No hay actas firmadas en este proyecto.',
|
|
109
|
+
'{plugin} · {at} · reason: {reason}': '{plugin} · {at} · motivo: {reason}',
|
|
110
|
+
'(none given)': '(sin motivo)',
|
|
111
|
+
' surface changed: {items}': ' superficie cambiada: {items}',
|
|
112
|
+
' code the house cannot follow: {items}': ' código que la casa no puede seguir: {items}',
|
|
113
|
+
' cases added: {added} · removed: {removed} · changed: {changed}': ' casos agregados: {added} · quitados: {removed} · cambiados: {changed}',
|
|
114
|
+
' used by the repair accepted at {at} (fingerprint {fingerprint})': ' usada por la reparación aceptada el {at} (huella {fingerprint})',
|
|
115
|
+
' no repair stands on it': ' ninguna reparación se apoya en ella',
|
|
116
|
+
'A repair of {id} is in progress in this session; let it be accepted or discarded first.': 'Hay una reparación de {id} en curso en esta sesión; espera a que se acepte o se descarte.',
|
|
117
|
+
'{id} has no repair to revert (no repaired_from in {file}).': '{id} no tiene una reparación que revertir (no hay repaired_from en {file}).',
|
|
118
|
+
'Reverted {id}: {file} points at the original {module} again; the trust the house recorded for the repair was removed.': 'Revertido {id}: {file} vuelve a apuntar al original {module}; se retiró la confianza que la casa registró para la reparación.',
|
|
119
|
+
'Reverted {id}: {file} points at the original {module} again.': 'Revertido {id}: {file} vuelve a apuntar al original {module}.',
|
|
120
|
+
'The repaired copy no longer serves: {id} waits for your trust in the installed code (/plugins enable {id}).': 'La copia reparada ya no sirve: {id} espera tu confianza en el código instalado (/plugins enable {id}).',
|
|
121
|
+
'{id} is not active in this session; /plugins enable {id} loads the installed code.': '{id} no está activo en esta sesión; /plugins enable {id} carga el código instalado.',
|
|
79
122
|
});
|
|
80
123
|
|
|
81
124
|
// Controles humanos de plugins desde la sesión: catálogo, alta/baja, confianza
|
|
@@ -97,9 +140,12 @@ export function parsePluginControl(text = '') {
|
|
|
97
140
|
if (action === 'config') return { name: 'PluginConfigure', input: { id: needId('config ID [key=value…]'), set: settings() } };
|
|
98
141
|
if (action === 'trust' && !rest.length) return { name: 'PluginTrustList', input: {} };
|
|
99
142
|
if (action === 'untrust') { needId('untrust ID'); if (rest.length > 1) throw new Error(t('Use /plugins {usage}.', { usage: 'untrust ID' })); return { name: 'PluginUntrust', input: { id } }; }
|
|
143
|
+
if (action === 'repairs' && !rest.length) return { name: 'PluginRepairs', input: {} };
|
|
144
|
+
if (action === 'acts' && !rest.length) return { name: 'PluginActs', input: {} };
|
|
145
|
+
if (action === 'revert') { needId('revert ID'); if (rest.length > 1) throw new Error(t('Use /plugins {usage}.', { usage: 'revert ID' })); return { name: 'PluginRevert', input: { id } }; }
|
|
100
146
|
if (action === 'allow') { needId('allow ID observe|all|none|OPERATION…'); if (rest.length < 2) throw new Error(t('Use /plugins {usage}.', { usage: t('allow ID observe|all|none|OPERATION…') })); return { name: 'PluginAllow', input: { id, operations: rest.slice(1) } }; }
|
|
101
147
|
try { return parsePluginCommand(text); }
|
|
102
|
-
catch { throw new Error(t('Use /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERATION {JSON}].')); }
|
|
148
|
+
catch { throw new Error(t('Use /plugins [list|available|add ID [k=v…]|remove ID|config ID [k=v…]|enable ID|disable ID|reload [ID]|trust|untrust ID|allow ID observe|all|none|OP…|run ID OPERATION {JSON}|repairs|acts|revert ID].')); }
|
|
103
149
|
}
|
|
104
150
|
|
|
105
151
|
export function parseChromeWorkflow(text = '') {
|
|
@@ -108,33 +154,21 @@ export function parseChromeWorkflow(text = '') {
|
|
|
108
154
|
return { name: 'ChromeWorkflow', input: mode ? { mode } : {} };
|
|
109
155
|
}
|
|
110
156
|
|
|
111
|
-
async function readRaw(file) {
|
|
112
|
-
const source = await readPluginConfig(file, { missing: true }); // valida y acota
|
|
113
|
-
if (!source.hash) return { plugins: [] };
|
|
114
|
-
return JSON.parse(await readFile(file, 'utf8'));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Escribe en un temporal del mismo directorio, lo valida con el lector real y lo publica con rename.
|
|
118
|
-
async function writeRaw(file, value) {
|
|
119
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
120
|
-
const temporary = path.join(path.dirname(file), `.plugins-${randomUUID()}.json`);
|
|
121
|
-
try {
|
|
122
|
-
const handle = await open(temporary, 'wx', 0o644);
|
|
123
|
-
try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); }
|
|
124
|
-
const checked = await readPluginConfig(temporary);
|
|
125
|
-
await rename(temporary, file);
|
|
126
|
-
return checked;
|
|
127
|
-
} catch (error) { await rm(temporary, { force: true }); throw error; }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
157
|
export class PluginControls {
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
/**
|
|
159
|
+
* stateDir: where the house keeps overlays and the records of repairs and acts (--state-dir, or the
|
|
160
|
+
* project's default). journal(row): appends a row to the session journal (plugin_repair_reverted).
|
|
161
|
+
* repair (set by the host): the PluginRepair of this process, asked whether a repair is in progress.
|
|
162
|
+
*/
|
|
163
|
+
constructor({ runtime, policy, cwd, trustStore, rules, confirm, trustByFlag = false, onWorkflow = async () => {}, currentWorkflow = () => null, notice = () => {}, stateDir, journal = async () => null }) {
|
|
164
|
+
Object.assign(this, { runtime, policy, cwd, confirm, trustByFlag, onWorkflow, currentWorkflow, notice, journal });
|
|
165
|
+
// By its real path, resolved once: overlays are recorded by real path, and a symlinked .comal or --state-dir must compare equal.
|
|
166
|
+
this.stateDir = realLocation(path.resolve(cwd ?? process.cwd(), stateDir ?? defaultStateDir(cwd ?? process.cwd())));
|
|
133
167
|
this.store = trustStore || new PluginTrustStore({ file: pluginTrustFile(), workspace: cwd });
|
|
134
168
|
this.rules = rules || new PermissionRules({ file: permissionRulesFile(), workspace: cwd });
|
|
135
169
|
// Grupos declarados por plugins incluidos; plugin:ID:all lo resuelve el almacén.
|
|
136
170
|
this.rules.groups = (id, group) => { const split = pluginOperations(id); return group === 'observe' ? split?.observations : group === 'actions' ? split?.actions : undefined; };
|
|
137
|
-
this.rules.gate = name => this.codeTrusted(name);
|
|
171
|
+
this.rules.gate = (name, context) => this.codeTrusted(name, context);
|
|
138
172
|
policy.rules = this.rules;
|
|
139
173
|
}
|
|
140
174
|
get project() { return this.runtime.file; }
|
|
@@ -152,19 +186,56 @@ export class PluginControls {
|
|
|
152
186
|
for (const warning of [this.store.warning, this.rules.warning]) if (warning) this.notice(warning);
|
|
153
187
|
const status = await this.runtime.initialize({ trust, trusted: trust ? undefined : this.trusted, signal });
|
|
154
188
|
for (const plugin of status.plugins) if (plugin.trust?.status === 'stale') this.notice(t('Plugin {id}: the remembered trust is no longer current ({reasons}); it stays in pending_trust. Review it with /plugins enable {id}.', { id: plugin.id, reasons: plugin.trust.reasons.join(', ') }));
|
|
189
|
+
// Loading stays compatible: a plugin whose contract cannot be stated still serves. Said once per start.
|
|
190
|
+
this.#contractNotices();
|
|
191
|
+
await this.#repairNotices().catch(() => {});
|
|
155
192
|
return status;
|
|
156
193
|
}
|
|
194
|
+
// One notice per repaired plugin per start when the human's original (repaired_from) is no longer the code
|
|
195
|
+
// the FIRST repair of the chain started from (its `fingerprint_before`): an update was installed.
|
|
196
|
+
async #repairNotices() {
|
|
197
|
+
const raw = await readRaw(this.project), told = new Set();
|
|
198
|
+
for (const entry of raw.plugins) {
|
|
199
|
+
if (typeof entry?.repaired_from !== 'string' || typeof entry.module !== 'string' || told.has(entry.id)) continue;
|
|
200
|
+
const first = await this.#firstRepair(entry.id, entry.module, entry.repaired_from);
|
|
201
|
+
if (!first) continue;
|
|
202
|
+
const original = await this.#original(entry.repaired_from, first.fingerprint_before);
|
|
203
|
+
if (original.changed) { told.add(entry.id); this.notice(t('Plugin {id}: the installed plugin changed since the repair (fingerprint {before} → {now}); the repaired copy is still the one in use. /plugins revert {id} tries the installed one.', { id: entry.id, before: first.fingerprint_before.slice(0, 12), now: original.fingerprint.slice(0, 12) })); }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The first repair under a live overlay, when the chain is whole and it started from this original.
|
|
207
|
+
async #firstRepair(id, module, original) {
|
|
208
|
+
const { records, complete } = await repairChain(this.stateDir, id, this.#resolve(module));
|
|
209
|
+
const first = records.at(-1), before = first?.before_module ?? first?.repaired_from;
|
|
210
|
+
return complete && typeof first.fingerprint_before === 'string' && typeof before === 'string' && path.resolve(before) === this.#resolve(original) ? first : null;
|
|
211
|
+
}
|
|
212
|
+
#resolve(module) { return realLocation(path.resolve(path.dirname(this.project), module)); }
|
|
213
|
+
// The current state of the code a repair started from, against the fingerprint recorded then.
|
|
214
|
+
async #original(module, before) {
|
|
215
|
+
try { const code = await inspectPluginModule(this.#resolve(module)); return { fingerprint: code.code_fingerprint, changed: code.code_fingerprint !== before, text: code.code_fingerprint === before ? t('unchanged since the repair') : t('changed since the repair (fingerprint {before} → {now})', { before: before.slice(0, 12), now: code.code_fingerprint.slice(0, 12) }) }; }
|
|
216
|
+
catch (error) { return { fingerprint: null, changed: false, missing: true, text: t('missing ({reason})', { reason: error.code || error.message }) }; }
|
|
217
|
+
}
|
|
157
218
|
/**
|
|
158
219
|
* Veto de reglas allow para operaciones de plugins locales: sólo valen mientras la
|
|
159
220
|
* generación viva tenga el mismo módulo, huella y config que el registro de confianza.
|
|
160
221
|
* Se evalúa en cada decisión, así que una recarga con otro código nunca hereda permisos.
|
|
161
222
|
*/
|
|
162
|
-
codeTrusted(name) {
|
|
223
|
+
codeTrusted(name, context) {
|
|
163
224
|
const match = /^plugin__([a-z][a-z0-9-]*)__/.exec(name); if (!match) return true;
|
|
164
225
|
const plugin = this.#live(match[1]);
|
|
165
226
|
if (!plugin || plugin.builtin) return Boolean(plugin);
|
|
166
227
|
const saved = this.store.find(this.project, plugin.id), entry = this.runtime.desired?.entries.find(item => item.id === plugin.id);
|
|
167
|
-
|
|
228
|
+
if (plugin.state === 'active' && plugin.code_fingerprint && saved && entry && saved.module === plugin.module && saved.code_fingerprint === plugin.code_fingerprint && saved.config_sha256 === configDigest(entry.config)) return true;
|
|
229
|
+
// A contract repair under verification (plugin-repair.mjs, Accept step 6): the human's saved rules name
|
|
230
|
+
// this plugin's operations, and the house has already established that the published contract equals
|
|
231
|
+
// the live one (or the human signed the act for it), so they are the same promises. The rules that
|
|
232
|
+
// applied to the previous generation keep applying, only to the house's own case runs (the invocation
|
|
233
|
+
// nested directly under that Accept), only for the overlay's exact code and the same configuration,
|
|
234
|
+
// and only while the trust record is still the human's for the previous code. Nothing is written.
|
|
235
|
+
// deny and ask are decided before this gate and are never affected.
|
|
236
|
+
const standing = this.repairStanding?.(plugin.id, context?.invocation);
|
|
237
|
+
return Boolean(standing && plugin.state === 'active' && entry && standing.module === plugin.module && standing.code_fingerprint === plugin.code_fingerprint
|
|
238
|
+
&& standing.config_sha256 === configDigest(entry.config) && saved && saved.module === standing.previous.module && saved.code_fingerprint === standing.previous.code_fingerprint && saved.config_sha256 === standing.config_sha256);
|
|
168
239
|
}
|
|
169
240
|
// Reglas allow de este proyecto que apuntan al plugin (nombres exactos o grupos plugin:ID:*).
|
|
170
241
|
#pluginAllow(id) { return rule => rule.list === 'allow' && rule.scope === this.rules.scope && (rule.rule.startsWith(`plugin__${id}__`) || parseGroup(rule.rule)?.plugin === id); }
|
|
@@ -213,7 +284,17 @@ export class PluginControls {
|
|
|
213
284
|
const rules = own.map(ruleText).join('; ');
|
|
214
285
|
return this.codeTrusted(`plugin__${id}__x`) ? t('permission rules: {rules}', { rules }) : t('permission rules: {rules} · allow rules do not apply until you trust the current code', { rules });
|
|
215
286
|
}
|
|
216
|
-
|
|
287
|
+
// One notice per plugin and reason while this process lives: at startup and after an enable.
|
|
288
|
+
#toldInvalid = new Set();
|
|
289
|
+
#contractNotices() {
|
|
290
|
+
for (const plugin of this.runtime.status().plugins) {
|
|
291
|
+
if (!plugin.contract_invalid || this.#toldInvalid.has(`${plugin.id}\n${plugin.contract_invalid}`)) continue;
|
|
292
|
+
this.#toldInvalid.add(`${plugin.id}\n${plugin.contract_invalid}`);
|
|
293
|
+
this.notice(t('Plugin {id}: its contract cannot be stated ({reason}). It loaded and serves as before, but it cannot be repaired by contract until its author fixes that.', { id: plugin.id, reason: plugin.contract_invalid }));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async enable(input, options) { try { return await this.#enable(input, options); } finally { try { this.#contractNotices(); } catch {} } }
|
|
297
|
+
async #enable({ id }, options) {
|
|
217
298
|
await this.runtime.discover();
|
|
218
299
|
const live = this.#live(id);
|
|
219
300
|
if (!live) throw new Error(t('Unknown plugin: {id}', { id }));
|
|
@@ -298,7 +379,7 @@ export class PluginControls {
|
|
|
298
379
|
const records = this.store.list();
|
|
299
380
|
const lines = [t('Trust: {file}', { file: this.store.file }) + (this.store.warning ? ` · ${this.store.warning}` : ''), t('Rules: {file}', { file: this.rules.file }) + (this.rules.warning ? ` · ${this.rules.warning}` : '')];
|
|
300
381
|
if (!records.length) lines.push(t('No trusted plugins.'));
|
|
301
|
-
for (const item of records) lines.push(t('{mark} {id} · {module} · fingerprint {fingerprint} ({count} files) · {at}\n project: {project}', { mark: item.project === this.project ? '●' : '○', id: item.id, module: item.module, fingerprint: item.code_fingerprint.slice(0, 12), count: item.files.length, at: item.trusted_at, project: item.project }) + (item.project === this.project ? `\n ${this.#grantSummary(item.id)}` : ''));
|
|
382
|
+
for (const item of records) lines.push(t('{mark} {id} · {module} · fingerprint {fingerprint} ({count} files) · {at}\n project: {project}', { mark: item.project === this.project ? '●' : '○', id: item.id, module: item.module, fingerprint: item.code_fingerprint.slice(0, 12), count: item.files.length, at: item.trusted_at, project: item.project }) + (item.reason === 'contract_repair' ? t(' · recorded by the house for a contract repair (/plugins repairs)') : '') + (item.project === this.project ? `\n ${this.#grantSummary(item.id)}` : ''));
|
|
302
383
|
return lines.join('\n');
|
|
303
384
|
}
|
|
304
385
|
async untrust({ id }) {
|
|
@@ -328,6 +409,128 @@ export class PluginControls {
|
|
|
328
409
|
return [t('Allow rules of {id} ({scope}): {rules} → {covered}.', { id, scope: this.rules.scope, rules: [...rules].join(', ') || t('no rules'), covered: covered.map(short).join(', ') || t('nothing') }), t('Still asking: {names}.', { names: asking.join(', ') || t('no operations') }),
|
|
329
410
|
...(denied.length ? [t('Explicitly denied (they win): {names}.', { names: denied.join(', ') })] : []), ...(covered.length && !allowed.length && !denied.length ? [t('Not applying yet: the live code does not match the recorded trust (/plugins enable).')] : [])].join('\n');
|
|
330
411
|
}
|
|
412
|
+
/** This project's accepted repairs, from the records beside their patches (newest first). */
|
|
413
|
+
async repairs() {
|
|
414
|
+
const { items, total } = await readRepairRecords(this.stateDir), lines = [t('Repairs: {dir}', { dir: overlaysDir(this.stateDir) })];
|
|
415
|
+
if (!items.length) { lines.push(t('No accepted repairs in this project.')); return lines.join('\n'); }
|
|
416
|
+
if (total > items.length) lines.push(t('Showing the {shown} most recent of {total}.', { shown: items.length, total }));
|
|
417
|
+
let raw = { plugins: [] }; try { raw = await readRaw(this.project); } catch {}
|
|
418
|
+
for (const item of items) {
|
|
419
|
+
const entry = raw.plugins.find(plugin => plugin?.id === item.plugin), record = path.join(item.dir, 'repair.json');
|
|
420
|
+
const live = typeof entry?.module === 'string' && repairRecordFile(this.stateDir, item.plugin, this.#resolve(entry.module)) === record;
|
|
421
|
+
const state = item.reverted_at ? t('reverted {at}', { at: item.reverted_at }) : live ? t('live') : t('no longer in use');
|
|
422
|
+
// The chain: what this repair was accepted on top of (an earlier repair, or the original).
|
|
423
|
+
const before = item.before_module ?? item.repaired_from, under = typeof before === 'string' ? repairRecordFile(this.stateDir, item.plugin, before) : null;
|
|
424
|
+
const below = under ? t('on top of repair {fingerprint}', { fingerprint: path.basename(path.dirname(under)).slice(0, 12) }) : t('on top of the original');
|
|
425
|
+
// The installed original against what the first repair of this chain recorded for it.
|
|
426
|
+
const { records, complete } = await repairChain(this.stateDir, item.plugin, path.join(item.dir, 'tree', 'x'));
|
|
427
|
+
const first = complete ? records.at(-1) : null, originalModule = item.original_module ?? (first ? first.before_module ?? first.repaired_from : undefined);
|
|
428
|
+
const original = typeof originalModule === 'string' && typeof first?.fingerprint_before === 'string' ? await this.#original(originalModule, first.fingerprint_before) : { text: '—' };
|
|
429
|
+
lines.push(`${item.plugin} · ${item.accepted_at} · ${item.authority === 'signed_act' ? t('signed act') : t('contract kept')} · ${state}`,
|
|
430
|
+
t(' fingerprint {before} → {after} · {below}', { before: String(item.fingerprint_before ?? '?').slice(0, 12), after: String(item.fingerprint_after ?? '?').slice(0, 12), below }),
|
|
431
|
+
t(' cases run: {cases}', { cases: Array.isArray(item.cases) && item.cases.length ? item.cases.join('; ') : t('(none)') }),
|
|
432
|
+
t(' patch: {patch}', { patch: item.patch }),
|
|
433
|
+
t(' installed original: {state} · {module}', { state: original.text, module: originalModule ?? '?' }));
|
|
434
|
+
}
|
|
435
|
+
return lines.join('\n');
|
|
436
|
+
}
|
|
437
|
+
/** The acts the human signed in this project's sessions (newest first), and the repair that stands on each. */
|
|
438
|
+
async acts() {
|
|
439
|
+
const { items, total } = await readActRecords(this.stateDir), lines = [t('Signed acts: {dir}', { dir: actsDir(this.stateDir) })];
|
|
440
|
+
if (!items.length) { lines.push(t('No signed acts in this project.')); return lines.join('\n'); }
|
|
441
|
+
if (total > items.length) lines.push(t('Showing the {shown} most recent of {total}.', { shown: items.length, total }));
|
|
442
|
+
const { items: repairs } = await readRepairRecords(this.stateDir, { limit: Infinity });
|
|
443
|
+
const listed = value => Array.isArray(value) && value.length ? value.join('; ') : t('(none)');
|
|
444
|
+
for (const item of items) {
|
|
445
|
+
const used = item.act && repairs.find(repair => repair.act?.sha256 === item.act.sha256 && repair.act?.sequence === item.act.sequence && repair.plugin === item.plugin);
|
|
446
|
+
lines.push(t('{plugin} · {at} · reason: {reason}', { plugin: item.plugin, at: item.signed_at, reason: item.reason ? `«${String(item.reason).slice(0, 300)}»` : t('(none given)') }),
|
|
447
|
+
t(' surface changed: {items}', { items: listed(item.surface_changed) }),
|
|
448
|
+
t(' cases added: {added} · removed: {removed} · changed: {changed}', { added: listed(item.cases_added), removed: listed(item.cases_removed), changed: listed(item.cases_changed) }),
|
|
449
|
+
...(Array.isArray(item.unfollowed) && item.unfollowed.length ? [t(' code the house cannot follow: {items}', { items: item.unfollowed.map(entry => `${entry?.file}: ${entry?.construct}`).join('; ').slice(0, 600) })] : []),
|
|
450
|
+
used ? t(' used by the repair accepted at {at} (fingerprint {fingerprint})', { at: used.accepted_at, fingerprint: String(used.fingerprint_after ?? '?').slice(0, 12) }) : t(' no repair stands on it'));
|
|
451
|
+
}
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
454
|
+
#serving(id, code) { const live = this.#live(id); return Boolean(code?.code_fingerprint) && live?.state === 'active' && live.module === code.module && live.code_fingerprint === code.code_fingerprint; }
|
|
455
|
+
/**
|
|
456
|
+
* Puts the human's original module back in plugins.json (from repaired_from, which every repair of a repair
|
|
457
|
+
* keeps, so one revert undoes the whole chain), with the same atomic writer /plugins add uses; drops the trust
|
|
458
|
+
* the house recorded for the repair and reloads. The original's trust is asked the usual way when it has none.
|
|
459
|
+
* Everything that can refuse is checked before anything is written. If the reload fails after the write,
|
|
460
|
+
* plugins.json (only if it still holds what revert wrote) and the repair's trust are put back. The overlays
|
|
461
|
+
* stay as history; their records say reverted.
|
|
462
|
+
*/
|
|
463
|
+
async revert({ id }, options = {}) {
|
|
464
|
+
if (this.repair?.busy?.(id)) throw new Error(t('A repair of {id} is in progress in this session; let it be accepted or discarded first.', { id }));
|
|
465
|
+
const previousBytes = await readFile(this.project).catch(() => null);
|
|
466
|
+
const raw = await readRaw(this.project), entry = raw.plugins.find(item => item?.id === id);
|
|
467
|
+
if (!entry) throw new Error(t('{id} is not in {file}.', { id, file: this.project }));
|
|
468
|
+
if (typeof entry.repaired_from !== 'string') throw new Error(t('{id} has no repair to revert (no repaired_from in {file}).', { id, file: this.project }));
|
|
469
|
+
const live = this.#live(id); if (live?.builtin) throw new Error(t('Host-reserved plugin: {id}', { id }));
|
|
470
|
+
const wasActive = live?.state === 'active';
|
|
471
|
+
// Another active plugin consuming a service this one provides would block the reload: refuse now.
|
|
472
|
+
const consumers = wasActive ? this.runtime.status().plugins.filter(item => item.id !== id && item.state === 'active' && item.requires?.some(name => live.provides?.includes(name))).map(item => item.id) : [];
|
|
473
|
+
if (consumers.length) throw new Error(t('Plugin {id} has active consumers ({consumers}); disable them before reverting. Nothing was changed.', { id, consumers: consumers.join(', ') }));
|
|
474
|
+
const originalPath = this.#resolve(entry.repaired_from);
|
|
475
|
+
let to;
|
|
476
|
+
try { const code = await inspectPluginModule(originalPath); to = { module: code.module, code_fingerprint: code.code_fingerprint }; }
|
|
477
|
+
catch (error) { throw new Error(t('The original module of {id} ({module}) is missing or cannot be read ({reason}). Nothing was changed.', { id, module: originalPath, reason: error.code || error.message })); }
|
|
478
|
+
const overlayModule = this.#resolve(entry.module);
|
|
479
|
+
let from = { module: overlayModule, code_fingerprint: null };
|
|
480
|
+
try { const code = await inspectPluginModule(overlayModule); from = { module: code.module, code_fingerprint: code.code_fingerprint }; } catch {}
|
|
481
|
+
// Whether the repaired copy is what serves now. By its module path alone: when the overlay was deleted by
|
|
482
|
+
// hand its fingerprint cannot be read any more, and what serves is in-memory code nobody can review.
|
|
483
|
+
const servingRepaired = () => { const now = this.#live(id); return now?.state === 'active' && [from.module, overlayModule].includes(now.module); };
|
|
484
|
+
const { repaired_from: original, ...next } = { ...entry, module: entry.repaired_from };
|
|
485
|
+
raw.plugins = raw.plugins.map(item => item?.id === id ? next : item);
|
|
486
|
+
const written = (await writeRaw(this.project, raw))?.hash;
|
|
487
|
+
// Only the record the house made for this overlay; a record the human made since is theirs.
|
|
488
|
+
const saved = this.store.find(this.project, id);
|
|
489
|
+
const trustRemoved = Boolean(saved?.reason === 'contract_repair' && (saved.module === from.module || saved.code_fingerprint === from.code_fingerprint)) && await this.store.revoke(this.project, id);
|
|
490
|
+
const lines = [trustRemoved ? t('Reverted {id}: {file} points at the original {module} again; the trust the house recorded for the repair was removed.', { id, file: this.project, module: original })
|
|
491
|
+
: t('Reverted {id}: {file} points at the original {module} again.', { id, file: this.project, module: original })];
|
|
492
|
+
try {
|
|
493
|
+
const { entry: current, code } = await this.runtime.inspect(id);
|
|
494
|
+
if (next.enabled === false) { if (servingRepaired()) await this.runtime.disable(id); }
|
|
495
|
+
else if (!wasActive) lines.push(t('{id} is not active in this session; /plugins enable {id} loads the installed code.', { id }));
|
|
496
|
+
else if (trustVerdict(this.store.find(this.project, id), { project: this.project, entry: current, code }).status !== 'trusted' && !this.confirm && !this.trustByFlag) {
|
|
497
|
+
// No human to ask and no --trust-plugins: the installed code serves this session only, as /plugins enable does.
|
|
498
|
+
await this.runtime.load(id, { signal: options.signal });
|
|
499
|
+
lines.push(t('{id}: active only in this session (no remembered trust). In the TUI, /plugins enable {id} asks for confirmation and remembers it; in -p, add --trust-plugins.', { id }));
|
|
500
|
+
} else {
|
|
501
|
+
lines.push(await this.#trustAndEnable(id, options));
|
|
502
|
+
// Declined: the repaired copy must not keep serving under a configuration that no longer names it.
|
|
503
|
+
if (servingRepaired()) { await this.runtime.disable(id); lines.push(t('The repaired copy no longer serves: {id} waits for your trust in the installed code (/plugins enable {id}).', { id })); }
|
|
504
|
+
}
|
|
505
|
+
} catch (error) {
|
|
506
|
+
throw await this.#revertFailed({ id, from, to, error, previousBytes, written, saved, trustRemoved, wasActive });
|
|
507
|
+
}
|
|
508
|
+
// Every repair of the chain under the overlay that served is undone by this revert.
|
|
509
|
+
const { records } = await repairChain(this.stateDir, id, from.module);
|
|
510
|
+
for (const record of records) if (!record.reverted_at) await markRepairReverted(record.file).catch(() => {});
|
|
511
|
+
await this.journal({ type: 'plugin_repair_reverted', source: 'human_session', plugin: id, from, to, trust_removed: trustRemoved, undone: records.map(record => record.fingerprint_after), state: this.#live(id)?.state ?? null });
|
|
512
|
+
return lines.join('\n');
|
|
513
|
+
}
|
|
514
|
+
// The reload after the write failed: plugins.json goes back (only if it is still what revert wrote), the
|
|
515
|
+
// repair's trust record goes back, and the repaired copy serves again if it stopped. Journaled; returns the error.
|
|
516
|
+
async #revertFailed({ id, from, to, error, previousBytes, written, saved, trustRemoved, wasActive }) {
|
|
517
|
+
const reason = String(error?.message ?? error).slice(0, 2000), restored = { plugins_json: false, trust: !trustRemoved, generation: 'kept' };
|
|
518
|
+
let current = null; try { current = await readFile(this.project); } catch {}
|
|
519
|
+
if (previousBytes && current && written && createHash('sha256').update(current).digest('hex') === written) {
|
|
520
|
+
const temporary = path.join(path.dirname(this.project), `.plugins-${randomUUID()}.json`);
|
|
521
|
+
try { await writeFile(temporary, previousBytes, { mode: 0o644, flag: 'wx' }); await rename(temporary, this.project); restored.plugins_json = true; }
|
|
522
|
+
catch (failure) { await rm(temporary, { force: true }).catch(() => {}); restored.plugins_json_error = failure.message; }
|
|
523
|
+
} else restored.plugins_json = 'left_as_edited';
|
|
524
|
+
if (trustRemoved) { try { await this.store.restore(this.project, id, saved); restored.trust = true; } catch (failure) { restored.trust_error = failure.message; } }
|
|
525
|
+
try { await this.runtime.discover(); } catch {}
|
|
526
|
+
if (wasActive && restored.plugins_json === true && !this.#serving(id, from)) {
|
|
527
|
+
try { await this.runtime.load(id, { expect: { module: from.module, code_fingerprint: from.code_fingerprint } }); restored.generation = 'reloaded'; } catch (failure) { restored.generation = 'failed'; restored.generation_error = failure.message; }
|
|
528
|
+
}
|
|
529
|
+
await this.journal({ type: 'plugin_repair_revert_failed', source: 'human_session', plugin: id, from, to, reason, restored });
|
|
530
|
+
const message = restored.plugins_json === true ? t('The revert of {id} failed while reloading ({reason}). {file} and the repair\'s trust were put back; the repair stays.', { id, reason, file: this.project })
|
|
531
|
+
: t('The revert of {id} failed while reloading ({reason}). {file} was edited meanwhile and was left as it is; check it with /plugins.', { id, reason, file: this.project });
|
|
532
|
+
return Object.assign(new Error(message), { code: error?.code, cause: error });
|
|
533
|
+
}
|
|
331
534
|
async workflow({ mode }) {
|
|
332
535
|
const raw = await readRaw(this.project);
|
|
333
536
|
if (!mode) return t('Chrome workflow: effective {effective} · saved in the project: {saved}', { effective: this.currentWorkflow() || 'auto', saved: raw.chrome_workflow || t('(nothing)') });
|
|
@@ -348,6 +551,9 @@ export class PluginControls {
|
|
|
348
551
|
{ name: 'PluginConfigure', description: 'Human shows or changes a configured plugin configuration.', input_schema: object({ id: idInput, set: setInput }, ['id']), run: run('configure') },
|
|
349
552
|
{ name: 'PluginTrustList', description: 'Human lists remembered plugin trust records.', input_schema: object(), run: run('trustList') },
|
|
350
553
|
{ name: 'PluginUntrust', description: 'Human revokes remembered trust and saved permissions of a plugin in this project.', input_schema: object({ id: idInput }, ['id']), run: run('untrust') },
|
|
554
|
+
{ name: 'PluginRepairs', description: 'Human lists the contract repairs accepted in this project: plugin, when, authority, cases run, patch and whether the installed original changed since.', input_schema: object(), run: run('repairs') },
|
|
555
|
+
{ name: 'PluginActs', description: 'Human lists the acts signed in this project to change a plugin contract: plugin, when, what changed and the reason.', input_schema: object(), run: run('acts') },
|
|
556
|
+
{ name: 'PluginRevert', description: 'Human reverts the last contract repair of a plugin: plugins.json names the replaced module again, the repair trust is removed and the plugin reloads (asking for trust of the original when needed).', input_schema: object({ id: idInput }, ['id']), run: run('revert') },
|
|
351
557
|
{ name: 'PluginAllow', description: 'Human saves which plugin operations are authorized without asking. Explicit denials still win.', input_schema: object({ id: idInput, operations: { type: 'array', items: { type: 'string', maxLength: 128 }, minItems: 1, maxItems: 64 } }, ['id', 'operations']), run: run('allow') },
|
|
352
558
|
{ name: 'ChromeWorkflow', description: 'Human shows or saves the Chrome entry workflow (auto|raw) for this project.', input_schema: object({ mode: { enum: ['auto', 'raw'] } }), run: run('workflow') },
|
|
353
559
|
];
|