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