diff --git a/docs/cloud-task-artifacts.md b/docs/cloud-task-artifacts.md new file mode 100644 index 0000000000..fa5a17315d --- /dev/null +++ b/docs/cloud-task-artifacts.md @@ -0,0 +1,7 @@ +# Cloud task artifacts + +Cloud agents run with `/tmp/workspace` as the sandbox workspace. A repository task uses `/tmp/workspace/repos//` as its working directory; a repository-free task uses `/tmp/workspace` directly. Claude and Codex receive the same working directory, so artifact paths do not vary by model or adapter. + +Files left in the sandbox filesystem are temporary and cannot be downloaded after the sandbox is released. Agents should call the `upload_artifact` tool for every non-code deliverable they create. The tool accepts files inside the session working directory, uploads them directly to task object storage, and registers them as `output` artifacts on the task run. Registered artifacts are available through the existing task artifact download endpoint. + +Repository changes should continue to be delivered through git rather than duplicated as task artifacts. A single uploaded artifact is limited to 30 MB. diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index 0a5aae5081..83db89bbaf 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -6,6 +6,7 @@ import { signedCommitTool } from "./tools/signed-commit"; import { signedMergeTool } from "./tools/signed-merge"; import { signedRewriteTool } from "./tools/signed-rewrite"; import { speakTool } from "./tools/speak"; +import { uploadArtifactTool } from "./tools/upload-artifact"; export { LOCAL_TOOLS_MCP_NAME, @@ -24,6 +25,7 @@ export const LOCAL_TOOLS: LocalTool[] = [ listReposTool, cloneRepoTool, speakTool, + uploadArtifactTool, finishTool, ]; diff --git a/packages/agent/src/adapters/local-tools/tools/upload-artifact.test.ts b/packages/agent/src/adapters/local-tools/tools/upload-artifact.test.ts new file mode 100644 index 0000000000..bb49893f72 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/upload-artifact.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const prepareTaskArtifactUploads = vi.fn(); +const finalizeTaskArtifactUploads = vi.fn(); + +vi.mock("../../../signed-commit-artefacts", () => ({ + createSandboxPosthogClient: () => ({ + prepareTaskArtifactUploads, + finalizeTaskArtifactUploads, + }), +})); + +import { uploadArtifactTool } from "./upload-artifact"; + +describe("uploadArtifactTool", () => { + let cwd: string; + + beforeEach(async () => { + vi.clearAllMocks(); + cwd = await mkdtemp(path.join(os.tmpdir(), "upload-artifact-")); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 204 }), + ); + prepareTaskArtifactUploads.mockResolvedValue([ + { + id: "artifact-1", + name: "report.csv", + type: "output", + size: 7, + storage_path: "tasks/artifacts/report.csv", + expires_in: 300, + presigned_post: { + url: "https://storage.example/upload", + fields: { key: "value" }, + }, + }, + ]); + finalizeTaskArtifactUploads.mockResolvedValue([ + { + id: "artifact-1", + name: "report.csv", + type: "output", + size: 7, + storage_path: "tasks/artifacts/report.csv", + uploaded_at: "2026-01-01T00:00:00Z", + }, + ]); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("uploads and finalizes a workspace file as an output artifact", async () => { + await writeFile(path.join(cwd, "report.csv"), "a,b\n1,2"); + + const result = await uploadArtifactTool.handler( + { cwd, taskId: "task-1", taskRunId: "run-1" }, + { path: "report.csv", contentType: "text/csv" }, + ); + + expect(result.isError).toBeUndefined(); + expect(prepareTaskArtifactUploads).toHaveBeenCalledWith("task-1", "run-1", [ + { name: "report.csv", type: "output", size: 7, content_type: "text/csv" }, + ]); + expect(fetch).toHaveBeenCalledWith( + "https://storage.example/upload", + expect.objectContaining({ method: "POST", body: expect.any(FormData) }), + ); + expect(finalizeTaskArtifactUploads).toHaveBeenCalledWith( + "task-1", + "run-1", + [ + expect.objectContaining({ + id: "artifact-1", + type: "output", + storage_path: "tasks/artifacts/report.csv", + }), + ], + ); + }); + + it("rejects files outside the session workspace", async () => { + const outside = await mkdtemp(path.join(os.tmpdir(), "outside-artifact-")); + const outsideFile = path.join(outside, "secret.txt"); + await writeFile(outsideFile, "secret"); + + try { + const result = await uploadArtifactTool.handler( + { cwd, taskId: "task-1", taskRunId: "run-1" }, + { path: outsideFile }, + ); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("inside the session workspace"); + expect(prepareTaskArtifactUploads).not.toHaveBeenCalled(); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts b/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts new file mode 100644 index 0000000000..12fd7a242a --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts @@ -0,0 +1,144 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { createSandboxPosthogClient } from "../../../signed-commit-artefacts"; +import { defineLocalTool, type LocalToolResult } from "../registry"; + +const MAX_ARTIFACT_UPLOAD_BYTES = 30 * 1024 * 1024; + +export const uploadArtifactTool = defineLocalTool({ + name: "upload_artifact", + description: + "Deliver a file you created to the user as a downloadable task artifact. " + + "Call this for every non-code deliverable (reports, images, archives, data files, and similar output) " + + "before your final response. The file must be inside the session workspace. Repository changes belong in git and should not be uploaded.", + schema: { + path: z + .string() + .min(1) + .describe( + "Absolute path, or a path relative to the session working directory.", + ), + name: z + .string() + .min(1) + .optional() + .describe("Download filename. Defaults to the source filename."), + contentType: z + .string() + .min(1) + .optional() + .describe("MIME type. Defaults to application/octet-stream."), + }, + alwaysLoad: true, + isEnabled: (ctx, meta) => + meta?.environment === "cloud" && !!ctx.taskId && !!ctx.taskRunId, + handler: async (ctx, args): Promise => { + if (!ctx.taskId || !ctx.taskRunId) { + return errorResult("Artifact upload is not available in this session."); + } + + try { + const workspace = await realpath(ctx.cwd); + const requestedPath = path.resolve(ctx.cwd, args.path); + const artifactPath = await realpath(requestedPath); + if ( + artifactPath !== workspace && + !artifactPath.startsWith(`${workspace}${path.sep}`) + ) { + return errorResult("Artifact must be inside the session workspace."); + } + + const fileStat = await stat(artifactPath); + if (!fileStat.isFile()) { + return errorResult("Artifact path must point to a file."); + } + if (fileStat.size > MAX_ARTIFACT_UPLOAD_BYTES) { + return errorResult("Artifact exceeds the 30 MB upload limit."); + } + + const client = createSandboxPosthogClient(); + if (!client) { + return errorResult( + "PostHog artifact storage is not configured in this sandbox.", + ); + } + + const name = args.name ?? path.basename(artifactPath); + const contentType = args.contentType ?? "application/octet-stream"; + const prepared = await client.prepareTaskArtifactUploads( + ctx.taskId, + ctx.taskRunId, + [ + { + name, + type: "output", + size: fileStat.size, + content_type: contentType, + }, + ], + ); + const upload = prepared[0]; + if (!upload) { + return errorResult("PostHog did not prepare the artifact upload."); + } + + const form = new FormData(); + for (const [key, value] of Object.entries(upload.presigned_post.fields)) { + form.append(key, value); + } + form.append( + "file", + new Blob([await readFile(artifactPath)], { type: contentType }), + name, + ); + const response = await fetch(upload.presigned_post.url, { + method: "POST", + body: form, + }); + if (!response.ok) { + return errorResult( + `Artifact storage upload failed (${response.status}).`, + ); + } + + const finalized = await client.finalizeTaskArtifactUploads( + ctx.taskId, + ctx.taskRunId, + [ + { + id: upload.id, + name, + type: "output", + storage_path: upload.storage_path, + content_type: contentType, + }, + ], + ); + if ( + !finalized.some( + (artifact) => artifact.storage_path === upload.storage_path, + ) + ) { + return errorResult("PostHog did not confirm the artifact upload."); + } + + return { + content: [ + { + type: "text", + text: `Uploaded ${name} as a downloadable task artifact. Mention it in your final response.`, + }, + ], + }; + } catch (error) { + return errorResult( + `Artifact upload failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, +}); + +function errorResult(message: string): LocalToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} diff --git a/packages/agent/src/signed-commit-artefacts.ts b/packages/agent/src/signed-commit-artefacts.ts index c0c5818b2f..0d32f4bfdf 100644 --- a/packages/agent/src/signed-commit-artefacts.ts +++ b/packages/agent/src/signed-commit-artefacts.ts @@ -58,7 +58,7 @@ export function resolveSandboxPosthogApi( return { apiUrl, apiKey, projectId }; } -function createSandboxPosthogClient( +export function createSandboxPosthogClient( env?: Record, envFilePath?: string, ): PostHogAPIClient | undefined {