← Commit history
Update 9 file(s)
9 files changed
+132−10
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.29+0.1.30
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" }