Skip to content
Merged
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
67 changes: 67 additions & 0 deletions packages/core/src/bootstrap/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadAttachments } from "./attachments.js";

const okResponse = (data: string) =>
new Response(new Blob([data]), { status: 200 });

describe("downloadAttachments", () => {
afterEach(() => vi.unstubAllGlobals());

it("dispatches all downloads in parallel, not one at a time", async () => {
const dir = mkdtempSync(join(tmpdir(), "att-"));
// fetch never settles on its own — we control it. A sequential loop would
// only have issued the FIRST fetch while waiting; parallel issues all three.
const releases: Array<(r: Response) => void> = [];
const fetchMock = vi.fn(
(url: string) => new Promise<Response>((resolve) => releases.push(() => resolve(okResponse(`body:${url}`)))),
);
vi.stubGlobal("fetch", fetchMock);

try {
const pending = downloadAttachments(
[
{ filename: "a.txt", content_type: "text/plain", url: "https://s3/a" },
{ filename: "b.txt", content_type: "text/plain", url: "https://s3/b" },
{ filename: "c.txt", content_type: "text/plain", url: "https://s3/c" },
],
dir,
);

// Let mkdir + the synchronous fan-out of fetch() calls flush.
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));

releases.forEach((release) => release());
await pending;

expect(readFileSync(join(dir, "a.txt"), "utf8")).toBe("body:https://s3/a");
expect(readFileSync(join(dir, "c.txt"), "utf8")).toBe("body:https://s3/c");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("rejects (fails boot) when any single download fails", async () => {
const dir = mkdtempSync(join(tmpdir(), "att-"));
const fetchMock = vi.fn((url: string) =>
Promise.resolve(url.endsWith("bad") ? new Response(null, { status: 500 }) : okResponse("ok")),
);
vi.stubGlobal("fetch", fetchMock);

try {
await expect(
downloadAttachments(
[
{ filename: "good.txt", content_type: "text/plain", url: "https://s3/good" },
{ filename: "boom.txt", content_type: "text/plain", url: "https://s3/bad" },
],
dir,
),
).rejects.toThrow(/download failed/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
37 changes: 37 additions & 0 deletions packages/core/src/bootstrap/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { basename, join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { log } from "../log.js";
import type { AttachmentSpec } from "../manifest/types.js";

/** Download one attachment to `dest`. Uses the Node global fetch (Node >= 18). */
export async function downloadAttachment(url: string, dest: string): Promise<void> {
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(`download failed (${res.status})`);
}
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(dest));
}

/** Download every attachment into `dir`, in parallel, sanitising filenames to
* basenames so a crafted name cannot escape the attachments directory.
*
* Concurrent rather than sequential: the set is small (throngx caps a task at
* 10 attachments) so unbounded fan-out needs no pool, and wall-clock is the
* slowest single download instead of their sum. `Promise.all` still fails loud
* — the first rejection rejects the whole call, which boot turns into a
* `StepError("attachments")`. */
export async function downloadAttachments(attachments: AttachmentSpec[], dir: string): Promise<void> {
if (attachments.length === 0) return;
await mkdir(dir, { recursive: true });
await Promise.all(
attachments.map((att) => {
const name = basename(att.filename);
const dest = join(dir, name);
log.info("downloading attachment", { name, dest });
return downloadAttachment(att.url, dest);
}),
);
}
2 changes: 2 additions & 0 deletions packages/core/src/control/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
import type { Server } from "node:http";
import { join } from "node:path";
import express, { type Express, type NextFunction, type Request, type Response } from "express";
import { downloadAttachments } from "../bootstrap/attachments.js";
import { syncOrClone } from "../bootstrap/git.js";
import { runSetupCommands } from "../bootstrap/setup.js";
import { injectGitIdentity } from "../bootstrap/git-identity.js";
Expand Down Expand Up @@ -101,6 +102,7 @@ export function defaultBootDeps(): BootDeps {
deleteCredentialConfig: () => deleteCredentialConfig(),
injectGitIdentity,
ensureWorkspace: (dir) => mkdirSync(dir, { recursive: true }),
downloadAttachments,
// `||` rather than `??` so a blank WORKSPACE_DIR falls back instead of
// resolving every clone destination against "".
workspaceRoot: process.env.WORKSPACE_DIR || join(homeDir(), "workspace"),
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/engine/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,15 @@ export interface EngineAdapter<TAgent = unknown, TConfig = unknown> {
*
* `workingDirectory` is the primary repo's destination, or the workspace root
* when the manifest carries no repos — so it is not necessarily a repository.
* See resolveWorkingDirectory. */
buildAgentConfig(manifest: Manifest<TAgent>, workingDirectory: string): TConfig;
* See resolveWorkingDirectory.
*
* `additionalDirectories` grants the engine access to directories outside
* `workingDirectory` — today, the sibling `attachments/` dir task-run.ts
* populates at boot when the manifest carries attachments. Optional, and
* defaulted by core to `[]`, so an adapter that has no notion of additional
* directories yet (see the codex adapter) stays a valid implementation of
* this interface unchanged. */
buildAgentConfig(manifest: Manifest<TAgent>, workingDirectory: string, additionalDirectories?: string[]): TConfig;

/** Start the engine's A2A server. */
createA2AServer(config: TConfig): Promise<ServerHandle>;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/manifest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export interface RepoSpec {
primary: boolean;
}

export interface AttachmentSpec {
filename: string;
content_type: string;
/** Short-lived presigned GET URL the sandbox downloads at boot. */
url: string;
}

/**
* Where `throng-creds` fetches GitHub tokens from, and the identity it presents
* when it does.
Expand Down Expand Up @@ -55,6 +62,9 @@ export interface WorkspaceManifest {
/** A literal token. Takes precedence over `credentials` when both are set. */
github_token: string | null;
setup_commands: string[];
/** Task file attachments, downloaded to a sibling `attachments/` dir at boot.
* Optional on the wire; defaults to [] when absent. */
attachments: AttachmentSpec[];
}

/** Engine-agnostic manifest skeleton owned by core: a workspace plus the commit
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/manifest/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,31 @@ describe("validate (registry routing)", () => {
});
});

describe("validate (attachments)", () => {
it("accepts a manifest with no attachments (defaults to [])", () => {
const r = validate(okInput, registry);
expect(r.ok).toBe(true);
if (r.ok) expect(r.manifest.attachments).toEqual([]);
});

it("carries valid attachments through", () => {
const input = {
...okInput,
attachments: [{ filename: "a.txt", content_type: "text/plain", url: "https://s3/x" }],
};
const r = validate(input, registry);
expect(r.ok).toBe(true);
if (r.ok) expect(r.manifest.attachments).toEqual(input.attachments);
});

it("rejects a malformed attachment entry", () => {
const input = { ...okInput, attachments: [{ filename: "a.txt" }] };
const r = validate(input, registry);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors.some((e) => e.field.startsWith("attachments"))).toBe(true);
});
});

describe("validate (empty repo list)", () => {
it("accepts repos: [] with a valid agent block", () => {
const r = validate({ repos: [], agent: { platform: "test", model: "m" } }, registry);
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/manifest/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Env } from "../env.js";
import type { AdapterRegistry, EngineAdapter } from "../engine/adapter.js";
import { log } from "../log.js";
import type {
AttachmentSpec,
BaseManifest,
CredentialsConfig,
FieldError,
Expand Down Expand Up @@ -182,6 +183,24 @@ function validateWorkspace(input: Record<string, unknown>, errors: FieldError[])
errors.push({ field: "setup_commands", reason: "each entry must be a non-empty string" });
}
}

if ("attachments" in input) {
const list = input.attachments;
if (!Array.isArray(list)) {
errors.push({ field: "attachments", reason: "must be a list" });
} else {
list.forEach((a, i) => {
if (
!isObject(a) ||
typeof a.filename !== "string" ||
typeof a.content_type !== "string" ||
typeof a.url !== "string"
) {
errors.push({ field: `attachments[${i}]`, reason: "must be {filename, content_type, url}" });
}
});
}
}
}

/** Rules that need every repo at once; run only after the per-field ones pass. */
Expand Down Expand Up @@ -338,6 +357,13 @@ function buildWorkspaceManifest(
credentials,
github_token: blankToNil(input.github_token) ?? blankToNil(env.GITHUB_TOKEN),
setup_commands: (input.setup_commands as string[] | undefined) ?? [],
attachments: Array.isArray(input.attachments)
? (input.attachments as AttachmentSpec[]).map((a) => ({
filename: a.filename,
content_type: a.content_type,
url: a.url,
}))
: [],
};
}

Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/task-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function deps(over: Partial<BootDeps> = {}): BootDeps {
deleteCredentialConfig: vi.fn(() => {}),
injectGitIdentity: vi.fn(() => {}),
ensureWorkspace: vi.fn(() => {}),
downloadAttachments: vi.fn(async () => {}),
workspaceRoot: "/home/user/workspace",
...over,
};
Expand Down Expand Up @@ -156,7 +157,7 @@ describe("TaskRun", () => {
expect(tr.lifecycle.status().state).toBe("ready");
expect(d.syncOrClone).not.toHaveBeenCalled();
expect(d.ensureWorkspace).toHaveBeenCalledWith("/home/user/workspace");
expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace");
expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace", []);
});

// Separate from the test above because it pins a different property: not that
Expand Down Expand Up @@ -191,6 +192,39 @@ describe("TaskRun", () => {
expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]);
expect(d.deleteCredentialConfig).toHaveBeenCalled();
});

it("downloads attachments into a sibling dir and grants the agent access", async () => {
const downloadAttachments = vi.fn(async () => {});
const claude = adapter();
const tr = new TaskRun(deps({ downloadAttachments }), { claude });

await tr.initialise({
...okPayload,
attachments: [{ filename: "a.txt", content_type: "text/plain", url: "https://s3/x" }],
});
await settle();

expect(downloadAttachments).toHaveBeenCalledOnce();
const [atts, dir] = downloadAttachments.mock.calls[0];
expect(atts).toHaveLength(1);
// sibling of workingDirectory (workspaceRoot/y -> workspaceRoot/attachments)
expect(dir).toBe("/home/user/workspace/attachments");

// buildAgentConfig received the attachments dir as an additional directory.
expect(claude.buildAgentConfig).toHaveBeenCalledWith(
expect.anything(),
"/home/user/workspace/y",
["/home/user/workspace/attachments"],
);
});

it("passes no extra dirs when there are no attachments", async () => {
const claude = adapter();
const tr = new TaskRun(deps(), { claude });
await tr.initialise(okPayload);
await settle();
expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace/y", []);
});
});

describe("TaskRun credential ordering", () => {
Expand Down
Loading