// SPDX-License-Identifier: GPL-3.0-only /** * Inferway Studio — in-iframe shim for the pinned ComfyUI frontend dist * (comfyui-frontend-package==1.51.10), Studio L3b slice 2, requirements 2/6/9. * * WHAT THIS IS * ------------ * A plain, unminified, dependency-free ES2020 script. It is both the file that * runs inside the sandboxed editor document AND the published source of what * runs there — nothing is bundled, minified or generated. * * The build injects ONE line into the dist's `index.html`: * * * * as the first element of ``, so this classic script executes before the * module bundle. Before the frontend starts it: * * 0. pulls in `inferway-bridge-protocol.js` (a classic script, written into * the parse stream with document.write, so it runs before the bundle * too) — the closed message-type list, schema validation and size caps; * 1. replaces localStorage / sessionStorage with in-memory Storage * implementations (the native ones throw SecurityError under the sandbox), * and — only when this frame's own url carries `?workflow=` — seeds * the two values the frontend reads to decide WHICH workflow to open * (its frame-local `clientId` and the active-path pointer); see * `seedSelectedWorkflow` below (Studio L4c); * 2. defines `document.cookie` so reads return "" and writes are ignored, * without ever throwing; * 3. replaces `fetch` and `XMLHttpRequest`: * - `/api/…` → never sent: becomes a postMessage request * to the parent page, resolved with the parent's answer; * - same-directory static assets (`/…`) → pass through to * the native fetch unchanged; * - `data:` and this document's own `blob:` URLs → decoded in memory * (no network, no CSP: `connect-src 'self'` refuses both, and the * inline `data:application/wasm` fetch must not raise an uncaught * rejection at boot); * - anything else (api.comfy.org, any other origin, `/v1/…`, …) → * refused: a thrown/rejected error plus a console.error naming the * path. Never silently allowed. * 4. replaces `WebSocket`: the editor's own `ws` URL gets an inert stub that * reports OPEN and never touches the network; any other WebSocket URL * throws (with a console.error); * 5. replaces `alert` / `confirm` / `prompt` with in-editor DOM dialogs. * The native calls are synchronous, a DOM dialog cannot be, so the * replacement returns immediately with the SAFE default — `false` * (cancel) for confirm, `null` (nothing typed) for prompt — and shows the * message in a dismissible in-editor dialog. Every native `confirm(` call * site in this dist is destructive (clear workflow / load default * workflow / follow a dangerous link), so cancel is the safe answer; * there is no native `alert(` or `prompt(` call site in 1.51.10; * 6. routes downloads (anchor clicks with the `download` attribute — which * is how every `createObjectURL` save reaches the disk) and clipboard * WRITES to the parent as bridge messages. Clipboard READS (paste) are * not implemented: a read would let an untrusted frame pull the user's * clipboard at any moment, and the parent has no gesture to gate it on; * 7. turns the frontend's version check against `api.comfy.org` off through * a SETTING the bridge answers (`Comfy.Notification.ShowVersionUpdates` * = false in the default settings table), not by patching the frontend. * See `website/src/lib/studioBridge.ts`. * * The editor document is untrusted: this file never sends a credential, never * opens a socket, and never lets a request leave the frame without a decision. */ (function () { "use strict"; var SHIM_VERSION = "inferway-shim/1 (comfyui-frontend 1.51.10)"; var LOG_PREFIX = "[inferway-shim] "; // ------------------------------------------------------------------------- // 0. Shared protocol + editor directory // ------------------------------------------------------------------------- function protocol() { var api = window.InferwayBridgeProtocol; if (!api) { console.error(LOG_PREFIX + "inferway-bridge-protocol.js did not load; the bridge is disabled"); return null; } return api; } /** * Pull the protocol file into the parse stream. document.write during * parsing inserts the tag right after this script, so it is fetched and * executed before the module bundle — no async ordering to get wrong. The * guard keeps a late/second evaluation from ever wiping the document. */ function loadProtocol() { if (window.InferwayBridgeProtocol) return; if (document.readyState !== "loading") return; try { document.write(''); } catch (error) { console.error(LOG_PREFIX + "could not load the bridge protocol: " + error); } } /** * The directory the dist is served under, e.g. `/console/studio/editor`. * Computed once, at load, from the document URL — exactly how the frontend * itself derives `api_base` (`location.pathname.split("/").slice(0,-1)`), * and before any client-side navigation could change the pathname. */ function computeEditorDir() { var path = location.pathname; var parts = path.split("/"); var last = parts[parts.length - 1]; if (last.indexOf(".") >= 0 || path.charAt(path.length - 1) === "/") parts.pop(); var dir = parts.join("/"); return dir === "" ? "/" : dir; } var EDITOR_DIR = computeEditorDir(); var stats = { bridgeRequests: 0, blockedRequests: 0, passthroughRequests: 0, blockedWebSockets: 0, inertWebSockets: 0, dialogs: 0, downloads: 0, clipboardWrites: 0, blockedClipboardReads: 0, }; /** * Classify one URL for fetch/XHR: * {kind:"bridge"} `/api/…` → postMessage to the parent * {kind:"passthrough"} same-directory static asset, blob:, data: * {kind:"refuse"} everything else — including every other origin */ function classifyUrl(raw) { var url; try { url = new URL(String(raw), location.href); } catch (error) { return { kind: "refuse", reason: "unresolved URL", path: String(raw) }; } var scheme = url.protocol; if (scheme === "blob:" || scheme === "data:" || scheme === "about:" || scheme === "chrome:" || scheme === "chrome-error:") { return { kind: "passthrough", path: url.href, url: url }; } if (scheme !== "http:" && scheme !== "https:") { return { kind: "refuse", reason: "scheme " + scheme, path: url.href }; } if (url.host !== location.host) { return { kind: "refuse", reason: "origin " + url.host + " is not the editor origin", path: url.href }; } var path = url.pathname; var inEditorDir = path === EDITOR_DIR || path.indexOf(EDITOR_DIR + "/") === 0; if (!inEditorDir) { return { kind: "refuse", reason: "outside the editor prefix " + EDITOR_DIR, path: path }; } var rest = path.slice(EDITOR_DIR.length); if (rest === "/api" || rest.indexOf("/api/") === 0) { var apiPath = rest.slice("/api".length); if (apiPath === "") apiPath = "/"; return { kind: "bridge", apiPath: apiPath + url.search, path: path + url.search }; } return { kind: "passthrough", path: path + url.search, url: url }; } /** The editor's own socket, or a refusal. Anything else must throw. */ function classifyWs(raw) { var url; try { url = new URL(String(raw), location.href); } catch (error) { return { kind: "refuse", reason: "unresolved URL", path: String(raw) }; } if (url.protocol !== "ws:" && url.protocol !== "wss:") { return { kind: "refuse", reason: "scheme " + url.protocol, path: url.href }; } if (url.host !== location.host) { return { kind: "refuse", reason: "origin " + url.host + " is not the editor origin", path: url.href }; } if (url.pathname === EDITOR_DIR + "/ws") return { kind: "inert", url: url }; return { kind: "refuse", reason: "not the editor socket", path: url.pathname }; } function refuseRequest(how, kind, detail) { var message = LOG_PREFIX + "blocked " + kind + " request: " + detail; stats.blockedRequests += 1; console.error(message); var error = new Error(message); error.name = "InferwayStudioBlockedRequest"; if (how === "throw") throw error; return Promise.reject(error); } // ------------------------------------------------------------------------- // 1. Storage — in-memory, because the sandboxed document has none // ------------------------------------------------------------------------- function createMemoryStorage() { var store = Object.create(null); return { getItem: function (key) { var name = String(key); return Object.prototype.hasOwnProperty.call(store, name) ? store[name] : null; }, setItem: function (key, value) { store[String(key)] = String(value); }, removeItem: function (key) { delete store[String(key)]; }, clear: function () { for (var name in store) { if (Object.prototype.hasOwnProperty.call(store, name)) delete store[name]; } }, key: function (index) { var keys = Object.keys(store); return index >= 0 && index < keys.length ? keys[index] : null; }, get length() { return Object.keys(store).length; }, }; } function defineOnWindow(name, value) { try { Object.defineProperty(window, name, { value: value, configurable: true, writable: true }); return; } catch (error) { /* fall through to a plain assignment */ } try { window[name] = value; } catch (error2) { console.error(LOG_PREFIX + "could not replace " + name + ": " + error2); } } /** * Register a listener through ONE list, kept on the window: a second * evaluation of this file (a re-injection) removes the previous generation * instead of stacking a duplicate handler on top of it. */ function removeAllPreviousListeners() { var previous = window.__inferwayStudioListeners; if (!Array.isArray(previous)) return; for (var i = 0; i < previous.length; i += 1) { var entry = previous[i]; entry.target.removeEventListener(entry.type, entry.handler, entry.capture); } } var listenerRegistrations = []; function listen(target, type, handler, capture) { target.addEventListener(type, handler, capture); listenerRegistrations.push({ target: target, type: type, handler: handler, capture: capture }); } /** * The native fetch, kept before the replacement so http(s) passthrough still works. */ var nativeFetchForBridge = null; /** * Object URLs this document created, so a `blob:` read can be served from * memory. `connect-src 'self'` does NOT admit `blob:` or `data:` — a native * fetch of either is refused by CSP — and neither needs the network: they * are decoded here, which also keeps the inline `data:application/wasm` * fetch three.js makes from raising an uncaught rejection at boot. */ var objectUrlBlobs = Object.create(null); function base64ToBytesLocal(base64) { var binary = window.atob(base64); var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes; } function decodeDataUrl(href) { var comma = href.indexOf(","); if (comma < 0) throw new Error("malformed data URL"); var meta = href.slice(5, comma); var payload = href.slice(comma + 1); var isBase64 = /;base64$/i.test(meta); var type = isBase64 ? meta.replace(/;base64$/i, "") : meta; var bytes; if (isBase64) bytes = base64ToBytesLocal(payload); else { // Non-base64 payload: percent-encoded text. This line used to call // `new TextEncoder().decode(…)` — `TextEncoder` has NO `decode`, so it // threw, fell out of the caller's try and sent the URL to the // CSP-blocked native fetch instead. A `TextDecoder` cannot build the // body either: it consumes bytes and rejects the string // `decodeURIComponent` returns. Encoding the decoded text is the exact // inverse operation a `Response` needs (it re-encodes UTF-8 the same // way), so the bytes a `data:` URL names are the bytes delivered. bytes = new TextEncoder().encode(decodeURIComponent(payload)); } return new Response(bytes, { status: 200, headers: { "content-type": type === "" ? "text/plain;charset=utf-8" : type }, }); } /** A Response for a data:/blob: URL this frame can answer locally, else null. */ function localResponse(url) { try { if (url.protocol === "data:") return decodeDataUrl(url.href); if (url.protocol === "blob:") { var blob = objectUrlBlobs[url.href]; if (!blob) return null; return blob.arrayBuffer().then(function (buffer) { return new Response(buffer, { status: 200, headers: { "content-type": blob.type || "application/octet-stream" }, }); }); } } catch (error) { console.error(LOG_PREFIX + "could not decode " + url.href.slice(0, 64) + ": " + error); } return null; } function installStorage() { defineOnWindow("localStorage", createMemoryStorage()); defineOnWindow("sessionStorage", createMemoryStorage()); } // ------------------------------------------------------------------------- // 1b. The selected workflow — the one thing the PARENT decides // ------------------------------------------------------------------------- /** * WHICH workflow this frame was opened for (Studio L4c, requirement: the * parent never initiates a message, so the editor has to PULL). * * The protocol lets this document ask the parent only what it would have * asked a ComfyUI server; nothing in the upstream frontend asks "which * workflow should I open?" at boot — that choice lives in this document's * own storage (`Comfy.Workflow.LastActivePath:` → * `{workspaceId, path}`, read by the frontend's `readActivePath` and used by * `initializeWorkflow` → `loadPreviousWorkflowFromStorage`), which the parent * cannot write: this frame has an opaque origin and the channel is one-way. * * So the frame reads the id out of ITS OWN url — `?workflow=`, put there * by the parent in the iframe `src` — and writes two storage values before * any of the frontend runs: * * 1. `sessionStorage.clientId` — upstream takes this from the socket's * `status` frame, and this frame's socket is inert by design, so * without it `getActivePath()` returns `null` before it looks at ANY * storage and step 2 could never be read; * 2. `localStorage["Comfy.Workflow.LastActivePath:personal"]` * = `{workspaceId:"personal", path:"workflows/.json"}` — the * pointer the frontend's `readActivePath` falls back to. * * Everything after that is upstream's own work, over the existing * `api-request` message type: * * initializeWorkflow → loadWorkflows() GET /userdata?dir=workflows… * → loadPreviousWorkflowFromStorage() * → getWorkflowByPath("workflows/.json") * → openWorkflow(...) GET /userdata/workflows%2F.json * * No new message type, no parent-initiated send, no protocol change: a pull * over two message shapes the frontend already sends. If anything here does * not match — a different id, no query, storage unavailable — nothing is * written and the editor boots exactly as it does today (blank), which is * the only correct failure mode for a hint. */ /** Ids are opaque to this file: letters, digits, `_` and `-` only — no `.`, * so no traversal can ever be spelled into the pointer. */ var WORKFLOW_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** `getWorkspaceId()` in the pinned dist returns this constant (1.51.10). */ var WORKSPACE_ID = "personal"; /** `Bs.lastActivePath(workspaceId)` in the pinned dist. */ var LAST_ACTIVE_PATH_KEY = "Comfy.Workflow.LastActivePath:" + WORKSPACE_ID; /** Where `api` reads `initialClientId` at construction (upstream name). */ var CLIENT_ID_KEY = "clientId"; /** * A frame-local client id, not a credential and never sent anywhere: the * socket that would have minted one is inert, run control is answered 503 * by the parent, and the only other thing it keys is frame-local storage. */ var CLIENT_ID_VALUE = "inferway-studio"; function selectedWorkflowPath() { var raw = null; try { raw = new URLSearchParams(location.search).get("workflow"); } catch (error) { raw = null; } if (typeof raw !== "string" || !WORKFLOW_ID_PATTERN.test(raw)) return null; return "workflows/" + raw + ".json"; } function seedSelectedWorkflow() { var path = selectedWorkflowPath(); if (!path) return; var pointer = JSON.stringify({ workspaceId: WORKSPACE_ID, path: path }); try { if (!sessionStorage.getItem(CLIENT_ID_KEY)) sessionStorage.setItem(CLIENT_ID_KEY, CLIENT_ID_VALUE); localStorage.setItem(LAST_ACTIVE_PATH_KEY, pointer); } catch (error) { console.error(LOG_PREFIX + "could not seed the selected workflow: " + error); } } // ------------------------------------------------------------------------- // 2. document.cookie — reads "", writes ignored, never throws // ------------------------------------------------------------------------- function installCookieShim() { var descriptor = { get: function () { return ""; }, set: function () {}, configurable: true }; try { Object.defineProperty(Document.prototype, "cookie", descriptor); return; } catch (prototypeError) { /* fall through to the instance */ } try { Object.defineProperty(document, "cookie", descriptor); } catch (error) { console.error(LOG_PREFIX + "could not shim document.cookie: " + error); } } // ------------------------------------------------------------------------- // 3. Bridge transport — postMessage requests to the parent, one reply each // ------------------------------------------------------------------------- var pending = Object.create(null); var requestSequence = 0; var protocolWarned = false; var originWarned = false; function nextId() { requestSequence += 1; return "iws" + requestSequence + "-" + Math.random().toString(36).slice(2, 10); } function warnProtocolOnce() { if (protocolWarned) return; protocolWarned = true; console.error(LOG_PREFIX + "bridge protocol unavailable; requests cannot be answered"); } function warnOriginOnce() { if (originWarned) return; originWarned = true; console.error( LOG_PREFIX + "this document has no usable origin (location.origin is \"null\" or empty); the bridge request was not sent" ); } /** * Send one request and resolve with the parent's single answer. `timeout` * keeps a dropped frame from hanging the editor forever. */ function sendRequest(type, fields, timeout) { var api = protocol(); if (!api) { warnProtocolOnce(); return Promise.reject(new Error(LOG_PREFIX + "bridge protocol unavailable")); } if (!window.parent || window.parent === window) { var noParent = new Error(LOG_PREFIX + "no parent frame: " + type + " cannot be answered here"); console.error(noParent.message); return Promise.reject(noParent); } // The parent frame is this document's own origin: `frame-ancestors 'self'` // admits only the site itself, and `location.origin` is the origin of THIS // document's URL. An opaque ("null") or empty origin names no window at // all, so send nothing rather than fall back to "*". var targetOrigin = location.origin; if (!targetOrigin || targetOrigin === "null") { warnOriginOnce(); return Promise.reject(new Error(LOG_PREFIX + "no usable parent origin: " + type + " was not sent")); } var message = { v: api.PROTOCOL_VERSION, type: type, id: nextId() }; for (var key in fields) { if (Object.prototype.hasOwnProperty.call(fields, key)) message[key] = fields[key]; } var check = api.validateMessage(message); if (!check.ok) { console.error(LOG_PREFIX + "refusing to send an invalid " + type + ": " + check.reason); return Promise.reject(new Error(LOG_PREFIX + "invalid outbound message: " + check.reason)); } stats.bridgeRequests += 1; return new Promise(function (resolve, reject) { var timer = setTimeout(function () { delete pending[message.id]; var reason = LOG_PREFIX + "bridge timeout after " + timeout + "ms for " + type; console.error(reason); reject(new Error(reason)); }, timeout); pending[message.id] = { resolve: resolve, reject: reject, timer: timer, responseType: api.RESPONSE_TYPE_FOR[type] }; // The site's own origin names the parent; the parent re-checks the // frame on every inbound message as well. window.parent.postMessage(message, targetOrigin); }); } function isResponseType(type) { return type === "api-response" || type === "download-ack" || type === "clipboard-ack"; } function installMessageListener() { listen(window, "message", function (event) { // Editor side: only the parent may answer. `event.origin` is "null" for // this document, so the SOURCE is the identity check (requirement 5). if (event.source !== window.parent) return; var message = event.data; var api = protocol(); if (!api) return; var check = api.validateMessage(message); if (!check.ok) { console.error(LOG_PREFIX + "rejected bridge message: " + check.reason); return; } if (!isResponseType(message.type)) { console.error(LOG_PREFIX + "rejected bridge message: the editor only accepts responses, got " + message.type); return; } var entry = pending[message.id]; if (!entry) { console.error(LOG_PREFIX + "rejected bridge message: no pending request with id " + message.id); return; } if (entry.responseType !== message.type) { console.error(LOG_PREFIX + "rejected bridge message: id " + message.id + " expects " + entry.responseType); return; } delete pending[message.id]; clearTimeout(entry.timer); entry.resolve(message); }); } function responseFrom(message) { var contentType = message.contentType || "application/json"; var init = { status: message.status, headers: { "content-type": contentType } }; if (message.status === 204 || message.status === 205 || message.status === 304) { return new Response(null, init); } return new Response(message.body, init); } /** api/… fetch: to the parent and back, never on the wire. */ function bridgeFetch(apiPath, method, body) { return sendRequest("api-request", { method: method, path: apiPath, body: body }, REQUEST_TIMEOUT_MS).then( function (answer) { if (answer.status >= 500 && answer.status <= 599) { console.error(LOG_PREFIX + "parent could not answer " + method + " " + apiPath + ": HTTP " + answer.status); } return responseFrom(answer); } ); } var REQUEST_TIMEOUT_MS = 15000; function bodyToString(body) { if (body === undefined || body === null) return null; if (typeof body === "string") return body; if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return body.toString(); return { unsupported: true }; } function readRequestBody(input, init) { if (init && Object.prototype.hasOwnProperty.call(init, "body")) return Promise.resolve(init.body); if (input && typeof input === "object" && typeof input.clone === "function" && input.body) { return input.clone().text().catch(function () { return undefined; }); } return Promise.resolve(undefined); } // ------------------------------------------------------------------------- // 4. fetch // ------------------------------------------------------------------------- function installFetch() { var nativeFetch = nativeFetchForBridge; if (!nativeFetch) { console.error(LOG_PREFIX + "no native fetch on this engine; static passthrough is unavailable"); } var replacement = function (input, init) { var raw = input && typeof input === "object" && typeof input.url === "string" ? input.url : String(input); var kind = classifyUrl(raw); if (kind.kind === "passthrough") { var local = localResponse(kind.url); if (local) { stats.passthroughRequests += 1; return Promise.resolve(local); } if (!nativeFetch) return Promise.reject(new Error(LOG_PREFIX + "fetch is unavailable")); stats.passthroughRequests += 1; return nativeFetch(input, init); } if (kind.kind === "refuse") return refuseRequest("reject", "fetch", kind.path + " (" + kind.reason + ")"); var method = (init && init.method) || (input && input.method) || "GET"; method = String(method).toUpperCase(); return readRequestBody(input, init).then(function (body) { var text = bodyToString(body); if (text && typeof text === "object" && text.unsupported) { return refuseRequest( "reject", "fetch with a binary body to", kind.path + " (uploads do not cross the bridge; only text bodies do)" ); } return bridgeFetch(kind.apiPath, method, text); }); }; Object.defineProperty(replacement, "__inferwayStudioNative", { value: nativeFetch, configurable: true, }); defineOnWindow("fetch", replacement); } // ------------------------------------------------------------------------- // 5. XMLHttpRequest — bridge for api/, native delegate for static, error // for everything else. Axios (and therefore every api.comfy.org client) // speaks XHR, so this path must be closed too. // ------------------------------------------------------------------------- function ShimXHR() { this.readyState = 0; this.status = 0; this.statusText = ""; this.response = null; this.responseText = ""; this.responseType = ""; this.responseURL = ""; this.responseHeaders = null; this.withCredentials = false; this.upload = { addEventListener: function () {}, removeEventListener: function () {} }; this._listeners = Object.create(null); this._native = null; this._method = "GET"; this._url = ""; this._aborted = false; this.onreadystatechange = null; this.onload = null; this.onerror = null; this.onloadend = null; this.onloadstart = null; this.onabort = null; this.ontimeout = null; this.onprogress = null; } ShimXHR.prototype.addEventListener = function (type, listener) { (this._listeners[type] || (this._listeners[type] = [])).push(listener); }; ShimXHR.prototype.removeEventListener = function (type, listener) { var list = this._listeners[type]; if (!list) return; var index = list.indexOf(listener); if (index >= 0) list.splice(index, 1); }; ShimXHR.prototype.dispatchEvent = function (event) { var type = event.type; var handler = this["on" + type]; if (typeof handler === "function") handler.call(this, event); var list = (this._listeners[type] || []).slice(); for (var i = 0; i < list.length; i += 1) list[i].call(this, event); return true; }; function xhrEvent(type, target) { if (typeof Event === "function") return new Event(type, { target: target }); var event = { type: type, target: target }; return event; } ShimXHR.prototype._setReadyState = function (state) { this.readyState = state; this.dispatchEvent(xhrEvent("readystatechange", this)); }; ShimXHR.prototype.open = function (method, url, async, user, password) { this._method = String(method || "GET").toUpperCase(); this._url = String(url); this._async = async !== false; this._aborted = false; var kind = classifyUrl(this._url); this._kind = kind; if (kind.kind === "refuse") { // Defer the failure: open() must not throw where the frontend does not // expect it, but nothing may be sent either. this._setReadyState(1); return; } if (kind.kind === "passthrough") { var NativeXHR = window.XMLHttpRequest.__native; if (!NativeXHR) { this._kind = { kind: "refuse", path: this._url, reason: "no native XHR to delegate to" }; this._setReadyState(1); return; } this._native = new NativeXHR(); this._wireNative(); this._native.open(method, url, async, user, password); this._setReadyState(this._native.readyState); return; } this._setReadyState(1); }; ShimXHR.prototype._wireNative = function () { var self = this; var native = this._native; var types = ["readystatechange", "loadstart", "progress", "abort", "error", "load", "loadend", "timeout"]; for (var i = 0; i < types.length; i += 1) { (function (type) { native.addEventListener(type, function (event) { self.readyState = native.readyState; self.status = native.status; self.statusText = native.statusText; self.responseURL = native.responseURL; try { self.responseText = native.responseText; } catch (_ignored) { /* a binary responseType has no responseText */ } self.response = native.response; self.dispatchEvent(xhrEvent(type, self)); }); })(types[i]); } }; ShimXHR.prototype.setRequestHeader = function (name, value) { if (this._native) this._native.setRequestHeader(name, value); }; ShimXHR.prototype.getAllResponseHeaders = function () { if (this._native) return this._native.getAllResponseHeaders(); if (!this.responseHeaders) return ""; var lines = []; for (var name in this.responseHeaders) { if (Object.prototype.hasOwnProperty.call(this.responseHeaders, name)) { lines.push(name + ": " + this.responseHeaders[name]); } } return lines.join("\r\n"); }; ShimXHR.prototype.getResponseHeader = function (name) { if (this._native) return this._native.getResponseHeader(name); if (!this.responseHeaders) return null; var wanted = String(name).toLowerCase(); for (var key in this.responseHeaders) { if (Object.prototype.hasOwnProperty.call(this.responseHeaders, key) && key.toLowerCase() === wanted) { return this.responseHeaders[key]; } } return null; }; ShimXHR.prototype.overrideMimeType = function (mime) { if (this._native) this._native.overrideMimeType(mime); this._mimeType = mime; }; ShimXHR.prototype.abort = function () { this._aborted = true; if (this._native) { this._native.abort(); return; } this._setReadyState(4); this.dispatchEvent(xhrEvent("abort", this)); this.dispatchEvent(xhrEvent("loadend", this)); }; ShimXHR.prototype._fail = function (message) { var self = this; stats.blockedRequests += 1; console.error(message); this.status = 0; this.statusText = ""; setTimeout(function () { if (self._aborted) return; self._setReadyState(4); self.dispatchEvent(xhrEvent("error", self)); self.dispatchEvent(xhrEvent("loadend", self)); }, 0); }; ShimXHR.prototype.send = function (body) { var self = this; if (this._native) { this._native.send(body); return; } if (!this._kind || this._kind.kind === "refuse") { this._fail( LOG_PREFIX + "blocked XHR request: " + (this._kind ? this._kind.path + " (" + this._kind.reason + ")" : this._url) ); return; } var text = bodyToString(body); if (text && typeof text === "object" && text.unsupported) { this._fail(LOG_PREFIX + "blocked XHR with a binary body to " + this._url); return; } this._setReadyState(2); this.dispatchEvent(xhrEvent("loadstart", this)); bridgeFetch(this._kind.apiPath, this._method, text) .then(function (answer) { if (self._aborted) return undefined; self.status = answer.status; self.statusText = answer.status >= 200 && answer.status < 300 ? "OK" : "Error"; try { self.responseURL = new URL(self._url, location.href).href; } catch (_ignored) { self.responseURL = self._url; } self.responseHeaders = {}; answer.headers.forEach(function (value, name) { self.responseHeaders[String(name).toLowerCase()] = value; }); if (self.responseType === "json") { return answer .text() .then(function (text) { self.responseText = ""; try { self.response = text === "" ? null : JSON.parse(text); } catch (_parseError) { self.response = null; } }) .then(function () { self._finish(); }); } return answer.text().then(function (text) { self.responseText = text; self.response = text; self._finish(); }); }) .catch(function (error) { if (self._aborted) return undefined; self._fail(String(error && error.message ? error.message : error)); }); }; ShimXHR.prototype._finish = function () { this._setReadyState(4); this.dispatchEvent(xhrEvent("load", this)); this.dispatchEvent(xhrEvent("loadend", this)); }; function installXHR() { var existing = window.XMLHttpRequest; // A previous evaluation already installed ours: keep the true native. var NativeXHR = existing && existing.__inferwayStudioNative ? existing.__inferwayStudioNative : existing; function Replacement() { return new ShimXHR(); } Replacement.prototype = ShimXHR.prototype; Replacement.__inferwayStudioNative = NativeXHR; Replacement.__native = NativeXHR; Replacement.UNSENT = 0; Replacement.OPENED = 1; Replacement.HEADERS_RECEIVED = 2; Replacement.LOADING = 3; Replacement.DONE = 4; defineOnWindow("XMLHttpRequest", Replacement); } // ------------------------------------------------------------------------- // 6. WebSocket — inert stub for the editor's own socket, throw otherwise // ------------------------------------------------------------------------- function createEventTarget(host) { var listeners = Object.create(null); return { addEventListener: function (type, listener) { (listeners[type] || (listeners[type] = [])).push(listener); }, removeEventListener: function (type, listener) { var list = listeners[type]; if (!list) return; var index = list.indexOf(listener); if (index >= 0) list.splice(index, 1); }, dispatchEvent: function (event) { var type = event.type; var handler = host["on" + type]; if (typeof handler === "function") handler.call(host, event); var list = (listeners[type] || []).slice(); for (var i = 0; i < list.length; i += 1) list[i].call(host, event); return true; }, }; } function ShimWebSocket(url, protocols) { var target; if (!(this instanceof ShimWebSocket)) return new ShimWebSocket(url, protocols); var kind = classifyWs(url); if (kind.kind !== "inert") { stats.blockedWebSockets += 1; var message = LOG_PREFIX + "blocked WebSocket: " + (kind.path || url) + " (" + kind.reason + ")"; console.error(message); throw new Error(message); } stats.inertWebSockets += 1; this.url = String(url); this.readyState = ShimWebSocket.CONNECTING; this.bufferedAmount = 0; this.extensions = ""; this.protocol = ""; this.binaryType = "arraybuffer"; this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null; target = createEventTarget(this); this.addEventListener = target.addEventListener; this.removeEventListener = target.removeEventListener; this.dispatchEvent = target.dispatchEvent; var self = this; // "Reports open and never sends anything over the network": the frontend // stops retrying the real socket, and progress arrives by polling (E8). setTimeout(function () { if (self.readyState !== ShimWebSocket.CONNECTING) return; self.readyState = ShimWebSocket.OPEN; self.dispatchEvent({ type: "open", target: self }); }, 0); void protocols; } ShimWebSocket.CONNECTING = 0; ShimWebSocket.OPEN = 1; ShimWebSocket.CLOSING = 2; ShimWebSocket.CLOSED = 3; ShimWebSocket.prototype.CONNECTING = 0; ShimWebSocket.prototype.OPEN = 1; ShimWebSocket.prototype.CLOSING = 2; ShimWebSocket.prototype.CLOSED = 3; ShimWebSocket.prototype.send = function () { /* inert: nothing is ever written to a socket */ }; ShimWebSocket.prototype.close = function (code, reason) { var self = this; if (this.readyState === ShimWebSocket.CLOSED || this.readyState === ShimWebSocket.CLOSING) return; this.readyState = ShimWebSocket.CLOSING; setTimeout(function () { self.readyState = ShimWebSocket.CLOSED; self.dispatchEvent({ type: "close", target: self, code: code || 1000, reason: String(reason || "") }); }, 0); }; function installWebSocket() { defineOnWindow("WebSocket", ShimWebSocket); } // ------------------------------------------------------------------------- // 7. Dialogs — alert / confirm / prompt become in-editor DOM dialogs // ------------------------------------------------------------------------- var DIALOG_STYLE_ID = "inferway-studio-dialog-style"; function ensureDialogStyle() { if (document.getElementById(DIALOG_STYLE_ID)) return; var style = document.createElement("style"); style.id = DIALOG_STYLE_ID; style.textContent = [ ".inferway-studio-backdrop{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:inherit}", ".inferway-studio-dialog{background:#1b1b1f;color:#f2f2f3;border:1px solid #3a3a40;border-radius:10px;padding:16px 18px;max-width:420px;width:calc(100% - 48px);box-shadow:0 12px 40px rgba(0,0,0,.5)}", ".inferway-studio-dialog h2{margin:0 0 8px;font-size:15px;line-height:1.4}", ".inferway-studio-dialog p{margin:0 0 8px;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word}", ".inferway-studio-dialog .inferway-studio-note{color:#ffb4a8}", ".inferway-studio-dialog button{margin-top:6px;padding:6px 14px;border-radius:6px;border:1px solid #4a4a52;background:#2a2a30;color:#f2f2f3;font-size:13px;cursor:pointer}", ".inferway-studio-dialog button:hover{background:#33333a}", ].join(""); (document.head || document.documentElement).appendChild(style); } function dismissDialog(element) { if (element && element.parentNode) element.parentNode.removeChild(element); } /** * Render one dismissible dialog. Returns the element so the caller can * count it. Every dialog is informational: the decision (cancel) has already * been returned to the caller by the time this paints. */ function showInEditorDialog(title, message, note) { ensureDialogStyle(); var backdrop = document.createElement("div"); backdrop.className = "inferway-studio-backdrop"; var dialog = document.createElement("div"); dialog.className = "inferway-studio-dialog"; dialog.setAttribute("role", "alertdialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-label", title); var heading = document.createElement("h2"); heading.textContent = title; dialog.appendChild(heading); var body = document.createElement("p"); body.className = "inferway-studio-message"; body.textContent = message === undefined || message === null ? "" : String(message); dialog.appendChild(body); if (note) { var noteElement = document.createElement("p"); noteElement.className = "inferway-studio-note"; noteElement.textContent = note; dialog.appendChild(noteElement); } var button = document.createElement("button"); button.type = "button"; button.textContent = "OK"; button.addEventListener("click", function () { dismissDialog(backdrop); }); dialog.appendChild(button); backdrop.addEventListener("click", function (event) { if (event.target === backdrop) dismissDialog(backdrop); }); backdrop.appendChild(dialog); (document.body || document.documentElement).appendChild(backdrop); stats.dialogs += 1; return backdrop; } function dialogKeydown(event) { if (event.key !== "Escape" && event.key !== "Enter") return; var backdrops = document.querySelectorAll(".inferway-studio-backdrop"); if (backdrops.length > 0) dismissDialog(backdrops[backdrops.length - 1]); } /** * All three are synchronous in JS and asynchronous in a DOM, so the return * value is decided first — always the non-destructive one: * alert → undefined (nothing to answer), dialog shown * confirm → false (cancel), dialog explains the cancellation * prompt → null (no input), dialog shows the question */ function installDialogs() { defineOnWindow("alert", function (message) { showInEditorDialog("Notice", message, null); return undefined; }); defineOnWindow("confirm", function (message) { showInEditorDialog( "Confirmation unavailable", message, "The editor runs in a sandbox without native dialogs, so this action was cancelled. Nothing changed." ); console.warn(LOG_PREFIX + "confirm() cancelled by the shim: " + String(message).slice(0, 200)); return false; }); defineOnWindow("prompt", function (message, defaultValue) { showInEditorDialog( "Input unavailable", message, "The editor runs in a sandbox without native dialogs, so nothing was entered. Nothing changed." ); console.warn(LOG_PREFIX + "prompt() cancelled by the shim: " + String(message).slice(0, 200)); void defaultValue; return null; }); listen(document, "keydown", dialogKeydown, true); } // ------------------------------------------------------------------------- // 8. Downloads — anchor clicks with `download` go to the parent // ------------------------------------------------------------------------- function toBase64(bytes) { var binary = ""; var chunk = 0x8000; for (var i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return window.btoa(binary); } /** * Read the bytes behind a download href: data: and tracked blob: URLs are * decoded in the frame (no network, no CSP), `api/…` is read OVER THE * BRIDGE like every other api request, and only a same-directory static * asset ever reaches the native fetch — which the URL classifier has * already confined to this directory. */ function readDownloadSource(href, kind) { var url; try { url = new URL(String(href), location.href); } catch (error) { return Promise.reject(new Error("unresolved download URL: " + href)); } var local = localResponse(url); if (local) { var responsePromise = local instanceof Promise ? local : Promise.resolve(local); return responsePromise.then(function (response) { return response.arrayBuffer().then(function (buffer) { return { bytes: new Uint8Array(buffer), mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0], }; }); }); } if (kind && kind.kind === "bridge") { // The stashed native fetch below would send a REAL request to the api // prefix — exactly what requirement 2 forbids. The bridge answers it. return bridgeFetch(kind.apiPath, "GET", null).then(function (response) { if (!response.ok) throw new Error("HTTP " + response.status); return response.arrayBuffer().then(function (buffer) { return { bytes: new Uint8Array(buffer), mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0], }; }); }); } var nativeFetch = nativeFetchForBridge; if (!nativeFetch) return Promise.reject(new Error(LOG_PREFIX + "no native fetch to read the download with")); return nativeFetch(href).then(function (response) { if (!response.ok) throw new Error("HTTP " + response.status); return response.arrayBuffer().then(function (buffer) { return { bytes: new Uint8Array(buffer), mime: (response.headers.get("content-type") || "application/octet-stream").split(";")[0], }; }); }); } /** * The ONE subresource in upstream `index.html` that lives under `api/` is * ``. It is not a fetch * and not an XHR, so the interception above never sees it. The link element * is dropped the moment it is attached (a MutationObserver runs before any * style sheet is applied), and every other element that would resolve under * `api/` is dropped with it: nothing under the api prefix may be loaded as * a resource either (requirement 2). Everything else is left alone — * including `` elements: they are not subresources, their click * is intercepted above, and an `api/…` href on one is read over the bridge. */ function installApiSubresourceGuard() { if (typeof MutationObserver !== "function") return; var root = document.documentElement; if (!root) return; var observer = new MutationObserver(function (records) { for (var i = 0; i < records.length; i += 1) { var added = records[i].addedNodes; for (var j = 0; j < added.length; j += 1) { var node = added[j]; if (!node || node.nodeType !== 1) continue; if (node.tagName === "A" && node.hasAttribute("download")) continue; var attribute = node.getAttribute && (node.getAttribute("href") || node.getAttribute("src")); if (!attribute) continue; var kind = classifyUrl(attribute); if (kind.kind === "bridge") { console.error(LOG_PREFIX + "blocked the subresource " + kind.path + " under the api prefix"); if (node.parentNode) node.parentNode.removeChild(node); } } } }); observer.observe(root, { childList: true, subtree: true }); listen(document, "DOMContentLoaded", function () { observer.disconnect(); }); } /** Remember every object URL this frame creates so its blob can be read back. */ function installObjectUrlTracking() { var nativeCreate = URL.createObjectURL; var nativeRevoke = URL.revokeObjectURL; if (typeof nativeCreate === "function") { var replacementCreate = function (value) { var objectUrl = nativeCreate.call(URL, value); if (value && typeof value.arrayBuffer === "function") objectUrlBlobs[objectUrl] = value; return objectUrl; }; try { Object.defineProperty(URL, "createObjectURL", { value: replacementCreate, configurable: true, writable: true }); } catch (error) { try { URL.createObjectURL = replacementCreate; } catch (error2) { console.error(LOG_PREFIX + "could not wrap URL.createObjectURL: " + error2); } } } if (typeof nativeRevoke === "function") { var replacementRevoke = function (objectUrl) { delete objectUrlBlobs[String(objectUrl)]; return nativeRevoke.call(URL, objectUrl); }; try { Object.defineProperty(URL, "revokeObjectURL", { value: replacementRevoke, configurable: true, writable: true }); } catch (error) { try { URL.revokeObjectURL = replacementRevoke; } catch (error2) { console.error(LOG_PREFIX + "could not wrap URL.revokeObjectURL: " + error2); } } } } function safeFileName(name) { var cleaned = String(name || "download").split(/[\\/]/).pop() || "download"; return cleaned.slice(0, 255); } function saveViaParent(anchor) { var href = anchor.getAttribute("href"); var name = safeFileName(anchor.getAttribute("download")); if (!href) { console.error(LOG_PREFIX + "blocked download without an href: " + name); return; } var kind = classifyUrl(href); if (kind.kind === "refuse") { console.error(LOG_PREFIX + "blocked download of " + kind.path + " (" + kind.reason + ")"); return; } var mime = "application/octet-stream"; readDownloadSource(href, kind) .then(function (file) { mime = file.mime; return sendRequest("download", { name: name, mime: mime, data: toBase64(file.bytes) }, REQUEST_TIMEOUT_MS); }) .then(function (ack) { if (!ack.ok) { console.error(LOG_PREFIX + "parent refused the download of " + name + ": " + (ack.error || "no reason")); return; } stats.downloads += 1; console.warn(LOG_PREFIX + "download handed to the parent: " + name); }) .catch(function (error) { console.error(LOG_PREFIX + "download of " + name + " failed: " + (error && error.message ? error.message : error)); }); } function installDownloadInterception() { // Capture phase: the default (the browser's own download) is stopped // before it can start, while the editor's own click handlers still run. listen( document, "click", function (event) { var node = event.target; if (!node || typeof node.closest !== "function") return; var anchor = node.closest("a[download]"); if (!anchor) return; event.preventDefault(); saveViaParent(anchor); }, true ); } // ------------------------------------------------------------------------- // 9. Clipboard — writes cross the bridge, reads do not exist here // ------------------------------------------------------------------------- function clipboardError(message) { console.error(LOG_PREFIX + message); if (typeof DOMException === "function") return new DOMException(message, "NotAllowedError"); var error = new Error(message); error.name = "NotAllowedError"; return error; } function refuseClipboardRead(operation) { stats.blockedClipboardReads += 1; return Promise.reject( clipboardError( "clipboard " + operation + " is not available: a sandboxed editor may not read the user's clipboard. Copy still works; paste must be supplied by the host UI." ) ); } function installClipboard() { var implementation = { writeText: function (text) { if (typeof text !== "string") { return Promise.reject(new TypeError(LOG_PREFIX + "clipboard.writeText expects a string")); } return sendRequest("clipboard-write", { text: text }, REQUEST_TIMEOUT_MS).then(function (ack) { if (!ack.ok) throw clipboardError("parent refused the clipboard write: " + (ack.error || "no reason")); stats.clipboardWrites += 1; return undefined; }); }, write: function (items) { // Only text/plain survives the bridge (the parent handler takes a // string). An image write is refused loudly rather than dropped. var list = typeof items === "object" && items !== null ? Array.prototype.slice.call(items) : []; var textItem = null; var onlyText = list.length > 0; for (var i = 0; i < list.length; i += 1) { var types = list[i] && list[i].types; var itemTypes = types ? Array.prototype.slice.call(types) : []; if (itemTypes.length === 1 && itemTypes[0] === "text/plain" && !textItem) textItem = list[i]; else onlyText = false; } if (onlyText && textItem && typeof textItem.getType === "function") { return textItem.getType("text/plain").then(function (blob) { return blob.text().then(function (text) { return implementation.writeText(text); }); }); } return Promise.reject( clipboardError( "clipboard.write is limited to text/plain here; writing files or images to the clipboard is not supported by the editor bridge" ) ); }, readText: function () { return refuseClipboardRead("readText"); }, read: function () { return refuseClipboardRead("read"); }, }; try { Object.defineProperty(navigator, "clipboard", { value: implementation, configurable: true, enumerable: true }); } catch (error) { console.error(LOG_PREFIX + "could not replace navigator.clipboard: " + error); } } // ------------------------------------------------------------------------- // 10. Keep the native fetch reachable for our own download reads // ------------------------------------------------------------------------- function stashNativeFetch() { var current = window.fetch; // A previous evaluation of this file already wrapped the native one. if (current && current.__inferwayStudioNative) { nativeFetchForBridge = current.__inferwayStudioNative; return; } if (typeof current === "function") nativeFetchForBridge = current.bind(window); } // ------------------------------------------------------------------------- // Install, in order, before anything else runs // ------------------------------------------------------------------------- removeAllPreviousListeners(); loadProtocol(); installStorage(); seedSelectedWorkflow(); installCookieShim(); installMessageListener(); stashNativeFetch(); installObjectUrlTracking(); installFetch(); installXHR(); installWebSocket(); installDialogs(); installDownloadInterception(); installClipboard(); installApiSubresourceGuard(); window.__inferwayStudioListeners = listenerRegistrations; /** * Introspection handle — always present, never conditional. It exposes * counts and the URL classifier so tests and support engineers can read * what the shim decided; it exposes no message payloads and changes no * behaviour. */ defineOnWindow( "__inferwayStudioShim", Object.freeze({ version: SHIM_VERSION, editorDir: EDITOR_DIR, protocolUrl: "./inferway-bridge-protocol.js", classify: classifyUrl, classifyWs: classifyWs, stats: stats, }) ); })();