diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a265606183e..7f3ebb49292 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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; authToken?: string; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}) { + return runClaudeSubscriptionLogin(input); +} + +export async function runClaudeSubscriptionLogin(input: { + runId: string; + agent: AdapterExecutionContext["agent"]; + config: Record; + context?: Record; + authToken?: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + 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; + context?: Record; + authToken?: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + timeoutSec?: number; +}) { + return runClaudeSubscriptionLogin({ + ...input, + timeoutSec: input.timeoutSec ?? 8, + }); +} + +export async function runClaudeLogout(input: { + runId: string; + agent: AdapterExecutionContext["agent"]; + config: Record; + context?: Record; + authToken?: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}) { + 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 { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const executionTarget = readAdapterExecutionTarget({ diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index e55a65cde08..cc896a8229e 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -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"; diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 0b79edb01a8..d7024e48dca 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -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( diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index fbf66f792b6..f5aaaa464f4 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -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; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index fb706f8ece1..5d00ea8342c 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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>>; + const getClaudeLocalAgentForBoardAction = async ( + req: Request, + res: Response, + actionLabel: string, + ): Promise => { 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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 81b900610f9..8ba78523532 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 3dc1fae75a1..96079e309bf 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -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(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + getClaudeSubscriptionAuthUrl: (id: string, companyId?: string) => + api.post(agentPath(id, companyId, "/claude-subscription-auth-url"), {}), + logoutFromClaude: (id: string, companyId?: string) => + api.post(agentPath(id, companyId, "/claude-logout"), {}), availableSkills: () => api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 8eedd915511..205e9fa8984 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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 (
+ {agent.adapterType === "claude_local" && ( + + )} (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 ( +
+
+ + + + {claudeAuthUrl && ( + + )} +
+ {authRequired && ( +

+ This run needs Claude subscription auth before it can be retried. +

+ )} + {(runClaudeLogin.isError || getClaudeSubscriptionAuthUrl.isError || runClaudeLogout.isError) && ( +

+ {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"} +

+ )} + {claudeAuthUrl && ( +

+ Auth URL: + + {claudeAuthUrl} + +

+ )} + {claudeLoginResult && ( + <> + {claudeLoginResult.timedOut && claudeAuthUrl && ( +

+ The auth command was stopped after capturing the browser URL. +

+ )} + {!!claudeLoginResult.stdout && ( +
+              {claudeLoginResult.stdout}
+            
+ )} + {!!claudeLoginResult.stderr && ( +
+              {claudeLoginResult.stderr}
+            
+ )} + + )} +
+ ); +} + /* ---- 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(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(() => { if (!run.startedAt) return 0; @@ -3361,52 +3487,12 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } {run.errorCode && ({run.errorCode})}
)} - {run.errorCode === "claude_auth_required" && adapterType === "claude_local" && ( -
- - {runClaudeLogin.isError && ( -

- {runClaudeLogin.error instanceof Error - ? runClaudeLogin.error.message - : "Failed to run Claude login"} -

- )} - {claudeLoginResult?.loginUrl && ( -

- Login URL: - - {claudeLoginResult.loginUrl} - -

- )} - {claudeLoginResult && ( - <> - {!!claudeLoginResult.stdout && ( -
-                        {claudeLoginResult.stdout}
-                      
- )} - {!!claudeLoginResult.stderr && ( -
-                        {claudeLoginResult.stderr}
-                      
- )} - - )} -
+ {adapterType === "claude_local" && ( + )} {hasNonZeroExit && (