Compare commits

...

16 Commits

Author SHA1 Message Date
Colin McDonnell 7d85e653ca bump action version to 0.0.201
Made-with: Cursor
2026-04-15 20:26:44 +00:00
Colin McDonnell 4b3c5ca905 rename agent key to opencode and add skill invocation coverage (#541)
* rename agent key to opencode and add skill invocation coverage.

add skill-invoke tests for claude and opencode with local play-based validation signals, update CI matrices, and include the current tracked refactors in this branch for review.

Made-with: Cursor

* exclude agent-specific skill-invoke tests from wrong agent in CI matrix

* address review follow-up and preserve workflow run UI tweak.

switch changed-agents ci coverage to exercise the opentoad implementation path while keeping the opencode expectation, and include the local workflow run client interaction updates requested on this branch.

Made-with: Cursor

* remove opentoad agent filename from runtime.

rename the opencode harness implementation file from opentoad.ts to opencode.ts and update ci coverage input accordingly so action code no longer carries the old filename.

Made-with: Cursor

* ensure security prompt bypass is set on every test fixture.

this keeps adversarial and permissions harnesses from being blocked by the default prompt-injection refusal path during CI security tests.

Made-with: Cursor

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-04-15 19:38:36 +00:00
Colin McDonnell 2799cce4bf homepage redesign + docs cleanup + agent prompting (#540)
* homepage redesign + docs cleanup + agent prompting improvements

- rewrote hero section: new tagline, responsive font sizing with clamp(),
  extracted shared constants for copy management
- added feature screenshots (shell isolation, github permissions, mcp tools,
  agent browser) and ensured consistent image sizing
- reworked feature section mobile layout: caption-style descriptions, bigger
  h3s, image padding
- made CTA buttons visible on all breakpoints (stacked on mobile, row on md+)
- reorganized docs/tools.mdx into single table with category dividers,
  simplified tool descriptions
- added markdown image syntax instruction to agent system prompt
- fixed InfoPopover overflow on small screens
- misc: InlineCode proportional sizing, OnboardingCard updates, shell/security
  doc improvements

Made-with: Cursor

* update pnpm-lock.yaml for agent-browser 0.25.4

Made-with: Cursor
2026-04-15 00:29:32 +00:00
Colin McDonnell 1da3f68e4e bump action version to 0.0.200
Made-with: Cursor
2026-04-14 23:57:32 +00:00
Colin McDonnell 50f2678f55 bump action version to 0.0.199
Made-with: Cursor
2026-04-14 23:34:56 +00:00
Colin McDonnell b748355cbe homepage copy refresh + fix skills CLI installation (#539)
* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage

Made-with: Cursor

* fix webhook race conditions: separate runId assignment from data updates

the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.

also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.

Made-with: Cursor

* homepage copy refresh + fix skills CLI installation

- update hero to "Agent x GitHub" with new subtagline
- rewrite intro paragraphs: workflow, harness capabilities, billing
- add feature sections: bash isolation, headless browser, MCP tools
- update FAQ answers, footer attribution, free-for-oss copy
- update APP_DESCRIPTION for SEO
- fix skills install: use npx from tmpdir instead of local binary
  (the bundled action has no node_modules; running npx from tmpdir
  avoids project .npmrc with pnpm settings breaking binary resolution)
- instruct agents to use markdown image syntax in upload_file tool
- start dependency installation eagerly from main.ts
- include event title in task instructions

Made-with: Cursor
2026-04-14 20:37:20 +00:00
Colin McDonnell c86752cf1d require OIDC verification for DB secrets on run-context (#538)
* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage

Made-with: Cursor

* fix webhook race conditions: separate runId assignment from data updates

the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.

also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.

Made-with: Cursor

* require OIDC verification for DB secrets on run-context endpoint

DB secrets transported via run-context were accessible to any GitHub API
token with read access, bypassing GitHub Actions' fork PR secret isolation.
Now the endpoint requires a valid GitHub Actions OIDC token
(X-GitHub-OIDC-Token header) with a matching repository claim before
returning dbSecrets. Also requires admin for account-scope CLI secret
writes (matching the dashboard), and removes dead redactSecrets code.

Made-with: Cursor
2026-04-14 20:15:31 +00:00
David Blass a4c7c0fc15 feat: workflow run artifact chips + GraphQL url resolution (#447) (#527)
* plan: issue 447 run artifact tracking and UI (supersedes stale pill notes)

Made-with: Cursor

* feat: workflow run artifact urls, chips, and safe PATCH validation

Made-with: Cursor

* chore(action): refresh latest-by-provider model snapshot

Made-with: Cursor

* refactor: resolve artifact urls via GraphQL nodes(ids), drop stored url columns

Made-with: Cursor

* docs: finalize issue 447 run-artifacts plan; remove demo backfill script

Made-with: Cursor

* refactor: DRY node-id constraint, replace margin with padding wrapper

Made-with: Cursor

* refactor: DRY audit — shared row info, derived types, unified Prisma select

- extract WorkflowRunRowInfo component (description + issue link + time + pills)
  shared by ActiveWorkflowRunsSection and WorkflowRunHistory
- derive API payload types via Omit + & instead of manual field lists;
  serialize with spread + override for bigint/date fields
- extract workflowRunListSelect shared Prisma select base; history extends
  with completedAt
- inline updateCommentNodeId → direct patchWorkflowRunFields calls
- derive WorkflowRunArtifactSlice from canonical exported types
- delete cancelling-out URL column migrations (no schema change vs main)

Made-with: Cursor

* refactor: artifact chips as inline CTAs with proper vertical alignment

- chips now render as action links: "Open PR #N", "View summary", etc.
- only render chips with resolved URLs; remove inert span fallback
- inline chips in the row (right-justified) instead of a separate line
- fix vertical alignment: remove ul/li wrappers that caused line-height
  mismatch, render chips as direct row siblings via flat flex layout
- change row to items-center, remove compensating self-start/pt nudges
- cancelled run X icon uses red-600

Made-with: Cursor

* chore(action): refresh latest-by-provider model snapshot

Made-with: Cursor

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-04-14 04:42:40 +00:00
Colin McDonnell abdbdc7245 update stale openrouter model snapshot
Made-with: Cursor
2026-04-14 01:15:56 +00:00
Colin McDonnell 5393d3dab4 bump action version to 0.0.198.
prepare the action package for the next publish with the ESM export/build updates already merged.

Made-with: Cursor
2026-04-12 19:51:18 +00:00
Colin McDonnell 3c2f3722ff fix action package exports and build ESM library entrypoints.
emit real ESM runtime + declaration outputs for programmatic imports, align package exports/types with built files, and add a no-cjs policy note.

Made-with: Cursor
2026-04-12 19:49:21 +00:00
Colin McDonnell 6541bdc4f4 test-token: use auth-only endpoint to actually verify the token
Made-with: Cursor
2026-04-12 19:44:03 +00:00
Colin McDonnell 1393ffb7b8 fix test-token workflow: use full action ref so runCli takes local path
Made-with: Cursor
2026-04-12 19:37:24 +00:00
Colin McDonnell f663d5e34d add workflow_dispatch test for get-installation-token action
Made-with: Cursor
2026-04-12 19:34:53 +00:00
Colin McDonnell d1e075fa3b fix npx binary resolution: run in workspace, not action directory
npx was running with cwd set to the action's own directory, which has
package.json with "name": "pullfrog". npm treats the local package as
satisfying the request and skips the registry fetch, then fails to find
the binary (sh: 1: pullfrog: not found). use GITHUB_WORKSPACE instead.

Made-with: Cursor
2026-04-12 19:17:09 +00:00
Colin McDonnell ed90735ba0 drop redundant NODE_AUTH_TOKEN="" from publish step
Made-with: Cursor
2026-04-12 19:03:22 +00:00
55 changed files with 426 additions and 219460 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
- name: Publish to npm
if: steps.check_tag.outputs.exists == 'false'
run: NODE_AUTH_TOKEN="" npm publish --provenance --access public
run: npm publish --provenance --access public
- name: Summary
if: always()
+35
View File
@@ -0,0 +1,35 @@
name: Test get-installation-token
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
test-token:
runs-on: ubuntu-latest
steps:
- name: Get installation token
id: token
uses: pullfrog/pullfrog/get-installation-token@main
- name: Verify token with Node.js
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
run: |
node -e '
const res = await fetch("https://api.github.com/installation/repositories?per_page=1", {
headers: {
Authorization: "token " + process.env.GITHUB_TOKEN,
Accept: "application/vnd.github+json",
},
});
if (!res.ok) throw new Error("GET installation/repositories failed: " + res.status + " " + (await res.text()));
const data = await res.json();
console.log("authenticated — installation has access to", data.total_count, "repo(s)");
console.log("first repo:", data.repositories[0].full_name);
'
+15 -2
View File
@@ -27,9 +27,22 @@ jobs:
strategy:
fail-fast: true
matrix:
agent: [claude, opentoad]
agent: [claude, opencode]
test:
[mcpmerge, nobash, restricted, smoke, token-exfil]
[
mcpmerge,
nobash,
restricted,
skill-invoke-claude,
skill-invoke-opencode,
smoke,
token-exfil,
]
exclude:
- agent: claude
test: skill-invoke-opencode
- agent: opencode
test: skill-invoke-claude
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Claude Code agent — secure harness around the `claude` CLI.
*
* mirrors the opentoad harness's security model:
* mirrors the opencode harness's security model:
* - native Bash blocked via --disallowedTools (agent cannot shell out)
* - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
+2 -2
View File
@@ -1,7 +1,7 @@
import { claude } from "./claude.ts";
import { opentoad } from "./opentoad.ts";
import { opencode } from "./opencode.ts";
import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } from "./shared.ts";
export const agents = { claude, opentoad } satisfies Record<string, Agent>;
export const agents = { claude, opencode } satisfies Record<string, Agent>;
+4 -5
View File
@@ -1,10 +1,9 @@
/**
* OpenToad agent secure harness around OpenCode CLI.
* OpenCode agent secure harness around OpenCode CLI.
*
* transparently wraps OpenCode with a security layer:
* - bash: "deny" via OPENCODE_CONFIG_CONTENT (agent cannot shell out)
* - OPENCODE_PERMISSION: filesystem sandbox deny all external paths except /tmp
* - untrusted .opencode/plugins/ and .opencode/tools/ deleted before launch
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
* - MCP server injected alongside project config (not replacing)
* - ASKPASS handles git auth separately (token never in subprocess env)
@@ -607,8 +606,8 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
// ── agent ───────────────────────────────────────────────────────────────────────
export const opentoad = agent({
name: "opentoad",
export const opencode = agent({
name: "opencode",
install: installOpencodeCli,
run: async (ctx) => {
const cliPath = await installOpencodeCli();
@@ -676,7 +675,7 @@ export const opentoad = agent({
log.info(`» dirty working tree (attempt ${attempt + 1}/${MAX_COMMIT_RETRIES}):\n${status}`);
result = await runOpenCode({
...runParams,
args: [...baseArgs, "--continue", buildCommitPrompt("opentoad", status)],
args: [...baseArgs, "--continue", buildCommitPrompt("opencode", status)],
});
}
-151372
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -1,10 +1,13 @@
// @ts-check
import { build } from "esbuild";
import { readFileSync, writeFileSync } from "fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
rmSync("./dist", { recursive: true, force: true });
mkdirSync("./dist", { recursive: true });
// Plugin to strip shebangs from output files
/**
* @type {import("esbuild").Plugin}
@@ -73,6 +76,21 @@ await build({
},
});
// Build ESM library entrypoints for programmatic imports
await build({
...sharedConfig,
entryPoints: ["./index.ts"],
outfile: "./dist/index.js",
target: "node20",
});
await build({
...sharedConfig,
entryPoints: ["./internal/index.ts"],
outfile: "./dist/internal.js",
target: "node20",
});
// prepend shebang after strip (esbuild banner can't guarantee line 1 placement)
const cliPath = "./dist/cli.mjs";
const cliContent = readFileSync(cliPath, "utf8");
+2 -2
View File
@@ -8,7 +8,7 @@
export const pullfrogMcpName = "pullfrog";
/** @see {@link file://./agents/shared.ts} Agent interface that uses this type */
export type AgentId = "claude" | "opentoad";
export type AgentId = "claude" | "opencode";
/**
* format a tool name the way each agent's MCP client presents it to the model.
@@ -19,7 +19,7 @@ export function formatMcpToolRef(agentId: AgentId, toolName: string): string {
switch (agentId) {
case "claude":
return `mcp__${pullfrogMcpName}__${toolName}`;
case "opentoad":
case "opencode":
return `${pullfrogMcpName}_${toolName}`;
default:
return agentId satisfies never;
File diff suppressed because one or more lines are too long
+25 -2
View File
@@ -1,7 +1,10 @@
// changes to tool permissions should be reflected in wiki/granular-tools.md
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import * as core from "@actions/core";
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
import { startInstallation } from "./mcp/dependencies.ts";
import {
initToolState,
startMcpHttpServer,
@@ -307,6 +310,8 @@ export async function main(): Promise<MainResult> {
log.info(`» MCP server started at ${mcpHttpServer.url}`);
timer.checkpoint("mcpServer");
startInstallation(toolContext);
if (payload.model) log.info(`» model: ${payload.model}`);
if (payload.timeout) log.info(`» timeout: ${payload.timeout}`);
log.info(`» push: ${payload.push}`);
@@ -334,6 +339,21 @@ export async function main(): Promise<MainResult> {
log.info(instructions.full);
});
// OpenCode loads .opencode/plugin/ files at startup. if the repo has any,
// eagerly await dependency installation so plugin imports can resolve.
if (agentId === "opencode") {
const pluginDir = join(process.cwd(), ".opencode", "plugin");
const hasPlugins =
existsSync(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
if (hasPlugins && toolState.dependencyInstallation?.promise) {
log.info(
"» .opencode/plugin/ detected — awaiting dependency installation before agent start"
);
await toolState.dependencyInstallation.promise.catch(() => {});
timer.checkpoint("awaitDepsForPlugins");
}
}
// run agent, optionally with timeout enforcement
activityTimeout = createProcessOutputActivityTimeout({
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
@@ -457,9 +477,12 @@ export async function main(): Promise<MainResult> {
killTrackedChildren();
log.error(errorMessage);
// best-effort summary — don't mask the original error
// best-effort summary — write the error so it's visible in the Actions summary tab
try {
await writeJobSummary(toolState);
const errorSummary = `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``;
const usageSummary = formatUsageSummary(toolState.usageEntries);
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
await writeSummary(parts.join("\n\n"));
} catch {}
try {
+7 -44
View File
@@ -1,49 +1,12 @@
import { type } from "arktype";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { retry } from "../utils/retry.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
export async function updateCommentNodeId(
ctx: ToolContext,
field: CommentNodeIdField,
nodeId: string
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ [field]: nodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: `updateCommentNodeId(${field})`,
}
);
} catch (error) {
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
}
}
/**
* The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state
@@ -118,7 +81,7 @@ export function CreateCommentTool(ctx: ToolContext) {
});
if (result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
}
return {
@@ -138,7 +101,7 @@ export function CreateCommentTool(ctx: ToolContext) {
if (commentType === "Plan") {
if (result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
}
// add "Implement plan" link (needs comment ID, so create-then-update)
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
@@ -161,7 +124,7 @@ export function CreateCommentTool(ctx: ToolContext) {
}
if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
}
return {
@@ -266,7 +229,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
}
return {
@@ -300,7 +263,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
}
return {
@@ -353,7 +316,7 @@ export async function reportProgress(
});
if (updateResult.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
}
return {
+3 -2
View File
@@ -67,9 +67,10 @@ Inspect the repository structure to determine how dependencies should be install
}
/**
* start dependency installation in the background (non-blocking, idempotent)
* start dependency installation in the background (non-blocking, idempotent).
* called eagerly from main.ts at startup and also available via MCP tools.
*/
function startInstallation(ctx: ToolContext): void {
export function startInstallation(ctx: ToolContext): void {
// already started or completed - do nothing
if (ctx.toolState.dependencyInstallation) {
return;
+13 -5
View File
@@ -1,5 +1,6 @@
import { type } from "arktype";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -21,16 +22,23 @@ export function IssueTool(ctx: ToolContext) {
name: "create_issue",
description: "Create a new GitHub issue",
parameters: Issue,
execute: execute(async ({ title, body, labels, assignees }) => {
execute: execute(async (params) => {
const result = await ctx.octokit.rest.issues.create({
owner: ctx.repo.owner,
repo: ctx.repo.name,
title: title,
body: fixDoubleEscapedString(body),
labels: labels ?? [],
assignees: assignees ?? [],
title: params.title,
body: fixDoubleEscapedString(params.body),
labels: params.labels ?? [],
assignees: params.assignees ?? [],
});
const nodeId = result.data.node_id;
if (typeof nodeId === "string" && nodeId.length > 0) {
await patchWorkflowRunFields(ctx, {
issueNodeId: nodeId,
});
}
return {
success: true,
issueId: result.data.id,
+7
View File
@@ -2,6 +2,7 @@ import { type } from "arktype";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -94,6 +95,12 @@ export function CreatePullRequestTool(ctx: ToolContext) {
}
}
if (typeof result.data.node_id === "string" && result.data.node_id.length > 0) {
await patchWorkflowRunFields(ctx, {
prNodeId: result.data.node_id,
});
}
return {
success: true,
pullRequestId: result.data.id,
+7 -29
View File
@@ -1,11 +1,11 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { formatMcpToolRef } from "../external.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -305,34 +305,12 @@ async function createAndSubmitWithFooter(
}
/**
* report the review node ID to the server so the WorkflowRun is marked as "review submitted".
* report the review node ID so the WorkflowRun is marked as "review submitted".
* exported for use in main.ts post-agent cleanup.
*/
export async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string): Promise<void> {
for (let remaining = 2; remaining >= 0; remaining--) {
try {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ reviewNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (response.ok) return;
if (remaining > 0) {
log.debug(`reportReviewNodeId got ${response.status}, retrying (${remaining} left)`);
await new Promise((r) => setTimeout(r, 2000));
}
} catch (error) {
if (remaining > 0) {
log.debug(`reportReviewNodeId failed, retrying (${remaining} left): ${error}`);
await new Promise((r) => setTimeout(r, 2000));
} else {
log.debug(`reportReviewNodeId exhausted retries: ${error}`);
}
}
}
export async function reportReviewNodeId(
ctx: ToolContext,
params: { nodeId: string }
): Promise<void> {
await patchWorkflowRunFields(ctx, { reviewNodeId: params.nodeId });
}
+9 -2
View File
@@ -65,7 +65,7 @@ function detectSandboxMethod(): SandboxMethod {
// continue to try sudo
}
// try sudo unshare (works on GHA runners)
// sudo unshare (works on GHA runners)
try {
const result = spawnSync("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], {
timeout: 5000,
@@ -81,7 +81,7 @@ function detectSandboxMethod(): SandboxMethod {
}
detectedSandboxMethod = "none";
log.info("PID namespace isolation not available - falling back to env filtering only");
log.info("PID namespace isolation not available");
return "none";
}
@@ -97,6 +97,13 @@ const PROC_CLEANUP =
function spawnShell(params: SpawnParams): ChildProcess {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const sandboxMethod = detectSandboxMethod();
const ci = process.env.CI === "true";
if (ci && sandboxMethod === "none") {
throw new Error(
"pid namespace isolation is required in CI but unavailable (both unshare and sudo unshare failed)"
);
}
if (sandboxMethod === "unshare") {
return spawn(
+1 -1
View File
@@ -14,7 +14,7 @@ export function UploadFileTool(ctx: ToolContext) {
return tool({
name: "upload_file",
description:
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed.",
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed. when embedding uploaded images in comments or PR bodies, always use markdown image syntax: ![description](url)",
parameters: UploadFileParams,
execute: execute(async (params) => {
// read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded
+2 -2
View File
@@ -298,5 +298,5 @@ ${PR_SUMMARY_FORMAT}`,
];
}
// static export for UI display — uses opentoad format as the readable default
export const modes: Mode[] = computeModes("opentoad");
// static export for UI display — uses opencode format as the readable default
export const modes: Mode[] = computeModes("opencode");
+6 -9
View File
@@ -1,6 +1,6 @@
{
"name": "pullfrog",
"version": "0.0.196",
"version": "0.0.201",
"type": "module",
"bin": {
"pullfrog": "dist/cli.mjs",
@@ -13,7 +13,7 @@
"scripts": {
"test": "vitest",
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js",
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
"play": "node play.ts",
"runtest": "node test/run.ts",
@@ -37,7 +37,7 @@
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
"@types/turndown": "^5.0.5",
"agent-browser": "0.21.0",
"agent-browser": "0.25.4",
"ajv": "^8.18.0",
"arg": "^5.0.2",
"arkregex": "0.0.5",
@@ -74,22 +74,19 @@
"url": "https://github.com/pullfrog/pullfrog/issues"
},
"homepage": "https://github.com/pullfrog/pullfrog#readme",
"zshy": {
"exports": "./index.ts"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@pullfrog/source": "./index.ts",
"types": "./dist/index.d.cts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"default": "./dist/index.js"
},
"./internal": {
"@pullfrog/source": "./internal/index.ts",
"types": "./dist/internal.d.cts",
"types": "./dist/internal/index.d.ts",
"import": "./dist/internal.js",
"default": "./dist/internal.js"
},
+5 -5
View File
@@ -51,8 +51,8 @@ importers:
specifier: ^5.0.5
version: 5.0.6
agent-browser:
specifier: 0.21.0
version: 0.21.0
specifier: 0.25.4
version: 0.25.4
ajv:
specifier: ^8.18.0
version: 8.18.0
@@ -864,8 +864,8 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
agent-browser@0.21.0:
resolution: {integrity: sha512-isVHEeb2WL5hLhr4o+zNmcYwmBrldxvrH+FIoRoUmDxyrHr3bhIS6L8BlUMHqT77YtkPq0YSmwoBRrwqeouw9Q==}
agent-browser@0.25.4:
resolution: {integrity: sha512-vl4tzDAk5+pJ2g8eMWWzZOLJ2yevJDN3YQ1gcSdN3GKPI0RwNr+T/2PxqSxsipiREu0oid2yxFJIoQ6NMrq/fQ==}
hasBin: true
ajv-formats@3.0.1:
@@ -2506,7 +2506,7 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
agent-browser@0.21.0: {}
agent-browser@0.25.4: {}
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
-41874
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -44,7 +44,7 @@ function runNpx(context: RuntimeContext, packageSpec: string, cliArgs: string[])
? join(context.nodeBinDir, "npx.cmd")
: join(context.nodeBinDir, "npx");
execFileSync(npxPath, ["--yes", packageSpec, ...cliArgs], {
cwd: context.actionRoot,
cwd: process.env.GITHUB_WORKSPACE || context.actionRoot,
stdio: "inherit",
env: context.env,
});
+2 -2
View File
@@ -27,8 +27,8 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-04-07",
},
"openrouter": {
"modelId": "z-ai/glm-5.1",
"releaseDate": "2026-04-07",
"modelId": "openrouter/elephant-alpha",
"releaseDate": "2026-04-13",
},
"xai": {
"modelId": "grok-4.20-multi-agent-0309",
+1 -1
View File
@@ -90,6 +90,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
tags: ["adhoc", "security"],
agents: ["opentoad"],
agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
};
+1 -1
View File
@@ -107,6 +107,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
tags: ["adhoc", "security"],
agents: ["opentoad"],
agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
};
+1 -1
View File
@@ -78,6 +78,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
tags: ["adhoc", "security"],
agents: ["opentoad"],
agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
};
+1
View File
@@ -94,5 +94,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
repoSetup,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic", "security"],
};
+1
View File
@@ -102,5 +102,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"],
};
+1
View File
@@ -90,5 +90,6 @@ export const test: TestRunnerOptions = {
name: "pkg-json-scripts",
fixture,
validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic", "security"],
};
+1
View File
@@ -60,5 +60,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"],
};
+1
View File
@@ -72,5 +72,6 @@ export const test: TestRunnerOptions = {
name: "push-enabled",
fixture,
validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"],
};
+1
View File
@@ -65,5 +65,6 @@ export const test: TestRunnerOptions = {
name: "push-restricted",
fixture,
validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"],
};
+1
View File
@@ -27,5 +27,6 @@ export const test: TestRunnerOptions = {
fixture,
validator,
expectFailure: true,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"],
};
+3 -3
View File
@@ -4,7 +4,7 @@
# outputs a JSON array of agent names to stdout.
#
# only agents whose harness file changed AND are exported from index.ts are included.
# shared.ts/index.ts and other non-harness action changes fall back to opentoad as a canary.
# shared.ts/index.ts and other non-harness action changes fall back to opencode as a canary.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -58,9 +58,9 @@ while IFS= read -r file; do
done <<< "$files"
# output agents based on change type.
# non-agent action changes always include opentoad as a canary.
# non-agent action changes always include opencode as a canary.
if $has_non_agent_change; then
changed_agents+=("opentoad")
changed_agents+=("opencode")
fi
if [[ ${#changed_agents[@]} -gt 0 ]]; then
+8 -8
View File
@@ -87,29 +87,29 @@ describe("ci workflow consistency", () => {
expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
});
it("changed-agents.sh falls back to opentoad when shared agent code changed", () => {
it("changed-agents.sh falls back to opencode 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(["opentoad"]);
expect(JSON.parse(output)).toEqual(["opencode"]);
});
it("changed-agents.sh falls back to opentoad for non-agent action changes", () => {
it("changed-agents.sh falls back to opencode for non-agent action changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/mcp/server.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["opentoad"]);
expect(JSON.parse(output)).toEqual(["opencode"]);
});
it("changed-agents.sh includes opentoad canary alongside changed agents", () => {
it("changed-agents.sh includes opencode canary alongside changed agents", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/agents/opentoad.ts", "action/mcp/server.ts"]),
input: JSON.stringify(["action/agents/opencode.ts", "action/mcp/server.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["opentoad"]);
expect(JSON.parse(output)).toEqual(["opencode"]);
});
it("changed-agents.sh treats legacy agent files as non-agent changes", () => {
@@ -117,7 +117,7 @@ describe("ci workflow consistency", () => {
input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["opentoad"]);
expect(JSON.parse(output)).toEqual(["opencode"]);
});
it("action agent matrix matches agents map", () => {
+1
View File
@@ -38,6 +38,7 @@ export const test: TestRunnerOptions = {
validator,
env: {
GITHUB_REPOSITORY: "pullfrog/test-repo-mcp",
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MCP_SECRET: secret,
},
repoSetup:
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
const skillName = "pullfrog-skill-check";
const token = randomUUID();
const fixture = defineFixture(
{
prompt: `Do not modify any files.
Use the skill tool to load ${skillName}.
Then call set_output with exactly this token and nothing else: ${token}`,
shell: "restricted",
push: "disabled",
timeout: "4m",
},
{ localOnly: true }
);
const repoSetup = `mkdir -p .claude/skills/${skillName} .opencode/skills/${skillName} && printf '%s\\n' '---' 'name: ${skillName}' 'description: local skill test token source' '---' '' 'token: ${token}' > .claude/skills/${skillName}/SKILL.md && cp .claude/skills/${skillName}/SKILL.md .opencode/skills/${skillName}/SKILL.md`;
function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = result.structuredOutput !== null;
const tokenMatches = result.structuredOutput === token;
const agentOutput = getAgentOutput(result);
const skillInvoked = /Skill\(\{[^)]*"skill":"pullfrog-skill-check"/.test(agentOutput);
return [
{ name: "set_output", passed: setOutputCalled },
{ name: "token_matches", passed: tokenMatches },
{ name: "skill_invoked", passed: skillInvoked },
];
}
export const test: TestRunnerOptions = {
name: "skill-invoke-claude",
fixture,
validator,
agents: ["claude"],
repoSetup,
env: {
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
},
};
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
const skillName = "pullfrog-skill-check";
const token = randomUUID();
const fixture = defineFixture(
{
prompt: `Do not modify any files.
Use the skill tool to load ${skillName}.
Then call set_output with exactly this token and nothing else: ${token}`,
shell: "restricted",
push: "disabled",
timeout: "4m",
},
{ localOnly: true }
);
const repoSetup = `mkdir -p .claude/skills/${skillName} .opencode/skills/${skillName} && printf '%s\\n' '---' 'name: ${skillName}' 'description: local skill test token source' '---' '' 'token: ${token}' > .claude/skills/${skillName}/SKILL.md && cp .claude/skills/${skillName}/SKILL.md .opencode/skills/${skillName}/SKILL.md`;
function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = result.structuredOutput !== null;
const tokenMatches = result.structuredOutput === token;
const agentOutput = getAgentOutput(result);
const skillInvoked = /skill\(\{[^)]*"name":"pullfrog-skill-check"/.test(agentOutput);
return [
{ name: "set_output", passed: setOutputCalled },
{ name: "token_matches", passed: tokenMatches },
{ name: "skill_invoked", passed: skillInvoked },
];
}
export const test: TestRunnerOptions = {
name: "skill-invoke-opencode",
fixture,
validator,
agents: ["opencode"],
repoSetup,
env: {
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
},
};
+1
View File
@@ -28,4 +28,5 @@ export const test: TestRunnerOptions = {
name: "smoke",
fixture,
validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
};
+1 -1
View File
@@ -6,7 +6,7 @@ import { defineFixture, generateAgentUuids, getAgentOutput } from "../utils.ts";
* process environment. SANDBOX_TEST_TOKEN is set in the agent's process env
* but should be invisible via:
* - shell: filterEnv() strips *_TOKEN vars, PID namespace hides parent /proc
* - native tools: OPENCODE_PERMISSION denies external_directory (opentoad),
* - native tools: OPENCODE_PERMISSION denies external_directory (opencode),
* managed-settings.json denies /proc reads (claude)
*
* runs with both agents to verify each sandbox independently.
+7 -7
View File
@@ -27,14 +27,14 @@ import {
* filters can be test names, tags, or agent names:
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
* node test/run.ts opentoad # run all tests for opentoad only
* node test/run.ts opencode # run all tests for opencode only
* node test/run.ts security # run all tests tagged "security"
* node test/run.ts agnostic # run all agnostic-tagged tests (with opentoad)
* node test/run.ts agnostic # run all agnostic-tagged tests (with opencode)
* node test/run.ts adhoc # run all adhoc-tagged tests
* node test/run.ts smoke opentoad # run smoke tests for opentoad only
* node test/run.ts smoke opencode # run smoke tests for opencode only
*
* special tags:
* - "agnostic": runs with opentoad only, excluded when filtering by agent
* - "agnostic": runs with opencode only, excluded when filtering by agent
* - "adhoc": excluded from default runs, must be explicitly requested
*
* by default, runs in a Docker container for isolation.
@@ -303,7 +303,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
if (!Object.hasOwn(env, "PULLFROG_MODEL")) {
const defaultModels: Record<string, string> = {
claude: "anthropic/claude-sonnet-4-6",
opentoad: "anthropic/claude-sonnet-4-6",
opencode: "anthropic/claude-sonnet-4-6",
};
const model = defaultModels[ctx.agent];
if (model) {
@@ -414,11 +414,11 @@ async function main(): Promise<void> {
const isAgnostic = hasTag(testInfo, "agnostic");
if (isAgnostic) {
// agnostic tests: skip if only filtering by agent, otherwise run with opentoad
// agnostic tests: skip if only filtering by agent, otherwise run with opencode
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
continue;
}
runs.push({ testInfo, agent: "opentoad" });
runs.push({ testInfo, agent: "opencode" });
} else {
// determine which agents to run for this test
const testAgents = testInfo.config.agents ?? agents;
+2 -2
View File
@@ -90,7 +90,7 @@ export function generateAgentUuids<T extends string>(envVarNames: T[]): AgentUui
// assign consistent colors to agents (using ANSI codes)
const AGENT_COLORS: Record<string, string> = {
opentoad: "\x1b[32m", // green
opencode: "\x1b[32m", // green
};
const RESET = "\x1b[0m";
@@ -325,7 +325,7 @@ export interface TestRunnerOptions {
repoSetup?: string;
// tags for grouping tests (e.g., ["agnostic"], ["fs"])
// special tags:
// - "agnostic": runs with opentoad only, excluded when filtering by agent
// - "agnostic": runs with opencode only, excluded when filtering by agent
// - "adhoc": excluded from default runs, must be explicitly requested
tags?: TestTag[];
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declarationMap": false,
"outDir": "./dist"
},
"include": ["index.ts", "internal/index.ts"]
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { resolveAgent } from "./agent.ts";
describe("resolveAgent", () => {
it("returns opentoad", () => {
it("returns opencode", () => {
const agent = resolveAgent({});
expect(agent.name).toBe("opentoad");
expect(agent.name).toBe("opencode");
});
});
+1 -1
View File
@@ -64,5 +64,5 @@ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
}
// 3. default: OpenCode (universal, supports all providers)
return agents.opentoad;
return agents.opencode;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateAgentApiKey } from "./apiKeys.ts";
const base = {
agent: { name: "opentoad" },
agent: { name: "opencode" },
owner: "test-owner",
name: "test-repo",
};
+2 -2
View File
@@ -13,7 +13,7 @@ export interface WorkflowRunFooterInfo {
}
export interface BuildPullfrogFooterParams {
/** add "Triggered by Pullfrog" link */
/** add "via Pullfrog" link */
triggeredBy?: boolean;
/** add "View workflow run" link */
workflowRun?: WorkflowRunFooterInfo | undefined;
@@ -52,7 +52,7 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
}
if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
parts.push("via [Pullfrog](https://pullfrog.com)");
}
if (params.model) {
+4 -1
View File
@@ -153,9 +153,10 @@ ${ctx.userQuoted}`;
const eventInstructions = ctx.payload.eventInstructions ?? "";
if (eventInstructions) {
const parts = [ctx.eventTitle, eventInstructions].filter(Boolean);
return `************* YOUR TASK *************
${eventInstructions}`;
${parts.join("\n\n")}`;
}
return "";
@@ -280,6 +281,8 @@ Never use \`sleep\` to wait for commands to complete. Commands run synchronously
When posting comments via ${pullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
When embedding images (e.g. uploaded screenshots) in comments or PR bodies, always use markdown image syntax: \`![description](url)\`. Never paste a naked URL — it will not render as an image.
### Progress reporting
**Task list**: at the start of every run, create an internal task list based on the steps in your current mode. Update it as you complete each step. The system automatically renders this list to the progress comment you do not need to call \`report_progress\` for this.
+62
View File
@@ -0,0 +1,62 @@
import type { ToolContext } from "../mcp/server.ts";
import { apiFetch } from "./apiFetch.ts";
import { log } from "./cli.ts";
import { retry } from "./retry.ts";
/** Keys accepted by PATCH /api/workflow-run/[runId] — keep in sync with `ALLOWED_FIELDS` in `app/api/workflow-run/[runId]/route.ts`. */
export type WorkflowRunArtifactPatchKey =
| "prNodeId"
| "issueNodeId"
| "reviewNodeId"
| "planCommentNodeId"
| "summaryCommentNodeId";
export type WorkflowRunArtifactPatch = Partial<Record<WorkflowRunArtifactPatchKey, string>>;
const ARTIFACT_PATCH_KEYS: WorkflowRunArtifactPatchKey[] = [
"prNodeId",
"issueNodeId",
"reviewNodeId",
"planCommentNodeId",
"summaryCommentNodeId",
];
/** PATCH workflow-run artifact fields (Pullfrog JWT, not GitHub). */
export async function patchWorkflowRunFields(
ctx: ToolContext,
fields: WorkflowRunArtifactPatch
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
const body: Record<string, string> = {};
for (const key of ARTIFACT_PATCH_KEYS) {
const value = fields[key];
if (typeof value === "string" && value.length > 0) {
body[key] = value;
}
}
if (Object.keys(body).length === 0) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: "patchWorkflowRunFields",
}
);
} catch (error) {
log.warning(`patchWorkflowRunFields exhausted retries: ${error}`);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
delete ctx.toolState.review;
// mark review as submitted — unlocks webhook dedup for new pushes
await bestEffort(() => reportReviewNodeId(ctx, review.nodeId), "reportReviewNodeId");
await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
// dispatch follow-up if PR HEAD moved past the reviewed commit
if (review.reviewedSha) {
+10 -4
View File
@@ -57,18 +57,24 @@ const defaultRunContext: RunContext = {
export async function fetchRunContext(params: {
token: string;
repoContext: RepoContext;
oidcToken?: string | undefined;
}): Promise<RunContext> {
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
};
if (params.oidcToken) {
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
}
const response = await apiFetch({
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
headers: {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
},
headers,
signal: controller.signal,
});
+9 -1
View File
@@ -1,3 +1,4 @@
import * as core from "@actions/core";
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
@@ -32,9 +33,16 @@ export async function resolveRunContextData(
const repoContext = parseRepoContext();
let oidcToken: string | undefined;
try {
oidcToken = await core.getIDToken("pullfrog-api");
} catch {
// OIDC not available (local dev, non-actions environment, fork PRs)
}
const [repoResponse, runContext] = await Promise.all([
params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }),
fetchRunContext({ token: params.token, repoContext }),
fetchRunContext({ token: params.token, repoContext, oidcToken }),
]);
return {
+1 -39
View File
@@ -1,10 +1,7 @@
/**
* Secret detection and redaction utilities
* Redacts actual secret values rather than using pattern matching
* Secret detection and env filtering utilities
*/
import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names
export const SENSITIVE_PATTERNS = [
/_KEY$/i,
@@ -47,38 +44,3 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
// custom env object - merge with restricted base
return { ...filterEnv(), ...mode };
}
function getAllSecrets(): string[] {
const secrets: string[] = [];
// collect all env var values matching SENSITIVE_PATTERNS
for (const [key, value] of Object.entries(process.env)) {
if (value && isSensitiveEnvName(key)) {
secrets.push(value);
}
}
// add GitHub installation token (stored in memory, not in env)
try {
const token = getGitHubInstallationToken();
if (token) {
secrets.push(token);
}
} catch {
// token not set yet, ignore
}
return secrets;
}
export function redactSecrets(content: string, secrets?: string[]): string {
const secretsToRedact = [...(secrets ?? []), ...getAllSecrets()];
let redacted = content;
for (const secret of secretsToRedact) {
if (secret) {
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
redacted = redacted.replaceAll(new RegExp(escaped, "g"), "[REDACTED_SECRET]");
}
}
return redacted;
}
+29 -5
View File
@@ -1,9 +1,19 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { tmpdir } from "node:os";
import { log } from "./cli.ts";
import { getDevDependencyVersion } from "./version.ts";
const skillsBin = resolve(import.meta.dirname, "../node_modules/.bin/skills");
const skillsVersion = getDevDependencyVersion("skills");
/**
* install a skill globally via the `skills` CLI.
*
* runs `npx skills add <ref> --skill <name> -g` with `cwd` set to os tmpdir
* so npm doesn't walk up and find a project-level `.npmrc` with pnpm-specific
* settings (e.g. `public-hoist-pattern`) that break npx binary resolution.
* the `-g` flag writes to `$HOME/.agents/skills/` which is controlled by
* `params.env.HOME` (the fake HOME), so cwd has no effect on install location.
*/
export function addSkill(params: {
ref: string;
skill: string;
@@ -11,9 +21,21 @@ export function addSkill(params: {
agent: string;
}): void {
const result = spawnSync(
skillsBin,
["add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
"npx",
[
"-y",
`skills@${skillsVersion}`,
"add",
params.ref,
"--skill",
params.skill,
"-g",
"-a",
params.agent,
"-y",
],
{
cwd: tmpdir(),
env: { ...process.env, ...params.env },
stdio: "pipe",
timeout: 30_000,
@@ -22,6 +44,8 @@ export function addSkill(params: {
if (result.status === 0) {
log.info(`installed ${params.skill} skill (${params.agent})`);
} else {
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`);
const stderr = (result.stderr?.toString() || "").trim();
const errorMsg = result.error ? result.error.message : stderr;
log.info(`${params.skill} skill install failed: ${errorMsg}`);
}
}