@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.
Files changed (134) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/governance/chrome-task-v10.json +323 -0
  4. package/governance/chrome-task-v11.json +355 -0
  5. package/governance/chrome-task-v12.json +387 -0
  6. package/governance/chrome-task-v13.json +419 -0
  7. package/governance/chrome-task-v3.json +99 -0
  8. package/governance/chrome-task-v4.json +131 -0
  9. package/governance/chrome-task-v5.json +163 -0
  10. package/governance/chrome-task-v6.json +195 -0
  11. package/governance/chrome-task-v7.json +227 -0
  12. package/governance/chrome-task-v8.json +259 -0
  13. package/governance/chrome-task-v9.json +291 -0
  14. package/governance/source-task-v1.json +1 -0
  15. package/governance/source-task-v2.json +1 -0
  16. package/package.json +14 -1
  17. package/src/agent-recovery-proof.mjs +12 -1
  18. package/src/agent.mjs +94 -30
  19. package/src/audit.mjs +21 -6
  20. package/src/auto-guard.mjs +48 -1
  21. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  22. package/src/chrome-recovery-proof.mjs +6 -2
  23. package/src/chrome-source-syntax.mjs +93 -0
  24. package/src/cli.mjs +158 -54
  25. package/src/codex-provider.mjs +3 -2
  26. package/src/connection-settings.mjs +204 -0
  27. package/src/context-pressure.mjs +2 -1
  28. package/src/durable-agent-runs.mjs +5 -11
  29. package/src/event-channels.mjs +8 -0
  30. package/src/file-recovery-proof.mjs +11 -3
  31. package/src/file-recovery.mjs +24 -0
  32. package/src/gemini-provider.mjs +6 -1
  33. package/src/git-agent-revision.mjs +12 -0
  34. package/src/git-agent-workspaces.mjs +143 -0
  35. package/src/git-integration-lineage.mjs +20 -0
  36. package/src/git-integration-operations.mjs +200 -0
  37. package/src/git-integration-recovery-proof.mjs +70 -0
  38. package/src/git-page-cli-bridge.mjs +61 -0
  39. package/src/git-page-evidence.mjs +30 -0
  40. package/src/git-page-identity.mjs +10 -0
  41. package/src/git-page-proof.mjs +96 -0
  42. package/src/git-page-settlement.mjs +21 -0
  43. package/src/git-page-verification.mjs +134 -0
  44. package/src/git-plumbing.mjs +212 -0
  45. package/src/git-process-worker.mjs +50 -0
  46. package/src/git-source.mjs +110 -0
  47. package/src/git-workspaces.mjs +519 -0
  48. package/src/goals.mjs +13 -7
  49. package/src/house.mjs +246 -20
  50. package/src/i18n/cli-es.mjs +4 -0
  51. package/src/i18n/es.mjs +35 -0
  52. package/src/judges.mjs +27 -11
  53. package/src/mcp-runtime.mjs +15 -3
  54. package/src/model-controls.mjs +11 -1
  55. package/src/model-deadline.mjs +4 -3
  56. package/src/native-refusal.mjs +38 -0
  57. package/src/onboarding.mjs +88 -24
  58. package/src/openai-provider.mjs +14 -7
  59. package/src/operations.mjs +9 -2
  60. package/src/output-recovery.mjs +48 -0
  61. package/src/peer-tools.mjs +18 -8
  62. package/src/permission-controls.mjs +232 -0
  63. package/src/permission-rules.mjs +312 -0
  64. package/src/permissions.mjs +113 -30
  65. package/src/plugin-catalog.mjs +84 -0
  66. package/src/plugin-config.mjs +48 -6
  67. package/src/plugin-controls.mjs +332 -0
  68. package/src/plugin-notices.mjs +88 -0
  69. package/src/plugin-trust.mjs +104 -0
  70. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  71. package/src/plugins/chrome-lab/client.mjs +103 -25
  72. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  73. package/src/plugins/chrome-lab/index.mjs +116 -26
  74. package/src/plugins/chrome-lab/project.mjs +30 -2
  75. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  76. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  77. package/src/plugins/open-relay/actions.mjs +205 -0
  78. package/src/plugins/open-relay/bound.mjs +47 -0
  79. package/src/plugins/open-relay/client.mjs +158 -0
  80. package/src/plugins/open-relay/dossier.mjs +119 -0
  81. package/src/plugins/open-relay/index.mjs +153 -0
  82. package/src/plugins/open-relay/lookup.mjs +45 -0
  83. package/src/plugins/open-relay/notices.mjs +79 -0
  84. package/src/plugins/open-relay/watch.mjs +127 -0
  85. package/src/plugins.mjs +72 -29
  86. package/src/private-fs.mjs +40 -0
  87. package/src/process-lock.mjs +24 -13
  88. package/src/provider-auth.mjs +14 -3
  89. package/src/provider.mjs +11 -2
  90. package/src/read-only-check.mjs +7 -3
  91. package/src/scope-ceiling.mjs +56 -0
  92. package/src/scoped-policy.mjs +39 -0
  93. package/src/secret-login.mjs +68 -0
  94. package/src/session-bus.mjs +41 -7
  95. package/src/session-projections-candidate.mjs +119 -0
  96. package/src/session-projections.mjs +119 -0
  97. package/src/session-read-cache.mjs +23 -3
  98. package/src/sessions.mjs +35 -11
  99. package/src/source-editor-role.mjs +13 -0
  100. package/src/source-ownership.mjs +73 -0
  101. package/src/source-proposal-proof.mjs +55 -0
  102. package/src/source-proposals.mjs +118 -0
  103. package/src/source-requests.mjs +148 -0
  104. package/src/source-task-proof.mjs +24 -0
  105. package/src/source-task-revision-proof.mjs +44 -0
  106. package/src/subagents.mjs +187 -87
  107. package/src/tool-packs.mjs +5 -5
  108. package/src/tools.mjs +82 -15
  109. package/src/trusted-context.mjs +5 -1
  110. package/src/tui/connection-transition.mjs +1 -1
  111. package/src/tui/controller.mjs +8 -4
  112. package/src/tui/discovery.mjs +24 -12
  113. package/src/tui/inbound-blocks.mjs +55 -0
  114. package/src/tui/present.mjs +504 -0
  115. package/src/tui/screen.mjs +49 -13
  116. package/src/windows-private.ps1 +61 -0
  117. package/src/work-assertions.mjs +6 -2
  118. package/src/work-plan.mjs +34 -8
  119. package/src/work-state.mjs +15 -9
  120. package/src/workflows/chrome-page-cases.mjs +156 -0
  121. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  122. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  123. package/src/workflows/chrome-page-law.mjs +48 -0
  124. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  125. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  126. package/src/workflows/chrome-page-task.mjs +429 -0
  127. package/src/workflows/chrome-page-verifier.mjs +123 -0
  128. package/src/workflows/chrome-source.mjs +73 -0
  129. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  130. package/src/workflows/chrome-task-adoption.mjs +122 -0
  131. package/src/workflows/chrome-task.mjs +379 -74
  132. package/src/workflows/source-task-v1.mjs +124 -0
  133. package/src/workflows/source-task-v2.mjs +227 -0
  134. package/src/workflows/source-task.mjs +13 -0
