diff --git a/src/agents/apply-patch-update.ts b/src/agents/apply-patch-update.ts index 597d14b93..5042a3778 100644 --- a/src/agents/apply-patch-update.ts +++ b/src/agents/apply-patch-update.ts @@ -7,11 +7,17 @@ type UpdateFileChunk = { isEndOfFile: boolean; }; +async function defaultReadFile(filePath: string): Promise { + return fs.readFile(filePath, "utf8"); +} + export async function applyUpdateHunk( filePath: string, chunks: UpdateFileChunk[], + options?: { readFile?: (filePath: string) => Promise }, ): Promise { - const originalContents = await fs.readFile(filePath, "utf8").catch((err) => { + const reader = options?.readFile ?? defaultReadFile; + const originalContents = await reader(filePath).catch((err) => { throw new Error(`Failed to read file to update ${filePath}: ${err}`); }); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index 045bb712d..12b3aef26 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { applyUpdateHunk } from "./apply-patch-update.js"; -import { assertSandboxPath } from "./sandbox-paths.js"; +import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; const BEGIN_PATCH_MARKER = "*** Begin Patch"; const END_PATCH_MARKER = "*** End Patch"; @@ -59,9 +59,14 @@ export type ApplyPatchToolDetails = { summary: ApplyPatchSummary; }; +type SandboxApplyPatchConfig = { + root: string; + bridge: SandboxFsBridge; +}; + type ApplyPatchOptions = { cwd: string; - sandboxRoot?: string; + sandbox?: SandboxApplyPatchConfig; signal?: AbortSignal; }; @@ -72,11 +77,11 @@ const applyPatchSchema = Type.Object({ }); export function createApplyPatchTool( - options: { cwd?: string; sandboxRoot?: string } = {}, + options: { cwd?: string; sandbox?: SandboxApplyPatchConfig } = {}, // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance. ): AgentTool { const cwd = options.cwd ?? process.cwd(); - const sandboxRoot = options.sandboxRoot; + const sandbox = options.sandbox; return { name: "apply_patch", @@ -98,7 +103,7 @@ export function createApplyPatchTool( const result = await applyPatch(input, { cwd, - sandboxRoot, + sandbox, signal, }); @@ -129,6 +134,7 @@ export async function applyPatch( modified: new Set(), deleted: new Set(), }; + const fileOps = resolvePatchFileOps(options); for (const hunk of parsed.hunks) { if (options.signal?.aborted) { @@ -139,30 +145,32 @@ export async function applyPatch( if (hunk.kind === "add") { const target = await resolvePatchPath(hunk.path, options); - await ensureDir(target.resolved); - await fs.writeFile(target.resolved, hunk.contents, "utf8"); + await ensureDir(target.resolved, fileOps); + await fileOps.writeFile(target.resolved, hunk.contents); recordSummary(summary, seen, "added", target.display); continue; } if (hunk.kind === "delete") { const target = await resolvePatchPath(hunk.path, options); - await fs.rm(target.resolved); + await fileOps.remove(target.resolved); recordSummary(summary, seen, "deleted", target.display); continue; } const target = await resolvePatchPath(hunk.path, options); - const applied = await applyUpdateHunk(target.resolved, hunk.chunks); + const applied = await applyUpdateHunk(target.resolved, hunk.chunks, { + readFile: (path) => fileOps.readFile(path), + }); if (hunk.movePath) { const moveTarget = await resolvePatchPath(hunk.movePath, options); - await ensureDir(moveTarget.resolved); - await fs.writeFile(moveTarget.resolved, applied, "utf8"); - await fs.rm(target.resolved); + await ensureDir(moveTarget.resolved, fileOps); + await fileOps.writeFile(moveTarget.resolved, applied); + await fileOps.remove(target.resolved); recordSummary(summary, seen, "modified", moveTarget.display); } else { - await fs.writeFile(target.resolved, applied, "utf8"); + await fileOps.writeFile(target.resolved, applied); recordSummary(summary, seen, "modified", target.display); } } @@ -196,25 +204,52 @@ function formatSummary(summary: ApplyPatchSummary): string { return lines.join("\n"); } -async function ensureDir(filePath: string) { +type PatchFileOps = { + readFile: (filePath: string) => Promise; + writeFile: (filePath: string, content: string) => Promise; + remove: (filePath: string) => Promise; + mkdirp: (dir: string) => Promise; +}; + +function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps { + if (options.sandbox) { + const { root, bridge } = options.sandbox; + return { + readFile: async (filePath) => { + const buf = await bridge.readFile({ filePath, cwd: root }); + return buf.toString("utf8"); + }, + writeFile: (filePath, content) => bridge.writeFile({ filePath, cwd: root, data: content }), + remove: (filePath) => bridge.remove({ filePath, cwd: root, force: false }), + mkdirp: (dir) => bridge.mkdirp({ filePath: dir, cwd: root }), + }; + } + return { + readFile: (filePath) => fs.readFile(filePath, "utf8"), + writeFile: (filePath, content) => fs.writeFile(filePath, content, "utf8"), + remove: (filePath) => fs.rm(filePath), + mkdirp: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}), + }; +} + +async function ensureDir(filePath: string, ops: PatchFileOps) { const parent = path.dirname(filePath); if (!parent || parent === ".") return; - await fs.mkdir(parent, { recursive: true }); + await ops.mkdirp(parent); } async function resolvePatchPath( filePath: string, options: ApplyPatchOptions, ): Promise<{ resolved: string; display: string }> { - if (options.sandboxRoot) { - const resolved = await assertSandboxPath({ + if (options.sandbox) { + const resolved = options.sandbox.bridge.resolvePath({ filePath, cwd: options.cwd, - root: options.sandboxRoot, }); return { - resolved: resolved.resolved, - display: resolved.relative || resolved.resolved, + resolved: resolved.hostPath, + display: resolved.relativePath || resolved.hostPath, }; } diff --git a/src/agents/moltbot-tools.ts b/src/agents/moltbot-tools.ts index c10a55190..6bc6efc88 100644 --- a/src/agents/moltbot-tools.ts +++ b/src/agents/moltbot-tools.ts @@ -18,6 +18,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { createTtsTool } from "./tools/tts-tool.js"; +import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; export function createMoltbotTools(options?: { sandboxBrowserBridgeUrl?: string; @@ -37,6 +38,7 @@ export function createMoltbotTools(options?: { agentGroupSpace?: string | null; agentDir?: string; sandboxRoot?: string; + sandboxFsBridge?: SandboxFsBridge; workspaceDir?: string; sandboxed?: boolean; config?: MoltbotConfig; @@ -58,7 +60,10 @@ export function createMoltbotTools(options?: { ? createImageTool({ config: options?.config, agentDir: options.agentDir, - sandboxRoot: options?.sandboxRoot, + sandbox: + options?.sandboxRoot && options?.sandboxFsBridge + ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge } + : undefined, modelHasVision: options?.modelHasVision, }) : null; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..1df282739 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -745,7 +745,10 @@ export async function runEmbeddedAttempt( historyMessages: activeSession.messages, maxBytes: MAX_IMAGE_BYTES, // Enforce sandbox path restrictions when sandbox is enabled - sandboxRoot: sandbox?.enabled ? sandbox.workspaceDir : undefined, + sandbox: + sandbox?.enabled && sandbox?.fsBridge + ? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge } + : undefined, }); // Inject history images into their original message positions. diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index 6bb7bef9b..aa3cb1036 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -1,15 +1,14 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ImageContent } from "@mariozechner/pi-ai"; -import { assertSandboxPath } from "../../sandbox-paths.js"; import { sanitizeImageBlocks } from "../../tool-images.js"; import { extractTextFromMessage } from "../../../tui/tui-formatters.js"; import { loadWebMedia } from "../../../web/media.js"; import { resolveUserPath } from "../../../utils.js"; import { log } from "../logger.js"; +import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; /** * Common image file extensions for detection. @@ -167,8 +166,7 @@ export async function loadImageFromRef( workspaceDir: string, options?: { maxBytes?: number; - /** If set, enforce that file paths are within this sandbox root */ - sandboxRoot?: string; + sandbox?: { root: string; bridge: SandboxFsBridge }; }, ): Promise { try { @@ -180,46 +178,34 @@ export async function loadImageFromRef( return null; } - // For file paths, resolve relative to the appropriate root: - // - When sandbox is enabled, resolve relative to sandboxRoot for security - // - Otherwise, resolve relative to workspaceDir - // Note: ref.resolved may already be absolute (e.g., after ~ expansion in detectImageReferences), - // in which case we skip relative resolution. - if (ref.type === "path" && !path.isAbsolute(targetPath)) { - const resolveRoot = options?.sandboxRoot ?? workspaceDir; - targetPath = path.resolve(resolveRoot, targetPath); - } - - // Enforce sandbox restrictions if sandboxRoot is set - if (ref.type === "path" && options?.sandboxRoot) { - try { - const validated = await assertSandboxPath({ - filePath: targetPath, - cwd: options.sandboxRoot, - root: options.sandboxRoot, - }); - targetPath = validated.resolved; - } catch (err) { - // Log the actual error for debugging (sandbox violation or other path error) - log.debug( - `Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, - ); - return null; - } - } - - // Check file exists for local paths + // Resolve paths relative to sandbox or workspace as needed if (ref.type === "path") { - try { - await fs.stat(targetPath); - } catch { - log.debug(`Native image: file not found: ${targetPath}`); - return null; + if (options?.sandbox) { + try { + const resolved = options.sandbox.bridge.resolvePath({ + filePath: targetPath, + cwd: options.sandbox.root, + }); + targetPath = resolved.hostPath; + } catch (err) { + log.debug( + `Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } else if (!path.isAbsolute(targetPath)) { + targetPath = path.resolve(workspaceDir, targetPath); } } // loadWebMedia handles local file paths (including file:// URLs) - const media = await loadWebMedia(targetPath, options?.maxBytes); + const media = options?.sandbox + ? await loadWebMedia(targetPath, { + maxBytes: options.maxBytes, + readFile: (filePath) => + options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }), + }) + : await loadWebMedia(targetPath, options?.maxBytes); if (media.kind !== "image") { log.debug(`Native image: not an image file: ${targetPath} (got ${media.kind})`); @@ -320,8 +306,7 @@ export async function detectAndLoadPromptImages(params: { existingImages?: ImageContent[]; historyMessages?: unknown[]; maxBytes?: number; - /** If set, enforce that file paths are within this sandbox root */ - sandboxRoot?: string; + sandbox?: { root: string; bridge: SandboxFsBridge }; }): Promise<{ /** Images for the current prompt (existingImages + detected in current prompt) */ images: ImageContent[]; @@ -382,7 +367,7 @@ export async function detectAndLoadPromptImages(params: { for (const ref of allRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, - sandboxRoot: params.sandboxRoot, + sandbox: params.sandbox, }); if (image) { if (ref.messageIndex !== undefined) { diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index b66e50125..0153a808d 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -5,6 +5,7 @@ import sharp from "sharp"; import { describe, expect, it } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import { createMoltbotCodingTools } from "./pi-tools.js"; +import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; const defaultTools = createMoltbotCodingTools(); @@ -72,14 +73,16 @@ describe("createMoltbotCodingTools", () => { } }); it("filters tools by sandbox policy", () => { + const sandboxDir = path.join(os.tmpdir(), "moltbot-sandbox"); const sandbox = { enabled: true, sessionKey: "sandbox:test", - workspaceDir: path.join(os.tmpdir(), "moltbot-sandbox"), + workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"), workspaceAccess: "none", containerName: "moltbot-sbx-test", containerWorkdir: "/workspace", + fsBridge: createHostSandboxFsBridge(sandboxDir), docker: { image: "moltbot-sandbox:bookworm-slim", containerPrefix: "moltbot-sbx-", @@ -103,14 +106,16 @@ describe("createMoltbotCodingTools", () => { expect(tools.some((tool) => tool.name === "browser")).toBe(false); }); it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { + const sandboxDir = path.join(os.tmpdir(), "moltbot-sandbox"); const sandbox = { enabled: true, sessionKey: "sandbox:test", - workspaceDir: path.join(os.tmpdir(), "moltbot-sandbox"), + workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"), workspaceAccess: "ro", containerName: "moltbot-sbx-test", containerWorkdir: "/workspace", + fsBridge: createHostSandboxFsBridge(sandboxDir), docker: { image: "moltbot-sandbox:bookworm-slim", containerPrefix: "moltbot-sbx-", diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 852d27a22..7f5b21ab9 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -7,6 +7,7 @@ import "./test-helpers/fast-coding-tools.js"; import { createMoltbotTools } from "./moltbot-tools.js"; import { __testing, createMoltbotCodingTools } from "./pi-tools.js"; import { createSandboxedReadTool } from "./pi-tools.read.js"; +import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { createBrowserTool } from "./tools/browser-tool.js"; const defaultTools = createMoltbotCodingTools(); @@ -453,7 +454,10 @@ describe("createMoltbotCodingTools", () => { const outsidePath = path.join(os.tmpdir(), "moltbot-outside.txt"); await fs.writeFile(outsidePath, "outside", "utf8"); try { - const readTool = createSandboxedReadTool(tmpDir); + const readTool = createSandboxedReadTool({ + root: tmpDir, + bridge: createHostSandboxFsBridge(tmpDir), + }); await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow( /sandbox root/i, ); diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index cdcab3939..71ae29821 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -5,6 +5,7 @@ import { detectMime } from "../media/mime.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import { sanitizeToolResultImages } from "./tool-images.js"; +import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper // to normalize payloads and sanitize oversized images before they hit providers. @@ -247,19 +248,36 @@ function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { }; } -export function createSandboxedReadTool(root: string) { - const base = createReadTool(root) as unknown as AnyAgentTool; - return wrapSandboxPathGuard(createMoltbotReadTool(base), root); +type SandboxToolParams = { + root: string; + bridge: SandboxFsBridge; +}; + +export function createSandboxedReadTool(params: SandboxToolParams) { + const base = createReadTool(params.root, { + operations: createSandboxReadOperations(params), + }) as unknown as AnyAgentTool; + return wrapSandboxPathGuard(createMoltbotReadTool(base), params.root); } -export function createSandboxedWriteTool(root: string) { - const base = createWriteTool(root) as unknown as AnyAgentTool; - return wrapSandboxPathGuard(wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write), root); +export function createSandboxedWriteTool(params: SandboxToolParams) { + const base = createWriteTool(params.root, { + operations: createSandboxWriteOperations(params), + }) as unknown as AnyAgentTool; + return wrapSandboxPathGuard( + wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write), + params.root, + ); } -export function createSandboxedEditTool(root: string) { - const base = createEditTool(root) as unknown as AnyAgentTool; - return wrapSandboxPathGuard(wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit), root); +export function createSandboxedEditTool(params: SandboxToolParams) { + const base = createEditTool(params.root, { + operations: createSandboxEditOperations(params), + }) as unknown as AnyAgentTool; + return wrapSandboxPathGuard( + wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit), + params.root, + ); } export function createMoltbotReadTool(base: AnyAgentTool): AnyAgentTool { @@ -283,3 +301,53 @@ export function createMoltbotReadTool(base: AnyAgentTool): AnyAgentTool { }, }; } + +function createSandboxReadOperations(params: SandboxToolParams) { + return { + readFile: (absolutePath: string) => + params.bridge.readFile({ filePath: absolutePath, cwd: params.root }), + access: async (absolutePath: string) => { + const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root }); + if (!stat) { + throw createFsAccessError("ENOENT", absolutePath); + } + }, + detectImageMimeType: async (absolutePath: string) => { + const buffer = await params.bridge.readFile({ filePath: absolutePath, cwd: params.root }); + const mime = await detectMime({ buffer, filePath: absolutePath }); + return mime && mime.startsWith("image/") ? mime : undefined; + }, + } as const; +} + +function createSandboxWriteOperations(params: SandboxToolParams) { + return { + mkdir: async (dir: string) => { + await params.bridge.mkdirp({ filePath: dir, cwd: params.root }); + }, + writeFile: async (absolutePath: string, content: string) => { + await params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content }); + }, + } as const; +} + +function createSandboxEditOperations(params: SandboxToolParams) { + return { + readFile: (absolutePath: string) => + params.bridge.readFile({ filePath: absolutePath, cwd: params.root }), + writeFile: (absolutePath: string, content: string) => + params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content }), + access: async (absolutePath: string) => { + const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root }); + if (!stat) { + throw createFsAccessError("ENOENT", absolutePath); + } + }, + } as const; +} + +function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException { + const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException; + error.code = code; + return error; +} diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index d763393a4..084405d2c 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -214,6 +214,7 @@ export function createMoltbotCodingTools(options?: { ]); const execConfig = resolveExecConfig(options?.config); const sandboxRoot = sandbox?.workspaceDir; + const sandboxFsBridge = sandbox?.fsBridge; const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const workspaceRoot = options?.workspaceDir ?? process.cwd(); const applyPatchConfig = options?.config?.tools?.exec?.applyPatch; @@ -226,10 +227,19 @@ export function createMoltbotCodingTools(options?: { allowModels: applyPatchConfig?.allowModels, }); + if (sandboxRoot && !sandboxFsBridge) { + throw new Error("Sandbox filesystem bridge is unavailable."); + } + const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { if (tool.name === readTool.name) { if (sandboxRoot) { - return [createSandboxedReadTool(sandboxRoot)]; + return [ + createSandboxedReadTool({ + root: sandboxRoot, + bridge: sandboxFsBridge!, + }), + ]; } const freshReadTool = createReadTool(workspaceRoot); return [createMoltbotReadTool(freshReadTool)]; @@ -287,13 +297,19 @@ export function createMoltbotCodingTools(options?: { ? null : createApplyPatchTool({ cwd: sandboxRoot ?? workspaceRoot, - sandboxRoot: sandboxRoot && allowWorkspaceWrites ? sandboxRoot : undefined, + sandbox: + sandboxRoot && allowWorkspaceWrites + ? { root: sandboxRoot, bridge: sandboxFsBridge! } + : undefined, }); const tools: AnyAgentTool[] = [ ...base, ...(sandboxRoot ? allowWorkspaceWrites - ? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)] + ? [ + createSandboxedEditTool({ root: sandboxRoot, bridge: sandboxFsBridge! }), + createSandboxedWriteTool({ root: sandboxRoot, bridge: sandboxFsBridge! }), + ] : [] : []), ...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []), @@ -314,6 +330,7 @@ export function createMoltbotCodingTools(options?: { agentGroupSpace: options?.groupSpace ?? null, agentDir: options?.agentDir, sandboxRoot, + sandboxFsBridge, workspaceDir: options?.workspaceDir, sandboxed: !!sandbox, config: options?.config, diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 46612351d..efb88b290 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { createMoltbotCodingTools } from "./pi-tools.js"; +import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; async function withTempDir(prefix: string, fn: (dir: string) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -158,6 +159,7 @@ describe("sandboxed workspace paths", () => { workspaceAccess: "rw", containerName: "moltbot-sbx-test", containerWorkdir: "/workspace", + fsBridge: createHostSandboxFsBridge(sandboxDir), docker: { image: "moltbot-sandbox:bookworm-slim", containerPrefix: "moltbot-sbx-", diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 9c9c180e6..f784ecb53 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js"; import { ensureSandboxWorkspace } from "./workspace.js"; +import { createSandboxFsBridge } from "./fs-bridge.js"; export async function resolveSandboxContext(params: { config?: MoltbotConfig; @@ -80,7 +81,7 @@ export async function resolveSandboxContext(params: { evaluateEnabled, }); - return { + const sandboxContext: SandboxContext = { enabled: true, sessionKey: rawSessionKey, workspaceDir, @@ -93,6 +94,10 @@ export async function resolveSandboxContext(params: { browserAllowHostControl: cfg.browser.allowHostControl, browser: browser ?? undefined, }; + + sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext }); + + return sandboxContext; } export async function ensureSandboxWorkspaceForSession(params: { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 21e8c67b5..2ab1a2bea 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,5 +1,103 @@ import { spawn } from "node:child_process"; +type ExecDockerRawOptions = { + allowFailure?: boolean; + input?: Buffer | string; + signal?: AbortSignal; +}; + +export type ExecDockerRawResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +type ExecDockerRawError = Error & { + code: number; + stdout: Buffer; + stderr: Buffer; +}; + +function createAbortError(): Error { + const err = new Error("Aborted"); + err.name = "AbortError"; + return err; +} + +export function execDockerRaw( + args: string[], + opts?: ExecDockerRawOptions, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("docker", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let aborted = false; + + const signal = opts?.signal; + const handleAbort = () => { + if (aborted) return; + aborted = true; + child.kill("SIGTERM"); + }; + if (signal) { + if (signal.aborted) { + handleAbort(); + } else { + signal.addEventListener("abort", handleAbort); + } + } + + child.stdout?.on("data", (chunk) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + child.on("error", (error) => { + if (signal) signal.removeEventListener("abort", handleAbort); + reject(error); + }); + + child.on("close", (code) => { + if (signal) signal.removeEventListener("abort", handleAbort); + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + if (aborted || signal?.aborted) { + reject(createAbortError()); + return; + } + const exitCode = code ?? 0; + if (exitCode !== 0 && !opts?.allowFailure) { + const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; + const error: ExecDockerRawError = Object.assign( + new Error(message || `docker ${args.join(" ")} failed`), + { + code: exitCode, + stdout, + stderr, + }, + ); + reject(error); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + const stdin = child.stdin; + if (stdin) { + if (opts?.input !== undefined) { + stdin.end(opts.input); + } else { + stdin.end(); + } + } + }); +} + import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; @@ -10,28 +108,13 @@ import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; -export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { - return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { - const child = spawn("docker", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("close", (code) => { - const exitCode = code ?? 0; - if (exitCode !== 0 && !opts?.allowFailure) { - reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - }); +export async function execDocker(args: string[], opts?: { allowFailure?: boolean }) { + const result = await execDockerRaw(args, opts); + return { + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + code: result.code, + }; } export async function readDockerPort(containerName: string, port: number) { diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts new file mode 100644 index 000000000..eea195546 --- /dev/null +++ b/src/agents/sandbox/fs-bridge.ts @@ -0,0 +1,247 @@ +import path from "node:path"; + +import { resolveSandboxPath } from "../sandbox-paths.js"; +import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js"; +import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; + +type RunCommandOptions = { + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; +}; + +export type SandboxResolvedPath = { + hostPath: string; + relativePath: string; + containerPath: string; +}; + +export type SandboxFsStat = { + type: "file" | "directory" | "other"; + size: number; + mtimeMs: number; +}; + +export type SandboxFsBridge = { + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath; + readFile(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise; + writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise; + mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise; + remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise; + rename(params: { from: string; to: string; cwd?: string; signal?: AbortSignal }): Promise; + stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise; +}; + +export function createSandboxFsBridge(params: { sandbox: SandboxContext }): SandboxFsBridge { + return new SandboxFsBridgeImpl(params.sandbox); +} + +class SandboxFsBridgeImpl implements SandboxFsBridge { + private readonly sandbox: SandboxContext; + + constructor(sandbox: SandboxContext) { + this.sandbox = sandbox; + } + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + return resolveSandboxFsPath({ + sandbox: this.sandbox, + filePath: params.filePath, + cwd: params.cwd, + }); + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolvePath(params); + const result = await this.runCommand('set -euo pipefail; cat -- "$1"', { + args: [target.containerPath], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + this.ensureWriteAccess("write files"); + const target = this.resolvePath(params); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + const script = + params.mkdir === false + ? 'set -euo pipefail; cat >"$1"' + : 'set -euo pipefail; dir=$(dirname -- "$1"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; cat >"$1"'; + await this.runCommand(script, { + args: [target.containerPath], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + this.ensureWriteAccess("create directories"); + const target = this.resolvePath(params); + await this.runCommand('set -euo pipefail; mkdir -p -- "$1"', { + args: [target.containerPath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + this.ensureWriteAccess("remove files"); + const target = this.resolvePath(params); + const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter( + Boolean, + ); + const rmCommand = flags.length > 0 ? `rm ${flags.join(" ")}` : "rm"; + await this.runCommand(`set -euo pipefail; ${rmCommand} -- "$1"`, { + args: [target.containerPath], + signal: params.signal, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + this.ensureWriteAccess("rename files"); + const from = this.resolvePath({ filePath: params.from, cwd: params.cwd }); + const to = this.resolvePath({ filePath: params.to, cwd: params.cwd }); + await this.runCommand( + 'set -euo pipefail; dir=$(dirname -- "$2"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; mv -- "$1" "$2"', + { + args: [from.containerPath, to.containerPath], + signal: params.signal, + }, + ); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolvePath(params); + const result = await this.runCommand('set -euo pipefail; stat -c "%F|%s|%Y" -- "$1"', { + args: [target.containerPath], + signal: params.signal, + allowFailure: true, + }); + if (result.code !== 0) { + return null; + } + const text = result.stdout.toString("utf8").trim(); + const [typeRaw, sizeRaw, mtimeRaw] = text.split("|"); + const size = Number.parseInt(sizeRaw ?? "0", 10); + const mtime = Number.parseInt(mtimeRaw ?? "0", 10) * 1000; + return { + type: coerceStatType(typeRaw), + size: Number.isFinite(size) ? size : 0, + mtimeMs: Number.isFinite(mtime) ? mtime : 0, + }; + } + + private async runCommand( + script: string, + options: RunCommandOptions = {}, + ): Promise { + const dockerArgs = [ + "exec", + "-i", + this.sandbox.containerName, + "sh", + "-c", + script, + "moltbot-sandbox-fs", + ]; + if (options.args?.length) { + dockerArgs.push(...options.args); + } + return execDockerRaw(dockerArgs, { + input: options.stdin, + allowFailure: options.allowFailure, + signal: options.signal, + }); + } + + private ensureWriteAccess(action: string) { + if (!allowsWrites(this.sandbox.workspaceAccess)) { + throw new Error( + `Sandbox workspace (${this.sandbox.workspaceAccess}) does not allow ${action}.`, + ); + } + } +} + +function allowsWrites(access: SandboxWorkspaceAccess): boolean { + return access === "rw"; +} + +function resolveSandboxFsPath(params: { + sandbox: SandboxContext; + filePath: string; + cwd?: string; +}): SandboxResolvedPath { + const root = params.sandbox.workspaceDir; + const cwd = params.cwd ?? root; + const { resolved, relative } = resolveSandboxPath({ + filePath: params.filePath, + cwd, + root, + }); + const normalizedRelative = relative + ? relative.split(path.sep).filter(Boolean).join(path.posix.sep) + : ""; + const containerPath = normalizedRelative + ? path.posix.join(params.sandbox.containerWorkdir, normalizedRelative) + : params.sandbox.containerWorkdir; + return { + hostPath: resolved, + relativePath: normalizedRelative, + containerPath, + }; +} + +function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { + if (!typeRaw) return "other"; + const normalized = typeRaw.trim().toLowerCase(); + if (normalized.includes("directory")) return "directory"; + if (normalized.includes("file")) return "file"; + return "other"; +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index f27dfd715..72d08fba3 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SandboxFsBridge } from "./fs-bridge.js"; import type { SandboxDockerConfig } from "./types.docker.js"; export type { SandboxDockerConfig } from "./types.docker.js"; @@ -77,6 +78,7 @@ export type SandboxContext = { tools: SandboxToolPolicy; browserAllowHostControl: boolean; browser?: SandboxBrowserContext; + fsBridge?: SandboxFsBridge; }; export type SandboxWorkspaceInfo = { diff --git a/src/agents/test-helpers/host-sandbox-fs-bridge.ts b/src/agents/test-helpers/host-sandbox-fs-bridge.ts new file mode 100644 index 000000000..57827e689 --- /dev/null +++ b/src/agents/test-helpers/host-sandbox-fs-bridge.ts @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveSandboxPath } from "../sandbox-paths.js"; +import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "../sandbox/fs-bridge.js"; + +export function createHostSandboxFsBridge(rootDir: string): SandboxFsBridge { + const root = path.resolve(rootDir); + + const resolvePath = (filePath: string, cwd?: string): SandboxResolvedPath => { + const resolved = resolveSandboxPath({ + filePath, + cwd: cwd ?? root, + root, + }); + const relativePath = resolved.relative + ? resolved.relative.split(path.sep).filter(Boolean).join(path.posix.sep) + : ""; + const containerPath = relativePath ? path.posix.join("/workspace", relativePath) : "/workspace"; + return { + hostPath: resolved.resolved, + relativePath, + containerPath, + }; + }; + + return { + resolvePath: ({ filePath, cwd }) => resolvePath(filePath, cwd), + readFile: async ({ filePath, cwd }) => { + const target = resolvePath(filePath, cwd); + return fs.readFile(target.hostPath); + }, + writeFile: async ({ filePath, cwd, data, mkdir = true }) => { + const target = resolvePath(filePath, cwd); + if (mkdir) { + await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); + } + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + await fs.writeFile(target.hostPath, buffer); + }, + mkdirp: async ({ filePath, cwd }) => { + const target = resolvePath(filePath, cwd); + await fs.mkdir(target.hostPath, { recursive: true }); + }, + remove: async ({ filePath, cwd, recursive, force }) => { + const target = resolvePath(filePath, cwd); + await fs.rm(target.hostPath, { + recursive: recursive ?? false, + force: force ?? false, + }); + }, + rename: async ({ from, to, cwd }) => { + const source = resolvePath(from, cwd); + const target = resolvePath(to, cwd); + await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); + await fs.rename(source.hostPath, target.hostPath); + }, + stat: async ({ filePath, cwd }) => { + try { + const target = resolvePath(filePath, cwd); + const stats = await fs.stat(target.hostPath); + return { + type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", + size: stats.size, + mtimeMs: stats.mtimeMs, + } satisfies SandboxFsStat; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }, + }; +} diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 2b4e1aea1..89aa0a796 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MoltbotConfig } from "../../config/config.js"; import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js"; +import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js"; async function writeAuthProfiles(agentDir: string, profiles: unknown) { await fs.mkdir(agentDir, { recursive: true }); @@ -141,12 +142,13 @@ describe("image tool implicit imageModel config", () => { await fs.mkdir(agentDir, { recursive: true }); await fs.mkdir(sandboxRoot, { recursive: true }); await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8"); + const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; vi.stubEnv("OPENAI_API_KEY", "openai-test"); const cfg: MoltbotConfig = { agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } }, }; - const tool = createImageTool({ config: cfg, agentDir, sandboxRoot }); + const tool = createImageTool({ config: cfg, agentDir, sandbox }); expect(tool).not.toBeNull(); if (!tool) throw new Error("expected image tool"); @@ -196,7 +198,8 @@ describe("image tool implicit imageModel config", () => { }, }, }; - const tool = createImageTool({ config: cfg, agentDir, sandboxRoot }); + const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; + const tool = createImageTool({ config: cfg, agentDir, sandbox }); expect(tool).not.toBeNull(); if (!tool) throw new Error("expected image tool"); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index ff3ca1b2c..3da9ddbdd 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { @@ -21,8 +20,8 @@ import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-aut import { runWithImageModelFallback } from "../model-fallback.js"; import { resolveConfiguredModelRef } from "../model-selection.js"; import { ensureMoltbotModelsJson } from "../models-config.js"; -import { assertSandboxPath } from "../sandbox-paths.js"; import type { AnyAgentTool } from "./common.js"; +import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; import { coerceImageAssistantText, coerceImageModelConfig, @@ -174,34 +173,40 @@ function buildImageContext(prompt: string, base64: string, mimeType: string): Co }; } +type ImageSandboxConfig = { + root: string; + bridge: SandboxFsBridge; +}; + async function resolveSandboxedImagePath(params: { - sandboxRoot: string; + sandbox: ImageSandboxConfig; imagePath: string; }): Promise<{ resolved: string; rewrittenFrom?: string }> { const normalize = (p: string) => (p.startsWith("file://") ? p.slice("file://".length) : p); const filePath = normalize(params.imagePath); try { - const out = await assertSandboxPath({ + const resolved = params.sandbox.bridge.resolvePath({ filePath, - cwd: params.sandboxRoot, - root: params.sandboxRoot, + cwd: params.sandbox.root, }); - return { resolved: out.resolved }; + return { resolved: resolved.hostPath }; } catch (err) { const name = path.basename(filePath); const candidateRel = path.join("media", "inbound", name); - const candidateAbs = path.join(params.sandboxRoot, candidateRel); try { - await fs.stat(candidateAbs); + const stat = await params.sandbox.bridge.stat({ + filePath: candidateRel, + cwd: params.sandbox.root, + }); + if (!stat) throw err; } catch { throw err; } - const out = await assertSandboxPath({ + const out = params.sandbox.bridge.resolvePath({ filePath: candidateRel, - cwd: params.sandboxRoot, - root: params.sandboxRoot, + cwd: params.sandbox.root, }); - return { resolved: out.resolved, rewrittenFrom: filePath }; + return { resolved: out.hostPath, rewrittenFrom: filePath }; } } @@ -295,7 +300,7 @@ async function runImagePrompt(params: { export function createImageTool(options?: { config?: MoltbotConfig; agentDir?: string; - sandboxRoot?: string; + sandbox?: ImageSandboxConfig; /** If true, the model has native vision capability and images in the prompt are auto-injected */ modelHasVision?: boolean; }): AnyAgentTool | null { @@ -370,22 +375,25 @@ export function createImageTool(options?: { const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined; const maxBytes = pickMaxBytes(options?.config, maxBytesMb); - const sandboxRoot = options?.sandboxRoot?.trim(); + const sandboxConfig = + options?.sandbox && options?.sandbox.root.trim() + ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge } + : null; const isUrl = isHttpUrl; - if (sandboxRoot && isUrl) { + if (sandboxConfig && isUrl) { throw new Error("Sandboxed image tool does not allow remote URLs."); } const resolvedImage = (() => { - if (sandboxRoot) return imageRaw; + if (sandboxConfig) return imageRaw; if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw); return imageRaw; })(); const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl ? { resolved: "" } - : sandboxRoot + : sandboxConfig ? await resolveSandboxedImagePath({ - sandboxRoot, + sandbox: sandboxConfig, imagePath: resolvedImage, }) : { @@ -397,7 +405,13 @@ export function createImageTool(options?: { const media = isDataUrl ? decodeDataUrl(resolvedImage) - : await loadWebMedia(resolvedPath ?? resolvedImage, maxBytes); + : sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + readFile: (filePath) => + sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, maxBytes); if (media.kind !== "image") { throw new Error(`Unsupported media type: ${media.kind}`); } diff --git a/src/web/media.ts b/src/web/media.ts index 72f6d34de..081b108f8 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -24,6 +24,7 @@ export type WebMediaResult = { type WebMediaOptions = { maxBytes?: number; optimizeImages?: boolean; + readFile?: (filePath: string) => Promise; }; const HEIC_MIME_RE = /^image\/hei[cf]$/i; @@ -111,7 +112,7 @@ async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, ): Promise { - const { maxBytes, optimizeImages = true } = options; + const { maxBytes, optimizeImages = true, readFile: readFileOverride } = options; // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.) if (mediaUrl.startsWith("file://")) { try { @@ -201,7 +202,7 @@ async function loadWebMediaInternal( } // Local path - const data = await fs.readFile(mediaUrl); + const data = readFileOverride ? await readFileOverride(mediaUrl) : await fs.readFile(mediaUrl); const mime = await detectMime({ buffer: data, filePath: mediaUrl }); const kind = mediaKindFromMime(mime); let fileName = path.basename(mediaUrl) || undefined; @@ -217,19 +218,34 @@ async function loadWebMediaInternal( }); } -export async function loadWebMedia(mediaUrl: string, maxBytes?: number): Promise { +export async function loadWebMedia( + mediaUrl: string, + options?: number | WebMediaOptions, +): Promise { + if (typeof options === "number" || options === undefined) { + return await loadWebMediaInternal(mediaUrl, { + maxBytes: options, + optimizeImages: true, + }); + } return await loadWebMediaInternal(mediaUrl, { - maxBytes, - optimizeImages: true, + ...options, + optimizeImages: options.optimizeImages ?? true, }); } export async function loadWebMediaRaw( mediaUrl: string, - maxBytes?: number, + options?: number | WebMediaOptions, ): Promise { + if (typeof options === "number" || options === undefined) { + return await loadWebMediaInternal(mediaUrl, { + maxBytes: options, + optimizeImages: false, + }); + } return await loadWebMediaInternal(mediaUrl, { - maxBytes, + ...options, optimizeImages: false, }); }