fix(agents): suppress Write/Edit failed warning on response-timeout false-failure (#55424) (#86855)

* fix(agents): suppress Write/Edit failed warning on response-timeout false-failure (#55424)

Reporter sees '⚠️ Write failed' / '⚠️ Edit failed' warnings on Feishu (and other channels) even though the file was 100% saved successfully (8 of 8 verified writes succeeded; warning shown for all 8). Source path: tool-mutation records lastToolError.timedOut=true with a fileTarget when a write/edit tool ack reply times out after the disk mutation has already completed, then resolveToolErrorWarningPolicy goes through the default mutating-tool branch and emits the misleading failure summary.

Add a narrow gate inside resolveToolErrorWarningPolicy that suppresses the warning only when both lastToolError.timedOut is true AND lastToolError.fileTarget is defined. fileTarget is set by tool-mutation.ts only for the write/edit family (FILE_MUTATING_TOOL_NAMES), so this branch never matches exec/message/cron/gateway mutating-tool timeouts where the disk-write idempotency reasoning does not apply. Real file failures (no timeout) and timeouts without recorded fileTarget keep their visible warnings.

* fix: recover completed write timeouts safely

* fix: bound write timeout recovery precheck

* fix: type write recovery precheck fallback

* test: complete write recovery result mock

* test: isolate e2e timeout fixture shims

* test: stabilize e2e timeout fixture path

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ToToKr
2026-05-27 17:03:58 +09:00
committed by GitHub
parent 3104f36329
commit 7e702bb43d
10 changed files with 674 additions and 31 deletions

View File

@@ -468,6 +468,64 @@ describe("buildEmbeddedRunPayloads", () => {
);
});
it("still shows write tool errors when timedOut is true but no fileTarget was recorded", () => {
// Without `fileTarget` we cannot distinguish a confirmed file write from
// an unrelated mutating-tool timeout, so the default-visible warning is
// preserved to avoid hiding real failures.
const payloads = buildPayloads({
assistantTexts: ["Done."],
lastAssistant: { stopReason: "end_turn" } as unknown as AssistantMessage,
lastToolError: {
toolName: "write",
error: "invoke timed out",
timedOut: true,
mutatingAction: true,
},
});
expect(payloads).toHaveLength(2);
expect(payloads[1]?.isError).toBe(true);
expect(payloads[1]?.text).toContain("Write");
});
it("still shows write tool errors when timedOut and fileTarget only prove the attempted path", () => {
const payloads = buildPayloads({
assistantTexts: ["Done."],
lastAssistant: { stopReason: "end_turn" } as unknown as AssistantMessage,
lastToolError: {
toolName: "write",
error: "invoke timed out",
timedOut: true,
mutatingAction: true,
fileTarget: { path: "/tmp/openclaw/output.md" },
},
});
expect(payloads).toHaveLength(2);
expect(payloads[1]?.isError).toBe(true);
expect(payloads[1]?.text).toContain("Write");
});
it("still shows exec tool errors when timedOut is true (no file-write boundary)", () => {
// Exec timeouts never set `fileTarget`, so the new file-write boundary
// never matches. Exec/message/cron/gateway tools keep the visible
// warning because the disk-write idempotency reasoning does not apply.
const payloads = buildPayloads({
assistantTexts: ["The script is ready."],
lastAssistant: { stopReason: "end_turn" } as unknown as AssistantMessage,
lastToolError: {
toolName: "exec",
error: "command timed out",
timedOut: true,
mutatingAction: true,
},
});
expect(payloads).toHaveLength(2);
expect(payloads[1]?.isError).toBe(true);
expect(payloads[1]?.text).toContain("Exec");
});
it("shows exec tool errors when assistant output claims success", () => {
const payloads = buildPayloads({
assistantTexts: ["The script is ready to use and saved in your workspace."],

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
import { getToolParamsRecord } from "./pi-tools.params.js";
@@ -9,6 +10,30 @@ type EditToolRecoveryOptions = {
readFile: (absolutePath: string) => Promise<string>;
};
type WriteToolRecoveryOptions = {
root: string;
readFile: (absolutePath: string) => Promise<string>;
statFile?: (absolutePath: string) => Promise<WriteToolFileStat | null>;
};
type WriteToolParams = {
pathParam?: string;
content?: string;
};
type WriteToolFileStat = {
type: "file" | "directory" | "other";
size: number;
mtimeMs?: number;
};
type WriteToolOriginalState = "different" | "same" | "unknown";
type WriteToolPrecheck = {
state: WriteToolOriginalState;
beforeStat?: WriteToolFileStat | null;
};
type EditToolParams = {
pathParam?: string;
edits: EditReplacement[];
@@ -21,10 +46,28 @@ type EditReplacement = {
const EDIT_MISMATCH_MESSAGE = "Could not find the exact text in";
const EDIT_MISMATCH_HINT_LIMIT = 800;
const WRITE_PRECHECK_READ_LIMIT_BYTES = 1024 * 1024;
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function resolveEditPath(root: string, pathParam: string): string {
function normalizeMutationPathLikeUpstreamWrite(pathParam: string): string {
let normalized = pathParam.replace(UNICODE_SPACES, " ");
if (normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
const home = resolveOsHomeDir();
const expanded = home ? expandHomePrefix(pathParam, { home }) : pathParam;
const expanded = home ? expandHomePrefix(normalized, { home }) : normalized;
if (expanded.startsWith("file://")) {
try {
return fileURLToPath(expanded);
} catch {
return expanded;
}
}
return expanded;
}
function resolveFileMutationPath(root: string, pathParam: string): string {
const expanded = normalizeMutationPathLikeUpstreamWrite(pathParam);
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
}
@@ -57,6 +100,14 @@ function readEditReplacements(record: Record<string, unknown> | undefined): Edit
});
}
function readWriteToolParams(params: unknown): WriteToolParams {
const record = getToolParamsRecord(params);
return {
pathParam: readStringParam(record, "path", "file_path", "filePath", "filepath", "file"),
content: typeof record?.content === "string" ? record.content : undefined,
};
}
function readEditToolParams(params: unknown): EditToolParams {
const record = getToolParamsRecord(params);
return {
@@ -128,6 +179,19 @@ function buildEditSuccessResult(pathParam: string, editCount: number): AgentTool
} as AgentToolResult<unknown>;
}
function buildWriteSuccessResult(pathParam: string, content: string): AgentToolResult<unknown> {
return {
isError: false,
content: [
{
type: "text",
text: `Successfully wrote ${content.length} bytes to ${pathParam}`,
},
],
details: undefined,
} as AgentToolResult<unknown>;
}
function shouldAddMismatchHint(error: unknown) {
return error instanceof Error && error.message.includes(EDIT_MISMATCH_MESSAGE);
}
@@ -142,6 +206,88 @@ function appendMismatchHint(error: Error, currentContent: string): Error {
return enhanced;
}
function isWriteRecoveryCandidate(error: unknown, signal: AbortSignal | undefined): boolean {
if (signal?.aborted) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
message.includes("timed out") ||
message.includes("timeout")
);
}
function isMissingFileError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
if ("code" in error && (error as { code?: unknown }).code === "ENOENT") {
return true;
}
return error instanceof Error && error.message.includes("No such file or directory");
}
async function readOriginalWriteState(
absolutePath: string,
content: string,
options: WriteToolRecoveryOptions,
): Promise<WriteToolPrecheck> {
if (!options.statFile) {
return { state: "unknown" };
}
const contentBytes = Buffer.byteLength(content, "utf8");
let stat: WriteToolFileStat | null;
try {
stat = await options.statFile(absolutePath);
} catch (err) {
return { state: isMissingFileError(err) ? "different" : "unknown" };
}
if (!stat) {
return { state: "different", beforeStat: stat };
}
if (stat.type !== "file") {
return { state: "unknown", beforeStat: stat };
}
if (stat.size !== contentBytes) {
return { state: "different", beforeStat: stat };
}
if (stat.size > WRITE_PRECHECK_READ_LIMIT_BYTES) {
return { state: "unknown", beforeStat: stat };
}
try {
const originalContent = await options.readFile(absolutePath);
return { state: originalContent === content ? "same" : "different", beforeStat: stat };
} catch {
return { state: "unknown", beforeStat: stat };
}
}
async function didWriteMetadataChange(
absolutePath: string,
beforeStat: WriteToolFileStat | null | undefined,
options: WriteToolRecoveryOptions,
): Promise<boolean> {
if (!beforeStat || !options.statFile) {
return false;
}
let afterStat: WriteToolFileStat | null;
try {
afterStat = await options.statFile(absolutePath);
} catch {
return false;
}
if (!afterStat || afterStat.type !== "file") {
return false;
}
return afterStat.size !== beforeStat.size || afterStat.mtimeMs !== beforeStat.mtimeMs;
}
/**
* Recover from two edit-tool failure classes without changing edit semantics:
* - exact-match mismatch errors become actionable by including current file contents
@@ -161,7 +307,9 @@ export function wrapEditToolWithRecovery(
) => {
const { pathParam, edits } = readEditToolParams(params);
const absolutePath =
typeof pathParam === "string" ? resolveEditPath(options.root, pathParam) : undefined;
typeof pathParam === "string"
? resolveFileMutationPath(options.root, pathParam)
: undefined;
let originalContent: string | undefined;
if (absolutePath && edits.length > 0) {
@@ -211,3 +359,59 @@ export function wrapEditToolWithRecovery(
},
};
}
/**
* Recover write calls that complete the disk write but abort before returning.
* Readback is the source of truth; argument-derived paths never prove success.
*/
export function wrapWriteToolWithRecovery(
base: AnyAgentTool,
options: WriteToolRecoveryOptions,
): AnyAgentTool {
return {
...base,
execute: async (
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate?: AgentToolUpdateCallback<unknown>,
) => {
const { pathParam, content } = readWriteToolParams(params);
const absolutePath =
typeof pathParam === "string" && typeof content === "string"
? resolveFileMutationPath(options.root, pathParam)
: undefined;
const precheck: WriteToolPrecheck =
absolutePath && typeof content === "string"
? await readOriginalWriteState(absolutePath, content, options)
: { state: "unknown" };
try {
return await base.execute(toolCallId, params, signal, onUpdate);
} catch (err) {
if (
!isWriteRecoveryCandidate(err, signal) ||
typeof absolutePath !== "string" ||
typeof pathParam !== "string" ||
typeof content !== "string"
) {
throw err;
}
let currentContent: string | undefined;
try {
currentContent = await options.readFile(absolutePath);
} catch {
// Fall through to the original abort if readback fails.
}
const changed =
precheck.state === "different" ||
(precheck.state === "unknown" &&
(await didWriteMetadataChange(absolutePath, precheck.beforeStat, options)));
if (currentContent === content && changed) {
return buildWriteSuccessResult(pathParam, content);
}
throw err;
}
},
};
}

View File

@@ -1,8 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { wrapEditToolWithRecovery } from "./pi-tools.host-edit.js";
import { pathToFileURL } from "node:url";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { wrapEditToolWithRecovery, wrapWriteToolWithRecovery } from "./pi-tools.host-edit.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxFsBridge, SandboxFsStat } from "./sandbox/fs-bridge.js";
@@ -334,3 +336,293 @@ describe("edit tool recovery hardening", () => {
expectRecoveredText(result, `Successfully replaced text in ${filePath}.`);
});
});
describe("write tool recovery hardening", () => {
let tmpDir = "";
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = "";
}
});
function createRecoveredWriteTool(params: {
root: string;
readFile: (absolutePath: string) => Promise<string>;
statFile?: Parameters<typeof wrapWriteToolWithRecovery>[1]["statFile"];
execute: AnyAgentTool["execute"];
}) {
const base = {
name: "write",
execute: params.execute,
} as unknown as AnyAgentTool;
return wrapWriteToolWithRecovery(base, {
root: params.root,
readFile: params.readFile,
statFile:
params.statFile ??
(async (absolutePath) => {
try {
const stat = await fs.stat(absolutePath);
return {
type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other",
size: stat.size,
mtimeMs: stat.mtimeMs,
};
} catch (err) {
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code?: unknown }).code === "ENOENT"
) {
return null;
}
throw err;
}
}),
});
}
it("recovers success after a post-write abort when readback matches requested content", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "demo.txt");
const controller = new AbortController();
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async (_toolCallId, params) => {
const record = params as { path: string; content: string };
await fs.writeFile(record.path, record.content, "utf-8");
controller.abort();
throw new Error("Operation aborted");
},
});
const result = await tool.execute(
"call-1",
{ path: filePath, content: "finished\n" },
controller.signal,
);
expect((result as { isError?: unknown }).isError).toBe(false);
expect(result.content[0]).toEqual({
type: "text",
text: `Successfully wrote ${"finished\n".length} bytes to ${filePath}`,
});
});
it("keeps the original abort when readback does not match requested content", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "demo.txt");
const controller = new AbortController();
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async () => {
await fs.writeFile(filePath, "partial\n", "utf-8");
controller.abort();
throw new Error("Operation aborted");
},
});
await expect(
tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal),
).rejects.toThrow("Operation aborted");
});
it("keeps the original abort when the file already matched before execution", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "demo.txt");
await fs.writeFile(filePath, "finished\n", "utf-8");
const controller = new AbortController();
controller.abort();
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async () => {
throw new Error("Operation aborted");
},
});
await expect(
tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal),
).rejects.toThrow("Operation aborted");
});
it("does not pre-read large same-size files on successful writes", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "large.txt");
const content = "x".repeat(1024 * 1024 + 1);
const readFile = vi.fn(async () => {
throw new Error("readFile should not run on the success path");
});
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile,
statFile: async () => ({ type: "file", size: Buffer.byteLength(content, "utf8") }),
execute: async () =>
({
isError: false,
content: [{ type: "text", text: "ok" }],
details: undefined,
}) as AgentToolResult<unknown>,
});
const result = await tool.execute("call-1", { path: filePath, content }, undefined);
expect((result as { isError?: unknown }).isError).toBe(false);
expect(readFile).not.toHaveBeenCalled();
});
it("recovers large same-size rewrites when timeout follows changed metadata", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "large.txt");
const content = "x".repeat(1024 * 1024 + 1);
const readFile = vi.fn(async () => content);
let statCall = 0;
const statFile = vi.fn(async () => {
statCall += 1;
return {
type: "file",
size: Buffer.byteLength(content, "utf8"),
mtimeMs: statCall,
} as const;
});
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile,
statFile,
execute: async () => {
throw new Error("node invoke timed out");
},
});
const result = await tool.execute("call-1", { path: filePath, content }, undefined);
expect((result as { isError?: unknown }).isError).toBe(false);
expect(readFile).toHaveBeenCalledTimes(1);
expect(statFile).toHaveBeenCalledTimes(2);
});
it("recovers new-file writes when pre-stat throws before a timeout", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "created.txt");
const content = "created\n";
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: async () => content,
statFile: async () => {
throw new Error("No such file or directory");
},
execute: async () => {
throw new Error("node invoke timed out");
},
});
const result = await tool.execute("call-1", { path: filePath, content }, undefined);
expect((result as { isError?: unknown }).isError).toBe(false);
});
it("keeps timeout when pre-stat fails for an unknown reason", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "demo.txt");
const content = "already there\n";
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: async () => content,
statFile: async () => {
throw new Error("stat bridge failed");
},
execute: async () => {
throw new Error("node invoke timed out");
},
});
await expect(tool.execute("call-1", { path: filePath, content }, undefined)).rejects.toThrow(
"node invoke timed out",
);
});
it("recovers @-prefixed write paths through the upstream write path contract", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "notes.md");
const controller = new AbortController();
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async (_toolCallId, params) => {
const record = params as { content: string };
await fs.writeFile(filePath, record.content, "utf-8");
controller.abort();
throw new Error("Operation aborted");
},
});
const result = await tool.execute(
"call-1",
{ path: "@notes.md", content: "finished\n" },
controller.signal,
);
expect((result as { isError?: unknown }).isError).toBe(false);
});
it("recovers timeout-like post-write errors when readback matches requested content", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "demo.txt");
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async (_toolCallId, params) => {
const record = params as { path: string; content: string };
await fs.writeFile(record.path, record.content, "utf-8");
throw new Error("node invoke timed out");
},
});
const result = await tool.execute(
"call-1",
{ path: filePath, content: "finished\n" },
undefined,
);
expect((result as { isError?: unknown }).isError).toBe(false);
});
it("recovers file URL write paths through the upstream write path contract", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-"));
const filePath = path.join(tmpDir, "notes.md");
const fileUrl = pathToFileURL(filePath).href;
const controller = new AbortController();
const tool = createRecoveredWriteTool({
root: tmpDir,
readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"),
execute: async (_toolCallId, params) => {
const record = params as { content: string };
await fs.writeFile(filePath, record.content, "utf-8");
controller.abort();
throw new Error("Operation aborted");
},
});
const result = await tool.execute(
"call-1",
{ path: fileUrl, content: "finished\n" },
controller.signal,
);
expect((result as { isError?: unknown }).isError).toBe(false);
});
});

