mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
## Thinking Path > - Paperclip is the open-source app for managing AI agents, very commonly self-hosted as a headless Docker container. > - Its CLI authorizes board/admin actions through an interactive browser-approval flow (`auth login`, and the auto-recover path behind commands like `run list`). > - That flow calls `openUrl()`, which `spawn`s the OS browser opener (`xdg-open` on Linux). > - In a headless container there is no `xdg-open`; `spawn` reports the missing binary asynchronously via an `'error'` event, which the surrounding `try/catch` cannot catch, so Node aborts the process before the approval can be polled. > - This makes CLI board auth impossible in the most common self-hosted deployment shape. > - This PR makes `openUrl` error-handled, async, and truthful, and adds headless affordances. > - The benefit is that board-authenticated CLI commands degrade gracefully and work headless instead of crashing. ## Linked Issues or Issue Description Closes #7941 ## What Changed - `openUrl` (`cli/src/client/board-auth.ts`) is now async and attaches an `'error'` listener to the spawned opener: resolves `false` on async spawn failure (missing binary) or sync throw, and `true` only on a successful `'spawn'`. Fixes the unhandled-`'error'` crash and makes the return value honest. - `loginBoardCli` prints an accurate "couldn't open a browser" message, supports `--no-browser` / `PAPERCLIP_NO_BROWSER` to skip the open attempt, and renders the approval URL from `PAPERCLIP_PUBLIC_URL` (or `publicBaseUrl`) so it's reachable from a remote operator's browser. - Updated the three other `openUrl` call sites (`cloud.ts` ×2, `company.ts`) to `await` it. - Added the `auth login --no-browser` flag. - Tests: new `open-url.test.ts` (launch → true, async ENOENT → false, sync throw → false); extended auth-command-registration test for `--no-browser`. ## Verification - `pnpm --filter paperclipai typecheck` — clean. - `pnpm exec vitest run cli/src/__tests__/open-url.test.ts cli/src/__tests__/auth-command-registration.test.ts` — pass. - Manual, in a headless container with no `xdg-open`: `pnpm paperclipai auth login -C <company-id>` now prints the approval URL and waits (previously crashed with `spawn xdg-open ENOENT`); `--no-browser` skips the open attempt; `PAPERCLIP_PUBLIC_URL=...` renders a reachable approval URL; completing approval in a browser stores the credential and `run list` works. ## Risks Low. `openUrl` became async; all four call sites updated to `await`. Desktop behavior is unchanged (successful spawn still resolves true and opens the browser). No API, schema, or migration changes. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`) via Claude Code, with extended thinking and tool use, used to diagnose the bug and draft the fix and tests. Human-reviewed and tested on a live headless deployment. ## 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 - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — CLI only) - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import pc from "picocolors";
|
|
import { buildCliCommandLabel } from "./command-label.js";
|
|
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
|
|
|
type RequestedAccess = "board" | "instance_admin_required";
|
|
|
|
interface BoardAuthCredential {
|
|
apiBase: string;
|
|
token: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
userId?: string | null;
|
|
}
|
|
|
|
interface BoardAuthStore {
|
|
version: 1;
|
|
credentials: Record<string, BoardAuthCredential>;
|
|
}
|
|
|
|
interface CreateChallengeResponse {
|
|
id: string;
|
|
token: string;
|
|
boardApiToken: string;
|
|
approvalPath: string;
|
|
approvalUrl: string | null;
|
|
pollPath: string;
|
|
expiresAt: string;
|
|
suggestedPollIntervalMs: number;
|
|
}
|
|
|
|
interface ChallengeStatusResponse {
|
|
id: string;
|
|
status: "pending" | "approved" | "cancelled" | "expired";
|
|
command: string;
|
|
clientName: string | null;
|
|
requestedAccess: RequestedAccess;
|
|
requestedCompanyId: string | null;
|
|
requestedCompanyName: string | null;
|
|
approvedAt: string | null;
|
|
cancelledAt: string | null;
|
|
expiresAt: string;
|
|
approvedByUser: { id: string; name: string; email: string } | null;
|
|
}
|
|
|
|
function defaultBoardAuthStore(): BoardAuthStore {
|
|
return {
|
|
version: 1,
|
|
credentials: {},
|
|
};
|
|
}
|
|
|
|
function toStringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function normalizeApiBase(apiBase: string): string {
|
|
return apiBase.trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function isTruthyEnv(value: string | undefined): boolean {
|
|
const v = value?.trim().toLowerCase();
|
|
return v === "1" || v === "true" || v === "yes";
|
|
}
|
|
|
|
export function resolveBoardAuthStorePath(overridePath?: string): string {
|
|
if (overridePath?.trim()) return path.resolve(overridePath.trim());
|
|
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
|
|
return resolveDefaultCliAuthPath();
|
|
}
|
|
|
|
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
|
|
const filePath = resolveBoardAuthStorePath(storePath);
|
|
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
|
|
|
|
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
|
|
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
|
|
const normalized: Record<string, BoardAuthCredential> = {};
|
|
|
|
for (const [key, value] of Object.entries(credentials)) {
|
|
if (typeof value !== "object" || value === null) continue;
|
|
const record = value as unknown as Record<string, unknown>;
|
|
const apiBase = toStringOrNull(record.apiBase);
|
|
const token = toStringOrNull(record.token);
|
|
const createdAt = toStringOrNull(record.createdAt);
|
|
const updatedAt = toStringOrNull(record.updatedAt);
|
|
if (!apiBase || !token || !createdAt || !updatedAt) continue;
|
|
normalized[normalizeApiBase(key)] = {
|
|
apiBase,
|
|
token,
|
|
createdAt,
|
|
updatedAt,
|
|
userId: toStringOrNull(record.userId),
|
|
};
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
credentials: normalized,
|
|
};
|
|
}
|
|
|
|
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
|
|
const filePath = resolveBoardAuthStorePath(storePath);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
|
|
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
|
|
const store = readBoardAuthStore(storePath);
|
|
return store.credentials[normalizeApiBase(apiBase)] ?? null;
|
|
}
|
|
|
|
export function setStoredBoardCredential(input: {
|
|
apiBase: string;
|
|
token: string;
|
|
userId?: string | null;
|
|
storePath?: string;
|
|
}): BoardAuthCredential {
|
|
const normalizedApiBase = normalizeApiBase(input.apiBase);
|
|
const store = readBoardAuthStore(input.storePath);
|
|
const now = new Date().toISOString();
|
|
const existing = store.credentials[normalizedApiBase];
|
|
const credential: BoardAuthCredential = {
|
|
apiBase: normalizedApiBase,
|
|
token: input.token.trim(),
|
|
createdAt: existing?.createdAt ?? now,
|
|
updatedAt: now,
|
|
userId: input.userId ?? existing?.userId ?? null,
|
|
};
|
|
store.credentials[normalizedApiBase] = credential;
|
|
writeBoardAuthStore(store, input.storePath);
|
|
return credential;
|
|
}
|
|
|
|
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
|
|
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
const store = readBoardAuthStore(storePath);
|
|
if (!store.credentials[normalizedApiBase]) return false;
|
|
delete store.credentials[normalizedApiBase];
|
|
writeBoardAuthStore(store, storePath);
|
|
return true;
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const headers = new Headers(init?.headers ?? undefined);
|
|
if (init?.body !== undefined && !headers.has("content-type")) {
|
|
headers.set("content-type", "application/json");
|
|
}
|
|
if (!headers.has("accept")) {
|
|
headers.set("accept", "application/json");
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
...init,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
const message =
|
|
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
|
|
? (body as { error: string }).error
|
|
: `Request failed: ${response.status}`;
|
|
throw new Error(message);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export async function openUrl(url: string): Promise<boolean> {
|
|
const { command, args } =
|
|
process.platform === "darwin"
|
|
? { command: "open", args: [url] }
|
|
: process.platform === "win32"
|
|
? { command: "cmd", args: ["/c", "start", "", url] }
|
|
: { command: "xdg-open", args: [url] };
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
let child: ChildProcess;
|
|
try {
|
|
child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
} catch {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
child.once("error", () => resolve(false));
|
|
child.once("spawn", () => {
|
|
child.unref();
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function loginBoardCli(params: {
|
|
apiBase: string;
|
|
requestedAccess: RequestedAccess;
|
|
requestedCompanyId?: string | null;
|
|
clientName?: string | null;
|
|
command?: string;
|
|
storePath?: string;
|
|
print?: boolean;
|
|
openBrowser?: boolean;
|
|
publicBaseUrl?: string;
|
|
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
|
|
const apiBase = normalizeApiBase(params.apiBase);
|
|
const createUrl = `${apiBase}/api/cli-auth/challenges`;
|
|
const command = params.command?.trim() || buildCliCommandLabel();
|
|
|
|
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
command,
|
|
clientName: params.clientName?.trim() || "paperclipai cli",
|
|
requestedAccess: params.requestedAccess,
|
|
requestedCompanyId: params.requestedCompanyId?.trim() || null,
|
|
}),
|
|
});
|
|
|
|
const publicBase = params.publicBaseUrl?.trim() || process.env.PAPERCLIP_PUBLIC_URL?.trim();
|
|
const approvalUrl = publicBase
|
|
? `${normalizeApiBase(publicBase)}${challenge.approvalPath}`
|
|
: challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
|
|
|
if (params.print !== false) {
|
|
console.error(pc.bold("Board authentication required"));
|
|
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
|
}
|
|
|
|
const wantBrowser = params.openBrowser !== false && !isTruthyEnv(process.env.PAPERCLIP_NO_BROWSER);
|
|
const opened = wantBrowser ? await openUrl(approvalUrl) : false;
|
|
if (params.print !== false) {
|
|
const browserMessage = !wantBrowser
|
|
? "Browser open skipped — open the URL above to approve."
|
|
: opened
|
|
? "Opened the approval page in your browser."
|
|
: "Couldn't open a browser automatically — open the URL above to approve.";
|
|
console.error(pc.dim(browserMessage));
|
|
}
|
|
|
|
const expiresAtMs = Date.parse(challenge.expiresAt);
|
|
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
|
|
|
|
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
|
|
const status = await requestJson<ChallengeStatusResponse>(
|
|
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
|
|
);
|
|
|
|
if (status.status === "approved") {
|
|
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
|
|
`${apiBase}/api/cli-auth/me`,
|
|
{
|
|
headers: {
|
|
authorization: `Bearer ${challenge.boardApiToken}`,
|
|
},
|
|
},
|
|
);
|
|
setStoredBoardCredential({
|
|
apiBase,
|
|
token: challenge.boardApiToken,
|
|
userId: me.userId ?? me.user?.id ?? null,
|
|
storePath: params.storePath,
|
|
});
|
|
return {
|
|
token: challenge.boardApiToken,
|
|
approvalUrl,
|
|
userId: me.userId ?? me.user?.id ?? null,
|
|
};
|
|
}
|
|
|
|
if (status.status === "cancelled") {
|
|
throw new Error("CLI auth challenge was cancelled.");
|
|
}
|
|
if (status.status === "expired") {
|
|
throw new Error("CLI auth challenge expired before approval.");
|
|
}
|
|
|
|
await sleep(pollMs);
|
|
}
|
|
|
|
throw new Error("CLI auth challenge expired before approval.");
|
|
}
|
|
|
|
export async function revokeStoredBoardCredential(params: {
|
|
apiBase: string;
|
|
token: string;
|
|
}): Promise<void> {
|
|
const apiBase = normalizeApiBase(params.apiBase);
|
|
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${params.token}`,
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|