fix: resolve main-rebase fallout for sandbox fs bridge

This commit is contained in:
Peter Steinberger
2026-02-13 16:25:41 +01:00
parent 22c289100d
commit 795ec6aa2f
14 changed files with 79 additions and 51 deletions

View File

@@ -3,8 +3,8 @@ import { Type } from "@sinclair/typebox";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { applyUpdateHunk } from "./apply-patch-update.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { applyUpdateHunk } from "./apply-patch-update.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";
@@ -242,7 +242,9 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
async function ensureDir(filePath: string, ops: PatchFileOps) { 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 ops.mkdirp(parent); await ops.mkdirp(parent);
} }

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import type { AnyAgentTool } from "./tools/common.js"; import type { AnyAgentTool } from "./tools/common.js";
import { resolvePluginTools } from "../plugins/tools.js"; import { resolvePluginTools } from "../plugins/tools.js";
import { resolveSessionAgentId } from "./agent-scope.js"; import { resolveSessionAgentId } from "./agent-scope.js";
@@ -17,7 +18,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; 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 { createTtsTool } from "./tools/tts-tool.js"; import { createTtsTool } from "./tools/tts-tool.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
export function createOpenClawTools(options?: { export function createOpenClawTools(options?: {
sandboxBrowserBridgeUrl?: string; sandboxBrowserBridgeUrl?: string;

View File

@@ -1,11 +1,9 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js";
import type { ImageContent } from "@mariozechner/pi-ai";
import { sanitizeImageBlocks } from "../../tool-images.js"; import { sanitizeImageBlocks } from "../../tool-images.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.

View File

@@ -1,10 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
import type { AnyAgentTool } from "./pi-tools.types.js"; import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.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.
@@ -278,7 +278,7 @@ export function createSandboxedReadTool(params: SandboxToolParams) {
const base = createReadTool(params.root, { const base = createReadTool(params.root, {
operations: createSandboxReadOperations(params), operations: createSandboxReadOperations(params),
}) as unknown as AnyAgentTool; }) as unknown as AnyAgentTool;
return wrapSandboxPathGuard(createMoltbotReadTool(base), params.root); return wrapSandboxPathGuard(createOpenClawReadTool(base), params.root);
} }
export function createSandboxedWriteTool(params: SandboxToolParams) { export function createSandboxedWriteTool(params: SandboxToolParams) {

View File

@@ -3,9 +3,6 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createOpenClawCodingTools } from "./pi-tools.js"; import { createOpenClawCodingTools } from "./pi-tools.js";
import { describe, expect, it } from "vitest";
import { createMoltbotCodingTools } from "./pi-tools.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
vi.mock("../infra/shell-env.js", async (importOriginal) => { vi.mock("../infra/shell-env.js", async (importOriginal) => {

View File

@@ -9,11 +9,11 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
import { ensureSandboxBrowser } from "./browser.js"; import { ensureSandboxBrowser } from "./browser.js";
import { resolveSandboxConfigForAgent } from "./config.js"; import { resolveSandboxConfigForAgent } from "./config.js";
import { ensureSandboxContainer } from "./docker.js"; import { ensureSandboxContainer } from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { maybePruneSandboxes } from "./prune.js"; import { maybePruneSandboxes } from "./prune.js";
import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js";
import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.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?: OpenClawConfig; config?: OpenClawConfig;

View File

@@ -38,7 +38,9 @@ export function execDockerRaw(
const signal = opts?.signal; const signal = opts?.signal;
const handleAbort = () => { const handleAbort = () => {
if (aborted) return; if (aborted) {
return;
}
aborted = true; aborted = true;
child.kill("SIGTERM"); child.kill("SIGTERM");
}; };
@@ -58,12 +60,16 @@ export function execDockerRaw(
}); });
child.on("error", (error) => { child.on("error", (error) => {
if (signal) signal.removeEventListener("abort", handleAbort); if (signal) {
signal.removeEventListener("abort", handleAbort);
}
reject(error); reject(error);
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (signal) signal.removeEventListener("abort", handleAbort); if (signal) {
signal.removeEventListener("abort", handleAbort);
}
const stdout = Buffer.concat(stdoutChunks); const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks); const stderr = Buffer.concat(stderrChunks);
if (aborted || signal?.aborted) { if (aborted || signal?.aborted) {
@@ -98,7 +104,6 @@ export function execDockerRaw(
}); });
} }
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { computeSandboxConfigHash } from "./config-hash.js"; import { computeSandboxConfigHash } from "./config-hash.js";
@@ -281,9 +286,7 @@ export function buildSandboxCreateArgs(params: {
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) { if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
args.push("--cpus", String(params.cfg.cpus)); args.push("--cpus", String(params.cfg.cpus));
} }
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array< for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
[string, string | number | { soft?: number; hard?: number }]
>) {
const formatted = formatUlimitValue(name, value); const formatted = formatUlimitValue(name, value);
if (formatted) { if (formatted) {
args.push("--ulimit", formatted); args.push("--ulimit", formatted);

View File

@@ -4,9 +4,9 @@ vi.mock("./docker.js", () => ({
execDockerRaw: vi.fn(), execDockerRaw: vi.fn(),
})); }));
import type { SandboxContext } from "./types.js";
import { execDockerRaw } from "./docker.js"; import { execDockerRaw } from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js"; import { createSandboxFsBridge } from "./fs-bridge.js";
import type { SandboxContext } from "./types.js";
const mockedExecDockerRaw = vi.mocked(execDockerRaw); const mockedExecDockerRaw = vi.mocked(execDockerRaw);

View File

@@ -1,7 +1,6 @@
import path from "node:path"; import path from "node:path";
import { resolveSandboxPath } from "../sandbox-paths.js";
import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js"; import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js";
import { resolveSandboxPath } from "../sandbox-paths.js";
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
type RunCommandOptions = { type RunCommandOptions = {
@@ -244,9 +243,15 @@ function resolveSandboxFsPath(params: {
} }
function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { function coerceStatType(typeRaw?: string): "file" | "directory" | "other" {
if (!typeRaw) return "other"; if (!typeRaw) {
return "other";
}
const normalized = typeRaw.trim().toLowerCase(); const normalized = typeRaw.trim().toLowerCase();
if (normalized.includes("directory")) return "directory"; if (normalized.includes("directory")) {
if (normalized.includes("file")) return "file"; return "directory";
}
if (normalized.includes("file")) {
return "file";
}
return "other"; return "other";
} }

View File

@@ -1,8 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { resolveSandboxPath } from "../sandbox-paths.js";
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "../sandbox/fs-bridge.js"; import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "../sandbox/fs-bridge.js";
import { resolveSandboxPath } from "../sandbox-paths.js";
export function createHostSandboxFsBridge(rootDir: string): SandboxFsBridge { export function createHostSandboxFsBridge(rootDir: string): SandboxFsBridge {
const root = path.resolve(rootDir); const root = path.resolve(rootDir);
@@ -65,7 +64,9 @@ export function createHostSandboxFsBridge(rootDir: string): SandboxFsBridge {
mtimeMs: stats.mtimeMs, mtimeMs: stats.mtimeMs,
} satisfies SandboxFsStat; } satisfies SandboxFsStat;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error; throw error;
} }
}, },

View File

@@ -3,8 +3,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js"; import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.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 });

View File

@@ -1,5 +1,8 @@
import { type Api, type Context, complete, type Model } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import path from "node:path"; import path from "node:path";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { resolveUserPath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js"; import { loadWebMedia } from "../../web/media.js";
@@ -9,9 +12,8 @@ import { minimaxUnderstandImage } from "../minimax-vlm.js";
import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-auth.js"; import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-auth.js";
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 { ensureOpenClawModelsJson } from "../models-config.js";
import type { AnyAgentTool } from "./common.js"; import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
import { import {
coerceImageAssistantText, coerceImageAssistantText,
coerceImageModelConfig, coerceImageModelConfig,
@@ -207,7 +209,9 @@ async function resolveSandboxedImagePath(params: {
filePath: candidateRel, filePath: candidateRel,
cwd: params.sandbox.root, cwd: params.sandbox.root,
}); });
if (!stat) throw err; if (!stat) {
throw err;
}
} catch { } catch {
throw err; throw err;
} }
@@ -398,8 +402,12 @@ export function createImageTool(options?: {
} }
const resolvedImage = (() => { const resolvedImage = (() => {
if (sandboxConfig) return imageRaw; if (sandboxConfig) {
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw); return 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

View File

@@ -100,9 +100,9 @@ describe("web media loading", () => {
const { buffer, file } = await createLargeTestJpeg(); const { buffer, file } = await createLargeTestJpeg();
const cap = Math.max(1, Math.floor(buffer.length * 0.8)); const cap = Math.max(1, Math.floor(buffer.length * 0.8));
await expect( await expect(loadWebMedia(file, { maxBytes: cap, optimizeImages: false })).rejects.toThrow(
loadWebMedia(file, { maxBytes: cap, optimizeImages: false }), /Media exceeds/i,
).rejects.toThrow(/Media exceeds/i); );
}); });
it("sniffs mime before extension when loading local files", async () => { it("sniffs mime before extension when loading local files", async () => {

View File

@@ -25,6 +25,9 @@ export type WebMediaResult = {
type WebMediaOptions = { type WebMediaOptions = {
maxBytes?: number; maxBytes?: number;
optimizeImages?: boolean; optimizeImages?: boolean;
ssrfPolicy?: SsrFPolicy;
/** Allowed root directories for local path reads. "any" skips the check (caller already validated). */
localRoots?: string[] | "any";
readFile?: (filePath: string) => Promise<Buffer>; readFile?: (filePath: string) => Promise<Buffer>;
}; };
@@ -163,7 +166,13 @@ async function loadWebMediaInternal(
mediaUrl: string, mediaUrl: string,
options: WebMediaOptions = {}, options: WebMediaOptions = {},
): Promise<WebMediaResult> { ): Promise<WebMediaResult> {
const { maxBytes, optimizeImages = true, readFile: readFileOverride } = options; const {
maxBytes,
optimizeImages = true,
ssrfPolicy,
localRoots,
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 {
@@ -285,35 +294,39 @@ async function loadWebMediaInternal(
export async function loadWebMedia( export async function loadWebMedia(
mediaUrl: string, mediaUrl: string,
options?: number | WebMediaOptions, maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
): Promise<WebMediaResult> { ): Promise<WebMediaResult> {
if (typeof options === "number" || options === undefined) { if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
return await loadWebMediaInternal(mediaUrl, { return await loadWebMediaInternal(mediaUrl, {
maxBytes: options, maxBytes: maxBytesOrOptions,
optimizeImages: true, optimizeImages: true,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
}); });
} }
return await loadWebMediaInternal(mediaUrl, { return await loadWebMediaInternal(mediaUrl, {
...options, ...maxBytesOrOptions,
optimizeImages: options.optimizeImages ?? true, optimizeImages: maxBytesOrOptions.optimizeImages ?? true,
}); });
} }
export async function loadWebMediaRaw( export async function loadWebMediaRaw(
mediaUrl: string, mediaUrl: string,
options?: number | WebMediaOptions, maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
): Promise<WebMediaResult> { ): Promise<WebMediaResult> {
if (typeof options === "number" || options === undefined) { if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
return await loadWebMediaInternal(mediaUrl, { return await loadWebMediaInternal(mediaUrl, {
maxBytes: options, maxBytes: maxBytesOrOptions,
optimizeImages: false, optimizeImages: false,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
}); });
} }
return await loadWebMediaInternal(mediaUrl, { return await loadWebMediaInternal(mediaUrl, {
...options, ...maxBytesOrOptions,
optimizeImages: false, optimizeImages: false,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
}); });
} }