diff --git a/cli/src/__tests__/common.test.ts b/cli/src/__tests__/common.test.ts index 9562e47f..f436bee0 100644 --- a/cli/src/__tests__/common.test.ts +++ b/cli/src/__tests__/common.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { writeContext } from "../client/context.js"; import { setStoredBoardCredential } from "../client/board-auth.js"; -import { resolveApiBase, resolveCommandContext } from "../commands/client/common.js"; +import { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js"; const ORIGINAL_ENV = { ...process.env }; @@ -158,3 +158,30 @@ describe("resolveCommandContext", () => { expect(explicitResolved.authSource).toBe("explicit"); }); }); + +describe("inferContentTypeFromPath", () => { + it("maps the issue-attachment file types the server allows", () => { + // Must match server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES exactly. + expect(inferContentTypeFromPath("newsletter.html")).toBe("text/html"); + expect(inferContentTypeFromPath("page.htm")).toBe("text/html"); + expect(inferContentTypeFromPath("data.csv")).toBe("text/csv"); + expect(inferContentTypeFromPath("bundle.zip")).toBe("application/zip"); + expect(inferContentTypeFromPath("demo.mp4")).toBe("video/mp4"); + expect(inferContentTypeFromPath("clip.webm")).toBe("video/webm"); + expect(inferContentTypeFromPath("teaser.m4v")).toBe("video/x-m4v"); + expect(inferContentTypeFromPath("walkthrough.mov")).toBe("video/quicktime"); + expect(inferContentTypeFromPath("report.pdf")).toBe("application/pdf"); + expect(inferContentTypeFromPath("chart.png")).toBe("image/png"); + }); + + it("emits text types with no charset parameter so they match the exact allowlist", () => { + expect(inferContentTypeFromPath("notes.md")).toBe("text/markdown"); + expect(inferContentTypeFromPath("log.txt")).toBe("text/plain"); + }); + + it("is case-insensitive and returns undefined for unknown extensions", () => { + expect(inferContentTypeFromPath("/abs/Path/IMAGE.PNG")).toBe("image/png"); + expect(inferContentTypeFromPath("archive.unknownext")).toBeUndefined(); + expect(inferContentTypeFromPath("noextension")).toBeUndefined(); + }); +}); diff --git a/cli/src/__tests__/issue-subresources.test.ts b/cli/src/__tests__/issue-subresources.test.ts index fafd5de6..ce1c60b9 100644 --- a/cli/src/__tests__/issue-subresources.test.ts +++ b/cli/src/__tests__/issue-subresources.test.ts @@ -221,6 +221,40 @@ describe("issue subresource commands", () => { selectedOptionIds: ["file-a", "file-b"], }); }); + + it("forwards the agent run-id header and inferred content-type on attachment:upload", async () => { + // Regression: the multipart upload uses a hand-rolled fetch (not the JSON + // client), so it must forward X-Paperclip-Run-Id itself — otherwise an + // agent-authenticated upload is rejected with "401 Agent run id required". + const RUN_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const tmp = await mkdtemp(join(tmpdir(), "paperclip-cli-test-")); + const filePath = join(tmp, "deliverable.html"); + await writeFile(filePath, "hi", "utf8"); + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + try { + await run([ + "issue", "attachment:upload", ISSUE_ID, + "--company-id", COMPANY_ID, + "--file", filePath, + "--run-id", RUN_ID, + ]); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/issues/${ISSUE_ID}/attachments`); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + expect(headers["x-paperclip-run-id"]).toBe(RUN_ID); + expect(headers.authorization).toBe("Bearer board-token"); + const file = (init.body as FormData).get("file") as File; + expect(file.type).toBe("text/html"); + }); }); function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index 6a48b4ab..ad5095e6 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -13,6 +13,7 @@ export interface BaseClientOptions { profile?: string; apiBase?: string; apiKey?: string; + runId?: string; companyId?: string; json?: boolean; } @@ -34,6 +35,7 @@ export function addCommonClientOptions(command: Command, opts?: { includeCompany .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Paperclip API") .option("--api-key ", "Bearer token for agent-authenticated calls") + .option("--run-id ", "Heartbeat run id for agent-authenticated mutations (checkout/release/interactions/in-progress update); falls back to $PAPERCLIP_RUN_ID") .option("--json", "Output raw JSON"); if (opts?.includeCompany) { @@ -68,9 +70,16 @@ export function resolveCommandContext( ); } + // Agent-authenticated mutations (checkout, release, interactions, PATCH of an + // in-progress issue) require the X-Paperclip-Run-Id header (the server returns + // "401 Agent run id required" without it). Source it from --run-id, else the + // PAPERCLIP_RUN_ID env the adapter/embodiment context already exports. + const runId = options.runId?.trim() || process.env.PAPERCLIP_RUN_ID?.trim() || undefined; + const api = new PaperclipApiClient({ apiBase, apiKey, + runId, recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth() ? undefined : async ({ error }) => { @@ -126,18 +135,32 @@ export function apiPath(strings: TemplateStringsArray, ...values: Array { const bytes = await readFile(input.filePath); const form = new FormData(); form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(input.filePath) }), input.filePath.split(/[\\/]/).pop() ?? "attachment"); if (input.commentId) form.set("issueCommentId", input.commentId); + // This multipart upload uses a hand-rolled fetch rather than PaperclipApiClient, + // so it must forward the agent run-id header itself — otherwise an + // agent-authenticated upload is rejected with "401 Agent run id required" + // (the client injects x-paperclip-run-id automatically for JSON requests). + const headers: Record = {}; + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + if (input.runId) headers["x-paperclip-run-id"] = input.runId; const response = await fetch(buildApiUrl(apiBase, apiPath`/api/companies/${input.companyId}/issues/${input.issueId}/attachments`), { method: "POST", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + headers, body: form, }); return parseFetchResponse(response); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 1fed2d47..31bf37ff 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -608,6 +608,27 @@ describe("agent issue mutation checkout ownership", () => { expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); }); + it("rejects the checked-out owner without a run id on attachment upload (401)", async () => { + // Regression: an agent-authenticated client (e.g. the CLI's attachment:upload) + // that fails to send X-Paperclip-Run-Id must be rejected — mutating your own + // in-progress checkout requires proving run ownership. + const app = await createApp({ + type: "agent", + agentId: ownerAgentId, + companyId, + source: "agent_key", + // intentionally no runId + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) + .attach("file", Buffer.from("report"), { filename: "report.html", contentType: "text/html" }); + + expect(res.status, JSON.stringify(res.body)).toBe(401); + expect(res.body.error).toBe("Agent run id required"); + expect(mockStorageService.putFile).not.toHaveBeenCalled(); + }); + it("allows the checked-out owner with the matching run id to patch and update documents", async () => { const app = await createApp(ownerActor());