Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb22433760 | |||
| b6658ddbc1 | |||
| 37dcea86b9 | |||
| 97937f46f7 | |||
| e45c4a84a2 | |||
| 9a1f3bdb0a | |||
| b80c78bdbe | |||
| 8fd2b6aacb | |||
| 6ac428ee2b | |||
| 375e8e4455 | |||
| 593a956665 | |||
| 80ab5bad34 | |||
| 6313b09e30 | |||
| b753c67d0a | |||
| 4789a2b5e3 | |||
| 06683c1e0a | |||
| 796c56a0c2 | |||
| 002f550e56 | |||
| 0e1f1ccbb7 | |||
| 8a64742ddf | |||
| 8037c118cc | |||
| 6f108237d4 | |||
| d5508d99bb | |||
| 6a77ea6612 |
@@ -32,6 +32,8 @@ jobs:
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
API_URL: ${{ secrets.API_URL }}
|
||||
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
|
||||
# add any additional keys your agent(s) need
|
||||
# optionally, comment out any you won't use
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
agent: [claude, codex, cursor, gemini, opencode]
|
||||
test:
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -55,7 +57,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
test:
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- test preview system --> <!-- trivial touch -->
|
||||
<!-- test preview system --> <!-- test bypass 2 -->
|
||||
<p align="center">
|
||||
<h1 align="center">
|
||||
<picture>
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export const claude = agent({
|
||||
// select model and effort level
|
||||
const model = claudeEffortModels[ctx.payload.effort];
|
||||
const effortLevel = claudeEffortLevels[ctx.payload.effort];
|
||||
log.info(`» using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
log.info(`» model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
|
||||
// build disallowedTools based on tool permissions
|
||||
const disallowedTools = buildDisallowedTools(ctx);
|
||||
|
||||
+4
-5
@@ -29,7 +29,7 @@ const FALLBACK_MODEL = "gpt-5.2-codex";
|
||||
|
||||
function getCodexEffortConfig(model: string): Record<Effort, CodexEffortConfig> {
|
||||
return {
|
||||
mini: { model: "gpt-5.1-codex", reasoningEffort: "low" },
|
||||
mini: { model: "gpt-5.2-codex", reasoningEffort: "low" },
|
||||
auto: { model },
|
||||
max: { model, reasoningEffort: "high" },
|
||||
};
|
||||
@@ -155,10 +155,9 @@ export const codex = agent({
|
||||
|
||||
// get model and reasoning effort based on effort level
|
||||
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
|
||||
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
|
||||
if (effortConfig.reasoningEffort) {
|
||||
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
|
||||
}
|
||||
log.info(
|
||||
`» model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
|
||||
);
|
||||
|
||||
// determine sandbox mode based on push permission
|
||||
// push: "disabled" → read-only sandbox, otherwise workspace-write.
|
||||
|
||||
+5
-4
@@ -134,7 +134,7 @@ export const cursor = agent({
|
||||
try {
|
||||
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
||||
if (projectConfig.model) {
|
||||
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||
log.info(`» model: ${projectConfig.model} (from .cursor/cli.json)`);
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
@@ -146,9 +146,9 @@ export const cursor = agent({
|
||||
}
|
||||
|
||||
if (modelOverride) {
|
||||
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`);
|
||||
log.info(`» model: ${modelOverride}`);
|
||||
} else if (!existsSync(projectCliConfigPath)) {
|
||||
log.info(`» using default model, effort=${ctx.payload.effort}`);
|
||||
log.info(`» model: default`);
|
||||
}
|
||||
|
||||
// track logged model_call_ids to avoid duplicates
|
||||
@@ -439,5 +439,6 @@ function configureCursorTools(ctx: AgentRunContext): void {
|
||||
}
|
||||
|
||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
log.info(`» CLI config written to ${cliConfigPath}`, JSON.stringify(config, null, 2));
|
||||
log.info(`» CLI config written to ${cliConfigPath}`);
|
||||
log.debug(`» CLI config contents: ${JSON.stringify(config, null, 2)}`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||
// allow env var override for tests (e.g., to avoid flash RPD quota limits)
|
||||
const model = process.env.GEMINI_MODEL ?? effortConfig.model;
|
||||
const thinkingLevel = effortConfig.thinkingLevel;
|
||||
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
log.info(`» model: ${model} (thinkingLevel: ${thinkingLevel})`);
|
||||
|
||||
const realHome = homedir();
|
||||
const geminiConfigDir = join(realHome, ".gemini");
|
||||
|
||||
+25
-25
@@ -72,7 +72,9 @@ export const opencode = agent({
|
||||
const modelOverride = process.env.OPENCODE_MODEL;
|
||||
if (modelOverride) {
|
||||
args.push("--model", modelOverride);
|
||||
log.info(`» using model override: ${modelOverride}`);
|
||||
log.info(`» model: ${modelOverride} (override)`);
|
||||
} else {
|
||||
log.info(`» model: auto-selected by OpenCode`);
|
||||
}
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
@@ -186,13 +188,10 @@ export const opencode = agent({
|
||||
lastProviderError = providerError;
|
||||
log.error(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
} else {
|
||||
// try to parse as JSON for structured logging, fall back to warning
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
log.debug(JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
log.warning(trimmed);
|
||||
}
|
||||
// OpenCode's --print-logs output goes to stderr. demote internal
|
||||
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
||||
// call logs in the GitHub Actions step output.
|
||||
log.debug(trimmed);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -325,7 +324,7 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
||||
}
|
||||
|
||||
log.info(`» OpenCode config written to ${configPath}`);
|
||||
log.info(
|
||||
log.debug(
|
||||
`» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
|
||||
);
|
||||
log.debug(`OpenCode config contents:\n${configJson}`);
|
||||
@@ -558,27 +557,28 @@ const messageHandlers = {
|
||||
const status = event.part?.state?.status;
|
||||
const output = event.part?.state?.output;
|
||||
|
||||
// debug log all tool_use events to diagnose missing bash/MCP tool calls
|
||||
if (!toolName || !toolId) {
|
||||
log.debug(`» tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
|
||||
// surface dropped tool_use events visibly so missing tool calls are diagnosable
|
||||
log.info(
|
||||
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName && toolId) {
|
||||
// track tool call in current step
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
// track tool call in current step
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {},
|
||||
});
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {},
|
||||
});
|
||||
|
||||
// if tool already completed (status in same event), log output
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
// if tool already completed (status in same event), log output
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
},
|
||||
tool_result: (event: OpenCodeToolResultEvent, thinkingTimer: ThinkingTimer) => {
|
||||
|
||||
+10
-8
@@ -28,13 +28,15 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
|
||||
return {
|
||||
...input,
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
const bash = ctx.payload.bash;
|
||||
const web = ctx.payload.web;
|
||||
const search = ctx.payload.search;
|
||||
const push = ctx.payload.push;
|
||||
log.info(
|
||||
`» running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...`
|
||||
);
|
||||
log.info(`» agent: ${input.name}`);
|
||||
log.info(`» effort: ${ctx.payload.effort}`);
|
||||
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
||||
log.info(`» web: ${ctx.payload.web}`);
|
||||
log.info(`» search: ${ctx.payload.search}`);
|
||||
log.info(`» push: ${ctx.payload.push}`);
|
||||
log.info(`» bash: ${ctx.payload.bash}`);
|
||||
log.debug(`» payload: ${JSON.stringify(ctx.payload, null, 2)}`);
|
||||
|
||||
// build log box content: eventInstructions (if any) + user request (if any) + event data
|
||||
const logParts = [
|
||||
ctx.instructions.eventInstructions
|
||||
@@ -46,7 +48,7 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
|
||||
log.box(logParts.join("\n\n---\n\n"), {
|
||||
title: "Instructions",
|
||||
});
|
||||
log.info(`» tool permissions: web=${web}, search=${search}, push=${push}, bash=${bash}`);
|
||||
|
||||
return input.run(ctx);
|
||||
},
|
||||
...agentsManifest[input.name],
|
||||
|
||||
@@ -139417,15 +139417,43 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
|
||||
);
|
||||
|
||||
// utils/apiUrl.ts
|
||||
function getApiUrl() {
|
||||
const url4 = process.env.API_URL || "https://pullfrog.com";
|
||||
log.debug(`resolved API_URL: ${url4}`);
|
||||
return url4;
|
||||
function isLocalUrl(url4) {
|
||||
return url4.hostname === "localhost" || url4.hostname === "127.0.0.1";
|
||||
}
|
||||
function getVercelBypassHeaders() {
|
||||
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (!secret) return {};
|
||||
return { "x-vercel-protection-bypass": secret };
|
||||
function getApiUrl() {
|
||||
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||
const parsed2 = new URL(raw);
|
||||
if (parsed2.protocol !== "https:" && !isLocalUrl(parsed2)) {
|
||||
throw new Error(
|
||||
`API_URL must use https:// (got ${parsed2.protocol}). only localhost is exempt.`
|
||||
);
|
||||
}
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// utils/apiFetch.ts
|
||||
async function apiFetch(options) {
|
||||
const apiUrl = getApiUrl();
|
||||
const url4 = new URL(options.path, apiUrl);
|
||||
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (bypassSecret) {
|
||||
url4.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
||||
}
|
||||
const headers = {
|
||||
...options.headers
|
||||
};
|
||||
if (bypassSecret) {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url4.pathname}`);
|
||||
const init = {
|
||||
method: options.method ?? "GET",
|
||||
headers
|
||||
};
|
||||
if (options.body) init.body = options.body;
|
||||
if (options.signal) init.signal = options.signal;
|
||||
return fetch(url4.toString(), init);
|
||||
}
|
||||
|
||||
// utils/retry.ts
|
||||
@@ -139465,37 +139493,26 @@ function isOIDCAvailable() {
|
||||
}
|
||||
async function acquireTokenViaOIDC(opts) {
|
||||
const oidcToken = await core2.getIDToken("pullfrog-api");
|
||||
const apiUrl = getApiUrl();
|
||||
const params = new URLSearchParams();
|
||||
const repos = [...opts?.repos ?? []];
|
||||
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
if (targetRepo) {
|
||||
repos.push(targetRepo);
|
||||
}
|
||||
if (repos.length) {
|
||||
params.set("repos", repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
||||
const timeoutMs = 3e4;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const fetchOptions = {
|
||||
const tokenResponse = await apiFetch({
|
||||
path: `/api/github/installation-token${reposParam}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders()
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
|
||||
signal: controller.signal
|
||||
};
|
||||
if (opts?.permissions) {
|
||||
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
`${apiUrl}/api/github/installation-token${queryString}`,
|
||||
fetchOptions
|
||||
);
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||
@@ -139692,12 +139709,14 @@ async function resolveTokens(params) {
|
||||
}
|
||||
};
|
||||
}
|
||||
const gitContents = params.push === "disabled" ? "read" : "write";
|
||||
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
|
||||
const gitPermissions = params.push === "disabled" ? { contents: "read" } : { contents: "write", workflows: "write" };
|
||||
const gitToken = await acquireNewToken({ permissions: gitPermissions });
|
||||
if (isGitHubActions) {
|
||||
core3.setSecret(gitToken);
|
||||
}
|
||||
log.info(`\xBB acquired git token (contents:${gitContents})`);
|
||||
log.info(
|
||||
`\xBB acquired git token (${Object.entries(gitPermissions).map((e) => e.join(":")).join(", ")})`
|
||||
);
|
||||
const mcpToken = await acquireNewToken();
|
||||
if (isGitHubActions) {
|
||||
core3.setSecret(mcpToken);
|
||||
@@ -141000,6 +141019,11 @@ async function checkoutPrBranch(pullNumber, params) {
|
||||
if (isFork) {
|
||||
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
||||
}
|
||||
toolState.pushDest = {
|
||||
remoteName: isFork ? `pr-${pullNumber}` : "origin",
|
||||
remoteBranch: headBranch,
|
||||
localBranch
|
||||
};
|
||||
await executeLifecycleHook({
|
||||
event: "post-checkout",
|
||||
script: params.postCheckoutScript
|
||||
@@ -141057,14 +141081,15 @@ ${diffPreview}`);
|
||||
number: pr.data.number,
|
||||
title: pr.data.title,
|
||||
base: pr.data.base.ref,
|
||||
head: pr.data.head.ref,
|
||||
localBranch: `pr-${pull_number}`,
|
||||
remoteBranch: `refs/heads/${pr.data.head.ref}`,
|
||||
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
headRepo: headRepo.full_name,
|
||||
diffPath,
|
||||
toc: formatResult.toc,
|
||||
instructions: `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the line ranges to read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially.`
|
||||
instructions: `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the line ranges to read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially. the local branch is 'localBranch' (pr-{number}), not the remote branch name. when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -141399,6 +141424,9 @@ async function reportProgress(ctx, { body }) {
|
||||
action: "updated"
|
||||
};
|
||||
}
|
||||
if (existingCommentId === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
if (issueNumber === void 0) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
@@ -141805,7 +141833,10 @@ function resolveInstructions(ctx) {
|
||||
|
||||
Call \`delegate\` with a mode, effort level, and optional instructions:
|
||||
- \`mode\`: The workflow to run (see available modes below)
|
||||
- \`effort\`: \`"auto"\` (default, most capable), \`"mini"\` (fast, for simple tasks), or \`"max"\` (maximum capability)
|
||||
- \`effort\`:
|
||||
- \`"mini"\`: low-effort and fast, for simple tasks
|
||||
- \`"auto"\`: medium-effort, good for typical tasks that don't require significant reasoning
|
||||
- \`"max"\`: high-effort, good for PR reviews and complex coding tasks.
|
||||
- \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus.
|
||||
|
||||
### Single vs. multi-phase delegation
|
||||
@@ -141863,7 +141894,13 @@ ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}`;
|
||||
}
|
||||
function resolveSubagentInstructions(ctx) {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode.
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
|
||||
|
||||
### Delegation rules
|
||||
|
||||
- The \`delegate\` tool is NOT available to you \u2014 complete your task directly using the available tools.
|
||||
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
|
||||
- If you encounter an error you cannot resolve, report it clearly \u2014 do not attempt to delegate or re-run yourself.
|
||||
|
||||
${ctx.mode.prompt}`;
|
||||
const system = buildSystemPrompt({
|
||||
@@ -141903,7 +141940,7 @@ var DelegateParams = type({
|
||||
"the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')"
|
||||
),
|
||||
"effort?": Effort.describe(
|
||||
'effort level for the subagent: "mini" (fast), "auto" (default, highly capable), or "max" (maximum capability)'
|
||||
`effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don't require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)`
|
||||
),
|
||||
"instructions?": type.string.describe(
|
||||
"optional additional context or instructions for the subagent \u2014 use this to pass results from earlier delegations or narrow the subagent's focus"
|
||||
@@ -141927,7 +141964,7 @@ function DelegateTool(ctx) {
|
||||
execute: execute(async (params) => {
|
||||
if (ctx.toolState.delegationActive) {
|
||||
return {
|
||||
error: "delegation is not available inside a delegated subagent"
|
||||
error: "delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next."
|
||||
};
|
||||
}
|
||||
const selectedMode = resolveMode(ctx.modes, params.mode);
|
||||
@@ -142764,46 +142801,63 @@ var FileDeleteParams = type({
|
||||
var ListDirectoryParams = type({
|
||||
path: "string"
|
||||
});
|
||||
function resolveAndValidatePath(filePath) {
|
||||
var GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
|
||||
function resolveReadPath(filePath) {
|
||||
const cwd = realpathSync2(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
|
||||
return resolved;
|
||||
}
|
||||
if (existsSync4(resolved)) {
|
||||
const real = realpathSync2(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
if (real === cwd || real.startsWith(cwd + "/")) {
|
||||
return real;
|
||||
}
|
||||
return real;
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync4(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break;
|
||||
ancestor = parent;
|
||||
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
|
||||
return resolved;
|
||||
}
|
||||
if (existsSync4(ancestor)) {
|
||||
const realAncestor = realpathSync2(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
|
||||
}
|
||||
var GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
|
||||
function validateWritePath(params) {
|
||||
const resolved = resolveAndValidatePath(params.filePath);
|
||||
function resolveWritePath(filePath, bashPermission) {
|
||||
const cwd = realpathSync2(process.cwd());
|
||||
const relative = resolved.slice(cwd.length + 1);
|
||||
if (relative === ".git" || relative.startsWith(".git/")) {
|
||||
throw new Error(`writing to .git is not allowed: ${params.filePath}`);
|
||||
const resolved = resolve(cwd, filePath);
|
||||
if (bashPermission !== "enabled") {
|
||||
if (existsSync4(resolved)) {
|
||||
const real = realpathSync2(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
} else {
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync4(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break;
|
||||
ancestor = parent;
|
||||
}
|
||||
if (existsSync4(ancestor)) {
|
||||
const realAncestor = realpathSync2(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(
|
||||
`path must be within the repository (symlink escape blocked): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.bashPermission === "disabled") {
|
||||
const basename2 = relative.split("/").pop() || "";
|
||||
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
|
||||
throw new Error(`writing to .git is not allowed: ${filePath}`);
|
||||
}
|
||||
if (bashPermission === "disabled") {
|
||||
const basename2 = resolved.split("/").pop() || "";
|
||||
if (GIT_INTERPRETED_FILES.includes(basename2)) {
|
||||
throw new Error(
|
||||
`writing to ${basename2} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}`
|
||||
`writing to ${basename2} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142812,10 +142866,10 @@ function validateWritePath(params) {
|
||||
function FileReadTool(_ctx) {
|
||||
return tool({
|
||||
name: "file_read",
|
||||
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`,
|
||||
description: "Read a file. Path is relative to the repository root, or an absolute path to read tool result files (diffs, CI logs, etc.) from the temp directory.",
|
||||
parameters: FileReadParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const raw = readFileSync3(resolved, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
const offset = params.offset;
|
||||
@@ -142834,13 +142888,10 @@ function FileReadTool(_ctx) {
|
||||
function FileWriteTool(ctx) {
|
||||
return tool({
|
||||
name: "file_write",
|
||||
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`,
|
||||
description: "Write content to a file. Path is relative to the repository root. Writes to .git/ are blocked. Creates parent directories if needed.",
|
||||
parameters: FileWriteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const dir = dirname(resolved);
|
||||
mkdirSync2(dir, { recursive: true });
|
||||
writeFileSync5(resolved, params.content, "utf-8");
|
||||
@@ -142851,7 +142902,7 @@ function FileWriteTool(ctx) {
|
||||
function FileEditTool(ctx) {
|
||||
return tool({
|
||||
name: "file_edit",
|
||||
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence \u2014 set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`,
|
||||
description: "Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence \u2014 set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.",
|
||||
parameters: FileEditParams,
|
||||
execute: execute(async (params) => {
|
||||
if (params.old_string.length === 0) {
|
||||
@@ -142860,10 +142911,7 @@ function FileEditTool(ctx) {
|
||||
if (params.old_string === params.new_string) {
|
||||
throw new Error("old_string and new_string are identical");
|
||||
}
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const content = readFileSync3(resolved, "utf-8");
|
||||
const count = content.split(params.old_string).length - 1;
|
||||
if (count === 0) {
|
||||
@@ -142883,13 +142931,10 @@ function FileEditTool(ctx) {
|
||||
function FileDeleteTool(ctx) {
|
||||
return tool({
|
||||
name: "file_delete",
|
||||
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`,
|
||||
description: "Delete a file. Path is relative to the repository root. Deletes to .git/ are blocked. Cannot delete directories.",
|
||||
parameters: FileDeleteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
unlinkSync(resolved);
|
||||
return { path: params.path, deleted: true };
|
||||
})
|
||||
@@ -142898,10 +142943,10 @@ function FileDeleteTool(ctx) {
|
||||
function ListDirectoryTool(_ctx) {
|
||||
return tool({
|
||||
name: "list_directory",
|
||||
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`,
|
||||
description: "List files and directories. Path is relative to the repository root, or an absolute path to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
|
||||
parameters: ListDirectoryParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const entries = readdirSync(resolved, { withFileTypes: true });
|
||||
const sorted = entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
@@ -142915,23 +142960,22 @@ function ListDirectoryTool(_ctx) {
|
||||
}
|
||||
|
||||
// mcp/git.ts
|
||||
function getPushDestination(branch) {
|
||||
function getPushDestination(branch, storedDest) {
|
||||
if (storedDest && storedDest.localBranch === branch) {
|
||||
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
|
||||
const url4 = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
|
||||
log: false
|
||||
}).trim();
|
||||
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url: url4 };
|
||||
}
|
||||
try {
|
||||
const pushRef = $(
|
||||
"git",
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{push}`],
|
||||
{ log: false }
|
||||
).trim();
|
||||
const slashIndex = pushRef.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
throw new Error(`unexpected push ref format: ${pushRef}`);
|
||||
}
|
||||
const remoteName = pushRef.slice(0, slashIndex);
|
||||
const remoteBranch = pushRef.slice(slashIndex + 1);
|
||||
const url4 = $("git", ["remote", "get-url", "--push", remoteName], { log: false }).trim();
|
||||
return { remoteName, remoteBranch, url: url4 };
|
||||
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
||||
const merge4 = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
||||
const remoteBranch = merge4.replace(/^refs\/heads\//, "");
|
||||
const url4 = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
|
||||
return { remoteName: pushRemote, remoteBranch, url: url4 };
|
||||
} catch {
|
||||
log.debug(`no push tracking for ${branch}, falling back to origin/${branch}`);
|
||||
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
|
||||
const url4 = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
|
||||
return { remoteName: "origin", remoteBranch: branch, url: url4 };
|
||||
}
|
||||
@@ -142940,7 +142984,7 @@ function normalizeUrl(url4) {
|
||||
return url4.replace(/\.git$/, "").toLowerCase();
|
||||
}
|
||||
function validatePushDestination(params) {
|
||||
const dest = getPushDestination(params.branch);
|
||||
const dest = getPushDestination(params.branch, params.storedDest);
|
||||
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) {
|
||||
throw new Error(
|
||||
`Push blocked: destination does not match expected repository.
|
||||
@@ -142960,7 +143004,7 @@ function PushBranchTool(ctx) {
|
||||
const pushPermission = ctx.payload.push;
|
||||
return tool({
|
||||
name: "push_branch",
|
||||
description: "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
description: "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. The correct remote and remote branch are determined automatically from branch config set by checkout_pr. Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
parameters: PushBranch,
|
||||
execute: execute(async ({ branchName, force }) => {
|
||||
if (pushPermission === "disabled") {
|
||||
@@ -142971,7 +143015,11 @@ function PushBranchTool(ctx) {
|
||||
if (!pushUrl) {
|
||||
throw new Error("pushUrl not set - setupGit must run before push_branch");
|
||||
}
|
||||
const pushDest = validatePushDestination({ branch, pushUrl });
|
||||
const pushDest = validatePushDestination({
|
||||
branch,
|
||||
pushUrl,
|
||||
storedDest: ctx.toolState.pushDest
|
||||
});
|
||||
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
||||
throw new Error(
|
||||
`Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. Create a feature branch and open a PR instead.`
|
||||
@@ -144013,13 +144061,12 @@ function UploadFileTool(ctx) {
|
||||
const contentLength = buffer.length;
|
||||
const fileType = await fileTypeFromBuffer(buffer);
|
||||
const contentType = fileType?.mime || "application/octet-stream";
|
||||
const apiUrl = getApiUrl();
|
||||
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
|
||||
const response = await apiFetch({
|
||||
path: "/api/upload/signed-url",
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders()
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename,
|
||||
@@ -144052,10 +144099,10 @@ function UploadFileTool(ctx) {
|
||||
|
||||
// mcp/server.ts
|
||||
function initToolState(params) {
|
||||
const progressCommentId = params.progressCommentId ? parseInt(params.progressCommentId, 10) : null;
|
||||
const resolvedId = Number.isNaN(progressCommentId) ? null : progressCommentId;
|
||||
if (progressCommentId) {
|
||||
log.info(`\xBB using pre-created progress comment: ${progressCommentId}`);
|
||||
const parsed2 = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed2) ? void 0 : parsed2;
|
||||
if (resolvedId) {
|
||||
log.info(`\xBB using pre-created progress comment: ${resolvedId}`);
|
||||
}
|
||||
return {
|
||||
progressCommentId: resolvedId,
|
||||
@@ -144462,7 +144509,7 @@ import { join as join9 } from "node:path";
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.162",
|
||||
version: "0.0.164",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -144773,13 +144820,14 @@ var agent = (input) => {
|
||||
return {
|
||||
...input,
|
||||
run: async (ctx) => {
|
||||
const bash = ctx.payload.bash;
|
||||
const web = ctx.payload.web;
|
||||
const search2 = ctx.payload.search;
|
||||
const push = ctx.payload.push;
|
||||
log.info(
|
||||
`\xBB running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...`
|
||||
);
|
||||
log.info(`\xBB agent: ${input.name}`);
|
||||
log.info(`\xBB effort: ${ctx.payload.effort}`);
|
||||
if (ctx.payload.timeout) log.info(`\xBB timeout: ${ctx.payload.timeout}`);
|
||||
log.info(`\xBB web: ${ctx.payload.web}`);
|
||||
log.info(`\xBB search: ${ctx.payload.search}`);
|
||||
log.info(`\xBB push: ${ctx.payload.push}`);
|
||||
log.info(`\xBB bash: ${ctx.payload.bash}`);
|
||||
log.debug(`\xBB payload: ${JSON.stringify(ctx.payload, null, 2)}`);
|
||||
const logParts = [
|
||||
ctx.instructions.eventInstructions ? `EVENT-LEVEL INSTRUCTIONS:
|
||||
${ctx.instructions.eventInstructions}` : null,
|
||||
@@ -144790,7 +144838,6 @@ ${ctx.instructions.user}` : null,
|
||||
log.box(logParts.join("\n\n---\n\n"), {
|
||||
title: "Instructions"
|
||||
});
|
||||
log.info(`\xBB tool permissions: web=${web}, search=${search2}, push=${push}, bash=${bash}`);
|
||||
return input.run(ctx);
|
||||
},
|
||||
...agentsManifest[input.name]
|
||||
@@ -144846,7 +144893,7 @@ var claude = agent({
|
||||
const cliPath = await installClaude();
|
||||
const model = claudeEffortModels[ctx.payload.effort];
|
||||
const effortLevel = claudeEffortLevels[ctx.payload.effort];
|
||||
log.info(`\xBB using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
log.info(`\xBB model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
const disallowedTools = buildDisallowedTools(ctx);
|
||||
if (disallowedTools.length > 0) {
|
||||
log.info(`\xBB disallowed tools: ${disallowedTools.join(", ")}`);
|
||||
@@ -145029,7 +145076,7 @@ var PREFERRED_MODEL = "gpt-5.3-codex";
|
||||
var FALLBACK_MODEL = "gpt-5.2-codex";
|
||||
function getCodexEffortConfig(model) {
|
||||
return {
|
||||
mini: { model: "gpt-5.1-codex", reasoningEffort: "low" },
|
||||
mini: { model: "gpt-5.2-codex", reasoningEffort: "low" },
|
||||
auto: { model },
|
||||
max: { model, reasoningEffort: "high" }
|
||||
};
|
||||
@@ -145121,10 +145168,9 @@ var codex = agent({
|
||||
const [cliPath, model] = await Promise.all([installCodex(), resolveModel(apiKey)]);
|
||||
const codexDir = writeCodexConfig(ctx);
|
||||
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
|
||||
log.info(`\xBB using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
|
||||
if (effortConfig.reasoningEffort) {
|
||||
log.info(`\xBB using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
|
||||
}
|
||||
log.info(
|
||||
`\xBB model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
|
||||
);
|
||||
const sandboxMode = ctx.payload.push === "disabled" ? "read-only" : "workspace-write";
|
||||
const networkAccessEnabled = ctx.payload.web !== "disabled";
|
||||
const webSearchEnabled = ctx.payload.search !== "disabled";
|
||||
@@ -145342,7 +145388,7 @@ var cursor = agent({
|
||||
try {
|
||||
const projectConfig = JSON.parse(readFileSync5(projectCliConfigPath, "utf-8"));
|
||||
if (projectConfig.model) {
|
||||
log.info(`\xBB using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||
log.info(`\xBB model: ${projectConfig.model} (from .cursor/cli.json)`);
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
@@ -145353,9 +145399,9 @@ var cursor = agent({
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
if (modelOverride) {
|
||||
log.info(`\xBB using model: ${modelOverride}, effort=${ctx.payload.effort}`);
|
||||
log.info(`\xBB model: ${modelOverride}`);
|
||||
} else if (!existsSync6(projectCliConfigPath)) {
|
||||
log.info(`\xBB using default model, effort=${ctx.payload.effort}`);
|
||||
log.info(`\xBB model: default`);
|
||||
}
|
||||
const loggedModelCallIds = /* @__PURE__ */ new Set();
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
@@ -145555,7 +145601,8 @@ function configureCursorTools(ctx) {
|
||||
};
|
||||
}
|
||||
writeFileSync9(cliConfigPath, JSON.stringify(config3, null, 2), "utf-8");
|
||||
log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config3, null, 2));
|
||||
log.info(`\xBB CLI config written to ${cliConfigPath}`);
|
||||
log.debug(`\xBB CLI config contents: ${JSON.stringify(config3, null, 2)}`);
|
||||
}
|
||||
|
||||
// agents/gemini.ts
|
||||
@@ -145774,7 +145821,7 @@ function configureGeminiSettings(ctx) {
|
||||
const effortConfig = geminiEffortConfig[ctx.payload.effort];
|
||||
const model = process.env.GEMINI_MODEL ?? effortConfig.model;
|
||||
const thinkingLevel = effortConfig.thinkingLevel;
|
||||
log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
log.info(`\xBB model: ${model} (thinkingLevel: ${thinkingLevel})`);
|
||||
const realHome = homedir2();
|
||||
const geminiConfigDir = join12(realHome, ".gemini");
|
||||
const settingsPath = join12(geminiConfigDir, "settings.json");
|
||||
@@ -145865,7 +145912,9 @@ var opencode = agent({
|
||||
const modelOverride = process.env.OPENCODE_MODEL;
|
||||
if (modelOverride) {
|
||||
args2.push("--model", modelOverride);
|
||||
log.info(`\xBB using model override: ${modelOverride}`);
|
||||
log.info(`\xBB model: ${modelOverride} (override)`);
|
||||
} else {
|
||||
log.info(`\xBB model: auto-selected by OpenCode`);
|
||||
}
|
||||
process.env.HOME = tempHome;
|
||||
const env2 = {
|
||||
@@ -145948,12 +145997,7 @@ var opencode = agent({
|
||||
lastProviderError = providerError;
|
||||
log.error(`\xBB provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
} else {
|
||||
try {
|
||||
const parsed2 = JSON.parse(trimmed);
|
||||
log.debug(JSON.stringify(parsed2, null, 2));
|
||||
} catch {
|
||||
log.warning(trimmed);
|
||||
}
|
||||
log.debug(trimmed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -146052,7 +146096,7 @@ function configureOpenCode(ctx) {
|
||||
throw error49;
|
||||
}
|
||||
log.info(`\xBB OpenCode config written to ${configPath}`);
|
||||
log.info(
|
||||
log.debug(
|
||||
`\xBB OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
|
||||
);
|
||||
log.debug(`OpenCode config contents:
|
||||
@@ -146131,20 +146175,21 @@ var messageHandlers4 = {
|
||||
const status = event.part?.state?.status;
|
||||
const output = event.part?.state?.output;
|
||||
if (!toolName || !toolId) {
|
||||
log.debug(`\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
|
||||
log.info(
|
||||
`\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (toolName && toolId) {
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {}
|
||||
});
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {}
|
||||
});
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
},
|
||||
tool_result: (event, thinkingTimer) => {
|
||||
@@ -146725,23 +146770,18 @@ var defaultRunContext = {
|
||||
apiToken: ""
|
||||
};
|
||||
async function fetchRunContext(params) {
|
||||
const apiUrl = getApiUrl();
|
||||
const timeoutMs = 3e4;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders()
|
||||
},
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!response.ok) {
|
||||
return defaultRunContext;
|
||||
|
||||
+18
-14
@@ -3,6 +3,8 @@
|
||||
import { build } from "esbuild";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const isMainOnlyBuild = process.argv.includes("--main-only");
|
||||
|
||||
// Plugin to strip shebangs from output files
|
||||
/**
|
||||
* @type {import("esbuild").Plugin}
|
||||
@@ -67,20 +69,22 @@ await build({
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the post cleanup entry bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./post.ts"],
|
||||
outfile: "./post",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
if (!isMainOnlyBuild) {
|
||||
// Build the post cleanup entry bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./post.ts"],
|
||||
outfile: "./post",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the get-installation-token action
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./get-installation-token/entry.ts"],
|
||||
outfile: "./get-installation-token/entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
// Build the get-installation-token action
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./get-installation-token/entry.ts"],
|
||||
outfile: "./get-installation-token/entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
})
|
||||
}
|
||||
|
||||
console.log("» build completed successfully");
|
||||
|
||||
@@ -25681,15 +25681,43 @@ var core2 = __toESM(require_core(), 1);
|
||||
import { createSign } from "node:crypto";
|
||||
|
||||
// utils/apiUrl.ts
|
||||
function getApiUrl() {
|
||||
const url = process.env.API_URL || "https://pullfrog.com";
|
||||
log.debug(`resolved API_URL: ${url}`);
|
||||
return url;
|
||||
function isLocalUrl(url) {
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
}
|
||||
function getVercelBypassHeaders() {
|
||||
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (!secret) return {};
|
||||
return { "x-vercel-protection-bypass": secret };
|
||||
function getApiUrl() {
|
||||
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
|
||||
throw new Error(
|
||||
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
|
||||
);
|
||||
}
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// utils/apiFetch.ts
|
||||
async function apiFetch(options) {
|
||||
const apiUrl = getApiUrl();
|
||||
const url = new URL(options.path, apiUrl);
|
||||
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (bypassSecret) {
|
||||
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
||||
}
|
||||
const headers = {
|
||||
...options.headers
|
||||
};
|
||||
if (bypassSecret) {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
const init = {
|
||||
method: options.method ?? "GET",
|
||||
headers
|
||||
};
|
||||
if (options.body) init.body = options.body;
|
||||
if (options.signal) init.signal = options.signal;
|
||||
return fetch(url.toString(), init);
|
||||
}
|
||||
|
||||
// utils/retry.ts
|
||||
@@ -25729,37 +25757,26 @@ function isOIDCAvailable() {
|
||||
}
|
||||
async function acquireTokenViaOIDC(opts) {
|
||||
const oidcToken = await core2.getIDToken("pullfrog-api");
|
||||
const apiUrl = getApiUrl();
|
||||
const params = new URLSearchParams();
|
||||
const repos = [...opts?.repos ?? []];
|
||||
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
if (targetRepo) {
|
||||
repos.push(targetRepo);
|
||||
}
|
||||
if (repos.length) {
|
||||
params.set("repos", repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
||||
const timeoutMs = 3e4;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const fetchOptions = {
|
||||
const tokenResponse = await apiFetch({
|
||||
path: `/api/github/installation-token${reposParam}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders()
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
|
||||
signal: controller.signal
|
||||
};
|
||||
if (opts?.permissions) {
|
||||
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
`${apiUrl}/api/github/installation-token${queryString}`,
|
||||
fetchOptions
|
||||
);
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||
|
||||
+16
-3
@@ -132,7 +132,8 @@ export type CheckoutPrResult = {
|
||||
number: number;
|
||||
title: string;
|
||||
base: string;
|
||||
head: string;
|
||||
localBranch: string;
|
||||
remoteBranch: string;
|
||||
isFork: boolean;
|
||||
maintainerCanModify: boolean;
|
||||
url: string;
|
||||
@@ -289,6 +290,15 @@ export async function checkoutPrBranch(
|
||||
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
||||
}
|
||||
|
||||
// store push destination so push_branch can use it directly
|
||||
// git config is the primary mechanism, but toolState serves as a reliable fallback
|
||||
// in case git config reads fail in certain environments
|
||||
toolState.pushDest = {
|
||||
remoteName: isFork ? `pr-${pullNumber}` : "origin",
|
||||
remoteBranch: headBranch,
|
||||
localBranch,
|
||||
};
|
||||
|
||||
// execute post-checkout lifecycle hook
|
||||
await executeLifecycleHook({
|
||||
event: "post-checkout",
|
||||
@@ -356,7 +366,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
number: pr.data.number,
|
||||
title: pr.data.title,
|
||||
base: pr.data.base.ref,
|
||||
head: pr.data.head.ref,
|
||||
localBranch: `pr-${pull_number}`,
|
||||
remoteBranch: `refs/heads/${pr.data.head.ref}`,
|
||||
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
@@ -367,7 +378,9 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
|
||||
`use the line ranges to read specific files from the diff instead of reading the entire file. ` +
|
||||
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
|
||||
`review files selectively based on relevance rather than reading everything sequentially.`,
|
||||
`review files selectively based on relevance rather than reading everything sequentially. ` +
|
||||
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
|
||||
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`,
|
||||
} satisfies CheckoutPrResult;
|
||||
}),
|
||||
});
|
||||
|
||||
+16
-5
@@ -148,10 +148,14 @@ export const ReportProgress = type({
|
||||
});
|
||||
|
||||
/**
|
||||
* Standalone function to report progress to GitHub comment.
|
||||
* Can be called directly without going through the MCP tool interface.
|
||||
* Returns result data if successful.
|
||||
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result.
|
||||
* Report progress to a GitHub comment.
|
||||
*
|
||||
* progressCommentId has three states:
|
||||
* - undefined: no comment yet — will create one if an issue/PR target exists
|
||||
* - number: active comment — will update it in place
|
||||
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
|
||||
*
|
||||
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
|
||||
*/
|
||||
export async function reportProgress(
|
||||
ctx: ToolContext,
|
||||
@@ -201,6 +205,11 @@ export async function reportProgress(
|
||||
};
|
||||
}
|
||||
|
||||
// null = progress comment was deliberately deleted (e.g. by create_pull_request_review)
|
||||
if (existingCommentId === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
|
||||
// no existing comment - need an issue/PR to create one on
|
||||
// use fallback chain: dynamically set context > event payload
|
||||
if (issueNumber === undefined) {
|
||||
@@ -288,6 +297,8 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
/**
|
||||
* Delete the progress comment if it exists.
|
||||
* Used after submitting a PR review since the review body contains all necessary info.
|
||||
* Sets progressCommentId to null, which prevents future report_progress calls from
|
||||
* creating a new comment (the agent may call report_progress again after this).
|
||||
*/
|
||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
@@ -310,7 +321,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
// reset state and mark as updated so post script doesn't try to handle it
|
||||
// set to null (not undefined) so report_progress skips instead of creating a new comment
|
||||
ctx.toolState.progressCommentId = null;
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
|
||||
+3
-2
@@ -12,7 +12,7 @@ export const DelegateParams = type({
|
||||
"the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')"
|
||||
),
|
||||
"effort?": Effort.describe(
|
||||
'effort level for the subagent: "mini" (fast), "auto" (default, highly capable), or "max" (maximum capability)'
|
||||
'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)'
|
||||
),
|
||||
"instructions?": type.string.describe(
|
||||
"optional additional context or instructions for the subagent — use this to pass results from earlier delegations or narrow the subagent's focus"
|
||||
@@ -45,7 +45,8 @@ export function DelegateTool(ctx: ToolContext) {
|
||||
// guard: prevent subagent recursion
|
||||
if (ctx.toolState.delegationActive) {
|
||||
return {
|
||||
error: "delegation is not available inside a delegated subagent",
|
||||
error:
|
||||
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+103
-88
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { type } from "arktype";
|
||||
import type { BashPermission } from "../external.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -38,59 +39,6 @@ export const ListDirectoryParams = type({
|
||||
path: "string",
|
||||
});
|
||||
|
||||
// resolve path and validate it is within the repository.
|
||||
// uses realpathSync to follow symlinks and prevent symlink-based escapes.
|
||||
//
|
||||
// threat model: the primary scenario where symlink protection matters is a
|
||||
// malicious PR that plants symlinks in the repo (e.g. `secrets -> /etc/shadow`).
|
||||
// git materializes symlinks on linux, so after checkout the working tree contains
|
||||
// live symlinks. without realpathSync, file_read("secrets") would leak host files.
|
||||
//
|
||||
// when bash is enabled the agent can already `cat /etc/shadow` directly, so the
|
||||
// symlink check is defense-in-depth for that case. the check is most meaningful
|
||||
// when bash is disabled — the agent's only filesystem access is through these MCP
|
||||
// tools, and pre-planted symlinks become the sole escape vector.
|
||||
//
|
||||
// the path traversal check (resolve + startsWith) is always useful regardless of
|
||||
// bash — it blocks `../../etc/passwd` style escapes on every configuration.
|
||||
function resolveAndValidatePath(filePath: string): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// if the target exists, resolve symlinks to get the real location
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
// target doesn't exist yet (common for writes) — walk up to find
|
||||
// the first existing ancestor and verify it resolves within the repo.
|
||||
// this prevents creating files through symlinked parent directories.
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break; // reached filesystem root
|
||||
ancestor = parent;
|
||||
}
|
||||
|
||||
if (existsSync(ancestor)) {
|
||||
const realAncestor = realpathSync(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// also verify the resolved path (without symlink resolution) is within cwd
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// SECURITY: files that git interprets and can trigger code execution.
|
||||
// .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands.
|
||||
// .gitmodules can reference malicious submodule URLs that execute code on update.
|
||||
@@ -98,27 +46,92 @@ function resolveAndValidatePath(filePath: string): string {
|
||||
// and could write these files via shell, so blocking via MCP is redundant.
|
||||
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
|
||||
|
||||
type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
type ValidateWritePathParams = {
|
||||
filePath: string;
|
||||
bashPermission: BashPermission;
|
||||
};
|
||||
|
||||
function validateWritePath(params: ValidateWritePathParams): string {
|
||||
const resolved = resolveAndValidatePath(params.filePath);
|
||||
// resolve and validate a read path. allows:
|
||||
// 1. paths within the repo (with symlink protection to prevent malicious PR symlinks)
|
||||
// 2. paths within PULLFROG_TEMP_DIR (tool result files: diffs, CI logs, review threads, etc.)
|
||||
function resolveReadPath(filePath: string): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const relative = resolved.slice(cwd.length + 1);
|
||||
if (relative === ".git" || relative.startsWith(".git/")) {
|
||||
throw new Error(`writing to .git is not allowed: ${params.filePath}`);
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// allow reads from PULLFROG_TEMP_DIR (tool result files)
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// block git-interpreted files only when bash is disabled (no shell = no other way to create them)
|
||||
if (params.bashPermission === "disabled") {
|
||||
const basename = relative.split("/").pop() || "";
|
||||
// allow reads from the repo with symlink protection.
|
||||
// threat model: a malicious PR plants symlinks (e.g. `secrets -> /etc/shadow`).
|
||||
// git materializes symlinks on linux, so after checkout the working tree contains
|
||||
// live symlinks. realpathSync catches these and blocks the read.
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real === cwd || real.startsWith(cwd + "/")) {
|
||||
return real;
|
||||
}
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
|
||||
// path doesn't exist — check if it's within the repo
|
||||
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
|
||||
}
|
||||
|
||||
// resolve and validate a write path. enforces:
|
||||
// - repo-scoping with symlink protection (when bash !== "enabled")
|
||||
// - .git/ always blocked (defense-in-depth)
|
||||
// - .gitattributes/.gitmodules blocked when bash === "disabled"
|
||||
//
|
||||
// when bash=enabled, repo-scoping is dropped — the agent can write anywhere via native
|
||||
// bash, so restricting file_write to the repo would be security theater.
|
||||
function resolveWritePath(filePath: string, bashPermission: BashPermission): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// repo-scoping: enforced when agent doesn't have full bash
|
||||
if (bashPermission !== "enabled") {
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
} else {
|
||||
// target doesn't exist yet — walk up to find the first existing ancestor
|
||||
// and verify it resolves within the repo. prevents creating files through
|
||||
// symlinked parent directories.
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break;
|
||||
ancestor = parent;
|
||||
}
|
||||
if (existsSync(ancestor)) {
|
||||
const realAncestor = realpathSync(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(
|
||||
`path must be within the repository (symlink escape blocked): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .git always blocked anywhere in the path (defense-in-depth even with bash=enabled)
|
||||
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
|
||||
throw new Error(`writing to .git is not allowed: ${filePath}`);
|
||||
}
|
||||
|
||||
// git-interpreted files blocked anywhere in the path when bash is disabled
|
||||
if (bashPermission === "disabled") {
|
||||
const basename = resolved.split("/").pop() || "";
|
||||
if (GIT_INTERPRETED_FILES.includes(basename)) {
|
||||
throw new Error(
|
||||
`writing to ${basename} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}`
|
||||
`writing to ${basename} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,10 +142,12 @@ function validateWritePath(params: ValidateWritePathParams): string {
|
||||
export function FileReadTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_read",
|
||||
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`,
|
||||
description:
|
||||
"Read a file. Path is relative to the repository root, or an absolute path " +
|
||||
"to read tool result files (diffs, CI logs, etc.) from the temp directory.",
|
||||
parameters: FileReadParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const raw = readFileSync(resolved, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
|
||||
@@ -156,13 +171,12 @@ export function FileReadTool(_ctx: ToolContext) {
|
||||
export function FileWriteTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_write",
|
||||
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`,
|
||||
description:
|
||||
"Write content to a file. Path is relative to the repository root. " +
|
||||
"Writes to .git/ are blocked. Creates parent directories if needed.",
|
||||
parameters: FileWriteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const dir = dirname(resolved);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolved, params.content, "utf-8");
|
||||
@@ -174,7 +188,10 @@ export function FileWriteTool(ctx: ToolContext) {
|
||||
export function FileEditTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_edit",
|
||||
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence — set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`,
|
||||
description:
|
||||
"Replace text in a file. old_string must match exactly (including whitespace and indentation). " +
|
||||
"By default replaces a single unique occurrence — set replace_all to replace every occurrence. " +
|
||||
"Path is relative to the repository root. Writes to .git/ are blocked.",
|
||||
parameters: FileEditParams,
|
||||
execute: execute(async (params) => {
|
||||
if (params.old_string.length === 0) {
|
||||
@@ -184,10 +201,7 @@ export function FileEditTool(ctx: ToolContext) {
|
||||
throw new Error("old_string and new_string are identical");
|
||||
}
|
||||
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const count = content.split(params.old_string).length - 1;
|
||||
|
||||
@@ -213,13 +227,12 @@ export function FileEditTool(ctx: ToolContext) {
|
||||
export function FileDeleteTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_delete",
|
||||
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`,
|
||||
description:
|
||||
"Delete a file. Path is relative to the repository root. " +
|
||||
"Deletes to .git/ are blocked. Cannot delete directories.",
|
||||
parameters: FileDeleteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
unlinkSync(resolved);
|
||||
return { path: params.path, deleted: true };
|
||||
}),
|
||||
@@ -229,10 +242,12 @@ export function FileDeleteTool(ctx: ToolContext) {
|
||||
export function ListDirectoryTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "list_directory",
|
||||
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`,
|
||||
description:
|
||||
"List files and directories. Path is relative to the repository root, or an absolute path " +
|
||||
"to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
|
||||
parameters: ListDirectoryParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const entries = readdirSync(resolved, { withFileTypes: true });
|
||||
const sorted = entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
|
||||
+38
-31
@@ -3,7 +3,7 @@ import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import type { StoredPushDest, ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PushDestination = {
|
||||
@@ -14,37 +14,36 @@ type PushDestination = {
|
||||
|
||||
/**
|
||||
* get where git would actually push this branch.
|
||||
* uses git's native @{push} resolution, falls back to origin if unset.
|
||||
* prefers the stored destination from toolState (set by checkout_pr) when it
|
||||
* matches the current branch, because git config reads can silently fail in
|
||||
* certain environments causing pushes to the wrong remote branch.
|
||||
*
|
||||
* for branches created via checkout_pr: uses configured pushRemote/merge
|
||||
* for new branches (git checkout -b): falls back to origin/<branch>
|
||||
* falls back to reading branch.X.pushRemote and branch.X.merge from git config,
|
||||
* and finally to origin/<branch> for branches created without checkout_pr.
|
||||
*/
|
||||
function getPushDestination(branch: string): PushDestination {
|
||||
// try git's @{push} resolution first (works for checkout_pr branches)
|
||||
function getPushDestination(
|
||||
branch: string,
|
||||
storedDest: StoredPushDest | undefined
|
||||
): PushDestination {
|
||||
// prefer stored destination from checkout_pr when it matches the current branch
|
||||
if (storedDest && storedDest.localBranch === branch) {
|
||||
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
|
||||
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
|
||||
log: false,
|
||||
}).trim();
|
||||
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
|
||||
}
|
||||
|
||||
// fall back to git config (for branches not created by checkout_pr)
|
||||
try {
|
||||
const pushRef = $(
|
||||
"git",
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{push}`],
|
||||
{ log: false }
|
||||
).trim();
|
||||
|
||||
// pushRef is like "origin/main" or "pr-123/feature/foo"
|
||||
// parse carefully to handle branch names with slashes
|
||||
const slashIndex = pushRef.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
throw new Error(`unexpected push ref format: ${pushRef}`);
|
||||
}
|
||||
const remoteName = pushRef.slice(0, slashIndex);
|
||||
const remoteBranch = pushRef.slice(slashIndex + 1);
|
||||
|
||||
// get the actual URL git would push to (handles remote.X.pushurl)
|
||||
const url = $("git", ["remote", "get-url", "--push", remoteName], { log: false }).trim();
|
||||
|
||||
return { remoteName, remoteBranch, url };
|
||||
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
||||
const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
||||
const remoteBranch = merge.replace(/^refs\/heads\//, "");
|
||||
const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
|
||||
return { remoteName: pushRemote, remoteBranch, url };
|
||||
} catch {
|
||||
// @{push} not configured - branch was created locally without checkout_pr
|
||||
// fall back to origin with the same branch name
|
||||
log.debug(`no push tracking for ${branch}, falling back to origin/${branch}`);
|
||||
// no push config - branch was created locally without checkout_pr
|
||||
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
|
||||
const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
|
||||
return { remoteName: "origin", remoteBranch: branch, url };
|
||||
}
|
||||
@@ -60,6 +59,7 @@ function normalizeUrl(url: string): string {
|
||||
type ValidatePushParams = {
|
||||
branch: string;
|
||||
pushUrl: string;
|
||||
storedDest: StoredPushDest | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +67,7 @@ type ValidatePushParams = {
|
||||
* pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
|
||||
*/
|
||||
function validatePushDestination(params: ValidatePushParams): PushDestination {
|
||||
const dest = getPushDestination(params.branch);
|
||||
const dest = getPushDestination(params.branch, params.storedDest);
|
||||
|
||||
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) {
|
||||
throw new Error(
|
||||
@@ -95,7 +95,10 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "push_branch",
|
||||
description:
|
||||
"Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
|
||||
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
|
||||
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
|
||||
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
parameters: PushBranch,
|
||||
execute: execute(async ({ branchName, force }) => {
|
||||
// permission check
|
||||
@@ -110,7 +113,11 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
if (!pushUrl) {
|
||||
throw new Error("pushUrl not set - setupGit must run before push_branch");
|
||||
}
|
||||
const pushDest = validatePushDestination({ branch, pushUrl });
|
||||
const pushDest = validatePushDestination({
|
||||
branch,
|
||||
pushUrl,
|
||||
storedDest: ctx.toolState.pushDest,
|
||||
});
|
||||
|
||||
// block pushes to default branch in restricted mode
|
||||
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
||||
|
||||
+15
-7
@@ -15,10 +15,19 @@ export type BackgroundProcess = {
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
selectedMode?: string;
|
||||
@@ -34,7 +43,8 @@ export interface ToolState {
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
progressCommentId: number | null;
|
||||
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
||||
progressCommentId: number | null | undefined;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
output?: string;
|
||||
@@ -45,13 +55,11 @@ interface InitToolStateParams {
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const progressCommentId = params.progressCommentId
|
||||
? parseInt(params.progressCommentId, 10)
|
||||
: null;
|
||||
const resolvedId = Number.isNaN(progressCommentId) ? null : progressCommentId;
|
||||
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
if (progressCommentId) {
|
||||
log.info(`» using pre-created progress comment: ${progressCommentId}`);
|
||||
if (resolvedId) {
|
||||
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+3
-5
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { type } from "arktype";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "../utils/apiUrl.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) {
|
||||
const fileType = await fileTypeFromBuffer(buffer);
|
||||
const contentType = fileType?.mime || "application/octet-stream";
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
|
||||
const response = await apiFetch({
|
||||
path: "/api/upload/signed-url",
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/pullfrog",
|
||||
"version": "0.0.162",
|
||||
"version": "0.0.164",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type Inputs, main } from "./main.ts";
|
||||
import { defineFixture } from "./test/utils.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { runInDocker } from "./utils/docker.ts";
|
||||
import { ensureGitHubToken } from "./utils/github.ts";
|
||||
import { isInsideDocker } from "./utils/globals.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
@@ -34,6 +35,8 @@ config();
|
||||
config({ path: join(__dirname, "..", ".env") });
|
||||
|
||||
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
||||
await ensureGitHubToken();
|
||||
|
||||
// create unique temp directory path in OS temp location for parallel execution
|
||||
// use a parent dir from mkdtemp, then clone into a 'repo' subdirectory
|
||||
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
|
||||
|
||||
@@ -41258,7 +41258,7 @@ var Effort = type.enumerated("mini", "auto", "max");
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.162",
|
||||
version: "0.0.164",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
|
||||
@@ -30,11 +30,11 @@ function validator(result: AgentResult): ValidationCheck[] {
|
||||
const setOutputCalled = output !== null;
|
||||
const correctValue = setOutputCalled && /EFFORT_TEST_PASSED/i.test(output);
|
||||
|
||||
// the orchestrator runs at auto (effort=auto in its log line).
|
||||
// the delegate tool should spawn the subagent at mini (effort=mini in its log line).
|
||||
// if effort override works, we should see BOTH effort=auto AND effort=mini in the output.
|
||||
const orchestratorEffort = /running \w+ with effort=auto/i.test(agentOutput);
|
||||
const subagentEffort = /running \w+ with effort=mini/i.test(agentOutput);
|
||||
// the orchestrator runs at auto (» effort: auto in its log line).
|
||||
// the delegate tool should spawn the subagent at mini (» effort: mini in its log line).
|
||||
// if effort override works, we should see BOTH effort values in the output.
|
||||
const orchestratorEffort = /» effort:\s+auto/i.test(agentOutput);
|
||||
const subagentEffort = /» effort:\s+mini/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# determines which agents need testing based on changed files.
|
||||
# reads changed file paths from stdin (JSON array or newline-delimited).
|
||||
# outputs a JSON array of agent names to stdout.
|
||||
#
|
||||
# only agents whose harness file changed are included.
|
||||
# shared.ts/index.ts and other non-harness action changes fall back to claude as a canary.
|
||||
set -euo pipefail
|
||||
|
||||
# read stdin - auto-detect JSON array vs newline-delimited
|
||||
input=$(cat)
|
||||
if echo "$input" | jq -e 'type == "array"' > /dev/null 2>&1; then
|
||||
files=$(echo "$input" | jq -r '.[]')
|
||||
else
|
||||
files="$input"
|
||||
fi
|
||||
|
||||
# find which agent harness files changed
|
||||
changed_agents=()
|
||||
has_non_agent_change=false
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
case "$file" in
|
||||
action/agents/shared.ts|action/agents/index.ts)
|
||||
has_non_agent_change=true
|
||||
;;
|
||||
action/agents/*.ts)
|
||||
changed_agents+=("$(basename "$file" .ts)")
|
||||
;;
|
||||
action/*)
|
||||
has_non_agent_change=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$files"
|
||||
|
||||
# output agents based on change type.
|
||||
# non-agent action changes run claude as a canary.
|
||||
if [[ ${#changed_agents[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${changed_agents[@]}" | sort -u | jq -R . | jq -sc .
|
||||
elif $has_non_agent_change; then
|
||||
echo '["claude"]'
|
||||
else
|
||||
echo '[]'
|
||||
fi
|
||||
+30
-9
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -57,11 +58,14 @@ const expectedAgents = Object.keys(agentsManifest).sort();
|
||||
const crossagentTests = getTestNamesFromDir("crossagent");
|
||||
const agnosticTests = getTestNamesFromDir("agnostic");
|
||||
const adhocTests = getTestNamesFromDir("adhoc");
|
||||
const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents) }}";
|
||||
|
||||
// all API key names from all agents + GITHUB_TOKEN
|
||||
// all API key names from all agents + GITHUB_TOKEN + model overrides
|
||||
const expectedAgentEnvVars = [
|
||||
"GITHUB_TOKEN",
|
||||
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
||||
"GEMINI_MODEL",
|
||||
"OPENCODE_MODEL",
|
||||
].sort();
|
||||
|
||||
// agnostic tests only run with claude
|
||||
@@ -82,8 +86,25 @@ describe("ci workflow consistency", () => {
|
||||
const rootJob = rootWorkflow.jobs["action-agents"];
|
||||
const actionJob = actionWorkflow.jobs.agents;
|
||||
|
||||
it("root agent matrix matches agentsManifest", () => {
|
||||
expect([...rootJob.strategy!.matrix.agent].sort()).toEqual(expectedAgents);
|
||||
it("root agent matrix uses dynamic output from changes job", () => {
|
||||
expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
|
||||
});
|
||||
|
||||
it("changed-agents.sh falls back to claude when shared agent code changed", () => {
|
||||
const input = JSON.stringify(["action/agents/shared.ts"]);
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("changed-agents.sh falls back to claude for non-agent action changes", () => {
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input: JSON.stringify(["action/mcp/delegate.ts"]),
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("action agent matrix matches agentsManifest", () => {
|
||||
@@ -114,9 +135,9 @@ describe("ci workflow consistency", () => {
|
||||
expect(getEnvVarNames(rootJob)).toEqual(expectedAgentEnvVars);
|
||||
});
|
||||
|
||||
it("fail-fast is disabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(false);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(false);
|
||||
it("fail-fast is enabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(true);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,9 +169,9 @@ describe("ci workflow consistency", () => {
|
||||
expect(getEnvVarNames(rootJob)).toEqual(expectedAgnosticEnvVars);
|
||||
});
|
||||
|
||||
it("fail-fast is disabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(false);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(false);
|
||||
it("fail-fast is enabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(true);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ const secret = randomUUID();
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `Call the get_test_value tool from the robinMCP server, then call set_output with the exact value returned.`,
|
||||
prompt: `Call the get_test_value tool from the robinMCP server. It returns a JSON object with a "value" field. Extract that inner value string and pass it to set_output.`,
|
||||
bash: "disabled",
|
||||
effort: "mini",
|
||||
},
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `${buildBashToolPrompt("echo $PULLFROG_DIAGNOSTIC_ID")}
|
||||
prompt: `This is a test to determine token visibility in bash tool calls.
|
||||
|
||||
${buildBashToolPrompt("echo $PULLFROG_TEST_VALUE")}
|
||||
|
||||
Then also run: echo $PULLFROG_FILTER_TOKEN
|
||||
Then also run: echo $PULLFROG_TEST_TOKEN
|
||||
|
||||
Then call set_output with the exact output of each command, one per line:
|
||||
DIAGNOSTIC_ID=<value or "empty">
|
||||
@@ -27,14 +29,11 @@ FILTER_TOKEN=<value or "empty">`,
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
const { getUuid, agentEnv } = generateAgentUuids([
|
||||
"PULLFROG_DIAGNOSTIC_ID",
|
||||
"PULLFROG_FILTER_TOKEN",
|
||||
]);
|
||||
const { getUuid, agentEnv } = generateAgentUuids(["PULLFROG_TEST_VALUE", "PULLFROG_TEST_TOKEN"]);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const safeMarker = getUuid(result.agent, "PULLFROG_DIAGNOSTIC_ID");
|
||||
const filteredMarker = getUuid(result.agent, "PULLFROG_FILTER_TOKEN");
|
||||
const safeMarker = getUuid(result.agent, "PULLFROG_TEST_VALUE");
|
||||
const filteredMarker = getUuid(result.agent, "PULLFROG_TEST_TOKEN");
|
||||
|
||||
// require structured output from set_output tool
|
||||
const output = getStructuredOutput(result);
|
||||
|
||||
+6
-12
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { config } from "dotenv";
|
||||
import { runInDocker } from "../utils/docker.ts";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
import { ensureGitHubToken } from "../utils/github.ts";
|
||||
import { isInsideDocker } from "../utils/globals.ts";
|
||||
import {
|
||||
installSignalHandlers,
|
||||
@@ -311,14 +311,14 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
|
||||
}
|
||||
|
||||
// opencode: override to google/gemini-3-pro-preview to avoid flash's tight RPD quota limits
|
||||
// opencode: use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
||||
if (ctx.agent === "opencode") {
|
||||
env.OPENCODE_MODEL = "google/gemini-3-pro-preview";
|
||||
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
|
||||
}
|
||||
|
||||
// gemini: override to pro for all tests (including mini-effort) to avoid flash's tight RPD quota limits
|
||||
// gemini: use flash for all tests (including mini-effort) to avoid pro quota limits
|
||||
if (ctx.agent === "gemini") {
|
||||
env.GEMINI_MODEL = "gemini-3-pro-preview";
|
||||
env.GEMINI_MODEL ??= "gemini-3-flash-preview";
|
||||
}
|
||||
|
||||
// build file-based env vars for MCP servers that don't inherit parent env
|
||||
@@ -388,13 +388,7 @@ async function main(): Promise<void> {
|
||||
// run in Docker unless already inside
|
||||
if (!isInsideDocker) {
|
||||
// acquire token for docker if needed
|
||||
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
|
||||
console.log("» acquiring github installation token...");
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
}
|
||||
await ensureGitHubToken();
|
||||
runTestsInDocker(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
type ApiFetchOptions = {
|
||||
path: string;
|
||||
method?: string | undefined;
|
||||
headers?: Record<string, string> | undefined;
|
||||
body?: string | undefined;
|
||||
signal?: AbortSignal | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch wrapper for hitting the Pullfrog API with Vercel deployment protection bypass.
|
||||
*
|
||||
* adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
|
||||
* the server-side forwarding code uses query params, and the Vercel docs say both work,
|
||||
* so we do both as belt-and-suspenders.
|
||||
*
|
||||
* the query param approach is the primary bypass mechanism (matches server-side forwarding).
|
||||
* the header is added as a fallback.
|
||||
*/
|
||||
export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
|
||||
const apiUrl = getApiUrl();
|
||||
const url = new URL(options.path, apiUrl);
|
||||
|
||||
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (bypassSecret) {
|
||||
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// also add as header for belt-and-suspenders
|
||||
if (bypassSecret) {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
};
|
||||
if (options.body) init.body = options.body;
|
||||
if (options.signal) init.signal = options.signal;
|
||||
|
||||
return fetch(url.toString(), init);
|
||||
}
|
||||
+16
-13
@@ -1,24 +1,27 @@
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
function isLocalUrl(url: URL): boolean {
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve the Pullfrog API base URL.
|
||||
*
|
||||
* in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com.
|
||||
* in local dev: API_URL=http://localhost:3000 (from .env).
|
||||
*
|
||||
* enforces https:// for non-local URLs to prevent cleartext credential transmission.
|
||||
*/
|
||||
export function getApiUrl(): string {
|
||||
const url = process.env.API_URL || "https://pullfrog.com";
|
||||
log.debug(`resolved API_URL: ${url}`);
|
||||
return url;
|
||||
}
|
||||
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||
const parsed = new URL(raw);
|
||||
|
||||
/**
|
||||
* returns headers needed to bypass Vercel deployment protection on preview deployments.
|
||||
* when VERCEL_AUTOMATION_BYPASS_SECRET is set (preview repos), includes the bypass header.
|
||||
* otherwise returns an empty object (production / local dev).
|
||||
*/
|
||||
export function getVercelBypassHeaders(): Record<string, string> {
|
||||
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (!secret) return {};
|
||||
return { "x-vercel-protection-bypass": secret };
|
||||
if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
|
||||
throw new Error(
|
||||
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
|
||||
);
|
||||
}
|
||||
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ const testEnvAllowList = new Set([
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
|
||||
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
|
||||
"LOG_LEVEL",
|
||||
"DEBUG",
|
||||
"NODE_ENV",
|
||||
|
||||
+36
-28
@@ -2,7 +2,7 @@ import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import { retry } from "./retry.ts";
|
||||
|
||||
export interface InstallationToken {
|
||||
@@ -53,73 +53,63 @@ function isOIDCAvailable(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// github installation token permission levels
|
||||
type ReadWrite = "read" | "write";
|
||||
type WriteOnly = "write";
|
||||
type ReadOnly = "read";
|
||||
|
||||
// permission names use underscores (API format)
|
||||
type InstallationTokenPermissions = {
|
||||
/**
|
||||
* GitHub App installation access token permissions.
|
||||
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
|
||||
* fields and allowed values come from the `app-permissions` OpenAPI schema.
|
||||
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
|
||||
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
|
||||
*/
|
||||
type GitHubAppPermissions = {
|
||||
actions?: ReadWrite;
|
||||
artifact_metadata?: ReadWrite;
|
||||
attestations?: ReadWrite;
|
||||
checks?: ReadWrite;
|
||||
contents?: ReadWrite;
|
||||
deployments?: ReadWrite;
|
||||
id_token?: WriteOnly;
|
||||
issues?: ReadWrite;
|
||||
models?: ReadOnly;
|
||||
discussions?: ReadWrite;
|
||||
issues?: ReadWrite;
|
||||
packages?: ReadWrite;
|
||||
pages?: ReadWrite;
|
||||
pull_requests?: ReadWrite;
|
||||
security_events?: ReadWrite;
|
||||
statuses?: ReadWrite;
|
||||
workflows?: WriteOnly;
|
||||
};
|
||||
|
||||
type AcquireTokenOptions = {
|
||||
repos?: string[];
|
||||
permissions?: InstallationTokenPermissions;
|
||||
permissions?: GitHubAppPermissions;
|
||||
};
|
||||
|
||||
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
|
||||
const repos = [...(opts?.repos ?? [])];
|
||||
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
if (targetRepo) {
|
||||
repos.push(targetRepo);
|
||||
}
|
||||
if (repos.length) {
|
||||
params.set("repos", repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
||||
|
||||
const timeoutMs = 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit = {
|
||||
const tokenResponse = await apiFetch({
|
||||
path: `/api/github/installation-token${reposParam}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts?.permissions) {
|
||||
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
`${apiUrl}/api/github/installation-token${queryString}`,
|
||||
fetchOptions
|
||||
);
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -228,7 +218,7 @@ const checkRepositoryAccess = async (
|
||||
const createInstallationToken = async (
|
||||
jwt: string,
|
||||
installationId: number,
|
||||
permissions?: InstallationTokenPermissions
|
||||
permissions?: GitHubAppPermissions
|
||||
): Promise<string> => {
|
||||
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
|
||||
method: "POST",
|
||||
@@ -287,6 +277,24 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
|
||||
return await createInstallationToken(jwt, installationId, opts?.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a GitHub token is available in the environment.
|
||||
*
|
||||
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
|
||||
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
|
||||
*
|
||||
* **Not intended for production use** — this is a convenience for local
|
||||
* development and test harnesses where tokens aren't pre-provisioned.
|
||||
*/
|
||||
export async function ensureGitHubToken(): Promise<void> {
|
||||
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
|
||||
if (isOIDCAvailable()) {
|
||||
return await retry(() => acquireTokenViaOIDC(opts), {
|
||||
|
||||
+11
-2
@@ -346,7 +346,10 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
|
||||
Call \`delegate\` with a mode, effort level, and optional instructions:
|
||||
- \`mode\`: The workflow to run (see available modes below)
|
||||
- \`effort\`: \`"auto"\` (default, most capable), \`"mini"\` (fast, for simple tasks), or \`"max"\` (maximum capability)
|
||||
- \`effort\`:
|
||||
- \`"mini"\`: low-effort and fast, for simple tasks
|
||||
- \`"auto"\`: medium-effort, good for typical tasks that don't require significant reasoning
|
||||
- \`"max"\`: high-effort, good for PR reviews and complex coding tasks.
|
||||
- \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus.
|
||||
|
||||
### Single vs. multi-phase delegation
|
||||
@@ -419,7 +422,13 @@ export function resolveSubagentInstructions(
|
||||
): ResolvedInstructions {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode.
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
|
||||
|
||||
### Delegation rules
|
||||
|
||||
- The \`delegate\` tool is NOT available to you — complete your task directly using the available tools.
|
||||
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
|
||||
- If you encounter an error you cannot resolve, report it clearly — do not attempt to delegate or re-run yourself.
|
||||
|
||||
${ctx.mode.prompt}`;
|
||||
|
||||
|
||||
+9
-14
@@ -1,5 +1,5 @@
|
||||
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface Mode {
|
||||
@@ -52,24 +52,19 @@ export async function fetchRunContext(params: {
|
||||
token: string;
|
||||
repoContext: RepoContext;
|
||||
}): Promise<RunContext> {
|
||||
const apiUrl = getApiUrl();
|
||||
const timeoutMs = 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
|
||||
+5
-20
@@ -3,7 +3,6 @@
|
||||
* Redacts actual secret values rather than using pattern matching
|
||||
*/
|
||||
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import { getGitHubInstallationToken } from "./token.ts";
|
||||
|
||||
// patterns for sensitive env var names
|
||||
@@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
|
||||
function getAllSecrets(): string[] {
|
||||
const secrets: string[] = [];
|
||||
|
||||
// get all API key values from agent manifest
|
||||
for (const agent of Object.values(agentsManifest)) {
|
||||
for (const keyName of agent.apiKeyNames) {
|
||||
const envKey = keyName.toUpperCase();
|
||||
const value = process.env[envKey];
|
||||
if (value) {
|
||||
secrets.push(value);
|
||||
}
|
||||
// collect all env var values matching SENSITIVE_PATTERNS
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && isSensitiveEnvName(key)) {
|
||||
secrets.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
// for OpenCode: also scan all API_KEY environment variables (since apiKeyNames is empty)
|
||||
const opencodeAgent = agentsManifest.opencode;
|
||||
if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||
secrets.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add GitHub installation token
|
||||
// add GitHub installation token (stored in memory, not in env)
|
||||
try {
|
||||
const token = getGitHubInstallationToken();
|
||||
if (token) {
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export interface SetupGitParams extends GitContext {
|
||||
* - sets up authentication via gitToken (minimal contents:write)
|
||||
* - for PR events, checks out the PR branch using shared helper
|
||||
*
|
||||
* gitToken is a minimal-permission token (contents:write only) used for git operations.
|
||||
* gitToken is a minimal-permission token (contents + workflows) used for git operations.
|
||||
* it is assumed to be potentially exfiltratable, so it has limited scope.
|
||||
*/
|
||||
export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
|
||||
+11
-3
@@ -105,12 +105,20 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
|
||||
|
||||
// create git token based on push permission (assumed exfiltratable)
|
||||
// disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
|
||||
const gitContents = params.push === "disabled" ? "read" : "write";
|
||||
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
|
||||
// workflows permission is write-only in the API, so only requested when pushing is allowed
|
||||
const gitPermissions =
|
||||
params.push === "disabled"
|
||||
? { contents: "read" as const }
|
||||
: { contents: "write" as const, workflows: "write" as const };
|
||||
const gitToken = await acquireNewToken({ permissions: gitPermissions });
|
||||
if (isGitHubActions) {
|
||||
core.setSecret(gitToken);
|
||||
}
|
||||
log.info(`» acquired git token (contents:${gitContents})`);
|
||||
log.info(
|
||||
`» acquired git token (${Object.entries(gitPermissions)
|
||||
.map((e) => e.join(":"))
|
||||
.join(", ")})`
|
||||
);
|
||||
|
||||
// create full MCP token - not exfiltratable (only accessible via MCP tools)
|
||||
const mcpToken = await acquireNewToken();
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface WorkflowRunInfo {
|
||||
progressCommentId: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user