@melaya/runner 1.1.42 → 1.1.45

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.
@@ -39,6 +39,8 @@ import { SessionManager, SessionError, spaceUserDataDir, } from "./sessionManage
39
39
  import { ensureEngine } from "./browserProvisioner.js";
40
40
  import { runSandboxedScript } from "./codeWorker.js";
41
41
  import { classifyControlEffect, writeGatedInMode } from "./publishGate.js";
42
+ import { applyUploadResize, parseUploadResize, resolveDataUrlSource, resolveHandleSource, resolvePathSource, resolveUrlSource, UploadSourceError, } from "./uploadFetch.js";
43
+ import { unlink } from "node:fs/promises";
42
44
  // A wall-clock around a CDP command. Playwright's CDPSession.send has NO default
43
45
  // timeout, so a wedged/busy renderer means the promise NEVER settles (not a
44
46
  // rejection - no catch fires) and the lease op-chain queues every later op
@@ -1772,14 +1774,136 @@ export async function startBrowserBridge(opts) {
1772
1774
  };
1773
1775
  }
1774
1776
  case "upload_file": {
1775
- // Honest refusal, not act_kind_unknown. The tool is advertised and
1776
- // the schema validates the handle shape, but NOTHING anywhere
1777
- // resolves an approved handle back to bytes — not this bridge, not
1778
- // the server, not the extension. Saying "unknown kind" sent the model
1779
- // hunting for a different spelling of a tool that cannot work yet.
1780
- throw new BridgeError("effect_not_granted", "File upload is not available on this transport: an approved file handle cannot be resolved to " +
1781
- "a file yet, on any transport. Ask the USER to attach the file themselves with " +
1782
- "browser_ask_user, then continue once they confirm.");
1777
+ // Attach file(s) to a page <input type=file> via CDP
1778
+ // DOM.setFileInputFiles. Sources, in precedence order: a staged
1779
+ // handle ("fh_<hex>_<ext>", from the server's browserUploadMedia
1780
+ // staging flow), a public image URL (guarded direct download — the
1781
+ // runner IS the executing side), or a LOCAL-RUNNER-ONLY file path.
1782
+ // v1 is images only (png/jpg/webp/gif), 10MB, magic-byte verified.
1783
+ //
1784
+ // GATE FIRST, download after: selecting a file onto a site is a
1785
+ // commit-shaped write (same family as submit), so in safe autonomy
1786
+ // it stages the approval card BEFORE any bytes move. browser.py's
1787
+ // poll loop re-issues the same act; the approved re-issue is the one
1788
+ // that downloads and attaches.
1789
+ await enforcePublishGate(reg, page, kind, "", String(args.ref ?? "file input"), args.hitl_mode, "publish");
1790
+ const handle = String(args.handle ?? args.value ?? (Array.isArray(args.fileHandles) ? args.fileHandles[0] : "") ?? "").trim();
1791
+ const srcUrl = String(args.url ?? "").trim();
1792
+ const srcPath = String(args.path ?? "").trim();
1793
+ const srcData = String(args.data ?? "").trim();
1794
+ const attachmentId = String(args.attachment_id ?? "").trim();
1795
+ let file;
1796
+ try {
1797
+ if (attachmentId) {
1798
+ // Per-turn chat attachments resolve to fh_ handles on the SERVER
1799
+ // (assistant stream / ext-bridge). A runner-LAUNCHED browser is a
1800
+ // pipeline transport with no chat turn to hold them, so the id is
1801
+ // unresolvable here — name the sources that do work.
1802
+ throw new BridgeError("act_args_invalid", "attachment_id is not available on the runner-launched browser transport — pass the image as " +
1803
+ "a public `url`, a base64 `data` URL, or a runner-local `path` instead.");
1804
+ }
1805
+ if (handle)
1806
+ file = await resolveHandleSource(String(reg.spec.serverBase || ""), handle);
1807
+ else if (srcUrl)
1808
+ file = await resolveUrlSource(srcUrl);
1809
+ else if (srcData)
1810
+ file = await resolveDataUrlSource(srcData);
1811
+ else if (srcPath)
1812
+ file = await resolvePathSource(srcPath);
1813
+ else {
1814
+ throw new BridgeError("act_args_invalid", "upload_file needs a file source: `url` (public http(s) image URL, max 10MB), `data` (a base64 " +
1815
+ "data URL), a staged `handle` (fh_...), or — on this local runner only — `path` (an image file " +
1816
+ "on this machine).");
1817
+ }
1818
+ }
1819
+ catch (e) {
1820
+ if (e instanceof BridgeError)
1821
+ throw e;
1822
+ if (e instanceof UploadSourceError)
1823
+ throw new BridgeError("act_args_invalid", `upload_file: ${e.message}`);
1824
+ throw new BridgeError("unknown_outcome", `upload_file could not resolve the file source: ${e.message}`);
1825
+ }
1826
+ if (file.temp)
1827
+ reg.uploadTmpFiles.push(file.path);
1828
+ // Convert-before-attach (width/height/fit/format): a canvas job run
1829
+ // through the page's JS context — the runner has no image library,
1830
+ // but it HAS a browser. Pure computation; no DOM mutation.
1831
+ const resizeSpec = parseUploadResize(args);
1832
+ if (resizeSpec) {
1833
+ const cdpForResize = await getCdp(rec, page);
1834
+ try {
1835
+ file = await applyUploadResize(file, resizeSpec, async (expression) => {
1836
+ const r = await cdpRace(cdpForResize.send("Runtime.evaluate", {
1837
+ expression, awaitPromise: true, returnByValue: true,
1838
+ }), "Runtime.evaluate", 60_000);
1839
+ if (r.exceptionDetails)
1840
+ throw new Error(r.exceptionDetails.text || "page threw during the canvas conversion");
1841
+ return r.result?.value;
1842
+ });
1843
+ }
1844
+ catch (e) {
1845
+ if (e instanceof UploadSourceError)
1846
+ throw new BridgeError("act_args_invalid", `upload_file: ${e.message}`);
1847
+ throw new BridgeError("unknown_outcome", `upload_file could not convert the image: ${e.message}`);
1848
+ }
1849
+ if (file.temp)
1850
+ reg.uploadTmpFiles.push(file.path);
1851
+ }
1852
+ // Locate the REAL <input type=file>. The ref may point at the input
1853
+ // itself, at a styled upload button that HIDES the input, or be
1854
+ // absent entirely — the common case, since hidden inputs rarely get
1855
+ // a ref in the tree. Search order: self, descendant, label control,
1856
+ // the enclosing form's input, the page's first file input.
1857
+ const FIND_FROM_REF = "function(){ var el = this;" +
1858
+ " var isFile = function(n){ return n && n.tagName && n.tagName.toLowerCase()==='input' && (n.getAttribute('type')||'').toLowerCase()==='file'; };" +
1859
+ " if (isFile(el)) return el;" +
1860
+ " var q = el && el.querySelector ? el.querySelector('input[type=file]') : null; if (q) return q;" +
1861
+ " if (el && el.tagName && el.tagName.toLowerCase()==='label') {" +
1862
+ " var c = el.control || (el.htmlFor ? document.getElementById(el.htmlFor) : null); if (isFile(c)) return c; }" +
1863
+ " var f = el && el.closest ? el.closest('form') : null;" +
1864
+ " var q2 = f ? f.querySelector('input[type=file]') : null; if (q2) return q2;" +
1865
+ " var all = document.querySelectorAll('input[type=file]'); return all.length ? all[0] : null; }";
1866
+ const FIND_ON_PAGE = "(function(){ var all = Array.prototype.slice.call(document.querySelectorAll('input[type=file]'));" +
1867
+ " if (!all.length) return null;" +
1868
+ " var vis = all.filter(function(i){ var r = i.getBoundingClientRect(); return r.width>0 && r.height>0; });" +
1869
+ " return vis[0] || all[0]; })()";
1870
+ const cdp = await getCdp(rec, page);
1871
+ let inputObjectId = null;
1872
+ if (args.ref) {
1873
+ const binding = sessions.resolveRef(lease, args.ref);
1874
+ const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
1875
+ const found = await cdpRace(cdp.send("Runtime.callFunctionOn", {
1876
+ objectId: resolved.object.objectId,
1877
+ functionDeclaration: FIND_FROM_REF,
1878
+ }), "Runtime.callFunctionOn");
1879
+ inputObjectId = found.result?.objectId && found.result.subtype !== "null" ? found.result.objectId : null;
1880
+ }
1881
+ else {
1882
+ const found = await cdpRace(cdp.send("Runtime.evaluate", {
1883
+ expression: FIND_ON_PAGE,
1884
+ }), "Runtime.evaluate");
1885
+ inputObjectId = found.result?.objectId && found.result.subtype !== "null" ? found.result.objectId : null;
1886
+ }
1887
+ if (!inputObjectId) {
1888
+ throw new BridgeError("act_args_invalid", "no <input type=file> found" + (args.ref ? ` at or near ref '${args.ref}'` : " on this page") + ". " +
1889
+ "Some uploaders only create the input after their Upload button is clicked — click it first, then " +
1890
+ "retry upload_file. If the page opens a NATIVE file-picker dialog instead, hand off with browser_ask_user.");
1891
+ }
1892
+ // Set the files, then fire the events a real selection fires — CDP's
1893
+ // DOM.setFileInputFiles changes .files WITHOUT dispatching them, and
1894
+ // every framework listens on input/change.
1895
+ await cdpRace(cdp.send("DOM.setFileInputFiles", { files: [file.path], objectId: inputObjectId }), "DOM.setFileInputFiles");
1896
+ await cdpRace(cdp.send("Runtime.callFunctionOn", {
1897
+ objectId: inputObjectId,
1898
+ functionDeclaration: "function(){ this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); return this.files ? this.files.length : 0; }",
1899
+ returnByValue: true,
1900
+ }), "Runtime.callFunctionOn");
1901
+ return {
1902
+ uploaded: true,
1903
+ files: [file.name],
1904
+ bytes: file.bytes,
1905
+ note: "file attached to the input — the page's own submit/save control still commits it",
1906
+ };
1783
1907
  }
1784
1908
  default:
1785
1909
  throw new BridgeError("act_kind_unknown", `unhandled kind '${kind}'`);
@@ -2423,6 +2547,7 @@ export async function startBrowserBridge(opts) {
2423
2547
  sessionInit: null,
2424
2548
  traces: [],
2425
2549
  externalAttach: false,
2550
+ uploadTmpFiles: [],
2426
2551
  };
2427
2552
  byToken.set(token, reg);
2428
2553
  byRunId.set(spec.runId, reg);
@@ -2451,6 +2576,12 @@ export async function startBrowserBridge(opts) {
2451
2576
  for (const e of reg.enforcement.splice(0)) {
2452
2577
  await e.dispose().catch(() => { });
2453
2578
  }
2579
+ // Delete any upload temp files this run staged. Deferred to teardown on
2580
+ // purpose: the browser reads the file LAZILY when the page's form submits,
2581
+ // which can be several agent turns after the upload_file act.
2582
+ for (const p of reg.uploadTmpFiles.splice(0)) {
2583
+ await unlink(p).catch(() => { });
2584
+ }
2454
2585
  byRunId.delete(runId);
2455
2586
  byToken.delete(reg.token);
2456
2587
  activePageByRunId.delete(runId);