@milpa/comal 0.2.2 → 0.2.4
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/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +261 -62
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +283 -15
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
package/src/operations.mjs
CHANGED
|
@@ -17,6 +17,21 @@ export function toolEffects(name) {
|
|
|
17
17
|
return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// A model often sends an array/object field as a string holding JSON (sometimes broken JSON). Say so
|
|
21
|
+
// precisely instead of only "must be array", so the next call can be a real correction.
|
|
22
|
+
function stringValueHint(error, actual) {
|
|
23
|
+
const expected = [].concat(error.params?.type || []);
|
|
24
|
+
if (error.keyword !== 'type' || typeof actual !== 'string' || !expected.some(type => type === 'array' || type === 'object')) return {};
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(actual), parsedType = Array.isArray(parsed) ? 'array' : parsed === null ? 'null' : typeof parsed;
|
|
27
|
+
return { string_value: { valid_json: true, parsed_type: parsedType, hint: expected.includes(parsedType) ? `This field arrived as a string that contains JSON. Send the ${parsedType} itself as the field value, not a string.` : `This field arrived as a string whose JSON is a ${parsedType}, not ${expected.join(' or ')}.` } };
|
|
28
|
+
} catch (parseError) {
|
|
29
|
+
const position = Number(/position (\d+)/.exec(parseError.message)?.[1]);
|
|
30
|
+
const near = Number.isSafeInteger(position) ? `${position > 60 ? '…' : ''}${actual.slice(Math.max(0, position - 60), position + 40)}${position + 40 < actual.length ? '…' : ''}` : actual.slice(0, 100);
|
|
31
|
+
return { string_value: { valid_json: false, error: String(parseError.message).slice(0, 200), near, hint: `This field arrived as a string, and that string is not valid JSON either. Send the ${expected.join(' or ')} itself as the field value, with balanced brackets and escaped quotes.` } };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export class Operation {
|
|
21
36
|
#validate;
|
|
22
37
|
#validateOutput;
|
|
@@ -47,9 +62,9 @@ export class Operation {
|
|
|
47
62
|
const issues = this.#validate.errors.slice(0, 12).map(error => {
|
|
48
63
|
const pointer = error.instancePath || '';
|
|
49
64
|
let actual = input; for (const part of pointer.split('/').slice(1)) actual = actual?.[part.replace(/~1/g, '/').replace(/~0/g, '~')];
|
|
50
|
-
return { path: pointer || '/', rule: error.keyword, message: error.message, ...error.params, ...(['maxItems', 'minItems'].includes(error.keyword) && Array.isArray(actual) ? { actual_count: actual.length } : {}) };
|
|
65
|
+
return { path: pointer || '/', rule: error.keyword, message: error.message, ...error.params, ...(['maxItems', 'minItems'].includes(error.keyword) && Array.isArray(actual) ? { actual_count: actual.length } : {}), ...stringValueHint(error, actual) };
|
|
51
66
|
});
|
|
52
|
-
throw Object.assign(new Error(`Input inválido para ${this.contract.name}: ${this.#validate.errors.map(error => `${error.instancePath || 'input'} ${error.message}`).join('; ')}.`), {
|
|
67
|
+
throw Object.assign(new Error(`Input inválido para ${this.contract.name}: ${this.#validate.errors.map(error => `${error.instancePath || 'input'} ${error.message}${error.params?.additionalProperty ? ` (${error.params.additionalProperty})` : ''}`).join('; ')}.`), {
|
|
53
68
|
code: 'HOUSE_INPUT_SCHEMA', details: { operation: this.contract.name, executed: false, issues, next_action: 'Correct the named fields using the live tool schema; preserve all intended work. Do not repeat unchanged arguments, invent operation names, query the board, or infer that any effect ran.' },
|
|
54
69
|
});
|
|
55
70
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Identidad del paquete INSTALADO de Comal: nombre, versión e integridad. La integridad es
|
|
7
|
+
// el digest de {ruta → sha256} de todos los archivos que publica el paquete (los `files` de
|
|
8
|
+
// su package.json: bin, src, contratos, docs), calculado por Comal sobre sus propios bytes
|
|
9
|
+
// una vez por proceso. Sirve para que la confianza en los plugins INCLUIDOS se ate al
|
|
10
|
+
// paquete (se pide una vez por versión) y no a cada archivo ni a la ruta donde se instaló.
|
|
11
|
+
// Una copia de desarrollo (package.json con `private: true`, como el del repo) no tiene
|
|
12
|
+
// identidad de paquete: ahí la confianza sigue atada a la huella del código, como siempre.
|
|
13
|
+
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
14
|
+
const MAX_FILES = 20000, MAX_BYTES = 256 << 20;
|
|
15
|
+
const sha = value => createHash('sha256').update(value).digest('hex');
|
|
16
|
+
const posix = file => file.split(path.sep).join('/');
|
|
17
|
+
|
|
18
|
+
/** Digest canónico de {ruta → sha256}: lo que identifica el contenido del paquete. */
|
|
19
|
+
export const manifestIntegrity = files => sha(JSON.stringify(Object.keys(files).sort().map(key => [key, files[key]])));
|
|
20
|
+
|
|
21
|
+
async function hashTree(root, entries) {
|
|
22
|
+
const files = {}; let count = 0, bytes = 0;
|
|
23
|
+
const visit = async relative => {
|
|
24
|
+
const absolute = path.join(root, relative), info = await lstat(absolute).catch(() => null);
|
|
25
|
+
if (!info || info.isSymbolicLink()) return;
|
|
26
|
+
if (info.isDirectory()) { for (const name of (await readdir(absolute)).sort()) if (name !== 'node_modules' && !name.startsWith('.')) await visit(path.join(relative, name)); return; }
|
|
27
|
+
if (!info.isFile()) return;
|
|
28
|
+
if (++count > MAX_FILES || (bytes += info.size) > MAX_BYTES) throw new Error('package too large');
|
|
29
|
+
files[posix(relative)] = sha(await readFile(absolute));
|
|
30
|
+
};
|
|
31
|
+
for (const entry of new Set(['package.json', ...entries])) await visit(entry);
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map();
|
|
36
|
+
/**
|
|
37
|
+
* {root, name, version, integrity, files} del paquete instalado en `root` (por omisión, esta
|
|
38
|
+
* instalación) o null si es una copia de desarrollo o no se puede leer.
|
|
39
|
+
*/
|
|
40
|
+
export function packageIdentity(root = ROOT) {
|
|
41
|
+
const key = path.resolve(root);
|
|
42
|
+
if (!cache.has(key)) cache.set(key, (async () => {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(await readFile(path.join(key, 'package.json'), 'utf8'));
|
|
45
|
+
if (pkg.private === true || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !Array.isArray(pkg.files) || !pkg.files.length) return null;
|
|
46
|
+
const entries = pkg.files.filter(item => typeof item === 'string' && !path.isAbsolute(item) && !item.split(/[\\/]/).includes('..'));
|
|
47
|
+
const files = await hashTree(key, entries);
|
|
48
|
+
return Object.freeze({ root: key, name: pkg.name, version: pkg.version, integrity: manifestIntegrity(files), files: Object.freeze(files) });
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
})());
|
|
51
|
+
return cache.get(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Si TODOS los archivos dados ({file absoluto, sha256}) son del paquete y coinciden con los
|
|
56
|
+
* que se midieron para su integridad, devuelve {name, version, integrity}; si no (fuera del
|
|
57
|
+
* paquete o cambiados desde entonces), null: la confianza vuelve a la huella del código.
|
|
58
|
+
*/
|
|
59
|
+
export function packageCoverage(identity, files) {
|
|
60
|
+
if (!identity || !Array.isArray(files) || !files.length) return null;
|
|
61
|
+
for (const { file, sha256 } of files) {
|
|
62
|
+
const relative = posix(path.relative(identity.root, file));
|
|
63
|
+
if (!relative || relative.startsWith('../') || path.isAbsolute(relative) || identity.files[relative] !== sha256) return null;
|
|
64
|
+
}
|
|
65
|
+
return { name: identity.name, version: identity.version, integrity: identity.integrity };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const samePackage = (a, b) => Boolean(a && b && a.name === b.name && a.version === b.version && a.integrity === b.integrity);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Sondeo de mensajes para el peer de la TUI (cada 300 ms). Avisos de plugins, mensajes entre sesiones y
|
|
2
|
+
// resultados de subagentes viven en el libro de la sesión: si el libro no cambió desde el último «no hay
|
|
3
|
+
// nada», la respuesta tampoco. Así una sesión grande no se relee entera en cada tick (gate «extensión de
|
|
4
|
+
// bóveda»: ~760 ms por sondeo con 6 MB, peerProcessing siempre activo y ~120 % de CPU en reposo). Un
|
|
5
|
+
// sondeo completo cada `maxAgeMs` cubre lo que no deja rastro en el libro (p. ej. un subagente que murió).
|
|
6
|
+
export function memoizedPeerProbe({ stamp, probe, maxAgeMs = 5000, now = Date.now }) {
|
|
7
|
+
let last = null; // { stamp, at } del último sondeo completo que dijo «nada»
|
|
8
|
+
return async () => {
|
|
9
|
+
let current = null;
|
|
10
|
+
try { current = await stamp(); } catch { current = null; }
|
|
11
|
+
if (current !== null && last && last.stamp === current && now() - last.at < maxAgeMs) return false;
|
|
12
|
+
const answer = Boolean(await probe());
|
|
13
|
+
last = !answer && current !== null ? { stamp: current, at: now() } : null;
|
|
14
|
+
return answer;
|
|
15
|
+
};
|
|
16
|
+
}
|
package/src/permissions.mjs
CHANGED
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
// Work controls write only the session ledger; nested effects still use the house judge.
|
|
2
2
|
const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'McpStatus', 'HouseContext', 'HouseSnapshot', 'OperationCatalog', 'ReadTable', 'PluginStatus', 'WorkSnapshot', 'AuditStatus', 'EvidenceRead', 'WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft', 'WorkBoard']);
|
|
3
3
|
const EDIT_TOOLS = new Set(['Write', 'Edit', 'McpConfigure','GitIntegrationPrepare','GitIntegrationApply']);
|
|
4
|
-
import { inspectAutoAction, intentCoversAction } from './auto-guard.mjs';
|
|
4
|
+
import { inspectAutoAction, intentCoversAction, isProtectedPath } from './auto-guard.mjs';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { readOnlyBashCheck, chromeLabObservation, openRelayObservation, omarchyObservation } from './read-only-check.mjs';
|
|
7
7
|
import { describeRule, suggestRule } from './permission-rules.mjs';
|
|
8
|
+
import { measureHumanWait } from './turn-timing.mjs';
|
|
9
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const within = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); };
|
|
14
|
+
|
|
15
|
+
/** realpath of the nearest existing ancestor plus the rest: a location not created yet still compares canonically. */
|
|
16
|
+
async function canonicalish(file) {
|
|
17
|
+
let ancestor = path.resolve(file), suffix = [];
|
|
18
|
+
while (true) {
|
|
19
|
+
try { return path.join(await realpath(ancestor), ...suffix); }
|
|
20
|
+
catch { const parent = path.dirname(ancestor); if (parent === ancestor) return path.resolve(file); suffix.unshift(path.basename(ancestor)); ancestor = parent; }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Comal's private configuration and state: saved permission rules, plugin trust, credentials, peer tokens,
|
|
26
|
+
* the visual-approval key, relay tokens, session books. A writable or readable copy of any of them would let
|
|
27
|
+
* the model grant itself authority or take secrets, so no /add-dir may contain or sit inside one.
|
|
28
|
+
*/
|
|
29
|
+
export function privateLocations({ env = process.env, home = env.HOME || os.homedir(), extra = [] } = {}) {
|
|
30
|
+
const list = [
|
|
31
|
+
env.XDG_CONFIG_HOME && path.join(env.XDG_CONFIG_HOME, 'comal'), path.join(home, '.config', 'comal'),
|
|
32
|
+
env.XDG_STATE_HOME && path.join(env.XDG_STATE_HOME, 'comal'), path.join(home, '.local', 'state', 'comal'),
|
|
33
|
+
path.join(home, '.comal'), path.join(home, '.relay-hub'),
|
|
34
|
+
...['COMAL_AUTH_DIR', 'COMAL_CLOUD_CONFIG', 'COMAL_PEER_DIR', 'COMAL_VISUAL_KEY_DIR', 'COMAL_CONTEXT_STATS_DIR', 'COMAL_PLUGIN_CONFIG', 'COMAL_PROVIDERS_CONFIG', 'RELAY_HUB_ROOT'].map(name => env[name]),
|
|
35
|
+
...extra,
|
|
36
|
+
];
|
|
37
|
+
return [...new Set(list.filter(item => typeof item === 'string' && item).map(item => path.resolve(item)))];
|
|
38
|
+
}
|
|
39
|
+
const STATE_NAMES = ['.comal', '.harness'];
|
|
40
|
+
export const holdsComalState = (root, file) => path.relative(root, file).split(path.sep).some(segment => STATE_NAMES.includes(segment));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* /add-dir: directories the human opens mid-session for reading AND writing. Canonical and existing; never
|
|
44
|
+
* the filesystem root, a world-writable directory, Comal state (.comal/.harness), nor anything that contains
|
|
45
|
+
* or sits inside a private Comal location (see privateLocations).
|
|
46
|
+
*/
|
|
47
|
+
export async function workRootsFrom(paths = [], { env = process.env, home, private: extra = [] } = {}) {
|
|
48
|
+
const locations = [];
|
|
49
|
+
for (const location of privateLocations({ env, home, extra })) locations.push([location, await canonicalish(location)]);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const raw of paths) {
|
|
52
|
+
let canonical;
|
|
53
|
+
try { canonical = await realpath(raw); } catch { throw new Error(`/add-dir ${raw}: does not exist.`); }
|
|
54
|
+
const info = await stat(canonical);
|
|
55
|
+
if (!info.isDirectory()) throw new Error(`/add-dir ${raw}: not a directory.`);
|
|
56
|
+
if (canonical === path.parse(canonical).root) throw new Error(`/add-dir ${raw}: the filesystem root cannot be opened for writing.`);
|
|
57
|
+
if (holdsComalState(path.parse(canonical).root, canonical)) throw new Error(`/add-dir ${raw}: it is Comal state (.comal/.harness).`);
|
|
58
|
+
if (info.mode & 0o002) throw new Error(`/add-dir ${raw}: it is world-writable; another user could swap files under Comal. Choose a private directory.`);
|
|
59
|
+
const hit = locations.find(([location, real]) => [location, real].some(item => within(canonical, item) || within(item, canonical)));
|
|
60
|
+
if (hit) throw new Error(`/add-dir ${raw}: it touches a private Comal location (${hit[0]}); Comal configuration, credentials and session state stay out of reach. Choose a narrower directory.`);
|
|
61
|
+
if (!out.includes(canonical)) out.push(canonical);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** --add-dir: directorios de solo lectura elegidos por el humano al arrancar; canónicos y existentes. */
|
|
67
|
+
export async function readRootsFrom(paths = []) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const raw of paths) {
|
|
70
|
+
let canonical;
|
|
71
|
+
try { canonical = await realpath(raw); } catch { throw new Error(`--add-dir ${raw}: no existe.`); }
|
|
72
|
+
if (!(await stat(canonical)).isDirectory()) throw new Error(`--add-dir ${raw}: no es un directorio.`);
|
|
73
|
+
if (!out.includes(canonical)) out.push(canonical);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
8
77
|
READ_TOOLS.add('ToolPack');
|
|
9
78
|
for (const name of ['ArtifactAccount','ArtifactCapabilities','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
|
|
10
79
|
for (const name of ['ListDirectory', 'FileInfo', 'SourceRead', 'GitInspect','GitIntegrationStatus','GitIntegrationEvidence']) READ_TOOLS.add(name);
|
|
@@ -51,13 +120,36 @@ export class PermissionPolicy {
|
|
|
51
120
|
// rules: almacén de reglas guardadas (permission-rules.mjs) con decide(name, input) → {list, rule, scope}.
|
|
52
121
|
// contractOf(name): contrato vivo de la operación (house.registry.get(name)?.contract); de ahí sale
|
|
53
122
|
// el techo duro confirm:'always', que sólo el host lee y el modelo no puede cambiar.
|
|
54
|
-
|
|
123
|
+
#workRoots = []; #private = []; #privateExtra;
|
|
124
|
+
constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [] } = {}) {
|
|
125
|
+
this.#privateExtra = [...privateExtra];
|
|
55
126
|
if (!PERMISSION_MODES.includes(mode) && !(dangerouslySkipPermissions === true && mode === BYPASS_PERMISSION_MODE)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto; bypass requires the startup flag.');
|
|
56
127
|
Object.defineProperty(this, 'dangerouslySkipPermissions', { value: dangerouslySkipPermissions === true, enumerable: true });
|
|
57
128
|
this.#mode = this.dangerouslySkipPermissions ? BYPASS_PERMISSION_MODE : mode;
|
|
58
129
|
this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
|
|
59
130
|
this.classifier = classifier; this.cwd = cwd; this.rules = rules; this.contractOf = contractOf; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
|
|
131
|
+
// --add-dir: solo lectura (Read, FileInfo, ListDirectory, Grep, Glob); la escritura sigue en el workspace.
|
|
132
|
+
Object.defineProperty(this, 'readRoots', { value: Object.freeze([...readRoots]), enumerable: true });
|
|
133
|
+
}
|
|
134
|
+
/** /add-dir: read+write directories the human opened this session (a frozen copy; only addWorkRoot changes it). */
|
|
135
|
+
get workRoots() { return Object.freeze([...this.#workRoots]); }
|
|
136
|
+
/** Host-only: the human's /add-dir. Writes there still go through this policy like workspace writes. */
|
|
137
|
+
async addWorkRoot(dir, options = {}) {
|
|
138
|
+
const extra = [...this.#privateExtra, ...(options.private || [])];
|
|
139
|
+
const [canonical] = await workRootsFrom([dir], { ...options, private: extra });
|
|
140
|
+
// exact: restoring a recorded path; if it now resolves elsewhere (a swapped symlink), nothing opens.
|
|
141
|
+
if (options.exact && canonical !== dir) throw new Error(`${dir} now resolves to ${canonical}.`);
|
|
142
|
+
this.#private = [];
|
|
143
|
+
for (const location of privateLocations({ env: options.env, home: options.home, extra })) this.#private.push(location, await canonicalish(location));
|
|
144
|
+
if (this.cwd) { let workspace = this.cwd; try { workspace = await realpath(this.cwd); } catch {} if (within(workspace, canonical)) return { added: false, path: canonical, reason: 'inside_workspace' }; }
|
|
145
|
+
if (this.#workRoots.includes(canonical)) return { added: false, path: canonical, reason: 'already_added' };
|
|
146
|
+
this.#workRoots.push(canonical);
|
|
147
|
+
return { added: true, path: canonical };
|
|
60
148
|
}
|
|
149
|
+
/** Host-only: another session was resumed in this process; its own /add-dir rows are restored next. */
|
|
150
|
+
clearWorkRoots() { this.#workRoots = []; }
|
|
151
|
+
/** A private Comal location or a path an active plugin protects: closed even under an added directory. */
|
|
152
|
+
isPrivate(file) { return this.#private.some(location => within(location, file)) || isProtectedPath(file); }
|
|
61
153
|
get mode() { return this.#mode; }
|
|
62
154
|
set mode(mode) {
|
|
63
155
|
if (this.dangerouslySkipPermissions) throw new Error(BYPASS_LOCKED_MESSAGE);
|
|
@@ -137,7 +229,8 @@ export class PermissionPolicy {
|
|
|
137
229
|
if(this.confirm!==confirm || stamp!==permissionStamp(this))return this.confirm===confirm && visible===houseStamp(this) ? { allowed: false, stale: true } : { allowed: false };
|
|
138
230
|
await onWaiting?.({type:'house_waiting',question_id:questionId});
|
|
139
231
|
displayed=true;signal?.throwIfAborted();
|
|
140
|
-
|
|
232
|
+
// The visible card is human time: excluded from the turn's Comal time.
|
|
233
|
+
const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal)),offer);
|
|
141
234
|
signal?.throwIfAborted();
|
|
142
235
|
allowed=answer.allowed && this.confirm===confirm && (stamp===permissionStamp(this) || visible===houseStamp(this) && this.rules?.decide(name,input)?.list!=='deny');
|
|
143
236
|
if(!allowed || !answer.remember)return { allowed };
|
|
@@ -153,7 +246,7 @@ export class PermissionPolicy {
|
|
|
153
246
|
}
|
|
154
247
|
async authorizeAuto(name, input, signal, onWaiting, invocation, hostFacts, rule = this.rules?.decide(name, input)) {
|
|
155
248
|
const inspection = inspectAutoAction(name, input, this.cwd || process.cwd());
|
|
156
|
-
const data = { workspace: this.cwd, human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
|
|
249
|
+
const data = { workspace: this.cwd, ...(this.#workRoots.length ? { extra_work_directories: [...this.#workRoots] } : {}), human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
|
|
157
250
|
if(hostFacts)data.host_verified_facts=hostFacts;
|
|
158
251
|
let verdict;
|
|
159
252
|
if (inspection.hard) verdict = { decision: 'deny', risk: 'destructive', reason: inspection.hard, intent_quote: '', source: 'guard' };
|
package/src/plugin-config.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { t, registerMessages } from './i18n/index.mjs';
|
|
6
|
+
import { packageIdentity, packageCoverage } from './package-identity.mjs';
|
|
7
|
+
import { access } from 'node:fs/promises';
|
|
6
8
|
|
|
7
9
|
registerMessages({'Invalid plugin JSON: {detail}': 'Configuración de plugins JSON inválida: {detail}'});
|
|
8
10
|
|
|
@@ -108,7 +110,22 @@ export async function inspectPluginModule(requested) {
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
const list = [...files].map(([file, sha256]) => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256 })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
111
|
-
|
|
113
|
+
// Plugins que viajan dentro de un paquete instalado (Comal): su identidad es la del paquete
|
|
114
|
+
// si todos sus archivos coinciden con los que se midieron para su integridad (package-identity.mjs).
|
|
115
|
+
const owner = await packageRootOf(entry), pkg = owner && packageCoverage(await packageIdentity(owner), [...files].filter(([, sha256]) => sha256).map(([file, sha256]) => ({ file, sha256 })));
|
|
116
|
+
return { module: entry, code_fingerprint: hash(Buffer.from(JSON.stringify(list))), files: list, external: [...external].sort(), ...(pkg ? { package: pkg } : {}) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// El directorio más cercano (hasta 8 niveles arriba) con package.json. La identidad es de
|
|
120
|
+
// contenido (digest de los sha256 de los archivos del paquete), así que otro paquete sólo
|
|
121
|
+
// coincide con uno confiado si sus bytes son los mismos.
|
|
122
|
+
async function packageRootOf(file) {
|
|
123
|
+
let dir = path.dirname(file);
|
|
124
|
+
for (let level = 0; level < 8; level++) {
|
|
125
|
+
try { await access(path.join(dir, 'package.json')); return dir; } catch {}
|
|
126
|
+
const parent = path.dirname(dir); if (parent === dir) return null; dir = parent;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/** Import executes trusted host code. Call only after explicit human plugin trust. */
|
package/src/plugin-controls.mjs
CHANGED
|
@@ -139,7 +139,14 @@ export class PluginControls {
|
|
|
139
139
|
}
|
|
140
140
|
get project() { return this.runtime.file; }
|
|
141
141
|
/** Callback de arranque: decide si la confianza registrada sigue vigente para el código actual. */
|
|
142
|
-
trusted = async (entry, code) => trustVerdict(this.store.find(this.project, entry.id), { project: this.project, entry, code });
|
|
142
|
+
trusted = async (entry, code) => { const verdict = trustVerdict(this.store.find(this.project, entry.id), { project: this.project, entry, code }); await this.#follow(entry.id, verdict, code); return verdict; };
|
|
143
|
+
// Confianza de paquete (mismo Comal, otra ruta o huella): el registro sigue al código vivo
|
|
144
|
+
// para que las reglas allow (codeTrusted) apliquen. No amplía nada: es el mismo contenido.
|
|
145
|
+
async #follow(id, verdict, code) {
|
|
146
|
+
const saved = this.store.find(this.project, id);
|
|
147
|
+
if (verdict.status !== 'trusted' || !saved || (saved.module === code.module && saved.code_fingerprint === code.code_fingerprint)) return;
|
|
148
|
+
try { await this.store.update(this.project, id, () => ({ module: code.module, code_fingerprint: code.code_fingerprint, files: code.files })); } catch {}
|
|
149
|
+
}
|
|
143
150
|
async initialize({ trust = false, signal } = {}) {
|
|
144
151
|
await this.store.load(); await this.rules.load();
|
|
145
152
|
for (const warning of [this.store.warning, this.rules.warning]) if (warning) this.notice(warning);
|
|
@@ -166,22 +173,23 @@ export class PluginControls {
|
|
|
166
173
|
return name === 'PluginTrust' && this.trustByFlag;
|
|
167
174
|
}
|
|
168
175
|
#live(id) { return this.runtime.status().plugins.find(plugin => plugin.id === id); }
|
|
169
|
-
async #trustAndEnable(id, options = {}, { recommend = false } = {}) {
|
|
176
|
+
async #trustAndEnable(id, options = {}, { recommend = false, preapproved = false } = {}) {
|
|
170
177
|
const { entry, code } = await this.runtime.inspect(id), saved = this.store.find(this.project, id);
|
|
171
178
|
const verdict = trustVerdict(saved, { project: this.project, entry, code }), live = this.#live(id), active = live?.state === 'active';
|
|
172
179
|
const expect = { module: code.module, code_fingerprint: code.code_fingerprint };
|
|
173
180
|
if (verdict.status === 'trusted') {
|
|
181
|
+
await this.#follow(id, verdict, code);
|
|
174
182
|
if (!active) await this.runtime.enable(id, { signal: options.signal, expect });
|
|
175
183
|
else if (live.code_fingerprint !== code.code_fingerprint) await this.runtime.load(id, { signal: options.signal, expect });
|
|
176
184
|
return t('{id}: trust current · active · {grants}', { id, grants: this.#grantSummary(id) });
|
|
177
185
|
}
|
|
178
|
-
if (!this.confirm && !this.trustByFlag) {
|
|
186
|
+
if (!preapproved && !this.confirm && !this.trustByFlag) {
|
|
179
187
|
// Sin TUI ni --trust-plugins: la orden humana carga sólo esta sesión, como siempre.
|
|
180
188
|
// Conserva la salida JSON de estado que ya consumen scripts de -p.
|
|
181
189
|
const status = active ? this.runtime.status() : await this.runtime.enable(id, { signal: options.signal });
|
|
182
190
|
return JSON.stringify({ ...status, note: 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 }) });
|
|
183
191
|
}
|
|
184
|
-
const accepted = await this.#ask('PluginTrust', {
|
|
192
|
+
const accepted = preapproved || await this.#ask('PluginTrust', {
|
|
185
193
|
plugin: id, project: this.project, module: code.module, code_fingerprint: code.code_fingerprint, files: code.files.map(file => file.path), ...(code.external.length ? { uncovered_packages: code.external } : {}), config: entry.config,
|
|
186
194
|
...(verdict.status === 'stale' ? { changed: verdict.reasons } : {}),
|
|
187
195
|
effect: t('Comal will import and run this local Node code with your user permissions, now and on later startups while module, fingerprint and configuration do not change.'),
|
|
@@ -193,7 +201,7 @@ export class PluginControls {
|
|
|
193
201
|
const lines = [t('{id}: trust remembered ({count} files, fingerprint {fingerprint}) · active.', { id, count: code.files.length, fingerprint: code.code_fingerprint.slice(0, 12) })];
|
|
194
202
|
const split = pluginOperations(id);
|
|
195
203
|
if (recommend && split && !this.rules.list().some(this.#pluginAllow(id))) {
|
|
196
|
-
const allow = await this.#ask('PluginAllow', { plugin: id, allow: split.observations.map(short), still_ask: split.actions.map(short), note: t('Recommended: authorize only observations. Actions will keep asking; /plugins allow {id} all authorizes all of them. Explicit denials still win.', { id }) }, options);
|
|
204
|
+
const allow = preapproved || await this.#ask('PluginAllow', { plugin: id, allow: split.observations.map(short), still_ask: split.actions.map(short), note: t('Recommended: authorize only observations. Actions will keep asking; /plugins allow {id} all authorizes all of them. Explicit denials still win.', { id }) }, options);
|
|
197
205
|
if (allow) await this.rules.update({ add: [{ list: 'allow', rule: `plugin:${id}:observe`, scope: this.rules.scope }] });
|
|
198
206
|
else lines.push(t('No allow rule saved. Recommended: /plugins allow {id} observe.', { id }));
|
|
199
207
|
}
|
|
@@ -224,10 +232,18 @@ export class PluginControls {
|
|
|
224
232
|
}).join('\n\n');
|
|
225
233
|
}
|
|
226
234
|
#split(id, set) { const { workflow, ...config } = set || {}; if (workflow !== undefined && !bundledPlugin(id)?.host?.workflow?.includes(workflow)) throw new Error(t('workflow does not apply to {id} or is not auto|raw.', { id })); return { workflow, config }; }
|
|
227
|
-
async add({ id, set }, options) {
|
|
235
|
+
async add({ id, set }, options) { return this.#add({ id, set }, options); }
|
|
236
|
+
/**
|
|
237
|
+
* Alta, confianza y lecturas sin preguntar confirmadas por una tarjeta del HOST que ya las
|
|
238
|
+
* mostró (la de /relay: versión de Comal, proyecto, permisos de la delegación). No vuelve a
|
|
239
|
+
* preguntar PluginTrust ni PluginAllow. Sólo plugins incluidos. No es una operación: ni el
|
|
240
|
+
* modelo ni /plugins la invocan; la llama el host después de la tarjeta del humano.
|
|
241
|
+
*/
|
|
242
|
+
async link({ id, set }, options = {}) { return this.#add({ id, set }, options, { preapproved: true, replace: true }); }
|
|
243
|
+
async #add({ id, set }, options, { preapproved = false, replace = false } = {}) {
|
|
228
244
|
const item = bundledPlugin(id); if (!item) throw new Error(t('{id} is not a bundled plugin. Use /plugins available; for others, edit {file}.', { id, file: this.project }));
|
|
229
245
|
const { workflow, config } = this.#split(id, set), raw = await readRaw(this.project), existing = raw.plugins.find(entry => entry.id === id);
|
|
230
|
-
const next = { id, module: item.module, enabled: true, config: validateBundledConfig(id, { ...item.defaults, ...(existing?.config || {}), ...config }) };
|
|
246
|
+
const next = { id, module: item.module, enabled: true, config: validateBundledConfig(id, { ...item.defaults, ...(replace ? {} : existing?.config || {}), ...config }) };
|
|
231
247
|
if (this.#live(id)?.state === 'active' && existing && existing.module !== item.module) throw new Error(t('{id} is active with another module; disable it before replacing it.', { id }));
|
|
232
248
|
raw.plugins = existing ? raw.plugins.map(entry => entry.id === id ? next : entry) : [...raw.plugins, next];
|
|
233
249
|
if (workflow) raw.chrome_workflow = workflow;
|
|
@@ -235,9 +251,16 @@ export class PluginControls {
|
|
|
235
251
|
if (workflow) await this.onWorkflow(workflow);
|
|
236
252
|
const lines = [t(existing ? 'Updated {id} in {file}' : 'Added {id} in {file}', { id, file: this.project }), t(' module: {module}', { module: item.module }), t(' config: {config}', { config: Object.entries(next.config).map(([key, value]) => `${key}=${value}`).join(' ') })];
|
|
237
253
|
if (this.#live(id)?.state === 'active' && existing && configDigest(existing.config) !== configDigest(next.config)) lines.push(await this.#reconfigure(id, options));
|
|
238
|
-
lines.push(await this.#trustAndEnable(id, options, { recommend: true }));
|
|
254
|
+
lines.push(await this.#trustAndEnable(id, options, { recommend: true, preapproved }));
|
|
239
255
|
return lines.join('\n');
|
|
240
256
|
}
|
|
257
|
+
/** Estado de un plugin incluido en este proyecto: config guardada, confianza vigente y estado vivo. */
|
|
258
|
+
async describe(id) {
|
|
259
|
+
const raw = await readRaw(this.project), entry = raw.plugins.find(item => item.id === id), live = this.#live(id);
|
|
260
|
+
let trust = { status: 'untrusted', reasons: ['no record'] }, code = null;
|
|
261
|
+
if (entry) { try { const inspected = await this.runtime.inspect(id); code = inspected.code; trust = trustVerdict(this.store.find(this.project, id), { project: this.project, entry: inspected.entry, code }); } catch (error) { trust = { status: 'error', reasons: [error.message] }; } }
|
|
262
|
+
return { configured: Boolean(entry), config: entry?.config || null, state: live?.state || null, error: live?.error || null, trust, package: code?.package || null, code_fingerprint: code?.code_fingerprint || null };
|
|
263
|
+
}
|
|
241
264
|
async #reconfigure(id, options) {
|
|
242
265
|
const saved = this.store.find(this.project, id), { entry } = await this.runtime.inspect(id);
|
|
243
266
|
if (saved) await this.store.update(this.project, id, () => ({ config_sha256: configDigest(entry.config) }));
|
package/src/plugin-trust.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readPrivateJson, writePrivateJson } from './permission-rules.mjs';
|
|
5
5
|
import { t, registerMessages } from './i18n/index.mjs';
|
|
6
|
+
import { samePackage } from './package-identity.mjs';
|
|
6
7
|
|
|
7
8
|
registerMessages({
|
|
8
9
|
'Invalid trust record.': 'Registro de confianza inválido.',
|
|
@@ -11,6 +12,7 @@ registerMessages({
|
|
|
11
12
|
'Trust record: invalid id or fingerprints.': 'Registro de confianza: id o huellas inválidas.',
|
|
12
13
|
'Trust record: invalid files.': 'Registro de confianza: archivos inválidos.',
|
|
13
14
|
'Trust record: invalid date.': 'Registro de confianza: fecha inválida.',
|
|
15
|
+
'Trust record: invalid package.': 'Registro de confianza: paquete inválido.',
|
|
14
16
|
'Unrecognized trust registry format.': 'Formato de registro de confianza no reconocido.',
|
|
15
17
|
'Trust registry ignored: {reason}': 'Registro de confianza ignorado: {reason}',
|
|
16
18
|
'No recorded trust for {id} in this project. Use /plugins enable {id}.': 'No hay confianza registrada para {id} en este proyecto. Usa /plugins enable {id}.',
|
|
@@ -32,25 +34,32 @@ export function pluginTrustFile(env = process.env) {
|
|
|
32
34
|
|
|
33
35
|
function record(value) {
|
|
34
36
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw fail(t('Invalid trust record.'));
|
|
35
|
-
const keys = ['project', 'id', 'module', 'code_fingerprint', 'config_sha256', 'files', 'trusted_at'];
|
|
37
|
+
const keys = ['project', 'id', 'module', 'code_fingerprint', 'config_sha256', 'files', 'trusted_at', 'package'];
|
|
36
38
|
for (const key of Object.keys(value)) if (!keys.includes(key)) throw fail(t('Trust record: unknown field {key}', { key }));
|
|
37
39
|
if (typeof value.project !== 'string' || !path.isAbsolute(value.project) || typeof value.module !== 'string' || !path.isAbsolute(value.module)) throw fail(t('Trust record: absolute paths required.'));
|
|
38
40
|
if (!ID.test(value.id || '') || !SHA.test(value.code_fingerprint || '') || !SHA.test(value.config_sha256 || '')) throw fail(t('Trust record: invalid id or fingerprints.'));
|
|
39
41
|
if (!Array.isArray(value.files) || value.files.length > 256 || value.files.some(file => !file || typeof file.path !== 'string' || !(file.sha256 === null || SHA.test(file.sha256)))) throw fail(t('Trust record: invalid files.'));
|
|
40
42
|
if (typeof value.trusted_at !== 'string') throw fail(t('Trust record: invalid date.'));
|
|
41
|
-
|
|
43
|
+
const pkg = value.package;
|
|
44
|
+
if (pkg !== undefined && (!pkg || typeof pkg.name !== 'string' || pkg.name.length > 214 || typeof pkg.version !== 'string' || pkg.version.length > 64 || !SHA.test(pkg.integrity || '') || Object.keys(pkg).some(key => !['name', 'version', 'integrity'].includes(key)))) throw fail(t('Trust record: invalid package.'));
|
|
45
|
+
return { project: value.project, id: value.id, module: value.module, code_fingerprint: value.code_fingerprint, config_sha256: value.config_sha256, files: value.files.map(file => ({ path: file.path, sha256: file.sha256 })), trusted_at: value.trusted_at, ...(pkg ? { package: { name: pkg.name, version: pkg.version, integrity: pkg.integrity } } : {}) };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* Juzga una entrada configurada contra el registro. Sólo hay confianza vigente
|
|
46
50
|
* si coinciden proyecto, id, módulo real, huella del código y digest de config.
|
|
51
|
+
* Plugins incluidos en un paquete con manifiesto (Comal instalado): la confianza es del
|
|
52
|
+
* PAQUETE (nombre, versión e integridad), así que vuelve a preguntarse una vez por versión
|
|
53
|
+
* y no depende de la ruta donde se instaló; el contenido sigue siendo el mismo porque la
|
|
54
|
+
* integridad es el digest de los archivos. Sin manifiesto (copia de desarrollo) vale la huella.
|
|
47
55
|
*/
|
|
48
56
|
// Los motivos van también en el estado que lee el modelo (PluginStatus): inglés fijo, sin t().
|
|
49
57
|
export function trustVerdict(saved, { project, entry, code }) {
|
|
50
58
|
if (!saved) return { status: 'untrusted', reasons: ['no record'] };
|
|
51
|
-
const reasons = [];
|
|
52
|
-
if (saved.module !== code.module) reasons.push('module path');
|
|
53
|
-
if (saved.
|
|
59
|
+
const reasons = [], pkg = samePackage(saved.package, code.package);
|
|
60
|
+
if (saved.module !== code.module && !pkg) reasons.push('module path');
|
|
61
|
+
if (saved.package && code.package && !pkg) reasons.push(`Comal package (${saved.package.version} → ${code.package.version})`);
|
|
62
|
+
else if (saved.code_fingerprint !== code.code_fingerprint && !pkg) {
|
|
54
63
|
const before = new Map(saved.files.map(file => [file.path, file.sha256])), changed = code.files.filter(file => before.get(file.path) !== file.sha256).map(file => file.path);
|
|
55
64
|
const removed = saved.files.filter(file => !code.files.some(next => next.path === file.path)).map(file => file.path);
|
|
56
65
|
reasons.push(`code fingerprint (${[...changed, ...removed].slice(0, 6).join(', ') || 'files'})`);
|
|
@@ -85,7 +94,7 @@ export class PluginTrustStore {
|
|
|
85
94
|
}
|
|
86
95
|
async trust({ project, entry, code }) {
|
|
87
96
|
await this.#ready();
|
|
88
|
-
const next = record({ project, id: entry.id, module: code.module, code_fingerprint: code.code_fingerprint, config_sha256: configDigest(entry.config), files: code.files, trusted_at: new Date().toISOString() });
|
|
97
|
+
const next = record({ project, id: entry.id, module: code.module, code_fingerprint: code.code_fingerprint, config_sha256: configDigest(entry.config), files: code.files, trusted_at: new Date().toISOString(), ...(code.package ? { package: code.package } : {}) });
|
|
89
98
|
await this.#save([...this.#records.filter(item => !(item.project === project && item.id === entry.id)), next]);
|
|
90
99
|
return structuredClone(next);
|
|
91
100
|
}
|
|
@@ -22,14 +22,14 @@ export async function remoteBridge(config, lines) {
|
|
|
22
22
|
if (!valid) throw new Error('Invalid private attach diagnostic arguments.');
|
|
23
23
|
return option;
|
|
24
24
|
};
|
|
25
|
-
const cdp = (method, params = {}, sessionId, diagnostic) => new Promise((resolve, reject) => {
|
|
25
|
+
const cdp = (method, params = {}, sessionId, diagnostic, replyOwner) => new Promise((resolve, reject) => {
|
|
26
26
|
const id = ++sequence, started = performance.now();
|
|
27
27
|
const outcome = (state,reason,error) => ({diagnostic:{version:1,method,session_id:sessionId,state,reason,remote_dispatched:true,timeout_ms:diagnostic.timeout_ms,elapsed_ms:performance.now()-started,...(error?{error}: {})}});
|
|
28
28
|
const timer = setTimeout(() => {
|
|
29
29
|
pending.delete(id);
|
|
30
30
|
diagnostic ? resolve(outcome('unknown','command_timeout')) : reject(new Error(`CDP timeout: ${method}; no se reintentó.`));
|
|
31
31
|
}, diagnostic?.timeout_ms ?? config.timeoutMs);
|
|
32
|
-
pending.set(id, { resolve, reject, timer, method, params, diagnostic, outcome });
|
|
32
|
+
pending.set(id, { resolve, reject, timer, method, params, sessionId, diagnostic, outcome, replyOwner });
|
|
33
33
|
try { socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
|
|
34
34
|
catch(error) { pending.delete(id); clearTimeout(timer); reject(error); }
|
|
35
35
|
});
|
|
@@ -60,6 +60,19 @@ export async function remoteBridge(config, lines) {
|
|
|
60
60
|
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', () => reject(new Error('No se pudo abrir WebSocket CDP.')), { once: true }); });
|
|
61
61
|
socket.addEventListener('message', event => {
|
|
62
62
|
const message = JSON.parse(event.data);
|
|
63
|
+
const matched = pending.get(message.id);
|
|
64
|
+
// Only the actual matched browser response can populate this lexical owner.
|
|
65
|
+
// Caller-shaped Errors, optional diagnostics and internal bridge commands
|
|
66
|
+
// never supply native reply provenance. Keep the untruncated raw response.
|
|
67
|
+
if (matched?.replyOwner && ['Page.bringToFront','DOM.focus'].includes(matched.method) &&
|
|
68
|
+
typeof event.data === 'string' && Buffer.byteLength(event.data) <= 8192) {
|
|
69
|
+
matched.replyOwner.nativeReply = {format:'comal-cdp-native-reply/1',
|
|
70
|
+
outer_request_id:matched.replyOwner.outerId, cdp_request_id:message.id,
|
|
71
|
+
method:matched.method, session_id:matched.sessionId,
|
|
72
|
+
params_sha256:digest(JSON.stringify(matched.params)),
|
|
73
|
+
browser_endpoint_id:/^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null,
|
|
74
|
+
remote_dispatched:true, reply_json:event.data};
|
|
75
|
+
}
|
|
63
76
|
if (message.id) { const entry = pending.get(message.id); if (!entry) return; pending.delete(message.id); clearTimeout(entry.timer); if (!message.error) { if (entry.method === 'Target.attachToTarget' && message.result?.sessionId) { attached.add(message.result.sessionId); if(entry.params.flatten === true) flattened.add(message.result.sessionId); } if (entry.method === 'Target.detachFromTarget') { attached.delete(entry.params.sessionId); flattened.delete(entry.params.sessionId); } } if(entry.diagnostic) { if(message.error && (!Number.isInteger(message.error.code) || typeof message.error.message !== 'string')) entry.reject(new Error('Malformed diagnostic CDP error.')); else entry.resolve(message.error ? entry.outcome('rejected','command_error',{code:message.error.code,message:message.error.message.slice(0,1000)}) : entry.outcome('enabled','command_reply')); } else message.error ? entry.reject(Object.assign(new Error(`${message.error.code}: ${message.error.message}`), { cdpError: { method: entry.method, code: message.error.code, message: String(message.error.message).slice(0, 1000) } })) : entry.resolve(message.result); }
|
|
64
77
|
else if (['Runtime.consoleAPICalled', 'Runtime.exceptionThrown', 'Runtime.executionContextCreated', 'Runtime.executionContextDestroyed', 'Runtime.executionContextsCleared', 'Log.entryAdded', 'Network.loadingFailed', 'Inspector.detached', 'Target.detachedFromTarget'].includes(message.method)) {
|
|
65
78
|
if(message.method === 'Target.detachedFromTarget') { attached.delete(message.params?.sessionId); flattened.delete(message.params?.sessionId); }
|
|
@@ -204,12 +217,12 @@ export async function remoteBridge(config, lines) {
|
|
|
204
217
|
return { url: `http://127.0.0.1:${server.address().port}/${file.split('/').map(encodeURIComponent).join('/')}`, lifetime: 'plugin_connection' };
|
|
205
218
|
};
|
|
206
219
|
lines.on('line', async line => {
|
|
207
|
-
let request;
|
|
220
|
+
let request, replyOwner;
|
|
208
221
|
try {
|
|
209
222
|
if (line.length > config.maxDeployBytes * 1.5 + 1000000) throw new Error('Mensaje demasiado grande.');
|
|
210
223
|
request = JSON.parse(line); let result;
|
|
211
224
|
const diagnostic = Object.hasOwn(request,'attachDiagnostic') ? diagnosticPolicy(request) : undefined;
|
|
212
|
-
if (request.action === 'cdp') result = await cdp(request.method, request.params, request.sessionId, diagnostic);
|
|
225
|
+
if (request.action === 'cdp') { replyOwner={outerId:request.id};result = await cdp(request.method, request.params, request.sessionId, diagnostic, replyOwner); }
|
|
213
226
|
else if (request.action === 'status') result = await ownExtensions();
|
|
214
227
|
else if (request.action === 'checkTarget') result = await checkTarget(request.params.targetId);
|
|
215
228
|
else if (request.action === 'open') result = await cdp('Target.createTarget', { url: await checkURL(request.params.url) });
|
|
@@ -223,10 +236,10 @@ export async function remoteBridge(config, lines) {
|
|
|
223
236
|
else if (request.action === 'preview') result = await preview(request.params);
|
|
224
237
|
else if (request.action === 'unload') { const extension = await extensionFor(request.params.project); await cdp('Extensions.uninstall', { id: extension.id }); result = { extension_id: extension.id, unloaded: true }; }
|
|
225
238
|
else throw new Error('Acción del bridge desconocida.');
|
|
226
|
-
const output = JSON.stringify({ id: request.id, result });
|
|
239
|
+
const output = JSON.stringify({ id: request.id, result, ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) });
|
|
227
240
|
if (output.length > 12000000) throw new Error('Respuesta excesiva; usa consultas acotadas.');
|
|
228
241
|
process.stdout.write(output + '\n');
|
|
229
|
-
} catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}) }); }
|
|
242
|
+
} catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}), ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) }); }
|
|
230
243
|
});
|
|
231
244
|
send({ ready: { browser: version.Browser, browser_endpoint_id: /^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null, protocol_version: version['Protocol-Version'], extensions: protocol.domains.find(domain => domain.domain === 'Extensions')?.commands.map(command => command.name) || [], remote_root: root, node: process.version } });
|
|
232
245
|
}
|