← Commit history
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.29\ No newline at end of file+0.1.30\ No newline at end of file
bridge/bridge.json+3−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Browser Extension",-  "version": "0.1.29",-  "expectedExtensionVersion": "0.1.2",+  "version": "0.1.30",+  "expectedExtensionVersion": "0.1.3",   "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.",   "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",   "author": "Adom Inc.",@@ -77,6 +77,7 @@     "nbrowser_taskbar",     "nbrowser_describe",     "nbrowser_set_date",+    "nbrowser_set_file_input",     "nbrowser_download_wait",     "nbrowser_totp",     "nbrowser_register_totp",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.1.29",+  "version": "0.1.30",   "private": true,   "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",   "main": "server.js",
bridge/server.js+35
@@ -484,6 +484,15 @@ function janitor() {       fs.writeFileSync(ACT_FILE, kept.join('\n') + (kept.length ? '\n' : ''));     }   } catch {}+  try { // staged upload files (set_file_input contentBase64 mode): sweep anything older than 24h+    const updir = path.join(os.tmpdir(), 'adom-nbrowser-uploads');+    const cutoff = Date.now() - 24 * 3600 * 1000;+    for (const d of fs.readdirSync(updir)) {+      const dp = path.join(updir, d);+      for (const f of fs.readdirSync(dp)) { const fp = path.join(dp, f); try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {} }+      try { if (!fs.readdirSync(dp).length) fs.rmdirSync(dp); } catch {}+    }+  } catch {}   try { // composited overlay icons are mtime-keyed per avatar; sweep ones not touched in 30 days     const cutoff = Date.now() - 30 * 24 * 3600 * 1000;     for (const f of fs.readdirSync(ICON_DIR)) {@@ -1178,6 +1187,7 @@ const VERB_META = {   nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input (click/move/type/key).', related: ['nbrowser_click', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy'] },   nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Scrolls the target into view, re-resolves its rect, and RETURNS changed:bool - whether the URL/DOM actually changed after the click - so you can detect "click landed but nothing happened" and retry. Optional gliding cursor + intent label. The finder PIERCES open shadow roots, so custom-element controls (Schwab/QBO) are clickable by text/selector.', related: ['nbrowser_select', 'nbrowser_wait_for', 'nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_wait_for', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'changed:false means the click dispatched but the page did not visibly change (disabled/covered element, missed target) - re-resolve or retry', 'for a deterministic post-click wait use nbrowser_wait_for, not a fixed sleep', 'to PICK an option from a custom dropdown, use nbrowser_select (waits for options + returns the value) - never coordinate-click a virtualized list'] },   nbrowser_select: { hint: 'Pick an option from a dropdown/combobox BY ITS VISIBLE TEXT - the reliable primitive for custom + native selects. Give {label} (the control\'s accessible name) OR {near} (a visible label beside it) to find the trigger, plus {optionText}. It opens the control with an isTrusted click, WAITS for the options to render, clicks the EXACT text match (piercing shadow roots), and RETURNS the newly-selected value so you can verify. Fixes coordinate-clicks landing on the wrong row of a virtualized list.', related: ['nbrowser_click', 'nbrowser_wait_for'], pitfalls: ['pass label OR near to locate the trigger; optionText must match the option\'s exact visible text (case-insensitive)', 'ALWAYS check the returned `selected` value before continuing - confirm it is the row you meant (mis-selecting a bank account by one row is the exact bug this prevents)', 'raise timeoutMs for a slow/virtualized option list (default 4000, max 20000)'] },+  nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you.', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },   nbrowser_set_date: { hint: 'Fill a react-aria SEGMENTED date field (role=spinbutton month/day/year, common in finance apps) - focuses it and types each digit as a real keydown so the segments advance. date="YYYY-MM-DD". Pass order:"DMY" for day-first locales (default MDY).', related: ['nbrowser_type', 'nbrowser_click'], pitfalls: ['for react-aria/segmented fields; a plain <input type=date> may just take nbrowser_type', 'US MDY order by default - pass order:"DMY" if the field is day-first', 'check fieldValue in the response; screenshot if it looks off'] },   nbrowser_totp: { hint: 'Type the current 6-digit 2FA code for a host into the FOCUSED field (or pass selector). The bridge computes the RFC-6238 code from the stored seed and the extension types it - the agent NEVER sees the seed or the code. Pass submit:true to press Enter after. Register the seed once with nbrowser_register_totp. The #1 unblock for unattended vendor automation.', related: ['nbrowser_register_totp', 'nbrowser_autologin', 'nbrowser_login_state'], pitfalls: ['errorCode:"no_totp_seed" = register it first with nbrowser_register_totp', 'focus the 2FA input first (click it) or pass selector', 'codes rotate every 30s - call this right when you need it'] },   nbrowser_register_totp: { hint: 'One-time: register a base32 TOTP secret for a host (the "manual entry / setup key" the vendor shows by the 2FA QR). Stored 0600 on the laptop, NEVER returned. Then nbrowser_totp {host} types codes.', related: ['nbrowser_totp'], pitfalls: ['base32 only (A-Z, 2-7)', 'stored on the laptop; the agent never sees it back', 'one seed per host - re-registering replaces it'] },@@ -1367,6 +1377,7 @@ function dispatch(verb, args) {         d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"move"|"type"|"key"', x: 'int?', y: 'int?', text: 'str?', key: 'str?' }, { ok: 'bool' }, 120, { example: { sessionId: 'work', type: 'click', x: 200, y: 300 } }),         d('nbrowser_click', 'Click by visible text / selector / xy; scrolls into view + returns changed:bool (did the URL/DOM actually change). Finder pierces open shadow roots. Optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str', settleMs: 'optional int (default 500)' }, { ok: 'bool', changed: 'bool', urlBefore: 'str', urlAfter: 'str' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }),         d('nbrowser_select', 'Pick a dropdown/combobox option BY VISIBLE TEXT (custom or native): finds the trigger by label|near, opens it, waits for options, clicks the exact match (shadow-piercing), returns the selected value to verify.', { sessionId: S, tabId: 'optional str', label: 'optional str (control accessible name)', near: 'optional str (visible label beside it)', optionText: 'required str (exact option text)', timeoutMs: 'optional int (default 4000, max 20000)' }, { ok: 'bool', selected: 'str', matchedExact: 'bool', clickedText: 'str', via: '"custom-dropdown"|"native-select"' }, 25, { example: { sessionId: 'work', near: 'Account', optionText: 'Individual …3456' } }),+        d('nbrowser_set_file_input', 'Upload files into an <input type=file> over CDP - no OS picker, background-safe. Direct (selector/text -> input, incl. hidden) or armForNextChooser:true for inputs created on Browse-click. files:[{name,contentBase64}] = bridge stages small files on the laptop.', { sessionId: 'required str', selector: 'optional str', text: 'optional str', filePaths: 'optional [abs laptop paths]', files: 'optional [{name, contentBase64}]', armForNextChooser: 'optional bool', chooserStatus: 'optional bool' }, { ok: 'bool', inputFound: 'bool?', via: 'direct|chooser', files: '[names]', armed: 'bool?', served: 'bool?' }, 20, { example: { sessionId: 'work', selector: 'input[type=file]', filePaths: ['C:/Users/me/Downloads/foo.png'] } }),         d('nbrowser_set_date', 'Fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY). Types each digit as a real keydown so segments advance.', { sessionId: S, selector: 'required CSS selector of the date field/segment', date: 'required "YYYY-MM-DD"', order: 'optional "MDY"(default)|"DMY"' }, { ok: 'bool', fieldValue: 'str?' }, 30, { example: { sessionId: 'work', selector: '[aria-label="Start date"]', date: '2026-03-15' } }),         d('nbrowser_totp', 'Type the current 6-digit 2FA code for a host into the focused field (agent never sees seed or code). Register the seed once with nbrowser_register_totp.', { sessionId: S, host: 'required host, e.g. "gusto.com"', selector: 'optional 2FA field selector (else the focused field)', submit: 'optional bool (press Enter after)' }, { ok: 'bool', typed: 'bool', digits: 'int' }, 20, { example: { sessionId: 'work', host: 'gusto.com', submit: true } }),         d('nbrowser_register_totp', 'One-time: register a base32 TOTP secret for a host (stored 0600 on the laptop, never returned).', { host: 'required host', seed: 'required base32 secret' }, { ok: 'bool', host: 'str' }, 15, { example: { host: 'gusto.com', seed: 'JBSWY3DPEHPK3PXP' } }),@@ -1726,6 +1737,30 @@ function dispatch(verb, args) {     if (verb === 'nbrowser_close') {       return resolve({ success: false, error: 'nbrowser_close is not an extension verb', _hint: 'Close a specific window with nbrowser_close_window {sessionId} or a tab with nbrowser_close_tab {sessionId,tabId}. There is no global "detach all" - the extension idle-detaches the debugger automatically when no verb is running.' });     }+    // nbrowser_set_file_input pre-flight (the bridge lives ON the laptop, so it can stat + stage):+    //  - files:[{name, contentBase64}] -> write to a janitored temp dir and substitute filePaths, so a+    //    cloud agent uploads small files in ONE call (no send_files round-trip);+    //  - stat every filePath BEFORE forwarding so a bad path fails fast as file_missing (CDP's own+    //    error for a missing file is cryptic).+    if (verb === 'nbrowser_set_file_input' && args) {+      try {+        if (Array.isArray(args.files) && args.files.length && !(args.filePaths || []).length) {+          const dir = path.join(os.tmpdir(), 'adom-nbrowser-uploads', String(args.sessionId || 'nosession').replace(/[^a-z0-9_-]/gi, '_'));+          fs.mkdirSync(dir, { recursive: true });+          args.filePaths = args.files.map((f, i) => {+            const name = String((f && f.name) || `file${i}`).replace(/[^a-z0-9._ -]/gi, '_');+            const fp = path.join(dir, name);+            fs.writeFileSync(fp, Buffer.from(String((f && f.contentBase64) || ''), 'base64'));+            return fp;+          });+          delete args.files;+        }+        if (Array.isArray(args.filePaths) && args.filePaths.length) { // both modes read the files at serve time+          const missing = (args.filePaths || []).filter((fp) => { try { return !fs.existsSync(fp); } catch { return true; } });+          if (missing.length) return resolve({ success: false, errorCode: 'file_missing', error: `file_missing: ${missing.join(', ')} not found on the laptop`, _hint: 'Paths must be ABSOLUTE paths on the machine running the browser. Push files there first with adom-desktop send_files, or pass files:[{name, contentBase64}] and the bridge stages them for you.' });+        }+      } catch (e) { return resolve({ success: false, error: 'upload staging failed: ' + ((e && e.message) || e) }); }+    }     const tgt = targetConn(args);     if (tgt && tgt.mismatch) {       return resolve({ success: false, errorCode: 'profile_mismatch', error: `profile_mismatch: session '${tgt.sessionId}' is pinned to profile '${tgt.pin}', but you called with profile '${tgt.wantKey}'.`, _hint: `Refused so you never act on the WRONG account (personal vs corporate). Drop the profile arg (the session already routes to '${tgt.pin}'), or open a NEW registered session in '${tgt.wantKey}' if you truly meant that account.` });
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom for Chrome & Edge",   "short_name": "Adom",-  "version": "0.1.2",+  "version": "0.1.3",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup.",   "minimum_chrome_version": "116",   "background": {
extension/src/cdp.js+83
@@ -777,6 +777,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {     return;   }   if (method === 'Page.javascriptDialogClosed') { const o = _openDialogs.get(tabId); _openDialogs.delete(tabId); if (o) _recentDialogs.push({ event: 'dialog_closed', tabId: String(tabId), dialogType: o.type, result: params.result, ts: Date.now() }); return; }+  if (method === 'Page.fileChooserOpened') {+    // armed by nbrowser_set_file_input {armForNextChooser:true}: satisfy the page's chooser with the+    // staged files - silently, in the background, no OS dialog ever appears.+    const arm = _chooserArm.get(tabId);+    if (arm && params.backendNodeId) {+      send(tabId, 'DOM.setFileInputFiles', { files: arm.files, backendNodeId: params.backendNodeId })+        .then(() => { _chooserServed.set(tabId, { files: arm.files.map((f) => String(f).split(/[\\/]/).pop()), ts: Date.now() }); })+        .catch(() => {})+        .finally(() => { if (arm.timer) clearTimeout(arm.timer); _chooserArm.delete(tabId); send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); });+    }+    return;+  }   let entry = null;   if (method === 'Runtime.exceptionThrown') {     const e = params.exceptionDetails || {};@@ -874,3 +886,74 @@ export async function typeTotp(tabId, args) {   if (args.submit === true) await pressKey(tabId, 'Enter');   return { ok: true, typed: true, digits: String(code).length, submitted: args.submit === true }; }++// ---- nbrowser_set_file_input: populate <input type=file> over CDP, NO OS picker ------------------+// The OS file dialog cannot be driven with the debugger attached (and it FOREGROUNDS), so uploads go+// through DOM.setFileInputFiles - the same primitive Puppeteer's uploadFile / Playwright's+// setInputFiles use. Two modes:+//   direct (default): resolve the input (selector/text -> walk Browse-button/label to its input) and+//     inject the files, then dispatch input+change so React/Angular listeners fire.+//   armForNextChooser: for UIs that CREATE the input inside the Browse click handler - we intercept+//     the page's next file chooser (Page.setInterceptFileChooserDialog) and satisfy it with the staged+//     files when Page.fileChooserOpened fires. Auto-disarms after 30s.+// LIMITATION (documented in the bridge hint): the File System Access API (showOpenFilePicker) cannot+// be satisfied over CDP at all - those sites need their drag-drop path.+function findFileInputFn(sel, txt) {+  return `(function(){+    var sel=${JSON.stringify(sel || null)}, txt=${JSON.stringify(txt || null)};+    function fromEl(el){+      if(!el) return null;+      if(el.matches && el.matches('input[type=file]')) return el;+      if(el.tagName==='LABEL' && el.htmlFor){ var t=document.getElementById(el.htmlFor); if(t && t.type==='file') return t; }+      var lbl=el.closest && el.closest('label'); if(lbl){ var i=lbl.querySelector('input[type=file]'); if(i) return i; }+      if(el.querySelector){ var i2=el.querySelector('input[type=file]'); if(i2) return i2; }+      var form=el.closest && el.closest('form'); if(form){ var i3=form.querySelector('input[type=file]'); if(i3) return i3; }+      var p=el.parentElement; for(var d=0; p && d<4; d++,p=p.parentElement){ var i4=p.querySelector('input[type=file]'); if(i4) return i4; }+      return null;+    }+    var el=null;+    if(sel) el=document.querySelector(sel);+    else if(txt){ var t=txt.trim().toLowerCase();+      var nodes=Array.prototype.slice.call(document.querySelectorAll('button,a,label,input,[role=button],div,span'));+      el=nodes.find(function(n){ var s=(n.innerText||n.value||n.getAttribute('aria-label')||'').trim().toLowerCase(); return s && s.indexOf(t)>=0 && s.length < t.length+60; })||null;+    }+    var inp=fromEl(el);+    if(!inp && !sel && !txt) inp=document.querySelector('input[type=file]');+    if(!inp){ var all=document.querySelectorAll('input[type=file]'); if(all.length===1) inp=all[0]; }+    return inp||null;+  })()`;+}+const _chooserArm = new Map();    // tabId -> { files, timer }+const _chooserServed = new Map(); // tabId -> { files, ts }+export async function setFileInput(tabId, args) {+  await attach(tabId);+  const _m = modalGuard(tabId); if (_m) return _m;+  const files = args.filePaths || [];+  if (args.chooserStatus === true) {+    const s = _chooserServed.get(Number(tabId)) || null;+    return { ok: true, armed: _chooserArm.has(Number(tabId)), served: !!s, ...(s ? { files: s.files, servedAgoMs: Date.now() - s.ts } : {}), _hint: s ? 'The intercepted file chooser was satisfied with the staged files.' : (_chooserArm.has(Number(tabId)) ? 'Still armed - click the Browse/Upload control now (nbrowser_click), then re-check.' : 'Not armed and nothing served. Arm with {armForNextChooser:true, filePaths} first.') };+  }+  if (!files.length) return { ok: false, errorCode: 'no_files', error: 'filePaths is empty', _hint: 'Pass filePaths:["C:/abs/path/on/the/laptop", ...]. Push files to the laptop first with adom-desktop send_files (or use files:[{name,contentBase64}] - the bridge stages them for you).' };+  if (args.armForNextChooser === true) {+    await send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: true });+    const prev = _chooserArm.get(Number(tabId)); if (prev && prev.timer) clearTimeout(prev.timer);+    const timer = setTimeout(() => { _chooserArm.delete(Number(tabId)); send(tabId, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {}); }, 30000);+    _chooserArm.set(Number(tabId), { files, timer });+    _chooserServed.delete(Number(tabId));+    return { ok: true, armed: true, files: files.map((f) => String(f).split(/[\\/]/).pop()), _hint: 'ARMED for 30s: now nbrowser_click the Browse/Upload control - the page\'s file chooser will be satisfied with these files silently (no OS dialog). Then confirm with {chooserStatus:true} or just screenshot the page.' };+  }+  // direct mode: resolve the input element as a RemoteObject+  let obj;+  try { obj = await send(tabId, 'Runtime.evaluate', { expression: findFileInputFn(args.selector, args.text), returnByValue: false }); }+  catch (e) { return { ok: false, error: 'finder evaluate failed: ' + ((e && e.message) || e) }; }+  const ro = obj && obj.result;+  if (!ro || !ro.objectId || ro.subtype === 'null') {+    const diag = await evaluate(tabId, `(function(){ return { fileInputs: document.querySelectorAll('input[type=file]').length, selMatched: ${args.selector ? `!!document.querySelector(${JSON.stringify(args.selector)})` : 'null'} }; })()`).catch(() => ({}));+    return { ok: false, errorCode: 'input_not_found', error: `no <input type=file> resolved from ${args.selector ? 'selector ' + args.selector : args.text ? 'text "' + args.text + '"' : 'the page'}`, diag, _hint: diag && diag.fileInputs === 0 ? 'This page has NO file input right now. If the input is created by the Browse button\'s click handler, use {armForNextChooser:true, filePaths} then click the button. If the site uses showOpenFilePicker (File System Access API), CDP cannot inject files at all - use the site\'s drag-drop zone manually.' : 'A file input exists but your selector/text did not lead to it - target the input directly (selector:"input[type=file]") or the visible Browse control.' };+  }+  try { await send(tabId, 'DOM.enable').catch(() => {}); await send(tabId, 'DOM.setFileInputFiles', { files, objectId: ro.objectId }); }+  catch (e) { return { ok: false, error: 'DOM.setFileInputFiles failed: ' + ((e && e.message) || e), _hint: 'Verify every path exists ON THE LAPTOP (the bridge stats them, but a file may have been moved) and that the resolved element truly is an input[type=file].' }; }+  try { await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: `function(){ this.dispatchEvent(new Event('input',{bubbles:true,composed:true})); this.dispatchEvent(new Event('change',{bubbles:true,composed:true})); }` }); } catch {}+  const names = await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: 'function(){ return Array.prototype.map.call(this.files||[], function(f){return f.name;}); }', returnByValue: true }).then((r) => (r && r.result && r.result.value) || []).catch(() => []);+  return { ok: true, inputFound: true, via: 'direct', files: names, dispatchedEvents: ['input', 'change'], _hint: 'Files injected + input/change dispatched. Screenshot to confirm the page shows the selected file(s); many UIs need a separate Submit/Upload click next.' };+}
extension/src/sw.js+2
@@ -232,6 +232,8 @@ async function dispatch(verb, args) {       return cdp.selectOption(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_select' }), args);     case 'nbrowser_set_date':      // #7 react-aria segmented date field       return cdp.setDate(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_date' }), args);+    case 'nbrowser_set_file_input': // CDP file upload - no OS picker (DOM.setFileInputFiles); armForNextChooser for transient inputs+      return cdp.setFileInput(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_file_input' }), args);     case 'nbrowser_totp':          // #3 type the bridge-computed 6-digit code (args.__code) into the focused 2FA field       return cdp.typeTotp(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_totp' }), args);     case 'nbrowser_download_wait': // #6 wait for a download to complete; optional saveToSubfolder
native-browser-bridge-manifest.json+4−4
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.1.29",-  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.29/native-browser-bridge-v0.1.29.zip",-  "sha256": "3ca90794f424a226f5daed4691e01880038bdc662310366a26d531b51fe9c73f",-  "size": 79849,+  "version": "0.1.30",+  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.30/native-browser-bridge-v0.1.30.zip",+  "sha256": "6b3b036ddb3b0a97db1fb7f94f09ab58d8f2bd1e742930eb6a43657adc082c4c",+  "size": 81322,   "released_at": "2026-07-18T00:00:00Z" }
tools/verbs-policy.json+2−1
@@ -55,5 +55,6 @@   "screenshot": "readonly",   "screenshot_full_res": "readonly",   "eval": "mutating",-  "evaluate": "alias"+  "evaluate": "alias",+  "set_file_input": "mutating" }\ No newline at end of file