Skip to content

Commit b6e354f

Browse files
committed
fix(file-transfer): handle late tar pipe errors
1 parent d1577a2 commit b6e354f

4 files changed

Lines changed: 120 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
2626
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
2727
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
28+
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
2829
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
2930
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
3031

extensions/file-transfer/src/shared/node-invoke-policy.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,28 @@ async function listDirFetchArchiveEntries(
367367
child.on("error", (error) => {
368368
clearTimeout(watchdog);
369369
if (!aborted) {
370+
aborted = true;
370371
resolve({
371372
ok: false,
372373
code: "ARCHIVE_ENTRIES_UNREADABLE",
373374
reason: `tar -tzf error: ${String(error)}`,
374375
});
375376
}
376377
});
378+
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
379+
if (aborted && error.code === "EPIPE") {
380+
return;
381+
}
382+
clearTimeout(watchdog);
383+
if (!aborted) {
384+
aborted = true;
385+
resolve({
386+
ok: false,
387+
code: "ARCHIVE_ENTRIES_UNREADABLE",
388+
reason: `tar -tzf input error: ${String(error)}`,
389+
});
390+
}
391+
});
377392
child.stdin.end(tarBuffer);
378393
});
379394
}

extensions/file-transfer/src/tools/dir-fetch-tool.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { EventEmitter } from "node:events";
12
import { spawn } from "node:child_process";
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
5-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
67
import { validateTarUncompressedBudget } from "./dir-fetch-tool.js";
78

89
let tmpRoot: string;
@@ -57,3 +58,49 @@ describe("validateTarUncompressedBudget", () => {
5758
},
5859
);
5960
});
61+
62+
describe("dir.fetch tar validation", () => {
63+
it("ignores late stdin EPIPE after tar listing has already settled", async () => {
64+
vi.resetModules();
65+
vi.doMock("node:child_process", async (importOriginal) => {
66+
const actual = await importOriginal<typeof import("node:child_process")>();
67+
return {
68+
...actual,
69+
spawn: vi.fn(() => {
70+
const child = new EventEmitter() as EventEmitter & {
71+
kill: ReturnType<typeof vi.fn>;
72+
stderr: EventEmitter;
73+
stdin: EventEmitter & { end: () => void };
74+
stdout: EventEmitter;
75+
};
76+
const stdout = new EventEmitter();
77+
const stderr = new EventEmitter();
78+
const stdin = new EventEmitter() as EventEmitter & { end: () => void };
79+
child.stdout = stdout;
80+
child.stderr = stderr;
81+
child.stdin = stdin;
82+
child.kill = vi.fn();
83+
stdin.end = () => {
84+
queueMicrotask(() => {
85+
stderr.emit("data", Buffer.from("invalid archive"));
86+
child.emit("close", 2);
87+
stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
88+
});
89+
};
90+
return child;
91+
}),
92+
};
93+
});
94+
95+
try {
96+
const { testing } = await import("./dir-fetch-tool.js");
97+
await expect(testing.preValidateTarball(Buffer.from("x"))).resolves.toEqual({
98+
ok: false,
99+
reason: "tar -tzf exited 2: invalid archive",
100+
});
101+
} finally {
102+
vi.doUnmock("node:child_process");
103+
vi.resetModules();
104+
}
105+
});
106+
});

extensions/file-transfer/src/tools/dir-fetch-tool.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,20 @@ async function listTarPaths(
139139
child.on("error", (e) => {
140140
clearTimeout(watchdog);
141141
if (!aborted) {
142+
aborted = true;
142143
resolve({ ok: false, reason: `tar -tzf error: ${String(e)}` });
143144
}
144145
});
146+
child.stdin.on("error", (e: NodeJS.ErrnoException) => {
147+
if (aborted && e.code === "EPIPE") {
148+
return;
149+
}
150+
clearTimeout(watchdog);
151+
if (!aborted) {
152+
aborted = true;
153+
resolve({ ok: false, reason: `tar -tzf input error: ${String(e)}` });
154+
}
155+
});
145156
child.stdin.end(tarBuffer);
146157
});
147158
}
@@ -201,9 +212,20 @@ async function listTarTypeChars(
201212
child.on("error", (e) => {
202213
clearTimeout(watchdog);
203214
if (!aborted) {
215+
aborted = true;
204216
resolve({ ok: false, reason: `tar -tzvf error: ${String(e)}` });
205217
}
206218
});
219+
child.stdin.on("error", (e: NodeJS.ErrnoException) => {
220+
if (aborted && e.code === "EPIPE") {
221+
return;
222+
}
223+
clearTimeout(watchdog);
224+
if (!aborted) {
225+
aborted = true;
226+
resolve({ ok: false, reason: `tar -tzvf input error: ${String(e)}` });
227+
}
228+
});
207229
child.stdin.end(tarBuffer);
208230
});
209231
}
@@ -386,28 +408,50 @@ async function unpackTar(tarBuffer: Buffer, destDir: string): Promise<void> {
386408
},
387409
);
388410
let stderrOut = "";
389-
const watchdog = setTimeout(() => {
411+
let settled = false;
412+
let watchdog: ReturnType<typeof setTimeout>;
413+
const fail = (error: Error): void => {
414+
if (settled) {
415+
return;
416+
}
417+
settled = true;
418+
clearTimeout(watchdog);
419+
reject(error);
420+
};
421+
const succeed = (): void => {
422+
if (settled) {
423+
return;
424+
}
425+
settled = true;
426+
clearTimeout(watchdog);
427+
resolve();
428+
};
429+
watchdog = setTimeout(() => {
390430
try {
391431
child.kill("SIGKILL");
392432
} catch {
393433
/* already gone */
394434
}
395-
reject(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`));
435+
fail(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`));
396436
}, TAR_UNPACK_TIMEOUT_MS);
397437
child.stderr.on("data", (chunk: Buffer) => {
398438
stderrOut += chunk.toString();
399439
});
400440
child.on("close", (code) => {
401-
clearTimeout(watchdog);
402441
if (code !== 0) {
403-
reject(new Error(`tar unpack exited ${code}: ${stderrOut.slice(0, 300)}`));
442+
fail(new Error(`tar unpack exited ${code}: ${stderrOut.slice(0, 300)}`));
404443
return;
405444
}
406-
resolve();
445+
succeed();
407446
});
408447
child.on("error", (e) => {
409-
clearTimeout(watchdog);
410-
reject(e);
448+
fail(e);
449+
});
450+
child.stdin.on("error", (e: NodeJS.ErrnoException) => {
451+
if (settled && e.code === "EPIPE") {
452+
return;
453+
}
454+
fail(e);
411455
});
412456
child.stdin.end(tarBuffer);
413457
});
@@ -677,3 +721,8 @@ export function createDirFetchTool(): AnyAgentTool {
677721
},
678722
};
679723
}
724+
725+
export const testing = {
726+
preValidateTarball,
727+
validateTarUncompressedBudget,
728+
};

0 commit comments

Comments
 (0)