Files
openclaw/src/agents/agent-tools.read.ts
Peter Steinberger bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00

1066 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs/promises";
import path from "node:path";
import { URL } from "node:url";
import { isWindowsDrivePath } from "../infra/archive-path.js";
import {
canonicalPathFromExistingAncestor,
root as fsRoot,
FsSafeError,
} from "../infra/fs-safe.js";
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js";
import { detectMime } from "../media/mime.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import {
REQUIRED_PARAM_GROUPS,
assertRequiredParams,
getToolParamsRecord,
wrapToolParamValidation,
} from "./agent-tools.params.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import type { ImageSanitizationLimits } from "./image-sanitization.js";
import { toRelativeWorkspacePath } from "./path-policy.js";
import type { AgentToolResult } from "./runtime/index.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { createEditTool, createReadTool, createWriteTool } from "./sessions/index.js";
import { sanitizeToolResultImages } from "./tool-images.js";
export {
REQUIRED_PARAM_GROUPS,
assertRequiredParams,
getToolParamsRecord,
wrapToolParamValidation,
} from "./agent-tools.params.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to sanitize oversized images before they hit providers.
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
const DEFAULT_READ_PAGE_MAX_BYTES = 32 * 1024;
const MAX_ADAPTIVE_READ_MAX_BYTES = 128 * 1024;
const ADAPTIVE_READ_CONTEXT_SHARE = 0.1;
const CHARS_PER_TOKEN_ESTIMATE = 4;
const MAX_ADAPTIVE_READ_PAGES = 4;
type OpenClawReadToolOptions = {
modelContextWindowTokens?: number;
imageSanitization?: ImageSanitizationLimits;
};
type ReadTruncationDetails = {
truncated: boolean;
outputLines: number;
firstLineExceedsLimit: boolean;
};
const OFFSET_BEYOND_EOF_RE = /^Offset \d+ is beyond end of file \(\d+ lines total\)$/;
const READ_CONTINUATION_NOTICE_RE =
/\n\n\[(?:Showing lines [^\]]*?Use offset=\d+ to continue\.|\d+ more lines in file\. Use offset=\d+ to continue\.)\]\s*$/;
const DAILY_MEMORY_PATH_RE = /^memory\/\d{4}-\d{2}-\d{2}\.md$/;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function resolveAdaptiveReadMaxBytes(options?: OpenClawReadToolOptions): number {
const contextWindowTokens = options?.modelContextWindowTokens;
if (
typeof contextWindowTokens !== "number" ||
!Number.isFinite(contextWindowTokens) ||
contextWindowTokens <= 0
) {
return DEFAULT_READ_PAGE_MAX_BYTES;
}
const fromContext = Math.floor(
contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * ADAPTIVE_READ_CONTEXT_SHARE,
);
return clamp(fromContext, DEFAULT_READ_PAGE_MAX_BYTES, MAX_ADAPTIVE_READ_MAX_BYTES);
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`;
}
return `${bytes}B`;
}
function getToolResultText(result: AgentToolResult<unknown>): string | undefined {
const content = Array.isArray(result.content) ? result.content : [];
const textBlocks = content
.map((block) => {
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
return (block as { text: string }).text;
}
return undefined;
})
.filter((value): value is string => typeof value === "string");
if (textBlocks.length === 0) {
return undefined;
}
return textBlocks.join("\n");
}
function withToolResultText(
result: AgentToolResult<unknown>,
text: string,
): AgentToolResult<unknown> {
const content = Array.isArray(result.content) ? result.content : [];
let replaced = false;
const nextContent: ToolContentBlock[] = content.map((block) => {
if (
!replaced &&
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text"
) {
replaced = true;
return Object.assign({}, block as TextContentBlock, { text });
}
return block;
});
if (replaced) {
return {
...result,
content: nextContent as unknown as AgentToolResult<unknown>["content"],
};
}
const textBlock = { type: "text", text } as unknown as TextContentBlock;
return {
...result,
content: [textBlock] as unknown as AgentToolResult<unknown>["content"],
};
}
function extractReadTruncationDetails(
result: AgentToolResult<unknown>,
): ReadTruncationDetails | null {
const details = (result as { details?: unknown }).details;
if (!details || typeof details !== "object") {
return null;
}
const truncation = (details as { truncation?: unknown }).truncation;
if (!truncation || typeof truncation !== "object") {
return null;
}
const record = truncation as Record<string, unknown>;
if (record.truncated !== true) {
return null;
}
const outputLinesRaw = record.outputLines;
const outputLines =
typeof outputLinesRaw === "number" && Number.isFinite(outputLinesRaw)
? Math.max(0, Math.floor(outputLinesRaw))
: 0;
return {
truncated: true,
outputLines,
firstLineExceedsLimit: record.firstLineExceedsLimit === true,
};
}
function stripReadContinuationNotice(text: string): string {
return text.replace(READ_CONTINUATION_NOTICE_RE, "");
}
function stripReadTruncationContentDetails(
result: AgentToolResult<unknown>,
): AgentToolResult<unknown> {
const details = (result as { details?: unknown }).details;
if (!details || typeof details !== "object") {
return result;
}
const detailsRecord = details as Record<string, unknown>;
const truncationRaw = detailsRecord.truncation;
if (!truncationRaw || typeof truncationRaw !== "object") {
return result;
}
const truncation = truncationRaw as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(truncation, "content")) {
return result;
}
const { content: _content, ...restTruncation } = truncation;
return {
...result,
details: {
...detailsRecord,
truncation: restTruncation,
},
};
}
function isOffsetBeyondEof(error: unknown, args: Record<string, unknown>): boolean {
const offset = args.offset;
return (
typeof offset === "number" &&
Number.isFinite(offset) &&
offset > 0 &&
error instanceof Error &&
OFFSET_BEYOND_EOF_RE.test(error.message)
);
}
function emptyReadResult(): AgentToolResult<unknown> {
const textBlock = { type: "text", text: "" } satisfies TextContentBlock;
return { content: [textBlock], details: undefined };
}
function missingDailyMemoryReadResult(relativePath: string): AgentToolResult<unknown> {
return {
content: [
{
type: "text",
text: `No daily memory file exists yet at ${relativePath}.`,
},
],
details: {
status: "not_found",
path: relativePath,
optional: true,
},
};
}
function normalizeDailyMemoryReadPath(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "");
return DAILY_MEMORY_PATH_RE.test(normalized) ? normalized : undefined;
}
function isNotFoundError(error: unknown): boolean {
if (typeof (error as NodeJS.ErrnoException | undefined)?.code === "string") {
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
if (!(error instanceof Error)) {
return false;
}
return /\bENOENT\b|no such file or directory|file not found/i.test(error.message);
}
async function executeReadPage(params: {
base: AnyAgentTool;
toolCallId: string;
args: Record<string, unknown>;
signal?: AbortSignal;
}): Promise<AgentToolResult<unknown>> {
try {
return await params.base.execute(params.toolCallId, params.args, params.signal);
} catch (error) {
if (isOffsetBeyondEof(error, params.args)) {
return emptyReadResult();
}
const missingDailyMemoryPath = normalizeDailyMemoryReadPath(params.args.path);
if (missingDailyMemoryPath && isNotFoundError(error)) {
return missingDailyMemoryReadResult(missingDailyMemoryPath);
}
throw error;
}
}
async function executeReadWithAdaptivePaging(params: {
base: AnyAgentTool;
toolCallId: string;
args: Record<string, unknown>;
signal?: AbortSignal;
maxBytes: number;
}): Promise<AgentToolResult<unknown>> {
const userLimit = params.args.limit;
const hasExplicitLimit =
typeof userLimit === "number" && Number.isFinite(userLimit) && userLimit > 0;
if (hasExplicitLimit) {
return await executeReadPage(params);
}
const offsetRaw = params.args.offset;
let nextOffset =
typeof offsetRaw === "number" && Number.isFinite(offsetRaw) && offsetRaw > 0
? Math.floor(offsetRaw)
: 1;
let firstResult: AgentToolResult<unknown> | null = null;
let aggregatedText = "";
let aggregatedBytes = 0;
let capped = false;
let continuationOffset: number | undefined;
for (let page = 0; page < MAX_ADAPTIVE_READ_PAGES; page += 1) {
const pageArgs = { ...params.args, offset: nextOffset };
const pageResult = await executeReadPage({
base: params.base,
toolCallId: params.toolCallId,
args: pageArgs,
signal: params.signal,
});
firstResult ??= pageResult;
const rawText = getToolResultText(pageResult);
if (typeof rawText !== "string") {
return pageResult;
}
const truncation = extractReadTruncationDetails(pageResult);
const canContinue =
Boolean(truncation?.truncated) &&
!truncation?.firstLineExceedsLimit &&
(truncation?.outputLines ?? 0) > 0 &&
page < MAX_ADAPTIVE_READ_PAGES - 1;
const pageText = canContinue ? stripReadContinuationNotice(rawText) : rawText;
const delimiter = aggregatedText && pageText ? "\n\n" : "";
const nextBytes = Buffer.byteLength(`${delimiter}${pageText}`, "utf-8");
if (aggregatedText && aggregatedBytes + nextBytes > params.maxBytes) {
capped = true;
continuationOffset = nextOffset;
break;
}
aggregatedText += `${delimiter}${pageText}`;
aggregatedBytes += nextBytes;
if (!canContinue || !truncation) {
return withToolResultText(pageResult, aggregatedText);
}
nextOffset += truncation.outputLines;
continuationOffset = nextOffset;
if (aggregatedBytes >= params.maxBytes) {
capped = true;
break;
}
}
if (!firstResult) {
return await executeReadPage(params);
}
let finalText = aggregatedText;
if (capped && continuationOffset) {
finalText += `\n\n[Read output capped at ${formatBytes(params.maxBytes)} for this call. Use offset=${continuationOffset} to continue.]`;
}
return withToolResultText(firstResult, finalText);
}
function rewriteReadImageHeader(text: string, mimeType: string): string {
// session runtime uses: "Read image file [image/png]"
if (text.startsWith("Read image file [") && text.endsWith("]")) {
return `Read image file [${mimeType}]`;
}
return text;
}
async function normalizeReadImageResult(
result: AgentToolResult<unknown>,
filePath: string,
): Promise<AgentToolResult<unknown>> {
const content = Array.isArray(result.content) ? result.content : [];
const image = content.find(
(b): b is ImageContentBlock =>
!!b &&
typeof b === "object" &&
(b as { type?: unknown }).type === "image" &&
typeof (b as { data?: unknown }).data === "string" &&
typeof (b as { mimeType?: unknown }).mimeType === "string",
);
if (!image) {
return result;
}
if (!image.data.trim()) {
throw new Error(`read: image payload is empty (${filePath})`);
}
const sniffed = await sniffMimeFromBase64(image.data);
if (!sniffed) {
return result;
}
if (!sniffed.startsWith("image/")) {
throw new Error(
`read: file looks like ${sniffed} but was treated as ${image.mimeType} (${filePath})`,
);
}
if (sniffed === image.mimeType) {
return result;
}
const nextContent = content.map((block) => {
if (block && typeof block === "object" && (block as { type?: unknown }).type === "image") {
const b = block as ImageContentBlock & { mimeType: string };
return Object.assign({}, b, { mimeType: sniffed }) satisfies ImageContentBlock;
}
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
const b = block as TextContentBlock & { text: string };
return Object.assign({}, b, {
text: rewriteReadImageHeader(b.text, sniffed),
}) satisfies TextContentBlock;
}
return block;
});
return { ...result, content: nextContent };
}
export function wrapToolWorkspaceRootGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return wrapToolWorkspaceRootGuardWithOptions(tool, root);
}
function mapContainerPathToWorkspaceRoot(params: {
filePath: string;
root: string;
containerWorkdir?: string;
}): string {
return mapContainerPathToRoot({
filePath: params.filePath,
root: params.root,
containerRoot: params.containerWorkdir,
}).filePath;
}
function resolveContainerPathCandidate(filePath: string): string | null {
let candidate = filePath.startsWith("@") ? filePath.slice(1) : filePath;
if (/^file:\/\//i.test(candidate)) {
const localFilePath = trySafeFileURLToPath(candidate);
if (localFilePath) {
candidate = localFilePath;
} else {
// Windows rejects posix-style file:///workspace/... in fileURLToPath; map via URL pathname
// when it clearly refers to the container workdir (same idea as sandbox-paths).
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return filePath;
}
if (parsed.protocol !== "file:") {
return filePath;
}
const host = parsed.hostname.trim().toLowerCase();
if (host && host !== "localhost") {
return filePath;
}
if (hasEncodedFileUrlSeparator(parsed.pathname)) {
return filePath;
}
let normalizedPathname: string;
try {
normalizedPathname = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
} catch {
return filePath;
}
candidate = normalizedPathname;
}
}
return candidate;
}
function mapContainerPathToRoot(params: {
filePath: string;
root: string;
containerRoot?: string;
}): { filePath: string; matched: boolean } {
const containerRoot = params.containerRoot?.trim();
if (!containerRoot) {
return { filePath: params.filePath, matched: false };
}
const normalizedRoot = containerRoot.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalizedRoot.startsWith("/") || !normalizedRoot) {
return { filePath: params.filePath, matched: false };
}
const candidate = resolveContainerPathCandidate(params.filePath);
if (candidate === null) {
return { filePath: params.filePath, matched: false };
}
const normalizedCandidate = candidate.replace(/\\/g, "/");
if (normalizedCandidate === normalizedRoot) {
return { filePath: path.resolve(params.root), matched: true };
}
const prefix = `${normalizedRoot}/`;
if (!normalizedCandidate.startsWith(prefix)) {
return { filePath: candidate, matched: false };
}
const relative = normalizedCandidate.slice(prefix.length);
if (!relative) {
return { filePath: path.resolve(params.root), matched: true };
}
return {
filePath: path.resolve(params.root, ...relative.split("/").filter(Boolean)),
matched: true,
};
}
export function resolveToolPathAgainstWorkspaceRoot(params: {
filePath: string;
root: string;
containerWorkdir?: string;
}): string {
const mapped = mapContainerPathToWorkspaceRoot(params);
const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped;
if (isWindowsDrivePath(candidate)) {
return path.win32.normalize(candidate);
}
if (path.isAbsolute(candidate)) {
return path.resolve(candidate);
}
return path.resolve(params.root, candidate || ".");
}
type MemoryFlushAppendOnlyWriteOptions = {
root: string;
relativePath: string;
containerWorkdir?: string;
sandbox?: {
root: string;
bridge: SandboxFsBridge;
};
};
async function readOptionalUtf8File(params: {
absolutePath: string;
relativePath: string;
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
signal?: AbortSignal;
}): Promise<string> {
try {
if (params.sandbox) {
const stat = await params.sandbox.bridge.stat({
filePath: params.relativePath,
cwd: params.sandbox.root,
signal: params.signal,
});
if (!stat) {
return "";
}
const buffer = await params.sandbox.bridge.readFile({
filePath: params.relativePath,
cwd: params.sandbox.root,
signal: params.signal,
});
return buffer.toString("utf-8");
}
return await fs.readFile(params.absolutePath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
return "";
}
throw error;
}
}
async function appendMemoryFlushContent(params: {
absolutePath: string;
root: string;
relativePath: string;
content: string;
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
signal?: AbortSignal;
}) {
if (!params.sandbox) {
const root = await fsRoot(params.root);
await root.append(params.relativePath, params.content, {
mkdir: true,
prependNewlineIfNeeded: true,
});
return;
}
const existing = await readOptionalUtf8File({
absolutePath: params.absolutePath,
relativePath: params.relativePath,
sandbox: params.sandbox,
signal: params.signal,
});
const separator =
existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : "";
const next = `${existing}${separator}${params.content}`;
if (params.sandbox) {
const parent = path.posix.dirname(params.relativePath);
if (parent && parent !== ".") {
await params.sandbox.bridge.mkdirp({
filePath: parent,
cwd: params.sandbox.root,
signal: params.signal,
});
}
await params.sandbox.bridge.writeFile({
filePath: params.relativePath,
cwd: params.sandbox.root,
data: next,
mkdir: true,
signal: params.signal,
});
return;
}
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
await fs.writeFile(params.absolutePath, next, "utf-8");
}
export function wrapToolMemoryFlushAppendOnlyWrite(
tool: AnyAgentTool,
options: MemoryFlushAppendOnlyWriteOptions,
): AnyAgentTool {
const allowedAbsolutePath = path.resolve(options.root, options.relativePath);
return {
...tool,
description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`,
execute: async (toolCallId, args, signal, onUpdate) => {
const record = getToolParamsRecord(args);
assertRequiredParams(record, REQUIRED_PARAM_GROUPS.write, tool.name);
const filePath =
typeof record?.path === "string" && record.path.trim() ? record.path : undefined;
const content = typeof record?.content === "string" ? record.content : undefined;
if (!filePath || content === undefined) {
return tool.execute(toolCallId, args, signal, onUpdate);
}
const resolvedPath = resolveToolPathAgainstWorkspaceRoot({
filePath,
root: options.root,
containerWorkdir: options.containerWorkdir,
});
if (resolvedPath !== allowedAbsolutePath) {
throw new Error(
`Memory flush writes are restricted to ${options.relativePath}; use that path only.`,
);
}
await appendMemoryFlushContent({
absolutePath: allowedAbsolutePath,
root: options.root,
relativePath: options.relativePath,
content,
sandbox: options.sandbox,
signal,
});
return {
content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }],
details: {
path: options.relativePath,
appendOnly: true,
},
};
},
};
}
function isSandboxRootEscapeError(error: unknown): error is Error {
return error instanceof Error && /^Path escapes sandbox root \(/i.test(error.message);
}
function withWorkspaceSafeTempHint(error: unknown): unknown {
if (!isSandboxRootEscapeError(error)) {
return error;
}
const message = error.message.includes(".openclaw/tmp/")
? error.message
: `${error.message}. Use a relative path under \`.openclaw/tmp/\` inside the workspace for scratch/temp/meta files that file tools need to read or write later.`;
return new Error(message, { cause: error });
}
async function assertSandboxPathWithinAnyRoot(params: {
filePath: string;
roots: readonly string[];
}) {
let firstRootEscapeError: unknown;
const seen = new Set<string>();
for (const candidateRoot of params.roots) {
const trimmedRoot = candidateRoot.trim();
if (!trimmedRoot) {
continue;
}
const root = path.resolve(trimmedRoot);
if (seen.has(root)) {
continue;
}
seen.add(root);
try {
return await assertSandboxPath({
filePath: params.filePath,
cwd: root,
root,
});
} catch (error) {
if (!isSandboxRootEscapeError(error)) {
throw error;
}
firstRootEscapeError ??= error;
}
}
throw firstRootEscapeError ?? new Error("Path guard has no configured roots.");
}
export function wrapToolWorkspaceRootGuardWithOptions(
tool: AnyAgentTool,
root: string,
options?: {
additionalRoots?: readonly string[];
additionalContainerMounts?: readonly {
containerRoot: string;
hostRoot: string;
}[];
containerWorkdir?: string;
pathParamKeys?: readonly string[];
normalizeGuardedPathParams?: boolean;
},
): AnyAgentTool {
const pathParamKeys =
options?.pathParamKeys && options.pathParamKeys.length > 0 ? options.pathParamKeys : ["path"];
return {
...tool,
execute: async (toolCallId, args, signal, onUpdate) => {
const record = getToolParamsRecord(args);
let normalizedRecord: Record<string, unknown> | undefined;
for (const key of pathParamKeys) {
const filePath = record?.[key];
if (typeof filePath !== "string" || !filePath.trim()) {
continue;
}
let guardedRoot = root;
const workspaceMapping = mapContainerPathToRoot({
filePath,
root,
containerRoot: options?.containerWorkdir,
});
let sandboxPath = workspaceMapping.filePath;
if (!workspaceMapping.matched) {
for (const mount of options?.additionalContainerMounts ?? []) {
const mountMapping = mapContainerPathToRoot({
filePath,
root: mount.hostRoot,
containerRoot: mount.containerRoot,
});
if (mountMapping.matched) {
guardedRoot = path.resolve(mount.hostRoot);
sandboxPath = mountMapping.filePath;
break;
}
}
}
const additionalRoots =
guardedRoot === root && !workspaceMapping.matched ? (options?.additionalRoots ?? []) : [];
let sandboxResult: Awaited<ReturnType<typeof assertSandboxPathWithinAnyRoot>>;
try {
sandboxResult = await assertSandboxPathWithinAnyRoot({
filePath: sandboxPath,
roots: [guardedRoot, ...additionalRoots],
});
} catch (error) {
throw withWorkspaceSafeTempHint(error);
}
if (options?.normalizeGuardedPathParams && record) {
normalizedRecord ??= { ...record };
normalizedRecord[key] = sandboxResult.resolved;
}
}
return tool.execute(toolCallId, normalizedRecord ?? args, signal, onUpdate);
},
};
}
type SandboxToolParams = {
root: string;
bridge: SandboxFsBridge;
modelContextWindowTokens?: number;
imageSanitization?: ImageSanitizationLimits;
};
export function createSandboxedReadTool(params: SandboxToolParams) {
const base = createReadTool(params.root, {
operations: createSandboxReadOperations(params),
}) as unknown as AnyAgentTool;
return createOpenClawReadTool(base, {
modelContextWindowTokens: params.modelContextWindowTokens,
imageSanitization: params.imageSanitization,
});
}
export function createSandboxedWriteTool(params: SandboxToolParams) {
const base = createWriteTool(params.root, {
operations: createSandboxWriteOperations(params),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
}
export function createSandboxedEditTool(params: SandboxToolParams) {
const base = createEditTool(params.root, {
operations: createSandboxEditOperations(params),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.edit);
}
export function createHostWorkspaceWriteTool(root: string, options?: { workspaceOnly?: boolean }) {
const base = createWriteTool(root, {
operations: createHostWriteOperations(root, options),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
}
export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) {
const base = createEditTool(root, {
operations: createHostEditOperations(root, options),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.edit);
}
export function createOpenClawReadTool(
base: AnyAgentTool,
options?: OpenClawReadToolOptions,
): AnyAgentTool {
return {
...base,
execute: async (toolCallId, params, signal) => {
const record = getToolParamsRecord(params);
assertRequiredParams(record, REQUIRED_PARAM_GROUPS.read, base.name);
const result = await executeReadWithAdaptivePaging({
base,
toolCallId,
args: record ?? {},
signal,
maxBytes: resolveAdaptiveReadMaxBytes(options),
});
const filePath = typeof record?.path === "string" ? record.path : "<unknown>";
const strippedDetailsResult = stripReadTruncationContentDetails(result);
const normalizedResult = await normalizeReadImageResult(strippedDetailsResult, filePath);
return sanitizeToolResultImages(
normalizedResult,
`read:${filePath}`,
options?.imageSanitization,
);
},
};
}
function createSandboxReadOperations(params: SandboxToolParams) {
return {
readFile: (absolutePath: string) =>
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
access: (absolutePath: string) => assertSandboxFileExists(params, 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 });
},
readFile: (absolutePath: string) =>
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
statFile: (absolutePath: string) =>
params.bridge.stat({ filePath: absolutePath, cwd: params.root }),
} 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: (absolutePath: string) => assertSandboxFileExists(params, absolutePath),
} as const;
}
async function assertSandboxFileExists(params: SandboxToolParams, absolutePath: string) {
const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root });
if (!stat) {
throw createFsAccessError("ENOENT", absolutePath);
}
}
function expandTildeToOsHome(filePath: string): string {
const home = resolveOsHomeDir();
return home ? expandHomePrefix(filePath, { home }) : filePath;
}
function resolveHostPath(filePath: string): string {
return path.resolve(expandTildeToOsHome(filePath));
}
async function writeHostFile(absolutePath: string, content: string) {
const resolved = resolveHostPath(absolutePath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, content, "utf-8");
}
async function statHostFile(absolutePath: string) {
try {
const stat = await fs.stat(absolutePath);
return {
type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other",
size: stat.size,
mtimeMs: stat.mtimeMs,
} as const;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return null;
}
throw error;
}
}
async function writeWorkspaceFile(
root: string,
rootPromise: ReturnType<typeof fsRoot>,
absolutePath: string,
content: string,
) {
const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath);
await (await rootPromise).write(relative, content, { mkdir: true });
}
function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) {
const workspaceOnly = options?.workspaceOnly ?? false;
if (!workspaceOnly) {
// When workspaceOnly is false, allow writes anywhere on the host
return {
mkdir: async (dir: string) => {
const resolved = resolveHostPath(dir);
await fs.mkdir(resolved, { recursive: true });
},
writeFile: writeHostFile,
readFile: async (absolutePath: string) =>
fs.readFile(path.resolve(expandTildeToOsHome(absolutePath))),
statFile: (absolutePath: string) =>
statHostFile(path.resolve(expandTildeToOsHome(absolutePath))),
} as const;
}
// When workspaceOnly is true, enforce workspace boundary
const rootPromise = fsRoot(root);
return {
mkdir: async (dir: string) => {
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
await assertSandboxPath({ filePath: resolved, cwd: root, root });
await fs.mkdir(resolved, { recursive: true });
},
writeFile: (absolutePath: string, content: string) =>
writeWorkspaceFile(root, rootPromise, absolutePath, content),
readFile: async (absolutePath: string) => {
const relative = toRelativeWorkspacePath(root, absolutePath);
return (await (await rootPromise).read(relative)).buffer;
},
statFile: async (absolutePath: string) => {
const relative = toRelativeWorkspacePath(root, absolutePath);
return statHostFile(path.resolve(root, relative));
},
} as const;
}
function createHostEditOperations(root: string, options?: { workspaceOnly?: boolean }) {
const workspaceOnly = options?.workspaceOnly ?? false;
if (!workspaceOnly) {
// When workspaceOnly is false, allow edits anywhere on the host
return {
readFile: async (absolutePath: string) => {
return await fs.readFile(resolveHostPath(absolutePath));
},
writeFile: writeHostFile,
access: async (absolutePath: string) => {
await fs.access(resolveHostPath(absolutePath));
},
} as const;
}
// When workspaceOnly is true, enforce workspace boundary
const rootPromise = fsRoot(root);
return {
readFile: async (absolutePath: string) => {
const relative = toRelativeWorkspacePath(root, absolutePath);
const safeRead = await (await rootPromise).read(relative);
return safeRead.buffer;
},
writeFile: (absolutePath: string, content: string) =>
writeWorkspaceFile(root, rootPromise, absolutePath, content),
access: async (absolutePath: string) => {
let relative: string;
try {
relative = toRelativeWorkspacePath(root, absolutePath);
} catch {
// Path escapes workspace root. Don't throw here the upstream
// library replaces any `access` error with a misleading "File not
// found" message. By returning silently the subsequent `readFile`
// call will throw the same "Path escapes workspace root" error
// through a code-path that propagates the original message.
return;
}
try {
const opened = await (await rootPromise).open(relative);
await opened.handle.close().catch(() => {});
} catch (error) {
if (error instanceof FsSafeError && error.code === "not-found") {
throw createFsAccessError("ENOENT", absolutePath);
}
if (error instanceof FsSafeError && error.code === "outside-workspace") {
// Don't throw here see the comment above about the upstream
// library swallowing access errors as "File not found".
return;
}
throw error;
}
},
} as const;
}
async function toCanonicalRelativeWorkspacePath(
root: string,
absolutePath: string,
): Promise<string> {
const lexicalRelative = toRelativeWorkspacePath(root, absolutePath);
const lexicalPath = path.resolve(root, lexicalRelative);
const parentPath = path.dirname(lexicalPath);
const [rootReal, canonicalParentPath] = await Promise.all([
fs.realpath(root),
canonicalPathFromExistingAncestor(parentPath),
]);
const canonicalPath = path.join(canonicalParentPath, path.basename(lexicalPath));
return toRelativeWorkspacePath(rootReal, canonicalPath);
}
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;
}