@milpa/comal 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/backend/contracts/remote-commands-v1.json +12 -0
- package/governance/chrome-task-v10.json +323 -0
- package/governance/chrome-task-v11.json +355 -0
- package/governance/chrome-task-v12.json +387 -0
- package/governance/chrome-task-v13.json +419 -0
- package/governance/chrome-task-v3.json +99 -0
- package/governance/chrome-task-v4.json +131 -0
- package/governance/chrome-task-v5.json +163 -0
- package/governance/chrome-task-v6.json +195 -0
- package/governance/chrome-task-v7.json +227 -0
- package/governance/chrome-task-v8.json +259 -0
- package/governance/chrome-task-v9.json +291 -0
- package/governance/source-task-v1.json +1 -0
- package/governance/source-task-v2.json +1 -0
- package/package.json +14 -1
- package/src/agent-recovery-proof.mjs +12 -1
- package/src/agent.mjs +94 -30
- package/src/audit.mjs +21 -6
- package/src/auto-guard.mjs +48 -1
- package/src/chrome-candidate-recovery-proof.mjs +47 -0
- package/src/chrome-recovery-proof.mjs +6 -2
- package/src/chrome-source-syntax.mjs +93 -0
- package/src/cli.mjs +158 -54
- package/src/codex-provider.mjs +3 -2
- package/src/connection-settings.mjs +204 -0
- package/src/context-pressure.mjs +2 -1
- package/src/durable-agent-runs.mjs +5 -11
- package/src/event-channels.mjs +8 -0
- package/src/file-recovery-proof.mjs +11 -3
- package/src/file-recovery.mjs +24 -0
- package/src/gemini-provider.mjs +6 -1
- package/src/git-agent-revision.mjs +12 -0
- package/src/git-agent-workspaces.mjs +143 -0
- package/src/git-integration-lineage.mjs +20 -0
- package/src/git-integration-operations.mjs +200 -0
- package/src/git-integration-recovery-proof.mjs +70 -0
- package/src/git-page-cli-bridge.mjs +61 -0
- package/src/git-page-evidence.mjs +30 -0
- package/src/git-page-identity.mjs +10 -0
- package/src/git-page-proof.mjs +96 -0
- package/src/git-page-settlement.mjs +21 -0
- package/src/git-page-verification.mjs +134 -0
- package/src/git-plumbing.mjs +212 -0
- package/src/git-process-worker.mjs +50 -0
- package/src/git-source.mjs +110 -0
- package/src/git-workspaces.mjs +519 -0
- package/src/goals.mjs +13 -7
- package/src/house.mjs +246 -20
- package/src/i18n/cli-es.mjs +4 -0
- package/src/i18n/es.mjs +35 -0
- package/src/judges.mjs +27 -11
- package/src/mcp-runtime.mjs +15 -3
- package/src/model-controls.mjs +11 -1
- package/src/model-deadline.mjs +4 -3
- package/src/native-refusal.mjs +38 -0
- package/src/onboarding.mjs +88 -24
- package/src/openai-provider.mjs +14 -7
- package/src/operations.mjs +9 -2
- package/src/output-recovery.mjs +48 -0
- package/src/peer-tools.mjs +18 -8
- package/src/permission-controls.mjs +232 -0
- package/src/permission-rules.mjs +312 -0
- package/src/permissions.mjs +113 -30
- package/src/plugin-catalog.mjs +84 -0
- package/src/plugin-config.mjs +48 -6
- package/src/plugin-controls.mjs +332 -0
- package/src/plugin-notices.mjs +88 -0
- package/src/plugin-trust.mjs +104 -0
- package/src/plugins/chrome-lab/bridge.mjs +38 -12
- package/src/plugins/chrome-lab/client.mjs +103 -25
- package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
- package/src/plugins/chrome-lab/index.mjs +116 -26
- package/src/plugins/chrome-lab/project.mjs +30 -2
- package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
- package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
- package/src/plugins/open-relay/actions.mjs +205 -0
- package/src/plugins/open-relay/bound.mjs +47 -0
- package/src/plugins/open-relay/client.mjs +158 -0
- package/src/plugins/open-relay/dossier.mjs +119 -0
- package/src/plugins/open-relay/index.mjs +153 -0
- package/src/plugins/open-relay/lookup.mjs +45 -0
- package/src/plugins/open-relay/notices.mjs +79 -0
- package/src/plugins/open-relay/watch.mjs +127 -0
- package/src/plugins.mjs +72 -29
- package/src/private-fs.mjs +40 -0
- package/src/process-lock.mjs +24 -13
- package/src/provider-auth.mjs +14 -3
- package/src/provider.mjs +11 -2
- package/src/read-only-check.mjs +7 -3
- package/src/scope-ceiling.mjs +56 -0
- package/src/scoped-policy.mjs +39 -0
- package/src/secret-login.mjs +68 -0
- package/src/session-bus.mjs +41 -7
- package/src/session-projections-candidate.mjs +119 -0
- package/src/session-projections.mjs +119 -0
- package/src/session-read-cache.mjs +23 -3
- package/src/sessions.mjs +35 -11
- package/src/source-editor-role.mjs +13 -0
- package/src/source-ownership.mjs +73 -0
- package/src/source-proposal-proof.mjs +55 -0
- package/src/source-proposals.mjs +118 -0
- package/src/source-requests.mjs +148 -0
- package/src/source-task-proof.mjs +24 -0
- package/src/source-task-revision-proof.mjs +44 -0
- package/src/subagents.mjs +187 -87
- package/src/tool-packs.mjs +5 -5
- package/src/tools.mjs +82 -15
- package/src/trusted-context.mjs +5 -1
- package/src/tui/connection-transition.mjs +1 -1
- package/src/tui/controller.mjs +8 -4
- package/src/tui/discovery.mjs +24 -12
- package/src/tui/inbound-blocks.mjs +55 -0
- package/src/tui/present.mjs +504 -0
- package/src/tui/screen.mjs +49 -13
- package/src/windows-private.ps1 +61 -0
- package/src/work-assertions.mjs +6 -2
- package/src/work-plan.mjs +34 -8
- package/src/work-state.mjs +15 -9
- package/src/workflows/chrome-page-cases.mjs +156 -0
- package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
- package/src/workflows/chrome-page-diagnostics.mjs +113 -0
- package/src/workflows/chrome-page-law.mjs +48 -0
- package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
- package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
- package/src/workflows/chrome-page-task.mjs +429 -0
- package/src/workflows/chrome-page-verifier.mjs +123 -0
- package/src/workflows/chrome-source.mjs +73 -0
- package/src/workflows/chrome-supplied-inputs.mjs +43 -0
- package/src/workflows/chrome-task-adoption.mjs +122 -0
- package/src/workflows/chrome-task.mjs +379 -74
- package/src/workflows/source-task-v1.mjs +124 -0
- package/src/workflows/source-task-v2.mjs +227 -0
- package/src/workflows/source-task.mjs +13 -0
package/src/plugins.mjs
CHANGED
|
@@ -2,7 +2,16 @@ import path from 'node:path';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { Operation, immutable, toolEffects } from './operations.mjs';
|
|
4
4
|
import { defaultStateDir } from './compat.mjs';
|
|
5
|
-
import { readPluginConfig, importPluginEntry } from './plugin-config.mjs';
|
|
5
|
+
import { readPluginConfig, importPluginEntry, inspectPluginModule } from './plugin-config.mjs';
|
|
6
|
+
import { normalizeNotice } from './plugin-notices.mjs';
|
|
7
|
+
import { t, registerMessages } from './i18n/index.mjs';
|
|
8
|
+
|
|
9
|
+
registerMessages({
|
|
10
|
+
'Plugin arguments must be a JSON object.': 'Los argumentos de plugin deben ser un objeto JSON.',
|
|
11
|
+
'Use /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].': 'Usa /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERACIÓN {JSON}].',
|
|
12
|
+
});
|
|
13
|
+
// Los errores del runtime pueden quedar en el estado que lee el modelo (PluginStatus):
|
|
14
|
+
// inglés fijo. Sólo los errores de uso de /plugins pasan por t().
|
|
6
15
|
|
|
7
16
|
const idPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
8
17
|
const jsonCopy = value => JSON.parse(JSON.stringify(value));
|
|
@@ -14,8 +23,8 @@ export function parsePluginCommand(text = '') {
|
|
|
14
23
|
if (action === 'reload' && !rest) return { name: 'PluginReload', input: {} };
|
|
15
24
|
if (['enable', 'disable', 'reload'].includes(action) && idPattern.test(rest)) return { name: { enable: 'PluginEnable', disable: 'PluginDisable', reload: 'PluginReload' }[action], input: { id: rest } };
|
|
16
25
|
const run = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(rest);
|
|
17
|
-
if (action === 'run' && run) { const input = JSON.parse(run[3] || '{}'); if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('
|
|
18
|
-
throw new Error('
|
|
26
|
+
if (action === 'run' && run) { const input = JSON.parse(run[3] || '{}'); if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error(t('Plugin arguments must be a JSON object.')); return { name: 'PluginRun', input: { id: run[1], operation: run[2], arguments: input } }; }
|
|
27
|
+
throw new Error(t('Use /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].'));
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
export class PluginRuntime {
|
|
@@ -24,11 +33,13 @@ export class PluginRuntime {
|
|
|
24
33
|
#services = new Map();
|
|
25
34
|
#queue = Promise.resolve();
|
|
26
35
|
#closing = false;
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
// notify(notice): el host encola avisos de plugins (plugin-notices.mjs); sin él, ctx.notify responde unavailable.
|
|
37
|
+
constructor({ cwd, house, services = {}, configPath, onEvent = () => {}, notify }) {
|
|
38
|
+
Object.assign(this, { cwd, house, onEvent, notify });
|
|
29
39
|
this.file = path.resolve(configPath || path.join(defaultStateDir(cwd), 'plugins.json'));
|
|
30
40
|
for (const [name, value] of Object.entries(services)) this.#services.set(name, { owner: 'host', value });
|
|
31
41
|
}
|
|
42
|
+
async #notify(notice) { if (typeof this.notify !== 'function') return { status: 'unavailable' }; try { return await this.notify(notice); } catch (error) { return { status: 'failed', error: String(error.message).slice(0, 200) }; } }
|
|
32
43
|
#exclusive(task) { const next = this.#queue.catch(() => {}).then(task); this.#queue = next; return next; }
|
|
33
44
|
async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} }
|
|
34
45
|
#definition(definition) {
|
|
@@ -41,7 +52,7 @@ export class PluginRuntime {
|
|
|
41
52
|
return immutable({ id: definition.id, version: definition.version, requires: names(definition.requires || []), provides: names(definition.provides || []), tool_loading: definition.tool_loading || 'eager', hook_timeout_ms: definition.hook_timeout_ms ?? 1000, discovery: discovery ? structuredClone(discovery) : null, config_schema: definition.config_schema ? structuredClone(definition.config_schema) : null, activate: definition.activate });
|
|
42
53
|
}
|
|
43
54
|
register(definition, { config = {}, builtin = false, module, fingerprint } = {}) {
|
|
44
|
-
if (this.#closing) throw new Error('
|
|
55
|
+
if (this.#closing) throw new Error('Plugin runtime closed.');
|
|
45
56
|
const manifest = this.#definition(definition);
|
|
46
57
|
if (this.#known.has(manifest.id)) throw new Error(`Plugin duplicado: ${manifest.id}`);
|
|
47
58
|
this.#known.set(manifest.id, { id: manifest.id, definition: manifest, config: immutable(jsonCopy(config)), builtin, module, fingerprint, state: 'registered', disabled: false });
|
|
@@ -49,7 +60,7 @@ export class PluginRuntime {
|
|
|
49
60
|
status() {
|
|
50
61
|
return { config_path: this.file, services: [...this.#services.keys()], plugins: [...this.#known.values()].map(item => {
|
|
51
62
|
const scope = this.#instances.get(item.id);
|
|
52
|
-
return { id: item.id, version: item.definition?.version, builtin: item.builtin || false, state: item.state, module: item.module || item.entry?.module, fingerprint: item.fingerprint, tool_loading: item.definition?.tool_loading || 'eager', operations: scope?.active ? [...scope.operations.keys()] : [], requires: item.definition?.requires || [], provides: item.definition?.provides || [], active_calls: scope?.leases || 0, ...(item.error ? { error: item.error } : {}) };
|
|
63
|
+
return { id: item.id, version: item.definition?.version, builtin: item.builtin || false, state: item.state, module: item.module || item.entry?.module, fingerprint: item.fingerprint, ...(item.code_fingerprint ? { code_fingerprint: item.code_fingerprint } : {}), ...(item.trust ? { trust: item.trust } : {}), tool_loading: item.definition?.tool_loading || 'eager', operations: scope?.active ? [...scope.operations.keys()] : [], requires: item.definition?.requires || [], provides: item.definition?.provides || [], active_calls: scope?.leases || 0, ...(item.error ? { error: item.error } : {}) };
|
|
53
64
|
}) };
|
|
54
65
|
}
|
|
55
66
|
async discover({ missing = true } = {}) {
|
|
@@ -63,49 +74,66 @@ export class PluginRuntime {
|
|
|
63
74
|
}
|
|
64
75
|
this.desired = source; return source;
|
|
65
76
|
}
|
|
66
|
-
|
|
77
|
+
// trusted(entry, inspection) is the host's remembered human trust. It reads code, never imports it.
|
|
78
|
+
async initialize({ trust = false, trusted, signal } = {}) {
|
|
67
79
|
await this.discover();
|
|
68
80
|
if (trust) await this.load(undefined, { signal });
|
|
81
|
+
else if (trusted) for (const entry of this.desired.entries.filter(entry => entry.enabled)) {
|
|
82
|
+
const item = this.#known.get(entry.id); if (!item || item.builtin || this.#instances.get(entry.id)?.active) continue;
|
|
83
|
+
let verdict;
|
|
84
|
+
try { verdict = await trusted(entry, await inspectPluginModule(entry.module)); }
|
|
85
|
+
catch (error) { verdict = { status: 'unavailable', reasons: [error.message] }; }
|
|
86
|
+
item.trust = verdict;
|
|
87
|
+
if (verdict?.status !== 'trusted') { if (verdict?.status === 'stale') await this.#event({ id: entry.id, state: 'pending_trust', trust: verdict.status, error: `Trust no longer current (${verdict.reasons.join(', ')}). Use /plugins enable ${entry.id} to review it.` }); continue; }
|
|
88
|
+
try { await this.load(entry.id, { signal, expect: verdict.expect }); }
|
|
89
|
+
catch (error) { if (error.code === 'PLUGIN_TRUST_STALE') { item.state = 'pending_trust'; item.error = null; item.trust = { status: 'stale', reasons: ['code'] }; await this.#event({ id: entry.id, state: 'pending_trust', trust: 'stale', error: error.message }); } else if (signal?.aborted) throw error; }
|
|
90
|
+
}
|
|
69
91
|
return this.status();
|
|
70
92
|
}
|
|
71
|
-
|
|
93
|
+
/** Inspect configured local code for a human trust decision. Never imports it. */
|
|
94
|
+
async inspect(id) {
|
|
95
|
+
await this.discover(); const entry = this.desired.entries.find(entry => entry.id === id);
|
|
96
|
+
if (!entry) throw new Error(`Local plugin not configured: ${id}`);
|
|
97
|
+
return { entry, code: await inspectPluginModule(entry.module) };
|
|
98
|
+
}
|
|
99
|
+
async #import(item, signal, expect) {
|
|
72
100
|
signal?.throwIfAborted();
|
|
73
101
|
if (!item.entry) return item;
|
|
74
|
-
const imported = await importPluginEntry({ ...item.entry, enabled: true }); signal?.throwIfAborted();
|
|
75
|
-
return { ...item, definition: this.#definition(imported.definition), config: immutable(jsonCopy(item.entry.config)), module: imported.module, fingerprint: imported.fingerprint };
|
|
102
|
+
const imported = await importPluginEntry({ ...item.entry, enabled: true }, { expect }); signal?.throwIfAborted();
|
|
103
|
+
return { ...item, definition: this.#definition(imported.definition), config: immutable(jsonCopy(item.entry.config)), module: imported.module, fingerprint: imported.fingerprint, code_fingerprint: imported.code_fingerprint, trust: expect ? { status: 'trusted' } : item.trust };
|
|
76
104
|
}
|
|
77
|
-
async load(id, { signal } = {}) {
|
|
105
|
+
async load(id, { signal, expect } = {}) {
|
|
78
106
|
return this.#exclusive(async () => {
|
|
79
107
|
this.#open(); const source = await this.discover();
|
|
80
108
|
const wanted = new Set(this.desired.entries.filter(entry => entry.enabled).map(entry => entry.id));
|
|
81
109
|
const entries = id ? [this.desired.entries.some(entry => entry.id === id) ? this.#known.get(id) : undefined] : this.desired.entries.filter(entry => entry.enabled).map(entry => this.#known.get(entry.id));
|
|
82
|
-
if (entries.some(item => !item || item.builtin)) throw new Error('
|
|
110
|
+
if (entries.some(item => !item || item.builtin)) throw new Error('Local plugin not configured or reserved.');
|
|
83
111
|
const retiring = id ? [] : [...this.#instances.values()].filter(scope => scope.active && scope.item.entry && !wanted.has(scope.id));
|
|
84
112
|
const changing = new Set([...entries.map(item => item.id), ...retiring.map(scope => scope.id)]);
|
|
85
|
-
for (const scope of this.#instances.values()) if (scope.active && !changing.has(scope.id) && [...scope.requiredOwners].some(owner => changing.has(owner))) throw new Error(`Plugin ${scope.id}
|
|
113
|
+
for (const scope of this.#instances.values()) if (scope.active && !changing.has(scope.id) && [...scope.requiredOwners].some(owner => changing.has(owner))) throw new Error(`Plugin ${scope.id} depends on a provider that will change. Disable it first.`);
|
|
86
114
|
const candidates = [];
|
|
87
|
-
for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#import(item, signal)); }
|
|
88
|
-
await this.#activate(candidates, signal, retiring, async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('
|
|
115
|
+
for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#import(item, signal, id ? expect : undefined)); }
|
|
116
|
+
await this.#activate(candidates, signal, retiring, async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('The plugin configuration changed during activation; nothing was published.'); }); return this.status();
|
|
89
117
|
});
|
|
90
118
|
}
|
|
91
|
-
#open() { if (this.#closing) throw new Error('
|
|
119
|
+
#open() { if (this.#closing) throw new Error('Plugin runtime closed.'); }
|
|
92
120
|
#dependents(id) {
|
|
93
121
|
const dependents = [...this.#instances.values()].filter(scope => scope.active && scope.id !== id && scope.requiredOwners.has(id));
|
|
94
|
-
if (dependents.length) throw new Error(`Plugin ${id}
|
|
122
|
+
if (dependents.length) throw new Error(`Plugin ${id} has active consumers: ${dependents.map(scope => scope.id).join(', ')}. Disable them first.`);
|
|
95
123
|
}
|
|
96
|
-
enable(id, { signal } = {}) {
|
|
124
|
+
enable(id, { signal, expect } = {}) {
|
|
97
125
|
return this.#exclusive(async () => {
|
|
98
|
-
this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`
|
|
126
|
+
this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
|
|
99
127
|
if (this.#instances.get(id)?.active) return this.status();
|
|
100
128
|
const source = item.entry ? await this.discover() : null;
|
|
101
|
-
if (source && !source.entries.some(entry => entry.id === id)) throw new Error(`Plugin
|
|
102
|
-
const candidate = item.entry ? await this.#import(item, signal) : { ...item, disabled: false };
|
|
103
|
-
await this.#activate([candidate], signal, [], source ? async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('
|
|
129
|
+
if (source && !source.entries.some(entry => entry.id === id)) throw new Error(`Plugin no longer configured: ${id}`);
|
|
130
|
+
const candidate = item.entry ? await this.#import(item, signal, expect) : { ...item, disabled: false };
|
|
131
|
+
await this.#activate([candidate], signal, [], source ? async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('The plugin configuration changed during activation; nothing was published.'); } : undefined); return this.status();
|
|
104
132
|
});
|
|
105
133
|
}
|
|
106
134
|
reload(id, { signal } = {}) {
|
|
107
135
|
return this.#exclusive(async () => {
|
|
108
|
-
this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`
|
|
136
|
+
this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
|
|
109
137
|
this.#dependents(id); await this.#activate([await this.#import(item, signal)], signal); return this.status();
|
|
110
138
|
});
|
|
111
139
|
}
|
|
@@ -138,7 +166,7 @@ export class PluginRuntime {
|
|
|
138
166
|
candidates.forEach(visit); return ordered;
|
|
139
167
|
}
|
|
140
168
|
async #stage(item, available, signal) {
|
|
141
|
-
const scope = { id: item.id, item, generation: randomUUID(), state: 'loading', active: false, retired: false, leases: 0, waiters: [], operations: new Map(), services: new Map(), listeners: [], guards: [], removers: [], disposers: [], controller: new AbortController(), requiredOwners: new Set() };
|
|
169
|
+
const scope = { id: item.id, item, generation: randomUUID(), state: 'loading', active: false, retired: false, leases: 0, waiters: [], operations: new Map(), services: new Map(), listeners: [], guards: [], removers: [], disposers: [], notices: [], controller: new AbortController(), requiredOwners: new Set() };
|
|
142
170
|
const abortActivation = () => scope.controller.abort(signal.reason);
|
|
143
171
|
if (signal?.aborted) abortActivation(); else signal?.addEventListener('abort', abortActivation, { once: true });
|
|
144
172
|
const alive = () => { if (scope.retired || !['loading', 'active'].includes(scope.state)) throw new Error('Scope de plugin retirado.'); };
|
|
@@ -146,15 +174,22 @@ export class PluginRuntime {
|
|
|
146
174
|
const lease = async callback => { if (!scope.active || scope.retired) throw new Error('El plugin fue retirado; no se ejecutó.'); scope.leases++; try { return await callback(); } finally { if (!--scope.leases) scope.waiters.splice(0).forEach(resolve => resolve()); } };
|
|
147
175
|
const register = (definition, human = false) => {
|
|
148
176
|
staging();
|
|
149
|
-
for (const key of Object.keys(definition)) if (!['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', ...(item.builtin ? ['permission'] : [])].includes(key)) throw new Error(`Contrato reservado al host: ${key}`);
|
|
177
|
+
for (const key of Object.keys(definition)) if (!['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', 'confirm', 'confirm_summary', ...(item.builtin ? ['permission'] : [])].includes(key)) throw new Error(`Contrato reservado al host: ${key}`);
|
|
178
|
+
// confirm:'always' sólo puede subir el techo (pedir siempre al humano), nunca bajarlo.
|
|
179
|
+
if (Object.hasOwn(definition, 'confirm') && definition.confirm !== 'always') throw new Error("confirm de plugin sólo admite 'always'.");
|
|
180
|
+
if (Object.hasOwn(definition, 'confirm_summary') && typeof definition.confirm_summary !== 'function') throw new Error('confirm_summary de plugin debe ser una función.');
|
|
150
181
|
if (Object.hasOwn(definition, 'permission') && (typeof definition.permission !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(definition.permission))) throw new Error('Un builtin requiere un permiso nominal válido; no puede omitir el juicio.');
|
|
151
182
|
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(definition.name) || typeof definition.run !== 'function') throw new Error('Nombre/handler de operación de plugin inválido.');
|
|
152
183
|
const name = item.builtin ? definition.name : `plugin__${item.id}__${definition.name}`;
|
|
153
184
|
if (name.length > 64 || scope.operations.has(name)) throw new Error(`Operación duplicada o nombre demasiado largo: ${name}`);
|
|
154
|
-
const handler = definition.run;
|
|
185
|
+
const handler = definition.run, summarize = definition.confirm_summary;
|
|
155
186
|
scope.operations.set(name, new Operation({ name, description: definition.description, input_schema: definition.input_schema, output_schema: definition.output_schema, revision: scope.generation, origin: { plugin: item.id, version: item.definition.version, generation: scope.generation, builtin: item.builtin || false, module_sha256: item.fingerprint || null, ...(!human && item.definition.tool_loading === 'deferred' ? { tool_loading: 'deferred', discovery: item.definition.discovery } : {}) }, effects: definition.effects || (item.builtin ? toolEffects(name) : toolEffects('__unknown_plugin')), audience: human ? 'human' : 'any', surfaces: human ? ['cli', 'tui'] : ['cli', 'tui', 'agent'], permission: item.builtin ? definition.permission ?? name : name, steps: false,
|
|
187
|
+
...(definition.confirm ? { confirm: definition.confirm } : {}),
|
|
188
|
+
// El resumen corre con el mismo lease y la misma señal que la operación: si el plugin se retira, no se muestra.
|
|
189
|
+
...(summarize ? { confirm_summary: (input, options = {}) => lease(() => summarize(immutable(jsonCopy(input)), Object.freeze({ signal: options.signal ? AbortSignal.any([options.signal, scope.controller.signal]) : scope.controller.signal }))) } : {}),
|
|
156
190
|
run: (input, options) => lease(() => handler(immutable(jsonCopy(input)), Object.freeze({ signal: options.signal ? AbortSignal.any([options.signal, scope.controller.signal]) : scope.controller.signal,
|
|
157
|
-
...(item.builtin ? { invocation_id: options.invocation_id, sessionId: options.sessionId, actor: options.actor ? immutable(jsonCopy(options.actor)) : null } : {})
|
|
191
|
+
...(item.builtin ? { invocation_id: options.invocation_id, sessionId: options.sessionId, actor: options.actor ? immutable(jsonCopy(options.actor)) : null } : {}),
|
|
192
|
+
...(item.builtin&&item.id==='delegation'&&['Agent','AgentBatch','AgentContinue','AgentResume'].includes(name)&&typeof options.revalidate==='function'?{revalidate:async request=>{alive();scope.controller.signal.throwIfAborted();return options.revalidate(request);}}:{}) }))) }));
|
|
158
193
|
return name;
|
|
159
194
|
};
|
|
160
195
|
const context = Object.freeze({
|
|
@@ -168,6 +203,12 @@ export class PluginRuntime {
|
|
|
168
203
|
const watch = { event, listener }; scope.listeners.push(watch); if (scope.active) this.#watch(scope, watch, lease);
|
|
169
204
|
},
|
|
170
205
|
guard: check => { staging(); if (typeof check !== 'function') throw new Error('Guarda requiere función.'); scope.guards.push(check); },
|
|
206
|
+
// Aviso a la conversación: información externa, nunca intención humana ni permiso.
|
|
207
|
+
notify: async input => {
|
|
208
|
+
alive(); const notice = normalizeNotice(input, { plugin: item.id, generation: scope.generation });
|
|
209
|
+
if (!scope.active) { if (scope.notices.length >= 16) return { status: 'full' }; return new Promise(resolve => scope.notices.push({ notice, resolve })); }
|
|
210
|
+
return this.#notify(notice);
|
|
211
|
+
},
|
|
171
212
|
});
|
|
172
213
|
scope.lease = lease;
|
|
173
214
|
try {
|
|
@@ -220,6 +261,7 @@ export class PluginRuntime {
|
|
|
220
261
|
this.#known.set(scope.id, scope.item); this.#instances.set(scope.id, scope);
|
|
221
262
|
for (const watch of scope.listeners) this.#watch(scope, watch, scope.lease);
|
|
222
263
|
for (const check of scope.guards) scope.removers.push(this.house.guard(`${scope.id}:${scope.generation}`, view => scope.active && scope.operations.has(view.operation) ? check(view) : undefined));
|
|
264
|
+
for (const { notice, resolve } of scope.notices.splice(0)) this.#notify(notice).then(resolve);
|
|
223
265
|
}
|
|
224
266
|
committed = true;
|
|
225
267
|
const cleanupTasks = previous.map(old => this.#retire(old));
|
|
@@ -237,6 +279,7 @@ export class PluginRuntime {
|
|
|
237
279
|
}
|
|
238
280
|
async #retire(scope) {
|
|
239
281
|
if (scope.retirement) return scope.retirement; scope.retired = true; scope.active = false; scope.state = 'unloading'; scope.controller.abort(new Error('Plugin retirado.'));
|
|
282
|
+
for (const { resolve } of scope.notices.splice(0)) resolve({ status: 'retired' });
|
|
240
283
|
scope.removers.splice(0).forEach(remove => remove());
|
|
241
284
|
scope.retirement = this.#cleanup(scope); return scope.retirement;
|
|
242
285
|
}
|
|
@@ -249,7 +292,7 @@ export class PluginRuntime {
|
|
|
249
292
|
}
|
|
250
293
|
disable(id) {
|
|
251
294
|
return this.#exclusive(async () => {
|
|
252
|
-
this.#open(); this.#dependents(id); const item = this.#known.get(id); if (!item) throw new Error(`
|
|
295
|
+
this.#open(); this.#dependents(id); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
|
|
253
296
|
const scope = this.#instances.get(id); item.disabled = true; item.state = 'disabled';
|
|
254
297
|
if (scope?.active) {
|
|
255
298
|
this.house.registry.replace([...scope.operations.keys()], []);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {mkdir,lstat,readFile,realpath} from 'node:fs/promises';
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {createInterface} from 'node:readline';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const fail=message=>Object.assign(new Error(message),{code:'PRIVATE_STATE_UNSAFE'});
|
|
7
|
+
let broker,starting,sequence=0;
|
|
8
|
+
async function windowsBroker(){
|
|
9
|
+
if(broker)return broker;if(starting)return starting;
|
|
10
|
+
starting=(async()=>{
|
|
11
|
+
const script=await readFile(new URL('./windows-private.ps1',import.meta.url),'utf8'),program=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
|
|
12
|
+
const child=spawn(program,['-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{stdio:['pipe','pipe','pipe'],windowsHide:true});
|
|
13
|
+
const pending=new Map(),lines=createInterface({input:child.stdout});let diagnostic='';
|
|
14
|
+
const references=active=>{const method=active?'ref':'unref';child[method]();for(const stream of[child.stdin,child.stdout,child.stderr])stream[method]?.();};
|
|
15
|
+
child.stderr.on('data',data=>{if(diagnostic.length<2000)diagnostic+=data;});child.stdin.on('error',()=>{});
|
|
16
|
+
const stopped=()=>{for(const job of pending.values()){clearTimeout(job.timer);job.reject(fail('Windows private-state inspection stopped. '+diagnostic.slice(0,300)));}pending.clear();if(broker?.child===child)broker=undefined;};
|
|
17
|
+
child.on('error',stopped);child.on('close',stopped);
|
|
18
|
+
lines.on('line',line=>{let result;try{result=JSON.parse(line);}catch{stopped();child.kill();return;}const job=pending.get(result.id);if(!job)return;clearTimeout(job.timer);pending.delete(result.id);if(!pending.size)references(false);result.ok?job.resolve():job.reject(fail(result.error||'Windows private-state ACL does not verify.'));});
|
|
19
|
+
const result={child,request:(action,file)=>new Promise((resolve,reject)=>{
|
|
20
|
+
const id=++sequence;references(true);const timer=setTimeout(()=>{pending.delete(id);reject(fail('Windows ACL inspection exceeded its deadline.'));if(!pending.size)references(false);},15000);pending.set(id,{resolve,reject,timer});
|
|
21
|
+
child.stdin.write(JSON.stringify({id,action,path:path.resolve(file)})+'\n',error=>{if(error){clearTimeout(timer);pending.delete(id);reject(fail('Windows ACL inspection could not be sent.'));if(!pending.size)references(false);}});
|
|
22
|
+
})};references(false);broker=result;return result;
|
|
23
|
+
})();try{return await starting;}finally{starting=undefined;}
|
|
24
|
+
}
|
|
25
|
+
export async function ensurePrivateDirectory(directory){
|
|
26
|
+
if(process.platform==='win32')await(await windowsBroker()).request('ensure',directory);
|
|
27
|
+
else await mkdir(directory,{recursive:true,mode:0o700});
|
|
28
|
+
}
|
|
29
|
+
export async function assertPrivateDirectory(directory,info){
|
|
30
|
+
info ||= await lstat(directory);
|
|
31
|
+
if(!info.isDirectory()||info.isSymbolicLink()||await realpath(directory)!==path.resolve(directory))throw fail('Private state is not a canonical directory.');
|
|
32
|
+
if(process.platform==='win32')await(await windowsBroker()).request('directory',directory);
|
|
33
|
+
else if(info.mode&0o077||process.getuid&&info.uid!==process.getuid())throw fail('Private directory permissions or owner do not verify.');
|
|
34
|
+
}
|
|
35
|
+
export async function assertPrivateFile(file,info){
|
|
36
|
+
info ||= await lstat(file);
|
|
37
|
+
if(!info.isFile()||info.isSymbolicLink())throw fail('Private state is not a regular file.');
|
|
38
|
+
if(process.platform==='win32')await(await windowsBroker()).request('file',file);
|
|
39
|
+
else if(info.mode&0o077||process.getuid&&info.uid!==process.getuid())throw fail('Private file permissions or owner do not verify.');
|
|
40
|
+
}
|
package/src/process-lock.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { open, lstat, unlink } from 'node:fs/promises';
|
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { assertPrivateFile } from './private-fs.mjs';
|
|
6
7
|
|
|
7
8
|
const optional = file => { try { return readFileSync(file,'utf8').trim(); } catch { return null; } };
|
|
8
9
|
const start = pid => optional(`/proc/${pid}/stat`)?.split(') ').slice(1).join(') ').split(' ')[19] || null;
|
|
@@ -18,23 +19,33 @@ export function processDead(owner) {
|
|
|
18
19
|
return Boolean(owner.start && current && owner.start!==current);
|
|
19
20
|
}
|
|
20
21
|
const fail = (message,code='SESSION_IN_USE') => Object.assign(new Error(message),{code});
|
|
21
|
-
async function inspect(name) {
|
|
22
|
+
async function inspect(name,fault=async()=>{}) {
|
|
23
|
+
const named=await lstat(name,{bigint:true});if(named.isSymbolicLink()||!named.isFile())throw fail('Unsafe lock type; no lock was removed.','LOCK_UNSAFE');
|
|
24
|
+
await fault('named',{path:name});
|
|
22
25
|
const file=await open(name,constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
23
26
|
try {
|
|
24
|
-
const info=await file.stat();
|
|
25
|
-
if(
|
|
26
|
-
|
|
27
|
+
const info=await file.stat({bigint:true});await fault('opened',{path:name});
|
|
28
|
+
if(info.ino!==named.ino||info.dev!==named.dev||!await same(name,info))throw fail('Lock changed during observation; no owner was assumed.','LOCK_CHANGED');
|
|
29
|
+
if(!info.isFile() || info.size>4096n)throw fail('Unsafe lock object or size; no lock was removed.','LOCK_UNSAFE');
|
|
30
|
+
try{await assertPrivateFile(name,await file.stat());}catch{
|
|
31
|
+
if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
|
|
32
|
+
throw fail('Lock privacy does not verify; no lock was removed.','LOCK_UNSAFE');
|
|
33
|
+
}
|
|
34
|
+
if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
|
|
35
|
+
const bytes=Buffer.alloc(4097),{bytesRead}=await file.read(bytes,0,bytes.length,0);if(bytesRead>4096)throw fail('Lock exceeds its owner-record bound.','LOCK_UNSAFE');
|
|
36
|
+
const text=bytes.subarray(0,bytesRead).toString('utf8').trim();let owner;
|
|
27
37
|
try{owner=JSON.parse(text);}catch{throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');}
|
|
28
38
|
if(typeof owner==='number')owner={pid:owner}; // Legacy PID: only ESRCH proves death.
|
|
29
39
|
if(!owner || !Number.isSafeInteger(owner.pid) || owner.pid<=0)throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');
|
|
40
|
+
if(!await same(name,info))throw fail('Lock changed while reading its owner; no owner was assumed.','LOCK_CHANGED');
|
|
30
41
|
return {info,owner};
|
|
31
42
|
} finally{await file.close();}
|
|
32
43
|
}
|
|
33
|
-
async function same(name,info){try{const current=await lstat(name);return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
|
|
44
|
+
async function same(name,info){try{const current=await lstat(name,{bigint:true});return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
|
|
34
45
|
|
|
35
46
|
// Only this exclusion gate may reclaim a dead owner. It is never automatically
|
|
36
47
|
// reclaimed itself: two reapers must not unlink a replacement lock concurrently.
|
|
37
|
-
export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{}}={}) {
|
|
48
|
+
export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{},resource,fault=async()=>{}}={}) {
|
|
38
49
|
const until=Date.now()+waitMs;
|
|
39
50
|
for(;;){
|
|
40
51
|
let file, recovered;
|
|
@@ -42,29 +53,29 @@ export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{}}={}
|
|
|
42
53
|
catch(error){
|
|
43
54
|
if(error.code!=='EEXIST')throw error;
|
|
44
55
|
let old;
|
|
45
|
-
try{old=await inspect(name);}catch(e){if(e.code==='ENOENT')continue;if(e.code
|
|
56
|
+
try{old=await inspect(name,fault);}catch(e){if(e.code==='ENOENT')continue;if(['LOCK_UNKNOWN','LOCK_CHANGED'].includes(e.code)&&Date.now()<until){await delay(10);continue;}if(e.code==='LOCK_CHANGED')throw fail('The lock changed during observation; no owner was assumed or removed.');throw e;}
|
|
46
57
|
if(processDead(old.owner)){
|
|
47
58
|
let gate;
|
|
48
59
|
try{gate=await open(`${name}.reclaim`,'wx',0o600);}
|
|
49
60
|
catch(e){if(e.code!=='EEXIST')throw e;if(Date.now()>=until)throw fail('Recuperación de lock en uso o interrumpida; no se retiró.','LOCK_RECOVERY_BUSY');}
|
|
50
61
|
if(gate)try{
|
|
51
62
|
await gate.writeFile(JSON.stringify(processIdentity));await gate.sync();
|
|
52
|
-
const current=await inspect(name).catch(e=>{if(e.code
|
|
63
|
+
const current=await inspect(name,fault).catch(e=>{if(['ENOENT','LOCK_CHANGED'].includes(e.code))return null;throw e;});
|
|
53
64
|
if(current && processDead(current.owner) && await same(name,current.info)){
|
|
54
|
-
await validate();
|
|
65
|
+
await validate(current.owner);
|
|
55
66
|
if(await same(name,current.info)){
|
|
56
67
|
await unlink(name);
|
|
57
68
|
try{file=await open(name,'wx',0o600);recovered={path:name,owner:current.owner};}
|
|
58
69
|
catch(e){if(e.code!=='EEXIST')throw e;}
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
|
-
} finally{const info=await gate.stat();if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
|
|
72
|
+
} finally{const info=await gate.stat({bigint:true});if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
|
|
62
73
|
}
|
|
63
74
|
if(!file){if(Date.now()>=until)throw fail('Sesión en uso o propietario no recuperable; no se retiró el lock.');await delay(10);continue;}
|
|
64
75
|
}
|
|
65
|
-
const owner={...processIdentity,token:randomUUID()};
|
|
76
|
+
const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
|
|
66
77
|
try{await file.writeFile(JSON.stringify(owner));await file.sync();}
|
|
67
|
-
catch(error){const info=await file.stat();if(await same(name,info))await unlink(name);await file.close();throw error;}
|
|
68
|
-
return {file,recovered,release:async()=>{const info=await file.stat();try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
|
|
78
|
+
catch(error){const info=await file.stat({bigint:true});if(await same(name,info))await unlink(name);await file.close();throw error;}
|
|
79
|
+
return {file,recovered,release:async()=>{const info=await file.stat({bigint:true});try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
|
|
69
80
|
}
|
|
70
81
|
}
|
package/src/provider-auth.mjs
CHANGED
|
@@ -10,15 +10,26 @@ export const AUTH_PROVIDERS = Object.freeze([
|
|
|
10
10
|
{ id: 'codex', label: 'Codex / ChatGPT', method: 'device_code', subscription: true, protocol: 'openai', baseURL: 'https://chatgpt.com/backend-api/codex', documentation: 'https://learn.chatgpt.com/docs/auth' },
|
|
11
11
|
{ id: 'kimi', label: 'Kimi Code', method: 'device_code', experimental: true, thirdPartyMethod: 'subscription_key', subscription: true, protocol: 'openai', baseURL: 'https://api.kimi.com/coding/v1', apiKeyEnv: 'KIMI_CODE_API_KEY', documentation: 'https://www.kimi.com/en/help/kimi-code/membership-guide' },
|
|
12
12
|
{ id: 'minimax', label: 'MiniMax M Plan', method: 'subscription_key', subscription: true, protocol: 'anthropic', baseURL: 'https://api.minimax.io/anthropic', model: 'MiniMax-M3', apiKeyEnv: 'MINIMAX_SUBSCRIPTION_KEY', documentation: 'https://platform.minimax.io/docs/m-plan/quickstart' },
|
|
13
|
-
{ id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' }
|
|
13
|
+
{ id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' },
|
|
14
|
+
// Ollama Cloud expone una API compatible con OpenAI en /v1 y /api/show para
|
|
15
|
+
// descubrir la ventana. La llave sólo viaja en el header hacia ese origen.
|
|
16
|
+
{ id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' }
|
|
14
17
|
].map(Object.freeze));
|
|
18
|
+
const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama' });
|
|
15
19
|
const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Error(message), { code });
|
|
16
20
|
function definition(name) {
|
|
17
21
|
const id = String(name || '').toLowerCase();
|
|
18
|
-
const found = AUTH_PROVIDERS.find(item => item.id === (
|
|
19
|
-
if (!found) throw failure('Unknown login provider: codex, kimi, minimax or
|
|
22
|
+
const found = AUTH_PROVIDERS.find(item => item.id === (Object.hasOwn(ALIASES, id) ? ALIASES[id] : id));
|
|
23
|
+
if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok or ollama.', 'PROVIDER_AUTH_UNKNOWN');
|
|
20
24
|
return found;
|
|
21
25
|
}
|
|
26
|
+
export function authProviderDefinition(name) { return definition(name); }
|
|
27
|
+
// Proveedores cuya credencial es una llave que el humano puede escribir en el
|
|
28
|
+
// campo oculto de la TUI. Codex sólo admite su login de dispositivo.
|
|
29
|
+
export function acceptsSecretLogin(name) {
|
|
30
|
+
const p = definition(name);
|
|
31
|
+
return p.id !== 'codex' && (p.method !== 'device_code' || Boolean(p.thirdPartyMethod));
|
|
32
|
+
}
|
|
22
33
|
export function authProviderDefaults(name) {
|
|
23
34
|
const p = definition(name);
|
|
24
35
|
return { authProvider: p.id, provider: p.protocol, protocol: p.protocol, baseURL: p.baseURL, ...(p.model ? { model: p.model } : {}) };
|
package/src/provider.mjs
CHANGED
|
@@ -53,9 +53,18 @@ export async function analyzeImages(provider, { prompt, media, signal, maxTokens
|
|
|
53
53
|
return { answer: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), usage: result.usage, model: provider.model };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Sólo conteos y el nombre de la herramienta que estaba escribiéndose al cortarse.
|
|
57
|
+
function truncationDetails(content = []) {
|
|
58
|
+
const blocks = content.filter(block => block && typeof block === 'object'), last = blocks.at(-1);
|
|
59
|
+
const sum = pick => blocks.reduce((total, block) => total + pick(block), 0);
|
|
60
|
+
return { text_chars: sum(block => block.type === 'text' ? (block.text || '').length : 0), reasoning_chars: sum(block => block.type === 'thinking' ? (block.thinking || '').length : 0),
|
|
61
|
+
tool_argument_chars: sum(block => block.type !== 'tool_use' ? 0 : block.truncated_input_chars ?? JSON.stringify(block.input ?? {}).length),
|
|
62
|
+
...(last?.type === 'tool_use' && typeof last.name === 'string' && last.name ? { tool_name: last.name } : {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
function validateMessage(message) {
|
|
57
66
|
if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
|
|
58
|
-
if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage)});
|
|
67
|
+
if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage),details:truncationDetails(message.content)});
|
|
59
68
|
const ids = new Set();
|
|
60
69
|
for (const block of message.content) {
|
|
61
70
|
if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
|
|
@@ -135,7 +144,7 @@ export class AnthropicProvider {
|
|
|
135
144
|
else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
|
|
136
145
|
} else if (event.type === 'content_block_stop') {
|
|
137
146
|
if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
|
|
138
|
-
if (partial.has(event.index)) blocks.get(event.index)
|
|
147
|
+
if (partial.has(event.index)) { const block = blocks.get(event.index), raw = partial.get(event.index); try { block.input = JSON.parse(raw); } catch { delete block.input; block.truncated_input_chars = raw.length; } }
|
|
139
148
|
closed.add(event.index);
|
|
140
149
|
} else if (event.type === 'message_delta') {
|
|
141
150
|
if (!message) throw new Error('Delta without a message.');
|
package/src/read-only-check.mjs
CHANGED
|
@@ -31,8 +31,12 @@ export function readOnlyBashCheck(command) {
|
|
|
31
31
|
|
|
32
32
|
// Known shipped observation implementations only, not arbitrary plugin/MCP hints.
|
|
33
33
|
// Rechecking still goes through the live contract, guard and permission judge.
|
|
34
|
-
const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console'].map(name => `plugin__chrome-lab__${name}`));
|
|
34
|
+
const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console', 'WorkerDiagnostics'].map(name => `plugin__chrome-lab__${name}`));
|
|
35
35
|
export const chromeLabObservation = operation => CHROME_LAB_READS.has(operation);
|
|
36
|
-
|
|
36
|
+
// open-relay: lecturas del canal admin (sin mutación). Las acciones de la fase 2
|
|
37
|
+
// (Reply, Ack, ApprovePlan, RejectPlan) no son observaciones: pasan por la tarjeta.
|
|
38
|
+
const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier'].map(name => `plugin__open-relay__${name}`));
|
|
39
|
+
export const openRelayObservation = operation => OPEN_RELAY_READS.has(operation);
|
|
40
|
+
export const readOnlyWorkCheck = (operation, args) => ['Read', 'Glob', 'Grep', 'ListDirectory', 'FileInfo', 'ChromeVerify','SourceVerify'].includes(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
|
|
37
41
|
export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
|
|
38
|
-
export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key));
|
|
42
|
+
export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && (RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key)) || operation==='plugin__chrome-lab__WorkerDiagnostics'&&Object.hasOwn(args||{},'connection_id')&&Object.hasOwn(args||{},'browser_endpoint_id'));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {digest,immutable} from './operations.mjs';
|
|
2
|
+
export const SCOPE_MECHANISM='comal-house-scope-v1';
|
|
3
|
+
export const scopeFailure=(message,code='HOUSE_SCOPE_UNAVAILABLE')=>Object.assign(new Error(message),{code});
|
|
4
|
+
const id=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
|
|
5
|
+
export function guardIdentity(name,binding,token){
|
|
6
|
+
if(!id(name))throw scopeFailure('The guard needs a bounded host identity.','HOUSE_SCOPE_INVALID');
|
|
7
|
+
if(binding===undefined)return immutable({id:name,instance_token:token});
|
|
8
|
+
if(!id(binding?.revision)||binding.configuration_digest!=null&&!/^[a-f0-9]{64}$/.test(binding.configuration_digest))throw scopeFailure('The host guard implementation/configuration identity is invalid.','HOUSE_SCOPE_INVALID');
|
|
9
|
+
return immutable({id:name,revision:binding.revision,configuration_digest:binding.configuration_digest??null});
|
|
10
|
+
}
|
|
11
|
+
export function mechanismIdentity(name,binding){
|
|
12
|
+
if(!id(name)||!id(binding?.revision)||typeof binding.check!=='function'||!Array.isArray(binding.operations)||binding.operations.some(name=>name!=='*'&&!id(name))||binding.configuration_digest!=null&&!/^[a-f0-9]{64}$/.test(binding.configuration_digest))throw scopeFailure('The host mechanism identity is invalid.','HOUSE_SCOPE_INVALID');
|
|
13
|
+
return immutable({id:name,revision:binding.revision,operations:[...binding.operations],configuration_digest:binding.configuration_digest??null});
|
|
14
|
+
}
|
|
15
|
+
export function scopeCeiling(body){return immutable({...body,digest:digest(body)});}
|
|
16
|
+
export function validateScopeCeiling(value,parent){
|
|
17
|
+
if(!value||typeof value!=='object')throw scopeFailure('The original scope ceiling is missing.','HOUSE_SCOPE_INVALID');
|
|
18
|
+
const{digest:stamp,...body}=value;
|
|
19
|
+
try{
|
|
20
|
+
if(body.schema!=='comal-scope-ceiling/1'||digest(body)!==stamp||!body.parent||typeof body.parent.house!=='string'||typeof body.parent.session_id!=='string'||digest(body.parent)!==digest(parent)||!Array.isArray(body.laws)||body.laws.length>64||!Array.isArray(body.guards)||body.guards.length>128||Buffer.byteLength(JSON.stringify(value))>1024*1024||Object.keys(body).some(key=>!['schema','parent','laws','guards'].includes(key)))throw Error();
|
|
21
|
+
for(const source of body.laws){
|
|
22
|
+
if(!source||typeof source.house!=='string'||typeof source.session_id!=='string'||typeof source.activation_id!=='string'||!/^[a-f0-9]{64}$/.test(source.mechanism_stamp||'')||!Array.isArray(source.mechanisms))throw Error();
|
|
23
|
+
const{plan_hash,...core}=source.plan||{};if(core.schema!=='comal-governance/v1'||digest(core)!==plan_hash||!Array.isArray(core.gates)||!Array.isArray(core.decisions))throw Error();
|
|
24
|
+
for(const entry of source.mechanisms){const identity=entry?.identity;if(!id(entry?.gate_id)||!id(identity?.id)||!id(identity?.revision)||!Array.isArray(identity.operations)||identity.operations.some(op=>op!=='*'&&!id(op)))throw Error();}
|
|
25
|
+
}
|
|
26
|
+
for(const guard of body.guards)if(!id(guard?.id)||!(id(guard.revision)&&guard.instance_token===undefined||guard.revision===undefined&&/^[a-f0-9-]{36}$/.test(guard.instance_token||'')))throw Error();
|
|
27
|
+
}catch{throw scopeFailure('The original scope ceiling or parent binding does not verify.','HOUSE_SCOPE_INVALID');}
|
|
28
|
+
return immutable(structuredClone(value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Local semantic audit of a recorded scope; never re-runs a guard or mechanism.
|
|
32
|
+
export function auditScopeAuthority(rows,{cwd,id:sessionId}={}){
|
|
33
|
+
const violations=[],starts=new Map(),fault=(row,reason)=>violations.push({invocation_id:row.invocation_id,operation:row.operation,reason}),births=rows.filter(row=>row.type==='house_scope_bound');let birth,revoked=false,activeLaw;
|
|
34
|
+
for(const row of rows){
|
|
35
|
+
if(row.type==='house_scope_bound'){
|
|
36
|
+
if(birth)fault(row,'scope_birth_duplicated');
|
|
37
|
+
try{validateScopeCeiling(row.ceiling,{house:row.parent_house,session_id:row.parent_session_id});if(row.source!=='scope_host'||row.mechanism!==SCOPE_MECHANISM||row.ceiling_digest!==row.ceiling.digest||row.house!==cwd||row.session_id!==sessionId||!row.actor?.id||!['agent','subagent','human'].includes(row.actor.kind)||!/^[a-f0-9-]{36}$/.test(row.scope_id||''))throw Error();birth=row;}catch{fault(row,'scope_birth_invalid');}
|
|
38
|
+
}
|
|
39
|
+
if(row.type==='house_scope_revoked'){
|
|
40
|
+
if(row.source!=='scope_host'||row.mechanism!==SCOPE_MECHANISM||row.house!==cwd||row.session_id!==sessionId||birth&&row.scope_id!==birth.scope_id)fault(row,'scope_revocation_invalid');else revoked=true;
|
|
41
|
+
}
|
|
42
|
+
if(!birth)continue;
|
|
43
|
+
if(['house_proposal','house_started','house_receipt'].includes(row.type)&&digest(row.actor??null)!==digest(birth.actor))fault(row,'scope_actor_binding_mismatch');
|
|
44
|
+
if(row.type==='house_started')starts.set(row.invocation_id,{revoked,missingLaw:birth.ceiling.laws.length&&(!activeLaw||row.governance_plan_hash!==activeLaw.plan.plan_hash||row.mechanism_stamp!==activeLaw.mechanism_stamp||row.governance_activation_id!==activeLaw.activation_id)});
|
|
45
|
+
if(row.type==='house_receipt'&&['returned','failed'].includes(row.status)){const start=starts.get(row.invocation_id);if(start?.revoked)fault(row,'scope_dispatch_after_revocation');if(start?.missingLaw)fault(row,'scope_dispatch_without_original_law');}
|
|
46
|
+
if(row.type==='governance_activated')activeLaw=row.inherited_from?row:null;
|
|
47
|
+
if(row.type==='governance_activated'&&row.inherited_from){
|
|
48
|
+
try{if(!Array.isArray(row.inherited_from))throw Error();for(const source of birth.ceiling.laws){
|
|
49
|
+
const ref={house:source.house,session_id:source.session_id,plan_hash:source.plan.plan_hash,mechanism_stamp:source.mechanism_stamp,activation_id:source.activation_id},index=row.inherited_from.findIndex(value=>digest(value)===digest(ref));
|
|
50
|
+
if(index<0){fault(row,'scope_original_law_omitted');continue;}const prefix='scope'+index+':',scope=name=>prefix+(prefix.length+name.length>128?digest(name):name);
|
|
51
|
+
for(const gate of source.plan.gates){const actual=row.plan?.gates?.find(value=>value.id===scope(gate.id));if(!actual||actual.tier!==gate.tier||digest(actual.operations)!==digest(gate.operations)||actual.decision_id!==scope(gate.decision_id))fault(row,'scope_original_gate_changed');}
|
|
52
|
+
}}catch{fault(row,'scope_original_law_invalid');}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return{scope:'recorded_scope_identity_and_revocation',births:births.length,revoked,consistent:!violations.length,violations,executable_attestation:false};
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {PermissionPolicy,BYPASS_PERMISSION_MODE,PERMISSION_MODES} from './permissions.mjs';
|
|
2
|
+
import {digest,immutable} from './operations.mjs';
|
|
3
|
+
|
|
4
|
+
const fail=message=>Object.assign(new Error(message),{code:'SCOPED_POLICY_INVALID'});
|
|
5
|
+
const modes=new Set([...PERMISSION_MODES,BYPASS_PERMISSION_MODE]);
|
|
6
|
+
export function capturePolicyCeiling(parent,intentions=parent.context?.intentions||[]){
|
|
7
|
+
return immutable({mode:parent.mode,allowed:[...parent.allowed],denied:[...parent.denied],intention_digest:digest(intentions)});
|
|
8
|
+
}
|
|
9
|
+
// Restoring a saved ceiling never restores startup bypass or grants from a child
|
|
10
|
+
// prompt. Actual source root and local recorder belong to the scoped runtime.
|
|
11
|
+
export function scopedPolicy({parent,ceiling=capturePolicyCeiling(parent),cwd,intentions=parent.context?.intentions||[],actions=[],readOnly=false,onDecision=()=>{}}){
|
|
12
|
+
if(!parent?.authorize||typeof cwd!=='string'||!modes.has(ceiling?.mode)||!['allowed','denied'].every(key=>Array.isArray(ceiling[key])&&ceiling[key].every(name=>typeof name==='string'))||ceiling.intention_digest&&ceiling.intention_digest!==digest(intentions))throw fail('The original policy ceiling or trusted human context does not verify.');
|
|
13
|
+
const original=immutable(structuredClone(ceiling)),trusted=immutable(structuredClone(intentions)),initialActions=immutable(structuredClone(actions));
|
|
14
|
+
const mode=()=>{
|
|
15
|
+
if(readOnly)return 'plan';const current=parent.mode,prior=original.mode;if(!modes.has(current))throw fail('The current parent mode is unavailable.');
|
|
16
|
+
if(prior===BYPASS_PERMISSION_MODE)return current===BYPASS_PERMISSION_MODE&&!parent.dangerouslySkipPermissions?'default':current;
|
|
17
|
+
if(current===BYPASS_PERMISSION_MODE)return prior;
|
|
18
|
+
return prior==='plan'||current==='plan'?'plan':prior==='default'||current==='default'?'default':prior==='auto'||current==='auto'?'auto':'acceptEdits';
|
|
19
|
+
};
|
|
20
|
+
const initial=mode(),policy=new PermissionPolicy({mode:initial,dangerouslySkipPermissions:initial===BYPASS_PERMISSION_MODE,allowed:original.allowed,denied:original.denied,cwd,onDecision});
|
|
21
|
+
Object.defineProperties(policy,{
|
|
22
|
+
mode:{get:mode},allowed:{get:()=>new Set(original.allowed.filter(name=>parent.allowed.has(name)))},denied:{get:()=>new Set([...original.denied,...parent.denied])},
|
|
23
|
+
classifier:{get:()=>parent.classifier},confirm:{get:()=>parent.confirm},rules:{get:()=>parent.rules},contractOf:{get:()=>parent.contractOf},ceiling:{value:original},
|
|
24
|
+
});
|
|
25
|
+
policy.setContext=context=>PermissionPolicy.prototype.setContext.call(policy,{intentions:structuredClone(trusted),actions:[...initialActions,...(context?.actions||[])].slice(-20)});
|
|
26
|
+
policy.setContext({actions:[]});return policy;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A chain must retain each trusted human context as well as intersecting rule
|
|
30
|
+
// sets/modes. Merely nesting scopedPolicy objects selects the newest context
|
|
31
|
+
// for the classifier; a later broader request could then replace the old one.
|
|
32
|
+
export function scopedPolicyStack({parent,bindings,cwd,onDecision=()=>{}}){
|
|
33
|
+
if(!Array.isArray(bindings)||!bindings.length)throw fail('A policy stack requires original native bindings.');
|
|
34
|
+
const scope=(parent,b)=>scopedPolicy({parent,ceiling:b.authority,cwd,intentions:b.inherited.intentions,actions:b.inherited.actions,onDecision});
|
|
35
|
+
const combined=bindings.reduce(scope,parent),unique=[...new Map(bindings.map(b=>[digest({authority:b.authority,inherited:b.inherited}),b])).values()],checks=unique.map(b=>scope(combined,b));
|
|
36
|
+
const setContext=combined.setContext.bind(combined);combined.setContext=context=>{setContext(context);for(const policy of checks)policy.setContext(context);};
|
|
37
|
+
combined.authorize=async(name,input,options)=>{let result;for(const policy of checks){result=await policy.authorize(name,input,options);if(!result.allowed)return result;}return result;};
|
|
38
|
+
return combined;
|
|
39
|
+
}
|