webhelm 1.0.25 → 1.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,35 @@
1
+ // Browser-side upload source for the existing WebHelm owned Session.
2
+ // Pure string generation; JSON encoding protects filenames and selector quotes.
3
+ const SELECTOR_JS = "(() => ['#upload-files','#upload-media','input[name=\"upload-media\"]','input[type=\"file\"]'].find(s=>document.querySelector(s))||null)()";
4
+ const OPEN_JS = "(() => {const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').trim()==='Add files and more');if(b)b.click();return !!b;})()";
5
+ const STATE_JS = "(() => ({names:[...document.querySelectorAll('button[aria-label^=\"Remove \"]')].map(b=>(b.getAttribute('aria-label')||'').replace(/^Remove file\\s+\\d+:\\s*/,'').replace(/^Remove\\s+/,'')),pending:[...document.querySelectorAll('[role=\"progressbar\"],[aria-busy=\"true\"],[data-state=\"loading\"]')].some(e=>e.getClientRects().length>0)}))()";
6
+
7
+ export function attachmentUploadScript(paths) {
8
+ if (!Array.isArray(paths) || !paths.length) return "({ready:true})";
9
+ return [
10
+ "return await (async()=>{",
11
+ "const files="+JSON.stringify(paths)+";",
12
+ "const locate=()=>js("+JSON.stringify(SELECTOR_JS)+");",
13
+ "let input=await locate();",
14
+ "if(!input){",
15
+ " await js("+JSON.stringify(OPEN_JS)+");",
16
+ " for(let i=0;i<40&&!input;i++){await sleep(150);input=await locate();}",
17
+ "}",
18
+ "if(!input)throw Error('ChatGPT file input unavailable');",
19
+ "const names=[];",
20
+ "for(const file of files){",
21
+ " await upload_file(input,file);",
22
+ " const name=file.split('/').pop();names.push(name);",
23
+ " const expected=names.filter(n=>n===name).length;",
24
+ " let ready=false;",
25
+ " for(let i=0;i<480;i++){",
26
+ " const state=await js("+JSON.stringify(STATE_JS)+");",
27
+ " if(!state.pending&&state.names.filter(n=>n===name).length>=expected){ready=true;break;}",
28
+ " await sleep(250);",
29
+ " }",
30
+ " if(!ready)throw Error('ChatGPT attachment upload not confirmed: '+name);",
31
+ "}",
32
+ "return {ready:true,names};",
33
+ "})()",
34
+ ].join("\n");
35
+ }
@@ -2,22 +2,26 @@ export function targetUrl(projectId, threadId, message) {
2
2
  if (!threadId || !message) {
3
3
  throw new Error("thread_id and message are required");
4
4
  }
5
- const prefix = projectId ? `/g/${encodeURIComponent(projectId)}` : "";
6
- return `https://chatgpt.com${prefix}/c/${
7
- encodeURIComponent(threadId)
8
- }?${new URLSearchParams({ prompt: message })}`;
5
+ // A thread identity is global; ChatGPT may redirect /c/id to a
6
+ // Project-scoped /g/project-id[-slug]/c/id. Use the ordinary URL.
7
+ return "https://chatgpt.com/c/" + encodeURIComponent(threadId) +
8
+ "?" + new URLSearchParams({ prompt: message });
9
9
  }
10
10
  export const PROBE_JS =
11
11
  `(() => { const visible=e=>!!e&&!!e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden'; const body=(document.body?.innerText||'').toLowerCase(); if(body.includes('could not load this chatgpt conversation'))return {state:'BLOCKED',error:'thread_load_failed'}; if(body.includes('log in to chatgpt')||body.includes('sign in to chatgpt'))return {state:'BLOCKED',error:'authentication_required'}; const stop=[...document.querySelectorAll('button')].some(b=>{if(!visible(b))return false;const l=(b.getAttribute('aria-label')||'').trim().toLowerCase(),t=(b.getAttribute('title')||'').trim().toLowerCase(),d=(b.getAttribute('data-testid')||'').trim().toLowerCase();return ['stop generating','stop answering','stop response','stop'].includes(l)||['stop generating','stop answering'].includes(t)||['stop-button','stop-generating-button'].includes(d)}); if(stop)return {state:'RUNNING',stop_visible:true}; const e=document.querySelector('#prompt-textarea,[contenteditable="true"][data-lexical-editor="true"],[role="textbox"][contenteditable="true"],textarea'); const editable=e?.getAttribute?.('contenteditable')==='true'; return {state:visible(e)?'READY':'NOT_READY',stop_visible:false,composer_text:e?(editable?(e.textContent||''):(e.value||e.textContent||e.innerText||'')):''}; })()`;
