mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 20:53:23 +02:00
## 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>
299 lines
9.8 KiB
TypeScript
299 lines
9.8 KiB
TypeScript
import pc from "picocolors";
|
|
import type { Command } from "commander";
|
|
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
|
|
import { buildCliCommandLabel } from "../../client/command-label.js";
|
|
import { readConfig } from "../../config/store.js";
|
|
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
|
|
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
|
|
|
|
export interface BaseClientOptions {
|
|
config?: string;
|
|
dataDir?: string;
|
|
context?: string;
|
|
profile?: string;
|
|
apiBase?: string;
|
|
apiKey?: string;
|
|
runId?: string;
|
|
companyId?: string;
|
|
json?: boolean;
|
|
}
|
|
|
|
export interface ResolvedClientContext {
|
|
api: PaperclipApiClient;
|
|
companyId?: string;
|
|
profileName: string;
|
|
profile: ClientContextProfile;
|
|
json: boolean;
|
|
authSource: "explicit" | "env" | "profile_env" | "stored_board" | "none";
|
|
}
|
|
|
|
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
|
|
command
|
|
.option("-c, --config <path>", "Path to Paperclip config file")
|
|
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
|
.option("--context <path>", "Path to CLI context file")
|
|
.option("--profile <name>", "CLI context profile name")
|
|
.option("--api-base <url>", "Base URL for the Paperclip API")
|
|
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
|
|
.option("--run-id <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) {
|
|
command.option("-C, --company-id <id>", "Company ID (overrides context default)");
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
export function resolveCommandContext(
|
|
options: BaseClientOptions,
|
|
opts?: { requireCompany?: boolean },
|
|
): ResolvedClientContext {
|
|
const context = readContext(options.context);
|
|
const { name: profileName, profile } = resolveProfile(context, options.profile);
|
|
|
|
const apiBase = resolveApiBase(options, profile);
|
|
|
|
const resolvedApiKey = resolveApiKey(options, profile);
|
|
const explicitApiKey = resolvedApiKey.value;
|
|
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
|
|
const apiKey = explicitApiKey || storedBoardCredential?.token;
|
|
|
|
const companyId =
|
|
options.companyId?.trim() ||
|
|
process.env.PAPERCLIP_COMPANY_ID?.trim() ||
|
|
profile.companyId;
|
|
|
|
if (opts?.requireCompany && !companyId) {
|
|
throw new Error(
|
|
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclipai context set`.",
|
|
);
|
|
}
|
|
|
|
// 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 }) => {
|
|
const requestedAccess = error.message.includes("Instance admin required")
|
|
? "instance_admin_required"
|
|
: "board";
|
|
if (!shouldRecoverBoardAuth(error)) {
|
|
return null;
|
|
}
|
|
const login = await loginBoardCli({
|
|
apiBase,
|
|
requestedAccess,
|
|
requestedCompanyId: companyId ?? null,
|
|
command: buildCliCommandLabel(),
|
|
});
|
|
return login.token;
|
|
},
|
|
});
|
|
return {
|
|
api,
|
|
companyId,
|
|
profileName,
|
|
profile,
|
|
json: Boolean(options.json),
|
|
authSource: explicitApiKey ? resolvedApiKey.source : storedBoardCredential ? "stored_board" : "none",
|
|
};
|
|
}
|
|
|
|
export function resolveApiBase(options: Pick<BaseClientOptions, "apiBase" | "config">, profile: ClientContextProfile = {}): string {
|
|
return normalizeApiBase(
|
|
options.apiBase?.trim() ||
|
|
process.env.PAPERCLIP_API_URL?.trim() ||
|
|
profile.apiBase ||
|
|
inferApiBaseFromConfig(options.config),
|
|
);
|
|
}
|
|
|
|
export function normalizeApiBase(apiBase: string): string {
|
|
return apiBase.trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
export function apiPath(strings: TemplateStringsArray, ...values: Array<string | number | boolean | null | undefined>): string {
|
|
let path = strings[0] ?? "";
|
|
values.forEach((value, index) => {
|
|
if (value === null || value === undefined || String(value).trim() === "") {
|
|
throw new Error("Cannot build API path with an empty path segment.");
|
|
}
|
|
path += `${encodeURIComponent(String(value))}${strings[index + 1] ?? ""}`;
|
|
});
|
|
return path;
|
|
}
|
|
|
|
export function inferContentTypeFromPath(filePath: string): string | undefined {
|
|
const ext = filePath.split(/[\\/]/).pop()?.split(".").pop()?.toLowerCase();
|
|
if (!ext) return undefined;
|
|
// These MIME strings are matched against the server's issue-attachment
|
|
// allowlist (server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES) by EXACT
|
|
// string, so text types must carry no "; charset=..." parameter or the upload
|
|
// is rejected with "422 Unsupported attachment content type". Keep this set in
|
|
// sync with that allowlist (plus svg/avif, accepted by the asset routes).
|
|
return {
|
|
avif: "image/avif",
|
|
csv: "text/csv",
|
|
gif: "image/gif",
|
|
htm: "text/html",
|
|
html: "text/html",
|
|
jpeg: "image/jpeg",
|
|
jpg: "image/jpeg",
|
|
json: "application/json",
|
|
m4v: "video/x-m4v",
|
|
md: "text/markdown",
|
|
mov: "video/quicktime",
|
|
mp4: "video/mp4",
|
|
pdf: "application/pdf",
|
|
png: "image/png",
|
|
qt: "video/quicktime",
|
|
svg: "image/svg+xml",
|
|
txt: "text/plain",
|
|
webm: "video/webm",
|
|
webp: "image/webp",
|
|
zip: "application/zip",
|
|
}[ext];
|
|
}
|
|
|
|
function resolveApiKey(
|
|
options: Pick<BaseClientOptions, "apiKey">,
|
|
profile: ClientContextProfile,
|
|
): { value: string | undefined; source: "explicit" | "env" | "profile_env" | "none" } {
|
|
const optionValue = options.apiKey?.trim();
|
|
if (optionValue) return { value: optionValue, source: "explicit" };
|
|
|
|
const envValue = process.env.PAPERCLIP_API_KEY?.trim();
|
|
if (envValue) return { value: envValue, source: "env" };
|
|
|
|
const profileEnvValue = readKeyFromProfileEnv(profile);
|
|
if (profileEnvValue) return { value: profileEnvValue, source: "profile_env" };
|
|
|
|
return { value: undefined, source: "none" };
|
|
}
|
|
|
|
function shouldRecoverBoardAuth(error: ApiRequestError): boolean {
|
|
if (error.status === 401) return true;
|
|
if (error.status !== 403) return false;
|
|
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
|
|
}
|
|
|
|
function canAttemptInteractiveBoardAuth(): boolean {
|
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
}
|
|
|
|
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(data, null, 2));
|
|
return;
|
|
}
|
|
|
|
if (opts.label) {
|
|
console.log(pc.bold(opts.label));
|
|
}
|
|
|
|
if (Array.isArray(data)) {
|
|
if (data.length === 0) {
|
|
console.log(pc.dim("(empty)"));
|
|
return;
|
|
}
|
|
for (const item of data) {
|
|
if (typeof item === "object" && item !== null) {
|
|
console.log(formatInlineRecord(item as Record<string, unknown>));
|
|
} else {
|
|
console.log(String(item));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof data === "object" && data !== null) {
|
|
console.log(JSON.stringify(data, null, 2));
|
|
return;
|
|
}
|
|
|
|
if (data === undefined || data === null) {
|
|
console.log(pc.dim("(null)"));
|
|
return;
|
|
}
|
|
|
|
console.log(String(data));
|
|
}
|
|
|
|
export function formatInlineRecord(record: Record<string, unknown>): string {
|
|
const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"];
|
|
const seen = new Set<string>();
|
|
const parts: string[] = [];
|
|
|
|
for (const key of keyOrder) {
|
|
if (!(key in record)) continue;
|
|
parts.push(`${key}=${renderValue(record[key])}`);
|
|
seen.add(key);
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (seen.has(key)) continue;
|
|
if (typeof value === "object") continue;
|
|
parts.push(`${key}=${renderValue(value)}`);
|
|
}
|
|
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function renderValue(value: unknown): string {
|
|
if (value === null || value === undefined) return "-";
|
|
if (typeof value === "string") {
|
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
return compact.length > 90 ? `${compact.slice(0, 87)}...` : compact;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
return "[object]";
|
|
}
|
|
|
|
export function inferApiBaseFromConfig(configPath?: string): string {
|
|
const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost";
|
|
let port = Number(process.env.PAPERCLIP_SERVER_PORT || "");
|
|
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|
try {
|
|
const config = readConfig(configPath);
|
|
port = Number(config?.server?.port ?? 3100);
|
|
} catch {
|
|
port = 3100;
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|
port = 3100;
|
|
}
|
|
|
|
return `http://${envHost}:${port}`;
|
|
}
|
|
|
|
function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined {
|
|
if (!profile.apiKeyEnvVarName) return undefined;
|
|
return process.env[profile.apiKeyEnvVarName]?.trim() || undefined;
|
|
}
|
|
|
|
export function handleCommandError(error: unknown): never {
|
|
if (error instanceof ApiRequestError) {
|
|
const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : "";
|
|
console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(pc.red(message));
|
|
process.exit(1);
|
|
}
|