@milpa/comal 0.2.2 → 0.2.3

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 (109) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +213 -55
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +38 -1
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -1,15 +1,135 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { performance } from 'node:perf_hooks';
5
+ import {AsyncLocalStorage} from 'node:async_hooks';
4
6
  import { remoteBridge } from './bridge.mjs';
5
7
  import { OPTIONAL_PHASE_MS, PUBLIC_DIAGNOSTIC_BYTES, diagnosticEnvelope } from './diagnostics.mjs';
8
+ import { parseNativeReply } from './native-reply.mjs';
9
+ import {legacyFill,takeLegacyFillOrigin} from './legacy-fill.mjs';
10
+ const nativeSignalAborted=Object.getOwnPropertyDescriptor(AbortSignal.prototype,'aborted').get;
11
+ const originalSignalState=signal=>{if(signal===undefined)return false;try{return nativeSignalAborted.call(signal);}catch{return null;}};
6
12
 
7
13
  export class ChromeLabClient {
14
+ #nativeReplies = new WeakMap();
15
+ #pendingReplies = new Map();
16
+ #replyConnectionGeneration = 0;
17
+ #replySessionGenerations = new Map();
18
+ #requestSequence = 0;
19
+ #replyAttachments = new Map();
20
+ #recentRequests = new Map();
21
+ #fillCompletions = new WeakMap();
22
+ #fillObservations = new WeakMap();
23
+ #fillOrder = 0;
24
+ #fillOwner = new AsyncLocalStorage();
25
+ async fillLegacy(input,options) {
26
+ const originalOptions=Object.freeze({signal:options.signal,sessionId:options.sessionId});
27
+ const frame=Object.freeze({owner:Object.freeze({}),start:this.#requestSequence,order:++this.#fillOrder,signal:originalOptions.signal});
28
+ return this.#fillOwner.run(frame,async()=>{
29
+ const result=await legacyFill(this,input,originalOptions),origin=takeLegacyFillOrigin(result);
30
+ if(origin)this.#recordFillCompletion(result,origin,frame);
31
+ return result;
32
+ });
33
+ }
34
+ #recordFillCompletion(result,origin,frame) {
35
+ if(origin.client!==this || typeof origin.input.selector!=='string'||origin.options.signal!==frame.signal||originalSignalState(frame.signal)!==false)return;
36
+ const a=this.#nativeReplies.get(origin.activation),f=this.#nativeReplies.get(origin.error);
37
+ const activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
38
+ if(!a||!f||!activation||!focus||activation.method!=='Page.bringToFront'||activation.outcome!=='native_success_reply'||
39
+ focus.method!=='DOM.focus'||focus.outcome!=='native_error_reply')return;
40
+ const error=JSON.parse(focus.reply_json).error;
41
+ if(error.code!==-32000||error.message!=='Element is not focusable')return;
42
+ const first=a.capture.guard.id,last=f.capture.guard.id;
43
+ if(first!==frame.start+1||last!==first+3||last!==this.#requestSequence)return;
44
+ const captures=Array.from({length:4},(_,i)=>this.#recentRequests.get(first+i));
45
+ const methods=['Page.bringToFront','DOM.getDocument','DOM.querySelector','DOM.focus'];
46
+ if(captures.some((c,i)=>!c||c.guard.fillOwner!==frame.owner||c.guard.method!==methods[i]||c.guard.action!=='cdp'||!c.state.settled||!c.state.observedReply||c.state.nativeFailed!==(i===3)||
47
+ c.state.writeState!=='acknowledged'||c.state.failed!==(i===3)||
48
+ originalSignalState(c.guard.signal)!==false||c.guard.sessionId!==origin.options.sessionId||
49
+ !c.guard.binding||Object.keys(c.guard.binding).some(k=>c.guard.binding[k]!==a.capture.guard.binding[k])))return;
50
+ if(captures[0]!==a.capture||captures[3]!==f.capture||activation.target_id!==origin.input.target_id||focus.target_id!==origin.input.target_id)return;
51
+ const root=captures[1].state.nodeId,node=captures[2].state.nodeId;
52
+ if(!Number.isSafeInteger(root)||root<1||!Number.isSafeInteger(node)||node<1)return;
53
+ const params=[{}, {depth:0}, {nodeId:root,selector:origin.input.selector}, {nodeId:node}];
54
+ if(captures.some((c,i)=>c.guard.paramsSha256!==createHash('sha256').update(JSON.stringify(params[i])).digest('hex')))return;
55
+ Object.freeze(result.observation.cdp_error);Object.freeze(result.observation);Object.freeze(result);
56
+ this.#fillCompletions.set(result,{origin,captures,last,frame});
57
+ }
58
+ beginLegacyFillObservation(signal) {
59
+ if(signal===undefined||originalSignalState(signal)!==false)throw new Error('A live original handler signal is required for input observation.');
60
+ const token=Object.freeze({});this.#fillObservations.set(token,Object.freeze({signal,start:this.#requestSequence,order:++this.#fillOrder}));return token;
61
+ }
62
+ closeLegacyFillObservation(token){this.#fillObservations.delete(token);}
63
+ takeLegacyFillCompletion(result,observation) {
64
+ let boundary=null;
65
+ if(arguments.length>1){boundary=this.#fillObservations.get(observation);this.#fillObservations.delete(observation);if(!boundary||originalSignalState(boundary.signal)!==false)return null;}
66
+ const record=result&&typeof result==='object'?this.#fillCompletions.get(result):null;
67
+ if(!record)return null;this.#fillCompletions.delete(result);
68
+ const {origin,captures,last,frame}=record,activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
69
+ if(boundary&&(frame.signal!==boundary.signal||frame.start<boundary.start||frame.order<=boundary.order))return null;
70
+ if(originalSignalState(frame.signal)!==false||last!==this.#requestSequence||!activation||!focus||captures.some(c=>c.state.writeState!=='acknowledged'||
71
+ originalSignalState(c.guard.signal)!==false||performance.now()>=c.guard.requestDeadline))return null;
72
+ return Object.freeze({format:'comal-legacy-fill-completion/1',outcome:'completed_blocked_before_keyboard',
73
+ target_id:origin.input.target_id,selector:origin.input.selector,
74
+ request_ids:Object.freeze(captures.map(c=>c.guard.id)),activation,focus,input_commands_started:0,
75
+ original_handler_signal_bound:frame.signal!==undefined,
76
+ native_boundary_bound:boundary!==null,
77
+ authority:false,behavior_acceptance:false,retry_authorized:false,remote_nonexecution_proved:false,phase_lifetime_bound:false});
78
+ }
79
+ #replyBinding(sessionId) {
80
+ const targets = [...this.sessions].filter(([, id]) => id === sessionId);
81
+ if (this.closed || this.failure || !this.connectionId || !this.info?.browser_endpoint_id ||
82
+ typeof sessionId !== 'string' || !sessionId || targets.length !== 1) return null;
83
+ const attached=this.#replyAttachments.get(sessionId);
84
+ if(!attached || attached.targetId!==targets[0][0] || attached.connectionId!==this.connectionId ||
85
+ attached.browserEndpointId!==this.info.browser_endpoint_id || attached.generation!==this.#replyConnectionGeneration)return null;
86
+ if(!this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
87
+ return {connectionId:this.connectionId, browserEndpointId:this.info.browser_endpoint_id,
88
+ sessionId, targetId:targets[0][0], contextEpoch:this.collections.get(sessionId)?.epoch ?? null,
89
+ connectionGeneration:this.#replyConnectionGeneration,sessionGeneration:this.#replySessionGenerations.get(sessionId)};
90
+ }
91
+ #recordNativeReply(message, entry, error, result) {
92
+ const capture=this.#pendingReplies.get(message.id),guard=capture?.guard;
93
+ if (!guard || guard.id!==message.id || capture.entry!==entry || capture.state.settled || guard.action !== 'cdp')return;
94
+ capture.state.observedReply=true;capture.state.nativeFailed=Boolean(error);
95
+ capture.state.nodeId=guard.method==='DOM.getDocument'?result?.root?.nodeId:guard.method==='DOM.querySelector'?result?.nodeId:null;
96
+ const current=guard.connectionId===this.connectionId && guard.browserEndpointId===this.info?.browser_endpoint_id && guard.connectionGeneration===this.#replyConnectionGeneration;
97
+ if(current && !error && guard.method==='Target.attachToTarget' && guard.attachTargetId &&
98
+ result && Object.keys(result).join()==='sessionId' && typeof result.sessionId==='string' && result.sessionId) {
99
+ this.#replySessionGenerations.delete(result.sessionId);
100
+ this.#replyAttachments.set(result.sessionId,this.#replyAttachments.has(result.sessionId)?null:Object.freeze({targetId:guard.attachTargetId,
101
+ connectionId:guard.connectionId,browserEndpointId:guard.browserEndpointId,generation:guard.connectionGeneration}));
102
+ }
103
+ if(current && !error && guard.method==='Target.detachFromTarget'){this.#replyAttachments.delete(guard.detachSessionId);this.#replySessionGenerations.delete(guard.detachSessionId);}
104
+ if (!guard.binding ||
105
+ !Number.isFinite(guard.requestDeadline) || !Number.isSafeInteger(guard.requestBudgetMs) ||
106
+ guard.requestBudgetMs<1 || guard.requestBudgetMs>2147483647) return;
107
+ const proof = parseNativeReply(message.native_reply, {id:guard.id, method:guard.method,
108
+ sessionId:guard.sessionId, paramsSha256:guard.paramsSha256,
109
+ browserEndpointId:guard.binding.browserEndpointId, error, cdpError:error?.cdpError, result});
110
+ const value = error || result;
111
+ if (proof && value && typeof value === 'object') this.#nativeReplies.set(value, {capture, proof});
112
+ }
113
+ nativeReplyProvenance(value) {
114
+ const record = value && typeof value === 'object' ? this.#nativeReplies.get(value) : null;
115
+ if (!record) return null;
116
+ const {capture, proof} = record,{guard,state}=capture,binding = this.#replyBinding(guard.sessionId);
117
+ if (state.writeState !== 'acknowledged' || !state.settled || originalSignalState(guard.signal)!==false ||
118
+ performance.now() >= guard.requestDeadline || !binding ||
119
+ Object.keys(binding).some(k => binding[k] !== guard.binding[k])) return null;
120
+ // This is an observation of this original object, never a live host-phase
121
+ // capability. The admitting House invocation/deadline must be bound separately.
122
+ return Object.freeze({...proof, target_id:binding.targetId, connection_id:binding.connectionId,
123
+ original_signal_bound:guard.signal!==undefined});
124
+ }
8
125
  constructor(config, { spawnProcess = spawn } = {}) {
9
126
  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();
127
+ // Ownership recorded by this connection: only these are cleaned up in relay mode.
128
+ this.openedTargets = new Set(); this.deployedProjects = new Set(); this.lastCleanup = null;
10
129
  }
11
130
  async connect(signal) {
12
131
  signal?.throwIfAborted(); if (this.closed) throw new Error('Plugin Chrome Lab cerrado.');
132
+ if (this.preempted() && !this.pending.size && !this.attaching.size && !this.reconnecting) this.forgetPreemptedTransport();
13
133
  if (!this.connection) this.connection = this.start();
14
134
  if (!signal) return this.connection;
15
135
  return new Promise((resolve, reject) => {
@@ -19,17 +139,31 @@ export class ChromeLabClient {
19
139
  if (signal.aborted) abort();
20
140
  });
21
141
  }
142
+ // Relay mode: relayd cut the SSH bridge because another attempt or check was waiting for the
143
+ // host (it says so on stderr) and closed this attempt's tabs. That is not an uncertain
144
+ // transport: whatever was pending already failed, so the next operation is new and may open
145
+ // another connection, which waits for its turn in relayd's lease queue. Nothing is replayed.
146
+ preempted() {
147
+ return Boolean(this.failure) && this.failureReason === 'connection_lost' && Boolean(process.env.RELAY_REMOTE_NAMESPACE) && /relay-ssh: LEASE_EXPIRED/.test(this.transportStderr?.() || '');
148
+ }
149
+ forgetPreemptedTransport() {
150
+ for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'lease_preempted');
151
+ this.child = undefined; this.connection = undefined; this.failure = undefined; this.failureReason = undefined; this.info = undefined; this.transportStderr = undefined;
152
+ this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = []; this.openedTargets.clear();
153
+ }
22
154
  start() {
155
+ this.#replyConnectionGeneration++;
23
156
  this.connectionId = randomUUID();
24
157
  return new Promise((resolve, reject) => {
25
158
  let ready = false, stderr = '';
159
+ this.transportStderr = () => stderr;
26
160
  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)})})";
