Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ jobs:
if: ${{ env.RUN_CODE == 'true' }}
run: pnpm smoke:local

# Real intra-file patch reconstruction through the in-memory harness (ADR 0089
# stage 4): the unit tests use a fake reconstructor, so this is the only check
# that exercises real decrypt -> apply diff -> hash-verify -> re-encrypt -> serve.
- name: Local patch smoke
if: ${{ env.RUN_CODE == 'true' }}
run: pnpm smoke:local:patch

# The OpenAPI specs are validated by the release security attestation
# (`pnpm security:attest`), which otherwise only runs at deploy/release
# time. Gate the *deterministic* spec check here too so a contract change
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ jobs:
AGENT_PASTE_EPHEMERAL_SMOKE_WORKOS_ACCESS_TOKEN: ${{ secrets.AGENT_PASTE_EPHEMERAL_SMOKE_WORKOS_ACCESS_TOKEN }}
run: node scripts/smoke-hosted-ephemeral.mjs pr

# Real intra-file patch reconstruction against the deployed PR preview (ADR 0089
# stage 4): exercises decrypt -> apply diff -> hash-verify -> re-encrypt -> serve
# byte-exact + the patch_conflict path through the live upload/api/content Workers.
- name: Hosted patch reconstruction smoke
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
AGENT_PASTE_PR_API_URL: ${{ steps.deploy.outputs.api_url }}
AGENT_PASTE_PR_UPLOAD_URL: ${{ steps.deploy.outputs.upload_url }}
AGENT_PASTE_PR_SMOKE_HARNESS_SECRET: ${{ steps.deploy.outputs.smoke_harness_secret }}
run: node scripts/smoke-local-patch.mjs pr

- name: Lighthouse dashboard accessibility gate
env:
# Override in workflow_dispatch or forked test runs if the style-guide threshold changes.
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ _Avoid_: Empty artifact, draft artifact

<a id="revision"></a>
**Revision**:
A saved state of an **Artifact** after creation or update.
A saved state of an **Artifact** after creation or update. A **Revision** has zero or one parent **Revision** (a commit chain within the **Artifact**); a **Revision** published against a parent may inherit unchanged files from it instead of re-uploading them.
_Avoid_: Version, snapshot

