Compare commits

...

22 Commits

Author SHA1 Message Date
Colin McDonnell 37dcea86b9 0.0.163 2026-02-16 04:42:33 +00:00
Colin McDonnell 97937f46f7 console UI improvements and cleanup (#311)
* console UI improvements and cleanup

- add verify workflow button and API endpoint for manual installation check
- move env var check into PromptBox as blocking overlay (hoisted to RepoConsole)
- extract FlagsCheatSheet modal, replace verbose flag hints everywhere
- add info popovers for repo setup / post-checkout script descriptions
- remove unused prAutoFixCiFailures schema fields and migration
- default mentionAllowNonCollaborator to disabled for safety on public repos
- update docs for triggers and getting started

Co-authored-by: Cursor <cursoragent@cursor.com>

* add diagnostic logging for push_branch bug investigation

temporary [push-debug] logs to trace why getPushDestination falls back
to origin/<localBranch> instead of using the correct remote branch name
for same-repo PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* add git config diagnostic to verify original bug cause

Co-authored-by: Cursor <cursoragent@cursor.com>

* temporarily disable StoredPushDest to test git config path

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove diagnostic logging for push_branch investigation

verified that StoredPushDest fix works correctly on preview repo.
both the stored dest path and the git config fallback resolve to the
correct remote branch in the GitHub Actions environment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix formatting in AgentSettings and TriggersSettings

Co-authored-by: Cursor <cursoragent@cursor.com>

* pass derived env var state to PromptBox instead of raw secrets data

eliminates duplicated derivation logic between RepoConsole and PromptBox
by passing envVarMissing, envVarChecking, and agentKeyNames as props.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: prevent duplicate comment after PR review deletes progress comment

progressCommentId now uses three states: undefined (no comment yet),
number (active), null (deliberately deleted). After create_pull_request_review
deletes the progress comment, subsequent report_progress calls skip instead
of creating a new comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* effort descriptions, test ordering, husky, docs images, typo fix

- rewrote delegation effort level descriptions to per-level breakdown
- action-agents now waits for action-agnostic; action-agnostic waits for root
- added husky + lint-staged (biome check --write on staged files)
- updated triggers docs images and triggers.mdx content
- fixed "figured" → "figures" typo on landing page
- updated pnpm-lock.yaml

Co-authored-by: Cursor <cursoragent@cursor.com>

* Commit

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:41:04 +00:00
Colin McDonnell e45c4a84a2 remove dead preview API request forwarding (#309)
the action now calls preview deployments directly via API_URL secret
(set by preview-create.ts), making the production-side forwarding
fallback unnecessary. also removes orphaned workflowRun.ts interface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:04:23 +00:00
Colin McDonnell 9a1f3bdb0a relax filesystem permissions: reads allow temp dir, writes conditional on bash (#308)
reads (file_read, list_directory) now allow paths within the repo OR
PULLFROG_TEMP_DIR. this fixes the bug where agents with full permissions
couldn't read PR diffs, CI logs, review threads, or background bash
output because those files live under /tmp/pullfrog-xxx/.

writes (file_write, file_edit, file_delete) now only enforce repo-scoping
when bash !== "enabled". when bash=enabled the agent can write anywhere
via native bash, so restricting file_write was security theater. .git/
stays blocked in all modes as defense-in-depth.

replaced the conflated resolveAndValidatePath/validateWritePath helpers
with separate resolveReadPath and resolveWritePath functions that cleanly
separate path resolution from permission enforcement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:02:45 +00:00
Colin McDonnell b80c78bdbe Tweak 2026-02-14 03:58:27 +00:00
Colin McDonnell 8fd2b6aacb Tweak 2026-02-14 03:52:08 +00:00
Colin McDonnell 6ac428ee2b Tweak 2026-02-14 03:46:12 +00:00
Colin McDonnell 375e8e4455 use codex-mini-latest for mini effort level
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:38:04 +00:00
Colin McDonnell 593a956665 clarify mcpmerge prompt to extract inner value from JSON response
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:35:30 +00:00
Colin McDonnell 80ab5bad34 Tweak 2026-02-14 03:30:17 +00:00
Colin McDonnell 6313b09e30 switch opencode tests to codex, gemini tests to flash-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:24:21 +00:00
Colin McDonnell b753c67d0a switch test default models to gemini-2.5-pro
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:12:47 +00:00
Colin McDonnell 4789a2b5e3 respect GEMINI_MODEL and OPENCODE_MODEL env vars in test runner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:49:13 +00:00
Colin McDonnell 06683c1e0a respect GEMINI_MODEL and OPENCODE_MODEL env vars in test runner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:46:39 +00:00
Colin McDonnell 796c56a0c2 add model override vars to expected CI env vars
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:39:40 +00:00
Colin McDonnell 002f550e56 pass GEMINI_MODEL and OPENCODE_MODEL through root test workflow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:37:58 +00:00
Colin McDonnell 0e1f1ccbb7 pass GEMINI_MODEL and OPENCODE_MODEL vars through CI and Docker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:33:00 +00:00
Colin McDonnell 8a64742ddf sync action workflow fail-fast to match root workflow
the root workflow was updated to fail-fast: true but the action
workflow wasn't updated to match. the ci consistency test enforces
they stay in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:10:18 +00:00
Mateusz Burzyński 8037c118cc Generate tokens before running action/play.ts (#296)
* Generate tokens before running `action/play.ts`

* Extract `ensureGitHubToken` utility

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-02-13 20:09:54 +00:00
Colin McDonnell 6f108237d4 Deployment protection bypass (#298)
* test preview bypass 2

Co-authored-by: Cursor <cursoragent@cursor.com>

* add apiFetch wrapper with Vercel bypass via query param + header

the template workflow was missing VERCEL_AUTOMATION_BYPASS_SECRET,
so all action API calls to preview deployments hit Vercel's
deployment protection without bypass. this also consolidates the
bypass logic into a single fetch wrapper that applies the secret
as both a query parameter (matching server-side forwarding) and
a header for belt-and-suspenders reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>

* security hardening for Vercel bypass

- redact bypass token from webhook forwarder logs and response body
- remove dead x-preview-api-forward header
- refactor getAllSecrets() to use SENSITIVE_PATTERNS instead of hardcoded list
- enforce https:// on API_URL (localhost exempt for local dev)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 20:01:48 +00:00
Mateusz Burzyński d5508d99bb Base Cloudflare integration with the codebase (#261)
* Base Cloudflare integration with the codebase

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add a trigger script

* tweak script

* rename dir

* remove preinstallation from Dockerfile

* stream output

* fix type error

* remove redundant waitUntil

* use alarm

* rename to ActionSandbox

* tweak timeouts

* update

* update wrangler types

* fix bad rebase

* update env var name

* rename queues

* add settings to avoid pesky warnings

* add catch

* retry enqueueIndexingJob

* forward to DLQ

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-02-13 19:17:27 +00:00
Colin McDonnell 6a77ea6612 fix push_branch resolving to wrong remote branch (#282)
getPushDestination used git's @{push} which under push.default=simple
resolves using the local branch name as the remote branch name. since
checkout_pr uses pr-N as the local name, this resolved to origin/pr-N
instead of the actual PR branch (e.g. origin/pullfrog/feature-branch).

this caused two failure modes:
- agent passes remote branch name to push_branch → "src refspec does
  not match any" because no local branch has that name
- agent calls push_branch with no args → silently pushes to a new
  remote branch pr-N instead of updating the PR branch

fix: read branch.X.pushRemote and branch.X.merge from git config
directly (the exact config checkout_pr already writes) instead of
relying on @{push}. also rename head → localBranch + remoteBranch
in CheckoutPrResult to make the distinction explicit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 19:15:40 +00:00
29 changed files with 548 additions and 400 deletions
+2
View File
@@ -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 }}
+4 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
<!-- test preview system --> <!-- trivial touch -->
<!-- test preview system --> <!-- test bypass 2 -->
<p align="center">
<h1 align="center">
<picture>
+1 -1
View File
@@ -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" },
};
+151 -117
View File
@@ -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}`);
@@ -141000,6 +141017,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 +141079,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 +141422,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 +141831,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
@@ -141903,7 +141932,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"
@@ -142764,46 +142793,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 +142858,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 +142880,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 +142894,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 +142903,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 +142923,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 +142935,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 +142952,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 +142976,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 +142996,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 +143007,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 +144053,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 +144091,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 +144501,7 @@ import { join as join9 } from "node:path";
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.162",
version: "0.0.163",
type: "module",
files: [
"index.js",
@@ -145029,7 +145068,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" }
};
@@ -146725,23 +146764,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
View File
@@ -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");
+42 -25
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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"
+103 -88
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.162",
"version": "0.0.163",
"type": "module",
"files": [
"index.js",
+3
View File
@@ -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-"));
+1 -1
View File
@@ -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.163",
type: "module",
files: [
"index.js",
+9 -7
View File
@@ -58,10 +58,12 @@ const crossagentTests = getTestNamesFromDir("crossagent");
const agnosticTests = getTestNamesFromDir("agnostic");
const adhocTests = getTestNamesFromDir("adhoc");
// 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
@@ -114,9 +116,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 +150,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);
});
});
});
+1 -1
View File
@@ -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",
},
+7 -8
View File
@@ -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
View File
@@ -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);
}
+50
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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",
+24 -19
View File
@@ -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 {
@@ -85,41 +85,28 @@ type AcquireTokenOptions = {
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);
@@ -287,6 +274,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), {
+4 -1
View File
@@ -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
+9 -14
View File
@@ -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
View File
@@ -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) {
-3
View File
@@ -1,3 +0,0 @@
export interface WorkflowRunInfo {
progressCommentId: string | null;
}