27
161
  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 });
28
162
  const timer = setTimeout(() => { fail(new Error('Timeout iniciando Chrome Lab por SSH.')); child.kill(); }, this.config.timeoutMs);
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(); };
163
+ const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; this.#replyConnectionGeneration++;this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; this.failureReason = reason; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
30
164
  child.on('error', fail); child.stdin.on('error', fail);
31
165
  child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-1500); });
32
- child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}`),'connection_lost'));
166
+ child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}${process.env.RELAY_REMOTE_NAMESPACE ? ' Si relayd cedió el host (LEASE_EXPIRED), la siguiente operación reconecta y espera su turno; tus pestañas se cerraron, vuelve a abrirlas.' : ''}`),'connection_lost'));
33
167
  const lines = createInterface({ input: child.stdout });
34
168
  lines.on('line', line => {
35
169
  if (this.child !== child) return;
@@ -39,7 +173,7 @@ export class ChromeLabClient {
39
173
  if (message.fatal) { fail(new Error(message.fatal)); child.kill(); }
40
174
  else if (message.ready) { ready = true; clearTimeout(timer); this.info = message.ready; resolve(message.ready); }
41
175
  else if (message.event) this.observe(message.event);
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); } }
176
+ 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}); this.#recordNativeReply(message,entry,error,result); entry.finish(error,result); } }
43
177
  } catch (error) { fail(error); child.kill(); }
