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:
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user