12
12
  export function classifyProbe(observedUrl, expectedUrl, state) {
13
13
  const actual = new URL(observedUrl), expected = new URL(expectedUrl);
14
- if (
15
- actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
16
- actual.pathname.replace(/\/$/, "") !== expected.pathname.replace(/\/$/, "")
17
- ) return "BLOCKED";
14
+ const thread = url => {
15
+ const match = url.pathname.split('/c/')[1]?.split('/')[0] || null;
16
+ return match ? decodeURIComponent(match) : null;
17
+ };
18
+ if (actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
19
+ !thread(actual) || thread(actual) !== thread(expected)) return "BLOCKED";
18
20
  if (["RUNNING", "BLOCKED"].includes(state?.state)) return state.state;
19
- if (actual.search) return "NOT_READY";
20
- return state?.state === "READY" && String(state.composer_text || "").trim()
21
+ const prompt = expected.searchParams.get("prompt") || "";
22
+ const normalize = text => String(text || "").replace(/\s+/g, " ").trim();
23
+ if (actual.search && actual.searchParams.get("prompt") !== prompt) return "NOT_READY";
24
+ return state?.state === "READY" && normalize(state.composer_text) === normalize(prompt)
21
25
  ? "READY"
22
26
  : "NOT_READY";
23
27
  }
@@ -1,9 +1,11 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
- import { mkdirSync } from "node:fs";
3
- import { join, resolve } from "node:path";
2
+ import { mkdirSync, existsSync, statSync } from "node:fs";
3
+ import { join, resolve, isAbsolute } from "node:path";
4
+ import { attachmentUploadScript } from "./attachment-upload.mjs";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
  import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
6
- import { CLICK_SEND_JS, SEND_ENABLED_JS } from "./lifecycle-common.mjs";
7
+ import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
8
+ import { submissionScript } from "./submission-script.mjs";
7
9
  import { DurableQueueCore } from "../durable-queue-core.mjs";
8
10
 
9
11
  const transitions = new Set([
@@ -23,19 +25,26 @@ const chatgptQueuePolicy = {
23
25
  one(db, sql, ...args) { return db.prepare(sql).get(...args) || null; },
24
26
  rows(db) { return db.prepare("SELECT * FROM turns ORDER BY seq").all(); },
25
27
  enqueue(db, request = {}, now) {
26
- const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false} = request;
28
+ const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false, file=[]} = request;
29
+ if (!Array.isArray(file) || file.length > 4 ||
30
+ file.some(p => typeof p !== "string" || !isAbsolute(p) ||
31
+ !existsSync(p) || !statSync(p).isFile())) {
32
+ throw new TypeError("file must contain at most 4 existing absolute file paths");
33
+ }
34
+ if (file.length && mergeable) throw new TypeError("attachment turns cannot merge");
35
+ const filesJson = JSON.stringify(file);
27
36
  const key=idempotency_key ?? idempotencyKey;
28
- if (!thread_id || !message || !key) throw new TypeError("thread_id, message, idempotency_key required");
37
+ if (!thread_id || (!message && !file.length) || !key) throw new TypeError("thread_id, message, idempotency_key required");
29
38
  if (typeof mergeable !== "boolean") throw new TypeError("mergeable must be a boolean");
30
39
  const merged=this.one(db, "SELECT * FROM merged_requests WHERE idem_key=?", key);
31
40
  if (merged) {
32
- if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id) throw new Error("idempotency key reused with different payload");
41
+ if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id || filesJson!=="[]") throw new Error("idempotency key reused with different payload");
33
42
  const row=this.one(db, "SELECT * FROM turns WHERE id=?", merged.queue_id);
34
43
  return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
35
44
  }
36
45
  const same=this.one(db, "SELECT * FROM turns WHERE idem_key=?", key);
