@crewhaus/tool-result-store 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +39 -0
- package/dist/index.js +119 -0
- package/package.json +12 -9
- package/src/index.test.ts +0 -299
- package/src/index.ts +0 -156
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolExecuteResult } from "@crewhaus/tool-catalog";
|
|
2
|
+
import type { ToolResult } from "@crewhaus/tool-executor";
|
|
3
|
+
export type StoreOptions = {
|
|
4
|
+
readonly runId: string;
|
|
5
|
+
readonly toolUseId: string;
|
|
6
|
+
readonly thresholdBytes?: number;
|
|
7
|
+
readonly previewLines?: number;
|
|
8
|
+
readonly rootDir?: string;
|
|
9
|
+
};
|
|
10
|
+
export type StoredResult = {
|
|
11
|
+
readonly previewContent: ToolExecuteResult;
|
|
12
|
+
readonly fullPath: string | null;
|
|
13
|
+
readonly persisted: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare const DEFAULT_THRESHOLD_BYTES = 10240;
|
|
16
|
+
export declare const DEFAULT_PREVIEW_LINES = 100;
|
|
17
|
+
export declare const DEFAULT_ROOT_DIR = ".crewhaus/tool-results";
|
|
18
|
+
/**
|
|
19
|
+
* If `result.content` is at or under threshold, return it unchanged.
|
|
20
|
+
* Otherwise persist the full text under `<rootDir>/<runId>/<toolUseId>.txt`
|
|
21
|
+
* and return `{ previewContent, fullPath, persisted: true }`. Errors
|
|
22
|
+
* (`isError: true`) follow the same path so large stack traces are
|
|
23
|
+
* captured.
|
|
24
|
+
*/
|
|
25
|
+
export declare function storeAndPreview(result: ToolResult, opts: StoreOptions): Promise<StoredResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Defence-in-depth boundary check: assert that a resolved absolute path lives
|
|
28
|
+
* strictly under `root`. Redundant with `rejectUnsafeSegment` on the normal
|
|
29
|
+
* code path (which already strips separators and `..`), but kept — and exported
|
|
30
|
+
* so it can be exercised directly — so a future caller that resolves a path
|
|
31
|
+
* some other way can't silently escape the storage root.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assertUnderRoot(abs: string, root: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Used by tests to confirm the resolved storage location for a given
|
|
36
|
+
* runId/toolUseId pair. Performs the same traversal rejection as
|
|
37
|
+
* `storeAndPreview()` so callers see the same errors.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveStoragePath(runId: string, toolUseId: string, rootDir?: string): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R3 `tool-result-store` — persist large tool outputs to disk
|
|
3
|
+
* and return a preview the model can re-read via the `Read` tool. This
|
|
4
|
+
* keeps wide messages out of the conversation context (cheaper, faster)
|
|
5
|
+
* while preserving full output for any later look-up.
|
|
6
|
+
*
|
|
7
|
+
* Default policy:
|
|
8
|
+
* - Threshold: 10240 bytes (10 KB) of UTF-8.
|
|
9
|
+
* - Storage path: `<rootDir>/<runId>/<toolUseId>.txt`
|
|
10
|
+
* (rootDir defaults to `.crewhaus/tool-results` under cwd).
|
|
11
|
+
* - Preview: first 100 lines + `[truncated, full output at <fullPath>]`
|
|
12
|
+
*
|
|
13
|
+
* Idempotent writes: the file is created with `flag: "wx"` (write-
|
|
14
|
+
* exclusive). If a previous run already wrote the same `(runId,
|
|
15
|
+
* toolUseId)` pair we treat that as success and reuse the existing file.
|
|
16
|
+
*
|
|
17
|
+
* Path traversal: `runId` and `toolUseId` are joined under `rootDir`
|
|
18
|
+
* after rejecting any value containing path separators or `..` to
|
|
19
|
+
* prevent a tool-result from escaping its run directory.
|
|
20
|
+
*
|
|
21
|
+
* Reference: `claude-code/utils/toolResultStorage.ts` — uses
|
|
22
|
+
* `<persisted-output>` XML wrappers, a per-tool size budget, and a
|
|
23
|
+
* GrowthBook flag to override per tool. We collapse to a single global
|
|
24
|
+
* threshold and a plain-text marker.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
27
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
28
|
+
import { RuntimeError } from "@crewhaus/errors";
|
|
29
|
+
import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
|
|
30
|
+
// When a tenant context is active, fail closed on a resolved storage path that
|
|
31
|
+
// escapes the tenant's toolResultRoot (CWE-1230). Outside a tenant scope (the
|
|
32
|
+
// common CLI case) this is a no-op so non-tenant behaviour is unchanged.
|
|
33
|
+
function fence(absPath) {
|
|
34
|
+
if (currentTenantContext() !== undefined) {
|
|
35
|
+
assertSamePath(absPath, requireTenant().toolResultRoot);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const DEFAULT_THRESHOLD_BYTES = 10240;
|
|
39
|
+
export const DEFAULT_PREVIEW_LINES = 100;
|
|
40
|
+
export const DEFAULT_ROOT_DIR = ".crewhaus/tool-results";
|
|
41
|
+
/**
|
|
42
|
+
* If `result.content` is at or under threshold, return it unchanged.
|
|
43
|
+
* Otherwise persist the full text under `<rootDir>/<runId>/<toolUseId>.txt`
|
|
44
|
+
* and return `{ previewContent, fullPath, persisted: true }`. Errors
|
|
45
|
+
* (`isError: true`) follow the same path so large stack traces are
|
|
46
|
+
* captured.
|
|
47
|
+
*/
|
|
48
|
+
export async function storeAndPreview(result, opts) {
|
|
49
|
+
const thresholdBytes = opts.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
|
|
50
|
+
const previewLines = opts.previewLines ?? DEFAULT_PREVIEW_LINES;
|
|
51
|
+
const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
|
|
52
|
+
// Section 14 — non-string content (image content arrays) bypasses
|
|
53
|
+
// persistence entirely. The blocks are forwarded as-is to the model so
|
|
54
|
+
// it can see the image; size capping happens inside the producing tool
|
|
55
|
+
// (e.g. tool-image enforces a 5 MB per-image limit on disk).
|
|
56
|
+
if (typeof result.content !== "string") {
|
|
57
|
+
return { previewContent: result.content, fullPath: null, persisted: false };
|
|
58
|
+
}
|
|
59
|
+
const byteLength = Buffer.byteLength(result.content, "utf8");
|
|
60
|
+
if (byteLength <= thresholdBytes) {
|
|
61
|
+
return { previewContent: result.content, fullPath: null, persisted: false };
|
|
62
|
+
}
|
|
63
|
+
rejectUnsafeSegment("runId", opts.runId);
|
|
64
|
+
rejectUnsafeSegment("toolUseId", opts.toolUseId);
|
|
65
|
+
const fullPath = join(rootDir, opts.runId, `${opts.toolUseId}.txt`);
|
|
66
|
+
fence(resolve(fullPath));
|
|
67
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
68
|
+
try {
|
|
69
|
+
await writeFile(fullPath, result.content, { flag: "wx" });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const code = err.code;
|
|
73
|
+
if (code !== "EEXIST")
|
|
74
|
+
throw err;
|
|
75
|
+
// Another run already persisted this exact (runId, toolUseId) — treat
|
|
76
|
+
// as success. The body is identical because tool_use_id is unique.
|
|
77
|
+
}
|
|
78
|
+
const lines = result.content.split("\n");
|
|
79
|
+
const head = lines.slice(0, previewLines).join("\n");
|
|
80
|
+
const previewContent = `${head}\n[truncated, full output at ${fullPath}]`;
|
|
81
|
+
return { previewContent, fullPath, persisted: true };
|
|
82
|
+
}
|
|
83
|
+
function rejectUnsafeSegment(label, value) {
|
|
84
|
+
if (value === "" ||
|
|
85
|
+
value === "." ||
|
|
86
|
+
value === ".." ||
|
|
87
|
+
value.includes("/") ||
|
|
88
|
+
value.includes("\\") ||
|
|
89
|
+
value.includes("\0") ||
|
|
90
|
+
value.includes("..")) {
|
|
91
|
+
throw new RuntimeError(`tool-result-store: ${label} "${value}" contains path-traversal characters`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Defence-in-depth boundary check: assert that a resolved absolute path lives
|
|
96
|
+
* strictly under `root`. Redundant with `rejectUnsafeSegment` on the normal
|
|
97
|
+
* code path (which already strips separators and `..`), but kept — and exported
|
|
98
|
+
* so it can be exercised directly — so a future caller that resolves a path
|
|
99
|
+
* some other way can't silently escape the storage root.
|
|
100
|
+
*/
|
|
101
|
+
export function assertUnderRoot(abs, root) {
|
|
102
|
+
if (!abs.startsWith(`${root}${sep}`)) {
|
|
103
|
+
throw new RuntimeError("tool-result-store: resolved path escapes rootDir");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Used by tests to confirm the resolved storage location for a given
|
|
108
|
+
* runId/toolUseId pair. Performs the same traversal rejection as
|
|
109
|
+
* `storeAndPreview()` so callers see the same errors.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveStoragePath(runId, toolUseId, rootDir = DEFAULT_ROOT_DIR) {
|
|
112
|
+
rejectUnsafeSegment("runId", runId);
|
|
113
|
+
rejectUnsafeSegment("toolUseId", toolUseId);
|
|
114
|
+
const abs = resolve(rootDir, runId, `${toolUseId}.txt`);
|
|
115
|
+
// Sanity: the resolved path must still live under rootDir.
|
|
116
|
+
assertUnderRoot(abs, resolve(rootDir));
|
|
117
|
+
fence(abs);
|
|
118
|
+
return abs;
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-result-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persist large tool results to disk and return a preview the model can re-read",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/tenancy": "0.1.
|
|
17
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
18
|
-
"@crewhaus/tool-executor": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5",
|
|
19
|
+
"@crewhaus/tenancy": "0.1.5",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.1.5",
|
|
21
|
+
"@crewhaus/tool-executor": "0.1.5"
|
|
19
22
|
},
|
|
20
23
|
"license": "Apache-2.0",
|
|
21
24
|
"author": {
|
|
@@ -35,5 +38,5 @@
|
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
37
40
|
},
|
|
38
|
-
"files": ["
|
|
41
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
39
42
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { RuntimeError } from "@crewhaus/errors";
|
|
6
|
-
import { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
|
|
7
|
-
import type { ToolResult } from "@crewhaus/tool-executor";
|
|
8
|
-
import {
|
|
9
|
-
DEFAULT_PREVIEW_LINES,
|
|
10
|
-
DEFAULT_THRESHOLD_BYTES,
|
|
11
|
-
assertUnderRoot,
|
|
12
|
-
resolveStoragePath,
|
|
13
|
-
storeAndPreview,
|
|
14
|
-
} from "./index";
|
|
15
|
-
|
|
16
|
-
const TMP_ROOTS: string[] = [];
|
|
17
|
-
function newTempRoot(): string {
|
|
18
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-result-store-"));
|
|
19
|
-
TMP_ROOTS.push(dir);
|
|
20
|
-
return dir;
|
|
21
|
-
}
|
|
22
|
-
afterAll(() => {
|
|
23
|
-
for (const dir of TMP_ROOTS) {
|
|
24
|
-
rmSync(dir, { recursive: true, force: true });
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function makeResult(content: string, isError = false): ToolResult {
|
|
29
|
-
return { toolUseId: "tu_X", content, isError };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("storeAndPreview — under threshold", () => {
|
|
33
|
-
test("small content is returned verbatim, no file written", async () => {
|
|
34
|
-
const rootDir = newTempRoot();
|
|
35
|
-
const out = await storeAndPreview(makeResult("tiny output"), {
|
|
36
|
-
runId: "run_a",
|
|
37
|
-
toolUseId: "tu_1",
|
|
38
|
-
rootDir,
|
|
39
|
-
});
|
|
40
|
-
expect(out.persisted).toBe(false);
|
|
41
|
-
expect(out.fullPath).toBeNull();
|
|
42
|
-
expect(out.previewContent).toBe("tiny output");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("exactly at threshold is still treated as small", async () => {
|
|
46
|
-
const rootDir = newTempRoot();
|
|
47
|
-
const content = "a".repeat(DEFAULT_THRESHOLD_BYTES);
|
|
48
|
-
const out = await storeAndPreview(makeResult(content), {
|
|
49
|
-
runId: "run_a",
|
|
50
|
-
toolUseId: "tu_1",
|
|
51
|
-
rootDir,
|
|
52
|
-
});
|
|
53
|
-
expect(out.persisted).toBe(false);
|
|
54
|
-
expect(out.fullPath).toBeNull();
|
|
55
|
-
expect(out.previewContent).toBe(content);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("storeAndPreview — non-string content", () => {
|
|
60
|
-
test("image content-block array bypasses persistence and is forwarded verbatim", async () => {
|
|
61
|
-
const rootDir = newTempRoot();
|
|
62
|
-
// A large base64 payload that would blow past the byte threshold if it
|
|
63
|
-
// were a string — confirms the array short-circuits before any sizing.
|
|
64
|
-
const blocks: ToolResult["content"] = [
|
|
65
|
-
{
|
|
66
|
-
type: "image",
|
|
67
|
-
source: {
|
|
68
|
-
type: "base64",
|
|
69
|
-
media_type: "image/png",
|
|
70
|
-
data: "Q".repeat(DEFAULT_THRESHOLD_BYTES + 10),
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
];
|
|
74
|
-
const out = await storeAndPreview(
|
|
75
|
-
{ toolUseId: "tu_img", content: blocks, isError: false },
|
|
76
|
-
{
|
|
77
|
-
runId: "run_img",
|
|
78
|
-
toolUseId: "tu_img",
|
|
79
|
-
rootDir,
|
|
80
|
-
},
|
|
81
|
-
);
|
|
82
|
-
expect(out.persisted).toBe(false);
|
|
83
|
-
expect(out.fullPath).toBeNull();
|
|
84
|
-
// Same reference forwarded unchanged — no copy, no preview wrapping.
|
|
85
|
-
expect(out.previewContent).toBe(blocks);
|
|
86
|
-
// Nothing was written under the run directory.
|
|
87
|
-
expect(() => statSync(join(rootDir, "run_img"))).toThrow();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("mixed text + image blocks are also forwarded as-is", async () => {
|
|
91
|
-
const rootDir = newTempRoot();
|
|
92
|
-
const blocks: ToolResult["content"] = [
|
|
93
|
-
{ type: "text", text: "caption" },
|
|
94
|
-
{
|
|
95
|
-
type: "image",
|
|
96
|
-
source: { type: "base64", media_type: "image/jpeg", data: "AAAA" },
|
|
97
|
-
},
|
|
98
|
-
];
|
|
99
|
-
const out = await storeAndPreview(
|
|
100
|
-
{ toolUseId: "tu_mix", content: blocks, isError: false },
|
|
101
|
-
{
|
|
102
|
-
runId: "run_mix",
|
|
103
|
-
toolUseId: "tu_mix",
|
|
104
|
-
rootDir,
|
|
105
|
-
},
|
|
106
|
-
);
|
|
107
|
-
expect(out.persisted).toBe(false);
|
|
108
|
-
expect(out.fullPath).toBeNull();
|
|
109
|
-
expect(out.previewContent).toEqual(blocks);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe("storeAndPreview — over threshold", () => {
|
|
114
|
-
test("large content is persisted; preview shows first N lines + marker", async () => {
|
|
115
|
-
const rootDir = newTempRoot();
|
|
116
|
-
const lines = Array.from({ length: 250 }, (_, i) => `line ${i + 1}`);
|
|
117
|
-
const content = lines.join("\n");
|
|
118
|
-
// pad past the byte threshold so the persistence path fires
|
|
119
|
-
const padded = `${content}\n${"x".repeat(DEFAULT_THRESHOLD_BYTES)}`;
|
|
120
|
-
expect(Buffer.byteLength(padded, "utf8")).toBeGreaterThan(DEFAULT_THRESHOLD_BYTES);
|
|
121
|
-
const out = await storeAndPreview(makeResult(padded), {
|
|
122
|
-
runId: "run_b",
|
|
123
|
-
toolUseId: "tu_2",
|
|
124
|
-
rootDir,
|
|
125
|
-
});
|
|
126
|
-
expect(out.persisted).toBe(true);
|
|
127
|
-
expect(out.fullPath).not.toBeNull();
|
|
128
|
-
if (out.fullPath === null) throw new Error("unreachable");
|
|
129
|
-
expect(statSync(out.fullPath).size).toBe(Buffer.byteLength(padded, "utf8"));
|
|
130
|
-
|
|
131
|
-
// Preview = first DEFAULT_PREVIEW_LINES lines + truncation marker.
|
|
132
|
-
if (typeof out.previewContent !== "string") throw new Error("expected string preview");
|
|
133
|
-
const previewLines = out.previewContent.split("\n");
|
|
134
|
-
// last line should be the marker, the line before that may be partial.
|
|
135
|
-
expect(previewLines[previewLines.length - 1]).toContain("[truncated, full output at ");
|
|
136
|
-
expect(previewLines[previewLines.length - 1]).toContain(out.fullPath);
|
|
137
|
-
expect(previewLines.length).toBe(DEFAULT_PREVIEW_LINES + 1);
|
|
138
|
-
// First preview line should be the very first line of input.
|
|
139
|
-
expect(previewLines[0]).toBe("line 1");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("custom previewLines, thresholdBytes, rootDir overrides honored", async () => {
|
|
143
|
-
const rootDir = newTempRoot();
|
|
144
|
-
const content = "line a\nline b\nline c\nline d\nline e\nline f";
|
|
145
|
-
const out = await storeAndPreview(makeResult(content), {
|
|
146
|
-
runId: "run_c",
|
|
147
|
-
toolUseId: "tu_3",
|
|
148
|
-
rootDir,
|
|
149
|
-
thresholdBytes: 5,
|
|
150
|
-
previewLines: 2,
|
|
151
|
-
});
|
|
152
|
-
expect(out.persisted).toBe(true);
|
|
153
|
-
expect(out.previewContent).toContain("line a\nline b\n[truncated, full output at ");
|
|
154
|
-
expect(out.fullPath).toContain(rootDir);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("idempotent on retry — second call with same ids returns same path without throwing", async () => {
|
|
158
|
-
const rootDir = newTempRoot();
|
|
159
|
-
const content = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
|
|
160
|
-
const first = await storeAndPreview(makeResult(content), {
|
|
161
|
-
runId: "run_d",
|
|
162
|
-
toolUseId: "tu_4",
|
|
163
|
-
rootDir,
|
|
164
|
-
});
|
|
165
|
-
const second = await storeAndPreview(makeResult(content), {
|
|
166
|
-
runId: "run_d",
|
|
167
|
-
toolUseId: "tu_4",
|
|
168
|
-
rootDir,
|
|
169
|
-
});
|
|
170
|
-
expect(first.fullPath).toBe(second.fullPath);
|
|
171
|
-
if (first.fullPath === null) throw new Error("unreachable");
|
|
172
|
-
// File still exists and has the original content.
|
|
173
|
-
expect(readFileSync(first.fullPath, "utf8")).toBe(content);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("multi-byte UTF-8 byte length, not character length, drives the threshold", async () => {
|
|
177
|
-
const rootDir = newTempRoot();
|
|
178
|
-
// 5000 emoji × 4 bytes each = 20_000 bytes (over threshold) but only 5000 chars.
|
|
179
|
-
const content = "🌟".repeat(5000);
|
|
180
|
-
expect(content.length).toBe(10000); // emoji is 2 surrogate code units in JS
|
|
181
|
-
expect(Buffer.byteLength(content, "utf8")).toBeGreaterThan(DEFAULT_THRESHOLD_BYTES);
|
|
182
|
-
const out = await storeAndPreview(makeResult(content), {
|
|
183
|
-
runId: "run_e",
|
|
184
|
-
toolUseId: "tu_5",
|
|
185
|
-
rootDir,
|
|
186
|
-
});
|
|
187
|
-
expect(out.persisted).toBe(true);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("error results (isError: true) also get persisted when large", async () => {
|
|
191
|
-
const rootDir = newTempRoot();
|
|
192
|
-
const stack = `Error: boom\n${" at foo\n".repeat(5000)}`;
|
|
193
|
-
const out = await storeAndPreview(makeResult(stack, true), {
|
|
194
|
-
runId: "run_f",
|
|
195
|
-
toolUseId: "tu_6",
|
|
196
|
-
rootDir,
|
|
197
|
-
});
|
|
198
|
-
expect(out.persisted).toBe(true);
|
|
199
|
-
expect(out.fullPath).toContain(`${rootDir}/run_f/tu_6.txt`);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("storeAndPreview — path traversal guard", () => {
|
|
204
|
-
test("runId with .. is rejected", async () => {
|
|
205
|
-
const rootDir = newTempRoot();
|
|
206
|
-
const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
|
|
207
|
-
await expect(
|
|
208
|
-
storeAndPreview(makeResult(big), {
|
|
209
|
-
runId: "../escape",
|
|
210
|
-
toolUseId: "tu_7",
|
|
211
|
-
rootDir,
|
|
212
|
-
}),
|
|
213
|
-
).rejects.toThrow(/runId/);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("toolUseId with slash is rejected", async () => {
|
|
217
|
-
const rootDir = newTempRoot();
|
|
218
|
-
const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
|
|
219
|
-
await expect(
|
|
220
|
-
storeAndPreview(makeResult(big), {
|
|
221
|
-
runId: "ok",
|
|
222
|
-
toolUseId: "etc/passwd",
|
|
223
|
-
rootDir,
|
|
224
|
-
}),
|
|
225
|
-
).rejects.toThrow(/toolUseId/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("empty runId is rejected", async () => {
|
|
229
|
-
const rootDir = newTempRoot();
|
|
230
|
-
const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
|
|
231
|
-
await expect(
|
|
232
|
-
storeAndPreview(makeResult(big), { runId: "", toolUseId: "tu", rootDir }),
|
|
233
|
-
).rejects.toThrow(/runId/);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("resolveStoragePath returns a path under rootDir", () => {
|
|
237
|
-
const path = resolveStoragePath("run_x", "tu_y", "/tmp/cr-test");
|
|
238
|
-
expect(path).toContain("/tmp/cr-test/run_x/tu_y.txt");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("assertUnderRoot accepts a path strictly under root", () => {
|
|
242
|
-
expect(() => assertUnderRoot("/tmp/cr-root/run/file.txt", "/tmp/cr-root")).not.toThrow();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("assertUnderRoot rejects a path that escapes root (defence-in-depth throw)", () => {
|
|
246
|
-
// Exercises the boundary check directly: a resolved path that does NOT
|
|
247
|
-
// sit under root must throw, even though rejectUnsafeSegment normally
|
|
248
|
-
// makes this unreachable from resolveStoragePath.
|
|
249
|
-
expect(() => assertUnderRoot("/etc/passwd", "/tmp/cr-root")).toThrow(RuntimeError);
|
|
250
|
-
expect(() => assertUnderRoot("/tmp/cr-root-sibling/x", "/tmp/cr-root")).toThrow(
|
|
251
|
-
/escapes rootDir/,
|
|
252
|
-
);
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe("storeAndPreview — cross-tenant fencing (CWE-1230)", () => {
|
|
257
|
-
const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
|
|
258
|
-
|
|
259
|
-
test("inside tenantA, a store rooted under tenantB fails closed", async () => {
|
|
260
|
-
const tenantsRoot = newTempRoot();
|
|
261
|
-
const tenantA = buildTenant("tenant-a", { tenantsRoot });
|
|
262
|
-
const tenantB = buildTenant("tenant-b", { tenantsRoot });
|
|
263
|
-
// Persisting under tenantB's toolResultRoot while tenantA is active
|
|
264
|
-
// resolves a path outside tenantA's root, so it fails closed.
|
|
265
|
-
await withTenant(tenantA, async () => {
|
|
266
|
-
await expect(
|
|
267
|
-
storeAndPreview(makeResult(big), {
|
|
268
|
-
runId: "run_x",
|
|
269
|
-
toolUseId: "tu_x",
|
|
270
|
-
rootDir: tenantB.toolResultRoot,
|
|
271
|
-
}),
|
|
272
|
-
).rejects.toThrow(TenancyError);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("inside tenantA, a store rooted under tenantA persists", async () => {
|
|
277
|
-
const tenantsRoot = newTempRoot();
|
|
278
|
-
const tenantA = buildTenant("tenant-a", { tenantsRoot });
|
|
279
|
-
await withTenant(tenantA, async () => {
|
|
280
|
-
const out = await storeAndPreview(makeResult(big), {
|
|
281
|
-
runId: "run_x",
|
|
282
|
-
toolUseId: "tu_x",
|
|
283
|
-
rootDir: tenantA.toolResultRoot,
|
|
284
|
-
});
|
|
285
|
-
expect(out.persisted).toBe(true);
|
|
286
|
-
expect(out.fullPath).toContain(tenantA.toolResultRoot);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("no active tenant — behaviour is unchanged (no fencing)", async () => {
|
|
291
|
-
const rootDir = newTempRoot();
|
|
292
|
-
const out = await storeAndPreview(makeResult(big), {
|
|
293
|
-
runId: "run_x",
|
|
294
|
-
toolUseId: "tu_x",
|
|
295
|
-
rootDir,
|
|
296
|
-
});
|
|
297
|
-
expect(out.persisted).toBe(true);
|
|
298
|
-
});
|
|
299
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R3 `tool-result-store` — persist large tool outputs to disk
|
|
3
|
-
* and return a preview the model can re-read via the `Read` tool. This
|
|
4
|
-
* keeps wide messages out of the conversation context (cheaper, faster)
|
|
5
|
-
* while preserving full output for any later look-up.
|
|
6
|
-
*
|
|
7
|
-
* Default policy:
|
|
8
|
-
* - Threshold: 10240 bytes (10 KB) of UTF-8.
|
|
9
|
-
* - Storage path: `<rootDir>/<runId>/<toolUseId>.txt`
|
|
10
|
-
* (rootDir defaults to `.crewhaus/tool-results` under cwd).
|
|
11
|
-
* - Preview: first 100 lines + `[truncated, full output at <fullPath>]`
|
|
12
|
-
*
|
|
13
|
-
* Idempotent writes: the file is created with `flag: "wx"` (write-
|
|
14
|
-
* exclusive). If a previous run already wrote the same `(runId,
|
|
15
|
-
* toolUseId)` pair we treat that as success and reuse the existing file.
|
|
16
|
-
*
|
|
17
|
-
* Path traversal: `runId` and `toolUseId` are joined under `rootDir`
|
|
18
|
-
* after rejecting any value containing path separators or `..` to
|
|
19
|
-
* prevent a tool-result from escaping its run directory.
|
|
20
|
-
*
|
|
21
|
-
* Reference: `claude-code/utils/toolResultStorage.ts` — uses
|
|
22
|
-
* `<persisted-output>` XML wrappers, a per-tool size budget, and a
|
|
23
|
-
* GrowthBook flag to override per tool. We collapse to a single global
|
|
24
|
-
* threshold and a plain-text marker.
|
|
25
|
-
*/
|
|
26
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
27
|
-
import { dirname, join, resolve, sep } from "node:path";
|
|
28
|
-
import { RuntimeError } from "@crewhaus/errors";
|
|
29
|
-
import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
|
|
30
|
-
import type { ToolExecuteResult } from "@crewhaus/tool-catalog";
|
|
31
|
-
import type { ToolResult } from "@crewhaus/tool-executor";
|
|
32
|
-
|
|
33
|
-
// When a tenant context is active, fail closed on a resolved storage path that
|
|
34
|
-
// escapes the tenant's toolResultRoot (CWE-1230). Outside a tenant scope (the
|
|
35
|
-
// common CLI case) this is a no-op so non-tenant behaviour is unchanged.
|
|
36
|
-
function fence(absPath: string): void {
|
|
37
|
-
if (currentTenantContext() !== undefined) {
|
|
38
|
-
assertSamePath(absPath, requireTenant().toolResultRoot);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export type StoreOptions = {
|
|
43
|
-
readonly runId: string;
|
|
44
|
-
readonly toolUseId: string;
|
|
45
|
-
readonly thresholdBytes?: number;
|
|
46
|
-
readonly previewLines?: number;
|
|
47
|
-
readonly rootDir?: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type StoredResult = {
|
|
51
|
-
readonly previewContent: ToolExecuteResult;
|
|
52
|
-
readonly fullPath: string | null;
|
|
53
|
-
readonly persisted: boolean;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export const DEFAULT_THRESHOLD_BYTES = 10240;
|
|
57
|
-
export const DEFAULT_PREVIEW_LINES = 100;
|
|
58
|
-
export const DEFAULT_ROOT_DIR = ".crewhaus/tool-results";
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* If `result.content` is at or under threshold, return it unchanged.
|
|
62
|
-
* Otherwise persist the full text under `<rootDir>/<runId>/<toolUseId>.txt`
|
|
63
|
-
* and return `{ previewContent, fullPath, persisted: true }`. Errors
|
|
64
|
-
* (`isError: true`) follow the same path so large stack traces are
|
|
65
|
-
* captured.
|
|
66
|
-
*/
|
|
67
|
-
export async function storeAndPreview(
|
|
68
|
-
result: ToolResult,
|
|
69
|
-
opts: StoreOptions,
|
|
70
|
-
): Promise<StoredResult> {
|
|
71
|
-
const thresholdBytes = opts.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
|
|
72
|
-
const previewLines = opts.previewLines ?? DEFAULT_PREVIEW_LINES;
|
|
73
|
-
const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
|
|
74
|
-
|
|
75
|
-
// Section 14 — non-string content (image content arrays) bypasses
|
|
76
|
-
// persistence entirely. The blocks are forwarded as-is to the model so
|
|
77
|
-
// it can see the image; size capping happens inside the producing tool
|
|
78
|
-
// (e.g. tool-image enforces a 5 MB per-image limit on disk).
|
|
79
|
-
if (typeof result.content !== "string") {
|
|
80
|
-
return { previewContent: result.content, fullPath: null, persisted: false };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const byteLength = Buffer.byteLength(result.content, "utf8");
|
|
84
|
-
if (byteLength <= thresholdBytes) {
|
|
85
|
-
return { previewContent: result.content, fullPath: null, persisted: false };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
rejectUnsafeSegment("runId", opts.runId);
|
|
89
|
-
rejectUnsafeSegment("toolUseId", opts.toolUseId);
|
|
90
|
-
|
|
91
|
-
const fullPath = join(rootDir, opts.runId, `${opts.toolUseId}.txt`);
|
|
92
|
-
fence(resolve(fullPath));
|
|
93
|
-
await mkdir(dirname(fullPath), { recursive: true });
|
|
94
|
-
try {
|
|
95
|
-
await writeFile(fullPath, result.content, { flag: "wx" });
|
|
96
|
-
} catch (err) {
|
|
97
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
98
|
-
if (code !== "EEXIST") throw err;
|
|
99
|
-
// Another run already persisted this exact (runId, toolUseId) — treat
|
|
100
|
-
// as success. The body is identical because tool_use_id is unique.
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const lines = result.content.split("\n");
|
|
104
|
-
const head = lines.slice(0, previewLines).join("\n");
|
|
105
|
-
const previewContent = `${head}\n[truncated, full output at ${fullPath}]`;
|
|
106
|
-
|
|
107
|
-
return { previewContent, fullPath, persisted: true };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function rejectUnsafeSegment(label: string, value: string): void {
|
|
111
|
-
if (
|
|
112
|
-
value === "" ||
|
|
113
|
-
value === "." ||
|
|
114
|
-
value === ".." ||
|
|
115
|
-
value.includes("/") ||
|
|
116
|
-
value.includes("\\") ||
|
|
117
|
-
value.includes("\0") ||
|
|
118
|
-
value.includes("..")
|
|
119
|
-
) {
|
|
120
|
-
throw new RuntimeError(
|
|
121
|
-
`tool-result-store: ${label} "${value}" contains path-traversal characters`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Defence-in-depth boundary check: assert that a resolved absolute path lives
|
|
128
|
-
* strictly under `root`. Redundant with `rejectUnsafeSegment` on the normal
|
|
129
|
-
* code path (which already strips separators and `..`), but kept — and exported
|
|
130
|
-
* so it can be exercised directly — so a future caller that resolves a path
|
|
131
|
-
* some other way can't silently escape the storage root.
|
|
132
|
-
*/
|
|
133
|
-
export function assertUnderRoot(abs: string, root: string): void {
|
|
134
|
-
if (!abs.startsWith(`${root}${sep}`)) {
|
|
135
|
-
throw new RuntimeError("tool-result-store: resolved path escapes rootDir");
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Used by tests to confirm the resolved storage location for a given
|
|
141
|
-
* runId/toolUseId pair. Performs the same traversal rejection as
|
|
142
|
-
* `storeAndPreview()` so callers see the same errors.
|
|
143
|
-
*/
|
|
144
|
-
export function resolveStoragePath(
|
|
145
|
-
runId: string,
|
|
146
|
-
toolUseId: string,
|
|
147
|
-
rootDir: string = DEFAULT_ROOT_DIR,
|
|
148
|
-
): string {
|
|
149
|
-
rejectUnsafeSegment("runId", runId);
|
|
150
|
-
rejectUnsafeSegment("toolUseId", toolUseId);
|
|
151
|
-
const abs = resolve(rootDir, runId, `${toolUseId}.txt`);
|
|
152
|
-
// Sanity: the resolved path must still live under rootDir.
|
|
153
|
-
assertUnderRoot(abs, resolve(rootDir));
|
|
154
|
-
fence(abs);
|
|
155
|
-
return abs;
|
|
156
|
-
}
|