Merge fae8175f82 into 4583f88626
This commit is contained in:
commit
8e36ee0224
@ -7,11 +7,17 @@ type UpdateFileChunk = {
|
|||||||
isEndOfFile: boolean;
|
isEndOfFile: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function defaultReadFile(filePath: string): Promise<string> {
|
||||||
|
return fs.readFile(filePath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyUpdateHunk(
|
export async function applyUpdateHunk(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
chunks: UpdateFileChunk[],
|
chunks: UpdateFileChunk[],
|
||||||
|
options?: { readFile?: (filePath: string) => Promise<string> },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
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}`);
|
throw new Error(`Failed to read file to update ${filePath}: ${err}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { applyUpdateHunk } from "./apply-patch-update.js";
|
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 BEGIN_PATCH_MARKER = "*** Begin Patch";
|
||||||
const END_PATCH_MARKER = "*** End Patch";
|
const END_PATCH_MARKER = "*** End Patch";
|
||||||
@ -59,9 +59,14 @@ export type ApplyPatchToolDetails = {
|
|||||||
summary: ApplyPatchSummary;
|
summary: ApplyPatchSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SandboxApplyPatchConfig = {
|
||||||
|
root: string;
|
||||||
|
bridge: SandboxFsBridge;
|
||||||
|
};
|
||||||
|
|
||||||
type ApplyPatchOptions = {
|
type ApplyPatchOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
sandboxRoot?: string;
|
sandbox?: SandboxApplyPatchConfig;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,11 +77,11 @@ const applyPatchSchema = Type.Object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function createApplyPatchTool(
|
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.
|
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
|
||||||
): AgentTool<any, ApplyPatchToolDetails> {
|
): AgentTool<any, ApplyPatchToolDetails> {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const sandboxRoot = options.sandboxRoot;
|
const sandbox = options.sandbox;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
@ -98,7 +103,7 @@ export function createApplyPatchTool(
|
|||||||
|
|
||||||
const result = await applyPatch(input, {
|
const result = await applyPatch(input, {
|
||||||
cwd,
|
cwd,
|
||||||
sandboxRoot,
|
sandbox,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -129,6 +134,7 @@ export async function applyPatch(
|
|||||||
modified: new Set<string>(),
|
modified: new Set<string>(),
|
||||||
deleted: new Set<string>(),
|
deleted: new Set<string>(),
|
||||||
};
|
};
|
||||||
|
const fileOps = resolvePatchFileOps(options);
|
||||||
|
|
||||||
for (const hunk of parsed.hunks) {
|
for (const hunk of parsed.hunks) {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
@ -139,30 +145,32 @@ export async function applyPatch(
|
|||||||
|
|
||||||
if (hunk.kind === "add") {
|
if (hunk.kind === "add") {
|
||||||
const target = await resolvePatchPath(hunk.path, options);
|
const target = await resolvePatchPath(hunk.path, options);
|
||||||
await ensureDir(target.resolved);
|
await ensureDir(target.resolved, fileOps);
|
||||||
await fs.writeFile(target.resolved, hunk.contents, "utf8");
|
await fileOps.writeFile(target.resolved, hunk.contents);
|
||||||
recordSummary(summary, seen, "added", target.display);
|
recordSummary(summary, seen, "added", target.display);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hunk.kind === "delete") {
|
if (hunk.kind === "delete") {
|
||||||
const target = await resolvePatchPath(hunk.path, options);
|
const target = await resolvePatchPath(hunk.path, options);
|
||||||
await fs.rm(target.resolved);
|
await fileOps.remove(target.resolved);
|
||||||
recordSummary(summary, seen, "deleted", target.display);
|
recordSummary(summary, seen, "deleted", target.display);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await resolvePatchPath(hunk.path, options);
|
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) {
|
if (hunk.movePath) {
|
||||||
const moveTarget = await resolvePatchPath(hunk.movePath, options);
|
const moveTarget = await resolvePatchPath(hunk.movePath, options);
|
||||||
await ensureDir(moveTarget.resolved);
|
await ensureDir(moveTarget.resolved, fileOps);
|
||||||
await fs.writeFile(moveTarget.resolved, applied, "utf8");
|
await fileOps.writeFile(moveTarget.resolved, applied);
|
||||||
await fs.rm(target.resolved);
|
await fileOps.remove(target.resolved);
|
||||||
recordSummary(summary, seen, "modified", moveTarget.display);
|
recordSummary(summary, seen, "modified", moveTarget.display);
|
||||||
} else {
|
} else {
|
||||||
await fs.writeFile(target.resolved, applied, "utf8");
|
await fileOps.writeFile(target.resolved, applied);
|
||||||
recordSummary(summary, seen, "modified", target.display);
|
recordSummary(summary, seen, "modified", target.display);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,25 +204,52 @@ function formatSummary(summary: ApplyPatchSummary): string {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDir(filePath: string) {
|
type PatchFileOps = {
|
||||||
|
readFile: (filePath: string) => Promise<string>;
|
||||||
|
writeFile: (filePath: string, content: string) => Promise<void>;
|
||||||
|
remove: (filePath: string) => Promise<void>;
|
||||||
|
mkdirp: (dir: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
const parent = path.dirname(filePath);
|
||||||
if (!parent || parent === ".") return;
|
if (!parent || parent === ".") return;
|
||||||
await fs.mkdir(parent, { recursive: true });
|
await ops.mkdirp(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePatchPath(
|
async function resolvePatchPath(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
options: ApplyPatchOptions,
|
options: ApplyPatchOptions,
|
||||||
): Promise<{ resolved: string; display: string }> {
|
): Promise<{ resolved: string; display: string }> {
|
||||||
if (options.sandboxRoot) {
|
if (options.sandbox) {
|
||||||
const resolved = await assertSandboxPath({
|
const resolved = options.sandbox.bridge.resolvePath({
|
||||||
filePath,
|
filePath,
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
root: options.sandboxRoot,
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
resolved: resolved.resolved,
|
resolved: resolved.hostPath,
|
||||||
display: resolved.relative || resolved.resolved,
|
display: resolved.relativePath || resolved.hostPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
|||||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||||
import { createTtsTool } from "./tools/tts-tool.js";
|
import { createTtsTool } from "./tools/tts-tool.js";
|
||||||
|
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||||
|
|
||||||
export function createMoltbotTools(options?: {
|
export function createMoltbotTools(options?: {
|
||||||
sandboxBrowserBridgeUrl?: string;
|
sandboxBrowserBridgeUrl?: string;
|
||||||
@ -37,6 +38,7 @@ export function createMoltbotTools(options?: {
|
|||||||
agentGroupSpace?: string | null;
|
agentGroupSpace?: string | null;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
sandboxRoot?: string;
|
sandboxRoot?: string;
|
||||||
|
sandboxFsBridge?: SandboxFsBridge;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
@ -58,7 +60,10 @@ export function createMoltbotTools(options?: {
|
|||||||
? createImageTool({
|
? createImageTool({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
agentDir: options.agentDir,
|
agentDir: options.agentDir,
|
||||||
sandboxRoot: options?.sandboxRoot,
|
sandbox:
|
||||||
|
options?.sandboxRoot && options?.sandboxFsBridge
|
||||||
|
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
|
||||||
|
: undefined,
|
||||||
modelHasVision: options?.modelHasVision,
|
modelHasVision: options?.modelHasVision,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@ -745,7 +745,10 @@ export async function runEmbeddedAttempt(
|
|||||||
historyMessages: activeSession.messages,
|
historyMessages: activeSession.messages,
|
||||||
maxBytes: MAX_IMAGE_BYTES,
|
maxBytes: MAX_IMAGE_BYTES,
|
||||||
// Enforce sandbox path restrictions when sandbox is enabled
|
// 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.
|
// Inject history images into their original message positions.
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
import { assertSandboxPath } from "../../sandbox-paths.js";
|
|
||||||
import { sanitizeImageBlocks } from "../../tool-images.js";
|
import { sanitizeImageBlocks } from "../../tool-images.js";
|
||||||
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
||||||
import { loadWebMedia } from "../../../web/media.js";
|
import { loadWebMedia } from "../../../web/media.js";
|
||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
import { log } from "../logger.js";
|
import { log } from "../logger.js";
|
||||||
|
import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common image file extensions for detection.
|
* Common image file extensions for detection.
|
||||||
@ -167,8 +166,7 @@ export async function loadImageFromRef(
|
|||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
options?: {
|
options?: {
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
/** If set, enforce that file paths are within this sandbox root */
|
sandbox?: { root: string; bridge: SandboxFsBridge };
|
||||||
sandboxRoot?: string;
|
|
||||||
},
|
},
|
||||||
): Promise<ImageContent | null> {
|
): Promise<ImageContent | null> {
|
||||||
try {
|
try {
|
||||||
@ -180,46 +178,34 @@ export async function loadImageFromRef(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For file paths, resolve relative to the appropriate root:
|
// Resolve paths relative to sandbox or workspace as needed
|
||||||
// - 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
|
|
||||||
if (ref.type === "path") {
|
if (ref.type === "path") {
|
||||||
try {
|
if (options?.sandbox) {
|
||||||
await fs.stat(targetPath);
|
try {
|
||||||
} catch {
|
const resolved = options.sandbox.bridge.resolvePath({
|
||||||
log.debug(`Native image: file not found: ${targetPath}`);
|
filePath: targetPath,
|
||||||
return null;
|
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)
|
// 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") {
|
if (media.kind !== "image") {
|
||||||
log.debug(`Native image: not an image file: ${targetPath} (got ${media.kind})`);
|
log.debug(`Native image: not an image file: ${targetPath} (got ${media.kind})`);
|
||||||
@ -320,8 +306,7 @@ export async function detectAndLoadPromptImages(params: {
|
|||||||
existingImages?: ImageContent[];
|
existingImages?: ImageContent[];
|
||||||
historyMessages?: unknown[];
|
historyMessages?: unknown[];
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
/** If set, enforce that file paths are within this sandbox root */
|
sandbox?: { root: string; bridge: SandboxFsBridge };
|
||||||
sandboxRoot?: string;
|
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
/** Images for the current prompt (existingImages + detected in current prompt) */
|
/** Images for the current prompt (existingImages + detected in current prompt) */
|
||||||
images: ImageContent[];
|
images: ImageContent[];
|
||||||
@ -382,7 +367,7 @@ export async function detectAndLoadPromptImages(params: {
|
|||||||
for (const ref of allRefs) {
|
for (const ref of allRefs) {
|
||||||
const image = await loadImageFromRef(ref, params.workspaceDir, {
|
const image = await loadImageFromRef(ref, params.workspaceDir, {
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
sandboxRoot: params.sandboxRoot,
|
sandbox: params.sandbox,
|
||||||
});
|
});
|
||||||
if (image) {
|
if (image) {
|
||||||
if (ref.messageIndex !== undefined) {
|
if (ref.messageIndex !== undefined) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import sharp from "sharp";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import { createMoltbotCodingTools } from "./pi-tools.js";
|
import { createMoltbotCodingTools } from "./pi-tools.js";
|
||||||
|
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||||
|
|
||||||
const defaultTools = createMoltbotCodingTools();
|
const defaultTools = createMoltbotCodingTools();
|
||||||
|
|
||||||
@ -72,14 +73,16 @@ describe("createMoltbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("filters tools by sandbox policy", () => {
|
it("filters tools by sandbox policy", () => {
|
||||||
|
const sandboxDir = path.join(os.tmpdir(), "moltbot-sandbox");
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sessionKey: "sandbox:test",
|
sessionKey: "sandbox:test",
|
||||||
workspaceDir: path.join(os.tmpdir(), "moltbot-sandbox"),
|
workspaceDir: sandboxDir,
|
||||||
agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"),
|
agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"),
|
||||||
workspaceAccess: "none",
|
workspaceAccess: "none",
|
||||||
containerName: "moltbot-sbx-test",
|
containerName: "moltbot-sbx-test",
|
||||||
containerWorkdir: "/workspace",
|
containerWorkdir: "/workspace",
|
||||||
|
fsBridge: createHostSandboxFsBridge(sandboxDir),
|
||||||
docker: {
|
docker: {
|
||||||
image: "moltbot-sandbox:bookworm-slim",
|
image: "moltbot-sandbox:bookworm-slim",
|
||||||
containerPrefix: "moltbot-sbx-",
|
containerPrefix: "moltbot-sbx-",
|
||||||
@ -103,14 +106,16 @@ describe("createMoltbotCodingTools", () => {
|
|||||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||||
});
|
});
|
||||||
it("hard-disables write/edit when sandbox workspaceAccess is ro", () => {
|
it("hard-disables write/edit when sandbox workspaceAccess is ro", () => {
|
||||||
|
const sandboxDir = path.join(os.tmpdir(), "moltbot-sandbox");
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sessionKey: "sandbox:test",
|
sessionKey: "sandbox:test",
|
||||||
workspaceDir: path.join(os.tmpdir(), "moltbot-sandbox"),
|
workspaceDir: sandboxDir,
|
||||||
agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"),
|
agentWorkspaceDir: path.join(os.tmpdir(), "moltbot-workspace"),
|
||||||
workspaceAccess: "ro",
|
workspaceAccess: "ro",
|
||||||
containerName: "moltbot-sbx-test",
|
containerName: "moltbot-sbx-test",
|
||||||
containerWorkdir: "/workspace",
|
containerWorkdir: "/workspace",
|
||||||
|
fsBridge: createHostSandboxFsBridge(sandboxDir),
|
||||||
docker: {
|
docker: {
|
||||||
image: "moltbot-sandbox:bookworm-slim",
|
image: "moltbot-sandbox:bookworm-slim",
|
||||||
containerPrefix: "moltbot-sbx-",
|
containerPrefix: "moltbot-sbx-",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import "./test-helpers/fast-coding-tools.js";
|
|||||||
import { createMoltbotTools } from "./moltbot-tools.js";
|
import { createMoltbotTools } from "./moltbot-tools.js";
|
||||||
import { __testing, createMoltbotCodingTools } from "./pi-tools.js";
|
import { __testing, createMoltbotCodingTools } from "./pi-tools.js";
|
||||||
import { createSandboxedReadTool } from "./pi-tools.read.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";
|
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||||
|
|
||||||
const defaultTools = createMoltbotCodingTools();
|
const defaultTools = createMoltbotCodingTools();
|
||||||
@ -453,7 +454,10 @@ describe("createMoltbotCodingTools", () => {
|
|||||||
const outsidePath = path.join(os.tmpdir(), "moltbot-outside.txt");
|
const outsidePath = path.join(os.tmpdir(), "moltbot-outside.txt");
|
||||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||||
try {
|
try {
|
||||||
const readTool = createSandboxedReadTool(tmpDir);
|
const readTool = createSandboxedReadTool({
|
||||||
|
root: tmpDir,
|
||||||
|
bridge: createHostSandboxFsBridge(tmpDir),
|
||||||
|
});
|
||||||
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
||||||
/sandbox root/i,
|
/sandbox root/i,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { detectMime } from "../media/mime.js";
|
|||||||
import type { AnyAgentTool } from "./pi-tools.types.js";
|
import type { AnyAgentTool } from "./pi-tools.types.js";
|
||||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||||
import { sanitizeToolResultImages } from "./tool-images.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
|
// 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.
|
// 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) {
|
type SandboxToolParams = {
|
||||||
const base = createReadTool(root) as unknown as AnyAgentTool;
|
root: string;
|
||||||
return wrapSandboxPathGuard(createMoltbotReadTool(base), root);
|
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) {
|
export function createSandboxedWriteTool(params: SandboxToolParams) {
|
||||||
const base = createWriteTool(root) as unknown as AnyAgentTool;
|
const base = createWriteTool(params.root, {
|
||||||
return wrapSandboxPathGuard(wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write), root);
|
operations: createSandboxWriteOperations(params),
|
||||||
|
}) as unknown as AnyAgentTool;
|
||||||
|
return wrapSandboxPathGuard(
|
||||||
|
wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write),
|
||||||
|
params.root,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSandboxedEditTool(root: string) {
|
export function createSandboxedEditTool(params: SandboxToolParams) {
|
||||||
const base = createEditTool(root) as unknown as AnyAgentTool;
|
const base = createEditTool(params.root, {
|
||||||
return wrapSandboxPathGuard(wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit), root);
|
operations: createSandboxEditOperations(params),
|
||||||
|
}) as unknown as AnyAgentTool;
|
||||||
|
return wrapSandboxPathGuard(
|
||||||
|
wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit),
|
||||||
|
params.root,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMoltbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -214,6 +214,7 @@ export function createMoltbotCodingTools(options?: {
|
|||||||
]);
|
]);
|
||||||
const execConfig = resolveExecConfig(options?.config);
|
const execConfig = resolveExecConfig(options?.config);
|
||||||
const sandboxRoot = sandbox?.workspaceDir;
|
const sandboxRoot = sandbox?.workspaceDir;
|
||||||
|
const sandboxFsBridge = sandbox?.fsBridge;
|
||||||
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
||||||
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
||||||
const applyPatchConfig = options?.config?.tools?.exec?.applyPatch;
|
const applyPatchConfig = options?.config?.tools?.exec?.applyPatch;
|
||||||
@ -226,10 +227,19 @@ export function createMoltbotCodingTools(options?: {
|
|||||||
allowModels: applyPatchConfig?.allowModels,
|
allowModels: applyPatchConfig?.allowModels,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sandboxRoot && !sandboxFsBridge) {
|
||||||
|
throw new Error("Sandbox filesystem bridge is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
|
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
|
||||||
if (tool.name === readTool.name) {
|
if (tool.name === readTool.name) {
|
||||||
if (sandboxRoot) {
|
if (sandboxRoot) {
|
||||||
return [createSandboxedReadTool(sandboxRoot)];
|
return [
|
||||||
|
createSandboxedReadTool({
|
||||||
|
root: sandboxRoot,
|
||||||
|
bridge: sandboxFsBridge!,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
const freshReadTool = createReadTool(workspaceRoot);
|
const freshReadTool = createReadTool(workspaceRoot);
|
||||||
return [createMoltbotReadTool(freshReadTool)];
|
return [createMoltbotReadTool(freshReadTool)];
|
||||||
@ -287,13 +297,19 @@ export function createMoltbotCodingTools(options?: {
|
|||||||
? null
|
? null
|
||||||
: createApplyPatchTool({
|
: createApplyPatchTool({
|
||||||
cwd: sandboxRoot ?? workspaceRoot,
|
cwd: sandboxRoot ?? workspaceRoot,
|
||||||
sandboxRoot: sandboxRoot && allowWorkspaceWrites ? sandboxRoot : undefined,
|
sandbox:
|
||||||
|
sandboxRoot && allowWorkspaceWrites
|
||||||
|
? { root: sandboxRoot, bridge: sandboxFsBridge! }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const tools: AnyAgentTool[] = [
|
const tools: AnyAgentTool[] = [
|
||||||
...base,
|
...base,
|
||||||
...(sandboxRoot
|
...(sandboxRoot
|
||||||
? allowWorkspaceWrites
|
? allowWorkspaceWrites
|
||||||
? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)]
|
? [
|
||||||
|
createSandboxedEditTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
|
||||||
|
createSandboxedWriteTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
|
||||||
|
]
|
||||||
: []
|
: []
|
||||||
: []),
|
: []),
|
||||||
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
|
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
|
||||||
@ -314,6 +330,7 @@ export function createMoltbotCodingTools(options?: {
|
|||||||
agentGroupSpace: options?.groupSpace ?? null,
|
agentGroupSpace: options?.groupSpace ?? null,
|
||||||
agentDir: options?.agentDir,
|
agentDir: options?.agentDir,
|
||||||
sandboxRoot,
|
sandboxRoot,
|
||||||
|
sandboxFsBridge,
|
||||||
workspaceDir: options?.workspaceDir,
|
workspaceDir: options?.workspaceDir,
|
||||||
sandboxed: !!sandbox,
|
sandboxed: !!sandbox,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createMoltbotCodingTools } from "./pi-tools.js";
|
import { createMoltbotCodingTools } from "./pi-tools.js";
|
||||||
|
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||||
|
|
||||||
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
|
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
@ -158,6 +159,7 @@ describe("sandboxed workspace paths", () => {
|
|||||||
workspaceAccess: "rw",
|
workspaceAccess: "rw",
|
||||||
containerName: "moltbot-sbx-test",
|
containerName: "moltbot-sbx-test",
|
||||||
containerWorkdir: "/workspace",
|
containerWorkdir: "/workspace",
|
||||||
|
fsBridge: createHostSandboxFsBridge(sandboxDir),
|
||||||
docker: {
|
docker: {
|
||||||
image: "moltbot-sandbox:bookworm-slim",
|
image: "moltbot-sandbox:bookworm-slim",
|
||||||
containerPrefix: "moltbot-sbx-",
|
containerPrefix: "moltbot-sbx-",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "./runtime-status.js";
|
|||||||
import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js";
|
import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js";
|
||||||
import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js";
|
import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js";
|
||||||
import { ensureSandboxWorkspace } from "./workspace.js";
|
import { ensureSandboxWorkspace } from "./workspace.js";
|
||||||
|
import { createSandboxFsBridge } from "./fs-bridge.js";
|
||||||
|
|
||||||
export async function resolveSandboxContext(params: {
|
export async function resolveSandboxContext(params: {
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
@ -80,7 +81,7 @@ export async function resolveSandboxContext(params: {
|
|||||||
evaluateEnabled,
|
evaluateEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const sandboxContext: SandboxContext = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sessionKey: rawSessionKey,
|
sessionKey: rawSessionKey,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
@ -93,6 +94,10 @@ export async function resolveSandboxContext(params: {
|
|||||||
browserAllowHostControl: cfg.browser.allowHostControl,
|
browserAllowHostControl: cfg.browser.allowHostControl,
|
||||||
browser: browser ?? undefined,
|
browser: browser ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext });
|
||||||
|
|
||||||
|
return sandboxContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSandboxWorkspaceForSession(params: {
|
export async function ensureSandboxWorkspaceForSession(params: {
|
||||||
|
|||||||
@ -1,5 +1,103 @@
|
|||||||
import { spawn } from "node:child_process";
|
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<ExecDockerRawResult> {
|
||||||
|
return new Promise<ExecDockerRawResult>((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 { defaultRuntime } from "../../runtime.js";
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js";
|
||||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.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;
|
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
export async function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
||||||
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
|
const result = await execDockerRaw(args, opts);
|
||||||
const child = spawn("docker", args, {
|
return {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdout: result.stdout.toString("utf8"),
|
||||||
});
|
stderr: result.stderr.toString("utf8"),
|
||||||
let stdout = "";
|
code: result.code,
|
||||||
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 readDockerPort(containerName: string, port: number) {
|
export async function readDockerPort(containerName: string, port: number) {
|
||||||
|
|||||||
247
src/agents/sandbox/fs-bridge.ts
Normal file
247
src/agents/sandbox/fs-bridge.ts
Normal file
@ -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<Buffer>;
|
||||||
|
writeFile(params: {
|
||||||
|
filePath: string;
|
||||||
|
cwd?: string;
|
||||||
|
data: Buffer | string;
|
||||||
|
encoding?: BufferEncoding;
|
||||||
|
mkdir?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<void>;
|
||||||
|
mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
|
||||||
|
remove(params: {
|
||||||
|
filePath: string;
|
||||||
|
cwd?: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<void>;
|
||||||
|
rename(params: { from: string; to: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
|
||||||
|
stat(params: {
|
||||||
|
filePath: string;
|
||||||
|
cwd?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<SandboxFsStat | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Buffer> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<SandboxFsStat | null> {
|
||||||
|
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<ExecDockerRawResult> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { SandboxFsBridge } from "./fs-bridge.js";
|
||||||
import type { SandboxDockerConfig } from "./types.docker.js";
|
import type { SandboxDockerConfig } from "./types.docker.js";
|
||||||
|
|
||||||
export type { SandboxDockerConfig } from "./types.docker.js";
|
export type { SandboxDockerConfig } from "./types.docker.js";
|
||||||
@ -77,6 +78,7 @@ export type SandboxContext = {
|
|||||||
tools: SandboxToolPolicy;
|
tools: SandboxToolPolicy;
|
||||||
browserAllowHostControl: boolean;
|
browserAllowHostControl: boolean;
|
||||||
browser?: SandboxBrowserContext;
|
browser?: SandboxBrowserContext;
|
||||||
|
fsBridge?: SandboxFsBridge;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxWorkspaceInfo = {
|
export type SandboxWorkspaceInfo = {
|
||||||
|
|||||||
73
src/agents/test-helpers/host-sandbox-fs-bridge.ts
Normal file
73
src/agents/test-helpers/host-sandbox-fs-bridge.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.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) {
|
async function writeAuthProfiles(agentDir: string, profiles: unknown) {
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
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(agentDir, { recursive: true });
|
||||||
await fs.mkdir(sandboxRoot, { recursive: true });
|
await fs.mkdir(sandboxRoot, { recursive: true });
|
||||||
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
|
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
|
||||||
|
const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) };
|
||||||
|
|
||||||
vi.stubEnv("OPENAI_API_KEY", "openai-test");
|
vi.stubEnv("OPENAI_API_KEY", "openai-test");
|
||||||
const cfg: MoltbotConfig = {
|
const cfg: MoltbotConfig = {
|
||||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
|
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();
|
expect(tool).not.toBeNull();
|
||||||
if (!tool) throw new Error("expected image tool");
|
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();
|
expect(tool).not.toBeNull();
|
||||||
if (!tool) throw new Error("expected image tool");
|
if (!tool) throw new Error("expected image tool");
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -21,8 +20,8 @@ import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-aut
|
|||||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||||
import { resolveConfiguredModelRef } from "../model-selection.js";
|
import { resolveConfiguredModelRef } from "../model-selection.js";
|
||||||
import { ensureMoltbotModelsJson } from "../models-config.js";
|
import { ensureMoltbotModelsJson } from "../models-config.js";
|
||||||
import { assertSandboxPath } from "../sandbox-paths.js";
|
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
|
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
|
||||||
import {
|
import {
|
||||||
coerceImageAssistantText,
|
coerceImageAssistantText,
|
||||||
coerceImageModelConfig,
|
coerceImageModelConfig,
|
||||||
@ -174,34 +173,40 @@ function buildImageContext(prompt: string, base64: string, mimeType: string): Co
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImageSandboxConfig = {
|
||||||
|
root: string;
|
||||||
|
bridge: SandboxFsBridge;
|
||||||
|
};
|
||||||
|
|
||||||
async function resolveSandboxedImagePath(params: {
|
async function resolveSandboxedImagePath(params: {
|
||||||
sandboxRoot: string;
|
sandbox: ImageSandboxConfig;
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
}): Promise<{ resolved: string; rewrittenFrom?: string }> {
|
}): Promise<{ resolved: string; rewrittenFrom?: string }> {
|
||||||
const normalize = (p: string) => (p.startsWith("file://") ? p.slice("file://".length) : p);
|
const normalize = (p: string) => (p.startsWith("file://") ? p.slice("file://".length) : p);
|
||||||
const filePath = normalize(params.imagePath);
|
const filePath = normalize(params.imagePath);
|
||||||
try {
|
try {
|
||||||
const out = await assertSandboxPath({
|
const resolved = params.sandbox.bridge.resolvePath({
|
||||||
filePath,
|
filePath,
|
||||||
cwd: params.sandboxRoot,
|
cwd: params.sandbox.root,
|
||||||
root: params.sandboxRoot,
|
|
||||||
});
|
});
|
||||||
return { resolved: out.resolved };
|
return { resolved: resolved.hostPath };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const name = path.basename(filePath);
|
const name = path.basename(filePath);
|
||||||
const candidateRel = path.join("media", "inbound", name);
|
const candidateRel = path.join("media", "inbound", name);
|
||||||
const candidateAbs = path.join(params.sandboxRoot, candidateRel);
|
|
||||||
try {
|
try {
|
||||||
await fs.stat(candidateAbs);
|
const stat = await params.sandbox.bridge.stat({
|
||||||
|
filePath: candidateRel,
|
||||||
|
cwd: params.sandbox.root,
|
||||||
|
});
|
||||||
|
if (!stat) throw err;
|
||||||
} catch {
|
} catch {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const out = await assertSandboxPath({
|
const out = params.sandbox.bridge.resolvePath({
|
||||||
filePath: candidateRel,
|
filePath: candidateRel,
|
||||||
cwd: params.sandboxRoot,
|
cwd: params.sandbox.root,
|
||||||
root: params.sandboxRoot,
|
|
||||||
});
|
});
|
||||||
return { resolved: out.resolved, rewrittenFrom: filePath };
|
return { resolved: out.hostPath, rewrittenFrom: filePath };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,7 +300,7 @@ async function runImagePrompt(params: {
|
|||||||
export function createImageTool(options?: {
|
export function createImageTool(options?: {
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
sandboxRoot?: string;
|
sandbox?: ImageSandboxConfig;
|
||||||
/** If true, the model has native vision capability and images in the prompt are auto-injected */
|
/** If true, the model has native vision capability and images in the prompt are auto-injected */
|
||||||
modelHasVision?: boolean;
|
modelHasVision?: boolean;
|
||||||
}): AnyAgentTool | null {
|
}): AnyAgentTool | null {
|
||||||
@ -370,22 +375,25 @@ export function createImageTool(options?: {
|
|||||||
const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
|
const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
|
||||||
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
|
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;
|
const isUrl = isHttpUrl;
|
||||||
if (sandboxRoot && isUrl) {
|
if (sandboxConfig && isUrl) {
|
||||||
throw new Error("Sandboxed image tool does not allow remote URLs.");
|
throw new Error("Sandboxed image tool does not allow remote URLs.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedImage = (() => {
|
const resolvedImage = (() => {
|
||||||
if (sandboxRoot) return imageRaw;
|
if (sandboxConfig) return imageRaw;
|
||||||
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw);
|
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw);
|
||||||
return imageRaw;
|
return imageRaw;
|
||||||
})();
|
})();
|
||||||
const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl
|
const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl
|
||||||
? { resolved: "" }
|
? { resolved: "" }
|
||||||
: sandboxRoot
|
: sandboxConfig
|
||||||
? await resolveSandboxedImagePath({
|
? await resolveSandboxedImagePath({
|
||||||
sandboxRoot,
|
sandbox: sandboxConfig,
|
||||||
imagePath: resolvedImage,
|
imagePath: resolvedImage,
|
||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
@ -397,7 +405,13 @@ export function createImageTool(options?: {
|
|||||||
|
|
||||||
const media = isDataUrl
|
const media = isDataUrl
|
||||||
? decodeDataUrl(resolvedImage)
|
? 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") {
|
if (media.kind !== "image") {
|
||||||
throw new Error(`Unsupported media type: ${media.kind}`);
|
throw new Error(`Unsupported media type: ${media.kind}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export type WebMediaResult = {
|
|||||||
type WebMediaOptions = {
|
type WebMediaOptions = {
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
optimizeImages?: boolean;
|
optimizeImages?: boolean;
|
||||||
|
readFile?: (filePath: string) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
||||||
@ -111,7 +112,7 @@ async function loadWebMediaInternal(
|
|||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
options: WebMediaOptions = {},
|
options: WebMediaOptions = {},
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
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.)
|
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
|
||||||
if (mediaUrl.startsWith("file://")) {
|
if (mediaUrl.startsWith("file://")) {
|
||||||
try {
|
try {
|
||||||
@ -201,7 +202,7 @@ async function loadWebMediaInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Local path
|
// 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 mime = await detectMime({ buffer: data, filePath: mediaUrl });
|
||||||
const kind = mediaKindFromMime(mime);
|
const kind = mediaKindFromMime(mime);
|
||||||
let fileName = path.basename(mediaUrl) || undefined;
|
let fileName = path.basename(mediaUrl) || undefined;
|
||||||
@ -217,19 +218,34 @@ async function loadWebMediaInternal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWebMedia(mediaUrl: string, maxBytes?: number): Promise<WebMediaResult> {
|
export async function loadWebMedia(
|
||||||
|
mediaUrl: string,
|
||||||
|
options?: number | WebMediaOptions,
|
||||||
|
): Promise<WebMediaResult> {
|
||||||
|
if (typeof options === "number" || options === undefined) {
|
||||||
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
|
maxBytes: options,
|
||||||
|
optimizeImages: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
maxBytes,
|
...options,
|
||||||
optimizeImages: true,
|
optimizeImages: options.optimizeImages ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWebMediaRaw(
|
export async function loadWebMediaRaw(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
maxBytes?: number,
|
options?: number | WebMediaOptions,
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
|
if (typeof options === "number" || options === undefined) {
|
||||||
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
|
maxBytes: options,
|
||||||
|
optimizeImages: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
maxBytes,
|
...options,
|
||||||
optimizeImages: false,
|
optimizeImages: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user