Add Claude subscription auth controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 14:28:09 +00:00
1 parent ae59be5ead
commit db1e46f3dc
8 files changed
+342 -72

No files matched your search

@@ -111,6 +111,7 @@ function buildLoginResult(input: {
stdout: input.proc.stdout,
stderr: input.proc.stderr,
loginUrl: input.loginUrl,
authUrl: input.loginUrl,
};
}
@@ -331,6 +332,18 @@ export async function runClaudeLogin(input: {
context?: Record<string, unknown>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) {
return runClaudeSubscriptionLogin(input);
}
export async function runClaudeSubscriptionLogin(input: {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context?: Record<string, unknown>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
timeoutSec?: number;
}) {
const onLog = input.onLog ?? (async () => {});
const runtime = await buildClaudeRuntimeConfig({
@@ -341,13 +354,19 @@ export async function runClaudeLogin(input: {
authToken: input.authToken,
});
const proc = await runAdapterExecutionTargetProcess(input.runId, null, runtime.command, ["login"], {
cwd: runtime.cwd,
env: runtime.env,
timeoutSec: runtime.timeoutSec,
graceSec: runtime.graceSec,
onLog,
});
const proc = await runAdapterExecutionTargetProcess(
input.runId,
null,
runtime.command,
["auth", "login", "--claudeai"],
{
cwd: runtime.cwd,
env: runtime.env,
timeoutSec: input.timeoutSec ?? runtime.timeoutSec,
graceSec: runtime.graceSec,
onLog,
},
);
const loginMeta = detectClaudeLoginRequired({
parsed: null,
@@ -361,6 +380,58 @@ export async function runClaudeLogin(input: {
});
}
export async function runClaudeSubscriptionAuthUrl(input: {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context?: Record<string, unknown>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
timeoutSec?: number;
}) {
return runClaudeSubscriptionLogin({
...input,
timeoutSec: input.timeoutSec ?? 8,
});
}
export async function runClaudeLogout(input: {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context?: Record<string, unknown>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) {
const onLog = input.onLog ?? (async () => {});
const runtime = await buildClaudeRuntimeConfig({
runId: input.runId,
agent: input.agent,
config: input.config,
context: input.context ?? {},
authToken: input.authToken,
});
const proc = await runAdapterExecutionTargetProcess(
input.runId,
null,
runtime.command,
["auth", "logout"],
{
cwd: runtime.cwd,
env: runtime.env,
timeoutSec: runtime.timeoutSec,
graceSec: runtime.graceSec,
onLog,
},
);
return buildLoginResult({
proc,
loginUrl: null,
});
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
@@ -1,4 +1,11 @@
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
export {
claudeSessionCwdMatchesExecutionTarget,
execute,
runClaudeLogin,
runClaudeLogout,
runClaudeSubscriptionAuthUrl,
runClaudeSubscriptionLogin,
} from "./execute.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js";
export { testEnvironment } from "./test.js";
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
extractClaudeLoginUrl,
extractClaudeRetryNotBefore,
isClaudeTransientUpstreamError,
isClaudePoisonedPreviousMessageIdError,
@@ -7,6 +8,31 @@ import {
isClaudeImageProcessingError,
} from "./parse.js";
describe("extractClaudeLoginUrl", () => {
it("extracts the subscription auth URL from Claude CLI login output", () => {
const output = [
"Opening browser to sign in...",
"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=abc&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback.",
"Paste code here if prompted > ",
].join("\n");
expect(extractClaudeLoginUrl(output)).toBe(
"https://claude.com/cai/oauth/authorize?code=true&client_id=abc&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback",
);
});
it("prefers Claude auth URLs when other links are present", () => {
const output = [
"Docs: https://example.com/docs",
"Open https://claude.com/cai/oauth/authorize?state=paperclip to continue.",
].join("\n");
expect(extractClaudeLoginUrl(output)).toBe(
"https://claude.com/cai/oauth/authorize?state=paperclip",
);
});
});
describe("isClaudeTransientUpstreamError", () => {
it("classifies the 'out of extra usage' subscription window failure as transient", () => {
expect(
@@ -7,7 +7,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{}]+)/gi;
const CLAUDE_TRANSIENT_UPSTREAM_RE =
/(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached)/i;
+62 -5
View File
@@ -78,7 +78,11 @@ import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
runClaudeLogin,
runClaudeLogout,
runClaudeSubscriptionAuthUrl,
} from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
@@ -3258,20 +3262,31 @@ export function agentRoutes(
res.status(202).json(run);
});
router.post("/agents/:id/claude-login", async (req, res) => {
type ClaudeLocalAgent = NonNullable<Awaited<ReturnType<typeof svc.getById>>>;
const getClaudeLocalAgentForBoardAction = async (
req: Request,
res: Response,
actionLabel: string,
): Promise<ClaudeLocalAgent | null> => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
return null;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
if (agent.adapterType !== "claude_local") {
res.status(400).json({ error: "Login is only supported for claude_local agents" });
return;
res.status(400).json({ error: `${actionLabel} is only supported for claude_local agents` });
return null;
}
return agent;
};
router.post("/agents/:id/claude-login", async (req, res) => {
const agent = await getClaudeLocalAgentForBoardAction(req, res, "Login");
if (!agent) return;
const config = asRecord(agent.adapterConfig) ?? {};
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
@@ -3290,6 +3305,48 @@ export function agentRoutes(
res.json(result);
});
router.post("/agents/:id/claude-subscription-auth-url", async (req, res) => {
const agent = await getClaudeLocalAgentForBoardAction(req, res, "Subscription auth URL");
if (!agent) return;
const config = asRecord(agent.adapterConfig) ?? {};
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const result = await runClaudeSubscriptionAuthUrl({
runId: `claude-subscription-auth-url-${randomUUID()}`,
agent: {
id: agent.id,
companyId: agent.companyId,
name: agent.name,
adapterType: agent.adapterType,
adapterConfig: agent.adapterConfig,
},
config: runtimeConfig,
});
res.json(result);
});
router.post("/agents/:id/claude-logout", async (req, res) => {
const agent = await getClaudeLocalAgentForBoardAction(req, res, "Logout");
if (!agent) return;
const config = asRecord(agent.adapterConfig) ?? {};
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const result = await runClaudeLogout({
runId: `claude-logout-${randomUUID()}`,
agent: {
id: agent.id,
companyId: agent.companyId,
name: agent.name,
adapterType: agent.adapterType,
adapterConfig: agent.adapterConfig,
},
config: runtimeConfig,
});
res.json(result);
});
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+18
View File
@@ -2887,6 +2887,24 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/agents/{id}/claude-subscription-auth-url",
tags: ["agents"],
summary: "Get Claude subscription auth URL for agent",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/agents/{id}/claude-logout",
tags: ["agents"],
summary: "Trigger Claude logout for agent",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
});
// ─── Issue interactions & tree ───────────────────────────────────────────────
registry.registerPath({
+5
View File
@@ -49,6 +49,7 @@ export interface ClaudeLoginResult {
signal: string | null;
timedOut: boolean;
loginUrl: string | null;
authUrl: string | null;
stdout: string;
stderr: string;
}
@@ -228,6 +229,10 @@ export const agentsApi = {
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
getClaudeSubscriptionAuthUrl: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-subscription-auth-url"), {}),
logoutFromClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-logout"), {}),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
};
+144 -58
View File
@@ -67,12 +67,15 @@ import {
Eye,
EyeOff,
Copy,
ExternalLink,
ChevronRight,
ChevronDown,
ArrowLeft,
HelpCircle,
FolderOpen,
AlertTriangle,
LogIn,
LogOut,
} from "lucide-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -1479,6 +1482,9 @@ function AgentConfigurePage({
return (
<div className="max-w-3xl space-y-6">
{agent.adapterType === "claude_local" && (
<ClaudeAuthControls agentId={agent.id} companyId={companyId} />
)}
<ConfigurationTab
agent={agent}
onDirtyChange={onDirtyChange}
@@ -1549,6 +1555,138 @@ function AgentConfigurePage({
);
}
function ClaudeAuthControls({
agentId,
companyId,
authRequired = false,
}: {
agentId: string;
companyId?: string;
authRequired?: boolean;
}) {
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
useEffect(() => {
setClaudeLoginResult(null);
}, [agentId, companyId]);
const runClaudeLogin = useMutation({
mutationFn: () => agentsApi.loginWithClaude(agentId, companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
});
const getClaudeSubscriptionAuthUrl = useMutation({
mutationFn: () => agentsApi.getClaudeSubscriptionAuthUrl(agentId, companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
});
const runClaudeLogout = useMutation({
mutationFn: () => agentsApi.logoutFromClaude(agentId, companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
});
const claudeAuthUrl = claudeLoginResult?.authUrl ?? claudeLoginResult?.loginUrl ?? null;
const claudeAuthPending =
runClaudeLogin.isPending || getClaudeSubscriptionAuthUrl.isPending || runClaudeLogout.isPending;
return (
<div className="space-y-2 rounded-md border border-border/70 bg-accent/20 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => getClaudeSubscriptionAuthUrl.mutate()}
disabled={claudeAuthPending}
>
<ExternalLink className="h-3.5 w-3.5" />
{getClaudeSubscriptionAuthUrl.isPending ? "Getting URL..." : "Get subscription auth URL"}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => runClaudeLogin.mutate()}
disabled={claudeAuthPending}
>
<LogIn className="h-3.5 w-3.5" />
{runClaudeLogin.isPending ? "Running login..." : "Login with subscription"}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => runClaudeLogout.mutate()}
disabled={claudeAuthPending}
>
<LogOut className="h-3.5 w-3.5" />
{runClaudeLogout.isPending ? "Logging out..." : "Logout"}
</Button>
{claudeAuthUrl && (
<Button asChild variant="link" size="sm" className="h-7 px-1 text-xs">
<a href={claudeAuthUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
Open auth URL
</a>
</Button>
)}
</div>
{authRequired && (
<p className="text-xs text-muted-foreground">
This run needs Claude subscription auth before it can be retried.
</p>
)}
{(runClaudeLogin.isError || getClaudeSubscriptionAuthUrl.isError || runClaudeLogout.isError) && (
<p className="text-xs text-destructive">
{runClaudeLogin.error instanceof Error
? runClaudeLogin.error.message
: getClaudeSubscriptionAuthUrl.error instanceof Error
? getClaudeSubscriptionAuthUrl.error.message
: runClaudeLogout.error instanceof Error
? runClaudeLogout.error.message
: "Claude auth command failed"}
</p>
)}
{claudeAuthUrl && (
<p className="text-xs">
Auth URL:
<a
href={claudeAuthUrl}
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{claudeAuthUrl}
</a>
</p>
)}
{claudeLoginResult && (
<>
{claudeLoginResult.timedOut && claudeAuthUrl && (
<p className="text-xs text-muted-foreground">
The auth command was stopped after capturing the browser URL.
</p>
)}
{!!claudeLoginResult.stdout && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stdout}
</pre>
)}
{!!claudeLoginResult.stderr && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stderr}
</pre>
)}
</>
)}
</div>
);
}
/* ---- Configuration Tab ---- */
function ConfigurationTab({
@@ -3122,11 +3260,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
const run = hydratedRun ?? initialRun;
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
useEffect(() => {
setClaudeLoginResult(null);
}, [run.id]);
const cancelRun = useMutation({
mutationFn: () => heartbeatsApi.cancel(run.id),
@@ -3224,13 +3357,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
},
});
const runClaudeLogin = useMutation({
mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
});
const isRunning = run.status === "running" && !!run.startedAt && !run.finishedAt;
const [elapsedSec, setElapsedSec] = useState<number>(() => {
if (!run.startedAt) return 0;
@@ -3361,52 +3487,12 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
</div>
)}
{run.errorCode === "claude_auth_required" && adapterType === "claude_local" && (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => runClaudeLogin.mutate()}
disabled={runClaudeLogin.isPending}
>
{runClaudeLogin.isPending ? "Running claude login..." : "Login to Claude Code"}
</Button>
{runClaudeLogin.isError && (
<p className="text-xs text-destructive">
{runClaudeLogin.error instanceof Error
? runClaudeLogin.error.message
: "Failed to run Claude login"}
</p>
)}
{claudeLoginResult?.loginUrl && (
<p className="text-xs">
Login URL:
<a
href={claudeLoginResult.loginUrl}
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{claudeLoginResult.loginUrl}
</a>
</p>
)}
{claudeLoginResult && (
<>
{!!claudeLoginResult.stdout && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stdout}
</pre>
)}
{!!claudeLoginResult.stderr && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stderr}
</pre>
)}
</>
)}
</div>
{adapterType === "claude_local" && (
<ClaudeAuthControls
agentId={run.agentId}
companyId={run.companyId}
authRequired={run.errorCode === "claude_auth_required"}
/>
)}
{hasNonZeroExit && (
<div className="text-xs text-red-600 dark:text-red-400">