<a id="draft-revision"></a>
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@agent-paste/contracts": "workspace:*",
"@agent-paste/db": "workspace:*",
"@agent-paste/rotation": "workspace:*",
"@agent-paste/storage": "workspace:*",
"@agent-paste/tokens": "workspace:*",
"@agent-paste/worker-runtime": "workspace:*",
"@agent-paste/write-allowance": "workspace:*",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ export type PaginationInput = {

export type R2ListedObject = { key: string };
export type R2Objects = { objects: R2ListedObject[]; truncated: boolean; cursor?: string };
export type R2GetObjectBody = {
body: ReadableStream | ArrayBuffer | Uint8Array | string | null | undefined;
customMetadata?: Record<string, string>;
};
export type R2Bucket = {
list(options: { prefix?: string; cursor?: string; limit?: number }): Promise<R2Objects>;
delete(keys: string | string[]): Promise<void>;
// ADR 0090: the file-content read route decrypts a stored blob. This is
// the only read on api's R2 binding; every other api op lists or deletes.
get(key: string): Promise<R2GetObjectBody | null>;
};

export type KVNamespace = {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
revokeAccessLinkRoute,
} from "./routes/access-links.js";
import { getUsagePolicy, mcpWhoami, revokeCurrentApiKey, whoami } from "./routes/account.js";
import { readArtifactFileContent } from "./routes/artifact-file-content.js";
import {
billingCheckout,
billingInvoices,
Expand Down Expand Up @@ -202,6 +203,14 @@ apiDbRegistrar.mount(contractById("agentView.getRevision"), async (context, prin
revisionId: context.req.param("revision_id") ?? "",
}),
);
apiDbRegistrar.mount(contractById("artifacts.fileContent"), async (context, principal, db) => {
const revisionId = context.req.query("revision_id");
return readArtifactFileContent(context as AppContext, principal, db, {
artifactId: context.req.param("artifact_id") ?? "",
path: context.req.query("path") ?? "",
...(revisionId ? { revisionId } : {}),
});
});
apiDbRegistrar.mount(contractById("revisions.list"), async (context, principal, db) =>
listRevisions(context as AppContext, principal, db, { artifactId: context.req.param("artifact_id") ?? "" }),
);
Expand Down
220 changes: 220 additions & 0 deletions apps/api/src/routes/artifact-file-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { McpReadFileOutput } from "@agent-paste/contracts";
import type { Repository } from "@agent-paste/db";
import {
seedEncryptedWorkspaceBlob,
testArtifactBytesEncryptionEnv,
} from "@agent-paste/storage/test-helpers/encrypted-artifact-fixture";
import { describe, expect, it } from "vitest";
import { apiPrincipal, contextFor, nonePrincipal, responseJson, workspaceId } from "../../test/route-test-helpers.js";
import type { Env, R2GetObjectBody } from "../env.js";
import { readArtifactFileContent } from "./artifact-file-content.js";

// Real sha256 of the seeded plaintext so the route's row matches the blob key.
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

function fakeR2(seed?: { key: string; body: Uint8Array; customMetadata?: Record<string, string> }): Env["ARTIFACTS"] {
const store = new Map<string, { body: Uint8Array; customMetadata?: Record<string, string> }>();
if (seed) {
store.set(seed.key, { body: seed.body, customMetadata: seed.customMetadata });
}
return {
async get(key: string): Promise<R2GetObjectBody | null> {
return store.get(key) ?? null;
},
async list() {
return { objects: [], truncated: false };
},
async delete() {},
};
}

function dbWithFile(file: Record<string, unknown> | null): Repository {
return {
async getAgentView() {
return file ? { workspace_id: workspaceId, files: [file] } : null;
},
} as unknown as Repository;
}

const ARTIFACT_ID = "art_00000000000000000000000001";

describe("artifacts.fileContent route", () => {
it("returns the decoded text body + sha256 for a text file", async () => {
const plaintext = "# Title\nhello\n";
const sha = await sha256Hex(plaintext);
const seeded = await seedEncryptedWorkspaceBlob({ workspaceId, sha256: sha, plaintext });
const env: Env = {
...testArtifactBytesEncryptionEnv,
ARTIFACTS: fakeR2({ key: seeded.objectKey, body: seeded.body, customMetadata: seeded.customMetadata }),
};
const file = { path: "index.md", sha256: sha, size_bytes: plaintext.length, content_type: "text/markdown" };

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "index.md",
});

expect(response.status).toBe(200);
const json = await responseJson(response);
expect(json).toMatchObject({ path: "index.md", sha256: sha, is_binary: false, body: plaintext });
// The strict MCP output contract must accept the real handler output unchanged
// (guards the strict-parse-500 class: no extra fields like object_key leak).
expect(McpReadFileOutput.safeParse(json).success).toBe(true);
});

it("flags binary content with is_binary and no body", async () => {
const plaintext = new Uint8Array([0xff, 0xfe, 0x00, 0x01]);
const sha = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", plaintext)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const seeded = await seedEncryptedWorkspaceBlob({ workspaceId, sha256: sha, plaintext });
const env: Env = {
...testArtifactBytesEncryptionEnv,
ARTIFACTS: fakeR2({ key: seeded.objectKey, body: seeded.body, customMetadata: seeded.customMetadata }),
};
const file = {
path: "logo.bin",
sha256: sha,
size_bytes: plaintext.length,
content_type: "application/octet-stream",
};

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "logo.bin",
});

const json = await responseJson<{ is_binary: boolean; body?: string }>(response);
expect(json.is_binary).toBe(true);
expect(json.body).toBeUndefined();
expect(McpReadFileOutput.safeParse(json).success).toBe(true);
});

