Skip to content

Commit 5d3b2d0

Browse files
kapaleshreyasclaude
andcommitted
feat(protocol+sdk+server): attachments — workdir uploads at session create
Lets clients ship per-request files into the agent's workdir before the engine starts. Files overlay on top of the loader-materialized GAP repo (caller wins on path collisions). Path-jailed by the existing harness fs helpers — '..', absolute paths, and symlink-outs return 400. Wire shape (CreateSessionBody.attachments + RunBody.attachments): attachments: [ { path: "data.csv", content: "name,age\nA,30\n" }, { path: "report.pdf", content: "JVBERi0...", encoding: "base64" } ] Architecture: zero changes at the engine or substrate layer. The workdir is the universal interface — every engine (claude-agent-sdk, gitagent, deepagents) already operates on cwd via its native tools, and every substrate (local, bwrap, e2b, vzvm) already exposes the workdir to its harness child. We just write the files once at session-create time, inside the harness-server's createSession service, between loader.load() and engine startup. Files touched: - packages/protocol/src/harness-rest.ts: new Attachment zod schema + CreateSessionBody.attachments field - packages/harness-server/src/services/create-session.ts: materializeAttachments helper after loader.load; PATH_ESCAPE maps to 400 with the attempted path; one info log per attachment - packages/sdk/src/types.ts: ComputerAgentOptions.attachments - packages/sdk/src/computer-agent.ts: forwards attachments into the session create body - examples/computeragent-server.ts: RunBody.attachments → passed to ComputerAgent constructor Verified locally end-to-end on LocalSubstrate: - POST /run with attachments [secret.txt utf8, binary.bin base64] - Both written to workdir (harness logs: session.attachment.written) - Agent read secret.txt back via Bash, replied with exact content Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ad916ee commit 5d3b2d0

8 files changed

Lines changed: 671 additions & 494 deletions

File tree

examples/computeragent-server.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ interface RunBody {
115115
* Pair with `sessionId` to resume an existing conversation across processes.
116116
*/
117117
sessionStore?: { kind: string; options?: unknown };
118+
/**
119+
* Files to land in the agent's workdir BEFORE the engine starts. Written
120+
* AFTER the GAP repo is materialized, so attachments overlay on top
121+
* (caller wins on path collisions). Path-jailed by the harness server.
122+
*
123+
* attachments: [
124+
* { path: "input.csv", content: "name,age\nA,30\n" },
125+
* { path: "report.pdf", content: "JVBERi0...", encoding: "base64" }
126+
* ]
127+
*
128+
* The agent's tools (Read, Bash, etc.) see them as regular files in cwd.
129+
* Works with any substrate (local/bwrap/e2b) and any harness — files
130+
* are written once into the workdir, every engine sees them natively.
131+
*/
132+
attachments?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>;
118133
}
119134

120135
interface ActiveRun {
@@ -213,6 +228,9 @@ export class ComputerAgentServer {
213228
...(body.sessionId ? { sessionId: body.sessionId } : {}),
214229
...(body.debug ? { debug: true } : {}),
215230
...(body.sessionStore ? { sessionStore: body.sessionStore as never } : {}),
231+
...(body.attachments && body.attachments.length > 0
232+
? { attachments: body.attachments }
233+
: {}),
216234
});
217235

218236
// Track the agent so /artifact can find it by sessionId while the run is live.
@@ -451,7 +469,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
451469
console.log("");
452470
console.log("Endpoints:");
453471
console.log(" GET /health runtimes + default + active count");
454-
console.log(" POST /run body: {source, harness, runtime?, message, envs?, options?, gitToken?, model?, sessionStore?, sessionId?, debug?}");
472+
console.log(" POST /run body: {source, harness, runtime?, message, envs?, options?, gitToken?, model?, sessionStore?, sessionId?, debug?, attachments?}");
455473
console.log(" GET /workdir?sessionId=<id>");
456474
console.log(" GET /artifact?sessionId=<id>&path=<path>");
457475
console.log("");

packages/harness-server/src/services/create-session.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { mkdtemp, mkdir } from "node:fs/promises";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { randomUUID } from "node:crypto";
5-
import type { CreateSessionBody } from "@computeragent/protocol";
5+
import type { Attachment, CreateSessionBody } from "@computeragent/protocol";
66
import { Session } from "../session.js";
77
import { SessionRegistry } from "../registry.js";
88
import { BadRequest } from "../error-mapper.js";
9+
import { PathEscapeError } from "../path-jail.js";
10+
import { writeBytes } from "./workspace-fs.js";
911
import type { ServerDeps } from "../app.js";
1012
import { resolveStore } from "../stores/registry.js";
1113
import { wrapValidatingStore } from "../stores/validating-store.js";
@@ -43,6 +45,14 @@ export async function createSession(
4345
workdir,
4446
});
4547