44
178
  });
45
179
  child.stdin.write(JSON.stringify({ source: `(${remoteBridge.toString()})`, config: this.config }) + '\n');
@@ -47,6 +181,9 @@ export class ChromeLabClient {
47
181
  }
48
182
  observe(event) {
49
183
  const { method, params = {}, sessionId } = event;
184
+ const retiredSession=method==='Target.detachedFromTarget'?params.sessionId:sessionId;
185
+ if(['Target.detachedFromTarget','Inspector.detached'].includes(method)){this.#replySessionGenerations.delete(retiredSession);this.#replyAttachments.delete(retiredSession);}
186
+ else if(['Runtime.executionContextCreated','Runtime.executionContextDestroyed','Runtime.executionContextsCleared'].includes(method)&&this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
50
187
  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
188
  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
189
  if (method === 'Runtime.executionContextCreated') {
@@ -74,18 +211,54 @@ export class ChromeLabClient {
74
211
  if (this.events.length > 200) this.events.shift();
75
212
  }
76
213
  async request(action, params, { signal, expectedBinding, onDispatch, ...extra } = {}) {
214
+ if(['id','action','params','toJSON'].some(key=>Object.hasOwn(extra,key)))throw Object.assign(new Error('Reserved Chrome Lab request option.'),{code:'CHROME_REQUEST_OPTIONS'});
215
+ const fillFrame=this.#fillOwner.getStore();
216
+ // Preserve the private handler signal even when an instrumentation layer
217
+ // adds its original expiry signal or supplies a different request signal.
218
+ if(fillFrame?.signal!==undefined && signal!==fillFrame.signal)signal=AbortSignal.any(signal===undefined?[fillFrame.signal]:[fillFrame.signal,signal]);
77
219
  // Optional original transport ownership, checked again after connect's async boundary.
78
220
  // This protects a pinned caller from known local replacement before the write.
79
221
  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
222
  checkBinding();await this.connect(signal); signal?.throwIfAborted();checkBinding();if (this.failure) throw this.failure;
81
223
  return new Promise((resolve, reject) => {
82
- const id = ++this.sequence;
224
+ const id = ++this.#requestSequence;this.sequence=id;
225
+ if(!Number.isSafeInteger(id))throw new Error('Chrome Lab request sequence exhausted.');
226
+ const requestBudgetMs=this.config.timeoutMs+2000,requestDeadline=performance.now()+requestBudgetMs;
83
227
  const abort = () => finish(new Error('Operación abortada tras despacho; el efecto remoto puede haber ocurrido. Verifica antes de repetir.'));
84
- const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), this.config.timeoutMs + 2000);
85
- const finish = (error, value) => { clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id); error ? reject(error) : resolve(value); };
86
- this.pending.set(id, { finish, action, method: extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic }); signal?.addEventListener('abort', abort, { once: true });
228
+ const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), requestBudgetMs);
229
+ const entry = {action, method:extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic};
230
+ // The public pending registry remains useful transport bookkeeping. Its
231
+ // entries never own provenance's original signal, deadline or binding.
232
+ const capture={entry,guard:null,state:{settled:false,writeState:'not_sent'}};
233
+ const finish = (error, value) => { if(capture.state.settled)return;capture.state.settled=true;capture.state.failed=Boolean(error);clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id);this.#pendingReplies.delete(id);
234
+ // Open/Preview targets and deployed projects stay owned by this connection.
235
+ if (!error) { if (action === 'open' && typeof value?.targetId === 'string' && value.targetId) this.openedTargets.add(value.targetId); if (action === 'deploy' && typeof value?.project === 'string' && value.project) this.deployedProjects.add(value.project); if (action === 'unload' && typeof params?.project === 'string') this.deployedProjects.delete(params.project); }
236
+ error ? reject(error) : resolve(value); };
237
+ this.#pendingReplies.set(id,capture);
238
+ entry.finish=finish;this.pending.set(id, entry); signal?.addEventListener('abort', abort, { once: true });
87
239
  try { onDispatch?.(); } catch(error) { finish(error); return; }
88
- this.child.stdin.write(JSON.stringify({ id, action, params, ...extra }) + '\n', error => { if (error) finish(error); });
240
+ try {
241
+ signal?.throwIfAborted();if(capture.state.settled)return;checkBinding();
242
+ const envelope=Object.assign(Object.create(null),extra,{id,action,params});
243
+ const wire=JSON.stringify(envelope),sent=JSON.parse(wire);
244
+ if(!sent || Array.isArray(sent) || sent.id!==id || sent.action!==action)throw Object.assign(new Error('Chrome Lab serialized request identity changed.'),{code:'CHROME_REQUEST_OPTIONS'});
245
+ entry.method=sent.method;entry.sessionId=sent.sessionId;
246
+ const binding=this.#replyBinding(sent.sessionId);
247
+ capture.guard=Object.freeze({id,action:sent.action,method:sent.method,sessionId:sent.sessionId,fillOwner:fillFrame?.owner??null,
248
+ connectionId:this.connectionId,browserEndpointId:this.info?.browser_endpoint_id,connectionGeneration:this.#replyConnectionGeneration,
249
+ attachTargetId:sent.method==='Target.attachToTarget'&&typeof sent.params?.targetId==='string'?sent.params.targetId:null,
250
+ detachSessionId:sent.method==='Target.detachFromTarget'?sent.params?.sessionId:null,
251
+ binding:binding?Object.freeze(binding):null,signal,requestDeadline,requestBudgetMs,
252
+ paramsSha256:createHash('sha256').update(JSON.stringify(sent.params ?? {})).digest('hex')});
253
+ this.#recentRequests.set(id,capture);if(this.#recentRequests.size>32)this.#recentRequests.delete(this.#recentRequests.keys().next().value);
254
+ capture.state.writeState='pending';
255
+ this.child.stdin.write(wire + '\n', error => {
256
+ if(error)capture.state.writeState='failed';
257
+ else if(capture.state.writeState==='pending')capture.state.writeState='acknowledged';
258
+ if(error)finish(error);
259
+ });
260
+ }
261
+ catch(error){capture.state.writeState='failed';finish(error);}
89
262
  });
90
263
  }
91
264
  cdp(method, params = {}, { signal, sessionId, expectedBinding, attachDiagnostic, onDispatch } = {}) { return this.request('cdp', params, { signal, method, sessionId, expectedBinding, onDispatch, ...(attachDiagnostic !== undefined ? {attachDiagnostic} : {}) }); }
