Skip to content

Commit be48a43

Browse files
fix(tools-manager): replace spawnSync extraction with safe extractArchive API (#98988)
* fix(tools-manager): replace spawnSync extraction with safe extractArchive API Replace the synchronous spawnSync-based archive extraction (unzip/tar) with the safe extractArchive from @openclaw/fs-safe, which enforces: - maxArchiveBytes: 100 MB (prevents oversized compressed input) - maxExtractedBytes: 500 MB (prevents decompression bomb OOM) - maxEntries: 1000 (prevents zip bomb from exhausting inodes) - timeoutMs: 60,000 (prevents hung extraction) - Path traversal and symlink/hardlink protection (built-in) Removes 5 functions (formatSpawnFailure, runExtractionCommand, extractTarGzArchive, getWindowsTarCommand, extractZipArchive) and their associated imports, replacing them with a single async wrapper around the existing @openclaw/fs-safe infrastructure (re-exported from src/infra/archive.ts). Net: -83 lines, + security boundaries across all platforms. * fix(tools-manager): add download-stream byte cap and regression tests Add a maxBytes parameter to downloadFile that checks Content-Length before reading the body and enforces a streaming byte cap during transfer, so oversized archives are rejected before hitting disk. Extract MAX_ARCHIVE_BYTES as a module-level constant shared between downloadFile and extractArchiveSafe, ensuring both gates use the same 100 MB limit. Add two regression tests: - rejects downloads with Content-Length exceeding the archive byte cap - accepts downloads with Content-Length under the archive byte cap Ref. #98988 * fix(tools-manager): replace PassThrough with Transform stream limiter - Replace PassThrough data listener with Transform that rejects overflow chunks *before* they are forwarded to the file pipeline, preventing the offending chunk from landing on disk. - Add completion guard and cleanup of partial downloads on failure so downloadTool does not leave a partial archive on disk. - Add real behavior proof: test output and live fd/rg download and extraction through the safe extractArchive code path. 🦞 diamond lobster: L2 evidence (real function calls + real objects) Ref. #98988 * fix(tools-manager): replace PassThrough with Transform stream limiter Uses a Transform to reject overflow chunks *before* they are forwarded to the file pipeline (a PassThrough data listener acts *after* emission and cannot prevent the offending chunk from landing on disk). Wraps the pipeline in a completion-guarded block so that partial downloads are removed when the transfer fails mid-way. Ref. #98988 * fix(agents): harden helper archive downloads * fix(agents): preserve archive extraction cause --------- Co-authored-by: Vincent Koc <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 23f492f commit be48a43

3 files changed

Lines changed: 170 additions & 137 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222

2323
### Fixes
2424

25+
- **Agent helper downloads:** bound fd and ripgrep archive downloads and extraction with declared and streamed byte caps, extraction limits, timeouts, traversal-safe unpacking, and partial-file cleanup. (#98988) Thanks @LeonidasLux.
2526
- **OpenAI Realtime Codex auth:** reuse external Codex OAuth profiles for Realtime voice sessions when no explicit OpenAI API key is configured.
2627
- **OpenAI-compatible TTS voice notes:** route configured MP3 speech output through native voice-message delivery when the channel supports it, while keeping WAV output on the audio-file path. (#83227, #80317) Thanks @HemantSudarshan.
2728
- **Talk transcription providers:** cold-load explicitly configured Voice Call streaming providers, including runtime aliases, when another provider registry is already active, keeping catalog and session selection aligned. (#97170, #97738) Thanks @solavrc.

src/agents/utils/tools-manager.test.ts

Lines changed: 94 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
66

77
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
88
const spawnSyncMock = vi.hoisted(() => vi.fn());
9+
const extractArchiveMock = vi.hoisted(() => vi.fn());
910

1011
vi.mock("../../infra/net/fetch-guard.js", () => ({
1112
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
@@ -16,6 +17,10 @@ vi.mock("node:child_process", async (importOriginal) => ({
1617
spawnSync: spawnSyncMock,
1718
}));
1819

20+
vi.mock("../../infra/archive.js", () => ({
21+
extractArchive: extractArchiveMock,
22+
}));
23+
1924
let originalAgentDir: string | undefined;
2025
let tempAgentDir: string | undefined;
2126

@@ -24,6 +29,7 @@ beforeEach(() => {
2429
tempAgentDir = mkdtempSync(join(tmpdir(), "openclaw-tools-manager-"));
2530
setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
2631
fetchWithSsrFGuardMock.mockReset();
32+
extractArchiveMock.mockReset();
2733
spawnSyncMock.mockReturnValue({
2834
error: new Error("ENOENT"),
2935
status: null,
@@ -89,7 +95,7 @@ describe("ensureTool", () => {
8995
expect(downloadRelease).toHaveBeenCalledOnce();
9096
});
9197

92-
it("extracts Windows zip downloads with trusted System32 tools", async () => {
98+
it("extracts Windows zip downloads via safe archive API with size limits", async () => {
9399
vi.doMock("node:os", async (importOriginal) => ({
94100
...(await importOriginal<typeof import("node:os")>()),
95101
arch: () => "x64",
@@ -99,6 +105,9 @@ describe("ensureTool", () => {
99105
const { ensureTool } = await import("./tools-manager.js");
100106
const releaseCheckRelease = vi.fn(async () => {});
101107
const downloadRelease = vi.fn(async () => {});
108+
extractArchiveMock.mockImplementation(async (params: { destDir: string }) => {
109+
writeFileSync(join(params.destDir, "rg.exe"), "binary");
110+
});
102111
fetchWithSsrFGuardMock
103112
.mockResolvedValueOnce({
104113
response: new Response(JSON.stringify({ tag_name: "14.1.1" }), { status: 200 }),
@@ -110,50 +119,94 @@ describe("ensureTool", () => {
110119
release: downloadRelease,
111120
finalUrl: "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/archive.zip",
112121
});
113-
spawnSyncMock.mockImplementation((command: string, args: string[]) => {
114-
if (command === "C:\\Windows\\System32\\tar.exe") {
115-
return {
116-
error: undefined,
117-
status: 1,
118-
stderr: Buffer.from("tar failed"),
119-
stdout: Buffer.alloc(0),
120-
};
121-
}
122-
if (command === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") {
123-
const extractDir = args.at(-1);
124-
if (!extractDir) {
125-
throw new Error("expected extraction destination");
126-
}
127-
writeFileSync(join(extractDir, "rg.exe"), "binary");
128-
return {
129-
error: undefined,
130-
status: 0,
131-
stderr: Buffer.alloc(0),
132-
stdout: Buffer.alloc(0),
133-
};
134-
}
135-
return {
136-
error: new Error(`unexpected command: ${command}`),
137-
status: null,
138-
stderr: Buffer.alloc(0),
139-
stdout: Buffer.alloc(0),
140-
};
141-
});
142122

143123
await expect(ensureTool("rg", true)).resolves.toBe(join(tempAgentDir!, "bin", "rg.exe"));
144124

145-
expect(spawnSyncMock).toHaveBeenNthCalledWith(
146-
2,
147-
"C:\\Windows\\System32\\tar.exe",
148-
expect.any(Array),
149-
{ stdio: "pipe" },
125+
expect(extractArchiveMock).toHaveBeenCalledOnce();
126+
expect(extractArchiveMock).toHaveBeenCalledWith(
127+
expect.objectContaining({
128+
archivePath: expect.stringContaining(".zip"),
129+
destDir: expect.stringContaining("extract_tmp_rg_"),
130+
timeoutMs: 60_000,
131+
limits: {
132+
maxArchiveBytes: 100 * 1024 * 1024,
133+
maxExtractedBytes: 500 * 1024 * 1024,
134+
maxEntries: 1_000,
135+
},
136+
}),
150137
);
151-
expect(spawnSyncMock).toHaveBeenNthCalledWith(
152-
3,
153-
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
154-
expect.any(Array),
155-
{ stdio: "pipe" },
138+
});
139+
140+
it("rejects downloads whose declared size exceeds the byte cap", async () => {
141+
const response = new Response("oversized-body", {
142+
status: 200,
143+
headers: { "content-length": "11" },
144+
});
145+
const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
146+
const release = vi.fn(async () => {});
147+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
148+
response,
149+
release,
150+
finalUrl: "https://example.com/archive.tar.gz",
151+
});
152+
const destination = join(tempAgentDir!, "archive.tar.gz");
153+
const { testing } = await import("./tools-manager.js");
154+
155+
await expect(
156+
testing.downloadFile("https://example.com/archive.tar.gz", destination, 10),
157+
).rejects.toThrow("Download exceeds the 10-byte archive limit");
158+
159+
expect(cancel).toHaveBeenCalledOnce();
160+
expect(release).toHaveBeenCalledOnce();
161+
expect(existsSync(destination)).toBe(false);
162+
});
163+
164+
it("rejects streamed bytes above the cap and removes the partial file", async () => {
165+
const response = new Response(
166+
new ReadableStream<Uint8Array>({
167+
start(controller) {
168+
controller.enqueue(new Uint8Array([1, 2, 3, 4, 5, 6]));
169+
controller.enqueue(new Uint8Array([7, 8, 9, 10, 11, 12]));
170+
controller.close();
171+
},
172+
}),
173+
{ status: 200, headers: { "content-length": "6" } },
156174
);
175+
const release = vi.fn(async () => {});
176+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
177+
response,
178+
release,
179+
finalUrl: "https://example.com/archive.tar.gz",
180+
});
181+
const destination = join(tempAgentDir!, "archive.tar.gz");
182+
const { testing } = await import("./tools-manager.js");
183+
184+
await expect(
185+
testing.downloadFile("https://example.com/archive.tar.gz", destination, 10),
186+
).rejects.toThrow("Download exceeded the 10-byte archive limit");
187+
188+
expect(release).toHaveBeenCalledOnce();
189+
expect(existsSync(destination)).toBe(false);
190+
});
191+
192+
it("accepts downloads exactly at the byte cap", async () => {
193+
const body = new Uint8Array([1, 2, 3, 4]);
194+
const release = vi.fn(async () => {});
195+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
196+
response: new Response(body, {
197+
status: 200,
198+
headers: { "content-length": String(body.byteLength) },
199+
}),
200+
release,
201+
finalUrl: "https://example.com/archive.tar.gz",
202+
});
203+
const destination = join(tempAgentDir!, "archive.tar.gz");
204+
const { testing } = await import("./tools-manager.js");
205+
206+
await testing.downloadFile("https://example.com/archive.tar.gz", destination, body.byteLength);
207+
208+
expect(release).toHaveBeenCalledOnce();
209+
expect(readFileSync(destination)).toEqual(Buffer.from(body));
157210
});
158211
});
159212

0 commit comments

Comments
 (0)