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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 14:40:44 -06:00
parent e1f2be7ecf
commit 2ddf6213fd
4 changed files with 171 additions and 16 deletions

View File

@@ -35,6 +35,8 @@ export interface AdapterExecutionResult {
signal: string | null; signal: string | null;
timedOut: boolean; timedOut: boolean;
errorMessage?: string | null; errorMessage?: string | null;
errorCode?: string | null;
errorMeta?: Record<string, unknown>;
usage?: UsageSummary; usage?: UsageSummary;
/** /**
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`. * Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.

View File

@@ -19,7 +19,12 @@ import {
renderTemplate, renderTemplate,
runChildProcess, runChildProcess,
} from "@paperclip/adapter-utils/server-utils"; } 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( const PAPERCLIP_SKILLS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
@@ -47,27 +52,50 @@ async function buildSkillsDir(): Promise<string> {
return tmp; return tmp;
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { interface ClaudeExecutionInput {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context: Record<string, unknown>;
authToken?: string;
}
interface ClaudeRuntimeConfig {
command: string;
cwd: string;
env: Record<string, string>;
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<ClaudeRuntimeConfig> {
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 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()); const cwd = asString(config.cwd, process.cwd());
await ensureAbsoluteDirectory(cwd); await ensureAbsoluteDirectory(cwd);
const envConfig = parseObject(config.env); const envConfig = parseObject(config.env);
const hasExplicitApiKey = const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) }; const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId; env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId = const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
@@ -91,6 +119,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const linkedIssueIds = Array.isArray(context.issueIds) const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: []; : [];
if (wakeTaskId) { if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId; env.PAPERCLIP_TASK_ID = wakeTaskId;
} }
@@ -109,12 +138,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (linkedIssueIds.length > 0) { if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); 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) { if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken; env.PAPERCLIP_API_KEY = authToken;
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureCommandResolvable(command, cwd, runtimeEnv);
@@ -125,6 +157,75 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (fromExtraArgs.length > 0) return fromExtraArgs; if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args); return asStringArray(config.args);
})(); })();
return {
command,
cwd,
env,
timeoutSec,
graceSec,
extraArgs,
};
}
export async function runClaudeLogin(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 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<AdapterExecutionResult> {
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 skillsDir = await buildSkillsDir();
const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionParams = parseObject(runtime.sessionParams);
@@ -216,12 +317,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean }, opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
): AdapterExecutionResult => { ): AdapterExecutionResult => {
const { proc, parsedStream, parsed } = attempt; 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) { if (proc.timedOut) {
return { return {
exitCode: proc.exitCode, exitCode: proc.exitCode,
signal: proc.signal, signal: proc.signal,
timedOut: true, timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`, errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: "timeout",
errorMeta,
clearSession: Boolean(opts.clearSessionOnMissingSession), clearSession: Boolean(opts.clearSessionOnMissingSession),
}; };
} }
@@ -232,6 +347,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
signal: proc.signal, signal: proc.signal,
timedOut: false, timedOut: false,
errorMessage: parseFallbackErrorMessage(proc), errorMessage: parseFallbackErrorMessage(proc),
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
errorMeta,
resultJson: { resultJson: {
stdout: proc.stdout, stdout: proc.stdout,
stderr: proc.stderr, stderr: proc.stderr,
@@ -266,6 +383,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(proc.exitCode ?? 0) === 0 (proc.exitCode ?? 0) === 0
? null ? null
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`, : describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
errorMeta,
usage, usage,
sessionId: resolvedSessionId, sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams, sessionParams: resolvedSessionParams,

View File

@@ -1,4 +1,4 @@
export { execute } from "./execute.js"; export { execute, runClaudeLogin } from "./execute.js";
export { testEnvironment } from "./test.js"; export { testEnvironment } from "./test.js";
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js"; export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils"; import type { AdapterSessionCodec } from "@paperclip/adapter-utils";

View File

@@ -1,6 +1,9 @@
import type { UsageSummary } from "@paperclip/adapter-utils"; import type { UsageSummary } from "@paperclip/adapter-utils";
import { asString, asNumber, parseObject, parseJson } from "@paperclip/adapter-utils/server-utils"; import { asString, asNumber, parseObject, parseJson } from "@paperclip/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;
export function parseClaudeStreamJson(stdout: string) { export function parseClaudeStreamJson(stdout: string) {
let sessionId: string | null = null; let sessionId: string | null = null;
let model = ""; let model = "";
@@ -104,6 +107,37 @@ function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
return messages; 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<string, unknown> | 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, unknown>): string | null { export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
const subtype = asString(parsed.subtype, ""); const subtype = asString(parsed.subtype, "");
const resultText = asString(parsed.result, "").trim(); const resultText = asString(parsed.result, "").trim();