it("returns oversize text as metadata without reading R2", async () => {
let getCalled = false;
const env: Env = {
...testArtifactBytesEncryptionEnv,
ARTIFACTS: {
async get() {
getCalled = true;
return null;
},
async list() {
return { objects: [], truncated: false };
},
async delete() {},
},
};
const sha = await sha256Hex("placeholder");
const file = { path: "huge.txt", sha256: sha, size_bytes: 11 * 1024 * 1024, content_type: "text/plain" };

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "huge.txt",
});

const json = await responseJson<{ is_binary: boolean; body?: string }>(response);
expect(getCalled).toBe(false);
expect(json.is_binary).toBe(false);
expect(json.body).toBeUndefined();
});

it("flags oversize binary as is_binary from content type without reading R2", async () => {
let getCalled = false;
const env: Env = {
...testArtifactBytesEncryptionEnv,
ARTIFACTS: {
async get() {
getCalled = true;
return null;
},
async list() {
return { objects: [], truncated: false };
},
async delete() {},
},
};
const sha = await sha256Hex("placeholder");
const file = {
path: "huge.bin",
sha256: sha,
size_bytes: 11 * 1024 * 1024,
content_type: "application/octet-stream",
};

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "huge.bin",
});

const json = await responseJson<{ is_binary: boolean; body?: string }>(response);
expect(getCalled).toBe(false);
expect(json.is_binary).toBe(true);
expect(json.body).toBeUndefined();
});

it("404s when the path is not in the artifact or the row has no sha256", async () => {
const env: Env = { ...testArtifactBytesEncryptionEnv, ARTIFACTS: fakeR2() };
const missing = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(null), {
artifactId: ARTIFACT_ID,
path: "index.md",
});
expect(missing.status).toBe(404);

const nullSha = await readArtifactFileContent(
contextFor({ env }),
apiPrincipal(),
dbWithFile({ path: "index.md", size_bytes: 1, content_type: "text/plain" }),
{ artifactId: ARTIFACT_ID, path: "index.md" },
);
expect(nullSha.status).toBe(404);
});

it("401s without a workspace actor", async () => {
const response = await readArtifactFileContent(
contextFor({ env: testArtifactBytesEncryptionEnv }),
nonePrincipal(),
dbWithFile(null),
{ artifactId: ARTIFACT_ID, path: "index.md" },
);
expect(response.status).toBe(401);
});

it("returns storage_unavailable when the blob is missing", async () => {
const sha = await sha256Hex("present-in-row-missing-in-r2");
const env: Env = { ...testArtifactBytesEncryptionEnv, ARTIFACTS: fakeR2() };
const file = { path: "index.md", sha256: sha, size_bytes: 10, content_type: "text/markdown" };

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "index.md",
});
expect(response.status).toBe(503);
});

it("returns storage_unavailable (not 500) when decryption fails on tampered ciphertext", async () => {
// A corrupt/auth-tag-rejected ciphertext throws a plain Error from the ring, not a
// WorkspaceBlob* error. It must still degrade to 503 (retryable), never a 500 (ADR 0090).
const plaintext = "secret\n";
const sha = await sha256Hex(plaintext);
const seeded = await seedEncryptedWorkspaceBlob({ workspaceId, sha256: sha, plaintext });
const tampered = new Uint8Array(seeded.body);
tampered[tampered.length - 1] ^= 0xff; // flip a ciphertext byte → AES-GCM auth tag fails
const env: Env = {
...testArtifactBytesEncryptionEnv,
ARTIFACTS: fakeR2({ key: seeded.objectKey, body: tampered, customMetadata: seeded.customMetadata }),
};
const file = { path: "index.md", sha256: sha, size_bytes: plaintext.length, content_type: "text/markdown" };

const response = await readArtifactFileContent(contextFor({ env }), apiPrincipal(), dbWithFile(file), {
artifactId: ARTIFACT_ID,
path: "index.md",
});
expect(response.status).toBe(503);
});
});
Loading
Loading