mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
* 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:
@@ -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."],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"')) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user