Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/decompress-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type SearchDoc,
type SearchResult,
} from "acp-kernel";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -247,11 +248,11 @@ export function resolveDecompress(
const toFileOut = toFilePointer(args, ctx, header, body);
if (toFileOut !== null) return toFileOut;
const safeBlockId = blockId.replace(/[^a-zA-Z0-9_-]/g, "-");
const outPath = body.length > 10000 ? join(tmpdir(), `acp-decompress-${safeBlockId}-${Date.now()}.txt`) : null;
const outPath = body.length > 10000 ? join(tmpdir(), `acp-decompress-${safeBlockId}-${randomUUID()}.txt`) : null;
if (outPath) {
try {
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600 });
writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600, flag: "wx" });
trackedTempFiles.push({ path: outPath, mtimeMs: Date.now() });
reapTempFiles();
return toolOk(`${header}\nContent (${body.length} chars) written to: ${outPath}\nUse the read tool to access it.`);
Expand Down Expand Up @@ -451,13 +452,13 @@ function resolveDecompressRange(args: Record<string, unknown>, ctx: ProxyToolCtx
injText = toFileOut.text;
} else if (body.length > 10000) {
const safeBlockId = block.blockId.replace(/[^a-zA-Z0-9_-]/g, "-");
// [#1207 review F4] Span in the filename (two spans of one block in the
// same millisecond must not clobber each other) and 0600 (folded
// conversation content is not world-readable on multi-user hosts).
const outPath = join(tmpdir(), `acp-decompress-${safeBlockId}-${startRaw}-${endRaw}-${Date.now()}.txt`);
// [#1207 review F4] Span in the filename; each spill owns its path even when sessions
// reuse block and span ids in the same millisecond (#6191). 0600: folded conversation
// content is not world-readable on multi-user hosts.
const outPath = join(tmpdir(), `acp-decompress-${safeBlockId}-${startRaw}-${endRaw}-${randomUUID()}.txt`);
try {
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600 });
writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600, flag: "wx" });
trackedTempFiles.push({ path: outPath, mtimeMs: Date.now() });
reapTempFiles();
injText = `${header}\nContent (${body.length} chars) written to: ${outPath}\nUse the read tool to access it.`;
Expand Down
49 changes: 49 additions & 0 deletions tests/fix-decompress-tofile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,52 @@ test("#1691 range toFile: writes the span to the caller path and queues only a l
rmrf(scratch);
}
});

test("automatic whole-block spills keep different views written in one millisecond", (t) => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "bili-tofile-unique-"));
const tempKey = process.platform === "win32" ? "TEMP" : "TMPDIR";
const previousTmp = process.env[tempKey];
process.env[tempKey] = scratch;
try {
const { ctx, session, blockId } = foldBlock("unique-views", 8);
const one = "one-level view\n".repeat(1_000);
const full = "full original view\n".repeat(1_000);
session.blockContents.set(blockId, { one: { text: one, count: 1 }, full: { text: full, count: 2 } });
const now = Date.now();
t.mock.method(Date, "now", () => now);
const first = resolveDecompress({ blockId }, ctx).text.match(/written to: (.+)$/m)?.[1];
const second = resolveDecompress({ blockId, full: true }, ctx).text.match(/written to: (.+)$/m)?.[1];
assert.ok(first);
assert.ok(second);
assert.notEqual(first, second, "each export owns its file even when the clock has not advanced");
assert.equal(fs.readFileSync(first, "utf8"), one);
assert.equal(fs.readFileSync(second, "utf8"), full);
} finally {
if (previousTmp === undefined) delete process.env[tempKey];
else process.env[tempKey] = previousTmp;
rmrf(scratch);
}
});

test("automatic range spills do not register the same path twice in one millisecond", (t) => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "bili-tofile-range-unique-"));
const tempKey = process.platform === "win32" ? "TEMP" : "TMPDIR";
const previousTmp = process.env[tempKey];
process.env[tempKey] = scratch;
try {
const f = foldCcr();
const ctx = { core: f.core, config: f.config, messages: f.msgs, session: f.session, log: () => {} };
const now = Date.now();
t.mock.method(Date, "now", () => now);
for (let i = 0; i < 2; i++) {
assert.equal(resolveDecompress({ blockId: f.blockId, startId: "m00001", endId: "m00007" }, ctx).outcome, "success");
}
const files = fs.readdirSync(scratch).filter((name) => name.startsWith("acp-decompress-"));
assert.equal(files.length, 2, "each retention entry refers to a distinct file");
for (const file of files) assert.match(fs.readFileSync(path.join(scratch, file), "utf8"), /Historical detail 0\./);
} finally {
if (previousTmp === undefined) delete process.env[tempKey];
else process.env[tempKey] = previousTmp;
rmrf(scratch);
}
});
Loading