@@ -112,6 +285,27 @@ export class ChromeLabClient {
112
285
  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
286
  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
287
  }
288
+ // A page this connection opened stops being owned once CloseTab closed it (or found it gone).
289
+ forgetTarget(targetId) { this.openedTargets.delete(targetId); }
290
+ // Relay mode: the SSH bridge is what holds relayd's lease on the remote host. Once this
291
+ // connection owns nothing (every own tab closed, every own extension unloaded) and nothing
292
+ // is in flight, close the transport so relayd can free the host after its idle grace; the
293
+ // next operation reconnects lazily. Nothing is replayed because nothing is pending. Local
294
+ // mode keeps the connection as before.
295
+ async releaseIfIdle() {
296
+ if (!process.env.RELAY_REMOTE_NAMESPACE) return { released: false, reason: 'not_relay_mode' };
297
+ const child = this.child;
298
+ if (this.closed || this.reconnecting || this.failure || !child || child.exitCode !== null || child.signalCode !== null) return { released: false, reason: 'no_live_connection' };
299
+ if (this.openedTargets.size || this.deployedProjects.size) return { released: false, reason: 'still_owned', open_targets: this.openedTargets.size, deployed_projects: this.deployedProjects.size };
300
+ if (this.pending.size || this.attaching.size) return { released: false, reason: 'busy' };
301
+ // Detach first: the exit handler ignores a child that is no longer this.child, so the
302
+ // release is not recorded as a transport failure.
303
+ for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'connection_released');
304
+ this.child = undefined; this.connection = undefined; this.failure = undefined; this.info = undefined;
305
+ this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = [];
306
+ await new Promise(resolve => { const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1500); child.once('exit', () => { clearTimeout(timer); resolve(); }); child.stdin.end(); });
307
+ return { released: true, reason: 'nothing_owned' };
308
+ }
115
309
  // Explicit recovery only. A new connection does not replay any request or resolve its effect.
116
310
  async reconnect(signal) {
117
311
  signal?.throwIfAborted();
@@ -143,14 +337,18 @@ export class ChromeLabClient {
143
337
  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
338
  const check=()=>{signal?.throwIfAborted();if(!current())throw Object.assign(new Error('Original attach diagnostic session retired.'),{code:'CHROME_CDP_BINDING_CHANGED'});};
145
339
  const started=performance.now(), budget=Math.min(OPTIONAL_PHASE_MS,this.config.timeoutMs);
340
+ let phaseExhausted=false;
146
341
  for(const domain of record.domains) {
147
342
  check();const remaining=Math.floor(budget-(performance.now()-started));
148
- if(remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
343
+ if(phaseExhausted || remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
149
344
  domain.timeout_ms=remaining;const begin=performance.now();
150
345
  try {
151
346
  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
347
  check();const result=diagnosticEnvelope(value,{method:domain.method,sessionId,timeout_ms:remaining});
153
348
  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}: {})});
349
+ // A wait for the whole remaining phase has timed out. Do not spend a
350
+ // timer-rounding residue on another command in the other process.
351
+ if(result.reason==='command_timeout')phaseExhausted=true;
154
352
  if(result.state==='enabled')domain.enabled_acknowledged_at=domain.completed_at;
155
353
  } catch(error) {
156
354
  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;}
@@ -193,9 +391,41 @@ export class ChromeLabClient {
193
391
  if (serialized.length > 24000) return { ok: true, truncated: true, preview: serialized.subarray(0, 24000).toString('utf8') };
194
392
  return { ok: true, value };
195
393
  }
394
+ // Relay mode owns a per-attempt remoteRoot: closing that connection closes only the
395
+ // targets it opened (Open/Preview) and unloads the extensions the bridge reports as
396
+ // own (same ownExtensions rule). Foreign targets/extensions are never selected.
397
+ async relayCleanup() {
398
+ const namespace = process.env.RELAY_REMOTE_NAMESPACE;
399
+ if (!namespace) return { mode: 'local', cleaned: false, reason: 'not_relay_mode' };
400
+ if (this.reconnecting) return { mode: 'relay', namespace, cleaned: false, reason: 'reconnect' };
401
+ if (this.failure || !this.child || this.child.exitCode !== null || this.child.signalCode !== null) return { mode: 'relay', namespace, cleaned: false, reason: 'no_live_connection' };
402
+ if (!this.openedTargets.size && !this.deployedProjects.size) return { mode: 'relay', namespace, cleaned: false, reason: 'nothing_owned' };
403
+ const closed_targets = [], already_closed = [], unloaded = [], errors = [];
404
+ for (const targetId of [...this.openedTargets]) {
405
+ try { await this.cdp('Target.closeTarget', { targetId }); closed_targets.push(targetId); }
406
+ catch (error) { if (/No target|not found/i.test(error.message)) already_closed.push(targetId); else errors.push({ target_id: targetId, error: error.message }); }
407
+ }
408
+ let other_extensions_untouched = null;
409
+ try {
410
+ const status = await this.request('status', {});
411
+ other_extensions_untouched = status.other_extensions_count ?? null;
412
+ for (const extension of status.extensions) {
413
+ try { const result = await this.request('unload', { project: extension.project }); unloaded.push({ project: extension.project, extension_id: result.extension_id ?? extension.id }); }
414
+ catch (error) { errors.push({ project: extension.project, error: error.message }); }
415
+ }
416
+ } catch (error) { errors.push({ status: error.message }); }
417
+ return { mode: 'relay', namespace, cleaned: true, closed_targets, already_closed, unloaded, other_extensions_untouched, errors,
418
+ note: 'Cierre en modo relay: sólo los targets abiertos por esta conexión y las extensiones propias bajo su remoteRoot.' };
419
+ }
196
420
  async close() {
197
- if (this.closed) return; this.closed = true; for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
198
- if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return;
421
+ if (this.closed) return this.lastCleanup;
422
+ // Cleanup happens before the transport is marked closed: it is the only chance to
423
+ // ask the bridge which extensions are still owned under remoteRoot. A cleanup
424
+ // failure is reported in the result, never silently dropped and never fatal.
425
+ const cleanup = await this.relayCleanup().catch(error => ({ mode: process.env.RELAY_REMOTE_NAMESPACE ? 'relay' : 'local', cleaned: false, errors: [{ cleanup: error.message }] }));
426
+ this.lastCleanup = cleanup; this.closed = true;this.#replyConnectionGeneration++;this.#replySessionGenerations.clear();this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
427
+ if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return cleanup;
199
428
  await new Promise(resolve => { const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 1500); this.child.once('exit', () => { clearTimeout(timer); resolve(); }); this.child.stdin.end(); });
429
+ return cleanup;
200
430
  }
201
431
  }
