Files
PaperClipAI/cli/src/__tests__/common.test.ts
T
Aron PrinsandClaude Opus 4.8 8b85fdfa3c fix(cli): send X-Paperclip-Run-Id so agents can mutate their issues via the CLI (#7642)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents act on issues through the `paperclipai` CLI as well as the
HTTP API; the server gates agent-authenticated **mutations** of an
in-progress issue (checkout, release, interactions, PATCH, attachment
upload) behind the `X-Paperclip-Run-Id` header (`requireAgentRunId` /
`assertAgentIssueMutationAllowed`).
> - The CLI's HTTP client (`client/http.ts`) already supports sending
that header, but `resolveCommandContext` never populated `runId`, so
there was no way to provide it — every agent-authenticated mutation via
the CLI failed with `401 Agent run id required`.
> - Separately, `issue attachment:upload` hand-rolls its own multipart
`fetch` (bypassing the JSON client), so it never forwarded the run-id at
all, and its `inferContentTypeFromPath` couldn't produce `text/html` and
appended `; charset=utf-8` to `md`/`txt` — which fails the server's
exact-match content-type allowlist (`422 Unsupported attachment content
type`).
> - This PR lets the CLI send `X-Paperclip-Run-Id` from a new global
`--run-id` flag (falling back to `$PAPERCLIP_RUN_ID`), and fixes
`attachment:upload` to forward the run-id and emit server-allowed bare
MIME types.
> - The benefit is that an embodied agent can drive the full issue
lifecycle (checkout → work → disposition → upload deliverable) entirely
through the official CLI, instead of dropping to raw HTTP.

## Linked Issues or Issue Description

No issue exactly covers the CLI **send** side, so describing it here
(bug path). Related:

- `Refs #2063` — "Sub-agents cannot post comments on subtickets — Agent
run id required" (same error string; that report focuses on the server
gate, this PR fixes the CLI not sending the header for agent mutations).
- `Refs #1199` — injects `X-Paperclip-Run-Id` on the **http adapter's**
outbound request (server side). This PR is the complementary **CLI
client** side.

**Bug (per `bug_report.yml`):**
- **What happened:** Running agent-authenticated CLI mutations (`issue
checkout` / `issue update` on an in-progress issue / `issue
attachment:upload`) returns `401 Agent run id required`, even with
`--run-id`/`$PAPERCLIP_RUN_ID` set; `attachment:upload` of an
HTML/markdown deliverable additionally returns `422 Unsupported
attachment content type`.
- **Expected:** The CLI forwards the agent run-id so the server
authorizes the mutation, and uploads use a content-type the server
accepts.
- **Steps to reproduce:** As an agent token, `paperclipai issue checkout
<id> --agent-id <id>` then `paperclipai issue update <id> --status done`
(→ 401); `paperclipai issue attachment:upload <id> ./report.html` (→
401, then 422 once run-id is wired).
- **Deployment mode:** local_trusted (applies to all modes — server-side
gate is mode-independent).

## What Changed

- `cli/src/commands/client/common.ts`: resolve `runId` in
`resolveCommandContext` from a new global `--run-id` flag, falling back
to `$PAPERCLIP_RUN_ID`, so the existing HTTP client sends
`X-Paperclip-Run-Id`; thread `runId` into the attachment-upload path;
align `inferContentTypeFromPath` with the server's
`DEFAULT_ALLOWED_TYPES` (add
`html`/`htm`/`csv`/`zip`/`mp4`/`m4v`/`webm`/`mov`/`qt`, drop the `;
charset` suffix).
- `cli/src/commands/client/issue.ts`: pass `ctx.api.runId` into
`uploadAttachment` and send the `X-Paperclip-Run-Id` header on the
hand-rolled multipart request (matching what the JSON client injects
automatically).
- Tests: CLI asserts `attachment:upload` forwards `x-paperclip-run-id` +
the inferred bare MIME type, and that `inferContentTypeFromPath` covers
the allowed types; a server test locks the contract that an in-progress
checkout owner without a run-id is rejected `401` on attachment upload.

## Verification

```bash
# CLI tests (no DB)
node_modules/.bin/vitest run \
  cli/src/__tests__/common.test.ts \
  cli/src/__tests__/issue-subresources.test.ts
# → 2 files, 13 tests passed

# Server contract test (embedded postgres)
node_modules/.bin/vitest run \
  server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
# → 37 tests passed
```

