From 900515a73e2f9779951817143d46b09b58008f01 Mon Sep 17 00:00:00 2001 From: LexZeon <287497354+LexZeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:35:53 +0000 Subject: [PATCH 1/3] Add native Windows ARM64 builds and offline runtime guards --- docs/windows-arm64-offline.md | 128 ++++++++++++++++++ packages/app/src/entry.tsx | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 2 +- packages/opencode/script/build.ts | 13 +- packages/opencode/src/bun/index.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- packages/opencode/src/cli/cmd/tui/worker.ts | 1 + packages/opencode/src/config/config.ts | 8 +- packages/opencode/src/file/ripgrep.ts | 2 +- packages/opencode/src/index.ts | 1 + packages/opencode/src/offline/index.ts | 24 +++- packages/opencode/src/offline/network.ts | 44 ++++++ packages/opencode/src/permission/next.ts | 17 ++- packages/opencode/src/server/server.ts | 6 +- packages/opencode/test/offline/fetch-guard.ts | 103 ++++++++++++++ 15 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 docs/windows-arm64-offline.md create mode 100644 packages/opencode/src/offline/network.ts create mode 100644 packages/opencode/test/offline/fetch-guard.ts diff --git a/docs/windows-arm64-offline.md b/docs/windows-arm64-offline.md new file mode 100644 index 000000000000..2007ac4ce814 --- /dev/null +++ b/docs/windows-arm64-offline.md @@ -0,0 +1,128 @@ +# Windows ARM64 offline build + +This adds a native Windows ARM64 CLI target and makes the offline dependency +paths fail closed. It does not replace the existing Linux x64 bundle packager. + +## Build prerequisites + +- Windows on ARM64 and a native ARM64 Bun **1.4.4** runtime. +- A checkout of this fork, with this patch, and Git available for the build. +- Network access while installing the pinned build dependencies. + +Bun 1.3.10 can compile the CLI but cannot open the OpenTUI renderer on native +Windows ARM64 because its `bun:ffi` implementation is disabled. Bun 1.4 enables +that support; 1.4.4 is the version tested here. See the [Bun 1.4 release +notes](https://bun.com/blog/bun-v1.4) and [the Windows ARM64 FFI +fix](https://github.com/oven-sh/bun/pull/33696). + +Run these PowerShell commands from the repository root: + +```powershell +bun --version +bun -e 'console.log(process.platform, process.arch)' +# Expected: 1.4.4, then win32 arm64 +bun install --frozen-lockfile + +# A local catalog avoids a models.dev request during compilation. +# Supply explicit local provider/model definitions in the runtime config. +Set-Content -LiteralPath './models-local.json' -Value '{}' -Encoding ascii +$env:MODELS_DEV_API_JSON = (Resolve-Path './models-local.json').Path +$env:OPENCODE_VERSION = '1.2.13' +$env:OPENCODE_CHANNEL = 'local' + +Push-Location './packages/opencode' +try { bun run script/build.ts --single --skip-install } +finally { Pop-Location } +bun run --cwd packages/app build +``` + +The CLI output is +`packages/opencode/dist/opencode-windows-arm64/bin/opencode.exe`. +The local web assets are in `packages/app/dist`. +This process builds the application; it does not download language-model weights. + +## Prepare a separate runtime + +Use a dedicated directory for this fork. Set all four `XDG_CONFIG_HOME`, +`XDG_DATA_HOME`, `XDG_CACHE_HOME`, and `XDG_STATE_HOME` variables to new directories +before the process starts. Do not point this older fork at a newer OpenCode +database or cache. Its cache initialization and database migrations have their +own version assumptions. + +Also use separate `OPENCODE_TEST_HOME` and +`OPENCODE_TEST_MANAGED_CONFIG_DIR` directories if the deployment must avoid +loading the existing home or managed OpenCode settings. Use a fresh auth store. + +Enable the following settings before launching the compiled CLI: + +```powershell +$env:OPENCODE_OFFLINE_MODE = 'true' +$env:OPENCODE_OFFLINE_DEPS_PATH = '/deps' +$env:OPENCODE_DISABLE_AUTOUPDATE = 'true' +$env:OPENCODE_DISABLE_DEFAULT_PLUGINS = 'true' +$env:OPENCODE_DISABLE_LSP_DOWNLOAD = 'true' +$env:OPENCODE_DISABLE_MODELS_FETCH = 'true' +$env:OPENCODE_DISABLE_PROJECT_CONFIG = 'true' +$env:OPENCODE_DISABLE_EXTERNAL_SKILLS = 'true' +$env:OPENCODE_DISABLE_CLAUDE_CODE = 'true' +$env:OPENCODE_DISABLE_SHARE = 'true' +``` + +Prepare `deps/app` with the entire built web output, `deps/ripgrep/rg.exe` with a +compatible local ripgrep binary, and `deps/models.json` with the local catalog. +Ripgrep already on `PATH` is also supported. Required npm packages must already +be present in `deps/node_modules`; offline mode will not install missing ones. +Set `lsp: false` unless the required language servers have been staged locally. +The existing dependency download and packaging scripts still select Linux x64 +binaries, so do not run them to prepare Windows dependencies. + +Configure only the intended local provider, using an explicit literal-loopback +endpoint such as `http://127.0.0.1:11434/v1`, and the model IDs already installed +at that endpoint. Set `share: "disabled"`, `autoupdate: false`, `plugin: []`, and +disable unused web tools and remote MCP servers. This fork predates newer config +fields such as `subagent_depth`; validate copied configuration against its schema. + +## Behavior and validation + +In offline mode, missing web assets return HTTP 503 instead of proxying the +hosted UI. Missing ripgrep or npm dependencies fail without attempting a download. +The CLI and TUI worker guard JavaScript `fetch` and `preconnect`: only literal +loopback HTTP endpoints and embedded binary/Bun virtual-file resources are +permitted. Automatic redirects are rejected. Extra language grammars that use +remote URLs are not registered; OpenTUI's bundled parsers remain available. + +The bundled web response includes a CSP restricting external asset and connection +loads. Notification and project favicons use local assets. The named MCP tools +`spark_files_replace_text` and `spark_files_create_file` always require a fresh +approval unless denied: an `Always` response grants only that call, and cached +approval cannot bypass the next request. This does not change the MCP tools' +file-validation or mutation implementations. + +Observed validation on native Windows ARM64 with Bun 1.4.4: + +- Fresh checkout and frozen dependency installation built both CLI and web assets. +- Native TUI startup and system-DLL FFI scalar, pointer, and double calls worked. +- An isolated local-provider session read synthetic files and completed an exact + edit and a new-file creation only after approval. A later creation asked again; + rejecting it left the file absent. `Always` behaved as a one-call approval. +- The local HTML response included the external-resource CSP. +- The source fetch guard passed 26 checks covering local HTTP, embedded binary + data, misleading hostnames, UNC/file-path boundaries, and redirect blocking. + +Run the guard check from `packages/opencode`: + +```powershell +bun run test/offline/fetch-guard.ts +``` + +These are functional checks, not model-quality or performance benchmarks. The +inference check used a short-context test profile; it does not establish maximum +context size or full GPU offload. The Linux container offline suite was not rerun +for this Windows adaptation. Browser packet capture was not performed: CSP headers +and local HTML were checked, rather than claiming every browser feature was tested. + +The guard is not an operating-system egress sandbox. Raw sockets, child processes, +and third-party worker realms can have separate network stacks. CSP does not stop +a user opening an external link in another tab, and browser speech recognition is +not verified to run offline. Keep runtime dependencies, provider configuration, +tool permissions, and any process-level network controls explicit. diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index e9c0a4397c99..3caa774c5b63 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -68,7 +68,7 @@ const notify: Platform["notify"] = async (title, description, href) => { const notification = new Notification(title, { body: description ?? "", - icon: "https://opencode.ai/favicon-96x96-v3.png", + icon: new URL("/favicon-96x96-v3.png", location.origin).href, }) notification.onclick = () => { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 194f75f815b7..eed0ac7c0983 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -33,7 +33,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti = {} if (!skipInstall) { @@ -189,7 +196,9 @@ for (const item of targets) { }, }) - await $`rm -rf ./dist/${name}/bin/tui` + const tuiPath = path.resolve(distRoot, name, "bin/tui") + if (!tuiPath.startsWith(distRoot + path.sep)) throw new Error("Unexpected auxiliary output directory") + await fs.promises.rm(tuiPath, { recursive: true, force: true }) await Bun.file(`dist/${name}/package.json`).write( JSON.stringify( { diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 7bd1e6ac2ce5..ab2a29e20d28 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -66,7 +66,7 @@ export namespace BunProc { return offlinePath } } - log.warn("offline mode enabled but package not found in offline deps", { pkg }) + throw new Error(`Offline dependency ${pkg} is missing from the local bundle; no package download was attempted.`) } // Use lock to ensure only one install at a time diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 6d418ee7f130..3aee266033d8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -79,7 +79,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" -addDefaultParsers(parsers.parsers) +// offline-fork: additional grammars/queries above use remote URLs; keep core rendering offline. +if (!Flag.OPENCODE_OFFLINE_MODE) addDefaultParsers(parsers.parsers) class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index bb5495c48110..625d163bccc2 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,3 +1,4 @@ +import "@/offline/network" import { Installation } from "@/installation" import { Server } from "@/server/server" import { Log } from "@/util/log" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 761ce23f3d6c..faa0ec56a48d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -34,6 +34,7 @@ import { PackageRegistry } from "@/bun/registry" import { proxied } from "@/util/proxied" import { iife } from "@/util/iife" import { Control } from "@/control" +import { Offline } from "@/offline" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -80,7 +81,7 @@ export namespace Config { // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { + if (value.type === "wellknown" && !Offline.isEnabled()) { process.env[value.key] = value.token log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) const response = await fetch(`${key}/.well-known/opencode`) @@ -171,7 +172,9 @@ export namespace Config { } } - deps.push( + // offline-fork: stdio MCP needs no config-directory npm install. + // Required provider/plugin packages must already be bundled and fail explicitly if absent. + if (!Offline.isEnabled()) deps.push( iife(async () => { const shouldInstall = await needsInstall(dir) if (shouldInstall) await installDependencies(dir) @@ -266,6 +269,7 @@ export namespace Config { } export async function installDependencies(dir: string) { + if (Offline.isEnabled()) throw new Error("Offline mode does not install config dependencies. Preinstall required local packages before starting.") const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 7ec32a8be2de..382837767cd0 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -144,7 +144,7 @@ export namespace Ripgrep { return { filepath: offlinePath } } } - log.warn("offline mode enabled but ripgrep not found in offline deps") + throw new Error("Offline ripgrep is missing. Install the bundled local rg binary before starting; no download was attempted.") } const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9af79278c061..e8f5b2c36050 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,3 +1,4 @@ +import "./offline/network" import yargs from "yargs" import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" diff --git a/packages/opencode/src/offline/index.ts b/packages/opencode/src/offline/index.ts index 195d48d1121c..5e1e84c06a4f 100644 --- a/packages/opencode/src/offline/index.ts +++ b/packages/opencode/src/offline/index.ts @@ -3,6 +3,22 @@ import { Flag } from "../flag/flag" import { Filesystem } from "../util/filesystem" export namespace Offline { + // Browser requests use a separate network stack from the CLI fetch guard. + export const webCSP = [ + "default-src 'self'", + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "media-src 'self' data: blob:", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + ].join("; ") + export function isEnabled(): boolean { return Flag.OPENCODE_OFFLINE_MODE } @@ -38,8 +54,12 @@ export namespace Offline { export async function tryServeStaticFile(reqPath: string): Promise<{ body: ReturnType; mime: string } | undefined> { const appDir = resolveAppDist() if (!appDir) return undefined - const filePath = reqPath === "/" ? "/index.html" : reqPath - const file = Bun.file(path.join(appDir, filePath)) + const filePath = path.resolve(appDir, "." + (reqPath === "/" ? "/index.html" : reqPath)) + const relative = path.relative(appDir, filePath) + if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { + throw new Error("Offline web asset path leaves the bundled app directory.") + } + const file = Bun.file(filePath) if (await file.exists()) return { body: file, mime: Filesystem.mimeType(filePath) } // SPA fallback: serve index.html for client-side routes const index = Bun.file(path.join(appDir, "index.html")) diff --git a/packages/opencode/src/offline/network.ts b/packages/opencode/src/offline/network.ts new file mode 100644 index 000000000000..70ea88170ba3 --- /dev/null +++ b/packages/opencode/src/offline/network.ts @@ -0,0 +1,44 @@ +// Local deployment hardening for the community offline fork. +// This guards JavaScript fetch in each imported realm, not raw sockets or child processes. +import path from "node:path" +import { fileURLToPath } from "node:url" + +const enabled = ["1", "true"].includes(process.env.OPENCODE_OFFLINE_MODE?.toLowerCase() ?? "") + +export function assertOfflineURL(input: string | URL | Request) { + const url = new URL(input instanceof Request ? input.url : String(input)) + // OpenTUI's Yoga WASM is embedded as a binary data URL, not an outbound request. + if (/^data:application\/(?:octet-stream|wasm);base64,/i.test(url.href)) return + // Compiled Bun assets may use file URLs inside its virtual filesystem only. + // Host/UNC paths and ordinary disk file URLs are deliberately excluded. + if (url.protocol === "file:" && !url.hostname) { + const root = process.platform === "win32" ? "B:\\~BUN\\root" : "/$bunfs/root" + const file = (() => { + try { return fileURLToPath(url) } catch { return undefined } + })() + if (file) { + const relative = path.relative(root, file) + if (relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)) return + } + } + if (!["http:", "https:"].includes(url.protocol) || !["127.0.0.1", "[::1]"].includes(url.hostname)) { + throw new Error("Offline mode blocks this network request; only literal loopback HTTP endpoints are allowed.") + } +} + +if (enabled) { + const native = globalThis.fetch + const guarded = (async (input, init) => { + assertOfflineURL(input) + // Never let a permitted local endpoint automatically redirect to the internet. + const redirect = init?.redirect ?? (input instanceof Request ? input.redirect : undefined) + return native(input, { ...init, redirect: redirect === "manual" ? "manual" : "error" }) + }) as typeof fetch + if (native.preconnect) { + guarded.preconnect = ((...args: Parameters) => { + assertOfflineURL(args[0]) + return native.preconnect(...args) + }) as typeof native.preconnect + } + globalThis.fetch = guarded +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce9..f4cf8e02f93d 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -12,6 +12,8 @@ import os from "os" import z from "zod" export namespace PermissionNext { + // Local file mutations require a new approval for each exact tool call. + const perCall = new Set(["spark_files_replace_text", "spark_files_create_file"]) const log = Log.create({ service: "permission" }) function expand(pattern: string): string { @@ -136,7 +138,10 @@ export namespace PermissionNext { const s = await state() const { ruleset, ...request } = input for (const pattern of request.patterns ?? []) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved) + const configured = evaluate(request.permission, pattern, ruleset) + const rule = perCall.has(request.permission) + ? { ...configured, action: configured.action === "deny" ? "deny" : "ask" } + : evaluate(request.permission, pattern, ruleset, s.approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) @@ -170,13 +175,14 @@ export namespace PermissionNext { const s = await state() const existing = s.pending[input.requestID] if (!existing) return + const reply = perCall.has(existing.info.permission) && input.reply === "always" ? "once" : input.reply delete s.pending[input.requestID] Bus.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, - reply: input.reply, + reply, }) - if (input.reply === "reject") { + if (reply === "reject") { existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError()) // Reject all other pending permissions for this session const sessionID = existing.info.sessionID @@ -193,11 +199,11 @@ export namespace PermissionNext { } return } - if (input.reply === "once") { + if (reply === "once") { existing.resolve() return } - if (input.reply === "always") { + if (reply === "always") { for (const pattern of existing.info.always) { s.approved.push({ permission: existing.info.permission, @@ -211,6 +217,7 @@ export namespace PermissionNext { const sessionID = existing.info.sessionID for (const [id, pending] of Object.entries(s.pending)) { if (pending.info.sessionID !== sessionID) continue + if (perCall.has(pending.info.permission)) continue const ok = pending.info.patterns.every( (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7dd9c33dc922..46a9bdc0eafd 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -544,7 +544,11 @@ export namespace Server { .all("/*", async (c) => { // offline-fork: serve bundled web app in offline mode const offline = await Offline.tryServeStaticFile(c.req.path) - if (offline) return new Response(offline.body, { headers: { "Content-Type": offline.mime } }) + if (offline) return new Response(offline.body, { headers: { + "Content-Type": offline.mime, + "Content-Security-Policy": Offline.webCSP, + } }) + if (Offline.isEnabled()) return c.text("Offline web assets are missing; no remote UI was requested.", 503) const path = c.req.path diff --git a/packages/opencode/test/offline/fetch-guard.ts b/packages/opencode/test/offline/fetch-guard.ts new file mode 100644 index 000000000000..363a2ca5d322 --- /dev/null +++ b/packages/opencode/test/offline/fetch-guard.ts @@ -0,0 +1,103 @@ +// Exercises the actual deployment guard with two owned, temporary loopback servers. + +process.env.OPENCODE_OFFLINE_MODE = "true" +const guard = await import("../../src/offline/network.ts") + +const checks: Record[] = [] +const hits: string[] = [] +let sinkHits = 0 +const sink = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + sinkHits += 1 + return new Response("redirect destination must not be contacted") + }, +}) +const source = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const route = new URL(request.url).pathname + hits.push(route) + if (route === "/redirect-local") return Response.redirect(`http://127.0.0.1:${sink.port}/sink`) + if (route === "/redirect-external") return Response.redirect("https://example.invalid/guard-test") + return Response.json({ marker: "LOCAL_ONLY", method: request.method, route }) + }, +}) +const base = `http://127.0.0.1:${source.port}` +const report: Record = { + status: "running", + runtime: { bun: Bun.version, platform: process.platform, architecture: process.arch }, + method: "Actual source module and owned random loopback HTTP servers; no model, GPU, database or existing service used.", + checks, +} + +try { + const response = await fetch(base + "/api", { method: "POST", body: "synthetic" }) + const data = await response.json() + checks.push({ case: "local_api_post", pass: response.ok && data.marker === "LOCAL_ONLY" && data.method === "POST" }) + const requested = await fetch(new Request(base + "/request", { method: "POST", body: "request-object" })) + checks.push({ case: "local_request_object", pass: requested.ok && (await requested.json()).marker === "LOCAL_ONLY" }) + for (const mime of ["application/octet-stream", "application/wasm"]) { + const embedded = await fetch(`data:${mime};base64,QUdFTlRfTE9DQUw=`) + checks.push({ case: "embedded_binary_data", mime, pass: embedded.ok && (await embedded.text()) === "AGENT_LOCAL" }) + } + const virtual = process.platform === "win32" ? "file:///B:/~BUN/root/embedded/parser.wasm" : "file:///$bunfs/root/embedded/parser.wasm" + guard.assertOfflineURL(virtual) + checks.push({ case: "bun_virtual_file_url_class", pass: true, scope: "Guard path classification only; compiled Bun asset loading is application acceptance." }) + const blocked = [ + "https://example.invalid/never-request", + "http://example.invalid/never-request", + "http://127.0.0.1.evil.invalid/never-request", + "http://127.0.0.1@evil.invalid/never-request", + "http://localhost/never-request", + "http://127.0.0.2/never-request", + "http://[::ffff:127.0.0.1]/never-request", + "http://192.168.1.1/never-request", + "file:///etc/passwd", + "file:///C:/Windows/System32/not-a-bundled-asset.wasm", + "file://example.invalid/share/parser.wasm", + "file:////example.invalid/share/parser.wasm", + "file:///B:/~BUN/root/../../outside.wasm", + "file:///B:/~BUN/root/%2foutside.wasm", + "data:text/plain,synthetic", + "ftp://127.0.0.1/never-request", + ] + for (const url of blocked) { + let message = "" + try { await fetch(url) } catch (error) { message = error instanceof Error ? error.message : String(error) } + checks.push({ case: "blocked_url", url, pass: message.startsWith("Offline mode blocks this network request"), error: message }) + } + let requestError = "" + try { await fetch(new Request("https://example.invalid/request-object")) } catch (error) { + requestError = error instanceof Error ? error.message : String(error) + } + checks.push({ case: "external_request_object", pass: requestError.startsWith("Offline mode blocks this network request") }) + for (const route of ["/redirect-local", "/redirect-external"]) { + let error = "" + try { await fetch(base + route) } catch (caught) { error = caught instanceof Error ? caught.message : String(caught) } + checks.push({ case: "automatic_redirect_blocked", route, pass: Boolean(error) && sinkHits === 0, error }) + } + const manual = await fetch(base + "/redirect-external", { redirect: "manual" }) + checks.push({ case: "manual_redirect_not_followed", pass: manual.status === 302 && manual.headers.get("Location") === "https://example.invalid/guard-test" && sinkHits === 0 }) + if (fetch.preconnect) { + let error = "" + try { fetch.preconnect("https://example.invalid/") } catch (caught) { error = caught instanceof Error ? caught.message : String(caught) } + checks.push({ case: "external_preconnect_blocked", pass: error.startsWith("Offline mode blocks this network request") }) + } + report.status = checks.every((check) => check.pass === true) ? "pass" : "fail" +} catch (error) { + report.status = "fail" + report.error = error instanceof Error ? error.message : String(error) +} finally { + source.stop(true) + sink.stop(true) + report.local_requests = hits + report.redirect_destination_requests = sinkHits + report.cleanup = { owned_servers_stopped: true, existing_services_or_databases_touched: false } + report.limits = "Fetch/preconnect behavior only, not OS packet capture, raw sockets, third-party worker realms or child-process egress. External cases were rejected by the guard before native fetch/preconnect." + report.verified_at_utc = new Date().toISOString() +} +console.log(JSON.stringify({ status: report.status, checks: checks.length, failed: checks.filter((c) => c.pass !== true), redirect_destination_requests: sinkHits, runtime: report.runtime }, null, 2)) +process.exitCode = report.status === "pass" ? 0 : 1 From 6c7b90826442323b28e5e3591b2f63b1fd4c4d86 Mon Sep 17 00:00:00 2001 From: LexZeon <287497354+LexZeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:26:45 +0000 Subject: [PATCH 2/3] Normalize local images and preserve offline prompt errors --- bun.lock | 6 + docs/windows-arm64-offline.md | 31 +- package.json | 1 + .../components/prompt-input/attachments.ts | 2 +- packages/opencode/package.json | 2 + .../cli/cmd/tui/component/prompt/index.tsx | 95 +++--- packages/opencode/src/config/config.ts | 33 +- packages/opencode/src/image/codec.ts | 257 ++++++++++++++++ packages/opencode/src/image/image.ts | 43 +++ packages/opencode/src/offline/network.ts | 13 +- .../opencode/src/server/routes/session.ts | 8 +- packages/opencode/src/server/server.ts | 14 +- packages/opencode/src/session/prompt.ts | 43 ++- packages/opencode/src/tool/read.ts | 15 +- packages/opencode/test/image/codec.test.ts | 68 +++++ .../test/image/fixtures/LICENSE.libavif | 46 +++ .../image/fixtures/colors-animated-8bpc.avif | Bin 0 -> 1235 bytes .../test/image/fixtures/white-1x1.avif | Bin 0 -> 305 bytes packages/opencode/test/image/ingress.test.ts | 98 ++++++ packages/opencode/test/offline/fetch-guard.ts | 160 +++++++++- .../@silvia-odwyer%2Fphoton-node@0.3.4.patch | 285 ++++++++++++++++++ patches/LICENSE-Photon-Apache-2.0.txt | 201 ++++++++++++ patches/NOTICE-Photon.txt | 31 ++ 23 files changed, 1365 insertions(+), 87 deletions(-) create mode 100644 packages/opencode/src/image/codec.ts create mode 100644 packages/opencode/src/image/image.ts create mode 100644 packages/opencode/test/image/codec.test.ts create mode 100644 packages/opencode/test/image/fixtures/LICENSE.libavif create mode 100644 packages/opencode/test/image/fixtures/colors-animated-8bpc.avif create mode 100644 packages/opencode/test/image/fixtures/white-1x1.avif create mode 100644 packages/opencode/test/image/ingress.test.ts create mode 100644 patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch create mode 100644 patches/LICENSE-Photon-Apache-2.0.txt create mode 100644 patches/NOTICE-Photon.txt diff --git a/bun.lock b/bun.lock index 52bc415a7f31..33ead4065fc9 100644 --- a/bun.lock +++ b/bun.lock @@ -295,6 +295,7 @@ "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", + "@jsquash/avif": "2.1.1", "@modelcontextprotocol/sdk": "1.25.2", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", @@ -308,6 +309,7 @@ "@opentui/solid": "0.1.81", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -511,6 +513,7 @@ "tree-sitter-bash", ], "patchedDependencies": { + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", }, @@ -562,6 +565,9 @@ "zod": "4.1.8", }, "packages": { + "wasm-feature-detect": ["wasm-feature-detect@1.9.0", "", {}, "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@jsquash/avif": ["@jsquash/avif@2.1.1", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA=="], "@actions/artifact": ["@actions/artifact@5.0.1", "", { "dependencies": { "@actions/core": "^2.0.0", "@actions/github": "^6.0.1", "@actions/http-client": "^3.0.0", "@azure/storage-blob": "^12.29.1", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-dHJ5rHduhCKUikKTT9eXeWoUvfKia3IjR1sO/VTAV3DVAL4yMTRnl2iO5mcfiBjySHLwPNezwENAVskKYU5ymw=="], "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], diff --git a/docs/windows-arm64-offline.md b/docs/windows-arm64-offline.md index 2007ac4ce814..3bd0e3f3cc50 100644 --- a/docs/windows-arm64-offline.md +++ b/docs/windows-arm64-offline.md @@ -87,8 +87,9 @@ fields such as `subagent_depth`; validate copied configuration against its schem In offline mode, missing web assets return HTTP 503 instead of proxying the hosted UI. Missing ripgrep or npm dependencies fail without attempting a download. The CLI and TUI worker guard JavaScript `fetch` and `preconnect`: only literal -loopback HTTP endpoints and embedded binary/Bun virtual-file resources are -permitted. Automatic redirects are rejected. Extra language grammars that use +loopback HTTP endpoints, inline data, process-registered Blob URLs, and embedded +Bun virtual-file resources are permitted. Unknown or revoked Blob URLs and +automatic redirects are rejected. Extra language grammars that use remote URLs are not registered; OpenTUI's bundled parsers remain available. The bundled web response includes a CSP restricting external asset and connection @@ -106,8 +107,22 @@ Observed validation on native Windows ARM64 with Bun 1.4.4: edit and a new-file creation only after approval. A later creation asked again; rejecting it left the file absent. `Always` behaved as a one-call approval. - The local HTML response included the external-resource CSP. -- The source fetch guard passed 26 checks covering local HTTP, embedded binary - data, misleading hostnames, UNC/file-path boundaries, and redirect blocking. +- The source and compiled fetch guards passed 35 checks covering local HTTP, + inline data, registered/revoked Blob URLs, misleading hostnames, UNC/file-path + boundaries, and redirect blocking. + +Image input is normalized before message persistence and before streaming response +headers are committed. Local AVIF decoding is bundled as WASM; PNG/JPEG output is +passed to the selected model. The same validation covers prompt attachments, +file reads, and image results from tools and MCP. Invalid base64, damaged images, +MIME mismatches, and oversized inputs produce a readable HTTP 400; the terminal +restores a failed draft. Animated AVIF uses its first frame. + +The image update passed 47 targeted source tests, compiled API checks with mixed +valid/invalid concurrent requests, and real local-model AVIF/PNG inference. The +tests use synthetic data and two public libavif fixtures; their source and BSD +notice are included beside the fixtures. These checks do not establish image +support for every model or replace an operating-system network sandbox. Run the guard check from `packages/opencode`: @@ -116,8 +131,12 @@ bun run test/offline/fetch-guard.ts ``` These are functional checks, not model-quality or performance benchmarks. The -inference check used a short-context test profile; it does not establish maximum -context size or full GPU offload. The Linux container offline suite was not rerun +earlier file-permission inference check used a short-context profile; the separate +image checks used an existing 131,072-token backend. Neither establishes maximum +context size or full GPU offload. Draft restoration was reviewed in source; +backend rejection and subsequent-request recovery were exercised, while the TUI +restoration branch was not separately tested through the UI. +The Linux container offline suite was not rerun for this Windows adaptation. Browser packet capture was not performed: CSP headers and local HTML were checked, rather than claiming every browser feature was tested. diff --git a/package.json b/package.json index 2e7c1172aa64..fd0ad1eae0af 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@types/node": "catalog:" }, "patchedDependencies": { + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" } diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index a9e4e496512c..bd765bb97377 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -5,7 +5,7 @@ import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"] export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] const LARGE_PASTE_CHARS = 8000 const LARGE_PASTE_BREAKS = 120 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0e3594dc7d79..b856dd0dd7c1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -80,6 +80,7 @@ "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", + "@jsquash/avif": "2.1.1", "@modelcontextprotocol/sdk": "1.25.2", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", @@ -93,6 +94,7 @@ "@opentui/solid": "0.1.81", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83e..f25b95ec51d0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -570,6 +570,23 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() + const pending = { ...store.prompt, parts: [...store.prompt.parts] } + const failed = (error: unknown) => { + const data = typeof error === "object" && error !== null && "data" in error ? error.data : undefined + const message = + typeof data === "object" && data !== null && "message" in data && typeof data.message === "string" + ? data.message + : error instanceof Error + ? error.message + : "Failed to send prompt" + toast.show({ variant: "error", message, duration: 8000 }) + // offline-fork: keep a rejected attachment editable without overwriting a new draft. + if (!store.prompt.input && store.prompt.parts.length === 0) { + setStore("prompt", pending) + input.setText(pending.input) + restoreExtmarksFromParts(pending.parts) + } + } if (store.mode === "shell") { sdk.client.session.shell({ @@ -597,43 +614,51 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: local.agent.current().name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - messageID, - variant, - parts: nonTextParts - .filter((x) => x.type === "file") - .map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - }) + sdk.client.session + .command( + { + sessionID, + command: command.slice(1), + arguments: args, + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + messageID, + variant, + parts: nonTextParts + .filter((x) => x.type === "file") + .map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + }, + { throwOnError: true }, + ) + .catch(failed) } else { sdk.client.session - .prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) - .catch(() => {}) + .prompt( + { + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }, + { throwOnError: true }, + ) + .catch(failed) } history.append({ ...store.prompt, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index faa0ec56a48d..5729d3ea8278 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -174,12 +174,13 @@ export namespace Config { // offline-fork: stdio MCP needs no config-directory npm install. // Required provider/plugin packages must already be bundled and fail explicitly if absent. - if (!Offline.isEnabled()) deps.push( - iife(async () => { - const shouldInstall = await needsInstall(dir) - if (shouldInstall) await installDependencies(dir) - }), - ) + if (!Offline.isEnabled()) + deps.push( + iife(async () => { + const shouldInstall = await needsInstall(dir) + if (shouldInstall) await installDependencies(dir) + }), + ) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) @@ -269,7 +270,10 @@ export namespace Config { } export async function installDependencies(dir: string) { - if (Offline.isEnabled()) throw new Error("Offline mode does not install config dependencies. Preinstall required local packages before starting.") + if (Offline.isEnabled()) + throw new Error( + "Offline mode does not install config dependencies. Preinstall required local packages before starting.", + ) const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION @@ -1027,6 +1031,21 @@ export namespace Config { logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), + // offline-fork: validate and normalize image inputs before providers receive them. + attachment: z + .object({ + image: z + .object({ + auto_resize: z.boolean().optional(), + max_width: z.number().int().positive().optional(), + max_height: z.number().int().positive().optional(), + max_base64_bytes: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), command: z .record(z.string(), Command) .optional() diff --git a/packages/opencode/src/image/codec.ts b/packages/opencode/src/image/codec.ts new file mode 100644 index 000000000000..79f9e137a0b0 --- /dev/null +++ b/packages/opencode/src/image/codec.ts @@ -0,0 +1,257 @@ +// CPU-only image ingress. Both session APIs use this boundary before a provider sees image bytes. +// @ts-ignore Bun embeds these resources in compiled executables. +import avifWasm from "@jsquash/avif/codec/dec/avif_dec.wasm" with { type: "file" } +// @ts-ignore Bun embeds these resources in compiled executables. +import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +export const INPUT_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"] as const +export const MAX_INPUT_BYTES = 64 * 1024 * 1024 +export const MAX_PIXELS = 64 * 1024 * 1024 +const JPEG_QUALITIES = [80, 85, 70, 55, 40] + +export class ImageInputError extends Error { + constructor( + readonly kind: "data_url" | "base64" | "format" | "mime" | "decode" | "input_size" | "dimensions" | "size", + message: string, + readonly details: Record = {}, + ) { + super(message) + this.name = "ImageInputError" + } +} + +export interface Limits { + readonly autoResize: boolean + readonly maxWidth: number + readonly maxHeight: number + readonly maxBase64Bytes: number +} + +function canonical(mime: string) { + return mime.toLowerCase() === "image/jpg" ? "image/jpeg" : mime.toLowerCase() +} + +export function parseDataUrl(url: string, mime: string) { + if (url.length > Math.ceil(MAX_INPUT_BYTES / 3) * 4 + 128) + throw new ImageInputError("input_size", "Image file exceeds the 64 MiB input limit") + const match = /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/i.exec(url) + if (!match) throw new ImageInputError("data_url", "Image must be a base64 data URL with an explicit image MIME type") + if (canonical(match[1]) !== canonical(mime)) + throw new ImageInputError("mime", "Image MIME type does not match its data URL") + return { mime: canonical(mime), base64: match[2] } +} + +function bytesFromBase64(value: string) { + if (value.length > Math.ceil(MAX_INPUT_BYTES / 3) * 4) + throw new ImageInputError("input_size", "Image file exceeds the 64 MiB input limit") + if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) + throw new ImageInputError("base64", "Image contains invalid or incomplete base64 data") + const bytes = Buffer.from(value, "base64") + if (bytes.toString("base64") !== value) throw new ImageInputError("base64", "Image contains noncanonical base64 data") + if (bytes.length > MAX_INPUT_BYTES) + throw new ImageInputError("input_size", "Image file exceeds the 64 MiB input limit") + return bytes +} + +// Read dimensions before allocating a decoded pixel buffer, including AVIF's nested ISO-BMFF ispe properties. +function inspect(bytes: Buffer) { + if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) + return { mime: "image/png", width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } + if (bytes.length >= 10 && ["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) + return { mime: "image/gif", width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) } + if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216) { + let offset = 2 + while (offset + 4 <= bytes.length) { + if (bytes[offset++] !== 255) break + while (bytes[offset] === 255) offset++ + const marker = bytes[offset++] + if (marker === 217 || marker === 218) break + if (marker === 1 || (marker >= 208 && marker <= 215)) continue + if (offset + 2 > bytes.length) break + const size = bytes.readUInt16BE(offset) + if (size < 2 || offset + size > bytes.length) break + if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) + return { mime: "image/jpeg", width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) } + offset += size + } + throw new ImageInputError("decode", "JPEG image is damaged or has no readable dimensions") + } + if (bytes.length >= 30 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") { + let offset = 12 + while (offset + 8 <= bytes.length) { + const type = bytes.toString("ascii", offset, offset + 4) + const size = bytes.readUInt32LE(offset + 4) + const start = offset + 8 + if (start + size > bytes.length) break + if (type === "VP8X" && size >= 10) + return { + mime: "image/webp", + width: bytes.readUIntLE(start + 4, 3) + 1, + height: bytes.readUIntLE(start + 7, 3) + 1, + } + if (type === "VP8 " && size >= 10 && bytes.subarray(start + 3, start + 6).equals(Buffer.from([157, 1, 42]))) + return { + mime: "image/webp", + width: bytes.readUInt16LE(start + 6) & 16383, + height: bytes.readUInt16LE(start + 8) & 16383, + } + if (type === "VP8L" && size >= 5 && bytes[start] === 47) { + const bits = bytes.readUInt32LE(start + 1) + return { mime: "image/webp", width: (bits & 16383) + 1, height: ((bits >>> 14) & 16383) + 1 } + } + offset = start + size + (size & 1) + } + throw new ImageInputError("decode", "WebP image is damaged or has no readable dimensions") + } + if (bytes.length >= 16 && bytes.toString("ascii", 4, 8) === "ftyp") { + const ftypSize = bytes.readUInt32BE(0) + if (ftypSize < 16 || ftypSize > bytes.length) throw new ImageInputError("decode", "AVIF file header is damaged") + const brands = [bytes.toString("ascii", 8, 12)] + for (let offset = 16; offset + 4 <= ftypSize; offset += 4) brands.push(bytes.toString("ascii", offset, offset + 4)) + if (!brands.some((brand) => brand === "avif" || brand === "avis")) + throw new ImageInputError( + "format", + "This image container is not AVIF; supported inputs are PNG, JPEG, GIF, WebP and AVIF", + ) + const sizes: Array<{ width: number; height: number }> = [] + const visit = (start: number, end: number, depth: number) => { + if (depth > 8) throw new ImageInputError("decode", "AVIF image has excessively nested metadata") + for (let offset = start; offset + 8 <= end; ) { + const type = bytes.toString("ascii", offset + 4, offset + 8) + const declared = bytes.readUInt32BE(offset) + const header = declared === 1 ? 16 : 8 + if (offset + header > end) throw new ImageInputError("decode", "AVIF image metadata is truncated") + const size = + declared === 0 ? end - offset : declared === 1 ? Number(bytes.readBigUInt64BE(offset + 8)) : declared + if (!Number.isSafeInteger(size) || size < header || offset + size > end) + throw new ImageInputError("decode", "AVIF image metadata has an invalid box size") + if (type === "ispe" && size >= header + 12) + sizes.push({ + width: bytes.readUInt32BE(offset + header + 4), + height: bytes.readUInt32BE(offset + header + 8), + }) + // Sequence-only AVIF stores its canvas in tkhd rather than an item ispe property. + if (type === "tkhd") { + const version = bytes[offset + header] + const minimum = version === 1 ? 96 : version === 0 ? 84 : 0 + if (!minimum || size < header + minimum) throw new ImageInputError("decode", "AVIF track header is damaged") + const width = Math.ceil(bytes.readUInt32BE(offset + size - 8) / 65536) + const height = Math.ceil(bytes.readUInt32BE(offset + size - 4) / 65536) + if (width || height) sizes.push({ width, height }) + } + if (["meta", "iprp", "ipco", "moov", "trak"].includes(type)) + visit(offset + header + (type === "meta" ? 4 : 0), offset + size, depth + 1) + offset += size + } + } + visit(0, bytes.length, 0) + if (!sizes.length) throw new ImageInputError("decode", "AVIF image has no readable dimensions") + for (const size of sizes) checkDimensions(size.width, size.height) + return { mime: "image/avif", ...sizes.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b)) } + } + throw new ImageInputError("format", "Image bytes are not a supported PNG, JPEG, GIF, WebP or AVIF image") +} + +function checkDimensions(width: number, height: number) { + if (!width || !height || width * height > MAX_PIXELS) + throw new ImageInputError("dimensions", "Image dimensions are invalid or exceed the 64 megapixel decode limit", { + width, + height, + maxPixels: MAX_PIXELS, + }) +} + +function embeddedPath(value: string) { + return path.isAbsolute(value) ? value : fileURLToPath(new URL(value, import.meta.url)) +} + +let photonPromise: Promise | undefined +function loadPhoton() { + return (photonPromise ??= Promise.resolve().then(() => { + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + embeddedPath(photonWasm) + return import("@silvia-odwyer/photon-node") + })) +} + +let avifPromise: Promise | undefined +function loadAvif() { + return (avifPromise ??= import("@jsquash/avif/decode.js").then(async (module) => { + const wasmPath = embeddedPath(avifWasm) + const options = { wasmBinary: new Uint8Array(readFileSync(wasmPath)), locateFile: () => wasmPath } + await module.init(options) + return module + })) +} + +export async function normalize(base64: string, mime: string, limits: Limits) { + const bytes = bytesFromBase64(base64) + const info = inspect(bytes) + if (canonical(mime) !== info.mime) + throw new ImageInputError("mime", `Declared image MIME type does not match its ${info.mime} bytes`) + checkDimensions(info.width, info.height) + const photon = await loadPhoton() + const decoded = await (async () => { + try { + if (info.mime !== "image/avif") return photon.PhotonImage.new_from_byteslice(bytes) + const avif = await loadAvif() + // decode() returns the primary still image / first frame. It never forwards AVIF to the model. + const image = await avif.default(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + if (!image) throw new Error("empty decoded image") + checkDimensions(image.width, image.height) + return new photon.PhotonImage(new Uint8Array(image.data), image.width, image.height) + } catch (error) { + if (error instanceof ImageInputError) throw error + throw new ImageInputError("decode", `The ${info.mime} image is damaged or could not be decoded`) + } + })() + try { + const width = decoded.get_width() + const height = decoded.get_height() + checkDimensions(width, height) + const over = width > limits.maxWidth || height > limits.maxHeight || base64.length > limits.maxBase64Bytes + if (!over && info.mime !== "image/avif") return { base64, mime: info.mime, width, height } + const sizeError = () => + new ImageInputError( + "size", + `Image ${width}x${height} exceeds the configured ${limits.maxWidth}x${limits.maxHeight}/${limits.maxBase64Bytes} base64-byte limit`, + { + width, + height, + bytes: base64.length, + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxBytes: limits.maxBase64Bytes, + }, + ) + if (over && !limits.autoResize) throw sizeError() + const scale = over ? Math.min(1, limits.maxWidth / width, limits.maxHeight / height) : 1 + let size = { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) } + for (let step = 0; step < 32; step++) { + const resized = + size.width === width && size.height === height + ? decoded + : photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + try { + const encoders: Array Uint8Array]> = [ + ["image/png", () => resized.get_bytes()], + ...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const), + ] + for (const [mime, encode] of encoders) { + const candidate = Buffer.from(encode()).toString("base64") + if (candidate.length <= limits.maxBase64Bytes) return { base64: candidate, mime, ...size } + } + } finally { + if (resized !== decoded) resized.free() + } + if (!limits.autoResize || (size.width === 1 && size.height === 1)) break + size = { width: Math.max(1, Math.floor(size.width * 0.75)), height: Math.max(1, Math.floor(size.height * 0.75)) } + } + throw sizeError() + } finally { + decoded.free() + } +} diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 000000000000..ffe9246d605e --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,43 @@ +import z from "zod" +import { NamedError } from "@opencode-ai/util/error" +import { Config } from "../config/config" +import { ImageInputError, normalize as normalizeBytes, parseDataUrl } from "./codec" + +export namespace Image { + export const InputError = NamedError.create( + "ImageInputError", + z.object({ + message: z.string(), + kind: z.string(), + details: z.record(z.string(), z.union([z.string(), z.number()])), + }), + ) + + export function isImage(input: { mime: string; url: string }) { + return input.mime.toLowerCase().startsWith("image/") || /^data:image\//i.test(input.url) + } + + export async function attachments(parts?: T[]): Promise { + if (!parts) return + return Promise.all(parts.map((part) => (isImage(part) ? normalize(part) : part))) + } + + export async function normalize(input: T): Promise { + const config = (await Config.get()).attachment?.image + try { + const parsed = parseDataUrl(input.url, input.mime) + const output = await normalizeBytes(parsed.base64, parsed.mime, { + autoResize: config?.auto_resize ?? true, + maxWidth: config?.max_width ?? 2000, + maxHeight: config?.max_height ?? 2000, + maxBase64Bytes: config?.max_base64_bytes ?? 5 * 1024 * 1024, + }) + return { ...input, mime: output.mime, url: `data:${output.mime};base64,${output.base64}` } + } catch (error) { + if (!(error instanceof ImageInputError)) throw error + const failure = new InputError({ message: error.message, kind: error.kind, details: error.details }) + failure.message = error.message + throw failure + } + } +} diff --git a/packages/opencode/src/offline/network.ts b/packages/opencode/src/offline/network.ts index 70ea88170ba3..01c9bc323fcb 100644 --- a/packages/opencode/src/offline/network.ts +++ b/packages/opencode/src/offline/network.ts @@ -2,19 +2,26 @@ // This guards JavaScript fetch in each imported realm, not raw sockets or child processes. import path from "node:path" import { fileURLToPath } from "node:url" +import { resolveObjectURL } from "node:buffer" const enabled = ["1", "true"].includes(process.env.OPENCODE_OFFLINE_MODE?.toLowerCase() ?? "") export function assertOfflineURL(input: string | URL | Request) { const url = new URL(input instanceof Request ? input.url : String(input)) - // OpenTUI's Yoga WASM is embedded as a binary data URL, not an outbound request. - if (/^data:application\/(?:octet-stream|wasm);base64,/i.test(url.href)) return + // Inline data and registered Blob URLs are resolved locally by fetch, without network I/O. + // This includes image data downloaded by the AI SDK and OpenTUI's embedded WASM. + if (url.protocol === "data:") return + if (url.protocol === "blob:" && resolveObjectURL(url.href)) return // Compiled Bun assets may use file URLs inside its virtual filesystem only. // Host/UNC paths and ordinary disk file URLs are deliberately excluded. if (url.protocol === "file:" && !url.hostname) { const root = process.platform === "win32" ? "B:\\~BUN\\root" : "/$bunfs/root" const file = (() => { - try { return fileURLToPath(url) } catch { return undefined } + try { + return fileURLToPath(url) + } catch { + return undefined + } })() if (file) { const relative = path.relative(root, file) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 96e84361279c..712d1e54308c 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -803,12 +803,14 @@ export const SessionRoutes = lazy(() => ), validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + // offline-fork: validate and save inputs before committing streaming HTTP headers. + const message = await SessionPrompt.prompt({ ...body, sessionID, noReply: true }) c.status(200) c.header("Content-Type", "application/json") return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) + const msg = body.noReply ? message : await SessionPrompt.loop({ sessionID }) stream.write(JSON.stringify(msg)) }) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 46a9bdc0eafd..ad45a2433e22 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -40,7 +40,7 @@ import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" -import { Offline } from "../offline" // offline-fork +import { Offline } from "../offline" // offline-fork // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -68,6 +68,7 @@ export namespace Server { let status: ContentfulStatusCode if (err instanceof NotFoundError) status = 404 else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name === "ImageInputError") status = 400 else if (err.name.startsWith("Worktree")) status = 400 else status = 500 return c.json(err.toObject(), { status }) @@ -544,10 +545,13 @@ export namespace Server { .all("/*", async (c) => { // offline-fork: serve bundled web app in offline mode const offline = await Offline.tryServeStaticFile(c.req.path) - if (offline) return new Response(offline.body, { headers: { - "Content-Type": offline.mime, - "Content-Security-Policy": Offline.webCSP, - } }) + if (offline) + return new Response(offline.body, { + headers: { + "Content-Type": offline.mime, + "Content-Security-Policy": Offline.webCSP, + }, + }) if (Offline.isEnabled()) return c.text("Offline web assets are missing; no remote UI was requested.", 503) const path = c.req.path diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..9c6371ab211b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,8 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Image } from "@/image/image" +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -820,6 +822,8 @@ export namespace SessionPrompt { }, output, ) + // offline-fork: plugin and built-in image results must pass the same ingress checks. + output.attachments = await Image.attachments(output.attachments) return output }, }) @@ -867,10 +871,33 @@ export namespace SessionPrompt { result, ) + // offline-fork: MCP content is also sent directly to the model, so normalize it before copying attachments. + const content = await Promise.all( + result.content.map(async (item: CallToolResult["content"][number]) => { + if (item.type === "image") { + const image = await Image.normalize({ + mime: item.mimeType, + url: `data:${item.mimeType};base64,${item.data}`, + }) + return { ...item, mimeType: image.mime, data: image.url.slice(image.url.indexOf(",") + 1) } + } + if (item.type === "resource" && "blob" in item.resource && item.resource.mimeType?.startsWith("image/")) { + const image = await Image.normalize({ + mime: item.resource.mimeType, + url: `data:${item.resource.mimeType};base64,${item.resource.blob}`, + }) + return { + ...item, + resource: { ...item.resource, mimeType: image.mime, blob: image.url.slice(image.url.indexOf(",") + 1) }, + } + } + return item + }), + ) const textParts: string[] = [] const attachments: Omit[] = [] - for (const contentItem of result.content) { + for (const contentItem of content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { @@ -881,10 +908,10 @@ export namespace SessionPrompt { }) } else if (contentItem.type === "resource") { const { resource } = contentItem - if (resource.text) { + if ("text" in resource && resource.text) { textParts.push(resource.text) } - if (resource.blob) { + if ("blob" in resource && resource.blob) { attachments.push({ type: "file", mime: resource.mimeType ?? "application/octet-stream", @@ -912,7 +939,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, messageID: input.processor.message.id, })), - content: result.content, // directly return content to preserve ordering when outputting to model + content, // directly return normalized content to preserve ordering when outputting to model } } tools[key] = item @@ -1307,14 +1334,18 @@ export namespace SessionPrompt { }, ) + // offline-fork: one boundary covers TUI, web/API, files, and plugin-added images. + const normalized = await Promise.all( + parts.map((part) => (part.type === "file" && Image.isImage(part) ? Image.normalize(part) : part)), + ) await Session.updateMessage(info) - for (const part of parts) { + for (const part of normalized) { await Session.updatePart(part) } return { info, - parts, + parts: normalized, } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c981ac16e43c..983a6cd1569f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { Filesystem } from "../util/filesystem" +import { Image } from "../image/image" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -123,6 +124,11 @@ export const ReadTool = Tool.define("read", { const isPdf = mime === "application/pdf" if (isImage || isPdf) { const msg = `${isImage ? "Image" : "PDF"} read successfully` + const attachment = { + type: "file" as const, + mime, + url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`, + } return { title, output: msg, @@ -131,13 +137,8 @@ export const ReadTool = Tool.define("read", { truncated: false, loaded: instructions.map((i) => i.filepath), }, - attachments: [ - { - type: "file", - mime, - url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`, - }, - ], + // offline-fork: image files share the same validation and codec as user attachments. + attachments: [isImage ? await Image.normalize(attachment) : attachment], } } diff --git a/packages/opencode/test/image/codec.test.ts b/packages/opencode/test/image/codec.test.ts new file mode 100644 index 000000000000..9140012ff341 --- /dev/null +++ b/packages/opencode/test/image/codec.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { ImageInputError, normalize, parseDataUrl } from "../../src/image/codec" + +// Public libavif fixtures, commit e760e8f05375cbd0c6d72065b18cac4542b13092: +// https://github.com/AOMediaCodec/libavif/tree/e760e8f05375cbd0c6d72065b18cac4542b13092/tests/data +const still = readFileSync(new URL("./fixtures/white-1x1.avif", import.meta.url)) +const animation = readFileSync(new URL("./fixtures/colors-animated-8bpc.avif", import.meta.url)) +const limits = { autoResize: true, maxWidth: 2000, maxHeight: 2000, maxBase64Bytes: 5 * 1024 * 1024 } + +function sequenceOnly() { + const bytes = Buffer.from(animation) + for (let offset = 8; offset + 4 <= bytes.readUInt32BE(0); offset += 4) + if (bytes.toString("ascii", offset, offset + 4) === "avif") bytes.write("avis", offset, "ascii") + for (let offset = 0; offset + 8 <= bytes.length; offset += bytes.readUInt32BE(offset)) + if (bytes.toString("ascii", offset + 4, offset + 8) === "meta") bytes.write("free", offset + 4, "ascii") + return bytes +} + +describe("image codec", () => { + test("transcodes a real AVIF into a decodable PNG", async () => { + const image = await normalize(still.toString("base64"), "image/avif", limits) + expect(image).toMatchObject({ mime: "image/png", width: 1, height: 1 }) + expect(Buffer.from(image.base64, "base64").subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + expect(await normalize(image.base64, image.mime, limits)).toEqual(image) + }) + + test("rejects malformed URLs, base64, mismatched MIME, and damaged bytes", async () => { + expect(() => parseDataUrl("file:///image.avif", "image/avif")).toThrow(ImageInputError) + expect(() => parseDataUrl("data:image/png;base64,AAAA", "image/avif")).toThrow("does not match") + await expect(normalize("Zg", "image/avif", limits)).rejects.toThrow("base64") + await expect(normalize("Zh==", "image/avif", limits)).rejects.toThrow("noncanonical") + await expect(normalize(still.toString("base64"), "image/png", limits)).rejects.toThrow("MIME") + await expect(normalize(still.subarray(0, 32).toString("base64"), "image/avif", limits)).rejects.toThrow( + ImageInputError, + ) + expect((await normalize(still.toString("base64"), "image/avif", limits)).width).toBe(1) + }) + + test("preserves configured limits without bypassing auto_resize false", async () => { + await expect( + normalize(animation.toString("base64"), "image/avif", { ...limits, maxWidth: 1, autoResize: false }), + ).rejects.toThrow("configured") + const image = await normalize(animation.toString("base64"), "image/avif", { ...limits, maxWidth: 1, maxHeight: 1 }) + expect(image.width).toBe(1) + expect(image.height).toBe(1) + await expect(normalize(still.toString("base64"), "image/avif", { ...limits, maxBase64Bytes: 8 })).rejects.toThrow( + "configured", + ) + }) + + test("decodes the primary frame of animated and sequence-only AVIF", async () => { + const primary = await normalize(animation.toString("base64"), "image/avif", limits) + const sequence = await normalize(sequenceOnly().toString("base64"), "image/avif", limits) + expect(primary).toMatchObject({ mime: "image/png", width: 150, height: 150 }) + expect(sequence).toEqual(primary) + }) + + test("rejects oversized track dimensions before AVIF decoding", async () => { + const bytes = sequenceOnly() + const tag = bytes.indexOf("tkhd") + expect(tag).toBeGreaterThan(0) + const end = tag - 4 + bytes.readUInt32BE(tag - 4) + bytes.writeUInt32BE(0xffff0000, end - 8) + bytes.writeUInt32BE(0xffff0000, end - 4) + await expect(normalize(bytes.toString("base64"), "image/avif", limits)).rejects.toThrow("64 megapixel") + }) +}) diff --git a/packages/opencode/test/image/fixtures/LICENSE.libavif b/packages/opencode/test/image/fixtures/LICENSE.libavif new file mode 100644 index 000000000000..5f3a0e67d0c5 --- /dev/null +++ b/packages/opencode/test/image/fixtures/LICENSE.libavif @@ -0,0 +1,46 @@ +License and provenance for the two libavif test fixtures in this directory +====================================================================== + +These notices apply to white-1x1.avif and colors-animated-8bpc.avif only. +The filenames in this directory preserve the upstream bytes; white-1x1.avif +was renamed from upstream white_1x1.avif. + +Upstream commit: e760e8f05375cbd0c6d72065b18cac4542b13092 +Upstream fixture licensing and descriptions: +https://github.com/AOMediaCodec/libavif/blob/e760e8f05375cbd0c6d72065b18cac4542b13092/tests/data/README.md +Upstream license: +https://github.com/AOMediaCodec/libavif/blob/e760e8f05375cbd0c6d72065b18cac4542b13092/LICENSE + +white-1x1.avif (305 bytes) +Source: https://github.com/AOMediaCodec/libavif/blob/e760e8f05375cbd0c6d72065b18cac4542b13092/tests/data/white_1x1.avif +SHA256: ea4e43d1f07e4c00de16c13afa32376111bb306e51f08212cf4c1b6064df3667 + +colors-animated-8bpc.avif (1235 bytes) +Source: https://github.com/AOMediaCodec/libavif/blob/e760e8f05375cbd0c6d72065b18cac4542b13092/tests/data/colors-animated-8bpc.avif +SHA256: 2f8683d21725261f37f86e115f0c212cc52d0fefd3a2ddfcc4fa648c1859906d + +The upstream test-data README identifies both files as licensed the same as +libavif. The applicable general BSD 2-Clause notice from that commit follows: + +Copyright 2019 Joe Drago. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/opencode/test/image/fixtures/colors-animated-8bpc.avif b/packages/opencode/test/image/fixtures/colors-animated-8bpc.avif new file mode 100644 index 0000000000000000000000000000000000000000..afa875d1030cb48429efab1e0bb97044a6d5259a GIT binary patch literal 1235 zcma)5Pe>GD6o0e3$|kF%@lL(1Du&D&NOqdFLfB*t2`$(ibG1|4Xfa(01nedMJH*oVJbyxlfwij z1r+2MvuvG=EZKx0`!`VTMNTj_DwCwA9gUn1VY0MhGL^9{TeiH&oPvY+G3Mwt$_R6v z90ejxmpSaPF-Cwt7!0BaX~k4#96|)O>b6xF)vYnC+z9n0gmy89Yve=1!2*hJA^_l9 z;R8$5UH`#<-~sojP{)o1#JZVc`+jU%L+C9 ztE<-iRjb-d&Kf&SSmn)d8*+xAP3X#5t|*6AGB#^VM0w2(reSP1H@1J9Q}fj5f44@_ zxVH(BBF`I`SDArobQzv6Y7}zbws`)~<$|Spu}Y^ahAq_Zxx%Gn+o0XuX1Bt+?#lDH=B2l}LOwECG!#87$tVshkE3i? z`x5B8ig<-6hfd}aGU>F0yOATVkSBpE)s&}&m@CiQ!(^|o+|Wb|G&CXRfA#F?i&eGr z;pb9sYZxX<0QkYrcaM8QjbBcrTc9_d2rQ=o;+Z5s8Ug^pALi7ZBR5AE>f#UHMBj^# zHx;kE(YrGlgy!U?cedwS H&!YMpL*L33 literal 0 HcmV?d00001 diff --git a/packages/opencode/test/image/fixtures/white-1x1.avif b/packages/opencode/test/image/fixtures/white-1x1.avif new file mode 100644 index 0000000000000000000000000000000000000000..9d5c466444de5680b539673b26f5c770e98dacc0 GIT binary patch literal 305 zcmXv|L23d)5bQN#7Kvdo0l`D`=rNm9Zb9@WK4LQNk`6OYvkr^7CFDVV!UyCj!4GKl zsx?ekbyd?dL^LkrmzbJM0+cYn+41{z&Ao8+ zO_rcOu?tqaIZ=Y6m69QNfIDGcH*Yt|5KYm7t(~c#^I34AdGeE`68H-i8^DjekwOQ7 z%TCIKx2(oZnx>#TQ|GgvF%;2*4X#>Sx5adiI^VDcEZ%4a;A8Ei_1lIV`IU)%>fXLm SC(Hi$r91k6J0AB$J2?Sj(>4JB literal 0 HcmV?d00001 diff --git a/packages/opencode/test/image/ingress.test.ts b/packages/opencode/test/image/ingress.test.ts new file mode 100644 index 000000000000..bd6c7e051f73 --- /dev/null +++ b/packages/opencode/test/image/ingress.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import path from "node:path" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Server } from "../../src/server/server" +import { ReadTool } from "../../src/tool/read" +import { tmpdir } from "../fixture/fixture" + +const fixture = path.join(import.meta.dir, "fixtures", "white-1x1.avif") +const avif = { + type: "file" as const, + mime: "image/avif", + filename: "white.avif", + url: `data:image/avif;base64,${readFileSync(fixture).toString("base64")}`, +} +const config = { agent: { build: { model: "openai/gpt-5.2" } } } + +describe("image ingress", () => { + test("normalizes API image parts before persisting and preserves other parts", async () => { + await using tmp = await tmpdir({ git: true, config }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const result = await SessionPrompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: "text", text: "before" }, avif, { type: "text", text: "after" }], + }) + expect(result.parts.map((part) => part.type)).toEqual(["text", "file", "text"]) + expect(result.parts[1]).toMatchObject({ mime: "image/png", filename: "white.avif" }) + const stored = await Session.messages({ sessionID: session.id }) + expect(stored).toHaveLength(1) + expect(stored[0].parts[1]).toMatchObject({ mime: "image/png" }) + await Session.remove(session.id) + }, + }) + }) + + test("returns a specific HTTP 400 before headers and does not persist invalid images", async () => { + await using tmp = await tmpdir({ git: true, config }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const app = Server.App() + const url = `http://localhost/session/${session.id}/message?directory=${encodeURIComponent(tmp.path)}` + const response = await app.request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noReply: true, parts: [{ ...avif, url: "data:image/avif;base64,AAAA" }] }), + }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ name: "ImageInputError", data: { kind: "format" } }) + expect(await Session.messages({ sessionID: session.id })).toHaveLength(0) + const accepted = await app.request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noReply: true, parts: [avif] }), + }) + expect(accepted.status).toBe(200) + expect((await accepted.json()).parts[0]).toMatchObject({ mime: "image/png" }) + expect(await Session.messages({ sessionID: session.id })).toHaveLength(1) + await Session.remove(session.id) + }, + }) + }) + + test("read tool returns converted image bytes and rejects corrupted images", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "valid.avif"), readFileSync(fixture)) + await Bun.write(path.join(dir, "bad.avif"), "bad image") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await ReadTool.init() + const ctx = { + sessionID: "test", + messageID: "", + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => {}, + ask: async () => {}, + } + const result = await tool.execute({ filePath: path.join(tmp.path, "valid.avif") }, ctx) + expect(result.attachments?.[0]).toMatchObject({ mime: "image/png" }) + expect(result.attachments?.[0].url).toStartWith("data:image/png;base64,") + await expect(tool.execute({ filePath: path.join(tmp.path, "bad.avif") }, ctx)).rejects.toThrow("supported") + }, + }) + }) +}) diff --git a/packages/opencode/test/offline/fetch-guard.ts b/packages/opencode/test/offline/fetch-guard.ts index 363a2ca5d322..69062641b47c 100644 --- a/packages/opencode/test/offline/fetch-guard.ts +++ b/packages/opencode/test/offline/fetch-guard.ts @@ -29,7 +29,8 @@ const base = `http://127.0.0.1:${source.port}` const report: Record = { status: "running", runtime: { bun: Bun.version, platform: process.platform, architecture: process.arch }, - method: "Actual source module and owned random loopback HTTP servers; no model, GPU, database or existing service used.", + method: + "Actual source module and owned random loopback HTTP servers; no model, GPU, database or existing service used.", checks, } @@ -43,9 +44,95 @@ try { const embedded = await fetch(`data:${mime};base64,QUdFTlRfTE9DQUw=`) checks.push({ case: "embedded_binary_data", mime, pass: embedded.ok && (await embedded.text()) === "AGENT_LOCAL" }) } - const virtual = process.platform === "win32" ? "file:///B:/~BUN/root/embedded/parser.wasm" : "file:///$bunfs/root/embedded/parser.wasm" + const text = await fetch("data:text/plain,synthetic") + checks.push({ case: "inline_text_data", pass: text.ok && (await text.text()) === "synthetic" }) + const domainText = await fetch("data:text/plain,https%3A%2F%2Fexample.invalid%2Fnot-requested") + checks.push({ + case: "domain_inside_local_data", + pass: domainText.ok && (await domainText.text()) === "https://example.invalid/not-requested", + }) + // Public synthetic 1x1 PNG, shared with the Read tool tests; no user image data. + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ) + const image = await fetch(`data:image/png;base64,${png.toString("base64")}`) + checks.push({ + case: "inline_png_data", + pass: + image.ok && + image.headers.get("content-type") === "image/png" && + Buffer.from(await image.arrayBuffer()).equals(png), + }) + const imageRequest = await fetch(new Request(`data:image/png;base64,${png.toString("base64")}`)) + checks.push({ + case: "inline_png_request", + pass: imageRequest.ok && Buffer.from(await imageRequest.arrayBuffer()).equals(png), + }) + const blob = URL.createObjectURL(new Blob([png], { type: "image/png" })) + try { + const response = await fetch(blob) + checks.push({ + case: "local_png_blob", + pass: + response.ok && + response.headers.get("content-type") === "image/png" && + Buffer.from(await response.arrayBuffer()).equals(png), + }) + } finally { + URL.revokeObjectURL(blob) + } + let revokedError = "" + try { + await fetch(blob) + } catch (error) { + revokedError = error instanceof Error ? error.message : String(error) + } + checks.push({ + case: "revoked_blob_denied", + pass: revokedError.startsWith("Offline mode blocks this network request"), + }) + const codec = await import("../../src/image/codec.ts") + const avif = await Bun.file(new URL("../image/fixtures/white-1x1.avif", import.meta.url)).arrayBuffer() + const normalized = await codec.normalize(Buffer.from(avif).toString("base64"), "image/avif", { + autoResize: true, + maxWidth: 2000, + maxHeight: 2000, + maxBase64Bytes: 5 * 1024 * 1024, + }) + const converted = await fetch(`data:${normalized.mime};base64,${normalized.base64}`) + checks.push({ + case: "normalized_avif_data", + pass: + converted.ok && + normalized.mime === "image/png" && + Buffer.from(await converted.arrayBuffer()).equals(Buffer.from(normalized.base64, "base64")), + }) + const photon = await import("@silvia-odwyer/photon-node") + const pixels = new photon.PhotonImage(new Uint8Array([255, 255, 255, 255]), 1, 1) + try { + const jpeg = Buffer.from(pixels.get_bytes_jpeg(85)) + const response = await fetch(`data:image/jpeg;base64,${jpeg.toString("base64")}`) + checks.push({ + case: "inline_jpeg_data", + pass: + response.ok && + response.headers.get("content-type") === "image/jpeg" && + Buffer.from(await response.arrayBuffer()).equals(jpeg), + }) + } finally { + pixels.free() + } + const virtual = + process.platform === "win32" + ? "file:///B:/~BUN/root/embedded/parser.wasm" + : "file:///$bunfs/root/embedded/parser.wasm" guard.assertOfflineURL(virtual) - checks.push({ case: "bun_virtual_file_url_class", pass: true, scope: "Guard path classification only; compiled Bun asset loading is application acceptance." }) + checks.push({ + case: "bun_virtual_file_url_class", + pass: true, + scope: "Guard path classification only; compiled Bun asset loading is application acceptance.", + }) const blocked = [ "https://example.invalid/never-request", "http://example.invalid/never-request", @@ -55,36 +142,68 @@ try { "http://127.0.0.2/never-request", "http://[::ffff:127.0.0.1]/never-request", "http://192.168.1.1/never-request", + "blob:nodedata:unknown-local-object", + "blob:https://example.invalid/forged-object", "file:///etc/passwd", "file:///C:/Windows/System32/not-a-bundled-asset.wasm", "file://example.invalid/share/parser.wasm", "file:////example.invalid/share/parser.wasm", "file:///B:/~BUN/root/../../outside.wasm", "file:///B:/~BUN/root/%2foutside.wasm", - "data:text/plain,synthetic", "ftp://127.0.0.1/never-request", ] for (const url of blocked) { let message = "" - try { await fetch(url) } catch (error) { message = error instanceof Error ? error.message : String(error) } - checks.push({ case: "blocked_url", url, pass: message.startsWith("Offline mode blocks this network request"), error: message }) + try { + await fetch(url) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + checks.push({ + case: "blocked_url", + url, + pass: message.startsWith("Offline mode blocks this network request"), + error: message, + }) } let requestError = "" - try { await fetch(new Request("https://example.invalid/request-object")) } catch (error) { + try { + await fetch(new Request("https://example.invalid/request-object")) + } catch (error) { requestError = error instanceof Error ? error.message : String(error) } - checks.push({ case: "external_request_object", pass: requestError.startsWith("Offline mode blocks this network request") }) + checks.push({ + case: "external_request_object", + pass: requestError.startsWith("Offline mode blocks this network request"), + }) for (const route of ["/redirect-local", "/redirect-external"]) { let error = "" - try { await fetch(base + route) } catch (caught) { error = caught instanceof Error ? caught.message : String(caught) } + try { + await fetch(base + route) + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught) + } checks.push({ case: "automatic_redirect_blocked", route, pass: Boolean(error) && sinkHits === 0, error }) } const manual = await fetch(base + "/redirect-external", { redirect: "manual" }) - checks.push({ case: "manual_redirect_not_followed", pass: manual.status === 302 && manual.headers.get("Location") === "https://example.invalid/guard-test" && sinkHits === 0 }) + checks.push({ + case: "manual_redirect_not_followed", + pass: + manual.status === 302 && + manual.headers.get("Location") === "https://example.invalid/guard-test" && + sinkHits === 0, + }) if (fetch.preconnect) { let error = "" - try { fetch.preconnect("https://example.invalid/") } catch (caught) { error = caught instanceof Error ? caught.message : String(caught) } - checks.push({ case: "external_preconnect_blocked", pass: error.startsWith("Offline mode blocks this network request") }) + try { + fetch.preconnect("https://example.invalid/") + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught) + } + checks.push({ + case: "external_preconnect_blocked", + pass: error.startsWith("Offline mode blocks this network request"), + }) } report.status = checks.every((check) => check.pass === true) ? "pass" : "fail" } catch (error) { @@ -96,8 +215,21 @@ try { report.local_requests = hits report.redirect_destination_requests = sinkHits report.cleanup = { owned_servers_stopped: true, existing_services_or_databases_touched: false } - report.limits = "Fetch/preconnect behavior only, not OS packet capture, raw sockets, third-party worker realms or child-process egress. External cases were rejected by the guard before native fetch/preconnect." + report.limits = + "Fetch/preconnect behavior only, not OS packet capture, raw sockets, third-party worker realms or child-process egress. External cases were rejected by the guard before native fetch/preconnect." report.verified_at_utc = new Date().toISOString() } -console.log(JSON.stringify({ status: report.status, checks: checks.length, failed: checks.filter((c) => c.pass !== true), redirect_destination_requests: sinkHits, runtime: report.runtime }, null, 2)) +console.log( + JSON.stringify( + { + status: report.status, + checks: checks.length, + failed: checks.filter((c) => c.pass !== true), + redirect_destination_requests: sinkHits, + runtime: report.runtime, + }, + null, + 2, + ), +) process.exitCode = report.status === "pass" ? 0 : 1 diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 000000000000..da41cb715e0a --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,285 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..29a964a 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -1,6 +1,7 @@ + + let imports = {}; +-imports['__wbindgen_placeholder__'] = module.exports; ++const __wbindgen_placeholder__ = {}; ++imports['__wbindgen_placeholder__'] = __wbindgen_placeholder__; + let wasm; + const { TextEncoder, TextDecoder } = require(`util`); + +@@ -4272,12 +4273,12 @@ class Rgba { + } + module.exports.Rgba = Rgba; + +-module.exports.__wbg_new_abda76e883ba8a5f = function() { ++__wbindgen_placeholder__.__wbg_new_abda76e883ba8a5f = function() { + const ret = new Error(); + return ret; + }; + +-module.exports.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; +@@ -4285,7 +4286,7 @@ module.exports.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + +-module.exports.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { +@@ -4297,7 +4298,7 @@ module.exports.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { + } + }; + +-module.exports.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { ++__wbindgen_placeholder__.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { + let result; + try { + result = arg0 instanceof Window; +@@ -4308,42 +4309,42 @@ module.exports.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { + return ret; + }; + +-module.exports.__wbg_document_e5c1786dea6542e4 = function(arg0) { ++__wbindgen_placeholder__.__wbg_document_e5c1786dea6542e4 = function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + +-module.exports.__wbg_body_e70ae6abd01ae584 = function(arg0) { ++__wbindgen_placeholder__.__wbg_body_e70ae6abd01ae584 = function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + +-module.exports.__wbg_createElement_5d4c76f218b78145 = function() { return handleError(function (arg0, arg1, arg2) { ++__wbindgen_placeholder__.__wbg_createElement_5d4c76f218b78145 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments) }; + +-module.exports.__wbg_width_4c6f0048d64cf86b = function(arg0) { ++__wbindgen_placeholder__.__wbg_width_4c6f0048d64cf86b = function(arg0) { + const ret = arg0.width; + return ret; + }; + +-module.exports.__wbg_height_21f0d3fd8f753394 = function(arg0) { ++__wbindgen_placeholder__.__wbg_height_21f0d3fd8f753394 = function(arg0) { + const ret = arg0.height; + return ret; + }; + +-module.exports.__wbg_width_79e0847ed5883b03 = function(arg0) { ++__wbindgen_placeholder__.__wbg_width_79e0847ed5883b03 = function(arg0) { + const ret = arg0.width; + return ret; + }; + +-module.exports.__wbg_height_e4e4e4779f8feac0 = function(arg0) { ++__wbindgen_placeholder__.__wbg_height_e4e4e4779f8feac0 = function(arg0) { + const ret = arg0.height; + return ret; + }; + +-module.exports.__wbg_data_fda507064d127f5b = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_data_fda507064d127f5b = function(arg0, arg1) { + const ret = arg1.data; + const ptr1 = passArray8ToWasm0(ret, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; +@@ -4351,12 +4352,12 @@ module.exports.__wbg_data_fda507064d127f5b = function(arg0, arg1) { + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + +-module.exports.__wbg_newwithu8clampedarrayandsh_1fddccb3a94a5e05 = function() { return handleError(function (arg0, arg1, arg2, arg3) { ++__wbindgen_placeholder__.__wbg_newwithu8clampedarrayandsh_1fddccb3a94a5e05 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = new ImageData(getClampedArrayU8FromWasm0(arg0, arg1), arg2 >>> 0, arg3 >>> 0); + return ret; + }, arguments) }; + +-module.exports.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = function(arg0) { ++__wbindgen_placeholder__.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = function(arg0) { + let result; + try { + result = arg0 instanceof CanvasRenderingContext2D; +@@ -4367,24 +4368,24 @@ module.exports.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = fun + return ret; + }; + +-module.exports.__wbg_drawImage_fede06db74e39a60 = function() { return handleError(function (arg0, arg1, arg2, arg3) { ++__wbindgen_placeholder__.__wbg_drawImage_fede06db74e39a60 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.drawImage(arg1, arg2, arg3); + }, arguments) }; + +-module.exports.__wbg_drawImage_f395c8e43c79a909 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) { ++__wbindgen_placeholder__.__wbg_drawImage_f395c8e43c79a909 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) { + arg0.drawImage(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9); + }, arguments) }; + +-module.exports.__wbg_getImageData_5e1c242046e6b59e = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { ++__wbindgen_placeholder__.__wbg_getImageData_5e1c242046e6b59e = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.getImageData(arg1, arg2, arg3, arg4); + return ret; + }, arguments) }; + +-module.exports.__wbg_putImageData_a8b3e177ee06d521 = function() { return handleError(function (arg0, arg1, arg2, arg3) { ++__wbindgen_placeholder__.__wbg_putImageData_a8b3e177ee06d521 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.putImageData(arg1, arg2, arg3); + }, arguments) }; + +-module.exports.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(arg0) { ++__wbindgen_placeholder__.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(arg0) { + let result; + try { + result = arg0 instanceof HTMLCanvasElement; +@@ -4395,93 +4396,93 @@ module.exports.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(a + return ret; + }; + +-module.exports.__wbg_width_dc225e55343b745e = function(arg0) { ++__wbindgen_placeholder__.__wbg_width_dc225e55343b745e = function(arg0) { + const ret = arg0.width; + return ret; + }; + +-module.exports.__wbg_setwidth_488780db69b08846 = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_setwidth_488780db69b08846 = function(arg0, arg1) { + arg0.width = arg1 >>> 0; + }; + +-module.exports.__wbg_height_3a8bec2f3fe71b26 = function(arg0) { ++__wbindgen_placeholder__.__wbg_height_3a8bec2f3fe71b26 = function(arg0) { + const ret = arg0.height; + return ret; + }; + +-module.exports.__wbg_setheight_1761808c18403921 = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_setheight_1761808c18403921 = function(arg0, arg1) { + arg0.height = arg1 >>> 0; + }; + +-module.exports.__wbg_getContext_fc99dbd3a9a7e318 = function() { return handleError(function (arg0, arg1, arg2) { ++__wbindgen_placeholder__.__wbg_getContext_fc99dbd3a9a7e318 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.getContext(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments) }; + +-module.exports.__wbg_settextContent_f82a86a8df347e1c = function(arg0, arg1, arg2) { ++__wbindgen_placeholder__.__wbg_settextContent_f82a86a8df347e1c = function(arg0, arg1, arg2) { + arg0.textContent = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }; + +-module.exports.__wbg_appendChild_fa3b00dade9fc4cf = function() { return handleError(function (arg0, arg1) { ++__wbindgen_placeholder__.__wbg_appendChild_fa3b00dade9fc4cf = function() { return handleError(function (arg0, arg1) { + const ret = arg0.appendChild(arg1); + return ret; + }, arguments) }; + +-module.exports.__wbg_newnoargs_e643855c6572a4a8 = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbg_newnoargs_e643855c6572a4a8 = function(arg0, arg1) { + const ret = new Function(getStringFromWasm0(arg0, arg1)); + return ret; + }; + +-module.exports.__wbg_call_f96b398515635514 = function() { return handleError(function (arg0, arg1) { ++__wbindgen_placeholder__.__wbg_call_f96b398515635514 = function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments) }; + +-module.exports.__wbg_self_b9aad7f1c618bfaf = function() { return handleError(function () { ++__wbindgen_placeholder__.__wbg_self_b9aad7f1c618bfaf = function() { return handleError(function () { + const ret = self.self; + return ret; + }, arguments) }; + +-module.exports.__wbg_window_55e469842c98b086 = function() { return handleError(function () { ++__wbindgen_placeholder__.__wbg_window_55e469842c98b086 = function() { return handleError(function () { + const ret = window.window; + return ret; + }, arguments) }; + +-module.exports.__wbg_globalThis_d0957e302752547e = function() { return handleError(function () { ++__wbindgen_placeholder__.__wbg_globalThis_d0957e302752547e = function() { return handleError(function () { + const ret = globalThis.globalThis; + return ret; + }, arguments) }; + +-module.exports.__wbg_global_ae2f87312b8987fb = function() { return handleError(function () { ++__wbindgen_placeholder__.__wbg_global_ae2f87312b8987fb = function() { return handleError(function () { + const ret = global.global; + return ret; + }, arguments) }; + +-module.exports.__wbindgen_is_undefined = function(arg0) { ++__wbindgen_placeholder__.__wbindgen_is_undefined = function(arg0) { + const ret = arg0 === undefined; + return ret; + }; + +-module.exports.__wbg_buffer_fcbfb6d88b2732e9 = function(arg0) { ++__wbindgen_placeholder__.__wbg_buffer_fcbfb6d88b2732e9 = function(arg0) { + const ret = arg0.buffer; + return ret; + }; + +-module.exports.__wbg_new_bc5d9aad3f9ac80e = function(arg0) { ++__wbindgen_placeholder__.__wbg_new_bc5d9aad3f9ac80e = function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }; + +-module.exports.__wbg_set_4b3aa8445ac1e91c = function(arg0, arg1, arg2) { ++__wbindgen_placeholder__.__wbg_set_4b3aa8445ac1e91c = function(arg0, arg1, arg2) { + arg0.set(arg1, arg2 >>> 0); + }; + +-module.exports.__wbg_length_d9c4ded7e708c6a1 = function(arg0) { ++__wbindgen_placeholder__.__wbg_length_d9c4ded7e708c6a1 = function(arg0) { + const ret = arg0.length; + return ret; + }; + +-module.exports.__wbindgen_debug_string = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbindgen_debug_string = function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; +@@ -4489,16 +4490,16 @@ module.exports.__wbindgen_debug_string = function(arg0, arg1) { + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + +-module.exports.__wbindgen_throw = function(arg0, arg1) { ++__wbindgen_placeholder__.__wbindgen_throw = function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }; + +-module.exports.__wbindgen_memory = function() { ++__wbindgen_placeholder__.__wbindgen_memory = function() { + const ret = wasm.memory; + return ret; + }; + +-module.exports.__wbindgen_init_externref_table = function() { ++__wbindgen_placeholder__.__wbindgen_init_externref_table = function() { + const table = wasm.__wbindgen_export_2; + const offset = table.grow(4); + table.set(0, undefined); +@@ -4509,7 +4510,8 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. ++const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); + const bytes = require('fs').readFileSync(path); + + const wasmModule = new WebAssembly.Module(bytes); diff --git a/patches/LICENSE-Photon-Apache-2.0.txt b/patches/LICENSE-Photon-Apache-2.0.txt new file mode 100644 index 000000000000..1b16a2f72f2e --- /dev/null +++ b/patches/LICENSE-Photon-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023, Silvia O'Dwyer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/patches/NOTICE-Photon.txt b/patches/NOTICE-Photon.txt new file mode 100644 index 000000000000..1f6c84304317 --- /dev/null +++ b/patches/NOTICE-Photon.txt @@ -0,0 +1,31 @@ +Photon Node binding compatibility patch +====================================== + +Component: @silvia-odwyer/photon-node, version 0.3.4 +Copyright 2023, Silvia O'Dwyer +License: Apache License, Version 2.0 + +The complete license from the published 0.3.4 package is reproduced unchanged +in LICENSE-Photon-Apache-2.0.txt in this directory. + +Package source: +https://www.npmjs.com/package/@silvia-odwyer/photon-node/v/0.3.4 +https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz +Upstream repository: +https://github.com/silvia-odwyer/photon +Published package gitHead: +https://github.com/silvia-odwyer/photon/tree/685f5b155b36c5611c08ca678bb78ddbab3edbac + +The adjacent @silvia-odwyer%2Fphoton-node@0.3.4.patch modifies photon_rs.js: +- Uses a dedicated object for WebAssembly import bindings instead of binding + them to the changing CommonJS export object. +- Allows an explicit embedded WebAssembly resource path for Bun compiled + executables, while retaining the package-relative resource fallback. + +This is an application compatibility modification, not an upstream release. +The upstream package and its recorded source revision contain no separate +NOTICE file. This file documents the component attribution and local changes; +it does not replace or modify the Apache-2.0 license terms. + +Only the source patch and notices are distributed here. No Photon WebAssembly +binary or package archive is included in this source distribution. From c0af082a3dc7166844d0d31cd74a95a20ddd1be4 Mon Sep 17 00:00:00 2001 From: LexZeon <287497354+LexZeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:07:34 +0000 Subject: [PATCH 3/3] Handle Windows file clipboard and terminal image paste --- docs/windows-arm64-offline.md | 38 ++- .../cli/cmd/tui/component/prompt/index.tsx | 156 +++++++---- .../src/cli/cmd/tui/util/clipboard.ts | 68 ++++- .../src/cli/cmd/tui/util/image-input.ts | 176 ++++++++++++ packages/opencode/src/config/config.ts | 6 +- .../test/image/clipboard-input.test.ts | 260 ++++++++++++++++++ 6 files changed, 639 insertions(+), 65 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/image-input.ts create mode 100644 packages/opencode/test/image/clipboard-input.test.ts diff --git a/docs/windows-arm64-offline.md b/docs/windows-arm64-offline.md index 3bd0e3f3cc50..de6d2f0e5cea 100644 --- a/docs/windows-arm64-offline.md +++ b/docs/windows-arm64-offline.md @@ -82,6 +82,29 @@ at that endpoint. Set `share: "disabled"`, `autoupdate: false`, `plugin: []`, an disable unused web tools and remote MCP servers. This fork predates newer config fields such as `subagent_depth`; validate copied configuration against its schema. +## Paste images on Windows + +Use **Alt+V**, or click **Alt+V paste image** in the normal prompt footer, after +copying an image bitmap, an image file in Explorer, or a local image path. Windows +defaults include both `ctrl+v` and `alt+v`; a terminal host can intercept Ctrl+V +before OpenCode receives it. No global terminal settings need to be changed. +Ordinary clipboard text is still inserted as text. Empty clipboard input produces +a short hint instead of silently doing nothing. + +The Windows clipboard reader supports `CF_HDROP` file lists (up to 16 files) as +well as bitmaps. Local image paths accept matching single or double quotes, +including spaces and Unicode names. Windows PowerShell runs hidden in STA mode +with UTF-8 output, a timeout and a bounded output buffer. File content reads are +bounded by the existing 64 MiB image-input limit and use the existing image codec +for validation and normalization before attachment. + +Image files must be resident on a local fixed drive. UNC/device paths, mapped +network drives, remote URLs, symlinks/junctions and online-only Cloud Files are +rejected before content reads. Download an online-only image in Explorer first; +the attachment helper does not request hydration. Failed attachments show a +readable error. Terminal file-drop transport depends on the terminal host; when +it only inserts a path, copying that path and using Alt+V is the explicit route. + ## Behavior and validation In offline mode, missing web assets return HTTP 503 instead of proxying the @@ -116,7 +139,7 @@ headers are committed. Local AVIF decoding is bundled as WASM; PNG/JPEG output i passed to the selected model. The same validation covers prompt attachments, file reads, and image results from tools and MCP. Invalid base64, damaged images, MIME mismatches, and oversized inputs produce a readable HTTP 400; the terminal -restores a failed draft. Animated AVIF uses its first frame. +includes failed-draft restoration logic. Animated AVIF uses its first frame. The image update passed 47 targeted source tests, compiled API checks with mixed valid/invalid concurrent requests, and real local-model AVIF/PNG inference. The @@ -124,6 +147,19 @@ tests use synthetic data and two public libavif fixtures; their source and BSD notice are included beside the fixtures. These checks do not establish image support for every model or replace an operating-system network sandbox. +The subsequent Windows clipboard/path update passed the opencode typecheck and +all **41 image tests with 106 assertions**, including 33 focused clipboard, +path and keybinding tests. The **35 source fetch-guard checks** also passed with +zero redirect-destination requests. Native PowerShell UTF-8 serialization of a +synthetic Unicode path and the metadata-to-AVIF-to-PNG path were checked without +reading or changing the system clipboard. Both native CLI and web assets were +rebuilt with Bun 1.4.4. These tests do not claim a physically automated drag-and-drop +test or broad terminal-host compatibility. +Separate controlled native clipboard checks passed for an Explorer-style AVIF +file list with Unicode/spaces, a PNG bitmap, and a quoted text path; the prior +clipboard data was restored. They used public/synthetic fixtures and did not +test physical drag events or delivery of Alt+V by every terminal host. + Run the guard check from `packages/opencode`: ```powershell diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f25b95ec51d0..d49fd8138b9e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,8 +1,6 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" -import path from "path" -import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" import { EmptyBorder } from "@tui/component/border" @@ -21,6 +19,13 @@ import { useRenderer } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" +import { + defaultImageLimits, + loadLocalImage, + parseLocalImagePath, + prepareImage, + type PreparedImage, +} from "../../util/image-input" import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" @@ -200,14 +205,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", hidden: true, onSelect: async () => { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - await pasteImage({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - } + await pasteClipboard() }, }, { @@ -718,7 +716,73 @@ export function Prompt(props: PromptProps) { ) } - async function pasteImage(file: { filename?: string; content: string; mime: string }) { + function imageLimits() { + const config = ( + sync.data.config as { + attachment?: { + image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number } + } + } + ).attachment?.image + return { + autoResize: config?.auto_resize ?? defaultImageLimits.autoResize, + maxWidth: config?.max_width ?? defaultImageLimits.maxWidth, + maxHeight: config?.max_height ?? defaultImageLimits.maxHeight, + maxBase64Bytes: config?.max_base64_bytes ?? defaultImageLimits.maxBase64Bytes, + } + } + + function attachImage(file: PreparedImage) { + if (file.mime === "image/svg+xml") pasteText(file.content, `[SVG: ${file.filename ?? "image"}]`) + else pasteImage(file) + } + + async function pasteClipboard() { + try { + const content = await Clipboard.read() + if (content?.files) { + const images: PreparedImage[] = [] + for (const filepath of content.files) { + const image = await loadLocalImage(filepath, { limits: imageLimits() }) + if (!image) throw new Error("Clipboard file list must contain supported local images") + images.push(image) + } + for (const image of images) attachImage(image) + return true + } + if (content?.mime === "text/plain" && content.data) { + const filepath = parseLocalImagePath(content.data) + if (filepath) { + const image = await loadLocalImage(content.data, { limits: imageLimits() }) + if (image) attachImage(image) + } else { + const text = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const lines = (text.match(/\n/g)?.length ?? 0) + 1 + if ((lines >= 3 || text.length > 150) && !sync.data.config.experimental?.disable_paste_summary) + pasteText(text, `[Pasted ~${lines} lines]`) + else input.insertText(text) + } + return true + } + if (!content?.mime.startsWith("image/")) { + toast.show({ + variant: "info", + message: "Copy an image, image file or local image path, then use Alt+V", + duration: 3000, + }) + return true + } + attachImage( + await prepareImage({ filename: "clipboard", mime: content.mime, content: content.data }, imageLimits()), + ) + return true + } catch (error) { + toast.error(error) + return true + } + } + + function pasteImage(file: PreparedImage) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const count = store.prompt.parts.filter((x) => x.type === "file" && x.mime.startsWith("image/")).length @@ -866,17 +930,9 @@ export function Prompt(props: PromptProps) { // through bracketed paste, so we need to intercept the keypress and // directly read from clipboard before the terminal handles it if (keybind.match("input_paste", e)) { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - e.preventDefault() - await pasteImage({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - return - } - // If no image, let the default paste behavior continue + e.preventDefault() + await pasteClipboard() + return } if (keybind.match("input_clear", e) && store.prompt.input !== "") { input.clear() @@ -952,38 +1008,17 @@ export function Prompt(props: PromptProps) { return } - // trim ' from the beginning and end of the pasted content. just - // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const mime = Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image - if (mime === "image/svg+xml") { - event.preventDefault() - const content = await Filesystem.readText(filepath).catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${filename ?? "image"}]`) - return - } - } - if (mime.startsWith("image/")) { - event.preventDefault() - const content = await Filesystem.readArrayBuffer(filepath) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename, - mime, - content, - }) - return - } - } - } catch {} + try { + if (parseLocalImagePath(pastedContent)) { + event.preventDefault() + const image = await loadLocalImage(pastedContent, { limits: imageLimits() }) + if (image) attachImage(image) + return + } + } catch (error) { + event.preventDefault() + toast.error(error) + return } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 @@ -1153,6 +1188,19 @@ export function Prompt(props: PromptProps) { + + { + if (props.disabled) return + input.focus() + void pasteClipboard() + }} + > + + Alt+V paste image + + + 0}> {keybind.print("variant_cycle")} variants diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 1a8197bf4e81..fc1917da6d59 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -6,6 +6,8 @@ import { tmpdir } from "os" import path from "path" import { Filesystem } from "../../../../util/filesystem" import { Process } from "../../../../util/process" +import { execFile } from "node:child_process" +import { promisify } from "node:util" /** * Writes text to clipboard via OSC 52 escape sequence. @@ -25,6 +27,32 @@ export namespace Clipboard { export interface Content { data: string mime: string + files?: string[] + } + + export function parseWindowsContent(text: string): Content | undefined { + if (!text.trim()) return + if (text.length > Math.ceil((64 * 1024 * 1024) / 3) * 4 + 1024) + throw new Error("Clipboard image exceeds the 64 MiB input limit") + const value = JSON.parse(text) + if (Array.isArray(value.files)) { + if ( + value.files.length > 16 || + value.files.some((p: unknown) => typeof p !== "string" || !p || p.length > 32768 || /[\0\r\n]/.test(p)) + ) + throw new Error("Clipboard file list is invalid or contains more than 16 files") + if (!value.files.length) return + return { data: "", mime: "application/x-opencode-file-list", files: value.files } + } + if (value.mime !== "image/png" || typeof value.data !== "string") + throw new Error("Clipboard image format is invalid") + const data = value.data + if (!data || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) + throw new Error("Clipboard image contains invalid base64") + const bytes = Buffer.from(data, "base64") + if (bytes.length > 64 * 1024 * 1024 || bytes.toString("base64") !== data) + throw new Error("Clipboard image is invalid or exceeds the 64 MiB input limit") + return { data, mime: "image/png" } } export async function read(): Promise { @@ -45,15 +73,37 @@ export namespace Clipboard { } if (os === "win32" || release().includes("WSL")) { - const script = - "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" - const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() - if (base64) { - const imageBuffer = Buffer.from(base64.trim(), "base64") - if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } - } - } + const script = ` +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +Add-Type -AssemblyName System.Windows.Forms +$files = [System.Windows.Forms.Clipboard]::GetFileDropList() +if ($files.Count -gt 0) { + if ($files.Count -gt 16) { throw 'Paste at most 16 image files at once' } + @{ files = @($files) } | ConvertTo-Json -Compress + exit +} +$img = [System.Windows.Forms.Clipboard]::GetImage() +if ($img) { + try { + if ([long]$img.Width * $img.Height -gt 67108864) { throw 'Clipboard image exceeds the 64 megapixel limit' } + $ms = New-Object System.IO.MemoryStream + try { + $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + if ($ms.Length -gt 67108864) { throw 'Clipboard image exceeds the 64 MiB limit' } + @{ mime = 'image/png'; data = [System.Convert]::ToBase64String($ms.ToArray()) } | ConvertTo-Json -Compress + } finally { $ms.Dispose() } + } finally { $img.Dispose() } +}` + const result = await promisify(execFile)( + "powershell.exe", + ["-NonInteractive", "-NoProfile", "-STA", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], + { encoding: "utf8", timeout: 10_000, maxBuffer: 90 * 1024 * 1024, windowsHide: true }, + ).catch(() => { + throw new Error("Windows clipboard could not be read; copy the image again or paste its local file path") + }) + const content = parseWindowsContent(result.stdout.toString()) + if (content) return content } if (os === "linux") { diff --git a/packages/opencode/src/cli/cmd/tui/util/image-input.ts b/packages/opencode/src/cli/cmd/tui/util/image-input.ts new file mode 100644 index 000000000000..7036391ac461 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/image-input.ts @@ -0,0 +1,176 @@ +import path from "node:path" +import { lstat, open, realpath } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { MAX_INPUT_BYTES, normalize, type Limits } from "@/image/codec" + +const mimes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".svg": "image/svg+xml", +} + +export const defaultImageLimits: Limits = { + autoResize: true, + maxWidth: 2000, + maxHeight: 2000, + maxBase64Bytes: 5 * 1024 * 1024, +} + +export function parseLocalImagePath(input: string, platform: NodeJS.Platform = process.platform) { + let value = input.trim() + if (!value) return + const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) + if (quoted) value = value.slice(1, -1) + if (!/\.(png|jpe?g|gif|webp|avif|svg)(?:[?#:].*)?$/i.test(value)) return + if (value.length > 32768) throw new Error("Image path exceeds the supported length") + if (!quoted && /\s/.test(value) && !/[\\/]/.test(value)) return + if (/[\0\r\n]/.test(value)) throw new Error("Paste one local image path at a time") + if (/^file:/i.test(value)) { + const url = new URL(value) + if (url.hostname || url.search || url.hash) throw new Error("Only local image files are supported") + value = fileURLToPath(url) + } else if (/^[a-z][a-z\d+.-]*:/i.test(value) && !/^[a-z]:[\\/]/i.test(value)) { + throw new Error("Only local image files are supported; network URLs are not loaded") + } + if (/^(?:\\\\|\/\/)/.test(value)) throw new Error("Network and device image paths are not supported") + if (platform === "win32" && value.slice(/^[a-z]:/i.test(value) ? 2 : 0).includes(":")) + throw new Error("Alternate data stream image paths are not supported") + // Keep Windows separators intact; only POSIX shell escaping uses backslash-space. + if (platform !== "win32") value = value.replace(/\\ /g, " ") + return value +} + +// Query metadata with OPEN_REPARSE_POINT before a data read. Cloud Files placeholders +// must be made resident by the user; attaching an image must not hydrate them. +const windowsMetadataScript = String.raw` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class ImageLocalMetadata { + [StructLayout(LayoutKind.Sequential)] struct TagInfo { public uint Attributes; public uint Tag; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFileW(string p, uint a, uint s, IntPtr sa, uint c, uint f, IntPtr t); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandleEx(SafeFileHandle h, int k, out TagInfo i, uint n); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern uint GetFinalPathNameByHandleW(SafeFileHandle h, StringBuilder p, uint n, uint f); + public static void Check(string value) { + string full = Path.GetFullPath(value); + if (new DriveInfo(Path.GetPathRoot(full)).DriveType != DriveType.Fixed) + throw new IOException("Only image files on a local fixed drive are supported"); + string current = full; + bool leaf = true; + while (current != null) { + using (var handle = CreateFileW(current, 0x80, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) { + if (handle.IsInvalid) throw new IOException("Image metadata could not be opened"); + TagInfo info; + if (!GetFileInformationByHandleEx(handle, 9, out info, 8)) throw new IOException("Image metadata is unavailable"); + if ((info.Attributes & 0x400) != 0 && (info.Tag & 0xffff0fff) != 0x9000001a) + throw new IOException("Image symlinks and junctions are not supported"); + if (leaf && (info.Attributes & (0x1000 | 0x40000 | 0x400000)) != 0) + throw new IOException("Image is online-only. Download it in Explorer before attaching it"); + if (leaf && (info.Attributes & 0x10) != 0) throw new IOException("Choose an image file, not a folder"); + var final = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, final, (uint)final.Capacity, 0); + if (length == 0 || length >= final.Capacity) throw new IOException("Image path could not be verified"); + string actual = final.ToString(); + if (actual.StartsWith(@"\\?\")) actual = actual.Substring(4); + if (!String.Equals(actual.TrimEnd('\\'), current.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) + throw new IOException("Image path resolves outside its selected location"); + } + leaf = false; + current = Path.GetDirectoryName(current); + } + } +} +'@ +try { [ImageLocalMetadata]::Check($env:OPENCODE_IMAGE_INPUT_PATH); 'ok' } +catch { [Console]::Error.WriteLine($_.Exception.GetBaseException().Message); exit 1 } +` + +async function inspectWindows(filepath: string) { + try { + await promisify(execFile)( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-STA", + "-EncodedCommand", + Buffer.from(windowsMetadataScript, "utf16le").toString("base64"), + ], + { + env: { ...process.env, OPENCODE_IMAGE_INPUT_PATH: filepath }, + timeout: 10_000, + maxBuffer: 16 * 1024, + windowsHide: true, + }, + ) + } catch (error) { + const message = (error as { stderr?: string }).stderr?.trim() + throw new Error(message || "Image metadata could not be verified") + } +} + +export interface PreparedImage { + filename?: string + content: string + mime: string +} + +export async function prepareImage(image: PreparedImage, limits = defaultImageLimits): Promise { + const result = await normalize(image.content, image.mime, limits) + return { filename: image.filename, mime: result.mime, content: result.base64 } +} + +export async function loadLocalImage( + input: string, + options: { + limits?: Limits + platform?: NodeJS.Platform + inspectWindows?: (filepath: string) => Promise + } = {}, +): Promise { + const platform = options.platform ?? process.platform + const value = parseLocalImagePath(input, platform) + if (!value) return + const filepath = path.resolve(value) + if (platform === "win32") await (options.inspectWindows ?? inspectWindows)(filepath) + const info = await lstat(filepath) + if (!info.isFile() || info.isSymbolicLink()) throw new Error("Choose a regular local image file") + const resolved = await realpath(filepath) + if (platform === "win32" ? resolved.toLowerCase() !== filepath.toLowerCase() : resolved !== filepath) + throw new Error("Image symlinks and junctions are not supported") + if (info.size > MAX_INPUT_BYTES) throw new Error("Image file exceeds the 64 MiB input limit") + const file = await open(filepath, "r") + try { + const current = await file.stat() + if (!current.isFile() || current.dev !== info.dev || current.ino !== info.ino) + throw new Error("Image file changed while it was being attached") + if (current.size > MAX_INPUT_BYTES) throw new Error("Image file exceeds the 64 MiB input limit") + const buffer = Buffer.alloc(current.size + 1) + let count = 0 + while (count < buffer.length) { + const result = await file.read(buffer, count, buffer.length - count, count) + if (!result.bytesRead) break + count += result.bytesRead + } + if (count > current.size) throw new Error("Image file changed while it was being attached") + const content = buffer.subarray(0, count) + const mime = mimes[path.extname(filepath).toLowerCase()] + if (mime === "image/svg+xml") return { filename: path.basename(filepath), mime, content: content.toString("utf8") } + return await prepareImage( + { filename: path.basename(filepath), mime, content: content.toString("base64") }, + options.limits, + ) + } finally { + await file.close() + } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5729d3ea8278..613f99220bc3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -843,7 +843,11 @@ export namespace Config { agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), - input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_paste: z + .string() + .optional() + .default(process.platform === "win32" ? "ctrl+v,alt+v" : "ctrl+v") + .describe("Paste from clipboard (Alt+V also works when Windows Terminal intercepts Ctrl+V)"), input_submit: z.string().optional().default("return").describe("Submit input"), input_newline: z .string() diff --git a/packages/opencode/test/image/clipboard-input.test.ts b/packages/opencode/test/image/clipboard-input.test.ts new file mode 100644 index 000000000000..8c732425a034 --- /dev/null +++ b/packages/opencode/test/image/clipboard-input.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, mock, spyOn, test } from "bun:test" +import { readFileSync } from "node:fs" +import * as fs from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { pathToFileURL } from "node:url" +import { MAX_INPUT_BYTES } from "../../src/image/codec" +import { Clipboard } from "../../src/cli/cmd/tui/util/clipboard" +import { Config } from "../../src/config/config" +import { Keybind } from "../../src/util/keybind" +import { loadLocalImage, parseLocalImagePath } from "../../src/cli/cmd/tui/util/image-input" +import { tmpdir } from "../fixture/fixture" + +// The checked-in public libavif fixture is shared with codec.test.ts. +const avif = readFileSync(new URL("./fixtures/white-1x1.avif", import.meta.url)) +const limits = { autoResize: true, maxWidth: 2000, maxHeight: 2000, maxBase64Bytes: 5 * 1024 * 1024 } +const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + +function directory() { + return tmpdir({ + dispose: async (dir) => { + const root = path.resolve(os.tmpdir()) + const target = path.resolve(dir) + const same = (a: string, b: string) => + process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b + if (!same(path.dirname(target), root) || !/^opencode-test-[a-z0-9]+$/.test(path.basename(target))) + throw new Error("Refusing cleanup outside the owned test temporary directory") + if (!same(path.dirname(await fs.realpath(target)), await fs.realpath(root))) + throw new Error("Refusing redirected test temporary directory cleanup") + await fs.rm(target, { recursive: true, force: true }) + }, + }) +} + +test("Windows paste defaults include a terminal-safe Alt+V alternative and preserve custom bindings", () => { + const value = Config.Keybinds.parse({}).input_paste + expect(value).toBe(process.platform === "win32" ? "ctrl+v,alt+v" : "ctrl+v") + const [ctrl, alt] = Keybind.parse("ctrl+v,alt+v") + expect(ctrl).toMatchObject({ name: "v", ctrl: true, meta: false }) + expect(alt).toMatchObject({ name: "v", ctrl: false, meta: true }) + expect(Keybind.match(ctrl, alt!)).toBe(false) + expect(Config.Keybinds.parse({ input_paste: "super+v" }).input_paste).toBe("super+v") +}) + +describe("local image path input", () => { + test("accepts a complete Windows image path with either surrounding quote style", () => { + const file = String.raw`C:\image folder\图像 one.AVIF` + expect(parseLocalImagePath(file, "win32")).toBe(file) + expect(parseLocalImagePath(`"${file}"`, "win32")).toBe(file) + expect(parseLocalImagePath(`'${file}'`, "win32")).toBe(file) + expect(parseLocalImagePath(` "${file}" `, "win32")).toBe(file) + }) + + test("accepts quoted POSIX paths and shell-escaped spaces", () => { + expect(parseLocalImagePath("'/tmp/image folder/one.png'", "linux")).toBe("/tmp/image folder/one.png") + expect(parseLocalImagePath(String.raw`/tmp/image\ folder/one.png`, "linux")).toBe("/tmp/image folder/one.png") + }) + + test("preserves ordinary non-image text for the prompt", () => { + for (const value of ["", " ", "Please describe this image", "line one\nline two", "notes.txt", '"notes.txt"']) + expect(parseLocalImagePath(value)).toBeUndefined() + }) + + test.each([ + "https://example.invalid/image.png", + "http://127.0.0.1/image.avif", + "ftp://example.invalid/image.jpg", + "file://example.invalid/share/image.png", + "https://example.invalid/image.png?download=1", + ])("refuses a remote or non-file image URL: %s", (value) => { + expect(() => parseLocalImagePath(value, "win32")).toThrow() + }) + + test.each([ + String.raw`\\server\share\image.png`, + "//server/share/image.png", + String.raw`\\?\C:\images\image.avif`, + String.raw`\\.\C:\images\image.jpg`, + ])("refuses UNC and device paths: %s", (value) => { + expect(() => parseLocalImagePath(value, "win32")).toThrow() + }) + + test.each([ + String.raw`C:\images\image.png:stream`, + String.raw`C:\images\image.png:stream:$DATA`, + String.raw`C:\images\document.txt:stream.png`, + ])("refuses Windows alternate data streams: %s", (value) => { + expect(() => parseLocalImagePath(value, "win32")).toThrow() + }) + + test("refuses control characters in an image path", () => { + for (const value of ["C:\\images\\one\ntwo.png", "C:\\images\\one\rtwo.png", "C:\\images\\one\0two.png"]) + expect(() => parseLocalImagePath(value, "win32")).toThrow() + }) +}) + +describe("local image file loading", () => { + test("does not inspect or read an ordinary text prompt", async () => { + const inspectWindows = mock(async (_file: string) => {}) + expect( + await loadLocalImage("Please explain the attached diagram", { platform: "win32", inspectWindows }), + ).toBeUndefined() + expect(inspectWindows).not.toHaveBeenCalled() + }) + + test("normalizes an owned AVIF file after the Windows metadata check", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "image with spaces.avif") + await fs.writeFile(file, avif) + const inspectWindows = mock(async (_file: string) => {}) + const result = await loadLocalImage(`"${file}"`, { platform: "win32", inspectWindows, limits }) + expect(inspectWindows).toHaveBeenCalledTimes(1) + expect(inspectWindows).toHaveBeenCalledWith(file) + expect(result).toMatchObject({ filename: "image with spaces.avif", mime: "image/png" }) + expect(Buffer.from(result!.content, "base64").subarray(0, 8)).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + ) + }) + + test("accepts a native local file URL with encoded spaces", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "local image.avif") + await fs.writeFile(file, avif) + expect(parseLocalImagePath(pathToFileURL(file).href)).toBe(file) + expect(await loadLocalImage(pathToFileURL(file).href, { inspectWindows: async () => {}, limits })).toMatchObject({ + filename: "local image.avif", + mime: "image/png", + }) + }) + + test("a rejected metadata gate prevents file content reads", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "must-not-read.avif") + await fs.writeFile(file, avif) + const handle = await fs.open(file, "r") + const read = spyOn(Object.getPrototypeOf(handle), "read") + try { + const inspectWindows = mock(async (_file: string) => { + throw new Error("synthetic offline-only metadata") + }) + await expect(loadLocalImage(file, { platform: "win32", inspectWindows })).rejects.toThrow( + "synthetic offline-only metadata", + ) + expect(inspectWindows).toHaveBeenCalledWith(file) + expect(read).not.toHaveBeenCalled() + } finally { + read.mockRestore() + await handle.close() + } + }) + + test("checks Windows metadata before opening a nonexistent file", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "not-created.avif") + await expect( + loadLocalImage(file, { + platform: "win32", + inspectWindows: async () => { + throw new Error("synthetic metadata refusal") + }, + }), + ).rejects.toThrow("synthetic metadata refusal") + }) + + test("rejects directories and damaged image files", async () => { + await using tmp = await directory() + const folder = path.join(tmp.path, "folder.png") + const file = path.join(tmp.path, "damaged.avif") + await fs.mkdir(folder) + await fs.writeFile(file, "synthetic invalid image bytes") + const options = { inspectWindows: async () => {}, limits } + await expect(loadLocalImage(folder, options)).rejects.toThrow("regular") + await expect(loadLocalImage(file, options)).rejects.toThrow("supported") + }) + + test("rejects an extension whose MIME does not match the image bytes", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "wrong.png") + await fs.writeFile(file, avif) + await expect(loadLocalImage(file, { inspectWindows: async () => {}, limits })).rejects.toThrow("MIME") + }) + + test("rejects an oversized sparse file before reading its contents", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "oversized.avif") + const handle = await fs.open(file, "w+") + await handle.truncate(MAX_INPUT_BYTES + 1) + const read = spyOn(Object.getPrototypeOf(handle), "read") + try { + await expect(loadLocalImage(file, { inspectWindows: async () => {}, limits })).rejects.toThrow("64 MiB") + expect(read).not.toHaveBeenCalled() + expect((await handle.stat()).size).toBe(MAX_INPUT_BYTES + 1) + } finally { + read.mockRestore() + await handle.close() + } + }) + + test("keeps the existing SVG text contract", async () => { + await using tmp = await directory() + const file = path.join(tmp.path, "shape.svg") + const content = '' + await fs.writeFile(file, content) + expect(await loadLocalImage(file, { inspectWindows: async () => {} })).toEqual({ + filename: "shape.svg", + mime: "image/svg+xml", + content, + }) + }) +}) + +describe("Windows clipboard serialization", () => { + // Only the pure parser is called here; no test reads or changes the system clipboard. + test("returns no content for an empty response or empty file drop list", () => { + expect(Clipboard.parseWindowsContent("")).toBeUndefined() + expect(Clipboard.parseWindowsContent(" \r\n")).toBeUndefined() + expect(Clipboard.parseWindowsContent('{"files":[]}')).toBeUndefined() + }) + + test("preserves file drop ordering, Unicode and spaces", () => { + const files = [String.raw`C:\images\one image.avif`, String.raw`C:\images\图像二.png`] + expect(Clipboard.parseWindowsContent(JSON.stringify({ files }))).toEqual({ + data: "", + mime: "application/x-opencode-file-list", + files, + }) + }) + + test("accepts sixteen files and rejects a larger drop", () => { + const files = Array.from({ length: 16 }, (_, index) => String.raw`C:\images\${index}.png`) + expect(Clipboard.parseWindowsContent(JSON.stringify({ files }))?.files).toEqual(files) + expect(() => + Clipboard.parseWindowsContent(JSON.stringify({ files: [...files, String.raw`C:\images\extra.png`] })), + ).toThrow() + }) + + test("rejects malformed file lists", () => { + for (const files of [[""], [1], ["one\ntwo.png"], ["one\0two.png"], ["a".repeat(32769)]]) + expect(() => Clipboard.parseWindowsContent(JSON.stringify({ files }))).toThrow() + }) + + test("accepts a canonical PNG bitmap payload", () => { + expect(Clipboard.parseWindowsContent(JSON.stringify({ data: png, mime: "image/png" }))).toEqual({ + data: png, + mime: "image/png", + }) + }) + + test("rejects malformed JSON, unsupported payload shapes and invalid base64", () => { + for (const value of ["{", "null", "[]", '{"files":"image.png"}', '{"data":"Zg==","mime":"image/jpeg"}']) + expect(() => Clipboard.parseWindowsContent(value)).toThrow() + for (const data of ["", "Zg", "Zg=", "Zh==", "Zg==\n", "%%%="]) + expect(() => Clipboard.parseWindowsContent(JSON.stringify({ data, mime: "image/png" }))).toThrow() + }) + + test("rejects an oversized clipboard response before parsing or decoding", () => { + const content = "A".repeat(Math.ceil(MAX_INPUT_BYTES / 3) * 4 + 1025) + expect(() => Clipboard.parseWindowsContent(content)).toThrow("64 MiB") + }) +})