@milpa/comal 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +261 -62
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +283 -15
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -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ó.')),
|
|
85
|
-
const
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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', '
|
|
209
|
-
const
|
|
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
|
|
212
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|