@@ -7,11 +7,31 @@ export async function remoteBridge(config, lines) {
7
7
  if (!path.isAbsolute(config.remoteRoot) || root === path.parse(root).root) throw new Error('remoteRoot debe ser un directorio de laboratorio absoluto.');
8
8
  const send = value => process.stdout.write(JSON.stringify(value) + '\n');
9
9
  const digest = data => createHash('sha256').update(data).digest('hex');
10
- let socket, sequence = 0, closing = false; const pending = new Map(), servers = new Map(), attached = new Set();
11
- const cdp = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
12
- const id = ++sequence, timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}; no se reintentó.`)); }, config.timeoutMs);
13
- pending.set(id, { resolve, reject, timer, method, params });
14
- socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
10
+ let socket, sequence = 0, closing = false; const pending = new Map(), servers = new Map(), attached = new Set(), flattened = new Set();
11
+ // Only this private, validated optional path gets the smaller response-wait timer.
12
+ const diagnosticPolicy = request => {
13
+ const option = request.attachDiagnostic;
14
+ if (!option || typeof option !== 'object' || Array.isArray(option) || Object.keys(option).join() !== 'timeout_ms' ||
15
+ request.action !== 'cdp' || !['Log.enable','Network.enable'].includes(request.method) ||
16
+ typeof request.sessionId !== 'string' || !request.sessionId || !flattened.has(request.sessionId) ||
17
+ !Number.isInteger(option.timeout_ms) || option.timeout_ms < 1 || option.timeout_ms > Math.min(config.timeoutMs,2000)) throw new Error('Invalid private attach diagnostic policy.');
18
+ const params = request.params;
19
+ const valid = params && typeof params === 'object' && !Array.isArray(params) && (request.method === 'Log.enable'
20
+ ? Object.keys(params).length === 0
21
+ : Object.keys(params).sort().join() === 'maxResourceBufferSize,maxTotalBufferSize' && params.maxTotalBufferSize === 100000 && params.maxResourceBufferSize === 10000);
22
+ if (!valid) throw new Error('Invalid private attach diagnostic arguments.');
23
+ return option;
24
+ };
25
+ const cdp = (method, params = {}, sessionId, diagnostic) => new Promise((resolve, reject) => {
26
+ const id = ++sequence, started = performance.now();
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
+ const timer = setTimeout(() => {
29
+ pending.delete(id);
30
+ diagnostic ? resolve(outcome('unknown','command_timeout')) : reject(new Error(`CDP timeout: ${method}; no se reintentó.`));
31
+ }, diagnostic?.timeout_ms ?? config.timeoutMs);
32
+ pending.set(id, { resolve, reject, timer, method, params, diagnostic, outcome });
33
+ try { socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
34
+ catch(error) { pending.delete(id); clearTimeout(timer); reject(error); }
15
35
  });
16
36
  const shutdown = async (detach = true) => {
17
37
  if (closing) return; closing = true;
@@ -40,8 +60,10 @@ export async function remoteBridge(config, lines) {
40
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 }); });
41
61
  socket.addEventListener('message', event => {
42
62
  const message = JSON.parse(event.data);
43
- 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.method === 'Target.detachFromTarget') attached.delete(entry.params.sessionId); } message.error ? entry.reject(new Error(`${message.error.code}: ${message.error.message}`)) : entry.resolve(message.result); }
63
+ 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); }
44
64
  else if (['Runtime.consoleAPICalled', 'Runtime.exceptionThrown', 'Runtime.executionContextCreated', 'Runtime.executionContextDestroyed', 'Runtime.executionContextsCleared', 'Log.entryAdded', 'Network.loadingFailed', 'Inspector.detached', 'Target.detachedFromTarget'].includes(message.method)) {
65
+ if(message.method === 'Target.detachedFromTarget') { attached.delete(message.params?.sessionId); flattened.delete(message.params?.sessionId); }
66
+ if(message.method === 'Inspector.detached') { attached.delete(message.sessionId); flattened.delete(message.sessionId); }
45
67
  // Never ship arbitrary object previews or network bodies into the resident's context.
46
68
  if (message.method === 'Runtime.consoleAPICalled') message.params.args = (message.params.args || []).slice(0, 12).map(arg => ({ type: arg.type, text: String(arg.value ?? arg.description ?? arg.type).slice(0, 2000) }));
47
69
  if (JSON.stringify(message).length < 64000) send({ event: message });
@@ -88,7 +110,7 @@ export async function remoteBridge(config, lines) {
88
110
  await checkURL(targetInfo.url); return targetInfo;
89
111
  };
90
112
  const extensionFor = async name => { const dir = await owned(name); const expected = path.join(dir, 'current').toLowerCase(); const extension = (await extensions()).find(item => path.resolve(item.path).toLowerCase() === expected); if (!extension) throw new Error('Extensión del proyecto no cargada. Usa Deploy.'); return extension; };
91
- const deploy = async ({ project, files, attempt_id = randomUUID() }) => {
113
+ const deploy = async ({ project, files, attempt_id = randomUUID(), create_only=false }) => {
92
114
  if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(attempt_id)) throw new Error('Identidad de publicación inválida.');
93
115
  slug(project); if (!Array.isArray(files) || !files.length || files.length > 1000) throw new Error('Límite de archivos de despliegue.');
94
116
  let bytes = 0; const names = new Set();
@@ -98,7 +120,7 @@ export async function remoteBridge(config, lines) {
98
120
  if (manifest?.manifest_version !== 3 || !manifest.name || !manifest.version) throw new Error('Se requiere manifest.json MV3 válido.');
99
121
  await fs.mkdir(root, { recursive: true }); await realDirectory(root);
100
122
  const dir = projectDir(project);
101
- try { await fs.mkdir(dir); await fs.writeFile(path.join(dir, '.comal-lab.json'), JSON.stringify({ owner: 'comal-chrome-lab-v1', project }), { flag: 'wx' }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
123
+ try { await fs.mkdir(dir); await fs.writeFile(path.join(dir, '.comal-lab.json'), JSON.stringify({ owner: 'comal-chrome-lab-v1', project }), { flag: 'wx' }); } catch (error) { if (error.code !== 'EEXIST') throw error; if(create_only)throw new Error(JSON.stringify({code:'CHROME_PROJECT_EXISTS',executed:false,project,reason:'Atomic creation requires a new project directory; no source changed and no Chrome load dispatched.'})); }
102
124
  await owned(project);
103
125
  const lock = path.join(dir, '.deploy-lock'); await fs.mkdir(lock); // Fail closed if another Comal is deploying.
104
126
  const stage = path.join(dir, `stage-${randomUUID()}`), current = path.join(dir, 'current'), backup = path.join(dir, `backup-${randomUUID()}`);
@@ -138,7 +160,7 @@ export async function remoteBridge(config, lines) {
138
160
  const artifact = JSON.parse(raw);
139
161
  if (artifact.project !== project || artifact.remote_path?.toLowerCase() !== current.toLowerCase() || !Array.isArray(artifact.files) || !artifact.files.length || artifact.files.length > 1000) throw new Error('Recibo de otro proyecto o snapshot inválido.');
140
162
  await realDirectory(current);
141
- const observedFiles = []; let bytes = 0;
163
+ const observedFiles = []; let bytes = 0, manifestBytes;
142
164
  const visit = async (directory, prefix = '') => {
143
165
  await realDirectory(directory);
144
166
  for (const entry of (await fs.readdir(directory, { withFileTypes: true })).sort((a,b)=>a.name.localeCompare(b.name))) {
@@ -149,13 +171,16 @@ export async function remoteBridge(config, lines) {
149
171
  const data = await fs.readFile(absolute), after = await fs.lstat(absolute);
150
172
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino || data.length !== before.size) throw new Error('Snapshot remoto cambió durante la lectura.');
151
173
  observedFiles.push({ path: relative, sha256: digest(data) });
174
+ if (relative === 'manifest.json') manifestBytes = data;
152
175
  }
153
176
  };
154
177
  await visit(current);
155
178
  const extension = (await extensions()).find(item=>item.id===artifact.extension_id && path.resolve(item.path).toLowerCase()===current.toLowerCase());
156
179
  if (await locked() || await readReceipt() !== raw) return { status: 'busy', project };
157
180
  if (!extension?.enabled || JSON.stringify(observedFiles) !== JSON.stringify(artifact.files)) return { status: 'conflict', project, attempt_id: artifact.attempt_id || null, reason: 'Loaded extension or physical snapshot no longer matches the receipt.' };
158
- return { ...artifact, status: 'observed', files: artifact.files.slice(0,30), file_count: artifact.files.length, files_truncated: artifact.files.length>30, file_manifest_sha256: digest(JSON.stringify(artifact.files)), file_manifest_remote_path: receipt, checked_at: new Date().toISOString() };
181
+ const workerScript = manifestBytes && JSON.parse(manifestBytes).background?.service_worker;
182
+ if (workerScript !== undefined) safeFile(workerScript);
183
+ return { ...artifact, status: 'observed', worker_script: typeof workerScript === 'string' ? workerScript : null, files: artifact.files.slice(0,30), file_count: artifact.files.length, files_truncated: artifact.files.length>30, file_manifest_sha256: digest(JSON.stringify(artifact.files)), file_manifest_remote_path: receipt, checked_at: new Date().toISOString() };
159
184
  };
160
185
  const preview = async ({ project, file = 'fixture.html' }) => {
161
186
  const dir = path.join(await owned(project), 'current'); safeFile(file);
@@ -183,7 +208,8 @@ export async function remoteBridge(config, lines) {
183
208
  try {
184
209
  if (line.length > config.maxDeployBytes * 1.5 + 1000000) throw new Error('Mensaje demasiado grande.');
185
210
  request = JSON.parse(line); let result;
186
- if (request.action === 'cdp') result = await cdp(request.method, request.params, request.sessionId);
211
+ const diagnostic = Object.hasOwn(request,'attachDiagnostic') ? diagnosticPolicy(request) : undefined;
212
+ if (request.action === 'cdp') result = await cdp(request.method, request.params, request.sessionId, diagnostic);
187
213
  else if (request.action === 'status') result = await ownExtensions();
188
214
  else if (request.action === 'checkTarget') result = await checkTarget(request.params.targetId);
189
215
  else if (request.action === 'open') result = await cdp('Target.createTarget', { url: await checkURL(request.params.url) });
@@ -200,7 +226,7 @@ export async function remoteBridge(config, lines) {
200
226
  const output = JSON.stringify({ id: request.id, result });
201
227
  if (output.length > 12000000) throw new Error('Respuesta excesiva; usa consultas acotadas.');
202
228
  process.stdout.write(output + '\n');
203
- } catch (error) { send({ id: request?.id, error: error.message }); }
229
+ } catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}) }); }
204
230
  });
205
231
  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 } });
206
232
  }
@@ -2,10 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { remoteBridge } from './bridge.mjs';
5
+ import { OPTIONAL_PHASE_MS, PUBLIC_DIAGNOSTIC_BYTES, diagnosticEnvelope } from './diagnostics.mjs';
5
6
 
6
7
  export class ChromeLabClient {
7
8
  constructor(config, { spawnProcess = spawn } = {}) {
8
- this.config = config; this.spawnProcess = spawnProcess; this.pending = new Map(); this.sessions = new Map(); this.attaching = new Map(); this.targetInfo = new Map(); this.contexts = new Map(); this.events = []; this.sequence = 0; this.closed = false;
9
+ this.config = config; this.spawnProcess = spawnProcess; this.pending = new Map(); this.sessions = new Map(); this.attaching = new Map(); this.targetInfo = new Map(); this.contexts = new Map(); this.collections = new Map(); this.events = []; this.sequence = 0; this.closed = false; this.attachDiagnostics = new Map();
9
10
  }
10
11
  async connect(signal) {
11
12
  signal?.throwIfAborted(); if (this.closed) throw new Error('Plugin Chrome Lab cerrado.');
@@ -25,10 +26,10 @@ export class ChromeLabClient {
25
26
  const bootstrap = "const r=require('readline').createInterface({input:process.stdin});r.once('line',l=>{const p=JSON.parse(l);eval(p.source)(p.config,r).catch(e=>{console.log(JSON.stringify({fatal:e.message}));process.exit(1)})})";
26
27
  const child = this.child = this.spawnProcess('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ClearAllForwardings=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', this.config.sshHost, `node -e "${bootstrap}"`], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
27
28
  const timer = setTimeout(() => { fail(new Error('Timeout iniciando Chrome Lab por SSH.')); child.kill(); }, this.config.timeoutMs);
28
- const fail = error => { clearTimeout(timer); if (this.child !== child) return; this.failure = error; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
29
+ const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
29
30
  child.on('error', fail); child.stdin.on('error', fail);
30
31
  child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-1500); });
31
- child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}`)));
32
+ child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}`),'connection_lost'));
32
33
  const lines = createInterface({ input: child.stdout });
33
34
  lines.on('line', line => {
34
35
  if (this.child !== child) return;
@@ -38,7 +39,7 @@ export class ChromeLabClient {
38
39
  if (message.fatal) { fail(new Error(message.fatal)); child.kill(); }
39
40
  else if (message.ready) { ready = true; clearTimeout(timer); this.info = message.ready; resolve(message.ready); }
40
41
  else if (message.event) this.observe(message.event);
41
- else { const entry = this.pending.get(message.id); if (entry) entry.finish(message.error ? new Error(message.error) : null, message.result); }
42
+ else { const entry = this.pending.get(message.id); if (entry) { const error = message.error ? new Error(message.error) : null; if(error && entry.action==='cdp' && message.cdp_error?.method===entry.method && Number.isInteger(message.cdp_error.code) && typeof message.cdp_error.message==='string')error.cdpError={method:entry.method,code:message.cdp_error.code,message:message.cdp_error.message.slice(0,1000)};let result=message.result; if(!error && entry.attachDiagnostic) diagnosticEnvelope(result,{method:entry.method,sessionId:entry.sessionId,timeout_ms:entry.attachDiagnostic.timeout_ms}); entry.finish(error,result); } }
42
43
  } catch (error) { fail(error); child.kill(); }
43
44
  });
44
45
  child.stdin.write(JSON.stringify({ source: `(${remoteBridge.toString()})`, config: this.config }) + '\n');
@@ -46,35 +47,71 @@ export class ChromeLabClient {
46
47
  }
47
48
  observe(event) {
48
49
  const { method, params = {}, sessionId } = event;
49
- if (method === 'Target.detachedFromTarget') { for (const [target, session] of this.sessions) if (session === params.sessionId) this.sessions.delete(target); this.contexts.delete(params.sessionId); }
50
- if (method === 'Inspector.detached') { for (const [target, session] of this.sessions) if (session === sessionId) this.sessions.delete(target); this.contexts.delete(sessionId); }
51
- if (method === 'Runtime.executionContextCreated') { const contexts = this.contexts.get(sessionId) || new Map(); contexts.set(params.context.id, params.context); this.contexts.set(sessionId, contexts); return; }
52
- if (method === 'Runtime.executionContextDestroyed') { this.contexts.get(sessionId)?.delete(params.executionContextId); return; }
53
- if (method === 'Runtime.executionContextsCleared') { this.contexts.delete(sessionId); return; }
50
+ if (method === 'Target.detachedFromTarget') { this.retireDiagnostic(params.sessionId); for (const [target, session] of this.sessions) if (session === params.sessionId) this.sessions.delete(target); this.contexts.delete(params.sessionId); }
51
+ if (method === 'Inspector.detached') { this.retireDiagnostic(sessionId); for (const [target, session] of this.sessions) if (session === sessionId) this.sessions.delete(target); this.contexts.delete(sessionId); }
52
+ if (method === 'Runtime.executionContextCreated') {
53
+ const contexts = this.contexts.get(sessionId) || new Map(), collection = this.collection(sessionId), context = params.context;
54
+ collection.epoch++;
55
+ const seen = collection.seen.get(context.id);
56
+ if (collection.seen.has(context.id) && seen !== context.uniqueId) collection.reused.add(context.id);
57
+ if (collection.seen.size < 512) collection.seen.set(context.id, context.uniqueId); else collection.ambiguous = true;
58
+ contexts.set(context.id, context); this.contexts.set(sessionId, contexts); return;
59
+ }
60
+ if (method === 'Runtime.executionContextDestroyed') {
61
+ const contexts = this.contexts.get(sessionId);
62
+ // A delayed destruction for an old unique context must not remove a reused numeric ID.
63
+ if (typeof params.executionContextUniqueId === 'string') { for (const [id, context] of contexts || []) if (context.uniqueId === params.executionContextUniqueId) contexts.delete(id); }
64
+ else contexts?.delete(params.executionContextId);
65
+ this.collection(sessionId).epoch++; return;
66
+ }
67
+ if (method === 'Runtime.executionContextsCleared') { this.contexts.delete(sessionId); this.collection(sessionId).epoch++; return; }
54
68
  const target_id = [...this.sessions].find(([, session]) => session === sessionId)?.[0];
55
69
  const raw = JSON.stringify(params);
56
- this.events.push({ method, target_id, observed_at: new Date().toISOString(), details: raw.length > 4000 ? { truncated: true, preview: raw.slice(0, 4000) } : params });
70
+ const contextId = method === 'Runtime.consoleAPICalled' ? params.executionContextId : method === 'Runtime.exceptionThrown' ? params.exceptionDetails?.executionContextId : undefined;
71
+ const collection = this.collections.get(sessionId), context = this.contexts.get(sessionId)?.get(contextId);
72
+ const unique = context && !collection?.ambiguous && !collection?.reused.has(contextId) ? context.uniqueId : null;
73
+ this.events.push({ method, target_id, observed_at: new Date().toISOString(), binding: { connection_id: this.connectionId || null, browser_endpoint_id: this.info?.browser_endpoint_id || null, session_id: sessionId || null, context_unique_id: unique || null, context_epoch: collection?.epoch ?? null }, details: raw.length > 4000 ? { truncated: true, preview: raw.slice(0, 4000) } : params });
57
74
  if (this.events.length > 200) this.events.shift();
58
75
  }
59
- async request(action, params, { signal, ...extra } = {}) {
60
- await this.connect(signal); signal?.throwIfAborted(); if (this.failure) throw this.failure;
76
+ async request(action, params, { signal, expectedBinding, onDispatch, ...extra } = {}) {
77
+ // Optional original transport ownership, checked again after connect's async boundary.
78
+ // This protects a pinned caller from known local replacement before the write.
79
+ const checkBinding=()=>{if(expectedBinding&&(this.closed||this.failure||this.connectionId!==expectedBinding.connectionId||this.info?.browser_endpoint_id!==expectedBinding.browserEndpointId||extra.sessionId!==expectedBinding.sessionId||this.sessions.get(expectedBinding.targetId)!==expectedBinding.sessionId))throw Object.assign(new Error('Original CDP connection/session binding changed before dispatch.'),{code:'CHROME_CDP_BINDING_CHANGED',details:{remote_dispatched:false}});};
80
+ checkBinding();await this.connect(signal); signal?.throwIfAborted();checkBinding();if (this.failure) throw this.failure;
61
81
  return new Promise((resolve, reject) => {
62
82
  const id = ++this.sequence;
63
83
  const abort = () => finish(new Error('Operación abortada tras despacho; el efecto remoto puede haber ocurrido. Verifica antes de repetir.'));
64
84
  const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), this.config.timeoutMs + 2000);
65
85
  const finish = (error, value) => { clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id); error ? reject(error) : resolve(value); };
66
- this.pending.set(id, { finish }); signal?.addEventListener('abort', abort, { once: true });
86
+ this.pending.set(id, { finish, action, method: extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic }); signal?.addEventListener('abort', abort, { once: true });
87
+ try { onDispatch?.(); } catch(error) { finish(error); return; }
67
88
  this.child.stdin.write(JSON.stringify({ id, action, params, ...extra }) + '\n', error => { if (error) finish(error); });
68
89
  });
69
90
  }
70
- cdp(method, params = {}, { signal, sessionId } = {}) { return this.request('cdp', params, { signal, method, sessionId }); }
71
- resourceIdentity(targetId) {
91
+ cdp(method, params = {}, { signal, sessionId, expectedBinding, attachDiagnostic, onDispatch } = {}) { return this.request('cdp', params, { signal, method, sessionId, expectedBinding, onDispatch, ...(attachDiagnostic !== undefined ? {attachDiagnostic} : {}) }); }
92
+ resourceIdentity(targetId, { frameId, sessionId, connectionId } = {}) {
93
+ // A sole default context may belong to a child frame. Bind only to the
94
+ // root frame observed in the same attached session and connection.
95
+ const owned = typeof frameId === 'string' && frameId.length > 0 &&
96
+ typeof sessionId === 'string' && sessionId.length > 0 && sessionId === this.sessions.get(targetId) &&
97
+ typeof connectionId === 'string' && connectionId.length > 0 && connectionId === this.connectionId && !this.failure && !this.closed;
72
98
  const contexts = [...(this.contexts.get(this.sessions.get(targetId))?.values() || [])];
73
- const defaults = contexts.filter(item => item.auxData?.isDefault === true && typeof item.uniqueId === 'string' && item.uniqueId);
99
+ const defaults = owned ? contexts.filter(item => item.auxData?.isDefault === true && item.auxData.frameId === frameId && typeof item.uniqueId === 'string' && item.uniqueId) : [];
74
100
  return { connection_id: this.connectionId || null, browser_endpoint_id: this.info?.browser_endpoint_id || null,
75
101
  context_unique_id: defaults.length === 1 ? defaults[0].uniqueId : null,
76
102
  frame_id: defaults.length === 1 ? defaults[0].auxData.frameId || null : null };
77
103
  }
104
+ collection(sessionId) {
105
+ if (!this.collections.has(sessionId)) this.collections.set(sessionId, { started_at: new Date().toISOString(), epoch: 0, seen: new Map(), reused: new Set(), ambiguous: false });
106
+ return this.collections.get(sessionId);
107
+ }
108
+ workerIdentity(targetId) {
109
+ const target = this.targetInfo.get(targetId), session = this.sessions.get(targetId), contexts = [...(this.contexts.get(session)?.values() || [])];
110
+ if (this.failure || this.closed || target?.type !== 'service_worker' || typeof target.url !== 'string' || !target.url.startsWith('chrome-extension://') || contexts.length !== 1) return null;
111
+ const context = contexts[0], collection = this.collections.get(session);
112
+ if (!this.connectionId || !this.info?.browser_endpoint_id || !session || !collection || !Number.isInteger(context.id) || context.id < 1 || typeof context.uniqueId !== 'string' || !context.uniqueId || context.origin !== target.url) return null;
113
+ return { realm: 'extension_service_worker', target_id: targetId, url: target.url, connection_id: this.connectionId, browser_endpoint_id: this.info.browser_endpoint_id, session_id: session, context_unique_id: context.uniqueId, context_id: context.id, context_epoch: collection.epoch, collection_start: collection.started_at };
114
+ }
78
115
  // Explicit recovery only. A new connection does not replay any request or resolve its effect.
79
116
  async reconnect(signal) {
80
117
  signal?.throwIfAborted();
@@ -82,10 +119,49 @@ export class ChromeLabClient {
82
119
  this.reconnecting = true;
83
120
  try {
84
121
  await this.close(); this.child = undefined; this.connection = undefined; this.failure = undefined; this.info = undefined; this.closed = false;
85
- this.sessions.clear(); this.contexts.clear(); this.attaching.clear(); this.targetInfo.clear(); this.events = [];
122
+ this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.attaching.clear(); this.targetInfo.clear(); this.events = [];
86
123
  return await this.connect(signal);
87
124
  } finally { this.reconnecting = false; }
88
125
  }
126
+ retireDiagnostic(sessionId, reason = 'session_retired') {
127
+ for(const record of this.attachDiagnostics.values()) if(record.session_id === sessionId && !record.retired) {
128
+ record.retired = true;
129
+ for(const domain of record.domains) if(!domain.completed_at) Object.assign(domain,{state:domain.command_count?'unknown':'not_attempted',reason,completed_at:new Date().toISOString()});
130
+ }
131
+ }
132
+ diagnostics(targetId) {
133
+ const record=this.attachDiagnostics.get(targetId);
134
+ if(!record || record.retired || this.closed || this.failure || record.connection_id !== this.connectionId || record.browser_endpoint_id !== this.info?.browser_endpoint_id || this.sessions.get(targetId) !== record.session_id)return null;
135
+ const value=structuredClone(record);
136
+ return Buffer.byteLength(JSON.stringify(value))<=PUBLIC_DIAGNOSTIC_BYTES ? value : {version:1,state:'unavailable',reason:'details_omitted_due_to_limit',history_complete:false};
137
+ }
138
+ async optionalDiagnostics(targetId, sessionId, signal) {
139
+ const binding={targetId,sessionId,connectionId:this.connectionId,browserEndpointId:this.info?.browser_endpoint_id};
140
+ const record={version:1,policy:'awaited-optional-2000ms/1',connection_id:binding.connectionId,browser_endpoint_id:binding.browserEndpointId,target_id:targetId,session_id:sessionId,generation:randomUUID(),history_complete:false,runtime_collection:'since_attach',domains:['Log.enable','Network.enable'].map(method=>({method,state:'not_attempted',reason:'phase_budget_exhausted',command_count:0,timeout_ms:0,started_at:null,completed_at:null,elapsed_ms:0}))};
141
+ // One live record per target. A replacement generation cannot be updated by this closure.
142
+ this.attachDiagnostics.set(targetId,record);
143
+ const current=()=>!record.retired && this.attachDiagnostics.get(targetId)===record && !this.closed && !this.failure && this.connectionId===binding.connectionId && this.info?.browser_endpoint_id===binding.browserEndpointId && this.sessions.get(targetId)===sessionId;
144
+ const check=()=>{signal?.throwIfAborted();if(!current())throw Object.assign(new Error('Original attach diagnostic session retired.'),{code:'CHROME_CDP_BINDING_CHANGED'});};
145
+ const started=performance.now(), budget=Math.min(OPTIONAL_PHASE_MS,this.config.timeoutMs);
146
+ for(const domain of record.domains) {
147
+ check();const remaining=Math.floor(budget-(performance.now()-started));
148
+ if(remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
149
+ domain.timeout_ms=remaining;const begin=performance.now();
150
+ try {
151
+ const value=await this.cdp(domain.method,domain.method==='Log.enable'?{}:{maxTotalBufferSize:100000,maxResourceBufferSize:10000},{signal,sessionId,expectedBinding:binding,attachDiagnostic:{timeout_ms:remaining},onDispatch:()=>{check();if(performance.now()-started>=budget)throw Object.assign(new Error('Optional phase expired before dispatch.'),{code:'CHROME_DIAGNOSTIC_BUDGET'});domain.command_count=1;domain.started_at=new Date().toISOString();}});
152
+ check();const result=diagnosticEnvelope(value,{method:domain.method,sessionId,timeout_ms:remaining});
153
+ Object.assign(domain,{state:result.state,reason:result.reason,remote_dispatched:result.remote_dispatched,bridge_elapsed_ms:result.elapsed_ms,elapsed_ms:performance.now()-begin,completed_at:new Date().toISOString(),...(result.error?{error:result.error}: {})});
154
+ if(result.state==='enabled')domain.enabled_acknowledged_at=domain.completed_at;
155
+ } catch(error) {
156
+ if(error.code==='CHROME_DIAGNOSTIC_BUDGET' && domain.command_count===0) {Object.assign(domain,{reason:'phase_budget_exhausted',completed_at:new Date().toISOString(),elapsed_ms:performance.now()-begin});continue;}
157
+ // Only a validated command envelope is optional. Abort, retirement, protocol
158
+ // and transport failure propagate, including after an enable was dispatched.
159
+ if(this.attachDiagnostics.get(targetId)===record && !domain.completed_at) Object.assign(domain,{state:domain.command_count?'unknown':'not_attempted',reason:signal?.aborted?'operation_aborted':record.retired||error.code==='CHROME_CDP_BINDING_CHANGED'?'session_retired':'transport_uncertain',elapsed_ms:performance.now()-begin,completed_at:new Date().toISOString()});
160
+ throw error;
161
+ }
162
+ }
163
+ check();record.elapsed_ms=performance.now()-started;
164
+ }
89
165
  async attach(targetId, signal) {
90
166
  this.targetInfo.set(targetId, await this.request('checkTarget', { targetId }, { signal }));
91
167
  if (this.attaching.has(targetId)) return this.attaching.get(targetId);
@@ -93,22 +169,24 @@ export class ChromeLabClient {
93
169
  const attaching = (async () => {
94
170
  const { sessionId } = await this.cdp('Target.attachToTarget', { targetId, flatten: true }, { signal });
95
171
  this.sessions.set(targetId, sessionId);
172
+ this.collection(sessionId);
96
173
  try {
97
174
  await this.cdp('Runtime.enable', {}, { signal, sessionId });
98
- await this.cdp('Log.enable', {}, { signal, sessionId }).catch(() => {});
99
- await this.cdp('Network.enable', { maxTotalBufferSize: 100000, maxResourceBufferSize: 10000 }, { signal, sessionId }).catch(() => {});
175
+ await this.optionalDiagnostics(targetId,sessionId,signal);
100
176
  return sessionId;
101
- } catch (error) { this.sessions.delete(targetId); this.contexts.delete(sessionId); await this.cdp('Target.detachFromTarget', { sessionId }).catch(() => {}); throw error; }
177
+ } catch (error) { this.retireDiagnostic(sessionId); if(this.sessions.get(targetId)===sessionId)this.sessions.delete(targetId); this.contexts.delete(sessionId); await this.cdp('Target.detachFromTarget', { sessionId }).catch(() => {}); throw error; }
102
178
  })();
103
179
  this.attaching.set(targetId, attaching);
104
- try { return await attaching; } finally { this.attaching.delete(targetId); }
180
+ try { return await attaching; } finally { if(this.attaching.get(targetId)===attaching)this.attaching.delete(targetId); }
105
181
  }
106
- async evaluate(targetId, expression, { signal, context_id, await_promise = false } = {}) {
182
+ async evaluate(targetId, expression, { signal, context_id, unique_context_id, read_only = false, await_promise = false, extension_only = false } = {}) {
183
+ if (read_only && (!unique_context_id || await_promise || context_id !== undefined)) throw Object.assign(new Error('Read-only inspection requires an exact unique context and synchronous evaluation.'), { code: 'CHROME_INSPECTION_CONTEXT' });
107
184
  const sessionId = await this.attach(targetId, signal);
108
- const result = await this.cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: await_promise, timeout: Math.min(10000, this.config.timeoutMs), allowUnsafeEvalBlockedByCSP: false, ...(context_id !== undefined ? { contextId: context_id } : {}) }, { signal, sessionId });
185
+ if (extension_only && (this.targetInfo.get(targetId)?.type !== 'page' || !this.targetInfo.get(targetId)?.url.startsWith('chrome-extension://'))) throw Object.assign(new Error('Typed runtime messages require an owned extension page.'),{code:'CHROME_MESSAGE_TARGET'});
186
+ const result = await this.cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: await_promise, timeout: Math.min(10000, this.config.timeoutMs), allowUnsafeEvalBlockedByCSP: false, ...(context_id !== undefined ? { contextId: context_id } : {}), ...(unique_context_id ? { uniqueContextId: unique_context_id } : {}), ...(read_only ? { throwOnSideEffect: true, disableBreaks: true } : {}) }, { signal, sessionId });
109
187
  if (result.exceptionDetails) {
110
188
  const info = this.targetInfo.get(targetId);
111
- return { ok: false, error: (result.exceptionDetails.exception?.description || result.exceptionDetails.text).slice(0, 4000), ...(info ? { target: { target_id: targetId, type: info.type, url: info.url } } : {}), ...(/^https?:/.test(info?.url || '') && context_id === undefined ? { next_action: 'This is the HTTP page main world. chrome.storage/tabs extension APIs belong to the OWN extension target, or a suitable isolated context from Contexts. Use the exact target by project URL; await Promise APIs with await_promise:true.' } : {}) };
189
+ return { ok: false, error: (result.exceptionDetails.exception?.description || result.exceptionDetails.text).slice(0, 4000), ...(info ? { target: { target_id: targetId, type: info.type, url: info.url } } : {}), ...(read_only ? { next_action: 'Read-only inspection failed. Use a synchronous expression whose side effects V8 can rule out in the exact accepted document. Do not fall back to effectful evaluation or await a Promise.' } : /^https?:/.test(info?.url || '') && context_id === undefined ? { next_action: 'This is the HTTP page main world. chrome.storage/tabs extension APIs belong to the OWN extension target, or a suitable isolated context from Contexts. Use the exact target by project URL; await Promise APIs with await_promise:true.' } : {}) };
112
190
  }
113
191
  const value = result.result.value ?? result.result.unserializableValue ?? null;
114
192
  const serialized = Buffer.from(JSON.stringify(value));
@@ -116,7 +194,7 @@ export class ChromeLabClient {
116
194
  return { ok: true, value };
117
195
  }
118
196
  async close() {
119
- if (this.closed) return; this.closed = true;
197
+ if (this.closed) return; this.closed = true; for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
120
198
  if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return;
121
199
  await new Promise(resolve => { const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 1500); this.child.once('exit', () => { clearTimeout(timer); resolve(); }); this.child.stdin.end(); });
122
200
  }
@@ -0,0 +1,19 @@
1
+ // Builtin private attach protocol adopted from reviewed candidate03. No resident-controlled input or global health verdict.
2
+ export const OPTIONAL_PHASE_MS = 2000;
3
+ // UTF8 serialized JSON bytes. Oversize identity metadata becomes unavailable;
4
+ // identities are never truncated into a different, apparently exact binding.
5
+ export const PUBLIC_DIAGNOSTIC_BYTES = 16000;
6
+ export function diagnosticEnvelope(value, { method, sessionId, timeout_ms }) {
7
+ const fail = () => { throw Object.assign(new Error('Invalid attach diagnostic response envelope.'), { code: 'CHROME_DIAGNOSTIC_PROTOCOL' }); };
8
+ if (!value || Object.keys(value).length !== 1 || !value.diagnostic) return fail();
9
+ const d = value.diagnostic;
10
+ if (Object.keys(d).some(k => !['version','method','session_id','state','reason','remote_dispatched','timeout_ms','elapsed_ms','error'].includes(k)) ||
11
+ d.version !== 1 || d.method !== method || d.session_id !== sessionId || d.timeout_ms !== timeout_ms || d.remote_dispatched !== true ||
12
+ !Number.isFinite(d.elapsed_ms) || d.elapsed_ms < 0 ||
13
+ !['enabled','rejected','unknown'].includes(d.state) ||
14
+ !({enabled:'command_reply',rejected:'command_error',unknown:'command_timeout'}[d.state] === d.reason)) return fail();
15
+ if (d.state === 'rejected') {
16
+ if (!d.error || Object.keys(d.error).sort().join() !== 'code,message' || !Number.isInteger(d.error.code) || typeof d.error.message !== 'string' || d.error.message.length > 1000) return fail();
17
+ } else if (d.error !== undefined) return fail();
18
+ return structuredClone(d);
19
+ }