@@ -4,7 +4,11 @@ import { randomUUID, createHash } from 'node:crypto';
4
4
  import { ChromeLabClient } from './client.mjs';
5
5
  import { snapshotProject, safeRelative, evidenceDirectory, chromeProjectNameSchema } from './project.mjs';
6
6
  import {scopedInteract,inputScopeSchema} from './scoped-input.mjs';
7
+ import {legacyFill} from './legacy-fill.mjs';
7
8
  import { observeOwnedWorker, workerDiagnosticSchema } from './worker-diagnostics.mjs';
9
+ import { comalEnvironment } from '../../compat.mjs';
10
+ // Same pixel ceiling the resident enforces for image receipts (src/vision.mjs).
11
+ import { IMAGE_LIMIT } from '../../vision.mjs';
8
12
 
9
13
  export const CHROME_LAB_TOOLS = Object.freeze(['Status', 'Deploy', 'Deployment', 'Targets', 'Open', 'Preview', 'ReadDOM', 'Assert', 'Contexts', 'InspectValue', 'Evaluate', 'Interact', 'Message', 'Console', 'WorkerDiagnostics', 'Screenshot', 'CloseTab', 'Unload'].map(name => `plugin__chrome-lab__${name}`));
10
14
  const object = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
@@ -68,6 +72,75 @@ function frameDocuments(tree, contexts, identity, usable) {
68
72
  const result = { version: FRAME_DOCUMENTS_VERSION, status: 'observed', complete: true, frames };
69
73
  return Buffer.byteLength(JSON.stringify(result), 'utf8') <= 4000 ? result : unavailable('projection_byte_limit');
70
74
  }
75
+ // Pixel delivery to the model is the same receipt mechanism Read uses (src/vision.mjs):
76
+ // COMAL_VISION=off never sends pixels, and the tool result says so explicitly.
77
+ export function screenshotAttach(env = process.env) {
78
+ const value = comalEnvironment(env).COMAL_VISION ?? 'auto';
79
+ if (!['auto', 'on', 'off'].includes(value)) throw new Error('COMAL_VISION debe ser auto, on u off.');
80
+ return { vision: value, attach: value !== 'off' };
81
+ }
82
+ // Strict ranges; an out-of-range clip is refused before any CDP or SSH call.
83
+ export function screenshotClip(value) {
84
+ if (value === undefined) return null;
85
+ if (typeof value === 'string') { if (!value.length || value.length > 1000) throw new Error('clip selector debe tener entre 1 y 1000 caracteres.'); return { selector: value }; }
86
+ const number = key => typeof value?.[key] === 'number' && Number.isFinite(value[key]);
87
+ if (!value || typeof value !== 'object' || Array.isArray(value) || ['x', 'y', 'width', 'height'].some(key => !number(key))) throw new Error('clip requiere x, y, width y height numéricos, o un selector CSS.');
88
+ const { x, y, width, height } = value;
89
+ if (x < 0 || y < 0 || width <= 0 || height <= 0 || x > 100000 || y > 100000 || width > 100000 || height > 100000) throw new Error('clip exige x,y >= 0, width,height > 0 y ningún valor mayor de 100000 px.');
90
+ return { x, y, width, height };
91
+ }
92
+ export function screenshotScale(value) {
93
+ if (value === undefined) return 1;
94
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0.1 || value > 2) throw new Error('scale debe ser un número entre 0.1 y 2.');
95
+ return value;
96
+ }
97
+ export const SCREENSHOT_MAX_BYTES = 8000000;
98
+ // PNG IHDR: the capture's real pixel size, used for the documented token estimate.
99
+ export function pngSize(bytes) {
100
+ return bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) && bytes.subarray(12, 16).toString('latin1') === 'IHDR'
101
+ ? { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } : null;
102
+ }
103
+ // Rough provider-independent estimate (28x28 px tiles), not exact attribution.
104
+ export const imageTokenEstimate = size => size ? Math.ceil(size.width / 28) * Math.ceil(size.height / 28) : null;
105
+ const clipFailure = (code, details) => Object.assign(new Error(JSON.stringify({ code, executed: false, ...details })), { code });
106
+ // Resolve the exact capture rectangle before the single capture. No scrolling, no JS.
107
+ export async function screenshotRect(client, options, clip, scale, targetId) {
108
+ if (!clip) {
109
+ if (scale === 1) return null;
110
+ const metrics = await client.cdp('Page.getLayoutMetrics', {}, options), view = metrics.cssVisualViewport || metrics.cssLayoutViewport;
111
+ if (!(view?.clientWidth > 0) || !(view?.clientHeight > 0)) throw clipFailure('CHROME_SCREENSHOT_VIEWPORT_UNAVAILABLE', { target_id: targetId, next_action: 'El viewport no se pudo medir. Activa el target y reintenta sin scale; no se capturó nada.' });
112
+ return { x: 0, y: 0, width: Math.floor(view.clientWidth), height: Math.floor(view.clientHeight) };
113
+ }
114
+ if (!clip.selector) return clip;
115
+ const { root } = await client.cdp('DOM.getDocument', { depth: 0 }, options);
116
+ const { nodeId } = await client.cdp('DOM.querySelector', { nodeId: root.nodeId, selector: clip.selector }, options);
117
+ if (!nodeId) throw clipFailure('CHROME_SCREENSHOT_CLIP_MISSING', { target_id: targetId, selector: clip.selector, next_action: 'El selector de clip no existe en el documento actual. Corrige el selector o espera la carga; no se capturó nada.' });
118
+ let model;
119
+ try { ({ model } = await client.cdp('DOM.getBoxModel', { nodeId }, options)); }
120
+ catch (error) {
121
+ if (error.cdpError?.method !== 'DOM.getBoxModel' || error.cdpError.code !== -32000 || error.cdpError.message !== 'Node does not have a layout object') throw error;
122
+ throw clipFailure('CHROME_SCREENSHOT_CLIP_HIDDEN', { target_id: targetId, selector: clip.selector, next_action: 'El elemento de clip no tiene layout (oculto o sin caja). Corrige su visibilidad; no se capturó nada.' });
123
+ }
124
+ const quad = model?.content;
125
+ if (!Array.isArray(quad) || quad.length !== 8 || quad.some(value => !Number.isFinite(value))) throw clipFailure('CHROME_SCREENSHOT_CLIP_UNOBSERVED', { target_id: targetId, selector: clip.selector });
126
+ const xs = [quad[0], quad[2], quad[4], quad[6]], ys = [quad[1], quad[3], quad[5], quad[7]];
127
+ const rect = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
128
+ if (!(rect.width > 0) || !(rect.height > 0)) throw clipFailure('CHROME_SCREENSHOT_CLIP_EMPTY', { target_id: targetId, selector: clip.selector });
129
+ return rect;
130
+ }
131
+ // The PNG stays local evidence; only the recorded receipt can carry pixels to the model.
132
+ export function screenshotResult({ file, bytes, sha256, size, rect, scale, target_id, policy, attach }) {
133
+ const requested = attach ?? policy.attach;
134
+ const blocked = policy.vision === 'off' ? 'vision_off' : !requested ? 'attach_false' : bytes.length > IMAGE_LIMIT ? 'over_image_limit' : null;
135
+ return { path: file, evidence_relative: `evidence/chrome-lab/${path.basename(file)}`, bytes: bytes.length, sha256, target_id, observed_at: new Date().toISOString(),
136
+ width: size?.width ?? null, height: size?.height ?? null, clip: rect, scale, attached: !blocked, ...(blocked ? { attach_blocked: blocked } : {}),
137
+ image_token_estimate: imageTokenEstimate(size), vision: policy.vision, candidate: 'excluded',
138
+ evidence_note: 'El PNG vive en evidence/chrome-lab y queda fuera del candidato: sirve para inspección visual, nunca es la evidencia de la tarea.',
139
+ ...(blocked ? { attach_note: blocked === 'vision_off' ? 'COMAL_VISION=off: la imagen NO se envía al modelo; sólo queda el PNG en evidence/chrome-lab.'
140
+ : blocked === 'attach_false' ? 'attach:false: no se envía ninguna imagen al modelo en este turno; sólo queda el PNG en evidence/chrome-lab.'
141
+ : `El PNG (${bytes.length} bytes) excede IMAGE_LIMIT (${IMAGE_LIMIT}): no se adjunta imagen al modelo; sólo queda el archivo en evidence/chrome-lab.` }
142
+ : { image: { type: 'image', mimeType: 'image/png', data: bytes.toString('base64') }, attach_note: 'Los píxeles se adjuntan al recibo y llegan al modelo en el turno siguiente si anuncia visión (reserva 4096 tokens por imagen; la estimación es aproximada).' }) };
143
+ }
71
144
  // Builtin1.4.1 adopts awaited optional attach diagnostics; tests may inject the same client over a fixture transport.
