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;
timedOut: boolean;
errorMessage?: string | null;
errorCode?: string | null;
errorMeta?: Record<string, unknown>;
usage?: UsageSummary;
/**
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.

View File

@@ -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<string> {
return tmp;
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
interface ClaudeExecutionInput {
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 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<string, string> = { ...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<AdapterExec
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => 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<AdapterExec
if (linkedIssueIds.length > 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<AdapterExec
if (fromExtraArgs.length > 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<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 runtimeSessionParams = parseObject(runtime.sessionParams);
@@ -216,12 +317,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
): AdapterExecutionResult => {
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<AdapterExec
signal: proc.signal,
timedOut: false,
errorMessage: parseFallbackErrorMessage(proc),
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
errorMeta,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
@@ -266,6 +383,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(proc.exitCode ?? 0) === 0
? null
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
errorMeta,
usage,
sessionId: resolvedSessionId,
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 { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";

View File

@@ -1,6 +1,9 @@
import type { UsageSummary } from "@paperclip/adapter-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) {
let sessionId: string | null = null;
let model = "";
@@ -104,6 +107,37 @@ function extractClaudeErrorMessages(parsed: Record<string, unknown>): 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<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 {
const subtype = asString(parsed.subtype, "");
const resultText = asString(parsed.result, "").trim();