37
46
  if (same) {
38
- if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id) throw new Error("idempotency key reused with different payload");
47
+ if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id || (same.files_json||"[]")!==filesJson) throw new Error("idempotency key reused with different payload");
39
48
  return {ok:true,status:"queued",queue_id:same.id,thread_id:same.thread_id,state:same.state};
40
49
  }
41
50
  const tail=mergeable ? this.one(db, "SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1", thread_id) : null;
@@ -49,7 +58,7 @@ const chatgptQueuePolicy = {
49
58
  return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
50
59
  }
51
60
  const id=randomUUID();
52
- db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,?,'QUEUED',?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0);
61
+ db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable,files_json) VALUES(?,?,?,?,?,'QUEUED',?,?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0,filesJson);
53
62
  return {ok:true,status:"queued",queue_id:id,thread_id,state:"QUEUED"};
54
63
  },
55
64
  claim(db, owner, now, leaseSeconds) {
@@ -115,6 +124,9 @@ export class ChatGPTQueue extends DurableQueueCore {
115
124
  db.exec(
116
125
  `CREATE TABLE IF NOT EXISTS turns(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,idem_key TEXT NOT NULL UNIQUE,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,state TEXT NOT NULL,lease_owner TEXT,lease_until REAL,next_check_at REAL NOT NULL,created_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,receipt TEXT,mergeable INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS turns_thread_order ON turns(thread_id,seq); CREATE TABLE IF NOT EXISTS merged_requests(idem_key TEXT PRIMARY KEY,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,queue_id TEXT NOT NULL);`,
117
126
  );
127
+ if (!db.prepare("PRAGMA table_info(turns)").all().some(row => row.name === "files_json")) {
128
+ db.exec("ALTER TABLE turns ADD COLUMN files_json TEXT NOT NULL DEFAULT '[]'");
129
+ }
118
130
  super(db, chatgptQueuePolicy, now, {
119
131
  concurrency: Math.min(4, Math.max(1, Number(concurrency) || 4)), intervalMs,
120
132
  });
@@ -211,7 +223,7 @@ export class ChatGPTQueue extends DurableQueueCore {
211
223
  let state = "NOT_READY";
212
224
  while (Date.now() < deadline) {
213
225
  const source =
214
- `await (async()=>{ const page=await page_info(); const state=await js(${
226
+ `return await (async()=>{ const page=await page_info(); const state=await js(${
215
227
  JSON.stringify(PROBE_JS)
216
228
  }); if(state?.state==='READY' && !(await js(${
217
229
  JSON.stringify(SEND_ENABLED_JS)
@@ -226,20 +238,12 @@ export class ChatGPTQueue extends DurableQueueCore {
226
238
  async _send(item) {
227
239
  const sid = this.sessions.get(item.id);
228
240
  if (!sid) throw new Error("missing prepared browser session");
229
- const expected =
230
- targetUrl(item.project_id, item.thread_id, item.message).split("?")[0];
231
- const source = `await (async()=>{ const gate=await js(${
232
- JSON.stringify(PROBE_JS)
233
- }); const gateUrl=(await page_info()).url||''; let receipt={diagnostic:'pre_send_gate_not_ready'}; if(gate?.state==='READY' && await js(${
234
- JSON.stringify(SEND_ENABLED_JS)
235
- }) && Boolean((gate.composer_text||'').trim()) && gateUrl===${
236
- JSON.stringify(expected)
237
- } && await js(${
238
- JSON.stringify(CLICK_SEND_JS)
239
- })){ const until=Date.now()+30000; let disabled=false; while(Date.now()<until){ disabled=!(await js(${
240
- JSON.stringify(SEND_ENABLED_JS)
241
- })); if(disabled) break; await new Promise(resolve=>setTimeout(resolve,200)); } receipt=disabled?{verified_by:'send-button-disabled'}:{diagnostic:'post_submit_send_state_unverified'}; } return receipt; })()`;
242
- return this._exec(sid, source);
241
+ const attachments = JSON.parse(item.files_json || "[]");
242
+ if (attachments.length) {
243
+ const upload = await this._exec(sid, attachmentUploadScript(attachments));
244
+ if (!upload?.ready) throw new Error("ChatGPT attachments not ready for Send");
245
+ }
246
+ return this._exec(sid, submissionScript(item));
243
247
  }
244
248
  async _finish(id) {
245
249
  const sid = this.sessions.get(id);
@@ -293,7 +297,7 @@ export class ChatGPTQueue extends DurableQueueCore {
293
297
  }
294
298
  this.transition(item.id, owner, "CHECKING", "SENDING");
295
299
  const receipt = await this._send(item);
296
- if (receipt?.verified_by !== "send-button-disabled") {
300
+ if (!["send-button-disabled", "user-turn"].includes(receipt?.verified_by)) {
297
301
  this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
298
302
  receipt: receipt?.diagnostic || "missing_user_turn_receipt",
299
303
  });
@@ -0,0 +1,37 @@
1
+ import { PROBE_JS } from "./chatgpt-probe.mjs";
2
+ import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
3
+
4
+ /** Source executed inside a caller-owned WebHelm Session, never a separate tab. */
5
+ export function submissionScript(item) {
6
+ const prompt = item.message.slice(0, 120);
7
+ const selectorJS = "(() => {for(const s of ['button[aria-label=\"Send\"]','button[data-testid=\"send-button\"]','button[aria-label=\"Send prompt\"]']){const b=document.querySelector(s);if(b&&b.getClientRects().length&&!b.disabled)return s;}return null;})()";
8
+ const userTurnsJS = "(() => [...document.querySelectorAll('h4[data-conversation-role=\"user\"]')].filter(h=>(h.parentElement?.innerText||'').includes(" +
9
+ JSON.stringify(prompt) + ")).length)()";
10
+ return [
11
+ "return await (async()=>{",
12
+ "const gate=await js("+JSON.stringify(PROBE_JS)+");",
13
+ "const url=new URL((await page_info()).url);",
14
+ "const actualId=url.pathname.split('/c/')[1]?.split('/')[0]||'';",
15
+ "if(decodeURIComponent(actualId)!=="+JSON.stringify(item.thread_id)+
16
+ ")return {diagnostic:'thread_redirect_mismatch'};",
17
+ "if(gate?.state!=='READY'||!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
18
+ " return {diagnostic:'pre_send_gate_not_ready'};",
19
+ "if((gate.composer_text||'').replace(/\\s+/g,' ').trim()!=="+
20
+ JSON.stringify(item.message.replace(/\s+/g,' ').trim())+")",
21
+
22
+ " return {diagnostic:'pre_send_gate_not_ready'};",
23
+ "const selector=await js("+JSON.stringify(selectorJS)+");",
24
+ "if(!selector)return {diagnostic:'send_control_not_found'};",
25
+ "const before=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
26
+ "await click(selector);",
27
+ "for(let i=0;i<140;i++){",
28
+ " const count=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
29
+ " if(count>before)return {verified_by:'user-turn'};",
30
+ " if(!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
31
+ " return {verified_by:'send-button-disabled'};",
32
+ " await sleep(250);",
33
+ "}",
34
+ "return {diagnostic:'post_submit_user_turn_not_confirmed_no_resend'};",
35
+ "})()",
36
+ ].join("\n");
37
+ }
package/runtime/cli.mjs CHANGED
@@ -58,7 +58,8 @@ async function runPlatform(args) {
58
58
  if (sid && auto) throw Error('--session-id and --auto-session are mutually exclusive');
59
59
  if (platform === 'chatgpt' && action === 'new-turn' && mode === 'async') {
60
60
  if (sid || auto) throw Error('async new-turn cannot use browser session options');
61
- if (!config.thread_id || !config.message) throw Error('thread_id and message required');
61
+ if (!config.thread_id || (!config.message && !config.file?.length)) throw Error('thread_id and message or file required');
62
+ if (config.file !== undefined && (!Array.isArray(config.file) || config.file.length > 4 || config.file.some(f => typeof f !== 'string' || !path.isAbsolute(f) || !fs.existsSync(f)))) throw Error('file must contain at most 4 existing absolute paths');
62
63
  if (typeof config.mergeable !== 'undefined' && typeof config.mergeable !== 'boolean') throw Error('mergeable must be a boolean');
63
64
  const receipt = await request({op:'chatgpt-new-turn-enqueue', config: {...config, idempotency_key: config.idempotency_key || randomUUID(), mode}});
64
65
  if (!receipt.ok || !receipt.queue_id || receipt.thread_id !== String(config.thread_id)) throw Error('ChatGPT queue returned an invalid acceptance receipt');