Skip to content

Commit 5a8bc61

Browse files
authored
fix(artifacts): use file-backed sandbox transport (#174)
## Why Real research reports can produce document binaries large enough to make stdout an unreliable artifact transport. The sandbox renderer previously base64-encoded the entire binary into stdout and the host reparsed that stream as JSON, which caused production research PDF generation to fail with invalid artifact metadata. ## What changed - write generated document bytes to a bounded project-local staging file - emit only a small, marker-delimited metadata record over stdout - require the reported path to exactly match the host-selected staging path - read artifact bytes through the sandbox file API before workspace and R2 persistence - delete both staged input and output in `finally` - remove the old stdout/base64 compatibility path entirely - document the file-backed renderer protocol ## Architecture / migration effects - No database or migration changes - No new dependency or deployment configuration - The document renderer control channel and binary data channel are now separated ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` (passes; four existing Knip configuration hints remain) - `pnpm architecture:check` - `pnpm turbo skills:build` - Live Daytona sandbox: rendered a 16-page research-style Markdown PDF (77,026 bytes) - Live Daytona sandbox: rendered and identified DOCX, PDF, PPTX, and XLSX outputs - Confirmed renderer stdout contains only path-bound metadata - Confirmed exact input and output staging files are deleted after rendering Local verification used Node 26.4.0 while the repository pins Node 24.18.0; pnpm emitted the existing engine warning, and all checks passed.
1 parent 7e6fa96 commit 5a8bc61

3 files changed

Lines changed: 107 additions & 52 deletions

File tree

packages/agent-core/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,14 @@ and PDF therefore preserve the same headings, prose, lists, tables, links, citat
7979
and ordering; only print-safe pagination and document chrome differ. The project
8080
workspace is resolved only after remote research succeeds;
8181
the PDF is then stored both in the live project files and the durable
82-
generated-output store. Sandbox renderers delimit their final artifact metadata with
83-
an internal stdout marker so bounded library diagnostics cannot corrupt the payload.
84-
Document generators stage their bounded structured input
85-
in a hidden, project-local temporary file instead of embedding it in the sandbox
86-
command line, then delete that input after rendering; this keeps large reports
87-
within the sandbox process contract without retaining source payloads.
82+
generated-output store. Sandbox renderers write binary output to a bounded staging
83+
file and delimit only its small metadata object with an internal stdout marker. The
84+
host validates that path, reads the bytes through the sandbox file boundary, uploads
85+
the durable artifact, and removes both staging files. Document generators stage
86+
their bounded structured input in a hidden, project-local temporary file instead of
87+
embedding it in the sandbox command line, then delete that input after rendering;
88+
this keeps large reports within the sandbox process contract without retaining
89+
source payloads.
8890

8991
Composio REST tool discovery and execution responses are byte-bounded before
9092
parsing, then projected into bounded, valid JSON before entering model context.

packages/agent-core/src/tools/docs/execute.ts

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,28 @@ import {
3434
buildXlsxScript,
3535
} from "./scripts";
3636

37-
const SandboxArtifactSchema = z.strictObject({
38-
base64: z.string().min(1),
37+
const SandboxArtifactMetadataSchema = z.strictObject({
3938
filename: z.string().min(1),
4039
mimeType: z.string().min(1),
40+
path: z.string().min(1),
4141
});
4242

43-
type SandboxArtifact = z.infer<typeof SandboxArtifactSchema>;
43+
type SandboxArtifactMetadata = z.infer<typeof SandboxArtifactMetadataSchema>;
44+
45+
interface SandboxArtifact extends Omit<SandboxArtifactMetadata, "path"> {
46+
base64: string;
47+
}
48+
49+
interface ArtifactStaging {
50+
content: string;
51+
inputPath: string;
52+
outputPath: string;
53+
}
4454

4555
const MAX_WORKSPACE_ARTIFACT_BASE64_CHARS = 2_000_000;
4656
const MAX_STAGED_INPUT_CHARACTERS = 1_900_000;
4757
const WORKSPACE_ROOT = "/workspace";
48-
const ARTIFACT_STAGING_DIRECTORY = ".cheatcode/artifact-inputs";
58+
const ARTIFACT_STAGING_DIRECTORY = ".cheatcode/artifact-staging";
4959
const ARTIFACT_STDOUT_MARKER = "__CHEATCODE_ARTIFACT__";
5060

5161
export async function executeGenerateSlides(
@@ -54,8 +64,11 @@ export async function executeGenerateSlides(
5464
): Promise<GenerateSlidesOutput> {
5565
const parsed = GenerateSlidesInputSchema.parse(input);
5666
const filename = normalizeFilename(parsed.filename ?? parsed.title, "pptx");
57-
const artifact = await runArtifactScript(parsed, runtimeContext, "slide", (inputPath) =>
58-
buildSlidesScript(inputPath, filename),
67+
const artifact = await runArtifactScript(
68+
parsed,
69+
runtimeContext,
70+
"slide",
71+
(inputPath, outputPath) => buildSlidesScript(inputPath, outputPath, filename),
5972
);
6073
return GenerateSlidesOutputSchema.parse({
6174
...artifact,
@@ -70,8 +83,11 @@ export async function executeGenerateDocx(
7083
): Promise<GenerateDocxOutput> {
7184
const parsed = GenerateDocumentInputSchema.parse(input);
7285
const filename = normalizeFilename(parsed.filename ?? parsed.title, "docx");
73-
const artifact = await runArtifactScript(parsed, runtimeContext, "docx", (inputPath) =>
74-
buildDocxScript(inputPath, filename),
86+
const artifact = await runArtifactScript(
87+
parsed,
88+
runtimeContext,
89+
"docx",
90+
(inputPath, outputPath) => buildDocxScript(inputPath, outputPath, filename),
7591
);
7692
return GenerateDocxOutputSchema.parse({
7793
...artifact,
@@ -86,8 +102,8 @@ export async function executeGeneratePdf(
86102
): Promise<GeneratePdfOutput> {
87103
const parsed = GenerateDocumentInputSchema.parse(input);
88104
const filename = normalizeFilename(parsed.filename ?? parsed.title, "pdf");
89-
const artifact = await runArtifactScript(parsed, runtimeContext, "pdf", (inputPath) =>
90-
buildPdfScript(inputPath, filename),
105+
const artifact = await runArtifactScript(parsed, runtimeContext, "pdf", (inputPath, outputPath) =>
106+
buildPdfScript(inputPath, outputPath, filename),
91107
);
92108
return GeneratePdfOutputSchema.parse({
93109
...artifact,
@@ -107,7 +123,7 @@ export async function executeGenerateMarkdownPdf(
107123
{ markdown: parsed.markdown, title: parsed.title, tokens },
108124
runtimeContext,
109125
"pdf",
110-
(inputPath) => buildMarkdownPdfScript(inputPath, filename),
126+
(inputPath, outputPath) => buildMarkdownPdfScript(inputPath, outputPath, filename),
111127
);
112128
return GenerateMarkdownPdfOutputSchema.parse({
113129
...artifact,
@@ -122,8 +138,11 @@ export async function executeGenerateXlsx(
122138
): Promise<GenerateXlsxOutput> {
123139
const parsed = GenerateSpreadsheetInputSchema.parse(input);
124140
const filename = normalizeFilename(parsed.filename ?? parsed.title, "xlsx");
125-
const artifact = await runArtifactScript(parsed, runtimeContext, "xlsx", (inputPath) =>
126-
buildXlsxScript(inputPath, filename),
141+
const artifact = await runArtifactScript(
142+
parsed,
143+
runtimeContext,
144+
"xlsx",
145+
(inputPath, outputPath) => buildXlsxScript(inputPath, outputPath, filename),
127146
);
128147
return GenerateXlsxOutputSchema.parse({
129148
...artifact,
@@ -136,19 +155,22 @@ async function runArtifactScript(
136155
input: unknown,
137156
runtimeContext: CodeRuntimeContext,
138157
kind: ArtifactKind,
139-
buildScript: (inputPath: string) => string,
158+
buildScript: (inputPath: string, outputPath: string) => string,
140159
): Promise<ArtifactUploadResult> {
141160
if (!runtimeContext.artifacts) {
142161
throw new APIError(500, "internal_service_error", "Artifact storage is unavailable", {
143162
retriable: true,
144163
});
145164
}
146165

147-
const staging = stagedArtifactInput(input, runtimeContext.workspaceDir ?? WORKSPACE_ROOT);
166+
const staging = createArtifactStaging(input, runtimeContext.workspaceDir ?? WORKSPACE_ROOT);
148167
try {
149-
await runtimeContext.sandbox.writeFile({ content: staging.content, path: staging.path });
168+
await runtimeContext.sandbox.writeFile({
169+
content: staging.content,
170+
path: staging.inputPath,
171+
});
150172
const result = await runtimeContext.sandbox.runCode({
151-
code: buildScript(staging.path),
173+
code: buildScript(staging.inputPath, staging.outputPath),
152174
cwd: runtimeContext.workspaceDir ?? WORKSPACE_ROOT,
153175
language: "javascript",
154176
});
@@ -163,7 +185,8 @@ async function runArtifactScript(
163185
});
164186
}
165187

166-
const generated = parseSandboxArtifact(result.stdout);
188+
const metadata = parseSandboxArtifact(result.stdout, staging.outputPath);
189+
const generated = await readSandboxArtifact(runtimeContext, metadata);
167190
await writeWorkspaceArtifact(runtimeContext, generated);
168191
return await runtimeContext.artifacts.put({
169192
contentType: generated.mimeType,
@@ -172,24 +195,36 @@ async function runArtifactScript(
172195
kind,
173196
});
174197
} finally {
175-
await runtimeContext.sandbox.deleteFile({ path: staging.path }).catch(() => undefined);
198+
await runtimeContext.sandbox.deleteFile({ path: staging.inputPath }).catch(() => undefined);
199+
await runtimeContext.sandbox.deleteFile({ path: staging.outputPath }).catch(() => undefined);
176200
}
177201
}
178202

179-
function stagedArtifactInput(
180-
input: unknown,
181-
workspaceDir: string,
182-
): { content: string; path: string } {
203+
async function readSandboxArtifact(
204+
runtimeContext: CodeRuntimeContext,
205+
metadata: SandboxArtifactMetadata,
206+
): Promise<SandboxArtifact> {
207+
const file = await runtimeContext.sandbox.readFile({ encoding: "base64", path: metadata.path });
208+
return {
209+
base64: z.string().min(1).parse(file.content),
210+
filename: metadata.filename,
211+
mimeType: metadata.mimeType,
212+
};
213+
}
214+
215+
function createArtifactStaging(input: unknown, workspaceDir: string): ArtifactStaging {
183216
const content = JSON.stringify(input);
184217
if (content.length > MAX_STAGED_INPUT_CHARACTERS) {
185218
throw new APIError(422, "tool_validation_failed", "Document input is too large", {
186219
details: { inputCharacters: content.length },
187220
retriable: false,
188221
});
189222
}
223+
const stagingId = crypto.randomUUID();
190224
return {
191225
content,
192-
path: `${workspaceDir}/${ARTIFACT_STAGING_DIRECTORY}/${crypto.randomUUID()}.json`,
226+
inputPath: `${workspaceDir}/${ARTIFACT_STAGING_DIRECTORY}/${stagingId}.json`,
227+
outputPath: `${workspaceDir}/${ARTIFACT_STAGING_DIRECTORY}/${stagingId}.bin`,
193228
};
194229
}
195230

@@ -218,14 +253,19 @@ async function writeWorkspaceArtifact(
218253
}
219254
}
220255

221-
function parseSandboxArtifact(stdout: string): SandboxArtifact {
256+
function parseSandboxArtifact(stdout: string, expectedPath: string): SandboxArtifactMetadata {
222257
try {
223258
const markerIndex = stdout.lastIndexOf(ARTIFACT_STDOUT_MARKER);
224-
const payload =
225-
markerIndex === -1
226-
? stdout.trim()
227-
: stdout.slice(markerIndex + ARTIFACT_STDOUT_MARKER.length).trim();
228-
return SandboxArtifactSchema.parse(JSON.parse(payload));
259+
if (markerIndex === -1) {
260+
throw new Error("Artifact metadata marker is missing.");
261+
}
262+
const payload = stdout.slice(markerIndex + ARTIFACT_STDOUT_MARKER.length).trim();
263+
const metadataLine = payload.split(/\r?\n/u, 1)[0] ?? "";
264+
const metadata = SandboxArtifactMetadataSchema.parse(JSON.parse(metadataLine));
265+
if (metadata.path !== expectedPath) {
266+
throw new Error("Artifact metadata path did not match the staged output path.");
267+
}
268+
return metadata;
229269
} catch (error) {
230270
throw new APIError(
231271
502,

packages/agent-core/src/tools/docs/scripts.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,29 @@ function loadInput(inputPath: string): string {
1212
].join("\n");
1313
}
1414

15-
function emitArtifact(filename: string, mimeType: string): string {
15+
function emitArtifact(filename: string, mimeType: string, outputPath: string): string {
1616
return [
17-
"function emit(base64) {",
17+
"async function emit(data) {",
18+
' const { writeFile } = await import("node:fs/promises");',
19+
' const buffer = typeof data === "string" ? Buffer.from(data, "base64") : Buffer.from(data);',
20+
` await writeFile(${JSON.stringify(outputPath)}, buffer);`,
1821
` process.stdout.write("\\n${ARTIFACT_STDOUT_MARKER}" + JSON.stringify({`,
1922
` filename: ${JSON.stringify(filename)},`,
2023
` mimeType: ${JSON.stringify(mimeType)},`,
21-
" base64,",
24+
` path: ${JSON.stringify(outputPath)},`,
2225
" }));",
2326
"}",
2427
].join("\n");
2528
}
2629

27-
export function buildSlidesScript(inputPath: string, filename: string): string {
30+
export function buildSlidesScript(inputPath: string, outputPath: string, filename: string): string {
2831
return [
2932
RUNTIME_REQUIRE,
3033
loadInput(inputPath),
3134
emitArtifact(
3235
filename,
3336
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
37+
outputPath,
3438
),
3539
'const PptxGenJS = require("pptxgenjs");',
3640
"const pptx = new PptxGenJS();",
@@ -57,17 +61,18 @@ export function buildSlidesScript(inputPath: string, filename: string): string {
5761
" if (item.notes) { slide.addNotes(item.notes); }",
5862
"}",
5963
'const base64 = await pptx.write({ outputType: "base64" });',
60-
"emit(base64);",
64+
"await emit(base64);",
6165
].join("\n");
6266
}
6367

64-
export function buildDocxScript(inputPath: string, filename: string): string {
68+
export function buildDocxScript(inputPath: string, outputPath: string, filename: string): string {
6569
return [
6670
RUNTIME_REQUIRE,
6771
loadInput(inputPath),
6872
emitArtifact(
6973
filename,
7074
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
75+
outputPath,
7176
),
7277
'const { Document, HeadingLevel, Packer, Paragraph, TextRun } = require("docx");',
7378
"const children = [",
@@ -88,15 +93,19 @@ export function buildDocxScript(inputPath: string, filename: string): string {
8893
" sections: [{ children }],",
8994
"});",
9095
"const buffer = await Packer.toBuffer(doc);",
91-
'emit(Buffer.from(buffer).toString("base64"));',
96+
"await emit(buffer);",
9297
].join("\n");
9398
}
9499

95-
export function buildXlsxScript(inputPath: string, filename: string): string {
100+
export function buildXlsxScript(inputPath: string, outputPath: string, filename: string): string {
96101
return [
97102
RUNTIME_REQUIRE,
98103
loadInput(inputPath),
99-
emitArtifact(filename, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
104+
emitArtifact(
105+
filename,
106+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
107+
outputPath,
108+
),
100109
'const ExcelJS = require("exceljs");',
101110
"const workbook = new ExcelJS.Workbook();",
102111
'workbook.creator = "Cheatcode";',
@@ -114,15 +123,15 @@ export function buildXlsxScript(inputPath: string, filename: string): string {
114123
" worksheet.views = [{ state: 'frozen', ySplit: 1 }];",
115124
"}",
116125
"const buffer = await workbook.xlsx.writeBuffer();",
117-
'emit(Buffer.from(buffer).toString("base64"));',
126+
"await emit(buffer);",
118127
].join("\n");
119128
}
120129

121-
export function buildPdfScript(inputPath: string, filename: string): string {
130+
export function buildPdfScript(inputPath: string, outputPath: string, filename: string): string {
122131
return [
123132
RUNTIME_REQUIRE,
124133
loadInput(inputPath),
125-
emitArtifact(filename, "application/pdf"),
134+
emitArtifact(filename, "application/pdf", outputPath),
126135
'const ReactModule = await import(require.resolve("react"));',
127136
"const React = ReactModule.default ?? ReactModule;",
128137
'const renderer = await import(require.resolve("@react-pdf/renderer"));',
@@ -143,7 +152,7 @@ export function buildPdfScript(inputPath: string, filename: string): string {
143152
"});",
144153
"const document = h(Document, null, h(Page, { size: 'A4', style: styles.page }, children));",
145154
"const buffer = await renderToBuffer(document);",
146-
'emit(Buffer.from(buffer).toString("base64"));',
155+
"await emit(buffer);",
147156
].join("\n");
148157
}
149158

@@ -276,14 +285,18 @@ const MARKDOWN_PDF_DOCUMENT = [
276285
" ),",
277286
");",
278287
"const buffer = await renderToBuffer(document);",
279-
'emit(Buffer.from(buffer).toString("base64"));',
288+
"await emit(buffer);",
280289
].join("\n");
281290

282-
export function buildMarkdownPdfScript(inputPath: string, filename: string): string {
291+
export function buildMarkdownPdfScript(
292+
inputPath: string,
293+
outputPath: string,
294+
filename: string,
295+
): string {
283296
return [
284297
RUNTIME_REQUIRE,
285298
loadInput(inputPath),
286-
emitArtifact(filename, "application/pdf"),
299+
emitArtifact(filename, "application/pdf", outputPath),
287300
MARKDOWN_PDF_SETUP,
288301
MARKDOWN_PDF_STYLES,
289302
MARKDOWN_PDF_INLINE_RENDERER,

0 commit comments

Comments
 (0)