From 2ddf6213fda5618b7594bd5711e4fdc9531dd818 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 14:40:44 -0600 Subject: [PATCH] feat(adapter): detect claude-login-required errors and expose errorCode/errorMeta Add detectClaudeLoginRequired and extractClaudeLoginUrl to parse module. Extract buildClaudeRuntimeConfig for reuse by both execute and the new runClaudeLogin helper. Include errorCode: "claude_auth_required" and errorMeta: { loginUrl } in execution results when login is needed. Export runClaudeLogin from the package index. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-utils/src/types.ts | 2 + .../claude-local/src/server/execute.ts | 149 ++++++++++++++++-- .../adapters/claude-local/src/server/index.ts | 2 +- .../adapters/claude-local/src/server/parse.ts | 34 ++++ 4 files changed, 171 insertions(+), 16 deletions(-) diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index b3d437b0..5bc8c7e5 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -35,6 +35,8 @@ export interface AdapterExecutionResult { signal: string | null; timedOut: boolean; errorMessage?: string | null; + errorCode?: string | null; + errorMeta?: Record; usage?: UsageSummary; /** * Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`. diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 3105a2cf..573fb24c 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -19,7 +19,12 @@ import { renderTemplate, runChildProcess, } from "@paperclip/adapter-utils/server-utils"; -import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js"; +import { + parseClaudeStreamJson, + describeClaudeFailure, + detectClaudeLoginRequired, + isClaudeUnknownSessionError, +} from "./parse.js"; const PAPERCLIP_SKILLS_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -47,27 +52,50 @@ async function buildSkillsDir(): Promise { return tmp; } -export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; +interface ClaudeExecutionInput { + runId: string; + agent: AdapterExecutionContext["agent"]; + config: Record; + context: Record; + authToken?: string; +} + +interface ClaudeRuntimeConfig { + command: string; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; + extraArgs: string[]; +} + +function buildLoginResult(input: { + proc: RunProcessResult; + loginUrl: string | null; +}) { + return { + exitCode: input.proc.exitCode, + signal: input.proc.signal, + timedOut: input.proc.timedOut, + stdout: input.proc.stdout, + stderr: input.proc.stderr, + loginUrl: input.loginUrl, + }; +} + +async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { + const { runId, agent, config, context, authToken } = input; - const promptTemplate = asString( - config.promptTemplate, - "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", - ); - const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate); const command = asString(config.command, "claude"); - const model = asString(config.model, ""); - const effort = asString(config.effort, ""); - const maxTurns = asNumber(config.maxTurnsPerRun, 0); - const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false); - const cwd = asString(config.cwd, process.cwd()); await ensureAbsoluteDirectory(cwd); + const envConfig = parseObject(config.env); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || @@ -91,6 +119,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } @@ -109,12 +138,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } - for (const [k, v] of Object.entries(envConfig)) { - if (typeof v === "string") env[k] = v; + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; } + if (!hasExplicitApiKey && authToken) { env.PAPERCLIP_API_KEY = authToken; } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); await ensureCommandResolvable(command, cwd, runtimeEnv); @@ -125,6 +157,75 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); + + return { + command, + cwd, + env, + timeoutSec, + graceSec, + extraArgs, + }; +} + +export async function runClaudeLogin(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 runChildProcess(input.runId, runtime.command, ["login"], { + cwd: runtime.cwd, + env: runtime.env, + timeoutSec: runtime.timeoutSec, + graceSec: runtime.graceSec, + onLog, + }); + + const loginMeta = detectClaudeLoginRequired({ + parsed: null, + stdout: proc.stdout, + stderr: proc.stderr, + }); + + return buildLoginResult({ + proc, + loginUrl: loginMeta.loginUrl, + }); +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate); + const model = asString(config.model, ""); + const effort = asString(config.effort, ""); + const maxTurns = asNumber(config.maxTurnsPerRun, 0); + const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false); + + const runtimeConfig = await buildClaudeRuntimeConfig({ + runId, + agent, + config, + context, + authToken, + }); + const { command, cwd, env, timeoutSec, graceSec, extraArgs } = runtimeConfig; const skillsDir = await buildSkillsDir(); const runtimeSessionParams = parseObject(runtime.sessionParams); @@ -216,12 +317,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const { proc, parsedStream, parsed } = attempt; + const loginMeta = detectClaudeLoginRequired({ + parsed, + stdout: proc.stdout, + stderr: proc.stderr, + }); + const errorMeta = + loginMeta.loginUrl != null + ? { + loginUrl: loginMeta.loginUrl, + } + : undefined; + if (proc.timedOut) { return { exitCode: proc.exitCode, signal: proc.signal, timedOut: true, errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: "timeout", + errorMeta, clearSession: Boolean(opts.clearSessionOnMissingSession), }; } @@ -232,6 +347,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi; + export function parseClaudeStreamJson(stdout: string) { let sessionId: string | null = null; let model = ""; @@ -104,6 +107,37 @@ function extractClaudeErrorMessages(parsed: Record): string[] { return messages; } +export function extractClaudeLoginUrl(text: string): string | null { + const match = text.match(URL_RE); + if (!match || match.length === 0) return null; + for (const rawUrl of match) { + const cleaned = rawUrl.replace(/[\])}.!,?;:'\"]+$/g, ""); + if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) { + return cleaned; + } + } + return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null; +} + +export function detectClaudeLoginRequired(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { requiresLogin: boolean; loginUrl: string | null } { + const resultText = asString(input.parsed?.result, "").trim(); + const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line)); + return { + requiresLogin, + loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")), + }; +} + export function describeClaudeFailure(parsed: Record): string | null { const subtype = asString(parsed.subtype, ""); const resultText = asString(parsed.result, "").trim();