72
145
  export function createChromeLabPlugin({ clientFactory = config => new ChromeLabClient(config) } = {}) { return {
73
146
  id: 'chrome-lab', version: '1.4.1', requires: ['comal.workspace'],
@@ -76,7 +149,14 @@ export function createChromeLabPlugin({ clientFactory = config => new ChromeLabC
76
149
  const cwd = ctx.service('comal.workspace').cwd;
77
150
  const client = clientFactory({ cdpURL: 'http://127.0.0.1:9222', timeoutMs: 20000, maxDeployBytes: 10000000, ...config });
78
151
  const endpoint = new URL(client.config.cdpURL); if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('cdpURL debe ser HTTP(S) sin credenciales, query ni fragmento.');
79
- ctx.defer(() => client.close());
152
+ // Desactivar/recargar el plugin y cerrar el runtime pasan por aquí: en modo relay
153
+ // close() cierra los targets propios y descarga las extensiones propias del remoteRoot.
154
+ // Una limpieza parcial se avisa; nunca se convierte en éxito silencioso.
155
+ ctx.defer(async () => {
156
+ const cleanup = await client.close();
157
+ if (!cleanup?.errors?.length) return;
158
+ try { await ctx.notify({ kind: 'chrome_lab_cleanup', title: 'Chrome Lab cleanup was incomplete', body: `Relay cleanup left owned resources behind: ${JSON.stringify(cleanup.errors).slice(0, 1200)}`, ref: `chrome-lab-cleanup-${Date.now()}` }); } catch { /* el aviso es best-effort durante el retiro */ }
159
+ });
80
160
  const operation = (name, description, input_schema, effects, run, output_schema) => ctx.operation({ name, description, input_schema, effects, run, ...(output_schema ? { output_schema } : {}) });
81
161
  // attach validates the current URL even for cached sessions; do not repeat that round trip.
82
162
  const session = async (id, signal) => ({ signal, sessionId: await client.attach(id, signal) });
@@ -155,7 +235,8 @@ export function createChromeLabPlugin({ clientFactory = config => new ChromeLabC
155
235
  operation('Interact', 'Native click/fill/key/reload/navigation. Optional scope:{context_unique_id,shadow_path:[]} for click/fill selects exactly one element in that current default document through exposed shadow hosts. Same-origin child contexts must be explicitly selected from Contexts; unsupported/foreign/stale scopes refuse. Scoped clicks check native frame/hit; fills check exact deep focus before keyboard/text. No JS fallback. Missing/ambiguous/blocked elements return dispatched:false; any failure after input begins is uncertain and must not be replayed. Checks and physical dispatch are separate requests, not atomic. Legacy no-scope input keeps its existing top-document behavior.', object({ ...target, action: { enum: ['click', 'fill', 'key', 'reload', 'navigate'] }, scope:inputScopeSchema, selector: string(1000), text: { type: 'string', maxLength: 10000 }, key: { enum: ['Enter', 'Tab', 'Escape', 'Backspace', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] }, url: string(3000) }, ['target_id', 'action']), write, async (input, { signal }) => {
156
236
  if(input.scope!==undefined)return scopedInteract(client,input,{signal});
157
237
  const options = await session(input.target_id, signal);
158
- if(['click','fill','key'].includes(input.action))await client.cdp('Page.bringToFront',{},options);
238
+ if(input.action==='fill')return typeof client.fillLegacy==='function'?client.fillLegacy(input,options):legacyFill(client,input,options);
239
+ if(['click','key'].includes(input.action))await client.cdp('Page.bringToFront',{},options);
159
240
  if (input.action === 'reload') await client.cdp('Page.reload', { ignoreCache: true }, options);
160
241
  else if (input.action === 'navigate') { if (!input.url) throw new Error('navigate requiere url.'); const result = await client.request('navigate', { targetId: input.target_id, url: browserURL(input.url) }, options); if (result.errorText) throw new Error(result.errorText); }
161
242
  else if (input.action === 'key') { if (!input.key) throw new Error('key requiere key.'); for (const type of ['keyDown', 'keyUp']) await client.cdp('Input.dispatchKeyEvent', { type, key: input.key, ...(input.key === 'Enter' ? { text: '\r', windowsVirtualKeyCode: 13 } : {}) }, options); }
@@ -164,16 +245,7 @@ export function createChromeLabPlugin({ clientFactory = config => new ChromeLabC
164
245
  const { root } = await client.cdp('DOM.getDocument', { depth: 0 }, options);
165
246
  const { nodeId } = await client.cdp('DOM.querySelector', { nodeId: root.nodeId, selector: input.selector }, options);
166
247
  if (!nodeId) return {dispatched:false,blocked:true,code:'CHROME_INPUT_BLOCKED',action:input.action,target_id:input.target_id,selector:input.selector,observation:{phase:'selector_resolution',selector_found:false},next_action:'The selector has no element in the current document. Correct source or inspect readiness. No keyboard or pointer event was dispatched.'};
167
- if (input.action === 'fill') {
168
- if (input.text === undefined) throw new Error('fill requiere text.');
169
- try { await client.cdp('DOM.focus', { nodeId }, options); }
170
- catch (error) {
171
- if (error.cdpError?.method !== 'DOM.focus' || error.cdpError.code !== -32000 || error.cdpError.message !== 'Element is not focusable' || signal?.aborted) throw error;
172
- return { dispatched:false,blocked:true,code:'CHROME_INPUT_BLOCKED',action:input.action,target_id:input.target_id,selector:input.selector,observation:{phase:'before_keyboard_input',focus:'rejected',cdp_error:error.cdpError},next_action:'Correct the source visibility, disabled state, or field type. DOM.focus returned a native not-focusable error; no keyboard or text input was dispatched. Inspect the DOM instead of retrying unchanged input.' };
173
- }
174
- for (const type of ['keyDown', 'keyUp']) await client.cdp('Input.dispatchKeyEvent', { type, key: 'a', code: 'KeyA', modifiers: 2, windowsVirtualKeyCode: 65 }, options);
175
- await client.cdp('Input.insertText', { text: input.text }, options);
176
- } else {
248
+ {
177
249
  let layoutMethod='DOM.scrollIntoViewIfNeeded',model;
178
250
  try {
179
251
  await client.cdp(layoutMethod, { nodeId }, options);
@@ -205,24 +277,34 @@ export function createChromeLabPlugin({ clientFactory = config => new ChromeLabC
205
277
  return { events, collection: 'since_attach', bounded: true, truncated: events.length < candidates.length, ...(target_id?{attach_diagnostics:client.diagnostics(target_id)}:{}) };
206
278
  });
207
279
  operation('WorkerDiagnostics', 'Observe bounded console/exception text for the exact current owned publication and native service-worker generation. Reads physical deployment before/after, selects its declared worker, may attach a debugger. Missing/ambiguous/changed state is unavailable, never healthy. No arbitrary JavaScript, application message, restart, wake-up request, reconnect or test verdict. Application-logged text is untrusted and may contain application data.', workerDiagnosticSchema, read, (input,options)=>observeOwnedWorker(client,input,options));
208
- operation('Screenshot', 'Activates the exact validated target, captures its viewport and saves a PNG in workspace evidence/chrome-lab. Hidden tabs may not produce a Chrome frame. Returns path and SHA256, not image bytes. No capture retry; use vision to judge appearance.', object(target, ['target_id']), { ...read, workspace: 'write' }, async ({ target_id }, { signal }) => {
209
- const options = await session(target_id, signal);
280
+ operation('Screenshot', 'Activa el target validado, captura su viewport y guarda un PNG en evidence/chrome-lab. Los píxeles llegan al modelo en el turno siguiente como imagen del recibo (mismo mecanismo que Read), nunca como texto; con COMAL_VISION=off o attach:false sólo se guarda el archivo y el resultado lo dice. clip (selector CSS o rectángulo) y scale (0.1-2) recortan y bajan resolución para gastar menos tokens. Devuelve path y sha256; el PNG de evidence/chrome-lab nunca es la evidencia de la tarea. No hay reintento de captura.', object({ ...target, attach: { type: 'boolean' }, clip: { anyOf: [string(1000), { type: 'object', additionalProperties: false, properties: { x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' } }, required: ['x', 'y', 'width', 'height'] }] }, scale: { type: 'number', minimum: 0.1, maximum: 2 } }, ['target_id']), { ...read, workspace: 'write' }, async (input, { signal }) => {
281
+ const clip = screenshotClip(input.clip), scale = screenshotScale(input.scale), policy = screenshotAttach(process.env);
282
+ const options = await session(input.target_id, signal);
210
283
  await client.cdp('Page.bringToFront', {}, options);
211
- const screenshot = await client.cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, options);
212
- const bytes = Buffer.from(screenshot.data, 'base64'); if (bytes.length > 8000000) throw new Error('Screenshot excede 8MB.');
284
+ const rect = await screenshotRect(client, options, clip, scale, input.target_id);
285
+ const screenshot = await client.cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, ...(rect ? { clip: { ...rect, scale } } : {}) }, options);
286
+ const bytes = Buffer.from(screenshot.data, 'base64'); if (bytes.length > SCREENSHOT_MAX_BYTES) throw new Error('Screenshot excede 8MB.');
213
287
  const dir = await evidenceDirectory(cwd);
214
288
  const file = path.join(dir, `${Date.now()}-${randomUUID()}.png`); await writeFile(file, bytes, { flag: 'wx' });
215
- return { path: file, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), target_id, observed_at: new Date().toISOString() };
289
+ return screenshotResult({ file, bytes, sha256: createHash('sha256').update(bytes).digest('hex'), size: pngSize(bytes), rect, scale, target_id: input.target_id, policy, attach: input.attach });
216
290
  });
217
- operation('CloseTab', 'Cierra un target de página indicado explícitamente. No cierra Chrome ni cambia perfiles. Tiene efectos: no lo uses para pestañas ajenas al encargo. Si Deploy ya cerró el popup, devuelve already_closed.', object(target, ['target_id']), write, async ({ target_id }, { signal }) => {
291
+ // En modo relay, al no quedar nada propio en el host (pestañas cerradas, extensiones
292
+ // descargadas) se suelta la conexión: relayd libera el host tras su gracia y la próxima
293
+ // herramienta reconecta.
294
+ const release = async () => { try { return (await client.releaseIfIdle()).released ? { connection_released: true, release_note: 'No queda nada propio en el host: Chrome Lab cerró su conexión y el host queda libre para otros. La próxima herramienta de Chrome Lab reconecta.' } : {}; } catch { return {}; } };
295
+ operation('CloseTab', 'Cierra un target de página indicado explícitamente. No cierra Chrome ni cambia perfiles. Tiene efectos: no lo uses para pestañas ajenas al encargo. Si Deploy ya cerró el popup, devuelve already_closed. Si ya no queda nada propio abierto, suelta la conexión (connection_released).', object(target, ['target_id']), write, async ({ target_id }, { signal }) => {
218
296
  const exists = async () => (await client.cdp('Target.getTargets', {}, { signal })).targetInfos.find(item => item.targetId === target_id);
219
- const item = await exists(); if (!item) return { closed: true, already_closed: true, target_id };
220
- if (item.type !== 'page') throw new Error('CloseTab sólo cierra páginas, no workers/browser.');
221
- await client.request('checkTarget', { targetId: target_id }, { signal });
222
- try { return await client.cdp('Target.closeTarget', { targetId: target_id }, { signal }); }
223
- catch (error) { if (!await exists()) return { closed: true, already_closed: true, target_id }; throw error; }
297
+ const closeTab = async () => {
298
+ const item = await exists(); if (!item) return { closed: true, already_closed: true, target_id };
299
+ if (item.type !== 'page') throw new Error('CloseTab sólo cierra páginas, no workers/browser.');
300
+ await client.request('checkTarget', { targetId: target_id }, { signal });
301
+ try { return await client.cdp('Target.closeTarget', { targetId: target_id }, { signal }); }
302
+ catch (error) { if (!await exists()) return { closed: true, already_closed: true, target_id }; throw error; }
303
+ };
304
+ const result = await closeTab(); client.forgetTarget(target_id);
305
+ return { ...result, ...await release() };
224
306
  });
225
- operation('Unload', 'Desinstala sólo la extensión desempaquetada correspondiente a project bajo la raíz propia Comal. Puede borrar su storage. Conserva archivos remotos. Nunca toma un id de una extensión ajena.', object(project, ['project']), write, (input, { signal }) => client.request('unload', input, { signal }));
307
+ operation('Unload', 'Desinstala sólo la extensión desempaquetada correspondiente a project bajo la raíz propia Comal. Puede borrar su storage. Conserva archivos remotos. Nunca toma un id de una extensión ajena. Si ya no queda nada propio abierto, suelta la conexión (connection_released).', object(project, ['project']), write, async (input, { signal }) => ({ ...await client.request('unload', input, { signal }), ...await release() }));
226
308
  const bindRecovery=ctx.service('comal.workspace').bindChromePageRecoveryClient;
227
309
  if(bindRecovery)ctx.defer(bindRecovery(client));
228
310
  },
@@ -0,0 +1,25 @@
1
+ // Fixed legacy fill flow. Only this branch creates an origin record for its own
2
+ // returned object; origin records alone never authorize or certify anything.
3
+ const origins=new WeakMap();
4
+ export function takeLegacyFillOrigin(result){const origin=origins.get(result);origins.delete(result);return origin??null;}
5
+ export async function legacyFill(client,input,options){
6
+ const {action,target_id,selector,text,scope}=input;
7
+ if(action!=='fill'||scope!==undefined)throw new Error('Legacy fill requires an unscoped fill action.');
8
+ const originalOptions=Object.freeze({signal:options.signal,sessionId:options.sessionId});
9
+ const activation=await client.cdp('Page.bringToFront',{},originalOptions);
10
+ if(!selector)throw new Error('click/fill requiere selector.');
11
+ const {root}=await client.cdp('DOM.getDocument',{depth:0},originalOptions);
12
+ const {nodeId}=await client.cdp('DOM.querySelector',{nodeId:root.nodeId,selector},originalOptions);
13
+ if(!nodeId)return{dispatched:false,blocked:true,code:'CHROME_INPUT_BLOCKED',action,target_id,selector,observation:{phase:'selector_resolution',selector_found:false},next_action:'The selector has no element in the current document. Correct source or inspect readiness. No keyboard or pointer event was dispatched.'};
14
+ if(text===undefined)throw new Error('fill requiere text.');
15
+ try{await client.cdp('DOM.focus',{nodeId},originalOptions);}
16
+ catch(error){
17
+ if(error.cdpError?.method!=='DOM.focus'||error.cdpError.code!==-32000||error.cdpError.message!=='Element is not focusable'||originalOptions.signal?.aborted)throw error;
18
+ const result={dispatched:false,blocked:true,code:'CHROME_INPUT_BLOCKED',action,target_id,selector,observation:{phase:'before_keyboard_input',focus:'rejected',cdp_error:{...error.cdpError}},next_action:'Correct the source visibility, disabled state, or field type. DOM.focus returned a native not-focusable error; no keyboard or text input was dispatched. Inspect the DOM instead of retrying unchanged input.'};
19
+ origins.set(result,Object.freeze({client,activation,error,input:Object.freeze({action,target_id,selector}),options:originalOptions}));
20
+ return result;
21
+ }
22
+ for(const type of ['keyDown','keyUp'])await client.cdp('Input.dispatchKeyEvent',{type,key:'a',code:'KeyA',modifiers:2,windowsVirtualKeyCode:65},originalOptions);
23
+ await client.cdp('Input.insertText',{text},originalOptions);
24
+ return{dispatched:true,action,target_id,note:'Despacho observado; usa Assert/ReadDOM para comprobar resultado.'};
25
+ }
@@ -0,0 +1,31 @@
1
+ import {createHash} from 'node:crypto';
2
+
3
+ // Parsed wire DATA only. Eligibility is held privately by the receiving client;
4
+ // calling this parser never grants an operation, retry or application acceptance.
5
+ export function parseNativeReply(value, expected) {
6
+ const keys = (v, names) => v && typeof v === 'object' && !Array.isArray(v) &&
7
+ Object.keys(v).sort().join() === names.slice().sort().join();
8
+ if (!keys(value, ['format','outer_request_id','cdp_request_id','method','session_id','params_sha256','browser_endpoint_id','remote_dispatched','reply_json']) ||
9
+ value.format !== 'comal-cdp-native-reply/1' || value.remote_dispatched !== true ||
10
+ !Number.isSafeInteger(value.cdp_request_id) || value.cdp_request_id < 1 ||
11
+ !['Page.bringToFront','DOM.focus'].includes(value.method) ||
12
+ value.outer_request_id !== expected.id || value.method !== expected.method ||
13
+ value.session_id !== expected.sessionId || value.params_sha256 !== expected.paramsSha256 ||
14
+ value.browser_endpoint_id !== expected.browserEndpointId ||
15
+ typeof value.reply_json !== 'string' || Buffer.byteLength(value.reply_json) > 8192) return null;
16
+ let raw;
17
+ try { raw = JSON.parse(value.reply_json); } catch { return null; }
18
+ const field = expected.error ? 'error' : 'result';
19
+ if (!keys(raw, ['id','sessionId',field]) || raw.id !== value.cdp_request_id || raw.sessionId !== expected.sessionId) return null;
20
+ if (expected.error) {
21
+ const error = raw.error;
22
+ if (!(keys(error,['code','message']) || keys(error,['code','message','data'])) ||
23
+ !Number.isSafeInteger(error.code) || typeof error.message !== 'string' ||
24
+ expected.cdpError?.method !== value.method || expected.cdpError.code !== error.code ||
25
+ expected.cdpError.message !== error.message || expected.error.message !== `${error.code}: ${error.message}`) return null;
26
+ } else if (JSON.stringify(raw.result) !== JSON.stringify(expected.result)) return null;
27
+ return Object.freeze({ ...value, reply_sha256:createHash('sha256').update(value.reply_json).digest('hex'),
28
+ outcome:expected.error ? 'native_error_reply' : 'native_success_reply',
29
+ authority:false, behavior_acceptance:false, retry_authorized:false,
30
+ remote_nonexecution_proved:false, phase_lifetime_bound:false });
31
+ }