0055aef618
* feat: add Claude Code agent for Anthropic model users Re-adds Claude Code support (removed in #478) so users with Anthropic API keys or Claude Code OAuth tokens can use their Claude subscriptions directly. When an Anthropic model is selected and Claude Code credentials are available, the system auto-selects the Claude agent instead of OpenCode. The harness mirrors opentoad's security model: native Bash blocked via --disallowedTools, MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON streaming, provider error detection, cache/cost tracking, browser skill, and todo progress tracking. Key changes: - action/agents/claude.ts: full Claude Code harness - action/utils/agent.ts: auto-select Claude for anthropic/* models - action/utils/providerErrors.ts: extracted shared provider error detection - action/utils/skills.ts: extracted shared skill installation (agent-aware) - action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars - action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist - CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused toolId variable, fix apiKeys test env cleanup The apiKeys test cleanup stripped *_API_KEY vars but missed CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip provider prefix from PULLFROG_MODEL in Claude agent the env override path was returning the raw value (e.g. "anthropic/claude-sonnet-4-5") without stripping the provider prefix, causing the Claude CLI to receive an invalid model ID. Made-with: Cursor * fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows remove unused cliPath from Claude agent RunParams, and pass CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates so users with Claude Pro/Team subscriptions can use their membership. Made-with: Cursor * fix: block Bash subagent in Claude Code disallowedTools Made-with: Cursor * chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free) Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
|
|
import * as core from "@actions/core";
|
|
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
|
|
import {
|
|
initToolState,
|
|
startMcpHttpServer,
|
|
type ToolContext,
|
|
type ToolState,
|
|
} from "./mcp/server.ts";
|
|
import { computeModes } from "./modes.ts";
|
|
import {
|
|
type ActivityTimeout,
|
|
createProcessOutputActivityTimeout,
|
|
DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
|
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
|
} from "./utils/activity.ts";
|
|
import { resolveAgent } from "./utils/agent.ts";
|
|
import { apiFetch } from "./utils/apiFetch.ts";
|
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
|
import { resolveBody } from "./utils/body.ts";
|
|
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
|
import { onExitSignal } from "./utils/exitHandler.ts";
|
|
import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
|
|
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
|
|
import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts";
|
|
import { resolveInstructions } from "./utils/instructions.ts";
|
|
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
|
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
|
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
|
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
|
import { handleAgentResult } from "./utils/run.ts";
|
|
import { resolveRunContextData } from "./utils/runContextData.ts";
|
|
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
|
import { killTrackedChildren } from "./utils/subprocess.ts";
|
|
import { parseTimeString, TIMEOUT_DISABLED } from "./utils/time.ts";
|
|
import { Timer } from "./utils/timer.ts";
|
|
import { createTodoTracker } from "./utils/todoTracking.ts";
|
|
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
|
import { resolveRun } from "./utils/workflow.ts";
|
|
|
|
export { Inputs } from "./utils/payload.ts";
|
|
|
|
export interface MainResult {
|
|
success: boolean;
|
|
output?: string | undefined;
|
|
error?: string | undefined;
|
|
result?: string | undefined;
|
|
}
|
|
|
|
function resolveOutputSchema(): Record<string, unknown> | undefined {
|
|
const raw = core.getInput("output_schema");
|
|
if (!raw) return undefined;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`invalid output_schema: not valid JSON`);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error(`invalid output_schema: must be a JSON object`);
|
|
}
|
|
log.info("» structured output schema provided — output will be required");
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
import type { ResolvedPayload } from "./utils/payload.ts";
|
|
|
|
interface OidcCredentials {
|
|
requestUrl: string;
|
|
requestToken: string;
|
|
}
|
|
|
|
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
|
try {
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
|
|
const response = await apiFetch({
|
|
path: "/api/proxy-token",
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${oidcToken}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log.warning(`proxy key mint failed (${response.status})`);
|
|
return null;
|
|
}
|
|
|
|
const data = (await response.json()) as { key: string };
|
|
return data.key;
|
|
} catch (error) {
|
|
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
|
return null;
|
|
} finally {
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
}
|
|
}
|
|
|
|
async function resolveProxyModel(ctx: {
|
|
payload: ResolvedPayload;
|
|
oss: boolean;
|
|
proxyModel?: string | undefined;
|
|
oidcCredentials: OidcCredentials | null;
|
|
}): Promise<void> {
|
|
// env override = BYOK escape hatch, don't proxy
|
|
if (process.env.PULLFROG_MODEL?.trim()) return;
|
|
|
|
// OSS: server decided the model
|
|
if (ctx.oss && ctx.proxyModel) {
|
|
if (!ctx.oidcCredentials) {
|
|
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
|
|
return;
|
|
}
|
|
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
|
if (!key) return;
|
|
|
|
process.env.OPENROUTER_API_KEY = key;
|
|
core.setSecret(key);
|
|
ctx.payload.proxyModel = ctx.proxyModel;
|
|
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
|
return;
|
|
}
|
|
|
|
// managed billing will add its path here later
|
|
}
|
|
|
|
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
|
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
|
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
|
if (summaryParts.length > 0) {
|
|
await writeSummary(summaryParts.join("\n\n"));
|
|
}
|
|
}
|
|
|
|
export async function main(): Promise<MainResult> {
|
|
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
|
normalizeEnv();
|
|
|
|
// write usage summary on SIGINT/SIGTERM so the worker can read it after sandbox.exec
|
|
const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH;
|
|
if (usageSummaryPath) {
|
|
onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath));
|
|
}
|
|
|
|
const timer = new Timer();
|
|
let activityTimeout: ActivityTimeout | null = null;
|
|
|
|
// parse prompt early to extract progressCommentId for toolState
|
|
const resolvedPromptInput = resolvePromptInput();
|
|
|
|
const toolState = initToolState({
|
|
progressCommentId:
|
|
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
|
|
});
|
|
|
|
// resolve and fingerprint git binary before any agent code runs
|
|
resolveGit();
|
|
|
|
// get job token for initial API calls
|
|
const jobToken = getJobToken();
|
|
const initialOctokit = createOctokit(jobToken);
|
|
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
|
timer.checkpoint("runContextData");
|
|
|
|
// resolve payload to determine shell permission
|
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
|
toolState.model = payload.model;
|
|
if (payload.event.trigger === "pull_request_synchronize") {
|
|
toolState.beforeSha = payload.event.before_sha;
|
|
}
|
|
|
|
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
|
await using tokenRef = await resolveTokens({ push: payload.push });
|
|
|
|
// stash OIDC credentials in memory before wiping from process.env
|
|
// the agent's shell commands can't access JS variables, so this is safe
|
|
const oidcCredentials: OidcCredentials | null =
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
? {
|
|
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
|
|
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
|
|
}
|
|
: null;
|
|
|
|
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
|
|
if (payload.shell !== "enabled") {
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
}
|
|
|
|
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
|
await resolveProxyModel({
|
|
payload,
|
|
oss: runContext.oss,
|
|
proxyModel: runContext.proxyModel,
|
|
oidcCredentials,
|
|
});
|
|
|
|
// create octokit with MCP token for GitHub API calls
|
|
const octokit = createOctokit(tokenRef.mcpToken);
|
|
|
|
const runInfo = await resolveRun({ octokit });
|
|
let toolContext: ToolContext | undefined;
|
|
let progressCallbackDisabled = false;
|
|
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
|
|
|
try {
|
|
if (payload.cwd && process.cwd() !== payload.cwd) {
|
|
process.chdir(payload.cwd);
|
|
}
|
|
|
|
// resolve body - fetches body_html and converts to markdown if images present
|
|
// this ensures agents receive markdown with working signed image URLs
|
|
const originalBody = payload.event.body;
|
|
const resolvedBody = await resolveBody({
|
|
event: payload.event,
|
|
octokit,
|
|
repo: runContext.repo,
|
|
});
|
|
if (resolvedBody !== originalBody) {
|
|
payload.event.body = resolvedBody;
|
|
// also update prompt if original body was included there
|
|
if (originalBody && payload.prompt.includes(originalBody)) {
|
|
payload.prompt = payload.prompt.replace(originalBody, resolvedBody ?? "");
|
|
}
|
|
}
|
|
|
|
const tmpdir = createTempDirectory();
|
|
|
|
await using gitAuthServer = await startGitAuthServer(tmpdir);
|
|
setGitAuthServer(gitAuthServer);
|
|
|
|
const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model });
|
|
|
|
validateAgentApiKey({
|
|
agent,
|
|
model: payload.proxyModel ?? payload.model,
|
|
owner: runContext.repo.owner,
|
|
name: runContext.repo.name,
|
|
});
|
|
|
|
await setupGit({
|
|
gitToken: tokenRef.gitToken,
|
|
owner: runContext.repo.owner,
|
|
name: runContext.repo.name,
|
|
octokit,
|
|
toolState,
|
|
shell: payload.shell,
|
|
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
|
});
|
|
timer.checkpoint("git");
|
|
|
|
// execute setup lifecycle hook (runs once at initialization)
|
|
await executeLifecycleHook({
|
|
event: "setup",
|
|
script: runContext.repoSettings.setupScript,
|
|
});
|
|
timer.checkpoint("lifecycleHooks::setup");
|
|
|
|
const modes = [...computeModes(), ...runContext.repoSettings.modes];
|
|
|
|
const outputSchema = resolveOutputSchema();
|
|
|
|
// mcpServerUrl and tmpdir are set after server starts
|
|
toolContext = {
|
|
repo: runContext.repo,
|
|
payload,
|
|
octokit,
|
|
githubInstallationToken: tokenRef.mcpToken,
|
|
gitToken: tokenRef.gitToken,
|
|
apiToken: runContext.apiToken,
|
|
modes,
|
|
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
|
prepushScript: runContext.repoSettings.prepushScript,
|
|
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
|
|
modeInstructions: runContext.repoSettings.modeInstructions,
|
|
toolState,
|
|
runId: runInfo.runId,
|
|
jobId: runInfo.jobId,
|
|
mcpServerUrl: "",
|
|
tmpdir,
|
|
};
|
|
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
|
toolContext.mcpServerUrl = mcpHttpServer.url;
|
|
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
|
timer.checkpoint("mcpServer");
|
|
|
|
const instructions = resolveInstructions({
|
|
payload,
|
|
repo: runContext.repo,
|
|
modes,
|
|
outputSchema,
|
|
learnings: runContext.repoSettings.learnings,
|
|
});
|
|
// log instructions as soon as they are fully resolved
|
|
const logParts = [
|
|
instructions.eventInstructions
|
|
? `EVENT-LEVEL INSTRUCTIONS:\n${instructions.eventInstructions}`
|
|
: null,
|
|
instructions.user ? `USER REQUEST:\n${instructions.user}` : null,
|
|
instructions.event,
|
|
].filter(Boolean);
|
|
log.box(logParts.join("\n\n---\n\n"), {
|
|
title: "Instructions",
|
|
});
|
|
|
|
// run agent, optionally with timeout enforcement
|
|
activityTimeout = createProcessOutputActivityTimeout({
|
|
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
|
|
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
|
});
|
|
activityTimeout.promise.catch(() => {}); // prevent unhandled rejection if agent wins race
|
|
todoTracker = createTodoTracker(async (body) => {
|
|
if (progressCallbackDisabled || !toolContext) return;
|
|
try {
|
|
await reportProgress(toolContext, { body });
|
|
} catch (err) {
|
|
log.debug(`progress update failed: ${err}`);
|
|
}
|
|
});
|
|
toolState.todoTracker = todoTracker;
|
|
|
|
const agentPromise = agent.run({
|
|
payload,
|
|
mcpServerUrl: mcpHttpServer.url,
|
|
tmpdir,
|
|
instructions,
|
|
todoTracker,
|
|
});
|
|
|
|
// timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt:
|
|
// - --timeout=2h (or any duration like "--timeout=30m", "--timeout=1h30m") to set a custom timeout
|
|
// - --notimeout to disable timeout entirely
|
|
let result: Awaited<typeof agentPromise>;
|
|
if (payload.timeout === TIMEOUT_DISABLED) {
|
|
result = await Promise.race([agentPromise, activityTimeout.promise]);
|
|
} else {
|
|
const parsed = payload.timeout ? parseTimeString(payload.timeout) : null;
|
|
if (payload.timeout && parsed === null) {
|
|
log.warning(`invalid timeout format "${payload.timeout}", using default 1h`);
|
|
}
|
|
const timeoutMs = parsed ?? 3600000;
|
|
const actualTimeout = parsed !== null ? payload.timeout : "1h";
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(new Error(`agent run timed out after ${actualTimeout}`));
|
|
}, timeoutMs);
|
|
});
|
|
timeoutPromise.catch(() => {}); // prevent unhandled rejection if agent wins race
|
|
try {
|
|
result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
// accumulate top-level agent usage
|
|
if (result.usage) {
|
|
toolState.usageEntries.push(result.usage);
|
|
}
|
|
|
|
// validate this before writing job summary to avoid masking the error
|
|
if (outputSchema && !toolState.output) {
|
|
throw new Error(
|
|
"output_schema was provided but agent did not call set_output — structured output is required"
|
|
);
|
|
}
|
|
|
|
// post-agent review cleanup: reportReviewNodeId → follow-up re-review dispatch.
|
|
// runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement).
|
|
// best-effort: cleanup failures must not turn a successful agent run into a failure.
|
|
if (toolContext) {
|
|
await postReviewCleanup(toolContext).catch((error) => {
|
|
log.debug(`post-review cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
// clean up stranded progress comments. two cases:
|
|
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
|
|
// 2. tracker published a checklist but the agent never wrote a final summary
|
|
// (hasPublished=true, finalSummaryWritten=false).
|
|
// in both cases, delete the comment so it doesn't linger with stale content.
|
|
// wasUpdated is intentionally NOT set here — cleanup is not a real progress update.
|
|
// uses finalSummaryWritten (not todoTracker.enabled) so cleanup survives API failures
|
|
// in report_progress where cancel() ran but the write didn't succeed.
|
|
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
|
|
if (
|
|
toolContext &&
|
|
toolState.progressCommentId &&
|
|
(!toolState.wasUpdated || trackerWasLastWriter)
|
|
) {
|
|
await deleteProgressComment(toolContext).catch((error) => {
|
|
log.debug(`stranded progress comment cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
await writeJobSummary(toolState);
|
|
|
|
// emit structured output marker for test validation
|
|
if (toolState.output) {
|
|
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
|
|
core.setOutput("result", toolState.output);
|
|
}
|
|
|
|
return await handleAgentResult({
|
|
result,
|
|
toolState,
|
|
silent: payload.event.silent ?? false,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
|
|
progressCallbackDisabled = true;
|
|
todoTracker?.cancel();
|
|
killTrackedChildren();
|
|
log.error(errorMessage);
|
|
|
|
// best-effort summary — don't mask the original error
|
|
try {
|
|
await writeJobSummary(toolState);
|
|
} catch {}
|
|
|
|
try {
|
|
await reportErrorToComment({ toolState, error: errorMessage });
|
|
} catch {
|
|
// error reporting failed, but don't let it mask the original error
|
|
}
|
|
|
|
// best-effort review cleanup (e.g., agent timed out after submitting a review)
|
|
if (toolContext) {
|
|
await postReviewCleanup(toolContext).catch((error) => {
|
|
log.debug(`post-review cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
activityTimeout?.stop();
|
|
if (usageSummaryPath) {
|
|
await writeGitHubUsageSummaryToFile(usageSummaryPath);
|
|
}
|
|
}
|
|
}
|