feat(sandbox): allow image tool

This commit is contained in:
Peter Steinberger
2026-01-12 17:56:48 +00:00
parent 44e1f271c8
commit d24de1ec3b
6 changed files with 68 additions and 3 deletions

View File

@@ -104,4 +104,31 @@ describe("image tool implicit imageModel config", () => {
primary: "openai/gpt-5-mini",
});
});
it("sandboxes image paths like the read tool", async () => {
const stateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-sandbox-"),
);
const agentDir = path.join(stateDir, "agent");
const sandboxRoot = path.join(stateDir, "sandbox");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(sandboxRoot, { recursive: true });
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
vi.stubEnv("OPENAI_API_KEY", "openai-test");
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
};
const tool = createImageTool({ config: cfg, agentDir, sandboxRoot });
expect(tool).not.toBeNull();
if (!tool) throw new Error("expected image tool");
await expect(
tool.execute("t1", { image: "https://example.com/a.png" }),
).rejects.toThrow(/Sandboxed image tool does not allow remote URLs/i);
await expect(
tool.execute("t2", { image: "../escape.png" }),
).rejects.toThrow(/escapes sandbox root/i);
});
});

View File

@@ -24,6 +24,7 @@ import { runWithImageModelFallback } from "../model-fallback.js";
import { parseModelRef } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import { extractAssistantText } from "../pi-embedded-utils.js";
import { assertSandboxPath } from "../sandbox-paths.js";
import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
@@ -296,6 +297,7 @@ async function runImagePrompt(params: {
export function createImageTool(options?: {
config?: ClawdbotConfig;
agentDir?: string;
sandboxRoot?: string;
}): AnyAgentTool | null {
const agentDir = options?.agentDir;
if (!agentDir?.trim()) {
@@ -337,9 +339,23 @@ export function createImageTool(options?: {
typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
const resolvedImage = imageRaw.startsWith("~")
? resolveUserPath(imageRaw)
: imageRaw;
const sandboxRoot = options?.sandboxRoot?.trim();
const isUrl = /^https?:\/\//i.test(imageRaw);
if (sandboxRoot && isUrl) {
throw new Error("Sandboxed image tool does not allow remote URLs.");
}
const resolvedImage = sandboxRoot
? (
await assertSandboxPath({
filePath: imageRaw,
cwd: sandboxRoot,
root: sandboxRoot,
})
).resolved
: imageRaw.startsWith("~")
? resolveUserPath(imageRaw)
: imageRaw;
const media = await loadWebMedia(resolvedImage, maxBytes);
if (media.kind !== "image") {
throw new Error(`Unsupported media type: ${media.kind}`);