Manual: with an agent token and a valid `$PAPERCLIP_RUN_ID`, `issue
checkout` / `issue update --status done` / `issue attachment:upload
./report.html` now succeed where they previously returned 401/422.

## Risks

Low. Additive only:
- `--run-id` is a new optional flag; behavior is unchanged when it (and
`$PAPERCLIP_RUN_ID`) are unset — the header is simply omitted as before.
- The content-type map only **widens** the allowed set to match the
server's existing allowlist and removes a suffix the server already
rejected, so no previously-accepted upload changes type.
- No schema/migration changes; no server behavior changes (the server
test only documents the existing gate).

## Model Used

Claude Opus 4.8 (1M context window), via Claude Code (tool use / agentic
file edits + local test execution). Extended reasoning enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (CLI-only)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green — pending CI run on this PR
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
pending review
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:55:55 -07:00

188 lines
6.8 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
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 { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
function createTempPath(name: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
return path.join(dir, name);
}
describe("resolveCommandContext", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
delete process.env.PAPERCLIP_AUTH_STORE;
delete process.env.PAPERCLIP_SERVER_PORT;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses profile defaults when options/env are not provided", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "ops",
profiles: {
ops: {
apiBase: "http://127.0.0.1:9999",
companyId: "company-profile",
apiKeyEnvVarName: "AGENT_KEY",
},
},
},
contextPath,
);
process.env.AGENT_KEY = "key-from-env";
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
expect(resolved.companyId).toBe("company-profile");
expect(resolved.api.apiKey).toBe("key-from-env");
});
it("prefers explicit options over profile values", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://profile:3100",
companyId: "company-profile",
},
},
},
contextPath,
);
const resolved = resolveCommandContext(
{
context: contextPath,
apiBase: "http://override:3200",
apiKey: "direct-token",
companyId: "company-override",
},
{ requireCompany: true },
);
expect(resolved.api.apiBase).toBe("http://override:3200");
expect(resolved.companyId).toBe("company-override");
expect(resolved.api.apiKey).toBe("direct-token");
});
it("throws when company is required but unresolved", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: { default: {} },
},
contextPath,
);
expect(() =>
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
).toThrow(/Company ID is required/);
});
it("resolves api base by explicit, env, profile, then config/default precedence", () => {
const configPath = createTempPath("config.json");
fs.writeFileSync(configPath, JSON.stringify({
$meta: { version: 1, updatedAt: "2026-05-23T00:00:00.000Z", source: "onboard" },
database: { mode: "embedded-postgres" },
logging: { mode: "file" },
server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port: 4111 },
}));
expect(resolveApiBase({ apiBase: "http://explicit:1", config: configPath }, { apiBase: "http://profile:2" }))
.toBe("http://explicit:1");
process.env.PAPERCLIP_API_URL = "http://env:3/";
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2" })).toBe("http://env:3");
delete process.env.PAPERCLIP_API_URL;
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2/" })).toBe("http://profile:2");
expect(resolveApiBase({ config: configPath }, {})).toBe("http://localhost:4111");
});
it("prefers explicit and env tokens over profile env and stored board auth", () => {
const contextPath = createTempPath("context.json");
const authStorePath = createTempPath("auth.json");
process.env.PAPERCLIP_AUTH_STORE = authStorePath;
process.env.PROFILE_KEY = "profile-token";
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "stored-board-token",
userId: "user-1",
storePath: authStorePath,
});
writeContext(
{
version: 2,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://localhost:3100",
apiKeyEnvVarName: "PROFILE_KEY",
},
},
},
contextPath,
);
const profileResolved = resolveCommandContext({ context: contextPath });
expect(profileResolved.api.apiKey).toBe("profile-token");
expect(profileResolved.authSource).toBe("profile_env");
process.env.PAPERCLIP_API_KEY = "env-token";
const envResolved = resolveCommandContext({ context: contextPath });
expect(envResolved.api.apiKey).toBe("env-token");
expect(envResolved.authSource).toBe("env");
const explicitResolved = resolveCommandContext({ context: contextPath, apiKey: "explicit-token" });
expect(explicitResolved.api.apiKey).toBe("explicit-token");
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();
});
});