mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +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>
204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
import type { Command } from "commander";
|
|
import {
|
|
getStoredBoardCredential,
|
|
loginBoardCli,
|
|
removeStoredBoardCredential,
|
|
revokeStoredBoardCredential,
|
|
} from "../../client/board-auth.js";
|
|
import {
|
|
addCommonClientOptions,
|
|
apiPath,
|
|
handleCommandError,
|
|
printOutput,
|
|
resolveCommandContext,
|
|
type BaseClientOptions,
|
|
} from "./common.js";
|
|
|
|
interface AuthLoginOptions extends BaseClientOptions {
|
|
instanceAdmin?: boolean;
|
|
browser?: boolean;
|
|
}
|
|
|
|
interface AuthLogoutOptions extends BaseClientOptions {}
|
|
interface AuthWhoamiOptions extends BaseClientOptions {}
|
|
interface AuthChallengeOptions extends BaseClientOptions {
|
|
payloadJson?: string;
|
|
token?: string;
|
|
tokenEnv?: string;
|
|
}
|
|
|
|
export function registerClientAuthCommands(auth: Command): void {
|
|
addCommonClientOptions(
|
|
auth
|
|
.command("login")
|
|
.description("Authenticate the CLI for board-user access")
|
|
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
|
|
.option("--no-browser", "Don't try to open a browser; just print the approval URL")
|
|
.action(async (opts: AuthLoginOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const login = await loginBoardCli({
|
|
apiBase: ctx.api.apiBase,
|
|
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
|
|
requestedCompanyId: ctx.companyId ?? null,
|
|
command: "paperclipai auth login",
|
|
openBrowser: opts.browser,
|
|
});
|
|
printOutput(
|
|
{
|
|
ok: true,
|
|
apiBase: ctx.api.apiBase,
|
|
userId: login.userId ?? null,
|
|
approvalUrl: login.approvalUrl,
|
|
},
|
|
{ json: ctx.json },
|
|
);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
auth
|
|
.command("logout")
|
|
.description("Remove the stored board-user credential for this API base")
|
|
.action(async (opts: AuthLogoutOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const credential = getStoredBoardCredential(ctx.api.apiBase);
|
|
if (!credential) {
|
|
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
|
|
return;
|
|
}
|
|
let revoked = false;
|
|
try {
|
|
await revokeStoredBoardCredential({
|
|
apiBase: ctx.api.apiBase,
|
|
token: credential.token,
|
|
});
|
|
revoked = true;
|
|
} catch {
|
|
// Remove the local credential even if the server-side revoke fails.
|
|
}
|
|
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
|
|
printOutput(
|
|
{
|
|
ok: true,
|
|
apiBase: ctx.api.apiBase,
|
|
revoked,
|
|
removedLocalCredential,
|
|
},
|
|
{ json: ctx.json },
|
|
);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
auth
|
|
.command("revoke-current")
|
|
.description("Revoke the current board API token")
|
|
.action(async (opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.post("/api/cli-auth/revoke-current", {}), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
auth
|
|
.command("whoami")
|
|
.description("Show the current board-user identity for this API base")
|
|
.action(async (opts: AuthWhoamiOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const me = await ctx.api.get<{
|
|
user: { id: string; name: string; email: string } | null;
|
|
userId: string;
|
|
isInstanceAdmin: boolean;
|
|
companyIds: string[];
|
|
source: string;
|
|
keyId: string | null;
|
|
}>("/api/cli-auth/me");
|
|
printOutput(me, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
const challenge = auth.command("challenge").description("CLI auth challenge operations");
|
|
addCommonClientOptions(
|
|
challenge
|
|
.command("create")
|
|
.description("Create a CLI auth challenge")
|
|
.requiredOption("--payload-json <json>", "CreateCliAuthChallenge JSON payload")
|
|
.action(async (opts: AuthChallengeOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.post("/api/cli-auth/challenges", parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
addCommonClientOptions(
|
|
challenge
|
|
.command("get")
|
|
.description("Get a CLI auth challenge")
|
|
.argument("<id>", "Challenge ID")
|
|
.option("--token <token>", "Challenge secret")
|
|
.option("--token-env <name>", "Read the challenge secret from an environment variable")
|
|
.action(async (id: string, opts: AuthChallengeOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const query = new URLSearchParams({ token: resolveChallengeToken(opts) });
|
|
printOutput(await ctx.api.get(`${apiPath`/api/cli-auth/challenges/${id}`}?${query.toString()}`), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
for (const action of ["approve", "cancel"] as const) {
|
|
addCommonClientOptions(
|
|
challenge
|
|
.command(action)
|
|
.description(`${action} a CLI auth challenge`)
|
|
.argument("<id>", "Challenge ID")
|
|
.option("--token <token>", "Challenge secret")
|
|
.option("--token-env <name>", "Read the challenge secret from an environment variable")
|
|
.action(async (id: string, opts: AuthChallengeOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
printOutput(await ctx.api.post(`${apiPath`/api/cli-auth/challenges/${id}`}/${action}`, { token: resolveChallengeToken(opts) }), { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseJson(value: string): unknown {
|
|
return JSON.parse(value) as unknown;
|
|
}
|
|
|
|
function resolveChallengeToken(opts: AuthChallengeOptions): string {
|
|
const token = opts.token?.trim();
|
|
if (token) return token;
|
|
const envName = opts.tokenEnv?.trim();
|
|
if (envName) {
|
|
const envValue = process.env[envName]?.trim();
|
|
if (envValue) return envValue;
|
|
throw new Error(`Environment variable ${envName} is empty or not set.`);
|
|
}
|
|
throw new Error("Challenge secret is required. Pass --token or --token-env.");
|
|
}
|