View File

@@ -15,7 +15,7 @@ import { detectMime } from "../media/mime.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import type { ImageSanitizationLimits } from "./image-sanitization.js";
import { toRelativeWorkspacePath } from "./path-policy.js";
import { wrapEditToolWithRecovery } from "./pi-tools.host-edit.js";
import { wrapEditToolWithRecovery, wrapWriteToolWithRecovery } from "./pi-tools.host-edit.js";
import {
REQUIRED_PARAM_GROUPS,
assertRequiredParams,
@@ -239,7 +239,10 @@ function normalizeDailyMemoryReadPath(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
const normalized = value
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "");
return DAILY_MEMORY_PATH_RE.test(normalized) ? normalized : undefined;
}
@@ -802,7 +805,14 @@ export function createSandboxedWriteTool(params: SandboxToolParams) {
const base = createWriteTool(params.root, {
operations: createSandboxWriteOperations(params),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
const withRecovery = wrapWriteToolWithRecovery(base, {
root: params.root,
readFile: async (absolutePath: string) =>
(await params.bridge.readFile({ filePath: absolutePath, cwd: params.root })).toString("utf8"),
statFile: (absolutePath: string) =>
params.bridge.stat({ filePath: absolutePath, cwd: params.root }),
});
return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.write);
}
export function createSandboxedEditTool(params: SandboxToolParams) {
@@ -821,7 +831,32 @@ export function createHostWorkspaceWriteTool(root: string, options?: { workspace
const base = createWriteTool(root, {
operations: createHostWriteOperations(root, options),
}) as unknown as AnyAgentTool;
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
const withRecovery = wrapWriteToolWithRecovery(base, {
root,
readFile: (absolutePath: string) => fs.readFile(absolutePath, "utf-8"),
statFile: async (absolutePath: string) => {
let stat;
try {
stat = await fs.stat(absolutePath);
} catch (err) {
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code?: unknown }).code === "ENOENT"
) {
return null;
}
throw err;
}
return {
type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other",
size: stat.size,
mtimeMs: stat.mtimeMs,
};
},
});
return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.write);
}
export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) {

View File

@@ -16,7 +16,7 @@ export function buildStatPlan(
): SandboxFsCommandPlan {
return {
checks: [{ target, options: { action: "stat files" } }],
script: 'set -eu\ncd -- "$1"\nstat -c "%F|%s|%Y" -- "$2"',
script: 'set -eu\ncd -- "$1"\nstat -c "%F|%s|%y" -- "$2"',
args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
allowFailure: true,
};

View File

@@ -164,7 +164,7 @@ describe("sandbox fs bridge anchored ops", () => {
const target = getDockerArg(args, 1);
return dockerExecResult(`${target.replace("/workspace/alias", "/workspace/real")}\n`);
}
if (script.includes('stat -c "%F|%s|%Y"')) {
if (script.includes('stat -c "%F|%s|%y"')) {
return dockerExecResult("regular file|1|2");
}
return dockerExecResult("");
@@ -209,7 +209,7 @@ describe("sandbox fs bridge anchored ops", () => {
await bridge.stat({ filePath: "nested/file.txt" });
const statCall = findCallByScriptFragment('stat -c "%F|%s|%Y" -- "$2"');
const statCall = findCallByScriptFragment('stat -c "%F|%s|%y" -- "$2"');
const args = requireDockerCall(statCall, "stat")[0];
expect(getDockerArg(args, 1)).toBe("/workspace/nested");
expect(getDockerArg(args, 2)).toBe("file.txt");

View File

@@ -172,7 +172,7 @@ function installDockerReadMock(params?: { canonicalPath?: string }) {
if (script.includes('readlink -f -- "$cursor"')) {
return dockerExecResult(`${canonicalPath ?? getDockerArg(args, 1)}\n`);
}
if (script.includes('stat -c "%F|%s|%Y"')) {
if (script.includes('stat -c "%F|%s|%y"')) {
return dockerExecResult("regular file|1|2");
}
if (script.includes('cat -- "$1"')) {

View File

@@ -36,6 +36,15 @@ export function createSandboxFsBridge(params: {
return new SandboxFsBridgeImpl(params.sandbox);
}
function parseStatMtimeMs(value: string | undefined): number {
const raw = value ?? "0";
if (/^\d+(?:\.\d+)?$/.test(raw)) {
return Number(raw) * 1000;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
class SandboxFsBridgeImpl implements SandboxFsBridge {
private readonly sandbox: SandboxFsBridgeContext;
private readonly mounts: ReturnType<typeof buildSandboxFsMounts>;
@@ -208,11 +217,10 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
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,
mtimeMs: parseStatMtimeMs(mtimeRaw),
};
}

View File

@@ -15,6 +15,15 @@ import {
} from "./path-utils.js";
import { isExistingWorkspaceSkillMountSource } from "./workspace-mounts.js";
function parseStatMtimeMs(value: string | undefined): number {
const raw = value ?? "0";
if (/^\d+(?:\.\d+)?$/.test(raw)) {
return Number(raw) * 1000;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
type RemoteMountSource = "workspace" | "agent" | "protectedSkill";
type ResolvedRemotePath = SandboxResolvedPath & {
@@ -235,7 +244,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
signal: params.signal,
});
const result = await this.runRemoteScript({
script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"',
script: 'set -eu\nstat -c "%F|%s|%y" -- "$1"',
args: [canonical],
signal: params.signal,
});
@@ -244,7 +253,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
return {
type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other",
size: Number(sizeRaw),
mtimeMs: Number(mtimeRaw) * 1000,
mtimeMs: parseStatMtimeMs(mtimeRaw),
};
}

View File

@@ -5,7 +5,13 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
const helperPath = path.resolve("scripts/lib/openclaw-e2e-instance.sh");
const hostPath = process.env.PATH?.trim() || "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
const hostPath = [
path.dirname(process.execPath),
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/bin",
"/bin",
].join(path.delimiter);
function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'\\''`)}'`;
@@ -65,6 +71,16 @@ function writePackageFixture(packagePath: string): void {
}
}
function writeNodeShim(binDir: string): void {
const nodePath = path.join(binDir, "node");
try {
fs.symlinkSync(process.execPath, nodePath);
} catch {
fs.writeFileSync(nodePath, `#!/bin/sh\nexec ${shellQuote(process.execPath)} "$@"\n`);
fs.chmodSync(nodePath, 0o755);
}
}
describe("scripts/lib/openclaw-e2e-instance.sh", () => {
it("sources decoded test-state scripts", () => {
const result = runHelper(base64('export OPENCLAW_E2E_INSTANCE_TEST="ok"\n'));
@@ -107,7 +123,9 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
@@ -136,6 +154,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_NPM_ARGS: npmArgsPath,
OPENCLAW_TEST_NPM_BIN: path.join(tempDir, "npm"),
}),
},
);
@@ -171,7 +190,9 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
@@ -200,6 +221,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_NPM_ARGS: npmArgsPath,
OPENCLAW_TEST_NPM_BIN: path.join(tempDir, "npm"),
}),
},
);
@@ -234,7 +256,9 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
@@ -263,6 +287,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_NPM_ARGS: npmArgsPath,
OPENCLAW_TEST_NPM_BIN: path.join(tempDir, "npm"),
}),
},
);
@@ -285,8 +310,8 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
const npmArgsPath = path.join(tempDir, "npm-args.txt");
const logPath = path.join(tempDir, "install.log");
const packagePath = path.join(tempDir, "openclaw.tgz");
const nodeBinDir = path.dirname(process.execPath);
writePackageFixture(packagePath);
writeNodeShim(tempDir);
fs.writeFileSync(
path.join(tempDir, "npm"),
["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"),
@@ -306,7 +331,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{
encoding: "utf8",
env: shellTestEnv({
PATH: `${tempDir}:${nodeBinDir}`,
PATH: tempDir,
OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath,
OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s",
OPENCLAW_TEST_NPM_ARGS: npmArgsPath,
@@ -327,7 +352,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
it("bounds commands with the Node watchdog when timeout is unavailable", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-instance-node-watchdog-"));
try {
const nodeBinDir = path.dirname(process.execPath);
writeNodeShim(tempDir);
const startedAt = Date.now();
const result = spawnSync(
"/bin/bash",
@@ -342,7 +367,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{
encoding: "utf8",
env: shellTestEnv({
PATH: `${tempDir}:${nodeBinDir}`,
PATH: tempDir,
}),
timeout: 5_000,
},
@@ -416,9 +441,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [ "${1:-}" = "--kill-after=1s" ]; then exit 0; fi',
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "fixture-command" ]; do shift; done',
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_COMMAND_BIN" "$@"',
"",
].join("\n"),
);
@@ -448,10 +476,11 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{
encoding: "utf8",
env: shellTestEnv({
PATH: `${tempDir}:${process.env.PATH ?? ""}`,
PATH: `${tempDir}:${hostPath}`,
OPENCLAW_E2E_COMMAND_TIMEOUT: "17s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_COMMAND_ARGS: commandArgsPath,
OPENCLAW_TEST_COMMAND_BIN: path.join(tempDir, "fixture-command"),
}),
},
);
@@ -478,9 +507,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [ "${1:-}" = "--kill-after=1s" ]; then exit 0; fi',
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
`while [ "$#" -gt 0 ] && [ "$1" != ${shellQuote(path.join(tempDir, "openclaw"))} ]; do shift; done`,
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_OPENCLAW_BIN" "$@"',
"",
].join("\n"),
);
@@ -511,10 +543,11 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{
encoding: "utf8",
env: shellTestEnv({
PATH: `${tempDir}:${process.env.PATH ?? ""}`,
PATH: `${tempDir}:${hostPath}`,
OPENCLAW_E2E_COMMAND_TIMEOUT: "23s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_COMMAND_ARGS: commandArgsPath,
OPENCLAW_TEST_OPENCLAW_BIN: path.join(tempDir, "openclaw"),
}),
},
);
@@ -540,9 +573,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [ "${1:-}" = "--kill-after=1s" ]; then exit 0; fi',
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "script" ]; do shift; done',
'exec "$@"',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_SCRIPT_BIN" "$@"',
"",
].join("\n"),
);
@@ -572,10 +608,11 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{
encoding: "utf8",
env: shellTestEnv({
PATH: `${tempDir}:${process.env.PATH ?? ""}`,
PATH: `${tempDir}:${hostPath}`,
OPENCLAW_E2E_COMMAND_TIMEOUT: "31s",
OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath,
OPENCLAW_TEST_SCRIPT_ARGS: scriptArgsPath,
OPENCLAW_TEST_SCRIPT_BIN: path.join(tempDir, "script"),
}),
},
);