48+
// Materialize caller-supplied attachments on top of the loader's output.
49+
// Order matters: loader runs first (GAP repo files), then attachments
50+
// overlay — so a per-request file overrides a repo file with the same
51+
// name. Path-jailed by writeBytes; out-of-workdir paths → 400.
52+
if (body.attachments && body.attachments.length > 0) {
53+
await materializeAttachments(workdir, body.attachments, deps.logger);
54+
}
55+
4656
const merged = mergeEngineOptions(result.options, body.options);
4757
const final = result.harden ? result.harden(merged) : merged;
4858

@@ -122,3 +132,37 @@ function mergeEngineOptions(loaderOpts: unknown, bodyOpts: Record<string, unknow
122132
if (!loaderOpts || typeof loaderOpts !== "object" || Array.isArray(loaderOpts)) return bodyOpts;
123133
return { ...(loaderOpts as Record<string, unknown>), ...bodyOpts };
124134
}
135+
136+
/**
137+
* Write each attachment into the workdir before the engine starts.
138+
*
139+
* Path-jailed via `writeBytes` — relative paths that resolve outside the
140+
* workdir are rejected with 400 PATH_ESCAPE. Binary uploads use
141+
* `encoding: "base64"`; text uses `encoding: "utf8"` (default).
142+
*
143+
* One log line per attachment so the deployment is auditable.
144+
*/
145+
async function materializeAttachments(
146+
workdir: string,
147+
attachments: readonly Attachment[],
148+
logger: ServerDeps["logger"],
149+
): Promise<void> {
150+
for (const att of attachments) {
151+
const encoding = att.encoding ?? "utf8";
152+
const bytes =
153+
encoding === "base64" ? Buffer.from(att.content, "base64") : Buffer.from(att.content, "utf8");
154+
try {
155+
const { size } = await writeBytes(workdir, att.path, bytes);
156+
logger.info("session.attachment.written", { path: att.path, bytes: size, encoding });
157+
} catch (err) {
158+
if (err instanceof PathEscapeError) {
159+
throw BadRequest(
160+
"PATH_ESCAPE",
161+
`attachment path '${err.attempted}' resolves outside the session workdir`,
162+
{ attempted: err.attempted },
163+
);
164+
}
165+
throw err;
166+
}
167+
}
168+
}

packages/protocol/src/harness-rest.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ export const UserMessage = z.object({
2222
});
2323
export type UserMessage = z.infer<typeof UserMessage>;
2424

25+
/**
26+
* File the caller wants to land in the session workdir before the engine starts.
27+
*
28+
* - `path` is relative to the workdir. Path-jailed: `..`, absolute paths, and
29+
* symlink-outs are rejected with 400 PATH_ESCAPE.
30+
* - `content` is the file body. UTF-8 strings (default) or base64 for binary.
31+
* - Written AFTER the identity loader materializes the GAP repo, so attachments
32+
* overlay on top of repo files (caller wins on collisions).
33+
*/
34+
export const Attachment = z.object({
35+
path: z.string().min(1),
36+
content: z.string(),
37+
encoding: z.enum(["utf8", "base64"]).optional(),
38+
});
39+
export type Attachment = z.infer<typeof Attachment>;
40+
2541
/** Identity reference: which loader, with which source. */
2642
export const IdentityRef = z.object({
2743
loader: z.string().min(1),
@@ -51,6 +67,14 @@ export const CreateSessionBody = z.object({
5167
* `sessionId` IS the resume signal — no separate flag.
5268
*/
5369
sessionStore: SessionStoreConfig.optional(),
70+
/**
71+
* Files to materialize into the session workdir before the engine starts.
72+
* Written AFTER the identity loader populates the workdir, so an
73+
* attachment with the same name as a repo file overwrites it (caller
74+
* wins). Common uses: passing PDFs for analysis, CSVs for processing,
75+
* config overlays per request.
76+
*/
77+
attachments: z.array(Attachment).optional(),
5478
});
5579
export type CreateSessionBody = z.infer<typeof CreateSessionBody>;
5680

0 commit comments

Comments
 (0)