Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f6912deda | |||
| 7369e952e4 | |||
| fa01f9c06d | |||
| f65cb4d2e3 | |||
| e1b017f6e2 | |||
| 485c76457f | |||
| 995b39a122 | |||
| 26ced25a8f | |||
| 983ef8aba8 | |||
| 45cb7d05a1 | |||
| b64721edcf | |||
| 93d74a9bea | |||
| cb925556e8 | |||
| 410b11db71 | |||
| c3c0794504 | |||
| 69b9b96ddd | |||
| 101c666610 | |||
| 1f2f671be0 | |||
| 02a498e0cb | |||
| e4b086938e | |||
| 332ef73b87 | |||
| cd16ba67a6 | |||
| 9432a5b737 | |||
| 4c5cf444a2 | |||
| 26312055c5 | |||
| f34379415e | |||
| 9e019d89d2 | |||
| 5c60791b34 | |||
| 2d2d31adfa | |||
| 0ccaa68d3a | |||
| 4883a3eb7e | |||
| d022d02e71 | |||
| 97dce099c1 | |||
| 4547b0032e | |||
| 75b429ceca | |||
| 71feba0a76 | |||
| 6e2a15c195 | |||
| 1daf1571cf | |||
| 3539ddf943 | |||
| 3b880eb478 | |||
| 5e291edf05 | |||
| 0fa789c3e2 | |||
| 3fa309853b | |||
| 5604cf1868 | |||
| d8fb544f6b | |||
| e839fbeacd | |||
| b3e1cf6de3 | |||
| 900cf49871 | |||
| 0aa97f4fd0 | |||
| 672d8ccd00 | |||
| 280bb7ef15 | |||
| 84df6bbfb0 | |||
| 7e7733d0e3 | |||
| 6339eb43f8 | |||
| 8c24bc9c0b | |||
| bc970de683 | |||
| 7c0d8c3311 | |||
| 79344c653d | |||
| 0ca33995e5 | |||
| 20b4f683e5 | |||
| 03999f40ac | |||
| b539221a3d | |||
| 31833218ad | |||
| 7ca828637d | |||
| 2dc4f73d8b | |||
| 8596da9093 |
@@ -80,18 +80,18 @@ jobs:
|
|||||||
tag_name: ${{ steps.version.outputs.tag }}
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
release_name: "${{ steps.version.outputs.tag }}"
|
release_name: "${{ steps.version.outputs.tag }}"
|
||||||
body: |
|
body: |
|
||||||
## 📦 @pullfrog/action ${{ steps.version.outputs.version }}
|
## 📦 @pullfrog/pullfrog ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
### Usage in GitHub Actions
|
### Usage in GitHub Actions
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: pullfrog/action@${{ steps.version.outputs.major_tag }}
|
- uses: pullfrog/pullfrog@${{ steps.version.outputs.major_tag }}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installation via npm
|
### Installation via npm
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @pullfrog/action@${{ steps.version.outputs.version }}
|
npm install @pullfrog/pullfrog@${{ steps.version.outputs.version }}
|
||||||
```
|
```
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
@@ -118,5 +118,5 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
|
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
|
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- npm Registry: [@pullfrog/action@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@pullfrog/action/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
|
echo "- npm Registry: [@pullfrog/pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@pullfrog/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||||
name: Pullfrog
|
name: Pullfrog
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
prompt:
|
prompt:
|
||||||
type: string
|
type: string
|
||||||
description: 'Agent prompt'
|
description: Agent prompt
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
prompt:
|
prompt:
|
||||||
description: 'Agent prompt'
|
description: Agent prompt
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -19,26 +20,18 @@ jobs:
|
|||||||
pullfrog:
|
pullfrog:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
# optionally, setup your repo here
|
|
||||||
# the agent can figure this out itself, but pre-setup is more efficient
|
|
||||||
# - uses: actions/setup-node@v6
|
|
||||||
|
|
||||||
- name: Run agent
|
- name: Run agent
|
||||||
uses: pullfrog/action@main
|
uses: pullfrog/pullfrog@main
|
||||||
env:
|
|
||||||
log_level: ${{ vars.LOG_LEVEL }}
|
|
||||||
with:
|
with:
|
||||||
prompt: ${{ github.event.inputs.prompt }}
|
prompt: ${{ inputs.prompt }}
|
||||||
|
env:
|
||||||
# feel free to comment out any you won't use
|
# Feel free to comment out any you won't use
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
google_api_key: ${{ secrets.GOOGLE_API_KEY }}
|
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||||
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||||
cursor_api_key: ${{ secrets.CURSOR_API_KEY }}
|
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||||
|
|
||||||
|
|||||||
+28
-20
@@ -2,34 +2,42 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- run: pnpm install --frozen-lockfile
|
||||||
run: pnpm install --frozen-lockfile
|
- run: pnpm typecheck
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
- name: Run type check
|
agents:
|
||||||
run: pnpm typecheck
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
agent: [claude, codex, cursor, gemini, opencode]
|
||||||
|
test: [smoke, nobash]
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||||
|
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Run tests
|
- run: pnpm install --frozen-lockfile
|
||||||
run: pnpm test
|
- run: pnpm ${{ matrix.test }} ${{ matrix.agent }}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Trigger sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
# skip if pushed by our bot (breaks the loop)
|
||||||
|
if: github.actor != 'pullfrog[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get installation token
|
||||||
|
id: token
|
||||||
|
uses: ./get-installation-token
|
||||||
|
with:
|
||||||
|
repos: pullfrog
|
||||||
|
|
||||||
|
- name: Dispatch "action-repo-updated" event
|
||||||
|
run: |
|
||||||
|
gh api repos/pullfrog/app/dispatches \
|
||||||
|
-f event_type="action-repo-updated" \
|
||||||
|
-f client_payload='{
|
||||||
|
"before": "${{ github.event.before }}",
|
||||||
|
"after": "${{ github.event.after }}",
|
||||||
|
"compare_url": "${{ github.event.compare }}",
|
||||||
|
"pusher": "${{ github.actor }}"
|
||||||
|
}'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||||
@@ -47,3 +47,4 @@ examples
|
|||||||
dist
|
dist
|
||||||
|
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/frog-white-200px.png">
|
<source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/frog-white-200px.png">
|
||||||
<img src="https://pullfrog.com/frog-green-200px.png" width="25px" align="center" alt="Pullfrog logo" />
|
<img src="https://pullfrog.com/frog-green-200px.png" width="25px" align="center" alt="Green Pullfrog logo" />
|
||||||
</picture><br />
|
</picture><br />
|
||||||
Pullfrog
|
Pullfrog
|
||||||
</h1>
|
</h1>
|
||||||
@@ -19,16 +19,7 @@
|
|||||||
|
|
||||||
## What is Pullfrog?
|
## What is Pullfrog?
|
||||||
|
|
||||||
Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions.
|
Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions.
|
||||||
|
|
||||||
<!--
|
|
||||||
<a href="https://github.com/apps/pullfrog/installations/new">
|
|
||||||
<img src="https://pullfrog.com/add-to-github.png" alt="Add to GitHub" width="150px" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Once added, you can start triggering agent runs. -->
|
|
||||||
|
|
||||||
- **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task.
|
- **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task.
|
||||||
- **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard
|
- **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard
|
||||||
@@ -40,7 +31,7 @@ Once added, you can start triggering agent runs. -->
|
|||||||
- PR review requested
|
- PR review requested
|
||||||
- and more...
|
- and more...
|
||||||
|
|
||||||
Pullfrog is the bridge between GitHub and your preferred coding agents and GitHub. Use it for:
|
Pullfrog is the bridge between your preferred coding agents and GitHub. Use it for:
|
||||||
|
|
||||||
- **🤖 Coding tasks** — Tell `@pullfrog` to implement something and it'll spin up a PR. If CI fails, it'll read the logs and attempt a fix automatically. It'll automatically address any PR reviews too.
|
- **🤖 Coding tasks** — Tell `@pullfrog` to implement something and it'll spin up a PR. If CI fails, it'll read the logs and attempt a fix automatically. It'll automatically address any PR reviews too.
|
||||||
- **🔍 PR review** — Coding agents are great at reviewing PRs. Using the "PR created" trigger, you can configure Pullfrog to auto-review new PRs.
|
- **🔍 PR review** — Coding agents are great at reviewing PRs. Using the "PR created" trigger, you can configure Pullfrog to auto-review new PRs.
|
||||||
@@ -61,7 +52,7 @@ Install the Pullfrog GitHub App on your personal or organization account. During
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>Manual setup instructions</strong></summary>
|
<summary><strong>Manual setup instructions</strong></summary>
|
||||||
|
|
||||||
You can also use the `pullfrog/action` Action without a GitHub App installation. This is more time-consuming to set up, and it places limitations on the actions your Agent will be capable of performing.
|
You can also use the `pullfrog/pullfrog` Action without a GitHub App installation. This is more time-consuming to set up, and it places limitations on the actions your Agent will be capable of performing.
|
||||||
|
|
||||||
To manually set up the Pullfrog action, you need to set up two workflow files in your repository: `pullfrog.yml` (the execution logic) and `triggers.yml` (the event triggers).
|
To manually set up the Pullfrog action, you need to set up two workflow files in your repository: `pullfrog.yml` (the execution logic) and `triggers.yml` (the event triggers).
|
||||||
|
|
||||||
@@ -103,7 +94,7 @@ jobs:
|
|||||||
# - uses: actions/setup-node@v6
|
# - uses: actions/setup-node@v6
|
||||||
|
|
||||||
- name: Run agent
|
- name: Run agent
|
||||||
uses: pullfrog/action@v0
|
uses: pullfrog/pullfrog@v0
|
||||||
with:
|
with:
|
||||||
prompt: ${{ inputs.prompt }}
|
prompt: ${{ inputs.prompt }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
+17
-3
@@ -4,15 +4,29 @@ author: "Pullfrog"
|
|||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
prompt:
|
prompt:
|
||||||
description: "Prompt to send to the agent"
|
description: "Prompt to send to the agent (string or JSON payload)"
|
||||||
required: true
|
required: true
|
||||||
effort:
|
effort:
|
||||||
description: "Effort level: nothink (fast), think (default), max (most capable)"
|
description: "Effort level: mini (fast), auto (default), max (most capable)"
|
||||||
|
required: false
|
||||||
|
agent:
|
||||||
|
description: "Agent to use: claude, codex, gemini, cursor, opencode"
|
||||||
required: false
|
required: false
|
||||||
default: "think"
|
|
||||||
cwd:
|
cwd:
|
||||||
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
|
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
|
||||||
required: false
|
required: false
|
||||||
|
web:
|
||||||
|
description: "Web fetch permission: disabled or enabled (default: enabled)"
|
||||||
|
required: false
|
||||||
|
search:
|
||||||
|
description: "Web search permission: disabled or enabled (default: enabled)"
|
||||||
|
required: false
|
||||||
|
write:
|
||||||
|
description: "File write permission: disabled or enabled (default: enabled)"
|
||||||
|
required: false
|
||||||
|
bash:
|
||||||
|
description: "Bash permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled."
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "node24"
|
using: "node24"
|
||||||
|
|||||||
+48
-49
@@ -1,15 +1,16 @@
|
|||||||
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { Effort } from "../external.ts";
|
import type { Effort } from "../external.ts";
|
||||||
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import packageJson from "../package.json" with { type: "json" };
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { installFromNpmTarball } from "../utils/install.ts";
|
||||||
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
import { type AgentRunContext, agent } from "./shared.ts";
|
||||||
|
|
||||||
// Model selection based on effort level
|
// Model selection based on effort level
|
||||||
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
|
// Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability
|
||||||
const claudeEffortModels: Record<Effort, string> = {
|
const claudeEffortModels: Record<Effort, string> = {
|
||||||
nothink: "haiku",
|
mini: "haiku",
|
||||||
think: "opusplan",
|
auto: "opusplan",
|
||||||
max: "opus",
|
max: "opus",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,62 +20,60 @@ const claudeEffortModels: Record<Effort, string> = {
|
|||||||
// See: https://platform.claude.com/docs/en/build-with-claude/effort
|
// See: https://platform.claude.com/docs/en/build-with-claude/effort
|
||||||
// This approach could replace model selection if effort proves effective for controlling capability.
|
// This approach could replace model selection if effort proves effective for controlling capability.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build disallowedTools list from payload permissions.
|
||||||
|
*/
|
||||||
|
function buildDisallowedTools(ctx: AgentRunContext): string[] {
|
||||||
|
const disallowed: string[] = [];
|
||||||
|
if (ctx.payload.web === "disabled") disallowed.push("WebFetch");
|
||||||
|
if (ctx.payload.search === "disabled") disallowed.push("WebSearch");
|
||||||
|
if (ctx.payload.write === "disabled") disallowed.push("Write");
|
||||||
|
// both "disabled" and "restricted" block native bash
|
||||||
|
// "restricted" means use MCP bash tool instead
|
||||||
|
const bash = ctx.payload.bash;
|
||||||
|
if (bash !== "enabled") disallowed.push("Bash");
|
||||||
|
return disallowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installClaude(): Promise<string> {
|
||||||
|
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
||||||
|
return await installFromNpmTarball({
|
||||||
|
packageName: "@anthropic-ai/claude-agent-sdk",
|
||||||
|
version: versionRange,
|
||||||
|
executablePath: "cli.js",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const claude = agent({
|
export const claude = agent({
|
||||||
name: "claude",
|
name: "claude",
|
||||||
install: async () => {
|
install: installClaude,
|
||||||
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
run: async (ctx) => {
|
||||||
return await installFromNpmTarball({
|
// install CLI at start of run
|
||||||
packageName: "@anthropic-ai/claude-agent-sdk",
|
const cliPath = await installClaude();
|
||||||
version: versionRange,
|
|
||||||
executablePath: "cli.js",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
|
||||||
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
|
||||||
|
|
||||||
// select model based on effort level
|
// select model based on effort level
|
||||||
const model = claudeEffortModels[effort];
|
const model = claudeEffortModels[ctx.payload.effort];
|
||||||
log.info(`Using model: ${model} (effort: ${effort})`);
|
log.info(`» using model: ${model} (effort: ${ctx.payload.effort})`);
|
||||||
|
|
||||||
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
|
// build disallowedTools based on tool permissions
|
||||||
// disable native Bash; agents use MCP bash tool which filters secrets.
|
const disallowedTools = buildDisallowedTools(ctx);
|
||||||
// for private repos, native Bash is allowed since secrets are less exposed.
|
if (disallowedTools.length > 0) {
|
||||||
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
log.info(`» disallowed tools: ${disallowedTools.join(", ")}`);
|
||||||
const sandboxOptions: Options = payload.sandbox
|
|
||||||
? {
|
|
||||||
permissionMode: "default",
|
|
||||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
|
||||||
async canUseTool(toolName, input, _options) {
|
|
||||||
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
|
||||||
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
|
||||||
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
permissionMode: "bypassPermissions" as const,
|
|
||||||
disallowedTools,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (payload.sandbox) {
|
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass secrets via SDK's env option only (not process.env)
|
|
||||||
// This ensures secrets are only available to Claude Code subprocess, not user code
|
|
||||||
const queryOptions: Options = {
|
const queryOptions: Options = {
|
||||||
...sandboxOptions,
|
permissionMode: "bypassPermissions" as const,
|
||||||
mcpServers,
|
disallowedTools,
|
||||||
|
mcpServers: {
|
||||||
|
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
|
||||||
|
},
|
||||||
model,
|
model,
|
||||||
pathToClaudeCodeExecutable: cliPath,
|
pathToClaudeCodeExecutable: cliPath,
|
||||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
env: process.env,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryInstance = query({
|
const queryInstance = query({
|
||||||
prompt,
|
prompt: ctx.instructions.full,
|
||||||
options: queryOptions,
|
options: queryOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@ const messageHandlers: SDKMessageHandlers = {
|
|||||||
const outputTokens = usage?.output_tokens || 0;
|
const outputTokens = usage?.output_tokens || 0;
|
||||||
const totalInput = inputTokens + cacheRead + cacheWrite;
|
const totalInput = inputTokens + cacheRead + cacheWrite;
|
||||||
|
|
||||||
await log.summaryTable([
|
log.table([
|
||||||
[
|
[
|
||||||
{ data: "Cost", header: true },
|
{ data: "Cost", header: true },
|
||||||
{ data: "Input", header: true },
|
{ data: "Input", header: true },
|
||||||
|
|||||||
+67
-82
@@ -1,6 +1,5 @@
|
|||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import {
|
import {
|
||||||
Codex,
|
Codex,
|
||||||
type CodexOptions,
|
type CodexOptions,
|
||||||
@@ -9,142 +8,128 @@ import {
|
|||||||
type ThreadOptions,
|
type ThreadOptions,
|
||||||
} from "@openai/codex-sdk";
|
} from "@openai/codex-sdk";
|
||||||
import type { Effort } from "../external.ts";
|
import type { Effort } from "../external.ts";
|
||||||
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { installFromNpmTarball } from "../utils/install.ts";
|
||||||
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
|
import { type AgentRunContext, agent } from "./shared.ts";
|
||||||
|
|
||||||
// model configuration based on effort level
|
// model configuration based on effort level
|
||||||
const codexModel: Record<Effort, string> = {
|
const codexModel: Record<Effort, string> = {
|
||||||
nothink: "gpt-5.1-codex-mini",
|
mini: "gpt-5.1-codex-mini",
|
||||||
think: "gpt-5.1-codex",
|
// https://developers.openai.com/codex/models/
|
||||||
|
// gpt-5.2-codex is not yet available via api key (even through codex cli)
|
||||||
|
auto: "gpt-5.1-codex",
|
||||||
max: "gpt-5.1-codex-max",
|
max: "gpt-5.1-codex-max",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// reasoning effort configuration based on effort level
|
// reasoning effort configuration based on effort level
|
||||||
// uses modelReasoningEffort parameter from ThreadOptions
|
// uses modelReasoningEffort parameter from ThreadOptions
|
||||||
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||||
nothink: "low",
|
mini: "low",
|
||||||
think: undefined, // use default
|
auto: undefined, // use default
|
||||||
max: "high",
|
max: "high",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WriteCodexConfigParams {
|
function writeCodexConfig(ctx: AgentRunContext): string {
|
||||||
tempHome: string;
|
const codexDir = join(ctx.tmpdir, ".codex");
|
||||||
mcpServers: Record<string, McpHttpServerConfig>;
|
|
||||||
isPublicRepo: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string {
|
|
||||||
const codexDir = join(tempHome, ".codex");
|
|
||||||
mkdirSync(codexDir, { recursive: true });
|
mkdirSync(codexDir, { recursive: true });
|
||||||
const configPath = join(codexDir, "config.toml");
|
const configPath = join(codexDir, "config.toml");
|
||||||
|
|
||||||
// build MCP servers section
|
// build MCP servers section
|
||||||
const mcpServerSections: string[] = [];
|
log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
|
||||||
for (const [name, config] of Object.entries(mcpServers)) {
|
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]\nurl = "${ctx.mcpServerUrl}"`];
|
||||||
if (config.type !== "http") continue;
|
|
||||||
log.info(`» Adding MCP server '${name}' at ${config.url}`);
|
|
||||||
mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: for public repos, enforce env filtering via shell_environment_policy
|
// build features section for tool control
|
||||||
// this prevents vuln if user's ~/.codex/config.toml has ignore_default_excludes=true
|
// disable native shell if bash is "disabled" or "restricted"
|
||||||
// for private repos, no filtering - agents use native shell with full env access
|
// when "restricted", agent uses MCP bash tool which filters secrets
|
||||||
const shellPolicy = isPublicRepo
|
const bash = ctx.payload.bash;
|
||||||
? `[shell_environment_policy]
|
const features: string[] = [];
|
||||||
ignore_default_excludes = false`
|
if (bash !== "enabled") {
|
||||||
: "";
|
features.push("shell_command_tool = false");
|
||||||
|
features.push("unified_exec = false");
|
||||||
|
}
|
||||||
|
const featuresSection = features.length > 0 ? `[features]\n${features.join("\n")}` : "";
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
configPath,
|
configPath,
|
||||||
`# written by pullfrog
|
`# written by pullfrog
|
||||||
${shellPolicy}
|
${featuresSection}
|
||||||
|
|
||||||
${mcpServerSections.join("\n\n")}
|
${mcpServerSections.join("\n\n")}
|
||||||
`.trim() + "\n"
|
`.trim() + "\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPublicRepo) {
|
log.info(`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`);
|
||||||
log.info(`» Codex config written to ${configPath} (env filtering: enabled)`);
|
|
||||||
} else {
|
|
||||||
log.info(`» Codex config written to ${configPath} (private repo: no env filtering)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return codexDir;
|
return codexDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installCodex(): Promise<string> {
|
||||||
|
return await installFromNpmTarball({
|
||||||
|
packageName: "@openai/codex",
|
||||||
|
version: "latest",
|
||||||
|
executablePath: "bin/codex.js",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const codex = agent({
|
export const codex = agent({
|
||||||
name: "codex",
|
name: "codex",
|
||||||
install: async () => {
|
install: installCodex,
|
||||||
return await installFromNpmTarball({
|
run: async (ctx) => {
|
||||||
packageName: "@openai/codex",
|
// install CLI at start of run
|
||||||
version: "latest",
|
const cliPath = await installCodex();
|
||||||
executablePath: "bin/codex.js",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
||||||
|
|
||||||
// create config directory for codex before setting HOME
|
// create config directory for codex before setting HOME
|
||||||
const configDir = join(tempHome, ".config", "codex");
|
const configDir = join(ctx.tmpdir, ".config", "codex");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
const codexDir = writeCodexConfig({
|
const codexDir = writeCodexConfig(ctx);
|
||||||
tempHome,
|
|
||||||
mcpServers,
|
|
||||||
isPublicRepo: repo.isPublic,
|
|
||||||
});
|
|
||||||
|
|
||||||
setupProcessAgentEnv({
|
process.env.HOME = ctx.tmpdir;
|
||||||
OPENAI_API_KEY: apiKey,
|
process.env.CODEX_HOME = codexDir;
|
||||||
HOME: tempHome,
|
|
||||||
CODEX_HOME: codexDir, // point Codex to our config directory
|
|
||||||
});
|
|
||||||
|
|
||||||
// get model and reasoning effort based on effort level
|
// get model and reasoning effort based on effort level
|
||||||
const model = codexModel[effort];
|
const model = codexModel[ctx.payload.effort];
|
||||||
const modelReasoningEffort = codexReasoningEffort[effort];
|
const modelReasoningEffort = codexReasoningEffort[ctx.payload.effort];
|
||||||
log.info(`Using model: ${model}`);
|
log.info(`» using model: ${model}`);
|
||||||
if (modelReasoningEffort) {
|
if (modelReasoningEffort) {
|
||||||
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
|
log.info(`» using modelReasoningEffort: ${modelReasoningEffort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Codex
|
// Configure Codex
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("OPENAI_API_KEY is required for codex agent");
|
||||||
|
}
|
||||||
|
|
||||||
const codexOptions: CodexOptions = {
|
const codexOptions: CodexOptions = {
|
||||||
apiKey,
|
apiKey,
|
||||||
codexPathOverride: cliPath,
|
codexPathOverride: cliPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (payload.sandbox) {
|
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
||||||
}
|
|
||||||
|
|
||||||
const codex = new Codex(codexOptions);
|
const codex = new Codex(codexOptions);
|
||||||
|
|
||||||
// Build thread options with model and optional model_reasoning_effort
|
// build thread options based on tool permissions
|
||||||
const baseThreadOptions = payload.sandbox
|
const threadOptions: ThreadOptions = {
|
||||||
? {
|
model,
|
||||||
model,
|
approvalPolicy: "never" as const,
|
||||||
approvalPolicy: "never" as const,
|
// write: "disabled" → read-only sandbox, otherwise full access for git ops
|
||||||
sandboxMode: "read-only" as const,
|
sandboxMode: ctx.payload.write === "disabled" ? "read-only" : "danger-full-access",
|
||||||
networkAccessEnabled: false,
|
// web: controls network access
|
||||||
}
|
networkAccessEnabled: ctx.payload.web !== "disabled",
|
||||||
: {
|
// search: controls web search
|
||||||
model,
|
webSearchEnabled: ctx.payload.search !== "disabled",
|
||||||
approvalPolicy: "never" as const,
|
...(modelReasoningEffort && { modelReasoningEffort }),
|
||||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
};
|
||||||
sandboxMode: "danger-full-access" as const,
|
|
||||||
networkAccessEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const threadOptions: ThreadOptions = modelReasoningEffort
|
log.info(
|
||||||
? { ...baseThreadOptions, modelReasoningEffort }
|
`» Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}`
|
||||||
: baseThreadOptions;
|
);
|
||||||
|
|
||||||
const thread = codex.startThread(threadOptions);
|
const thread = codex.startThread(threadOptions);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
const streamedTurn = await thread.runStreamed(ctx.instructions.full);
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
for await (const event of streamedTurn.events) {
|
for await (const event of streamedTurn.events) {
|
||||||
@@ -192,7 +177,7 @@ const messageHandlers: {
|
|||||||
// No logging needed
|
// No logging needed
|
||||||
},
|
},
|
||||||
"turn.completed": async (event) => {
|
"turn.completed": async (event) => {
|
||||||
await log.summaryTable([
|
log.table([
|
||||||
[
|
[
|
||||||
{ data: "Input Tokens", header: true },
|
{ data: "Input Tokens", header: true },
|
||||||
{ data: "Cached Input Tokens", header: true },
|
{ data: "Cached Input Tokens", header: true },
|
||||||
|
|||||||
+80
-92
@@ -3,20 +3,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Effort } from "../external.ts";
|
import type { Effort } from "../external.ts";
|
||||||
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { installFromCurl } from "../utils/install.ts";
|
||||||
import {
|
import { type AgentRunContext, agent } from "./shared.ts";
|
||||||
agent,
|
|
||||||
type ConfigureMcpServersParams,
|
|
||||||
createAgentEnv,
|
|
||||||
installFromCurl,
|
|
||||||
} from "./shared.ts";
|
|
||||||
|
|
||||||
// effort configuration for Cursor
|
// effort configuration for Cursor
|
||||||
// only "max" overrides the model; nothink/think use default ("auto")
|
// only "max" overrides the model; mini/auto use default ("auto")
|
||||||
const cursorEffortModels: Record<Effort, string | null> = {
|
const cursorEffortModels: Record<Effort, string | null> = {
|
||||||
nothink: null, // use default (auto)
|
mini: null, // use default (auto)
|
||||||
think: null, // use default (auto)
|
auto: null, // use default (auto)
|
||||||
max: "opus-4.5-thinking",
|
max: "opus-4.5-thinking",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -92,17 +88,22 @@ type CursorEvent =
|
|||||||
| CursorToolCallEvent
|
| CursorToolCallEvent
|
||||||
| CursorResultEvent;
|
| CursorResultEvent;
|
||||||
|
|
||||||
|
async function installCursor(): Promise<string> {
|
||||||
|
return await installFromCurl({
|
||||||
|
installUrl: "https://cursor.com/install",
|
||||||
|
executableName: "cursor-agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const cursor = agent({
|
export const cursor = agent({
|
||||||
name: "cursor",
|
name: "cursor",
|
||||||
install: async () => {
|
install: installCursor,
|
||||||
return await installFromCurl({
|
run: async (ctx) => {
|
||||||
installUrl: "https://cursor.com/install",
|
// install CLI at start of run
|
||||||
executableName: "cursor-agent",
|
const cliPath = await installCursor();
|
||||||
});
|
|
||||||
},
|
configureCursorMcpServers(ctx);
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
|
configureCursorTools(ctx);
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
|
||||||
|
|
||||||
// determine model based on effort level
|
// determine model based on effort level
|
||||||
// respect project's .cursor/cli.json if it specifies a model
|
// respect project's .cursor/cli.json if it specifies a model
|
||||||
@@ -113,21 +114,21 @@ export const cursor = agent({
|
|||||||
try {
|
try {
|
||||||
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
||||||
if (projectConfig.model) {
|
if (projectConfig.model) {
|
||||||
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`);
|
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||||
} else {
|
} else {
|
||||||
modelOverride = cursorEffortModels[effort];
|
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
modelOverride = cursorEffortModels[effort];
|
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
modelOverride = cursorEffortModels[effort];
|
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
log.info(`Using model: ${modelOverride} (effort: ${effort})`);
|
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`);
|
||||||
} else if (!existsSync(projectCliConfigPath)) {
|
} else if (!existsSync(projectCliConfigPath)) {
|
||||||
log.info(`Using default model (effort: ${effort})`);
|
log.info(`» using default model, effort=${ctx.payload.effort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// track logged model_call_ids to avoid duplicates
|
// track logged model_call_ids to avoid duplicates
|
||||||
@@ -201,36 +202,31 @@ export const cursor = agent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPrompt = addInstructions({ payload, repo });
|
// build CLI args
|
||||||
log.group("Full prompt", () => log.info(fullPrompt));
|
const baseArgs = [
|
||||||
|
"--print",
|
||||||
// configure sandbox mode if enabled
|
ctx.instructions.full,
|
||||||
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
|
"--output-format",
|
||||||
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
|
"stream-json",
|
||||||
|
"--approve-mcps",
|
||||||
|
];
|
||||||
|
|
||||||
// add model flag if we have an override
|
// add model flag if we have an override
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
baseArgs.push("--model", modelOverride);
|
baseArgs.push("--model", modelOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorArgs = payload.sandbox
|
// always use --force since permissions are controlled via cli-config.json
|
||||||
? baseArgs // --force removed in sandbox mode to enforce safety checks
|
const cursorArgs = [...baseArgs, "--force"];
|
||||||
: [...baseArgs, "--force"];
|
|
||||||
|
|
||||||
if (payload.sandbox) {
|
log.info("» running Cursor CLI...");
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Running Cursor CLI...");
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(cliPath, cursorArgs, {
|
const child = spawn(cliPath, cursorArgs, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: createAgentEnv({
|
env: process.env,
|
||||||
CURSOR_API_KEY: apiKey,
|
|
||||||
}),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
|
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,73 +318,65 @@ export const cursor = agent({
|
|||||||
// There was an issue on macOS when you set HOME to a temp directory
|
// There was an issue on macOS when you set HOME to a temp directory
|
||||||
// it was unable to find the macOS keychain and would fail
|
// it was unable to find the macOS keychain and would fail
|
||||||
// temp solution is to stick with the actual $HOME
|
// temp solution is to stick with the actual $HOME
|
||||||
function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
function configureCursorMcpServers(ctx: AgentRunContext): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const cursorConfigDir = join(realHome, ".cursor");
|
const cursorConfigDir = join(realHome, ".cursor");
|
||||||
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
|
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
|
||||||
mkdirSync(cursorConfigDir, { recursive: true });
|
mkdirSync(cursorConfigDir, { recursive: true });
|
||||||
|
|
||||||
// Convert to Cursor's expected format (HTTP config)
|
const mcpServers = {
|
||||||
const cursorMcpServers: Record<string, { type: string; url: string }> = {};
|
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
};
|
||||||
if (serverConfig.type !== "http") {
|
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
|
||||||
throw new Error(
|
|
||||||
`Unsupported MCP server type for Cursor: ${(serverConfig as any).type || "unknown"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
cursorMcpServers[serverName] = {
|
|
||||||
type: "http",
|
|
||||||
url: serverConfig.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
|
|
||||||
log.info(`» MCP config written to ${mcpConfigPath}`);
|
log.info(`» MCP config written to ${mcpConfigPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CursorCliConfig {
|
||||||
|
permissions: {
|
||||||
|
allow: string[];
|
||||||
|
deny: string[];
|
||||||
|
};
|
||||||
|
sandbox?: {
|
||||||
|
mode: "enabled" | "disabled";
|
||||||
|
networkAccess?: "allowlist" | "full";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
* Configure Cursor CLI tool permissions via cli-config.json.
|
||||||
*
|
*
|
||||||
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
|
* Config path: $HOME/.config/cursor/ (not ~/.cursor/).
|
||||||
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
|
||||||
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
|
||||||
* For private repos, native Shell is allowed.
|
|
||||||
*
|
|
||||||
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
|
||||||
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
|
||||||
*/
|
*/
|
||||||
function configureCursorSandbox({
|
function configureCursorTools(ctx: AgentRunContext): void {
|
||||||
sandbox,
|
|
||||||
isPublicRepo,
|
|
||||||
}: {
|
|
||||||
sandbox: boolean;
|
|
||||||
isPublicRepo: boolean;
|
|
||||||
}): void {
|
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const cursorConfigDir = join(realHome, ".config", "cursor");
|
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||||
mkdirSync(cursorConfigDir, { recursive: true });
|
mkdirSync(cursorConfigDir, { recursive: true });
|
||||||
|
|
||||||
// deny native shell for public repos to prevent secret leakage
|
// build deny list based on tool permissions
|
||||||
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
const bash = ctx.payload.bash;
|
||||||
|
const deny: string[] = [];
|
||||||
|
if (ctx.payload.search === "disabled") deny.push("WebSearch");
|
||||||
|
if (ctx.payload.write === "disabled") deny.push("Write(**)");
|
||||||
|
// both "disabled" and "restricted" block native shell
|
||||||
|
if (bash !== "enabled") deny.push("Shell(*)");
|
||||||
|
|
||||||
const config = sandbox
|
const config: CursorCliConfig = {
|
||||||
? {
|
permissions: {
|
||||||
permissions: {
|
allow: ctx.payload.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"],
|
||||||
allow: ["Read(**)"],
|
deny,
|
||||||
deny: ["Write(**)", ...denyShell],
|
},
|
||||||
},
|
};
|
||||||
}
|
|
||||||
: {
|
// web: "disabled" requires sandbox with network blocking
|
||||||
permissions: {
|
// sandbox.networkAccess: "allowlist" blocks network in shell subprocesses via seatbelt
|
||||||
allow: ["Read(**)", "Write(**)"],
|
if (ctx.payload.web === "disabled") {
|
||||||
deny: denyShell,
|
config.sandbox = {
|
||||||
},
|
mode: "enabled",
|
||||||
};
|
networkAccess: "allowlist",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||||
log.info(
|
log.info(`» CLI config written to ${cliConfigPath}`, JSON.stringify(config, null, 2));
|
||||||
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-92
@@ -2,23 +2,25 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Effort } from "../external.ts";
|
import type { Effort } from "../external.ts";
|
||||||
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
|
import { installFromGithub } from "../utils/install.ts";
|
||||||
import { spawn } from "../utils/subprocess.ts";
|
import { spawn } from "../utils/subprocess.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { getGitHubInstallationToken } from "../utils/token.ts";
|
||||||
import {
|
import { type AgentRunContext, agent } from "./shared.ts";
|
||||||
agent,
|
|
||||||
type ConfigureMcpServersParams,
|
|
||||||
createAgentEnv,
|
|
||||||
installFromGithub,
|
|
||||||
} from "./shared.ts";
|
|
||||||
|
|
||||||
// effort configuration: model + thinking level
|
// effort configuration: model + thinking level
|
||||||
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
||||||
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
|
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
|
||||||
|
// latest models:
|
||||||
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
|
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
|
||||||
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
|
// https://ai.google.dev/gemini-api/docs/models
|
||||||
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
|
// the docs mention needing to enable preview features for these models but if you
|
||||||
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
|
// pass the model directly it works if we ever did need to do something like this,
|
||||||
|
// we could write to .gemini/settings.json
|
||||||
|
mini: { model: "gemini-3-flash-preview", thinkingLevel: "LOW" },
|
||||||
|
auto: { model: "gemini-3-flash-preview", thinkingLevel: "HIGH" },
|
||||||
|
max: { model: "gemini-3-pro-preview", thinkingLevel: "HIGH" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// gemini cli event types inferred from stream-json output (NDJSON format)
|
// gemini cli event types inferred from stream-json output (NDJSON format)
|
||||||
@@ -149,65 +151,45 @@ const messageHandlers = {
|
|||||||
String(stats.duration_ms || 0),
|
String(stats.duration_ms || 0),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
await log.summaryTable(rows);
|
log.table(rows);
|
||||||
} else if (event.status === "error") {
|
} else if (event.status === "error") {
|
||||||
log.error(`Gemini CLI failed: ${JSON.stringify(event)}`);
|
log.error(`Gemini CLI failed: ${JSON.stringify(event)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function installGemini(githubInstallationToken?: string): Promise<string> {
|
||||||
|
return await installFromGithub({
|
||||||
|
owner: "google-gemini",
|
||||||
|
repo: "gemini-cli",
|
||||||
|
assetName: "gemini.js",
|
||||||
|
...(githubInstallationToken && { githubInstallationToken }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const gemini = agent({
|
export const gemini = agent({
|
||||||
name: "gemini",
|
name: "gemini",
|
||||||
install: async (githubInstallationToken?: string) => {
|
install: installGemini,
|
||||||
return await installFromGithub({
|
run: async (ctx) => {
|
||||||
owner: "google-gemini",
|
// install CLI at start of run - use token for GitHub API rate limiting
|
||||||
repo: "gemini-cli",
|
const cliPath = await installGemini(getGitHubInstallationToken());
|
||||||
assetName: "gemini.js",
|
|
||||||
...(githubInstallationToken && { githubInstallationToken }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
|
|
||||||
// get model and thinking level based on effort
|
|
||||||
const { model, thinkingLevel } = geminiEffortConfig[effort];
|
|
||||||
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
|
||||||
|
|
||||||
configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel });
|
const model = configureGeminiSettings(ctx);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!process.env.GOOGLE_API_KEY && !process.env.GEMINI_API_KEY) {
|
||||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required for gemini agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPrompt = addInstructions({ payload, repo });
|
// build CLI args - --yolo for auto-approval
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
// tool restrictions handled via settings.json tools.exclude
|
||||||
|
const args = [
|
||||||
// build CLI args based on sandbox mode
|
"--model",
|
||||||
// for public repos, native shell is disabled via excludeTools in settings.json
|
model,
|
||||||
let args: string[];
|
"--yolo",
|
||||||
if (payload.sandbox) {
|
"--output-format=stream-json",
|
||||||
// sandbox mode: read-only tools only
|
"-p",
|
||||||
args = [
|
ctx.instructions.full,
|
||||||
"--model",
|
];
|
||||||
model,
|
|
||||||
"--allowed-tools",
|
|
||||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
|
||||||
"--allowed-mcp-server-names",
|
|
||||||
"gh_pullfrog",
|
|
||||||
"--output-format=stream-json",
|
|
||||||
"-p",
|
|
||||||
sessionPrompt,
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// normal mode: --yolo for auto-approval
|
|
||||||
// for public repos, shell is excluded via settings.json excludeTools
|
|
||||||
args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
|
||||||
if (repo.isPublic) {
|
|
||||||
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.sandbox) {
|
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
let stdoutBuffer = "";
|
let stdoutBuffer = "";
|
||||||
@@ -216,7 +198,7 @@ export const gemini = agent({
|
|||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: "node",
|
cmd: "node",
|
||||||
args: [cliPath, ...args],
|
args: [cliPath, ...args],
|
||||||
env: createAgentEnv({ GEMINI_API_KEY: apiKey }),
|
env: process.env,
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
finalOutput += text;
|
finalOutput += text;
|
||||||
@@ -271,7 +253,7 @@ export const gemini = agent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
|
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
|
||||||
log.info("✓ Gemini CLI completed successfully");
|
log.info("» Gemini CLI completed successfully");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -289,25 +271,16 @@ export const gemini = agent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type ConfigureGeminiParams = {
|
|
||||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
|
||||||
isPublicRepo: boolean;
|
|
||||||
thinkingLevel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Gemini CLI settings by writing to settings.json.
|
* Configure Gemini CLI settings by writing to settings.json.
|
||||||
* - MCP servers: uses `httpUrl` for HTTP/streamable transport
|
* Returns the model to use for CLI args.
|
||||||
* - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig
|
|
||||||
* - For public repos, excludeTools disables native shell
|
|
||||||
*
|
*
|
||||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||||
*/
|
*/
|
||||||
function configureGeminiSettings({
|
function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||||
mcpServers,
|
const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort];
|
||||||
isPublicRepo,
|
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||||
thinkingLevel,
|
|
||||||
}: ConfigureGeminiParams): void {
|
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const geminiConfigDir = join(realHome, ".gemini");
|
const geminiConfigDir = join(realHome, ".gemini");
|
||||||
const settingsPath = join(geminiConfigDir, "settings.json");
|
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||||
@@ -323,7 +296,7 @@ function configureGeminiSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// convert to Gemini's expected format (httpUrl for HTTP transport, no type field)
|
// convert to Gemini's expected format (httpUrl for HTTP transport, no type field)
|
||||||
type GeminiMcpServerConfig = {
|
interface GeminiMcpServerConfig {
|
||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
@@ -336,20 +309,22 @@ function configureGeminiSettings({
|
|||||||
description?: string;
|
description?: string;
|
||||||
includeTools?: string[];
|
includeTools?: string[];
|
||||||
excludeTools?: string[];
|
excludeTools?: string[];
|
||||||
};
|
|
||||||
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
|
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
||||||
if (serverConfig.type !== "http") {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported MCP server type for Gemini: ${(serverConfig as { type?: string }).type || "unknown"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
geminiMcpServers[serverName] = {
|
|
||||||
httpUrl: serverConfig.url,
|
|
||||||
trust: true, // trust our own MCP server to avoid confirmation prompts
|
|
||||||
};
|
|
||||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
|
||||||
}
|
}
|
||||||
|
log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}...`);
|
||||||
|
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {
|
||||||
|
[ghPullfrogMcpName]: {
|
||||||
|
httpUrl: ctx.mcpServerUrl,
|
||||||
|
trust: true, // trust our own MCP server to avoid confirmation prompts
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// build tools.exclude based on permissions (v0.3.0+ nested format)
|
||||||
|
const bash = ctx.payload.bash;
|
||||||
|
const exclude: string[] = [];
|
||||||
|
if (bash !== "enabled") exclude.push("run_shell_command");
|
||||||
|
if (ctx.payload.write === "disabled") exclude.push("write_file");
|
||||||
|
if (ctx.payload.web === "disabled") exclude.push("web_fetch");
|
||||||
|
if (ctx.payload.search === "disabled") exclude.push("google_web_search");
|
||||||
|
|
||||||
// merge with existing settings, overwriting mcpServers and modelConfig
|
// merge with existing settings, overwriting mcpServers and modelConfig
|
||||||
const newSettings: Record<string, unknown> = {
|
const newSettings: Record<string, unknown> = {
|
||||||
@@ -364,13 +339,15 @@ function configureGeminiSettings({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// v0.3.0+ nested format
|
||||||
|
...(exclude.length > 0 && { tools: { exclude } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// for public repos, exclude native shell tool to prevent secret leakage via env
|
|
||||||
if (isPublicRepo) {
|
|
||||||
newSettings.excludeTools = ["run_shell_command"];
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||||
log.info(`» Gemini settings written to ${settingsPath}`);
|
log.info(`» Gemini settings written to ${settingsPath}`);
|
||||||
|
if (exclude.length > 0) {
|
||||||
|
log.info(`» excluded tools: ${exclude.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-127
@@ -1,87 +1,59 @@
|
|||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
|
import { installFromNpmTarball } from "../utils/install.ts";
|
||||||
import { spawn } from "../utils/subprocess.ts";
|
import { spawn } from "../utils/subprocess.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { type AgentRunContext, agent } from "./shared.ts";
|
||||||
import {
|
|
||||||
agent,
|
async function installOpencode(): Promise<string> {
|
||||||
type ConfigureMcpServersParams,
|
return await installFromNpmTarball({
|
||||||
createAgentEnv,
|
packageName: "opencode-ai",
|
||||||
installFromNpmTarball,
|
version: "latest",
|
||||||
setupProcessAgentEnv,
|
executablePath: "bin/opencode",
|
||||||
} from "./shared.ts";
|
installDependencies: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const opencode = agent({
|
export const opencode = agent({
|
||||||
name: "opencode",
|
name: "opencode",
|
||||||
install: async () => {
|
install: installOpencode,
|
||||||
return await installFromNpmTarball({
|
run: async (ctx) => {
|
||||||
packageName: "opencode-ai",
|
// install CLI at start of run
|
||||||
version: "latest",
|
const cliPath = await installOpencode();
|
||||||
executablePath: "bin/opencode",
|
|
||||||
installDependencies: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
run: async ({
|
|
||||||
payload,
|
|
||||||
apiKey: _apiKey,
|
|
||||||
apiKeys,
|
|
||||||
mcpServers,
|
|
||||||
cliPath,
|
|
||||||
repo,
|
|
||||||
effort: _effort,
|
|
||||||
}) => {
|
|
||||||
// 1. configure home/config directory
|
// 1. configure home/config directory
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = ctx.tmpdir;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
configureOpenCode({
|
configureOpenCode(ctx);
|
||||||
mcpServers,
|
|
||||||
sandbox: payload.sandbox ?? false,
|
|
||||||
isPublicRepo: repo.isPublic,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
|
||||||
|
|
||||||
// message positional must come right after "run", before flags
|
// message positional must come right after "run", before flags
|
||||||
const args = ["run", prompt, "--format", "json"];
|
const args = ["run", ctx.instructions.full, "--format", "json"];
|
||||||
|
|
||||||
if (payload.sandbox) {
|
process.env.HOME = tempHome;
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. set up environment
|
|
||||||
setupProcessAgentEnv({ HOME: tempHome });
|
|
||||||
|
|
||||||
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
|
||||||
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
|
||||||
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
||||||
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
||||||
const env: Record<string, string> = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...createAgentEnv({ HOME: tempHome }),
|
...process.env,
|
||||||
|
HOME: tempHome,
|
||||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||||
|
// set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set)
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||||
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||||
};
|
};
|
||||||
// OpenCode doesn't support GitHub App installation tokens
|
// OpenCode doesn't support GitHub App installation tokens
|
||||||
delete env.GITHUB_TOKEN;
|
delete env.GITHUB_TOKEN;
|
||||||
|
|
||||||
// add API keys from apiKeys object
|
|
||||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
|
||||||
env[key.toUpperCase()] = value;
|
|
||||||
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
|
||||||
if (key === "GEMINI_API_KEY") {
|
|
||||||
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
|
|
||||||
log.info(`🚀 Starting OpenCode CLI: ${cliPath} ${args.join(" ")}`);
|
log.debug(`» starting OpenCode: ${cliPath} ${args.join(" ")}`);
|
||||||
log.info(`📁 Working directory: ${repoDir}`);
|
log.debug(`» working directory: ${repoDir}`);
|
||||||
log.debug(`🏠 HOME: ${env.HOME}`);
|
log.debug(`» HOME: ${env.HOME}`);
|
||||||
log.debug(`📋 XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
|
log.debug(`» XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let lastActivityTime = startTime;
|
let lastActivityTime = startTime;
|
||||||
@@ -128,7 +100,7 @@ export const opencode = agent({
|
|||||||
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
|
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
|
||||||
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
|
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
|
||||||
log.warning(
|
log.warning(
|
||||||
`⚠️ No activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastActivityTime = Date.now();
|
lastActivityTime = Date.now();
|
||||||
@@ -138,7 +110,7 @@ export const opencode = agent({
|
|||||||
} else {
|
} else {
|
||||||
// log unhandled event types for visibility
|
// log unhandled event types for visibility
|
||||||
log.info(
|
log.info(
|
||||||
`📋 OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
|
`» OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -162,12 +134,12 @@ export const opencode = agent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
log.info(`✅ OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
|
log.info(`» OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
|
||||||
|
|
||||||
// 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
|
// 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
|
||||||
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
|
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
|
||||||
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
|
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
|
||||||
await log.summaryTable([
|
log.table([
|
||||||
[
|
[
|
||||||
{ data: "Input Tokens", header: true },
|
{ data: "Input Tokens", header: true },
|
||||||
{ data: "Output Tokens", header: true },
|
{ data: "Output Tokens", header: true },
|
||||||
@@ -198,59 +170,30 @@ export const opencode = agent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ConfigureOpenCodeParams {
|
|
||||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
|
||||||
sandbox: boolean;
|
|
||||||
isPublicRepo: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure OpenCode via opencode.json config file.
|
* Configure OpenCode via opencode.json config file.
|
||||||
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
||||||
*/
|
*/
|
||||||
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
function configureOpenCode(ctx: AgentRunContext): void {
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
const configPath = join(configDir, "opencode.json");
|
const configPath = join(configDir, "opencode.json");
|
||||||
|
|
||||||
// build MCP servers config
|
// build MCP servers config
|
||||||
const opencodeMcpServers: Record<string, { type: "remote"; url: string }> = {};
|
const opencodeMcpServers = {
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
|
||||||
if (serverConfig.type !== "http") {
|
};
|
||||||
log.error(
|
|
||||||
`unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
opencodeMcpServers[serverName] = {
|
// build permission object based on tool permissions
|
||||||
type: "remote",
|
// note: OpenCode has no built-in web search tool
|
||||||
url: serverConfig.url,
|
const bash = ctx.payload.bash;
|
||||||
};
|
const permission = {
|
||||||
}
|
edit: ctx.payload.write === "disabled" ? "deny" : "allow",
|
||||||
|
bash: bash !== "enabled" ? "deny" : "allow",
|
||||||
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
|
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
||||||
// disable native bash; agents use MCP bash tool which filters secrets.
|
doom_loop: "allow",
|
||||||
// for private repos, native bash is allowed.
|
external_directory: "allow",
|
||||||
const bashPermission = isPublicRepo ? "deny" : "allow";
|
};
|
||||||
const permission = sandbox
|
|
||||||
? {
|
|
||||||
edit: "deny",
|
|
||||||
bash: "deny",
|
|
||||||
webfetch: "deny",
|
|
||||||
doom_loop: "allow",
|
|
||||||
external_directory: "allow",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
edit: "allow",
|
|
||||||
bash: bashPermission,
|
|
||||||
webfetch: "allow",
|
|
||||||
doom_loop: "allow",
|
|
||||||
external_directory: "allow",
|
|
||||||
};
|
|
||||||
|
|
||||||
// build complete config in one object
|
// build complete config in one object
|
||||||
const config = {
|
const config = {
|
||||||
@@ -268,7 +211,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenC
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`» OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
|
log.info(`» OpenCode config written to ${configPath}`);
|
||||||
|
log.info(
|
||||||
|
`» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
|
||||||
|
);
|
||||||
log.debug(`OpenCode config contents:\n${configJson}`);
|
log.debug(`OpenCode config contents:\n${configJson}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,10 +372,10 @@ let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }
|
|||||||
const messageHandlers = {
|
const messageHandlers = {
|
||||||
init: (event: OpenCodeInitEvent) => {
|
init: (event: OpenCodeInitEvent) => {
|
||||||
// initialization event - reset state
|
// initialization event - reset state
|
||||||
log.info(
|
log.debug(
|
||||||
`🔵 OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
`» OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
||||||
);
|
);
|
||||||
log.info(`🔵 OpenCode init event (full): ${JSON.stringify(event)}`);
|
log.debug(`» OpenCode init event (full): ${JSON.stringify(event)}`);
|
||||||
finalOutput = "";
|
finalOutput = "";
|
||||||
accumulatedTokens = { input: 0, output: 0 };
|
accumulatedTokens = { input: 0, output: 0 };
|
||||||
tokensLogged = false;
|
tokensLogged = false;
|
||||||
@@ -440,20 +386,20 @@ const messageHandlers = {
|
|||||||
if (message) {
|
if (message) {
|
||||||
if (event.delta) {
|
if (event.delta) {
|
||||||
// delta messages are streaming thoughts/reasoning
|
// delta messages are streaming thoughts/reasoning
|
||||||
log.info(
|
log.debug(
|
||||||
`💭 OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
`» OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// complete messages
|
// complete messages
|
||||||
log.info(
|
log.debug(
|
||||||
`💬 OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
`» OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
||||||
);
|
);
|
||||||
finalOutput = message;
|
finalOutput = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.role === "user") {
|
} else if (event.role === "user") {
|
||||||
log.info(
|
log.debug(
|
||||||
`💬 OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
`» OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -533,22 +479,22 @@ const messageHandlers = {
|
|||||||
const toolDuration = Date.now() - toolStartTime;
|
const toolDuration = Date.now() - toolStartTime;
|
||||||
toolCallTimings.delete(toolId);
|
toolCallTimings.delete(toolId);
|
||||||
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
||||||
log.info(
|
log.debug(
|
||||||
`🔧 OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
|
`» OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
|
||||||
);
|
);
|
||||||
if (output) {
|
if (output) {
|
||||||
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
||||||
}
|
}
|
||||||
if (toolDuration > 5000) {
|
if (toolDuration > 5000) {
|
||||||
log.warning(
|
log.warning(
|
||||||
`⚠️ Tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
|
`» ⚠️ tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
||||||
log.warning(`❌ Tool call failed: ${errorMsg}`);
|
log.error(`» ❌ tool call failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
result: async (event: OpenCodeResultEvent) => {
|
result: async (event: OpenCodeResultEvent) => {
|
||||||
@@ -556,22 +502,20 @@ const messageHandlers = {
|
|||||||
const duration = event.stats?.duration_ms || 0;
|
const duration = event.stats?.duration_ms || 0;
|
||||||
const toolCalls = event.stats?.tool_calls || 0;
|
const toolCalls = event.stats?.tool_calls || 0;
|
||||||
log.info(
|
log.info(
|
||||||
`🏁 OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}`
|
`» OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event.status === "error") {
|
if (event.status === "error") {
|
||||||
log.error(`❌ OpenCode CLI failed: ${JSON.stringify(event)}`);
|
log.error(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
|
||||||
} else {
|
} else {
|
||||||
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
|
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
|
||||||
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
|
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
|
||||||
const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
|
const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
|
||||||
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
|
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
|
||||||
log.info(
|
log.info(`» run complete: tool_calls=${toolCalls}, duration=${duration}ms`);
|
||||||
`📊 OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration}ms`
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
|
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
|
||||||
await log.summaryTable([
|
log.table([
|
||||||
[
|
[
|
||||||
{ data: "Input Tokens", header: true },
|
{ data: "Input Tokens", header: true },
|
||||||
{ data: "Output Tokens", header: true },
|
{ data: "Output Tokens", header: true },
|
||||||
|
|||||||
+26
-493
@@ -1,20 +1,8 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { chmodSync, createWriteStream, existsSync } from "node:fs";
|
|
||||||
import { mkdtemp } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import type { show } from "@ark/util";
|
import type { show } from "@ark/util";
|
||||||
import {
|
import { type AgentManifest, type AgentName, agentsManifest } from "../external.ts";
|
||||||
type AgentManifest,
|
|
||||||
type AgentName,
|
|
||||||
agentsManifest,
|
|
||||||
type Effort,
|
|
||||||
type Payload,
|
|
||||||
} from "../external.ts";
|
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { getGitHubInstallationToken } from "../utils/github.ts";
|
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||||
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result returned by agent execution
|
* Result returned by agent execution
|
||||||
@@ -27,493 +15,38 @@ export interface AgentResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repo info for agent context
|
* Minimal context passed to agent.run()
|
||||||
*/
|
*/
|
||||||
export interface RepoInfo {
|
export interface AgentRunContext {
|
||||||
owner: string;
|
payload: ResolvedPayload;
|
||||||
name: string;
|
mcpServerUrl: string;
|
||||||
defaultBranch: string;
|
tmpdir: string;
|
||||||
isPublic: boolean;
|
instructions: ResolvedInstructions;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for agent creation
|
|
||||||
*/
|
|
||||||
export interface AgentConfig {
|
|
||||||
apiKey: string;
|
|
||||||
apiKeys?: Record<string, string>; // all available keys for this agent
|
|
||||||
payload: Payload;
|
|
||||||
mcpServers: Record<string, McpHttpServerConfig>;
|
|
||||||
cliPath: string;
|
|
||||||
repo: RepoInfo;
|
|
||||||
effort: Effort;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parameters for configuring MCP servers
|
|
||||||
*/
|
|
||||||
export interface ConfigureMcpServersParams {
|
|
||||||
mcpServers: Record<string, McpHttpServerConfig>;
|
|
||||||
cliPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add agent-specific vars to a whitelisted environment object for agent subprocesses.
|
|
||||||
*
|
|
||||||
* @param agentSpecificVars - Object containing agent-specific environment variables to include
|
|
||||||
* @returns Whitelisted environment object safe for subprocess spawning
|
|
||||||
*/
|
|
||||||
export function createAgentEnv(agentSpecificVars: Record<string, string>): Record<string, string> {
|
|
||||||
const home = agentSpecificVars.HOME || process.env.HOME;
|
|
||||||
return {
|
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOME: home,
|
|
||||||
// XDG_CONFIG_HOME must match HOME to ensure CLI tools find config files in the right place.
|
|
||||||
// GitHub Actions sets XDG_CONFIG_HOME to /home/runner/.config which would override $HOME/.config lookup.
|
|
||||||
XDG_CONFIG_HOME: home ? join(home, ".config") : undefined,
|
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
|
||||||
GITHUB_TOKEN: getGitHubInstallationToken(),
|
|
||||||
...agentSpecificVars,
|
|
||||||
// values could be undefined but will be ignored
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up whitelisted environment variables in the current process.
|
|
||||||
* Used for SDKs that run in the same process (e.g., Claude SDK, Codex SDK).
|
|
||||||
* Includes agent-agnostic vars (PATH, HOME, LOG_LEVEL, NODE_ENV) plus agent-specific vars.
|
|
||||||
*
|
|
||||||
* @param agentSpecificVars - Object containing agent-specific environment variables to include
|
|
||||||
*/
|
|
||||||
export function setupProcessAgentEnv(agentSpecificVars: Record<string, string>): void {
|
|
||||||
Object.assign(process.env, createAgentEnv(agentSpecificVars));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parameters for installing from npm tarball
|
|
||||||
*/
|
|
||||||
export interface InstallFromNpmTarballParams {
|
|
||||||
packageName: string;
|
|
||||||
version: string;
|
|
||||||
executablePath: string;
|
|
||||||
installDependencies?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parameters for installing from curl script
|
|
||||||
*/
|
|
||||||
export interface InstallFromCurlParams {
|
|
||||||
installUrl: string;
|
|
||||||
executableName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parameters for installing from GitHub releases
|
|
||||||
*/
|
|
||||||
export interface InstallFromGithubParams {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
assetName?: string;
|
|
||||||
executablePath?: string;
|
|
||||||
githubInstallationToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parameters for installing from GitHub releases tarball
|
|
||||||
*/
|
|
||||||
export interface InstallFromGithubTarballParams {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
assetNamePattern: string;
|
|
||||||
executablePath: string;
|
|
||||||
githubInstallationToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NPM registry response data structure
|
|
||||||
*/
|
|
||||||
export interface NpmRegistryData {
|
|
||||||
"dist-tags": { latest: string };
|
|
||||||
versions: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a CLI tool from an npm package tarball
|
|
||||||
* Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable
|
|
||||||
* The temp directory will be cleaned up by the OS automatically
|
|
||||||
*/
|
|
||||||
export async function installFromNpmTarball({
|
|
||||||
packageName,
|
|
||||||
version,
|
|
||||||
executablePath,
|
|
||||||
installDependencies,
|
|
||||||
}: InstallFromNpmTarballParams): Promise<string> {
|
|
||||||
// Resolve version if it's a range or "latest"
|
|
||||||
let resolvedVersion = version;
|
|
||||||
if (version.startsWith("^") || version.startsWith("~") || version === "latest") {
|
|
||||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
|
||||||
log.debug(`» resolving version for ${version}...`);
|
|
||||||
try {
|
|
||||||
const registryResponse = await fetch(`${npmRegistry}/${packageName}`);
|
|
||||||
if (!registryResponse.ok) {
|
|
||||||
throw new Error(`Failed to query registry: ${registryResponse.status}`);
|
|
||||||
}
|
|
||||||
const registryData = (await registryResponse.json()) as NpmRegistryData;
|
|
||||||
resolvedVersion = registryData["dist-tags"].latest;
|
|
||||||
log.debug(`» resolved to version ${resolvedVersion}`);
|
|
||||||
} catch (error) {
|
|
||||||
log.warning(
|
|
||||||
`Failed to resolve version from registry: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug(`» installing ${packageName}@${resolvedVersion}...`);
|
|
||||||
|
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
|
||||||
const tarballPath = join(tempDir, "package.tgz");
|
|
||||||
|
|
||||||
// Download tarball from npm
|
|
||||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
|
||||||
// Handle scoped packages (e.g., @scope/package -> @scope%2Fpackage/-/package-version.tgz)
|
|
||||||
let tarballUrl: string;
|
|
||||||
if (packageName.startsWith("@")) {
|
|
||||||
const [scope, name] = packageName.slice(1).split("/");
|
|
||||||
const scopedPackageName = `@${scope}%2F${name}`;
|
|
||||||
tarballUrl = `${npmRegistry}/${scopedPackageName}/-/${name}-${resolvedVersion}.tgz`;
|
|
||||||
} else {
|
|
||||||
tarballUrl = `${npmRegistry}/${packageName}/-/${packageName}-${resolvedVersion}.tgz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug(`» downloading from ${tarballUrl}...`);
|
|
||||||
const response = await fetch(tarballUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write tarball to file
|
|
||||||
if (!response.body) throw new Error("Response body is null");
|
|
||||||
const fileStream = createWriteStream(tarballPath);
|
|
||||||
await pipeline(response.body, fileStream);
|
|
||||||
log.debug(`» downloaded tarball to ${tarballPath}`);
|
|
||||||
|
|
||||||
// Extract tarball
|
|
||||||
log.debug(`» extracting tarball...`);
|
|
||||||
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
if (extractResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find executable in the extracted package
|
|
||||||
const extractedDir = join(tempDir, "package");
|
|
||||||
const cliPath = join(extractedDir, executablePath);
|
|
||||||
|
|
||||||
if (!existsSync(cliPath)) {
|
|
||||||
throw new Error(`Executable not found in extracted package at ${cliPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install dependencies if requested
|
|
||||||
if (installDependencies) {
|
|
||||||
log.debug(`» installing dependencies for ${packageName}...`);
|
|
||||||
const installResult = spawnSync("npm", ["install", "--production"], {
|
|
||||||
cwd: extractedDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
if (installResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to install dependencies: ${installResult.stderr || installResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log.debug(`» dependencies installed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the file executable
|
|
||||||
chmodSync(cliPath, 0o755);
|
|
||||||
|
|
||||||
log.debug(`» ${packageName} installed at ${cliPath}`);
|
|
||||||
|
|
||||||
return cliPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch with retry logic if Retry-After header is present
|
|
||||||
*/
|
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
errorMessage: string
|
|
||||||
): Promise<Response> {
|
|
||||||
const response = await fetch(url, { headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("retry-after");
|
|
||||||
if (retryAfter) {
|
|
||||||
const waitSeconds = parseInt(retryAfter, 10);
|
|
||||||
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
|
|
||||||
log.info(`Rate limited, waiting ${waitSeconds} seconds before retry...`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
|
||||||
const retryResponse = await fetch(url, { headers });
|
|
||||||
if (!retryResponse.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`${errorMessage}: ${retryResponse.status} ${retryResponse.statusText} (retry failed)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return retryResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`${errorMessage}: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a CLI tool from GitHub releases
|
|
||||||
* Downloads the latest release asset from GitHub and returns the path to the executable
|
|
||||||
* The temp directory will be cleaned up by the OS automatically
|
|
||||||
*/
|
|
||||||
export async function installFromGithub({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
assetName,
|
|
||||||
executablePath,
|
|
||||||
githubInstallationToken,
|
|
||||||
}: InstallFromGithubParams): Promise<string> {
|
|
||||||
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
|
|
||||||
|
|
||||||
// fetch release from GitHub API (latest)
|
|
||||||
const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
|
||||||
log.info(`Fetching release from ${releaseUrl}...`);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (githubInstallationToken) {
|
|
||||||
headers.Authorization = `Bearer ${githubInstallationToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
|
|
||||||
|
|
||||||
const releaseData = (await releaseResponse.json()) as {
|
|
||||||
tag_name: string;
|
|
||||||
assets: Array<{
|
|
||||||
name: string;
|
|
||||||
browser_download_url: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
log.info(`Found release: ${releaseData.tag_name}`);
|
|
||||||
|
|
||||||
const asset = releaseData.assets.find((a) => a.name === assetName);
|
|
||||||
if (!asset) {
|
|
||||||
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
|
|
||||||
}
|
|
||||||
const assetUrl = asset.browser_download_url;
|
|
||||||
|
|
||||||
log.info(`Downloading asset from ${assetUrl}...`);
|
|
||||||
|
|
||||||
// create temp directory
|
|
||||||
const tempDirPrefix = `${owner}-${repo}-github-`;
|
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix));
|
|
||||||
|
|
||||||
// determine file extension and download path
|
|
||||||
const urlPath = new URL(assetUrl).pathname;
|
|
||||||
const fileName = urlPath.split("/").pop() || "asset";
|
|
||||||
const downloadPath = join(tempDir, fileName);
|
|
||||||
|
|
||||||
// download the asset
|
|
||||||
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
|
|
||||||
|
|
||||||
if (!assetResponse.body) throw new Error("Response body is null");
|
|
||||||
const fileStream = createWriteStream(downloadPath);
|
|
||||||
await pipeline(assetResponse.body, fileStream);
|
|
||||||
log.info(`Downloaded asset to ${downloadPath}`);
|
|
||||||
|
|
||||||
// determine the executable path
|
|
||||||
let cliPath: string;
|
|
||||||
if (executablePath) {
|
|
||||||
cliPath = join(tempDir, executablePath);
|
|
||||||
} else {
|
|
||||||
// no executablePath, assume the downloaded file is the executable
|
|
||||||
cliPath = downloadPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(cliPath)) {
|
|
||||||
throw new Error(`Executable not found at ${cliPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
chmodSync(cliPath, 0o755);
|
|
||||||
log.info(`✓ Installed from GitHub release at ${cliPath}`);
|
|
||||||
|
|
||||||
return cliPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a CLI tool from a GitHub release tarball
|
|
||||||
* Downloads the tar.gz from GitHub releases, extracts it, and returns the path to the CLI executable
|
|
||||||
* The temp directory will be cleaned up by the OS automatically
|
|
||||||
*/
|
|
||||||
export async function installFromGithubTarball({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
assetNamePattern,
|
|
||||||
executablePath,
|
|
||||||
githubInstallationToken,
|
|
||||||
}: InstallFromGithubTarballParams): Promise<string> {
|
|
||||||
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
|
|
||||||
|
|
||||||
// determine platform-specific asset name
|
|
||||||
const os = process.platform === "darwin" ? "darwin" : "linux";
|
|
||||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
||||||
const assetName = assetNamePattern.replace("{os}", os).replace("{arch}", arch);
|
|
||||||
|
|
||||||
// fetch release from GitHub API (latest)
|
|
||||||
const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
|
||||||
log.info(`Fetching release from ${releaseUrl}...`);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (githubInstallationToken) {
|
|
||||||
headers.Authorization = `Bearer ${githubInstallationToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
|
|
||||||
|
|
||||||
const releaseData = (await releaseResponse.json()) as {
|
|
||||||
tag_name: string;
|
|
||||||
assets: Array<{
|
|
||||||
name: string;
|
|
||||||
browser_download_url: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
log.info(`Found release: ${releaseData.tag_name}`);
|
|
||||||
|
|
||||||
const asset = releaseData.assets.find((a) => a.name === assetName);
|
|
||||||
if (!asset) {
|
|
||||||
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
|
|
||||||
}
|
|
||||||
const assetUrl = asset.browser_download_url;
|
|
||||||
|
|
||||||
log.info(`Downloading asset from ${assetUrl}...`);
|
|
||||||
|
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
|
||||||
const tarballPath = join(tempDir, assetName);
|
|
||||||
|
|
||||||
// download the asset
|
|
||||||
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
|
|
||||||
|
|
||||||
if (!assetResponse.body) throw new Error("Response body is null");
|
|
||||||
const fileStream = createWriteStream(tarballPath);
|
|
||||||
await pipeline(assetResponse.body, fileStream);
|
|
||||||
log.info(`Downloaded tarball to ${tarballPath}`);
|
|
||||||
|
|
||||||
// extract tar.gz
|
|
||||||
log.info(`Extracting tarball...`);
|
|
||||||
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
if (extractResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// find executable in the extracted tarball
|
|
||||||
const cliPath = join(tempDir, executablePath);
|
|
||||||
|
|
||||||
if (!existsSync(cliPath)) {
|
|
||||||
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// make the file executable
|
|
||||||
chmodSync(cliPath, 0o755);
|
|
||||||
|
|
||||||
log.info(`✓ ${owner}/${repo} installed at ${cliPath}`);
|
|
||||||
|
|
||||||
return cliPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a CLI tool from a curl-based install script
|
|
||||||
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
|
|
||||||
* The temp directory will be cleaned up by the OS automatically
|
|
||||||
*/
|
|
||||||
export async function installFromCurl({
|
|
||||||
installUrl,
|
|
||||||
executableName,
|
|
||||||
}: InstallFromCurlParams): Promise<string> {
|
|
||||||
log.info(`📦 Installing ${executableName}...`);
|
|
||||||
|
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
|
||||||
const installScriptPath = join(tempDir, "install.sh");
|
|
||||||
|
|
||||||
// Download the install script
|
|
||||||
log.info(`Downloading install script from ${installUrl}...`);
|
|
||||||
const installScriptResponse = await fetch(installUrl);
|
|
||||||
if (!installScriptResponse.ok) {
|
|
||||||
throw new Error(`Failed to download install script: ${installScriptResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!installScriptResponse.body) throw new Error("Response body is null");
|
|
||||||
const fileStream = createWriteStream(installScriptPath);
|
|
||||||
await pipeline(installScriptResponse.body, fileStream);
|
|
||||||
log.info(`Downloaded install script to ${installScriptPath}`);
|
|
||||||
|
|
||||||
// Make install script executable
|
|
||||||
chmodSync(installScriptPath, 0o755);
|
|
||||||
|
|
||||||
log.info(`Installing to temp directory at ${tempDir}...`);
|
|
||||||
|
|
||||||
const installResult = spawnSync("bash", [installScriptPath], {
|
|
||||||
cwd: tempDir,
|
|
||||||
env: {
|
|
||||||
// Run the install script with HOME set to temp directory
|
|
||||||
// ensuring a fresh install for each run
|
|
||||||
HOME: tempDir,
|
|
||||||
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place
|
|
||||||
XDG_CONFIG_HOME: join(tempDir, ".config"),
|
|
||||||
SHELL: process.env.SHELL,
|
|
||||||
USER: process.env.USER,
|
|
||||||
},
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (installResult.status !== 0) {
|
|
||||||
const errorOutput = installResult.stderr || installResult.stdout || "No output";
|
|
||||||
throw new Error(
|
|
||||||
`Failed to install ${executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
|
|
||||||
// Since we set HOME=tempDir, the deterministic path is:
|
|
||||||
const cliPath = join(tempDir, ".local", "bin", executableName);
|
|
||||||
|
|
||||||
if (!existsSync(cliPath)) {
|
|
||||||
throw new Error(`Executable not found at ${cliPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure binary is executable
|
|
||||||
chmodSync(cliPath, 0o755);
|
|
||||||
log.info(`✓ ${executableName} installed at ${cliPath}`);
|
|
||||||
|
|
||||||
return cliPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => {
|
export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => {
|
||||||
return { ...input, ...agentsManifest[input.name] } as never;
|
return {
|
||||||
|
...input,
|
||||||
|
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||||
|
const bash = ctx.payload.bash;
|
||||||
|
const web = ctx.payload.web;
|
||||||
|
const search = ctx.payload.search;
|
||||||
|
const write = ctx.payload.write;
|
||||||
|
log.info(`» running ${input.name} with effort=${ctx.payload.effort}...`);
|
||||||
|
log.box(ctx.instructions.user.trim() + "\n\n" + ctx.instructions.event.trim(), {
|
||||||
|
title: "Instructions",
|
||||||
|
});
|
||||||
|
log.info(`» tool permissions: web=${web}, search=${search}, write=${write}, bash=${bash}`);
|
||||||
|
return input.run(ctx);
|
||||||
|
},
|
||||||
|
...agentsManifest[input.name],
|
||||||
|
} as never;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentInput {
|
export interface AgentInput {
|
||||||
name: AgentName;
|
name: AgentName;
|
||||||
install: (token?: string) => Promise<string>;
|
install: (token?: string) => Promise<string>;
|
||||||
run: (config: AgentConfig) => Promise<AgentResult>;
|
run: (ctx: AgentRunContext) => Promise<AgentResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Agent extends AgentInput, AgentManifest {}
|
export interface Agent extends AgentInput, AgentManifest {}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
# Docker Testing Environment
|
|
||||||
|
|
||||||
`play.ts` runs in Docker by default for realistic testing (Linux, clean $HOME, matches CI).
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm play bash-test.ts # runs in Docker (default)
|
|
||||||
pnpm play --local bash-test.ts # runs on macOS (fast iteration)
|
|
||||||
PLAY_LOCAL=1 pnpm play ... # same as --local
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why Docker by Default?
|
|
||||||
|
|
||||||
1. **Matches CI** - Linux environment like GitHub Actions
|
|
||||||
2. **Clean $HOME** - No agent config pollution from `~/.claude`, `~/.cursor`
|
|
||||||
3. **Tests unshare** - Verifies PID namespace sandbox works
|
|
||||||
4. **Reproducible** - Same environment every run
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
| Mode | Overhead |
|
|
||||||
|------|----------|
|
|
||||||
| Docker (cached deps) | ~1.5s |
|
|
||||||
| Local (macOS) | ~0s |
|
|
||||||
|
|
||||||
For agent runs taking 30-120s, the 1.5s overhead is negligible.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. `play.ts` runs on host, loads `.env`
|
|
||||||
2. Spawns Docker container with:
|
|
||||||
- Volume-mounted `action/` code
|
|
||||||
- Named volume for Linux node_modules (persists between runs)
|
|
||||||
- SSH agent forwarding for git clone
|
|
||||||
- Env vars passed via `-e` flags
|
|
||||||
3. Inside Docker, `play.ts` runs again (detects `/.dockerenv` file)
|
|
||||||
4. Clones `GITHUB_REPOSITORY`, runs agent
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Docker not running:**
|
|
||||||
```
|
|
||||||
Cannot connect to the Docker daemon
|
|
||||||
```
|
|
||||||
→ Start Docker Desktop
|
|
||||||
|
|
||||||
**SSH clone fails:**
|
|
||||||
```
|
|
||||||
Permission denied (publickey)
|
|
||||||
```
|
|
||||||
→ Ensure SSH agent is running: `ssh-add -l`
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# Bash Tool Security
|
|
||||||
|
|
||||||
> **Note**: Security measures described here apply to **PUBLIC repositories only**. For private repos, agents can use native bash with full environment access.
|
|
||||||
|
|
||||||
## Architecture (Public Repos)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ GitHub Actions Runner │
|
|
||||||
│ (has secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Pullfrog Action (Node.js) │ │
|
|
||||||
│ │ - process.env contains all secrets │ │
|
|
||||||
│ │ - spawns agent CLI as child process │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
|
||||||
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
|
||||||
│ │ │ - has built-in Bash tool (DISABLED for public) │ │ │
|
|
||||||
│ │ │ - connects to MCP server for tools │ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
|
||||||
│ │ │ │ MCP Bash Tool (our code) │ │ │ │
|
|
||||||
│ │ │ │ - agent calls this for shell commands │ │ │ │
|
|
||||||
│ │ │ │ - spawns bash with filtered env │ │ │ │
|
|
||||||
│ │ │ │ - uses PID namespace isolation │ │ │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │
|
|
||||||
│ │ │ │ │ Bash subprocess │ │ │ │ │
|
|
||||||
│ │ │ │ │ - runs user-controlled commands │ │ │ │ │
|
|
||||||
│ │ │ │ │ - MUST NOT access secrets │ │ │ │ │
|
|
||||||
│ │ │ │ └─────────────────────────────────────────┘ │ │ │ │
|
|
||||||
│ │ │ └───────────────────────────────────────────────┘ │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key insight**: For **public repos**, the Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust since malicious actors can submit PRs with prompt injections. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
|
|
||||||
|
|
||||||
For **private repos**, the threat model is different — only trusted collaborators can trigger workflows, so we allow native bash with full environment access for better performance and compatibility.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Public vs Private Repos
|
|
||||||
|
|
||||||
| Repo Visibility | Native Bash | Env Filtering | PID Isolation |
|
|
||||||
|-----------------|-------------|---------------|---------------|
|
|
||||||
| **Public** | Disabled | Yes | Yes (in CI) |
|
|
||||||
| **Private** | Enabled | No | No |
|
|
||||||
|
|
||||||
**Rationale**: Public repos are at risk from prompt injection attacks via pull requests from untrusted contributors. Private repos only allow trusted collaborators, so the attack surface is much smaller.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Threat Model (Public Repos)
|
|
||||||
|
|
||||||
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
|
||||||
|
|
||||||
**Attack vectors:**
|
|
||||||
|
|
||||||
| Vector | Example | Mitigation |
|
|
||||||
|--------|---------|------------|
|
|
||||||
| Direct env access | `env \| grep KEY` | Filter env vars before spawn |
|
|
||||||
| Echo variable | `echo $ANTHROPIC_API_KEY` | Filter env vars before spawn |
|
|
||||||
| `/proc/$PPID/environ` | `cat /proc/$PPID/environ` | PID namespace isolation |
|
|
||||||
|
|
||||||
The first two are solved by passing filtered env to subprocess. The third requires special handling on Linux.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Attack: /proc/$PPID/environ (Public Repos)
|
|
||||||
|
|
||||||
On Linux, any process can read its parent's environment via `/proc/$PPID/environ`. Even if we spawn bash with a clean environment, the bash process can:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# read parent's (Node.js) environment - contains all secrets!
|
|
||||||
tr '\0' '\n' < /proc/$PPID/environ | grep KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
This bypasses environment filtering because we're reading the parent process's memory, not our own env.
|
|
||||||
|
|
||||||
**Why this matters:**
|
|
||||||
- Pullfrog Action (Node.js) has `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. in `process.env`
|
|
||||||
- We spawn agent CLI with filtered env (only its own API key)
|
|
||||||
- Agent CLI spawns MCP Bash tool
|
|
||||||
- MCP Bash tool spawns bash with filtered env (no secrets)
|
|
||||||
- BUT bash can read `/proc/$PPID/environ` → gets Node.js process's full env
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solution: PID Namespace Isolation (Public Repos)
|
|
||||||
|
|
||||||
We use Linux PID namespaces to hide the parent process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
unshare --pid --fork --mount-proc bash -c "$CMD"
|
|
||||||
```
|
|
||||||
|
|
||||||
| Flag | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `--pid` | Create new PID namespace |
|
|
||||||
| `--fork` | Fork so child is actually in new namespace |
|
|
||||||
| `--mount-proc` | Mount fresh `/proc` for new namespace |
|
|
||||||
|
|
||||||
**Result:**
|
|
||||||
- Child sees itself as PID 1
|
|
||||||
- Child's PPID is 0 (doesn't exist)
|
|
||||||
- `/proc` only shows processes in child's namespace
|
|
||||||
- Parent's PID is invisible → `/proc/$PPID/environ` fails
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### mcp/bash.ts
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
|
|
||||||
// filter sensitive env vars (only for public repos)
|
|
||||||
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
|
||||||
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
|
||||||
const filtered: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (value === undefined) continue;
|
|
||||||
// only filter sensitive vars for public repos
|
|
||||||
if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue;
|
|
||||||
filtered[key] = value;
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
// spawn with PID namespace in CI for public repos, plain spawn otherwise
|
|
||||||
function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess {
|
|
||||||
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
|
||||||
if (useNamespaceIsolation) {
|
|
||||||
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
|
||||||
}
|
|
||||||
return spawn("bash", ["-c", command], options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// BashTool uses ctx.repo.private to determine visibility
|
|
||||||
export function BashTool(ctx: ToolContext) {
|
|
||||||
const isPublicRepo = !ctx.repo.private;
|
|
||||||
// ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Defense in depth (public repos only):**
|
|
||||||
1. `filterEnv(true)` - prevents `env` and `echo $VAR` attacks
|
|
||||||
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Disabling Native Bash Tools (Public Repos)
|
|
||||||
|
|
||||||
For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Claude - conditional based on repo.isPublic
|
|
||||||
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
|
||||||
{ permissionMode: "bypassPermissions", disallowedTools }
|
|
||||||
|
|
||||||
// Cursor - conditional shell denial
|
|
||||||
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
|
||||||
{ permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } }
|
|
||||||
|
|
||||||
// OpenCode - conditional bash denial
|
|
||||||
const bashPermission = isPublicRepo ? "deny" : "allow";
|
|
||||||
{ permission: { edit: "allow", bash: bashPermission, ... } }
|
|
||||||
|
|
||||||
// Gemini - uses excludeTools in ~/.gemini/settings.json
|
|
||||||
newSettings.excludeTools = ["run_shell_command"];
|
|
||||||
|
|
||||||
// Codex - CLI internally scrubs env before spawning shell
|
|
||||||
// No SDK-level config needed; Codex handles this automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
For **private repos**, native bash is allowed for all agents.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing (Public Repo Scenario)
|
|
||||||
|
|
||||||
Run the vulnerability test in Docker to verify protection for public repos:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from action/ directory
|
|
||||||
docker run --rm \
|
|
||||||
-v "$(pwd):/app/action:cached" \
|
|
||||||
-v "pullfrog-action-node-modules:/app/action/node_modules" \
|
|
||||||
-w /app/action \
|
|
||||||
-e GITHUB_ACTIONS=true \
|
|
||||||
-e TEST_SECRET_KEY=test-secret \
|
|
||||||
-e ANTHROPIC_API_KEY=sk-test \
|
|
||||||
--cap-add SYS_ADMIN \
|
|
||||||
--security-opt seccomp:unconfined \
|
|
||||||
node:22 bash -c "corepack enable pnpm && pnpm install --frozen-lockfile && node test/proc-environ-vuln.ts"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
1. UNPROTECTED (filterEnv only):
|
|
||||||
Leaked: YES ❌
|
|
||||||
|
|
||||||
2. PROTECTED (unshare --pid --fork --mount-proc):
|
|
||||||
Leaked: NO ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Platform Notes
|
|
||||||
|
|
||||||
| Environment | Repo | Our approach |
|
|
||||||
|-------------|------|--------------|
|
|
||||||
| GitHub Actions (Linux) | Public | filterEnv + unshare + disable native bash |
|
|
||||||
| GitHub Actions (Linux) | Private | Full env + native bash allowed |
|
|
||||||
| Local dev (any OS) | Any | No filtering (local dev assumed trusted) |
|
|
||||||
|
|
||||||
We check `process.env.CI === "true"` (set by GitHub Actions) combined with `ctx.repo.private` to determine the security posture:
|
|
||||||
- **CI + Public repo**: Full protection with PID namespace isolation
|
|
||||||
- **CI + Private repo**: No protection (trusted collaborators only)
|
|
||||||
- **Local**: No protection (developer's own machine)
|
|
||||||
|
|
||||||
GitHub Actions uses Ubuntu runners where `unshare` works without root.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What This Does NOT Protect Against (Public Repos)
|
|
||||||
|
|
||||||
Even with protections enabled, bash subprocesses can still:
|
|
||||||
|
|
||||||
- **Network exfiltration**: Child has full network access
|
|
||||||
- **File access**: Child can read any file the runner can (same UID)
|
|
||||||
- **Resource exhaustion**: No cgroup limits
|
|
||||||
|
|
||||||
For those, you'd need `bwrap` with `--unshare-net`, `--ro-bind`, etc. But for the stated goal—preventing secret exfiltration via env—this is sufficient.
|
|
||||||
|
|
||||||
For **private repos**, none of these protections apply since we trust collaborators.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
|
||||||
|
|
||||||
### Claude, Cursor, OpenCode (Public Repos)
|
|
||||||
|
|
||||||
These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
|
||||||
|
|
||||||
For private repos, native bash is enabled for these agents.
|
|
||||||
|
|
||||||
### Gemini (Public Repos)
|
|
||||||
|
|
||||||
Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// written to ~/.gemini/settings.json
|
|
||||||
newSettings.excludeTools = ["run_shell_command"];
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools.
|
|
||||||
|
|
||||||
Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set.
|
|
||||||
|
|
||||||
### Codex
|
|
||||||
|
|
||||||
Codex CLI filters out env vars matching `KEY`, `SECRET`, or `TOKEN` (case-insensitive) by default via `shell_environment_policy.ignore_default_excludes = false`.
|
|
||||||
|
|
||||||
**Vulnerability**: If a user's `~/.codex/config.toml` has `ignore_default_excludes = true`, secrets will leak to shell commands.
|
|
||||||
|
|
||||||
**Our mitigation**: We set `CODEX_HOME` to a temp directory and write our own `config.toml` with `ignore_default_excludes = false` to enforce filtering regardless of what config exists in the user's `~/.codex/`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// set CODEX_HOME to override user's config
|
|
||||||
setupProcessAgentEnv({ CODEX_HOME: codexDir });
|
|
||||||
|
|
||||||
// write secure config to $CODEX_HOME/config.toml
|
|
||||||
writeFileSync(join(codexDir, "config.toml"), `
|
|
||||||
[shell_environment_policy]
|
|
||||||
ignore_default_excludes = false
|
|
||||||
`);
|
|
||||||
```
|
|
||||||
|
|
||||||
See [GitHub Issue #3064](https://github.com/openai/codex/issues/3064) and [config docs](https://github.com/openai/codex/blob/main/docs/config.md#shell_environment_policy).
|
|
||||||
|
|
||||||
**Verified behavior** (tested via `pnpm play codex-env-test.ts`):
|
|
||||||
- Default (no config): ✅ secrets filtered
|
|
||||||
- `ignore_default_excludes = false`: ✅ secrets filtered
|
|
||||||
- `ignore_default_excludes = true`: ❌ secrets leak
|
|
||||||
|
|
||||||
Example output when running `env | grep TEST` with our config:
|
|
||||||
```
|
|
||||||
TEST_SAFE_VAR=VISIBLE-SAFE-VALUE
|
|
||||||
# FAKE_SECRET_KEY and TEST_API_TOKEN are NOT visible (filtered)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Summary by Agent
|
|
||||||
|
|
||||||
| Agent | Public Repo | Private Repo |
|
|
||||||
|-------|-------------|--------------|
|
|
||||||
| Claude | Native bash **disabled** | Native bash allowed |
|
|
||||||
| Cursor | Native shell **disabled** | Native shell allowed |
|
|
||||||
| OpenCode | Native bash **disabled** | Native bash allowed |
|
|
||||||
| Gemini | Native shell **disabled** (via excludeTools) | Native bash allowed |
|
|
||||||
| Codex | Native shell allowed (CLI scrubs env internally) | Native bash allowed |
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
# WebFetch Tool Analysis
|
|
||||||
|
|
||||||
Analysis of webfetch/URL fetching implementations across three AI coding agents to inform the design of pullfrog's custom webfetch MCP tool.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. OpenCode Implementation
|
|
||||||
|
|
||||||
**Source**: `packages/opencode/src/tool/webfetch.ts`
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
OpenCode's webfetch is straightforward - a simple fetch wrapper with HTML-to-markdown conversion:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const response = await fetch(params.url, {
|
|
||||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...",
|
|
||||||
Accept: acceptHeader,
|
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
| Feature | Implementation |
|
|
||||||
|---------|---------------|
|
|
||||||
| **Output formats** | `text`, `markdown`, `html` (default: markdown) |
|
|
||||||
| **HTML→Markdown** | Uses `turndown` library |
|
|
||||||
| **Max response size** | 5MB hard limit |
|
|
||||||
| **Timeout** | 30s default, 120s max |
|
|
||||||
| **Permission system** | Application-level `ctx.ask()` prompt |
|
|
||||||
| **Domain blocking** | None - relies on user approval |
|
|
||||||
| **Caching** | None |
|
|
||||||
| **Redirect handling** | Native fetch behavior |
|
|
||||||
|
|
||||||
### HTML Processing
|
|
||||||
|
|
||||||
Two methods depending on output format:
|
|
||||||
|
|
||||||
1. **`extractTextFromHTML()`** - Uses Bun's `HTMLRewriter` to strip scripts/styles and extract text
|
|
||||||
2. **`convertHTMLToMarkdown()`** - Uses `turndown` with sensible defaults (ATX headings, fenced code blocks)
|
|
||||||
|
|
||||||
### Permission Model
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await ctx.ask({
|
|
||||||
permission: "webfetch",
|
|
||||||
patterns: [params.url],
|
|
||||||
always: ["*"], // User can allow all future requests
|
|
||||||
metadata: { url, format, timeout },
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verdict**: No enforcement - purely advisory. If user approves, the fetch proceeds with no restrictions.
|
|
||||||
|
|
||||||
### What I Like
|
|
||||||
- Clean, minimal implementation
|
|
||||||
- Good HTML processing with `turndown`
|
|
||||||
- Sensible size limits (5MB)
|
|
||||||
- Format flexibility
|
|
||||||
|
|
||||||
### What I Don't Like
|
|
||||||
- No domain whitelisting/blocklisting
|
|
||||||
- No caching (repeated requests to same URL are wasteful)
|
|
||||||
- Permission system is advisory-only
|
|
||||||
- No redirect safety checks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Claude Code Implementation
|
|
||||||
|
|
||||||
**Source**: Extracted from bundled `claude` CLI binary
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
Claude Code uses a more sophisticated approach with server-side domain validation:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Domain validation before fetch
|
|
||||||
async function Ci5(domain) {
|
|
||||||
const response = await fetch(
|
|
||||||
`https://claude.ai/api/web/domain_info?domain=${encodeURIComponent(domain)}`
|
|
||||||
);
|
|
||||||
if (response.status === 200) {
|
|
||||||
return response.data.can_fetch === true
|
|
||||||
? { status: "allowed" }
|
|
||||||
: { status: "blocked" };
|
|
||||||
}
|
|
||||||
return { status: "check_failed" };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
| Feature | Implementation |
|
|
||||||
|---------|---------------|
|
|
||||||
| **Domain blocklist** | Server-side API at `claude.ai/api/web/domain_info` |
|
|
||||||
| **Permission format** | `WebFetch(domain:example.com)` - domain-only, not URLs |
|
|
||||||
| **Wildcard support** | `domain:*.google.com` patterns |
|
|
||||||
| **HTTP→HTTPS upgrade** | Automatic protocol upgrade |
|
|
||||||
| **Caching** | 15-minute self-cleaning cache |
|
|
||||||
| **HTML→Markdown** | Uses `turndown` |
|
|
||||||
| **Redirect handling** | Special handling - informs user of cross-host redirects |
|
|
||||||
| **Enterprise override** | `skipWebFetchPreflight` setting |
|
|
||||||
|
|
||||||
### Domain Permission Model
|
|
||||||
|
|
||||||
Claude Code enforces domain-level permissions, not URL-level:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
WebFetch: (A) => {
|
|
||||||
if (A.includes("://") || A.startsWith("http"))
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "WebFetch permissions use domain format, not URLs",
|
|
||||||
suggestion: 'Use "domain:hostname" format',
|
|
||||||
examples: ["WebFetch(domain:example.com)", "WebFetch(domain:github.com)"]
|
|
||||||
};
|
|
||||||
if (!A.startsWith("domain:"))
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'WebFetch permissions must use "domain:" prefix',
|
|
||||||
examples: ["WebFetch(domain:example.com)", "WebFetch(domain:*.google.com)"]
|
|
||||||
};
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blocklist Enforcement Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User requests URL
|
|
||||||
↓
|
|
||||||
Extract hostname
|
|
||||||
↓
|
|
||||||
Check claude.ai/api/web/domain_info?domain=hostname
|
|
||||||
↓
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ allowed → proceed with fetch │
|
|
||||||
│ blocked → throw AC0 error │
|
|
||||||
│ check_failed → throw QC0 error │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redirect Handling
|
|
||||||
|
|
||||||
When a URL redirects to a different host:
|
|
||||||
```javascript
|
|
||||||
// Returns special response asking user to manually re-request
|
|
||||||
return `To complete your request, I need to fetch content from the redirected URL.
|
|
||||||
Please use WebFetch again with these parameters:
|
|
||||||
- url: "${redirectUrl}"
|
|
||||||
- prompt: "${originalPrompt}"`;
|
|
||||||
```
|
|
||||||
|
|
||||||
This prevents open redirect attacks where a "safe" domain redirects to a malicious one.
|
|
||||||
|
|
||||||
### What I Like
|
|
||||||
- **Server-side blocklist** - centralized, updateable without client changes
|
|
||||||
- **Domain-level permissions** - prevents path-based bypasses
|
|
||||||
- **Redirect safety** - cross-host redirects require explicit user action
|
|
||||||
- **15-minute caching** - reduces redundant requests
|
|
||||||
- **Enterprise override** - `skipWebFetchPreflight` for corporate environments
|
|
||||||
|
|
||||||
### What I Don't Like
|
|
||||||
- **External dependency** - requires `claude.ai` API to be available
|
|
||||||
- **No local blocklist** - can't work offline or with custom blocklists
|
|
||||||
- **Opaque blocklist** - users can't see what's blocked or why
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gemini CLI Implementation
|
|
||||||
|
|
||||||
**Source**: `@google/gemini-cli` npm package
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
Gemini CLI takes a fundamentally different approach - it doesn't have a dedicated webfetch tool. Instead it relies on:
|
|
||||||
|
|
||||||
1. **Google Search grounding** - built into the Gemini API
|
|
||||||
2. **MCP servers** - external tools can provide fetch capabilities
|
|
||||||
3. **No native URL fetching** - by design
|
|
||||||
|
|
||||||
### Key Observations
|
|
||||||
|
|
||||||
From searching the codebase:
|
|
||||||
- No `webfetch`, `url_fetch`, or similar tool definitions
|
|
||||||
- Has `github_fetch.ts` for fetching GitHub releases (internal use)
|
|
||||||
- Relies on model's built-in capabilities or MCP extensions
|
|
||||||
|
|
||||||
### Why No WebFetch?
|
|
||||||
|
|
||||||
Gemini's design philosophy appears to be:
|
|
||||||
1. Use the model's grounding capabilities for web information
|
|
||||||
2. Delegate specialized fetching to MCP servers
|
|
||||||
3. Avoid building network access into the CLI itself
|
|
||||||
|
|
||||||
### What I Like
|
|
||||||
- **Clean separation** - network access is opt-in via MCP
|
|
||||||
- **Security by default** - no built-in way to exfiltrate data
|
|
||||||
|
|
||||||
### What I Don't Like
|
|
||||||
- **Missing functionality** - can't fetch arbitrary URLs
|
|
||||||
- **Requires MCP setup** - more complex for users who need fetching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Pullfrog Design Decisions
|
|
||||||
|
|
||||||
### Core Requirements
|
|
||||||
|
|
||||||
1. **Domain-level whitelisting** - enforced in-tool, not advisory
|
|
||||||
2. **Simple implementation** - no external API dependencies
|
|
||||||
3. **GitHub-focused** - optimized for common development URLs
|
|
||||||
|
|
||||||
### Proposed Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ WebFetch Tool │
|
|
||||||
├─────────────────────────────────────────────────────┤
|
|
||||||
│ 1. Parse URL → extract hostname │
|
|
||||||
│ 2. Check against DOMAIN_ALLOWLIST │
|
|
||||||
│ 3. If not allowed → return error (not throw) │
|
|
||||||
│ 4. Fetch with timeout + size limits │
|
|
||||||
│ 5. Convert HTML → Markdown if needed │
|
|
||||||
│ 6. Return content │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Domain Allowlist Strategy
|
|
||||||
|
|
||||||
**Included in initial allowlist**:
|
|
||||||
```typescript
|
|
||||||
const DOMAIN_ALLOWLIST = new Set([
|
|
||||||
// Documentation sites
|
|
||||||
"docs.github.com",
|
|
||||||
"developer.mozilla.org",
|
|
||||||
"nodejs.org",
|
|
||||||
"docs.python.org",
|
|
||||||
"go.dev",
|
|
||||||
"doc.rust-lang.org",
|
|
||||||
"docs.microsoft.com",
|
|
||||||
"learn.microsoft.com",
|
|
||||||
|
|
||||||
// Package registries (documentation)
|
|
||||||
"npmjs.com",
|
|
||||||
"www.npmjs.com",
|
|
||||||
"pypi.org",
|
|
||||||
"crates.io",
|
|
||||||
"pkg.go.dev",
|
|
||||||
|
|
||||||
// GitHub (raw content, gists)
|
|
||||||
"raw.githubusercontent.com",
|
|
||||||
"gist.githubusercontent.com",
|
|
||||||
|
|
||||||
// Common API documentation
|
|
||||||
"api.github.com", // Already have GitHub tools, but for reference docs
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Explicitly NOT included**:
|
|
||||||
- `github.com` itself - we have dedicated GitHub MCP tools
|
|
||||||
- Social media sites
|
|
||||||
- General web pages
|
|
||||||
- Arbitrary user-provided domains
|
|
||||||
|
|
||||||
### Features Borrowed from Each Agent
|
|
||||||
|
|
||||||
| Feature | Source | Included? | Rationale |
|
|
||||||
|---------|--------|-----------|-----------|
|
|
||||||
| HTML→Markdown via turndown | OpenCode | ✅ | Clean, proven library |
|
|
||||||
| 5MB size limit | OpenCode | ✅ | Sensible default |
|
|
||||||
| Domain-level permissions | Claude Code | ✅ | Core requirement |
|
|
||||||
| Redirect safety checks | Claude Code | ✅ | Prevents open redirect attacks |
|
|
||||||
| 15-minute caching | Claude Code | ❌ | Adds complexity, MCP is stateless |
|
|
||||||
| Server-side blocklist | Claude Code | ❌ | External dependency |
|
|
||||||
| Enterprise override | Claude Code | ❌ | Not needed for GitHub Actions |
|
|
||||||
| No built-in fetching | Gemini | ❌ | We need this functionality |
|
|
||||||
|
|
||||||
### Features NOT Included (and why)
|
|
||||||
|
|
||||||
1. **Caching** - MCP tools are stateless by design. Caching would require shared state across requests. The agent can cache results itself.
|
|
||||||
|
|
||||||
2. **Server-side blocklist** - Would require standing up an API endpoint. The allowlist approach is simpler and more transparent.
|
|
||||||
|
|
||||||
3. **User permission prompts** - In GitHub Actions context, there's no interactive user. Allowlist is enforced automatically.
|
|
||||||
|
|
||||||
4. **Wildcard domain patterns** - Adds complexity. Start with explicit domains, add patterns if needed.
|
|
||||||
|
|
||||||
5. **Multiple output formats** - Start with markdown only. Can add `text` and `html` later if needed.
|
|
||||||
|
|
||||||
### Error Handling Strategy
|
|
||||||
|
|
||||||
Unlike OpenCode/Claude which throw errors, we return errors as content:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Domain not allowed - return message, don't throw
|
|
||||||
if (!isDomainAllowed(hostname)) {
|
|
||||||
return {
|
|
||||||
output: `Domain "${hostname}" is not in the allowlist. Allowed domains: ${Array.from(DOMAIN_ALLOWLIST).join(", ")}`,
|
|
||||||
error: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This lets the agent understand the limitation and potentially find alternative approaches.
|
|
||||||
|
|
||||||
### Redirect Handling
|
|
||||||
|
|
||||||
Adopt Claude Code's approach with modification:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// If redirect crosses domains, check the new domain
|
|
||||||
if (response.redirected) {
|
|
||||||
const redirectUrl = new URL(response.url);
|
|
||||||
if (!isDomainAllowed(redirectUrl.hostname)) {
|
|
||||||
return {
|
|
||||||
output: `URL redirected to "${redirectUrl.hostname}" which is not in the allowlist.`,
|
|
||||||
error: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
|
|
||||||
Add a new `webfetch` MCP tool that fetches web content with domain-level whitelisting enforced server-side. The whitelist is configured via the payload (from GitHub App), and non-whitelisted domains return a helpful message guiding the LLM to alternative approaches.
|
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
|
|
||||||
| Aspect | OpenCode | Claude Code | Our Implementation |
|
|
||||||
|--------|----------|-------------|-------------------|
|
|
||||||
| **Whitelisting** | Permission prompt (advisory) | External API `domain_info` | Payload-configured whitelist |
|
|
||||||
| **Enforcement** | None (user approval) | Server-side check | Server-side check |
|
|
||||||
| **HTML Processing** | Turndown for markdown | Turndown for markdown | Turndown for markdown |
|
|
||||||
| **Redirects** | Follows automatically | Detects cross-host redirects | Follow with host check |
|
|
||||||
| **Timeout** | 30s default, 120s max | Configurable | 30s default, 120s max |
|
|
||||||
|
|
||||||
### Step 1: Add whitelist to payload type
|
|
||||||
|
|
||||||
Update `index.ts` to include `allowedWebFetchDomains`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Payload {
|
|
||||||
// ... existing fields
|
|
||||||
allowedWebFetchDomains?: string[]; // e.g. ["github.com", "*.npmjs.com", "docs.python.org"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create `mcp/webfetch.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Core structure
|
|
||||||
export const WebFetchParams = type({
|
|
||||||
url: "string",
|
|
||||||
"format?": "'markdown' | 'text' | 'html'",
|
|
||||||
"timeout?": "number",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function WebFetchTool(ctx: ToolContext) {
|
|
||||||
return tool({
|
|
||||||
name: "webfetch",
|
|
||||||
description: `Fetch content from whitelisted web URLs...`,
|
|
||||||
parameters: WebFetchParams,
|
|
||||||
execute: execute(async (params) => {
|
|
||||||
// 1. Validate URL format
|
|
||||||
// 2. Check domain against whitelist (from ctx.payload)
|
|
||||||
// 3. Fetch with timeout and size limits
|
|
||||||
// 4. Convert HTML to markdown if needed
|
|
||||||
// 5. Return content or guidance message
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Domain Matching Logic
|
|
||||||
|
|
||||||
Support wildcards for subdomains:
|
|
||||||
|
|
||||||
- `github.com` - exact match
|
|
||||||
- `*.github.com` - any subdomain (e.g., `docs.github.com`, `api.github.com`)
|
|
||||||
- `*.npmjs.com` - matches `www.npmjs.com`, `registry.npmjs.com`, etc.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function isDomainAllowed(hostname: string, whitelist: string[]): boolean {
|
|
||||||
for (const pattern of whitelist) {
|
|
||||||
if (pattern.startsWith("*.")) {
|
|
||||||
const suffix = pattern.slice(1); // ".github.com"
|
|
||||||
if (hostname.endsWith(suffix) || hostname === pattern.slice(2)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (hostname === pattern) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Response for Non-Whitelisted Domains
|
|
||||||
|
|
||||||
When domain is not whitelisted, return guidance (not an error):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
message: `The domain "${hostname}" is not in the allowed list for direct fetching. ` +
|
|
||||||
`Consider using web_search to find relevant information, or ask the user to ` +
|
|
||||||
`provide the content directly. Allowed domains: ${whitelist.join(", ")}`,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: HTML to Markdown Conversion
|
|
||||||
|
|
||||||
Use Turndown (same as OpenCode) for HTML-to-markdown conversion:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import TurndownService from "turndown";
|
|
||||||
|
|
||||||
function htmlToMarkdown(html: string): string {
|
|
||||||
const turndown = new TurndownService({
|
|
||||||
headingStyle: "atx",
|
|
||||||
codeBlockStyle: "fenced",
|
|
||||||
});
|
|
||||||
turndown.remove(["script", "style", "meta", "link"]);
|
|
||||||
return turndown.turndown(html);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Register the Tool
|
|
||||||
|
|
||||||
Add to `mcp/index.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { WebFetchTool } from "./webfetch.ts";
|
|
||||||
|
|
||||||
// In the tools array
|
|
||||||
WebFetchTool(ctx),
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant LLM
|
|
||||||
participant MCP as MCP Server
|
|
||||||
participant WF as WebFetch Tool
|
|
||||||
participant Web as External URL
|
|
||||||
|
|
||||||
LLM->>MCP: webfetch(url, format)
|
|
||||||
MCP->>WF: execute(params)
|
|
||||||
WF->>WF: Parse URL, extract hostname
|
|
||||||
WF->>WF: Check whitelist from payload
|
|
||||||
alt Domain allowed
|
|
||||||
WF->>Web: fetch(url)
|
|
||||||
Web-->>WF: Response
|
|
||||||
WF->>WF: Convert to markdown
|
|
||||||
WF-->>MCP: {content, contentType}
|
|
||||||
MCP-->>LLM: Success result
|
|
||||||
else Domain not allowed
|
|
||||||
WF-->>MCP: {allowed: false, guidance}
|
|
||||||
MCP-->>LLM: Guidance message
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Create/Modify
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `mcp/webfetch.ts` | Create - main tool implementation |
|
|
||||||
| `mcp/index.ts` | Modify - register the tool |
|
|
||||||
| `index.ts` | Modify - add `allowedWebFetchDomains` to payload type |
|
|
||||||
| `package.json` | Modify - add `turndown` dependency |
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
Add to `package.json`:
|
|
||||||
|
|
||||||
- `turndown` - HTML to markdown conversion (same as OpenCode)
|
|
||||||
- `@types/turndown` - TypeScript types
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Implementation Checklist
|
|
||||||
|
|
||||||
- [ ] Add `allowedWebFetchDomains` field to payload type in `index.ts`
|
|
||||||
- [ ] Create `mcp/webfetch.ts` with domain whitelisting and HTML conversion
|
|
||||||
- [ ] Register `WebFetchTool` in `mcp/index.ts`
|
|
||||||
- [ ] Add `turndown` and `@types/turndown` dependencies to `package.json`
|
|
||||||
- [ ] Test with allowed domains
|
|
||||||
- [ ] Test with blocked domains
|
|
||||||
- [ ] Test redirect behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open Questions
|
|
||||||
|
|
||||||
1. **Should we support query parameters in allowlist?**
|
|
||||||
- e.g., allow `api.example.com/v1/*` but not `api.example.com/admin/*`
|
|
||||||
- Initial decision: No, domain-level only
|
|
||||||
|
|
||||||
2. **Should we allow configurable allowlists?**
|
|
||||||
- Via environment variable or config file?
|
|
||||||
- Initial decision: No, hardcoded for simplicity
|
|
||||||
|
|
||||||
3. **Should we support authentication headers?**
|
|
||||||
- For private documentation sites
|
|
||||||
- Initial decision: No, security risk
|
|
||||||
|
|
||||||
4. **Rate limiting?**
|
|
||||||
- Prevent agent from hammering a site
|
|
||||||
- Initial decision: Rely on timeout, add if needed
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
# Web Search Functionality by Agent
|
|
||||||
|
|
||||||
This document describes how each supported agent implements web search functionality.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Agent | Tool Name | Search Provider | API/Method |
|
|
||||||
|-------|-----------|-----------------|------------|
|
|
||||||
| Claude Code | `WebSearch` | Anthropic internal | Claude Code SDK |
|
|
||||||
| Gemini CLI | `google_web_search` | Google Search via Gemini API | `generateContent` with `model: 'web-search'` |
|
|
||||||
| OpenCode | `websearch` | Exa AI | MCP protocol to `https://mcp.exa.ai/mcp` |
|
|
||||||
|
|
||||||
All three agents also support a separate **web fetch** tool for directly retrieving and parsing web page content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Claude Code
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
- `WebSearch` - Search the web for information
|
|
||||||
- `WebFetch` - Fetch and process web content
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
Native functionality through `@anthropic-ai/claude-agent-sdk` (closed source). The actual search provider is internal to Anthropic's infrastructure.
|
|
||||||
|
|
||||||
### Configuration in Pullfrog
|
|
||||||
|
|
||||||
Web search can be disabled via the `disallowedTools` option:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In sandbox mode, web tools are disabled
|
|
||||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gemini CLI
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
- `google_web_search` - Perform web searches using Google Search
|
|
||||||
- `web_fetch` - Fetch and process content from URLs
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
Source: [`packages/core/src/tools/web-search.ts`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/tools/web-search.ts)
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
1. Sends query to Gemini API using `generateContent` with `model: 'web-search'`
|
|
||||||
2. Google performs the search and returns results with grounding metadata
|
|
||||||
3. Response includes inline citations, source URLs, and titles
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const response = await geminiClient.generateContent(
|
|
||||||
{ model: 'web-search' },
|
|
||||||
[{ role: 'user', parts: [{ text: this.params.query }] }],
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Returns processed summary (not raw search results)
|
|
||||||
- Inline citations with grounding metadata
|
|
||||||
- Sources list with titles and URIs
|
|
||||||
- UTF-8 byte position handling for accurate citation insertion
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
- `query` (string, required): The search query
|
|
||||||
|
|
||||||
### Web Fetch
|
|
||||||
|
|
||||||
The `web_fetch` tool processes content from URLs:
|
|
||||||
- Uses Gemini API's `urlContext` feature
|
|
||||||
- Fallback to direct HTTP fetch with `html-to-text` conversion
|
|
||||||
- Supports up to 20 URLs per request
|
|
||||||
- Converts GitHub blob URLs to raw URLs automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenCode
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
- `websearch` - Search the web using Exa AI
|
|
||||||
- `webfetch` - Fetch and read web pages
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
Source: [`packages/opencode/src/tool/websearch.ts`](https://github.com/sst/opencode/blob/main/packages/opencode/src/tool/websearch.ts)
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
1. Calls Exa AI's MCP endpoint at `https://mcp.exa.ai/mcp`
|
|
||||||
2. Uses JSON-RPC protocol to invoke the `web_search_exa` tool
|
|
||||||
3. Parses SSE response for search results
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const searchRequest: McpSearchRequest = {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: {
|
|
||||||
name: "web_search_exa",
|
|
||||||
arguments: {
|
|
||||||
query: params.query,
|
|
||||||
type: params.type || "auto",
|
|
||||||
numResults: params.numResults || 8,
|
|
||||||
livecrawl: params.livecrawl || "fallback",
|
|
||||||
contextMaxCharacters: params.contextMaxCharacters,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Real-time web searches with content scraping
|
|
||||||
- Configurable result count (default: 8)
|
|
||||||
- Live crawl modes: `fallback` (backup if cached unavailable) or `preferred` (prioritize live crawling)
|
|
||||||
- Search types: `auto` (balanced), `fast` (quick results), `deep` (comprehensive)
|
|
||||||
- Context max characters for LLM optimization
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
- `query` (string, required): The search query
|
|
||||||
- `numResults` (number, optional): Number of results to return (default: 8)
|
|
||||||
- `livecrawl` (enum, optional): `"fallback"` | `"preferred"`
|
|
||||||
- `type` (enum, optional): `"auto"` | `"fast"` | `"deep"`
|
|
||||||
- `contextMaxCharacters` (number, optional): Maximum characters for context
|
|
||||||
|
|
||||||
### Configuration in Pullfrog
|
|
||||||
|
|
||||||
Web tools are configured via the permission config in `opencode.json`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In sandbox mode
|
|
||||||
permission: {
|
|
||||||
webfetch: "deny",
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// In normal mode
|
|
||||||
permission: {
|
|
||||||
webfetch: "allow",
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
- `OPENCODE_ENABLE_EXA` - Enable Exa web search tools (required for "zen" users)
|
|
||||||
|
|
||||||
### Web Fetch
|
|
||||||
|
|
||||||
The `webfetch` tool directly fetches URLs:
|
|
||||||
- Direct HTTP fetch with browser-like User-Agent
|
|
||||||
- HTML to Markdown conversion using Turndown
|
|
||||||
- Configurable timeout (max 120 seconds)
|
|
||||||
- 5MB response size limit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison
|
|
||||||
|
|
||||||
| Feature | Claude Code | Gemini CLI | OpenCode |
|
|
||||||
|---------|-------------|------------|----------|
|
|
||||||
| Search Provider | Anthropic | Google | Exa AI |
|
|
||||||
| Result Format | Summary | Summary + Citations | Raw content |
|
|
||||||
| URL Fetching | Yes (`WebFetch`) | Yes (`web_fetch`) | Yes (`webfetch`) |
|
|
||||||
| Grounding/Citations | Unknown | Yes | No |
|
|
||||||
| Configurable Results | No | No | Yes (numResults) |
|
|
||||||
| Search Depth Options | No | No | Yes (auto/fast/deep) |
|
|
||||||
| Live Crawling | Unknown | Fallback only | Configurable |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
In Pullfrog's sandbox mode:
|
|
||||||
- **Claude Code**: `WebSearch` and `WebFetch` are explicitly disabled via `disallowedTools`
|
|
||||||
- **Gemini CLI**: No explicit disable mechanism in the wrapper (relies on default behavior)
|
|
||||||
- **OpenCode**: `webfetch` permission set to `"deny"` in sandbox mode
|
|
||||||
|
|
||||||
For public repositories, consider the implications of web search/fetch:
|
|
||||||
- Fetched content could potentially be used to inject prompts
|
|
||||||
- Search queries might leak information about the codebase context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Implementation Plan
|
|
||||||
|
|
||||||
### Option 1: Use Native Agent Web Search (Current State)
|
|
||||||
|
|
||||||
Each agent uses its own built-in web search:
|
|
||||||
- **Pros**: No additional implementation, leverages each provider's strengths
|
|
||||||
- **Cons**: Inconsistent behavior across agents, no unified control
|
|
||||||
|
|
||||||
**Current gaps:**
|
|
||||||
- Gemini CLI has no explicit disable mechanism for web search in sandbox mode
|
|
||||||
- No unified way to configure web search across all agents
|
|
||||||
|
|
||||||
### Option 2: Unified MCP Web Search Tool
|
|
||||||
|
|
||||||
Add a `web_search` tool to the Pullfrog MCP server (`mcp/`) that all agents can use:
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp/
|
|
||||||
├── bash.ts
|
|
||||||
├── webSearch.ts # New unified web search tool
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation approach:**
|
|
||||||
|
|
||||||
1. **Create `mcp/webSearch.ts`** with a provider-agnostic interface:
|
|
||||||
```typescript
|
|
||||||
export const webSearchTool = {
|
|
||||||
name: "web_search",
|
|
||||||
description: "Search the web for information",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
query: { type: "string", description: "Search query" },
|
|
||||||
numResults: { type: "number", description: "Number of results (default: 5)" },
|
|
||||||
},
|
|
||||||
required: ["query"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Choose a search provider** (options):
|
|
||||||
- **Exa AI** - Already used by OpenCode, good LLM-optimized results
|
|
||||||
- **Tavily** - Popular for AI agents, provides search + content extraction
|
|
||||||
- **SerpAPI** - Google results via API
|
|
||||||
- **Brave Search API** - Privacy-focused alternative
|
|
||||||
|
|
||||||
3. **Add to MCP server** in `mcp/server.ts`:
|
|
||||||
```typescript
|
|
||||||
import { webSearchTool, handleWebSearch } from "./webSearch.ts";
|
|
||||||
// Register tool...
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Disable native web search** for each agent:
|
|
||||||
- Claude: Add `"WebSearch"` to `disallowedTools`
|
|
||||||
- Gemini: Add `"google_web_search"` to `excludeTools` in settings.json
|
|
||||||
- OpenCode: Set `websearch: "deny"` in permission config
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- Consistent behavior across all agents
|
|
||||||
- Centralized control for security/sandbox modes
|
|
||||||
- Can filter/sanitize results before returning to agent
|
|
||||||
- Single API key management
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- Additional API costs (search provider)
|
|
||||||
- Loses provider-specific features (e.g., Gemini's grounding metadata)
|
|
||||||
|
|
||||||
### Option 3: Hybrid Approach
|
|
||||||
|
|
||||||
Allow native web search for private repos, use MCP tool for public repos:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In agent configuration
|
|
||||||
const useNativeWebSearch = !repo.isPublic;
|
|
||||||
|
|
||||||
// Claude
|
|
||||||
disallowedTools: repo.isPublic ? ["WebSearch", "WebFetch"] : [];
|
|
||||||
|
|
||||||
// Gemini
|
|
||||||
excludeTools: repo.isPublic ? ["google_web_search", "web_fetch"] : [];
|
|
||||||
|
|
||||||
// OpenCode
|
|
||||||
permission: {
|
|
||||||
websearch: repo.isPublic ? "deny" : "allow",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then for public repos, agents would use the MCP `web_search` tool which:
|
|
||||||
- Filters sensitive queries
|
|
||||||
- Sanitizes returned content
|
|
||||||
- Logs all searches for audit
|
|
||||||
|
|
||||||
### Recommended Approach
|
|
||||||
|
|
||||||
**Short-term**: Implement Option 3 (Hybrid) with these steps:
|
|
||||||
|
|
||||||
1. [ ] Add `excludeTools: ["google_web_search"]` for Gemini in public repo mode
|
|
||||||
2. [ ] Ensure OpenCode `websearch` permission is properly set for sandbox mode
|
|
||||||
3. [ ] Document the current native web search behavior for each agent
|
|
||||||
|
|
||||||
**Medium-term**: Implement Option 2 (Unified MCP) for public repos:
|
|
||||||
|
|
||||||
1. [ ] Create `mcp/webSearch.ts` using Exa AI (consistent with OpenCode)
|
|
||||||
2. [ ] Add `EXA_API_KEY` to secrets handling
|
|
||||||
3. [ ] Register web search in MCP server
|
|
||||||
4. [ ] Disable native web search for all agents when MCP tool is available
|
|
||||||
5. [ ] Add result sanitization to prevent prompt injection
|
|
||||||
|
|
||||||
### API Key Requirements
|
|
||||||
|
|
||||||
| Provider | Environment Variable | Notes |
|
|
||||||
|----------|---------------------|-------|
|
|
||||||
| Exa AI | `EXA_API_KEY` | Already used by OpenCode |
|
|
||||||
| Tavily | `TAVILY_API_KEY` | Popular alternative |
|
|
||||||
| Brave | `BRAVE_API_KEY` | Privacy-focused |
|
|
||||||
|
|
||||||
For the unified MCP approach, only one search provider API key would be needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Implementation Plan
|
|
||||||
|
|
||||||
### Option A: Unified MCP Web Search Tool
|
|
||||||
|
|
||||||
Create a custom MCP tool that provides consistent web search across all agents.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- Consistent behavior and results across agents
|
|
||||||
- Full control over search provider and rate limiting
|
|
||||||
- Can implement caching and deduplication
|
|
||||||
- Single point for security filtering
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- Additional infrastructure (need a search API key)
|
|
||||||
- Latency from proxying through MCP server
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
1. Add `websearch` tool to `mcp/` directory
|
|
||||||
2. Integrate with a search provider (options: Exa AI, SerpAPI, Brave Search, Tavily)
|
|
||||||
3. Configure each agent to use MCP tool instead of native:
|
|
||||||
- Claude: Add to `disallowedTools` and provide via MCP
|
|
||||||
- Gemini: Use `excludeTools` in settings.json for `google_web_search`
|
|
||||||
- OpenCode: Disable native via permission config
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// mcp/websearch.ts
|
|
||||||
export const websearchTool = {
|
|
||||||
name: "websearch",
|
|
||||||
description: "Search the web for current information",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
query: { type: "string", description: "Search query" },
|
|
||||||
numResults: { type: "number", description: "Number of results (1-10)" },
|
|
||||||
},
|
|
||||||
required: ["query"],
|
|
||||||
},
|
|
||||||
handler: async ({ query, numResults = 5 }) => {
|
|
||||||
// Use Exa, Brave, or other search API
|
|
||||||
const results = await searchProvider.search(query, numResults);
|
|
||||||
return formatResults(results);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Native Tools with Configuration
|
|
||||||
|
|
||||||
Keep using each agent's native web search but add consistent configuration.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- No additional infrastructure
|
|
||||||
- Agents can use optimized native implementations
|
|
||||||
- Less latency
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- Inconsistent results across agents
|
|
||||||
- Different capabilities per agent
|
|
||||||
- Harder to control/audit searches
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
1. Add `websearch_enabled` option to payload/config
|
|
||||||
2. Update each agent wrapper:
|
|
||||||
- Claude: Toggle `WebSearch` in `disallowedTools`
|
|
||||||
- Gemini: Add `google_web_search` to `excludeTools` in settings.json
|
|
||||||
- OpenCode: Set `websearch` permission in config
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// agents/claude.ts
|
|
||||||
const disallowedTools = payload.websearchEnabled
|
|
||||||
? ["Bash"]
|
|
||||||
: ["Bash", "WebSearch", "WebFetch"];
|
|
||||||
|
|
||||||
// agents/gemini.ts
|
|
||||||
if (!payload.websearchEnabled) {
|
|
||||||
newSettings.excludeTools = [...(newSettings.excludeTools || []), "google_web_search"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// agents/opencode.ts
|
|
||||||
permission: {
|
|
||||||
websearch: payload.websearchEnabled ? "allow" : "deny",
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option C: Hybrid Approach (Recommended)
|
|
||||||
|
|
||||||
Use native tools when available, with MCP fallback for consistency.
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
1. Define a `websearch` MCP tool as fallback
|
|
||||||
2. For agents with good native search (Claude, Gemini): use native
|
|
||||||
3. For agents without (or with unreliable) search: use MCP tool
|
|
||||||
4. Add configuration to force MCP-only mode if needed
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Per-agent configuration
|
|
||||||
const agentWebSearchConfig = {
|
|
||||||
claude: { useNative: true, mcpFallback: false },
|
|
||||||
gemini: { useNative: true, mcpFallback: false },
|
|
||||||
opencode: { useNative: false, mcpFallback: true }, // Exa requires API key
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Changes by Option
|
|
||||||
|
|
||||||
| Change | Option A | Option B | Option C |
|
|
||||||
|--------|----------|----------|----------|
|
|
||||||
| New MCP tool | Yes | No | Yes |
|
|
||||||
| Search API key | Yes | No | Optional |
|
|
||||||
| Agent wrapper changes | Yes | Yes | Yes |
|
|
||||||
| Action input changes | No | Yes | Yes |
|
|
||||||
| External dependencies | Yes | No | Optional |
|
|
||||||
|
|
||||||
### Recommended Next Steps
|
|
||||||
|
|
||||||
1. **Decide on search provider** - If going with MCP approach:
|
|
||||||
- Exa AI: Already used by OpenCode, good for code-related searches
|
|
||||||
- Brave Search: Privacy-focused, good general search
|
|
||||||
- Tavily: Designed for AI agents, includes content extraction
|
|
||||||
|
|
||||||
2. **Add configuration** - New action inputs:
|
|
||||||
```yaml
|
|
||||||
websearch:
|
|
||||||
description: 'Enable web search functionality'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Implement per-agent** - Start with Option B (simplest), upgrade to C if needed
|
|
||||||
|
|
||||||
4. **Add security controls** - Query filtering, domain allowlists, rate limiting
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Effort Levels
|
|
||||||
|
|
||||||
Pullfrog supports three effort levels that control model selection and reasoning depth:
|
|
||||||
|
|
||||||
- **`nothink`** — Fast, minimal reasoning. Best for simple tasks.
|
|
||||||
- **`think`** — Balanced (default). Good for most tasks.
|
|
||||||
- **`max`** — Maximum capability. Best for complex tasks requiring deep reasoning.
|
|
||||||
|
|
||||||
The effort level can be specified via the `effort` input in `action.yml` or in the payload's `effort` field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Claude Code
|
|
||||||
|
|
||||||
Claude Code uses model selection based on effort level.
|
|
||||||
|
|
||||||
| Effort | Model | Description |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| `nothink` | `haiku` | Fast, efficient |
|
|
||||||
| `think` | `opusplan` | Opus for planning, Sonnet for execution |
|
|
||||||
| `max` | `opus` | Full Opus |
|
|
||||||
|
|
||||||
> **Future direction:** Anthropic's beta `effort` parameter (`low`/`medium`/`high`) could replace model selection, using Opus 4.5 for all tasks with effort controlling token spend. See [Anthropic Effort Docs](https://platform.claude.com/docs/en/build-with-claude/effort).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Codex (OpenAI)
|
|
||||||
|
|
||||||
Codex uses both model selection and the `modelReasoningEffort` parameter from `ThreadOptions`.
|
|
||||||
|
|
||||||
| Effort | Model | `modelReasoningEffort` | Description |
|
|
||||||
|--------|-------|------------------------|-------------|
|
|
||||||
| `nothink` | `gpt-5.1-codex-mini` | `"low"` | Smaller model, reduced reasoning |
|
|
||||||
| `think` | `gpt-5.1-codex` | default | Standard model, default reasoning |
|
|
||||||
| `max` | `gpt-5.1-codex-max` | `"high"` | Largest model, maximum reasoning |
|
|
||||||
|
|
||||||
Valid values for `modelReasoningEffort`: `"minimal"` | `"low"` | `"medium"` | `"high"`
|
|
||||||
|
|
||||||
Reference: [Codex Config Reference](https://developers.openai.com/codex/config-reference/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gemini
|
|
||||||
|
|
||||||
Gemini uses a combination of model selection and `thinkingLevel` configuration via `settings.json`.
|
|
||||||
|
|
||||||
| Effort | Model | `thinkingLevel` | Description |
|
|
||||||
|--------|-------|-----------------|-------------|
|
|
||||||
| `nothink` | `gemini-2.5-flash` | `LOW` | Fast model, minimal thinking |
|
|
||||||
| `think` | `gemini-2.5-flash` | `HIGH` | Fast model, deep thinking |
|
|
||||||
| `max` | `gemini-2.5-pro` | `HIGH` | Most capable model, deep thinking |
|
|
||||||
|
|
||||||
The `thinkingLevel` is configured via:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"modelConfig": {
|
|
||||||
"generateContentConfig": {
|
|
||||||
"thinkingConfig": {
|
|
||||||
"thinkingLevel": "LOW"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Reference: [Gemini Thinking Docs](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cursor
|
|
||||||
|
|
||||||
Cursor uses model selection via the `--model` CLI flag. Project-level configuration in `.cursor/cli.json` takes precedence if a `model` is specified there.
|
|
||||||
|
|
||||||
| Effort | Model | Description |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| `nothink` | `auto` (default) | Let Cursor select optimal model |
|
|
||||||
| `think` | `auto` (default) | Let Cursor select optimal model |
|
|
||||||
| `max` | `opus-4.5-thinking` | Claude 4.5 Opus with thinking |
|
|
||||||
|
|
||||||
**Note:** If the project has `.cursor/cli.json` with a `model` field, that model is used regardless of effort level.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenCode
|
|
||||||
|
|
||||||
OpenCode does not currently have affordances for effort-level configuration. The effort parameter is ignored.
|
|
||||||
|
|
||||||
| Effort | Behavior |
|
|
||||||
|--------|----------|
|
|
||||||
| `nothink` | No effect |
|
|
||||||
| `think` | No effect |
|
|
||||||
| `max` | No effect |
|
|
||||||
@@ -1,29 +1,15 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point for GitHub Action
|
* entry point for pullfrog/pullfrog - unified action
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { Inputs, main } from "./main.ts";
|
import { main } from "./main.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
// Change to cwd input or GITHUB_WORKSPACE (where actions/checkout puts the repo)
|
|
||||||
// JavaScript actions run from the action's directory, not the checked out repo
|
|
||||||
const cwd = core.getInput("cwd") || process.env.GITHUB_WORKSPACE;
|
|
||||||
if (cwd && process.cwd() !== cwd) {
|
|
||||||
log.debug(`changing to working directory: ${cwd}`);
|
|
||||||
process.chdir(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inputs = Inputs.assert({
|
const result = await main();
|
||||||
prompt: core.getInput("prompt", { required: true }),
|
|
||||||
effort: core.getInput("effort") || "think",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await main(inputs);
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || "Agent execution failed");
|
throw new Error(result.error || "Agent execution failed");
|
||||||
|
|||||||
+1
-1
@@ -75,4 +75,4 @@ await build({
|
|||||||
plugins: [stripShebangPlugin],
|
plugins: [stripShebangPlugin],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ Build completed successfully!");
|
console.log("» build completed successfully");
|
||||||
|
|||||||
+35
-71
@@ -1,17 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* ⚠️ NO IMPORTS except modes.ts - this file is imported by Next.js and must avoid pulling in backend code.
|
* ⚠️ LIMITED IMPORTS - this file is imported by Next.js and must avoid pulling in backend code.
|
||||||
* All shared constants, types, and data used by both the Next.js app and the action runtime live here.
|
* All shared constants, types, and data used by both the Next.js app and the action runtime live here.
|
||||||
* Other files in action/ re-export from this file for backward compatibility.
|
* Other files in action/ re-export from this file for backward compatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { Mode } from "./modes.ts";
|
|
||||||
|
|
||||||
// mcp name constant
|
// mcp name constant
|
||||||
export const ghPullfrogMcpName = "gh_pullfrog";
|
export const ghPullfrogMcpName = "gh_pullfrog";
|
||||||
|
|
||||||
export interface AgentManifest {
|
export interface AgentManifest {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** empty array means accepts any *API_KEY* env var */
|
||||||
apiKeyNames: string[];
|
apiKeyNames: string[];
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ export const agentsManifest = {
|
|||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
displayName: "OpenCode",
|
displayName: "OpenCode",
|
||||||
apiKeyNames: [], // empty array means OpenCode accepts any API_KEY from environment
|
apiKeyNames: [],
|
||||||
url: "https://opencode.ai",
|
url: "https://opencode.ai",
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, AgentManifest>;
|
} as const satisfies Record<string, AgentManifest>;
|
||||||
@@ -52,9 +52,18 @@ export const AgentName = type.enumerated(...Object.keys(agentsManifest));
|
|||||||
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
||||||
|
|
||||||
// effort level type - controls model selection and thinking level
|
// effort level type - controls model selection and thinking level
|
||||||
export const Effort = type.enumerated("nothink", "think", "max");
|
// mini = fast/minimal, auto = balanced/default, max = maximum capability
|
||||||
|
export const Effort = type.enumerated("mini", "auto", "max");
|
||||||
export type Effort = typeof Effort.infer;
|
export type Effort = typeof Effort.infer;
|
||||||
|
|
||||||
|
// tool permission types shared with server dispatch
|
||||||
|
export type ToolPermission = "disabled" | "enabled";
|
||||||
|
export type BashPermission = "disabled" | "restricted" | "enabled";
|
||||||
|
|
||||||
|
// permission level for the author who triggered the event
|
||||||
|
// matches GitHub's permission levels: admin > write > maintain > triage > read > none
|
||||||
|
export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none";
|
||||||
|
|
||||||
// base interface for common payload event fields
|
// base interface for common payload event fields
|
||||||
interface BasePayloadEvent {
|
interface BasePayloadEvent {
|
||||||
issue_number?: number;
|
issue_number?: number;
|
||||||
@@ -82,6 +91,10 @@ interface BasePayloadEvent {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
comment_ids?: number[] | "all";
|
comment_ids?: number[] | "all";
|
||||||
|
/** permission level of the user who triggered this event */
|
||||||
|
authorPermission?: AuthorPermission;
|
||||||
|
/** when true, runs silently without progress comments (e.g., auto-labeling) */
|
||||||
|
silent?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,30 +203,13 @@ interface WorkflowDispatchEvent extends BasePayloadEvent {
|
|||||||
trigger: "workflow_dispatch";
|
trigger: "workflow_dispatch";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** simplified review comment data for payload */
|
|
||||||
export interface ReviewCommentData {
|
|
||||||
id: number;
|
|
||||||
body: string;
|
|
||||||
path: string;
|
|
||||||
line: number | null;
|
|
||||||
user: string | null;
|
|
||||||
html_url: string;
|
|
||||||
in_reply_to_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FixReviewEvent extends BasePayloadEvent {
|
interface FixReviewEvent extends BasePayloadEvent {
|
||||||
trigger: "fix_review";
|
trigger: "fix_review";
|
||||||
issue_number: number;
|
issue_number: number;
|
||||||
is_pr: true;
|
is_pr: true;
|
||||||
review_id: number;
|
review_id: number;
|
||||||
/** username of the person who triggered this action */
|
/** username of the person who triggered this action - use with get_review_comments approved_by */
|
||||||
triggerer: string;
|
triggerer: string;
|
||||||
/** "all" to fix all comments, or specific comment IDs to fix */
|
|
||||||
comment_ids: number[] | "all";
|
|
||||||
/** comments the triggerer approved (via thumbs up) - these should be addressed */
|
|
||||||
approved_comments: ReviewCommentData[];
|
|
||||||
/** other comments in the review - for context only, do not address unless asked */
|
|
||||||
unapproved_comments: ReviewCommentData[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImplementPlanEvent extends BasePayloadEvent {
|
interface ImplementPlanEvent extends BasePayloadEvent {
|
||||||
@@ -245,54 +241,22 @@ export type PayloadEvent =
|
|||||||
| ImplementPlanEvent
|
| ImplementPlanEvent
|
||||||
| UnknownEvent;
|
| UnknownEvent;
|
||||||
|
|
||||||
export interface DispatchOptions {
|
// writeable payload type for building payloads
|
||||||
/**
|
export interface WriteablePayload {
|
||||||
* Sandbox mode flag - when true, restricts agent to read-only operations
|
|
||||||
* (no Write, Web, or Bash access)
|
|
||||||
*/
|
|
||||||
readonly sandbox?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When true, disables progress comment (no "leaping into action" comment, no report_progress tool)
|
|
||||||
*/
|
|
||||||
readonly disableProgressComment?: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// payload type for agent execution
|
|
||||||
export interface Payload extends DispatchOptions {
|
|
||||||
"~pullfrog": true;
|
"~pullfrog": true;
|
||||||
|
/** agent slug identifier (e.g., "claude", "codex", "gemini") */
|
||||||
/**
|
agent?: AgentName | undefined;
|
||||||
* Agent slug identifier (e.g., "claude", "codex", "gemini")
|
/** the prompt/instructions for the agent to execute (body if @pullfrog tagged + per-trigger instructions) */
|
||||||
*/
|
prompt: string;
|
||||||
readonly agent: AgentName | null;
|
/** repo-level instructions (macro-expanded server-side) */
|
||||||
|
repoInstructions?: string | undefined;
|
||||||
/**
|
/** event data from webhook payload - discriminated union based on trigger field */
|
||||||
* The prompt/instructions for the agent to execute
|
event: PayloadEvent;
|
||||||
*/
|
/** effort level for model selection (mini, auto, max) - defaults to "auto" */
|
||||||
readonly prompt: string;
|
effort?: Effort | undefined;
|
||||||
|
/** working directory for the agent */
|
||||||
/**
|
cwd?: string | undefined;
|
||||||
* Event data from webhook payload.
|
|
||||||
* Discriminated union based on trigger field.
|
|
||||||
*/
|
|
||||||
readonly event: PayloadEvent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execution mode configuration
|
|
||||||
*/
|
|
||||||
modes: readonly Mode[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effort level for model selection (nothink, think, max)
|
|
||||||
* Defaults to "think" if not specified
|
|
||||||
*/
|
|
||||||
readonly effort?: Effort;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional IDs of the issue, PR, or comment that the agent is working on
|
|
||||||
*/
|
|
||||||
readonly comment_id?: number | null;
|
|
||||||
readonly issue_id?: number | null;
|
|
||||||
readonly pr_id?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// immutable payload type for agent execution
|
||||||
|
export type Payload = Readonly<WriteablePayload>;
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
Find all markdown files in the repository and list their names from https://github.com/ShawnMorreau/cal.com/
|
Tell me a joke.
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Payload } from "../external.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* test fixture: simulates an @pullfrog mention by a non-collaborator on a public repo.
|
|
||||||
* sandbox mode is enabled, so web access and file writes should be blocked.
|
|
||||||
*
|
|
||||||
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
"~pullfrog": true,
|
|
||||||
agent: null,
|
|
||||||
prompt: `Please do the following three things:
|
|
||||||
|
|
||||||
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
|
||||||
2. Create a file called sandbox-test.txt with the content "This should fail in sandbox mode"
|
|
||||||
3. Run a bash command: echo "hello from bash" > bash-test.txt
|
|
||||||
|
|
||||||
All three of these actions should fail because you are running in sandbox mode with restricted permissions (no Web, no Write, no Bash).`,
|
|
||||||
event: {
|
|
||||||
trigger: "issue_comment_created",
|
|
||||||
comment_id: 12345,
|
|
||||||
comment_body: "@pullfrog please fetch from web and write a file",
|
|
||||||
issue_number: 1,
|
|
||||||
},
|
|
||||||
modes: [],
|
|
||||||
sandbox: true,
|
|
||||||
} satisfies Payload;
|
|
||||||
+65
-169
@@ -25499,17 +25499,24 @@ var require_src = __commonJS({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// get-installation-token/entry.ts
|
// get-installation-token/entry.ts
|
||||||
|
var core4 = __toESM(require_core(), 1);
|
||||||
|
|
||||||
|
// utils/token.ts
|
||||||
var core3 = __toESM(require_core(), 1);
|
var core3 = __toESM(require_core(), 1);
|
||||||
|
|
||||||
// utils/github.ts
|
// utils/log.ts
|
||||||
var core2 = __toESM(require_core(), 1);
|
|
||||||
import { createSign } from "node:crypto";
|
|
||||||
|
|
||||||
// utils/cli.ts
|
|
||||||
var core = __toESM(require_core(), 1);
|
var core = __toESM(require_core(), 1);
|
||||||
var import_table = __toESM(require_src(), 1);
|
var import_table = __toESM(require_src(), 1);
|
||||||
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||||
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || process.env.RUNNER_DEBUG === "1" || core.isDebug();
|
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || process.env.RUNNER_DEBUG === "1" || core.isDebug();
|
||||||
|
function formatArgs(args) {
|
||||||
|
return args.map((arg) => {
|
||||||
|
if (typeof arg === "string") return arg;
|
||||||
|
if (arg instanceof Error) return `${arg.message}
|
||||||
|
${arg.stack}`;
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
}).join(" ");
|
||||||
|
}
|
||||||
function startGroup2(name) {
|
function startGroup2(name) {
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
core.startGroup(name);
|
core.startGroup(name);
|
||||||
@@ -25588,43 +25595,8 @@ function boxString(text, options) {
|
|||||||
function box(text, options) {
|
function box(text, options) {
|
||||||
const boxContent = boxString(text, options);
|
const boxContent = boxString(text, options);
|
||||||
core.info(boxContent);
|
core.info(boxContent);
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`\`\`\`
|
|
||||||
${text}
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async function summaryTable(rows, options) {
|
function printTable(rows, options) {
|
||||||
const { title } = options || {};
|
|
||||||
const formattedRows = rows.map(
|
|
||||||
(row) => row.map((cell) => {
|
|
||||||
if (typeof cell === "string") {
|
|
||||||
return { data: cell };
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
const summary2 = core.summary;
|
|
||||||
if (title) {
|
|
||||||
summary2.addRaw(`**${title}**
|
|
||||||
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
summary2.addTable(formattedRows);
|
|
||||||
}
|
|
||||||
if (title) {
|
|
||||||
core.info(`
|
|
||||||
${title}`);
|
|
||||||
}
|
|
||||||
const tableData = formattedRows.map((row) => row.map((cell) => cell.data));
|
|
||||||
const tableText = isGitHubActions ? tableData.map((row) => row.join(" | ")).join("\n") : (0, import_table.table)(tableData);
|
|
||||||
core.info(`
|
|
||||||
${tableText}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
async function printTable(rows, options) {
|
|
||||||
const { title } = options || {};
|
const { title } = options || {};
|
||||||
const tableData = rows.map(
|
const tableData = rows.map(
|
||||||
(row) => row.map((cell) => {
|
(row) => row.map((cell) => {
|
||||||
@@ -25642,121 +25614,47 @@ ${title}`);
|
|||||||
core.info(`
|
core.info(`
|
||||||
${formatted}
|
${formatted}
|
||||||
`);
|
`);
|
||||||
if (isGitHubActions) {
|
|
||||||
if (title) {
|
|
||||||
core.summary.addRaw(`**${title}**
|
|
||||||
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
core.summary.addRaw(`\`\`\`
|
|
||||||
${formatted}
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function separator(length = 50) {
|
function separator(length = 50) {
|
||||||
const separatorText = "\u2500".repeat(length);
|
const separatorText = "\u2500".repeat(length);
|
||||||
core.info(separatorText);
|
core.info(separatorText);
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`---
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var log = {
|
var log = {
|
||||||
/**
|
/** Print info message */
|
||||||
* Print info message
|
info: (...args) => {
|
||||||
*/
|
core.info(formatArgs(args));
|
||||||
info: (message) => {
|
|
||||||
core.info(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`${message}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/** Print warning message */
|
||||||
* Print warning message
|
warning: (...args) => {
|
||||||
*/
|
core.warning(formatArgs(args));
|
||||||
warning: (message) => {
|
|
||||||
core.warning(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`\u26A0\uFE0F ${message}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/** Print error message */
|
||||||
* Print error message
|
error: (...args) => {
|
||||||
*/
|
core.error(formatArgs(args));
|
||||||
error: (message) => {
|
|
||||||
core.error(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`\u274C ${message}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/** Print success message */
|
||||||
* Print success message
|
success: (...args) => {
|
||||||
*/
|
core.info(`\xBB ${formatArgs(args)}`);
|
||||||
success: (message) => {
|
|
||||||
const successMessage = `\u2705 ${message}`;
|
|
||||||
core.info(successMessage);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`${successMessage}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/** Print debug message (only if LOG_LEVEL=debug) */
|
||||||
* Print debug message (only if LOG_LEVEL=debug)
|
debug: (...args) => {
|
||||||
*/
|
|
||||||
debug: (message) => {
|
|
||||||
if (isDebugEnabled()) {
|
if (isDebugEnabled()) {
|
||||||
if (isGitHubActions) {
|
core.info(`[DEBUG] ${formatArgs(args)}`);
|
||||||
core.info(`[DEBUG] ${message}`);
|
|
||||||
} else {
|
|
||||||
core.info(`[DEBUG] ${message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/** Print a formatted box with text */
|
||||||
* Print a formatted box with text
|
|
||||||
*/
|
|
||||||
box,
|
box,
|
||||||
/**
|
/** Print a formatted table using the table package */
|
||||||
* Add a table to GitHub Actions job summary (rich formatting)
|
|
||||||
* Only use this once at the end of execution
|
|
||||||
*/
|
|
||||||
summaryTable,
|
|
||||||
/**
|
|
||||||
* Print a formatted table using the table package
|
|
||||||
*/
|
|
||||||
table: printTable,
|
table: printTable,
|
||||||
/**
|
/** Print a separator line */
|
||||||
* Print a separator line
|
|
||||||
*/
|
|
||||||
separator,
|
separator,
|
||||||
/**
|
/** Start a collapsed group (GitHub Actions) or regular group (local) */
|
||||||
* Write all accumulated summary content to the job summary
|
|
||||||
* Call this at the end of execution to finalize the summary
|
|
||||||
*/
|
|
||||||
writeSummary: async () => {
|
|
||||||
if (isGitHubActions) {
|
|
||||||
await core.summary.write();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
|
||||||
*/
|
|
||||||
startGroup: startGroup2,
|
startGroup: startGroup2,
|
||||||
/**
|
/** End a collapsed group */
|
||||||
* End a collapsed group
|
|
||||||
*/
|
|
||||||
endGroup: endGroup2,
|
endGroup: endGroup2,
|
||||||
/**
|
/** Run a callback within a collapsed group */
|
||||||
* Run a callback within a collapsed group
|
|
||||||
*/
|
|
||||||
group,
|
group,
|
||||||
/**
|
/** Log tool call information to console with formatted output */
|
||||||
* Log tool call information to console with formatted output
|
|
||||||
*/
|
|
||||||
toolCall: ({ toolName, input }) => {
|
toolCall: ({ toolName, input }) => {
|
||||||
const inputFormatted = formatJsonValue(input);
|
const inputFormatted = formatJsonValue(input);
|
||||||
const timestamp = isDebugEnabled() ? ` [${(/* @__PURE__ */ new Date()).toISOString()}]` : "";
|
const timestamp = isDebugEnabled() ? ` [${(/* @__PURE__ */ new Date()).toISOString()}]` : "";
|
||||||
@@ -25769,6 +25667,10 @@ function formatJsonValue(value) {
|
|||||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// utils/github.ts
|
||||||
|
var core2 = __toESM(require_core(), 1);
|
||||||
|
import { createSign } from "node:crypto";
|
||||||
|
|
||||||
// utils/retry.ts
|
// utils/retry.ts
|
||||||
var defaultShouldRetry = (error2) => {
|
var defaultShouldRetry = (error2) => {
|
||||||
if (!(error2 instanceof Error)) return false;
|
if (!(error2 instanceof Error)) return false;
|
||||||
@@ -25945,6 +25847,19 @@ async function acquireNewToken(opts) {
|
|||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function parseRepoContext() {
|
||||||
|
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||||
|
if (!githubRepo) {
|
||||||
|
throw new Error("GITHUB_REPOSITORY environment variable is required");
|
||||||
|
}
|
||||||
|
const [owner, name] = githubRepo.split("/");
|
||||||
|
if (!owner || !name) {
|
||||||
|
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
|
||||||
|
}
|
||||||
|
return { owner, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// utils/token.ts
|
||||||
async function revokeGitHubInstallationToken(token) {
|
async function revokeGitHubInstallationToken(token) {
|
||||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||||
try {
|
try {
|
||||||
@@ -25963,52 +25878,33 @@ async function revokeGitHubInstallationToken(token) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function parseRepoContext() {
|
|
||||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
|
||||||
if (!githubRepo) {
|
|
||||||
throw new Error("GITHUB_REPOSITORY environment variable is required");
|
|
||||||
}
|
|
||||||
const [owner, name] = githubRepo.split("/");
|
|
||||||
if (!owner || !name) {
|
|
||||||
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
|
|
||||||
}
|
|
||||||
return { owner, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
// get-installation-token/token.ts
|
|
||||||
async function acquireInstallationToken(opts) {
|
|
||||||
return acquireNewToken(opts);
|
|
||||||
}
|
|
||||||
async function revokeInstallationToken(token) {
|
|
||||||
return revokeGitHubInstallationToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// get-installation-token/entry.ts
|
// get-installation-token/entry.ts
|
||||||
var STATE_TOKEN = "token";
|
var STATE_TOKEN = "token";
|
||||||
var STATE_IS_POST = "isPost";
|
var STATE_IS_POST = "isPost";
|
||||||
async function main() {
|
async function main() {
|
||||||
core3.saveState(STATE_IS_POST, "true");
|
core4.saveState(STATE_IS_POST, "true");
|
||||||
const reposInput = core3.getInput("repos");
|
const reposInput = core4.getInput("repos");
|
||||||
const additionalRepos = reposInput ? reposInput.split(",").map((r) => r.trim()).filter(Boolean) : [];
|
const additionalRepos = reposInput ? reposInput.split(",").map((r) => r.trim()).filter(Boolean) : [];
|
||||||
const token = await acquireInstallationToken({ repos: additionalRepos });
|
const token = await acquireNewToken({ repos: additionalRepos });
|
||||||
core3.setSecret(token);
|
core4.setSecret(token);
|
||||||
core3.saveState(STATE_TOKEN, token);
|
core4.saveState(STATE_TOKEN, token);
|
||||||
core3.setOutput("token", token);
|
core4.setOutput("token", token);
|
||||||
const scope = additionalRepos.length ? `current repo + ${additionalRepos.join(", ")}` : "current repo only";
|
const scope = additionalRepos.length ? `current repo + ${additionalRepos.join(", ")}` : "current repo only";
|
||||||
core3.info(`\xBB installation token acquired (${scope})`);
|
core4.info(`\xBB installation token acquired (${scope})`);
|
||||||
}
|
}
|
||||||
async function post() {
|
async function post() {
|
||||||
const token = core3.getState(STATE_TOKEN);
|
const token = core4.getState(STATE_TOKEN);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
core3.debug("no token found in state, skipping revocation");
|
core4.debug("no token found in state, skipping revocation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await revokeInstallationToken(token);
|
await revokeGitHubInstallationToken(token);
|
||||||
core3.info("\xBB installation token revoked");
|
core4.info("\xBB installation token revoked");
|
||||||
}
|
}
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
const isPost = core3.getState(STATE_IS_POST) === "true";
|
const isPost = core4.getState(STATE_IS_POST) === "true";
|
||||||
if (isPost) {
|
if (isPost) {
|
||||||
await post();
|
await post();
|
||||||
} else {
|
} else {
|
||||||
@@ -26016,7 +25912,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
} catch (error2) {
|
} catch (error2) {
|
||||||
const message = error2 instanceof Error ? error2.message : String(error2);
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
||||||
core3.setFailed(message);
|
core4.setFailed(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await run();
|
await run();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { acquireInstallationToken, revokeInstallationToken } from "./token.ts";
|
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
|
||||||
|
|
||||||
const STATE_TOKEN = "token";
|
const STATE_TOKEN = "token";
|
||||||
const STATE_IS_POST = "isPost";
|
const STATE_IS_POST = "isPost";
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* token acquisition and revocation for get-installation-token action.
|
|
||||||
* reuses the existing github.ts utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { acquireNewToken, revokeGitHubInstallationToken } from "../utils/github.ts";
|
|
||||||
|
|
||||||
export async function acquireInstallationToken(opts?: { repos?: string[] }): Promise<string> {
|
|
||||||
return acquireNewToken(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeInstallationToken(token: string): Promise<void> {
|
|
||||||
return revokeGitHubInstallationToken(token);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* This exports the main function for programmatic usage
|
* This exports the main function for programmatic usage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
|
export type { Agent, AgentRunContext, AgentResult } from "./agents/shared.ts";
|
||||||
export {
|
export {
|
||||||
type Inputs as ExecutionInputs,
|
type Inputs as ExecutionInputs,
|
||||||
type MainResult,
|
type MainResult,
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
import { mkdtemp } from "node:fs/promises";
|
import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
|
||||||
import { tmpdir } from "node:os";
|
import { initToolState, startMcpHttpServer } from "./mcp/server.ts";
|
||||||
import { join } from "node:path";
|
import { computeModes } from "./modes.ts";
|
||||||
import { flatMorph } from "@ark/util";
|
import { resolveAgent } from "./utils/agent.ts";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import { validateApiKey } from "./utils/apiKeys.ts";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { log, writeSummary } from "./utils/cli.ts";
|
||||||
import { type } from "arktype";
|
|
||||||
import { type Agent, agents } from "./agents/index.ts";
|
|
||||||
import type { AgentResult } from "./agents/shared.ts";
|
|
||||||
import { type AgentName, agentsManifest, Effort, type Payload } from "./external.ts";
|
|
||||||
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
|
||||||
import { createMcpConfigs } from "./mcp/config.ts";
|
|
||||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
|
||||||
import { getModes, type Mode, modes } from "./modes.ts";
|
|
||||||
import packageJson from "./package.json" with { type: "json" };
|
|
||||||
import type { PrepResult } from "./prep/index.ts";
|
|
||||||
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
|
||||||
import { log } from "./utils/cli.ts";
|
|
||||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||||
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
import { createOctokit } from "./utils/github.ts";
|
||||||
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
import { resolveInstructions } from "./utils/instructions.ts";
|
||||||
|
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||||
|
import { resolvePayload } from "./utils/payload.ts";
|
||||||
|
import { resolveRepoData } from "./utils/repoData.ts";
|
||||||
|
import { handleAgentResult } from "./utils/run.ts";
|
||||||
|
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||||
import { Timer } from "./utils/timer.ts";
|
import { Timer } from "./utils/timer.ts";
|
||||||
|
import { resolveInstallationToken } from "./utils/token.ts";
|
||||||
|
import { resolveRun } from "./utils/workflow.ts";
|
||||||
|
|
||||||
export const Inputs = type({
|
export { Inputs } from "./utils/payload.ts";
|
||||||
prompt: "string",
|
|
||||||
"effort?": Effort,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Inputs = typeof Inputs.infer;
|
|
||||||
|
|
||||||
export interface MainResult {
|
export interface MainResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -34,162 +24,96 @@ export interface MainResult {
|
|||||||
error?: string | undefined;
|
error?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// intermediate result types for deterministic context building
|
export async function main(): Promise<MainResult> {
|
||||||
interface GitHubSetup {
|
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
||||||
owner: string;
|
normalizeEnv();
|
||||||
name: string;
|
|
||||||
octokit: Octokit;
|
|
||||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
|
||||||
repoSettings: RepoSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKeySetup =
|
// store original GITHUB_TOKEN
|
||||||
| { success: true; apiKey: string; apiKeys: Record<string, string> }
|
process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
|
||||||
const timer = new Timer();
|
const timer = new Timer();
|
||||||
await using tokenRef = await setupGitHubInstallationToken();
|
await using tokenRef = await resolveInstallationToken();
|
||||||
let payload: Payload | undefined;
|
process.env.GITHUB_TOKEN = tokenRef.token;
|
||||||
|
|
||||||
|
const octokit = createOctokit(tokenRef.token);
|
||||||
|
const runInfo = await resolveRun({ octokit });
|
||||||
|
const toolState = initToolState({ runInfo });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// phase 1: parse and validate inputs
|
const repo = await resolveRepoData({ octokit, token: tokenRef.token });
|
||||||
payload = parsePayload(inputs);
|
timer.checkpoint("repoData");
|
||||||
Inputs.assert(inputs);
|
|
||||||
setupGitConfig();
|
|
||||||
|
|
||||||
// phase 2: fast setup (github + temp dir)
|
// resolve payload after repoData so permissions can use DB settings
|
||||||
const [githubSetup, sharedTempDir] = await Promise.all([
|
// precedence: action inputs > json payload > repoSettings > fallbacks
|
||||||
initializeGitHub(tokenRef.token),
|
const payload = resolvePayload(repo.repoSettings);
|
||||||
createTempDirectory(),
|
if (payload.cwd && process.cwd() !== payload.cwd) {
|
||||||
]);
|
process.chdir(payload.cwd);
|
||||||
timer.checkpoint("githubSetup");
|
}
|
||||||
|
|
||||||
// phase 3: resolve agent (needs repo settings)
|
const tmpdir = createTempDirectory();
|
||||||
const agent = resolveAgent({
|
|
||||||
payload,
|
|
||||||
repoSettings: githubSetup.repoSettings,
|
|
||||||
});
|
|
||||||
const resolvedPayload = { ...payload, agent: agent.name };
|
|
||||||
|
|
||||||
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
|
const agent = resolveAgent({ payload, repoSettings: repo.repoSettings });
|
||||||
const apiKeySetup = validateApiKey({
|
|
||||||
|
validateApiKey({
|
||||||
agent,
|
agent,
|
||||||
owner: githubSetup.owner,
|
owner: repo.owner,
|
||||||
name: githubSetup.name,
|
name: repo.name,
|
||||||
});
|
});
|
||||||
if (!apiKeySetup.success) {
|
|
||||||
await reportErrorToComment({ error: apiKeySetup.error });
|
|
||||||
return { success: false, error: apiKeySetup.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase 5: parallel long-running operations (agent install + git auth)
|
await setupGit({
|
||||||
const toolState: ToolState = {};
|
token: tokenRef.token,
|
||||||
const [cliPath] = await Promise.all([
|
owner: repo.owner,
|
||||||
installAgentCli({ agent, token: tokenRef.token }),
|
name: repo.name,
|
||||||
setupGitAuth({
|
event: payload.event,
|
||||||
token: tokenRef.token,
|
octokit,
|
||||||
owner: githubSetup.owner,
|
|
||||||
name: githubSetup.name,
|
|
||||||
payload: resolvedPayload,
|
|
||||||
octokit: githubSetup.octokit,
|
|
||||||
toolState,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
timer.checkpoint("agentSetup+gitAuth");
|
|
||||||
|
|
||||||
// phase 6: compute modes
|
|
||||||
const computedModes: Mode[] = [
|
|
||||||
...getModes({
|
|
||||||
disableProgressComment: resolvedPayload.disableProgressComment,
|
|
||||||
}),
|
|
||||||
...(resolvedPayload.modes || []),
|
|
||||||
];
|
|
||||||
|
|
||||||
// phase 7: compute runId/jobId for MCP tools
|
|
||||||
const runId = process.env.GITHUB_RUN_ID || "";
|
|
||||||
if (runId) {
|
|
||||||
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
|
||||||
if (workflowRunInfo.progressCommentId) {
|
|
||||||
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
|
||||||
log.info(`📝 Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let jobId: string | undefined;
|
|
||||||
const jobName = process.env.GITHUB_JOB;
|
|
||||||
if (jobName && runId) {
|
|
||||||
const jobs = await githubSetup.octokit.rest.actions.listJobsForWorkflowRun({
|
|
||||||
owner: githubSetup.owner,
|
|
||||||
repo: githubSetup.name,
|
|
||||||
run_id: parseInt(runId, 10),
|
|
||||||
});
|
|
||||||
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
|
||||||
if (matchingJob) {
|
|
||||||
jobId = String(matchingJob.id);
|
|
||||||
log.info(`📋 Found job ID: ${jobId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase 8: build tool context and start MCP server
|
|
||||||
const toolContext: ToolContext = {
|
|
||||||
owner: githubSetup.owner,
|
|
||||||
name: githubSetup.name,
|
|
||||||
githubInstallationToken: tokenRef.token,
|
|
||||||
octokit: githubSetup.octokit,
|
|
||||||
payload: resolvedPayload,
|
|
||||||
repo: githubSetup.repo,
|
|
||||||
repoSettings: githubSetup.repoSettings,
|
|
||||||
modes: computedModes,
|
|
||||||
toolState,
|
toolState,
|
||||||
|
});
|
||||||
|
timer.checkpoint("git");
|
||||||
|
|
||||||
|
const modes = [...computeModes(), ...repo.repoSettings.modes];
|
||||||
|
|
||||||
|
await using mcpHttpServer = await startMcpHttpServer({
|
||||||
|
repo,
|
||||||
|
payload,
|
||||||
|
octokit,
|
||||||
|
githubInstallationToken: tokenRef.token,
|
||||||
agent,
|
agent,
|
||||||
sharedTempDir,
|
modes,
|
||||||
runId,
|
toolState,
|
||||||
jobId,
|
runId: runInfo.runId,
|
||||||
};
|
jobId: runInfo.jobId,
|
||||||
|
});
|
||||||
await using mcpHttpServer = await startMcpHttpServer(toolContext);
|
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
||||||
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`);
|
|
||||||
|
|
||||||
const mcpServers = createMcpConfigs(mcpHttpServer.url);
|
|
||||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
|
||||||
timer.checkpoint("mcpServer");
|
timer.checkpoint("mcpServer");
|
||||||
|
|
||||||
// BUILD FINAL IMMUTABLE CONTEXT
|
const instructions = resolveInstructions({
|
||||||
const ctx: AgentContext = {
|
payload,
|
||||||
...toolContext,
|
repoData: repo,
|
||||||
inputs,
|
modes,
|
||||||
mcpServerUrl: mcpHttpServer.url,
|
});
|
||||||
mcpServers,
|
|
||||||
cliPath,
|
|
||||||
apiKey: apiKeySetup.apiKey,
|
|
||||||
apiKeys: apiKeySetup.apiKeys,
|
|
||||||
};
|
|
||||||
|
|
||||||
// check for empty comment_ids in fix_review trigger - report and exit early
|
const result = await agent.run({
|
||||||
if (
|
payload,
|
||||||
ctx.payload.event.trigger === "fix_review" &&
|
mcpServerUrl: mcpHttpServer.url,
|
||||||
Array.isArray(ctx.payload.event.comment_ids) &&
|
tmpdir,
|
||||||
ctx.payload.event.comment_ids.length === 0
|
instructions,
|
||||||
) {
|
});
|
||||||
const noThumbsMessage = `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`;
|
|
||||||
log.error(noThumbsMessage);
|
// write last progress body to job summary
|
||||||
await reportProgress(ctx, { body: noThumbsMessage });
|
if (toolState.lastProgressBody) {
|
||||||
return { success: true };
|
writeSummary(toolState.lastProgressBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runAgent(ctx);
|
|
||||||
const mainResult = await handleAgentResult(result);
|
const mainResult = await handleAgentResult(result);
|
||||||
return mainResult;
|
return mainResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
log.error(errorMessage);
|
log.error(errorMessage);
|
||||||
try {
|
try {
|
||||||
await reportErrorToComment({ error: errorMessage });
|
await reportErrorToComment({ toolState, error: errorMessage });
|
||||||
} catch {
|
} catch {
|
||||||
// error reporting failed, but don't let it mask the original error
|
// error reporting failed, but don't let it mask the original error
|
||||||
}
|
}
|
||||||
await log.writeSummary();
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
@@ -198,320 +122,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
// ensure progress comment is updated if it was never updated during execution
|
// ensure progress comment is updated if it was never updated during execution
|
||||||
// do this before revoking the token so we can still make API calls
|
// do this before revoking the token so we can still make API calls
|
||||||
try {
|
try {
|
||||||
await ensureProgressCommentUpdated(payload);
|
await ensureProgressCommentUpdated(toolState);
|
||||||
} catch {
|
} catch {
|
||||||
// error updating comment, but don't let it mask the original error
|
// error updating comment, but don't let it mask the original error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an agent has API keys available (from process.env)
|
|
||||||
*/
|
|
||||||
function agentHasApiKeys(agent: Agent): boolean {
|
|
||||||
if (agent.name === "opencode") {
|
|
||||||
// opencode accepts any API_KEY from environment
|
|
||||||
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
|
||||||
}
|
|
||||||
// check if any of the agent's expected keys are in environment
|
|
||||||
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailableAgents(): Agent[] {
|
|
||||||
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all possible API key names from agentsManifest using flatMorph
|
|
||||||
*/
|
|
||||||
function getAllPossibleKeyNames(): string[] {
|
|
||||||
return Object.keys(
|
|
||||||
flatMorph(agentsManifest, (_, manifest) =>
|
|
||||||
manifest.apiKeyNames.map((keyName) => [keyName, true] as const)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a helpful error message for missing API key with links to repo settings
|
|
||||||
*/
|
|
||||||
function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
|
||||||
const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
|
|
||||||
|
|
||||||
const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
|
|
||||||
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
|
||||||
|
|
||||||
// for OpenCode, use a generic message since it accepts any API key
|
|
||||||
const isOpenCode = params.agent.name === "opencode";
|
|
||||||
let secretNameList: string;
|
|
||||||
if (isOpenCode) {
|
|
||||||
secretNameList =
|
|
||||||
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
|
|
||||||
} else {
|
|
||||||
const inputKeys =
|
|
||||||
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
|
|
||||||
const secretNames = inputKeys.map((key) => `\`${key}\``);
|
|
||||||
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Pullfrog is configured to use ${params.agent.displayName}, but the associated API key was not provided.
|
|
||||||
|
|
||||||
To fix this, add the required secret to your GitHub repository:
|
|
||||||
|
|
||||||
1. Go to: ${githubSecretsUrl}
|
|
||||||
2. Click "New repository secret"
|
|
||||||
3. Set the name to ${secretNameList}
|
|
||||||
4. Set the value to your API key
|
|
||||||
5. Click "Add secret"
|
|
||||||
|
|
||||||
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tool context - subset of Context needed by MCP tools
|
|
||||||
export interface ToolContext {
|
|
||||||
owner: string;
|
|
||||||
name: string;
|
|
||||||
githubInstallationToken: string;
|
|
||||||
octokit: Octokit;
|
|
||||||
payload: Payload;
|
|
||||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
|
||||||
repoSettings: RepoSettings;
|
|
||||||
modes: Mode[];
|
|
||||||
toolState: ToolState;
|
|
||||||
agent: Agent;
|
|
||||||
sharedTempDir: string;
|
|
||||||
runId: string;
|
|
||||||
jobId: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentContext extends Readonly<ToolContext> {
|
|
||||||
readonly inputs: Inputs;
|
|
||||||
readonly mcpServerUrl: string;
|
|
||||||
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
|
|
||||||
readonly cliPath: string;
|
|
||||||
readonly apiKey: string;
|
|
||||||
readonly apiKeys: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DependencyInstallationState {
|
|
||||||
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
||||||
promise: Promise<PrepResult[]> | undefined;
|
|
||||||
results: PrepResult[] | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolState {
|
|
||||||
prNumber?: number;
|
|
||||||
issueNumber?: number;
|
|
||||||
selectedMode?: string;
|
|
||||||
review?: {
|
|
||||||
id: number; // REST API database ID (for fix URLs)
|
|
||||||
nodeId: string; // GraphQL node ID (for mutations)
|
|
||||||
};
|
|
||||||
dependencyInstallation?: DependencyInstallationState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize GitHub connection: token, octokit, repo data, settings
|
|
||||||
*/
|
|
||||||
async function initializeGitHub(token: string): Promise<GitHubSetup> {
|
|
||||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
|
||||||
|
|
||||||
const { owner, name } = parseRepoContext();
|
|
||||||
|
|
||||||
const octokit = createOctokit(token);
|
|
||||||
|
|
||||||
// fetch repo data and settings in parallel
|
|
||||||
const [repoResponse, repoSettings] = await Promise.all([
|
|
||||||
octokit.repos.get({ owner, repo: name }),
|
|
||||||
fetchRepoSettings({ token, repoContext: { owner, name } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
octokit,
|
|
||||||
repo: repoResponse.data,
|
|
||||||
repoSettings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgent({
|
|
||||||
payload,
|
|
||||||
repoSettings,
|
|
||||||
}: {
|
|
||||||
payload: Payload;
|
|
||||||
repoSettings: RepoSettings;
|
|
||||||
}): Agent {
|
|
||||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
|
||||||
log.debug(
|
|
||||||
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`
|
|
||||||
);
|
|
||||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
|
||||||
|
|
||||||
if (configuredAgentName) {
|
|
||||||
const agent = agents[configuredAgentName];
|
|
||||||
if (!agent) {
|
|
||||||
throw new Error(`invalid agent name: ${configuredAgentName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if explicitly configured (via override or payload), respect it even without matching keys
|
|
||||||
// this allows users to force an agent selection (will fail later with clear error if no keys)
|
|
||||||
const isExplicitOverride = agentOverride !== undefined || payload.agent !== null;
|
|
||||||
if (isExplicitOverride) {
|
|
||||||
log.info(`Selected configured agent: ${agent.name}`);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// for repo-level defaults, check if agent has matching keys before selecting
|
|
||||||
if (agentHasApiKeys(agent)) {
|
|
||||||
log.info(`Selected configured agent: ${agent.name}`);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// fall through to auto-selection
|
|
||||||
const availableAgents = getAvailableAgents();
|
|
||||||
log.warning(
|
|
||||||
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
|
||||||
availableAgents.map((a) => a.name).join(", ") || "none"
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableAgents = getAvailableAgents();
|
|
||||||
if (availableAgents.length === 0) {
|
|
||||||
throw new Error("no agents available - missing API keys");
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = availableAgents[0];
|
|
||||||
log.info(`No agent configured, defaulting to first available agent: ${agent.name}`);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTempDirectory(): Promise<string> {
|
|
||||||
const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
|
||||||
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
|
|
||||||
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`);
|
|
||||||
return sharedTempDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayload(inputs: Inputs): Payload {
|
|
||||||
try {
|
|
||||||
const parsedPrompt = JSON.parse(inputs.prompt);
|
|
||||||
if (!("~pullfrog" in parsedPrompt)) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
// internal invocation: use effort from payload, fallback to input, default to "think"
|
|
||||||
return {
|
|
||||||
...parsedPrompt,
|
|
||||||
effort: parsedPrompt.effort ?? inputs.effort ?? "think",
|
|
||||||
} as Payload;
|
|
||||||
} catch {
|
|
||||||
// external invocation: use effort from input
|
|
||||||
return {
|
|
||||||
"~pullfrog": true,
|
|
||||||
agent: null,
|
|
||||||
prompt: inputs.prompt,
|
|
||||||
event: {
|
|
||||||
trigger: "unknown",
|
|
||||||
},
|
|
||||||
modes,
|
|
||||||
effort: inputs.effort ?? "think",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installAgentCli(params: { agent: Agent; token: string }): Promise<string> {
|
|
||||||
// gemini is the only agent that needs githubInstallationToken for install
|
|
||||||
if (params.agent.name === "gemini") {
|
|
||||||
return params.agent.install(params.token);
|
|
||||||
}
|
|
||||||
return params.agent.install();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectApiKeys(agent: Agent): Record<string, string> {
|
|
||||||
const apiKeys: Record<string, string> = {};
|
|
||||||
|
|
||||||
// read API keys from environment variables
|
|
||||||
for (const envKey of agent.apiKeyNames) {
|
|
||||||
const value = process.env[envKey];
|
|
||||||
if (value) {
|
|
||||||
apiKeys[envKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for OpenCode: check process.env for any API_KEY variables
|
|
||||||
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
|
||||||
apiKeys[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
|
|
||||||
const apiKeys = collectApiKeys(params.agent);
|
|
||||||
|
|
||||||
if (Object.keys(apiKeys).length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: buildMissingApiKeyError({
|
|
||||||
agent: params.agent,
|
|
||||||
owner: params.owner,
|
|
||||||
name: params.name,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
apiKey: Object.values(apiKeys)[0],
|
|
||||||
apiKeys,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|
||||||
const effort = ctx.payload.effort ?? "think";
|
|
||||||
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
|
|
||||||
// strip context from event
|
|
||||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
|
||||||
// format: prompt + two newlines + TOON encoded event
|
|
||||||
const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`;
|
|
||||||
log.box(promptContent, { title: "Prompt" });
|
|
||||||
|
|
||||||
return ctx.agent.run({
|
|
||||||
payload: ctx.payload,
|
|
||||||
mcpServers: ctx.mcpServers,
|
|
||||||
apiKey: ctx.apiKey,
|
|
||||||
apiKeys: ctx.apiKeys,
|
|
||||||
cliPath: ctx.cliPath,
|
|
||||||
repo: {
|
|
||||||
owner: ctx.owner,
|
|
||||||
name: ctx.name,
|
|
||||||
defaultBranch: ctx.repo.default_branch,
|
|
||||||
isPublic: !ctx.repo.private,
|
|
||||||
},
|
|
||||||
effort,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAgentResult(result: AgentResult): Promise<MainResult> {
|
|
||||||
if (!result.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: result.error || "Agent execution failed",
|
|
||||||
output: result.output!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
log.success("Task complete.");
|
|
||||||
await log.writeSummary();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
output: result.output || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
+110
-14
@@ -1,6 +1,9 @@
|
|||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, type StdioOptions, spawn } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { closeSync, openSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const BashParams = type({
|
export const BashParams = type({
|
||||||
@@ -8,6 +11,7 @@ export const BashParams = type({
|
|||||||
description: "string",
|
description: "string",
|
||||||
"timeout?": "number",
|
"timeout?": "number",
|
||||||
"working_directory?": "string",
|
"working_directory?": "string",
|
||||||
|
"background?": "boolean",
|
||||||
});
|
});
|
||||||
|
|
||||||
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||||
@@ -26,24 +30,33 @@ function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
|||||||
if (isPublicRepo && isSensitive(key)) continue;
|
if (isPublicRepo && isSensitive(key)) continue;
|
||||||
filtered[key] = value;
|
filtered[key] = value;
|
||||||
}
|
}
|
||||||
|
// restore original GITHUB_TOKEN (the one set by GitHub Actions, not our installation token)
|
||||||
|
// this allows git operations in subprocesses to work while keeping our installation token secure
|
||||||
|
if (process.env.ORIGINAL_GITHUB_TOKEN) {
|
||||||
|
filtered.GITHUB_TOKEN = process.env.ORIGINAL_GITHUB_TOKEN;
|
||||||
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpawnSandboxedParams = {
|
||||||
|
command: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
cwd: string;
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
stdio: StdioOptions;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* spawn command with filtered env. in CI, also use PID namespace isolation
|
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||||
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||||
*/
|
*/
|
||||||
function spawnSandboxed(
|
function spawnSandboxed(params: SpawnSandboxedParams): ChildProcess {
|
||||||
command: string,
|
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||||
options: { env: Record<string, string>; cwd: string; isPublicRepo: boolean }
|
|
||||||
): ChildProcess {
|
|
||||||
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
|
||||||
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
|
||||||
// only use PID namespace isolation for public repos in CI
|
// only use PID namespace isolation for public repos in CI
|
||||||
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo;
|
||||||
return useNamespaceIsolation
|
return useNamespaceIsolation
|
||||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
|
||||||
: spawn("bash", ["-c", command], spawnOpts);
|
: spawn("bash", ["-c", params.command], spawnOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** kill process and its entire process group */
|
/** kill process and its entire process group */
|
||||||
@@ -62,8 +75,16 @@ async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTempDir(): string {
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
|
if (!tempDir) {
|
||||||
|
throw new Error("PULLFROG_TEMP_DIR not set");
|
||||||
|
}
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
export function BashTool(ctx: ToolContext) {
|
export function BashTool(ctx: ToolContext) {
|
||||||
const isPublicRepo = !ctx.repo.private;
|
const isPublicRepo = !ctx.repo.repo.private;
|
||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
name: "bash",
|
name: "bash",
|
||||||
@@ -79,10 +100,46 @@ Use this tool to:
|
|||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||||
const cwd = params.working_directory ?? process.cwd();
|
const cwd = params.working_directory ?? process.cwd();
|
||||||
const proc = spawnSandboxed(params.command, {
|
const env = filterEnv(isPublicRepo);
|
||||||
env: filterEnv(isPublicRepo),
|
|
||||||
|
if (params.background) {
|
||||||
|
const tempDir = getTempDir();
|
||||||
|
const handle = `bg-${randomUUID().slice(0, 8)}`;
|
||||||
|
const outputPath = join(tempDir, `${handle}.log`);
|
||||||
|
const pidPath = join(tempDir, `${handle}.pid`);
|
||||||
|
const logFd = openSync(outputPath, "a");
|
||||||
|
let proc: ChildProcess;
|
||||||
|
try {
|
||||||
|
proc = spawnSandboxed({
|
||||||
|
command: params.command,
|
||||||
|
env,
|
||||||
|
cwd,
|
||||||
|
isPublicRepo,
|
||||||
|
stdio: ["ignore", logFd, logFd],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
closeSync(logFd);
|
||||||
|
}
|
||||||
|
if (!proc.pid) {
|
||||||
|
throw new Error("failed to start background process");
|
||||||
|
}
|
||||||
|
proc.unref();
|
||||||
|
writeFileSync(pidPath, `${proc.pid}\n`);
|
||||||
|
ctx.toolState.backgroundProcesses.set(handle, { pid: proc.pid, outputPath, pidPath });
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
outputPath,
|
||||||
|
pidPath,
|
||||||
|
message: `started background process ${handle} (pid ${proc.pid})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawnSandboxed({
|
||||||
|
command: params.command,
|
||||||
|
env,
|
||||||
cwd,
|
cwd,
|
||||||
isPublicRepo,
|
isPublicRepo,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = "",
|
let stdout = "",
|
||||||
@@ -127,3 +184,42 @@ Use this tool to:
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const KillBackgroundParams = type({
|
||||||
|
handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function KillBackgroundTool(ctx: ToolContext) {
|
||||||
|
return tool({
|
||||||
|
name: "kill_background",
|
||||||
|
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`,
|
||||||
|
parameters: KillBackgroundParams,
|
||||||
|
execute: execute(async (params) => {
|
||||||
|
const proc = ctx.toolState.backgroundProcesses.get(params.handle);
|
||||||
|
if (!proc) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `no background process with handle ${params.handle}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.toolState.backgroundProcesses.delete(params.handle);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `killed background process ${params.handle} (pid ${proc.pid})`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetCheckSuiteLogs = type({
|
export const GetCheckSuiteLogs = type({
|
||||||
@@ -17,8 +17,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
|||||||
const workflowRuns = await ctx.octokit.paginate(
|
const workflowRuns = await ctx.octokit.paginate(
|
||||||
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
||||||
{
|
{
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
check_suite_id,
|
check_suite_id,
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
|||||||
const logsForRuns = await Promise.all(
|
const logsForRuns = await Promise.all(
|
||||||
failedRuns.map(async (run) => {
|
failedRuns.map(async (run) => {
|
||||||
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
run_id: run.id,
|
run_id: run.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
|||||||
jobs.map(async (job) => {
|
jobs.map(async (job) => {
|
||||||
try {
|
try {
|
||||||
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -2,9 +2,9 @@ import { writeFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||||
@@ -118,7 +118,7 @@ export async function checkoutPrBranch(
|
|||||||
params: CheckoutPrBranchParams
|
params: CheckoutPrBranchParams
|
||||||
): Promise<CheckoutPrBranchResult> {
|
): Promise<CheckoutPrBranchResult> {
|
||||||
const { octokit, owner, name, token, pullNumber } = params;
|
const { octokit, owner, name, token, pullNumber } = params;
|
||||||
log.info(`🔀 checking out PR #${pullNumber}...`);
|
log.info(`» checking out PR #${pullNumber}...`);
|
||||||
|
|
||||||
// fetch PR metadata
|
// fetch PR metadata
|
||||||
const pr = await octokit.rest.pulls.get({
|
const pr = await octokit.rest.pulls.get({
|
||||||
@@ -149,7 +149,7 @@ export async function checkoutPrBranch(
|
|||||||
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
||||||
} else {
|
} else {
|
||||||
// fetch base branch so origin/<base> exists for diff operations
|
// fetch base branch so origin/<base> exists for diff operations
|
||||||
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
log.debug(`» fetching base branch (${baseBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
||||||
|
|
||||||
// checkout base branch first to avoid "refusing to fetch into current branch" error
|
// checkout base branch first to avoid "refusing to fetch into current branch" error
|
||||||
@@ -157,18 +157,18 @@ export async function checkoutPrBranch(
|
|||||||
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
||||||
|
|
||||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||||
log.debug(`🌿 fetching PR #${pullNumber} (${localBranch})...`);
|
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
|
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
|
||||||
|
|
||||||
// checkout the branch
|
// checkout the branch
|
||||||
$("git", ["checkout", localBranch]);
|
$("git", ["checkout", localBranch]);
|
||||||
log.debug(`✓ checked out PR #${pullNumber}`);
|
log.debug(`» checked out PR #${pullNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure base branch is fetched (needed for diff operations)
|
// ensure base branch is fetched (needed for diff operations)
|
||||||
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
|
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
|
||||||
if (alreadyOnBranch) {
|
if (alreadyOnBranch) {
|
||||||
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
log.debug(`» fetching base branch (${baseBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,23 +182,23 @@ export async function checkoutPrBranch(
|
|||||||
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
|
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
|
||||||
try {
|
try {
|
||||||
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`» added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
} catch {
|
} catch {
|
||||||
// remote already exists, update its URL
|
// remote already exists, update its URL
|
||||||
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`» updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// set branch push config so `git push` knows where to push
|
// set branch push config so `git push` knows where to push
|
||||||
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
|
||||||
// set merge ref so git knows the remote branch name (may differ from local)
|
// set merge ref so git knows the remote branch name (may differ from local)
|
||||||
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
||||||
log.debug(`📌 configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
||||||
|
|
||||||
// warn if maintainer can't modify (push will likely fail)
|
// warn if maintainer can't modify (push will likely fail)
|
||||||
if (!pr.data.maintainer_can_modify) {
|
if (!pr.data.maintainer_can_modify) {
|
||||||
log.warning(
|
log.warning(
|
||||||
`⚠️ fork PR has maintainer_can_modify=false - push operations will fail. ` +
|
`» fork PR has maintainer_can_modify=false - push operations will fail. ` +
|
||||||
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,8 +221,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const result = await checkoutPrBranch({
|
const result = await checkoutPrBranch({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
name: ctx.name,
|
name: ctx.repo.name,
|
||||||
token: ctx.githubInstallationToken,
|
token: ctx.githubInstallationToken,
|
||||||
pullNumber: pull_number,
|
pullNumber: pull_number,
|
||||||
});
|
});
|
||||||
@@ -232,8 +232,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// fetch PR metadata to return result
|
// fetch PR metadata to return result
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,8 +244,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// fetch PR files and format with line numbers
|
// fetch PR files and format with line numbers
|
||||||
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|||||||
+92
-143
@@ -1,16 +1,10 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { Payload } from "../external.ts";
|
import type { Agent } from "../agents/index.ts";
|
||||||
import { agentsManifest } from "../external.ts";
|
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
|
||||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import {
|
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
|
||||||
createOctokit,
|
import { getGitHubInstallationToken } from "../utils/token.ts";
|
||||||
getGitHubInstallationToken,
|
import { fetchWorkflowRunInfo } from "../utils/workflowRun.ts";
|
||||||
type OctokitWithPlugins,
|
import type { ToolContext, ToolState } from "./server.ts";
|
||||||
parseRepoContext,
|
|
||||||
} from "../utils/github.ts";
|
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,25 +14,20 @@ import { execute, tool } from "./shared.ts";
|
|||||||
*/
|
*/
|
||||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||||
|
|
||||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
|
||||||
|
|
||||||
interface BuildCommentFooterParams {
|
interface BuildCommentFooterParams {
|
||||||
payload: Payload;
|
agent: Agent | undefined;
|
||||||
octokit?: OctokitWithPlugins | undefined;
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
customParts?: string[] | undefined;
|
customParts?: string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCommentFooter({
|
async function buildCommentFooter({
|
||||||
payload,
|
agent,
|
||||||
octokit,
|
octokit,
|
||||||
customParts,
|
customParts,
|
||||||
}: BuildCommentFooterParams): Promise<string> {
|
}: BuildCommentFooterParams): Promise<string> {
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
|
|
||||||
const agentName = payload.agent;
|
|
||||||
const agentInfo = agentName ? agentsManifest[agentName] : null;
|
|
||||||
|
|
||||||
let workflowRunHtmlUrl: string | undefined;
|
let workflowRunHtmlUrl: string | undefined;
|
||||||
if (runId && octokit) {
|
if (runId && octokit) {
|
||||||
try {
|
try {
|
||||||
@@ -58,8 +47,8 @@ async function buildCommentFooter({
|
|||||||
const footerParams = {
|
const footerParams = {
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
agent: {
|
agent: {
|
||||||
displayName: agentInfo?.displayName || "Unknown agent",
|
displayName: agent?.displayName || "Unknown agent",
|
||||||
url: agentInfo?.url || "https://pullfrog.com",
|
url: agent?.url || "https://pullfrog.com",
|
||||||
},
|
},
|
||||||
workflowRun: runId
|
workflowRun: runId
|
||||||
? {
|
? {
|
||||||
@@ -77,6 +66,9 @@ async function buildCommentFooter({
|
|||||||
return buildPullfrogFooter(footerParams);
|
return buildPullfrogFooter(footerParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUGGESTION_FORMAT_DESCRIPTION =
|
||||||
|
"when suggesting code changes, use GitHub's suggestion format with ```suggestion blocks to enable one-click apply (e.g., 'you could do this\\n```suggestion\\nsuggested code here\\n```'). note: suggestions only work on pull request line-level review comments, not on issue/PR-level comments.";
|
||||||
|
|
||||||
function buildImplementPlanLink(
|
function buildImplementPlanLink(
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
@@ -87,19 +79,20 @@ function buildImplementPlanLink(
|
|||||||
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFooter(
|
interface AddFooterCtx {
|
||||||
body: string,
|
agent?: Agent | undefined;
|
||||||
payload: Payload,
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
octokit?: OctokitWithPlugins
|
}
|
||||||
): Promise<string> {
|
|
||||||
|
async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
|
||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({ payload, octokit });
|
const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
|
||||||
return `${bodyWithoutFooter}${footer}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Comment = type({
|
export const Comment = type({
|
||||||
issueNumber: type.number.describe("the issue number to comment on"),
|
issueNumber: type.number.describe("the issue number to comment on"),
|
||||||
body: type.string.describe("the comment body content"),
|
body: type.string.describe(`the comment body content. ${SUGGESTION_FORMAT_DESCRIPTION}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CreateCommentTool(ctx: ToolContext) {
|
export function CreateCommentTool(ctx: ToolContext) {
|
||||||
@@ -109,11 +102,11 @@ export function CreateCommentTool(ctx: ToolContext) {
|
|||||||
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
|
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
|
||||||
parameters: Comment,
|
parameters: Comment,
|
||||||
execute: execute(async ({ issueNumber, body }) => {
|
execute: execute(async ({ issueNumber, body }) => {
|
||||||
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
|
const bodyWithFooter = await addFooter(ctx, body);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number: issueNumber,
|
issue_number: issueNumber,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
@@ -130,7 +123,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
export const EditComment = type({
|
export const EditComment = type({
|
||||||
commentId: type.number.describe("the ID of the comment to edit"),
|
commentId: type.number.describe("the ID of the comment to edit"),
|
||||||
body: type.string.describe("the new comment body content"),
|
body: type.string.describe(`the new comment body content. ${SUGGESTION_FORMAT_DESCRIPTION}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function EditCommentTool(ctx: ToolContext) {
|
export function EditCommentTool(ctx: ToolContext) {
|
||||||
@@ -139,11 +132,11 @@ export function EditCommentTool(ctx: ToolContext) {
|
|||||||
description: "Edit a GitHub issue comment by its ID",
|
description: "Edit a GitHub issue comment by its ID",
|
||||||
parameters: EditComment,
|
parameters: EditComment,
|
||||||
execute: execute(async ({ commentId, body }) => {
|
execute: execute(async ({ commentId, body }) => {
|
||||||
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
|
const bodyWithFooter = await addFooter(ctx, body);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.updateComment({
|
const result = await ctx.octokit.rest.issues.updateComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
comment_id: commentId,
|
comment_id: commentId,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
@@ -159,69 +152,29 @@ export function EditCommentTool(ctx: ToolContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress comment ID from environment variable.
|
|
||||||
* This allows the webhook handler to pre-create a "leaping into action" comment
|
|
||||||
* and pass the ID to the action for updates.
|
|
||||||
*/
|
|
||||||
function getProgressCommentIdFromEnv(): number | null {
|
|
||||||
const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
|
|
||||||
if (envCommentId) {
|
|
||||||
const parsed = parseInt(envCommentId, 10);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// module-level variable to track the progress comment ID
|
|
||||||
// initialized lazily on first use to allow env var to be set after module load
|
|
||||||
let progressCommentId: number | null = null;
|
|
||||||
let progressCommentIdInitialized = false;
|
|
||||||
|
|
||||||
// track whether the progress comment was updated during execution
|
|
||||||
let progressCommentWasUpdated = false;
|
|
||||||
|
|
||||||
function getProgressCommentId(): number | null {
|
|
||||||
if (!progressCommentIdInitialized) {
|
|
||||||
progressCommentId = getProgressCommentIdFromEnv();
|
|
||||||
progressCommentIdInitialized = true;
|
|
||||||
}
|
|
||||||
return progressCommentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setProgressCommentId(id: number): void {
|
|
||||||
progressCommentId = id;
|
|
||||||
progressCommentIdInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReportProgress = type({
|
export const ReportProgress = type({
|
||||||
body: type.string.describe("the progress update content to share"),
|
body: type.string.describe("the progress update content to share"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/** Updates job summary with the given text if running in GitHub Actions. */
|
|
||||||
const updateSummary = (text: string) => isGitHubActions && core.summary.addRaw(text).write({ overwrite: true });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standalone function to report progress to GitHub comment.
|
* Standalone function to report progress to GitHub comment.
|
||||||
* Can be called directly without going through the MCP tool interface.
|
* Can be called directly without going through the MCP tool interface.
|
||||||
* Returns result data if successful, undefined if comment cannot be created.
|
* Returns result data if successful.
|
||||||
|
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result.
|
||||||
*/
|
*/
|
||||||
export async function reportProgress(
|
export async function reportProgress(
|
||||||
ctx: ToolContext,
|
ctx: ToolContext,
|
||||||
{ body }: { body: string }
|
{ body }: { body: string }
|
||||||
): Promise<
|
): Promise<{
|
||||||
| {
|
commentId?: number;
|
||||||
commentId: number;
|
url?: string;
|
||||||
url: string;
|
body: string;
|
||||||
body: string;
|
action: "created" | "updated" | "skipped";
|
||||||
action: "created" | "updated";
|
}> {
|
||||||
}
|
// always track the body for job summary
|
||||||
| undefined
|
ctx.toolState.lastProgressBody = body;
|
||||||
> {
|
|
||||||
const existingCommentId = getProgressCommentId();
|
const existingCommentId = ctx.toolState.progressComment.id;
|
||||||
const issueNumber =
|
const issueNumber =
|
||||||
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||||
@@ -230,27 +183,25 @@ export async function reportProgress(
|
|||||||
if (existingCommentId) {
|
if (existingCommentId) {
|
||||||
const customParts =
|
const customParts =
|
||||||
isPlanMode && issueNumber !== undefined
|
isPlanMode && issueNumber !== undefined
|
||||||
? [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, existingCommentId)]
|
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
payload: ctx.payload,
|
agent: ctx.agent,
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.updateComment({
|
const result = await ctx.octokit.rest.issues.updateComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
comment_id: existingCommentId,
|
comment_id: existingCommentId,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
progressCommentWasUpdated = true;
|
ctx.toolState.progressComment.wasUpdated = true;
|
||||||
|
|
||||||
await updateSummary(bodyWithFooter);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
@@ -260,47 +211,50 @@ export async function reportProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// no existing comment - create one
|
// no existing comment - need an issue/PR to create one on
|
||||||
// use fallback chain: dynamically set context > event payload
|
// use fallback chain: dynamically set context > event payload
|
||||||
if (issueNumber === undefined) {
|
if (issueNumber === undefined) {
|
||||||
// cannot create comment without issue_number (e.g., workflow_dispatch events)
|
// no-op: no comment target (e.g., workflow_dispatch events)
|
||||||
return undefined;
|
// body is already tracked for job summary
|
||||||
|
return { body, action: "skipped" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// for new comments, we need to create first, then update with Plan link if in Plan mode
|
// for new comments, we need to create first, then update with Plan link if in Plan mode
|
||||||
const initialBody = await addFooter(body, ctx.payload, ctx.octokit);
|
const initialBody = await addFooter(ctx, body);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number: issueNumber,
|
issue_number: issueNumber,
|
||||||
body: initialBody,
|
body: initialBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
// store the comment ID for future updates
|
// store the comment ID for future updates
|
||||||
setProgressCommentId(result.data.id);
|
ctx.toolState.progressComment = {
|
||||||
progressCommentWasUpdated = true;
|
id: result.data.id,
|
||||||
|
wasUpdated: true,
|
||||||
|
};
|
||||||
|
|
||||||
// if Plan mode, update the comment to add the "Implement plan" link
|
// if Plan mode, update the comment to add the "Implement plan" link
|
||||||
if (isPlanMode) {
|
if (isPlanMode) {
|
||||||
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
|
const customParts = [
|
||||||
|
buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, result.data.id),
|
||||||
|
];
|
||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
payload: ctx.payload,
|
agent: ctx.agent,
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
});
|
});
|
||||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
comment_id: result.data.id,
|
comment_id: result.data.id,
|
||||||
body: bodyWithPlanLink,
|
body: bodyWithPlanLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateSummary(bodyWithPlanLink);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: updateResult.data.id,
|
commentId: updateResult.data.id,
|
||||||
url: updateResult.data.html_url,
|
url: updateResult.data.html_url,
|
||||||
@@ -309,8 +263,6 @@ export async function reportProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSummary(initialBody);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
@@ -328,13 +280,12 @@ export function ReportProgressTool(ctx: ToolContext) {
|
|||||||
execute: execute(async ({ body }) => {
|
execute: execute(async ({ body }) => {
|
||||||
const result = await reportProgress(ctx, { body });
|
const result = await reportProgress(ctx, { body });
|
||||||
|
|
||||||
if (!result) {
|
if (result.action === "skipped") {
|
||||||
// gracefully handle case where no comment can be created
|
// no-op: no comment target, but progress is still tracked for job summary
|
||||||
// this happens for workflow_dispatch events or when there's no associated issue/PR
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
message:
|
message:
|
||||||
"cannot create progress comment: no issue_number found in the payload event. this may occur for workflow_dispatch events or when there is no associated issue/PR. if you need to comment on a specific issue or PR, use create_issue_comment with an explicit issueNumber.",
|
"progress recorded (no GitHub comment created - this may occur for workflow_dispatch events or when there is no associated issue/PR)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,27 +297,20 @@ export function ReportProgressTool(ctx: ToolContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the progress comment was updated during execution
|
|
||||||
*/
|
|
||||||
export function wasProgressCommentUpdated(): boolean {
|
|
||||||
return progressCommentWasUpdated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the progress comment if it exists.
|
* Delete the progress comment if it exists.
|
||||||
* Used after submitting a PR review since the review body contains all necessary info.
|
* Used after submitting a PR review since the review body contains all necessary info.
|
||||||
*/
|
*/
|
||||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||||
const existingCommentId = getProgressCommentId();
|
const existingCommentId = ctx.toolState.progressComment.id;
|
||||||
if (!existingCommentId) {
|
if (!existingCommentId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.octokit.rest.issues.deleteComment({
|
await ctx.octokit.rest.issues.deleteComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
comment_id: existingCommentId,
|
comment_id: existingCommentId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -379,9 +323,10 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
|
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
|
||||||
progressCommentId = null;
|
ctx.toolState.progressComment = {
|
||||||
progressCommentIdInitialized = true; // keep initialized so we don't re-fetch from env
|
id: null,
|
||||||
progressCommentWasUpdated = true; // mark as handled so ensureProgressCommentUpdated skips
|
wasUpdated: true,
|
||||||
|
};
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -392,18 +337,23 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
|||||||
* exited without ever calling reportProgress.
|
* exited without ever calling reportProgress.
|
||||||
*
|
*
|
||||||
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
|
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
|
||||||
* Will fetch comment ID from database if not available in environment variable.
|
* Will fetch comment ID from database if not available in toolState.
|
||||||
*/
|
*/
|
||||||
export async function ensureProgressCommentUpdated(payload?: Payload): Promise<void> {
|
export async function ensureProgressCommentUpdated(toolState: ToolState): Promise<void> {
|
||||||
// skip if comment was already updated during execution
|
// skip if comment was already updated during execution
|
||||||
if (progressCommentWasUpdated) {
|
if (toolState.progressComment.wasUpdated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to get comment ID from env var first, then from database if needed
|
// skip if there's already a progress body recorded (agent called report_progress)
|
||||||
let existingCommentId = getProgressCommentId();
|
if (toolState.lastProgressBody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// if not in env var, try fetching from database using run ID
|
// try to get comment ID from toolState first, then from database if needed
|
||||||
|
let existingCommentId = toolState.progressComment.id;
|
||||||
|
|
||||||
|
// if not in toolState, try fetching from database using run ID
|
||||||
if (!existingCommentId) {
|
if (!existingCommentId) {
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
if (runId) {
|
if (runId) {
|
||||||
@@ -411,9 +361,8 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
||||||
if (workflowRunInfo.progressCommentId) {
|
if (workflowRunInfo.progressCommentId) {
|
||||||
existingCommentId = parseInt(workflowRunInfo.progressCommentId, 10);
|
existingCommentId = parseInt(workflowRunInfo.progressCommentId, 10);
|
||||||
// cache it in env var for future use
|
if (Number.isNaN(existingCommentId)) {
|
||||||
if (!Number.isNaN(existingCommentId)) {
|
existingCommentId = null;
|
||||||
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -457,8 +406,8 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
|
|
||||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||||
|
|
||||||
// add footer if we have payload, otherwise use plain message
|
// add footer without agent info (we don't have context here)
|
||||||
const body = payload ? await addFooter(errorMessage, payload, octokit) : errorMessage;
|
const body = await addFooter({ octokit }, errorMessage);
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
@@ -472,7 +421,7 @@ export const ReplyToReviewComment = type({
|
|||||||
pull_number: type.number.describe("the pull request number"),
|
pull_number: type.number.describe("the pull request number"),
|
||||||
comment_id: type.number.describe("the ID of the review comment to reply to"),
|
comment_id: type.number.describe("the ID of the review comment to reply to"),
|
||||||
body: type.string.describe(
|
body: type.string.describe(
|
||||||
"extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'"
|
`extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'. ${SUGGESTION_FORMAT_DESCRIPTION}`
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -483,18 +432,18 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
|||||||
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
|
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
|
||||||
parameters: ReplyToReviewComment,
|
parameters: ReplyToReviewComment,
|
||||||
execute: execute(async ({ pull_number, comment_id, body }) => {
|
execute: execute(async ({ pull_number, comment_id, body }) => {
|
||||||
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
|
const bodyWithFooter = await addFooter(ctx, body);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
comment_id,
|
comment_id,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
|
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
|
||||||
progressCommentWasUpdated = true;
|
ctx.toolState.progressComment.wasUpdated = true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
|
||||||
|
|
||||||
export type McpName = typeof ghPullfrogMcpName;
|
|
||||||
|
|
||||||
export type McpConfigs = Record<McpName, McpHttpServerConfig>;
|
|
||||||
|
|
||||||
export function createMcpConfigs(mcpServerUrl: string): McpConfigs {
|
|
||||||
return {
|
|
||||||
[ghPullfrogMcpName]: {
|
|
||||||
type: "http",
|
|
||||||
url: mcpServerUrl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import type { PrepResult } from "../prep/index.ts";
|
import type { PrepResult } from "../prep/index.ts";
|
||||||
import { runPrepPhase } from "../prep/index.ts";
|
import { runPrepPhase } from "../prep/index.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { containsSecrets } from "../utils/secrets.ts";
|
import { containsSecrets } from "../utils/secrets.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export function CreateBranchTool(ctx: ToolContext) {
|
export function CreateBranchTool(ctx: ToolContext) {
|
||||||
const defaultBranch = ctx.repo.default_branch || "main";
|
const defaultBranch = ctx.repo.repo.default_branch || "main";
|
||||||
|
|
||||||
const CreateBranch = type({
|
const CreateBranch = type({
|
||||||
branchName: type.string.describe(
|
branchName: type.string.describe(
|
||||||
@@ -24,7 +24,7 @@ export function CreateBranchTool(ctx: ToolContext) {
|
|||||||
parameters: CreateBranch,
|
parameters: CreateBranch,
|
||||||
execute: execute(async ({ branchName, baseBranch }) => {
|
execute: execute(async ({ branchName, baseBranch }) => {
|
||||||
// baseBranch should always be defined due to default, but TypeScript needs help
|
// baseBranch should always be defined due to default, but TypeScript needs help
|
||||||
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
|
const resolvedBaseBranch = baseBranch || ctx.repo.repo.default_branch || "main";
|
||||||
|
|
||||||
// validate branch name for secrets
|
// validate branch name for secrets
|
||||||
if (containsSecrets(branchName)) {
|
if (containsSecrets(branchName)) {
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const Issue = type({
|
export const Issue = type({
|
||||||
@@ -22,8 +22,8 @@ export function IssueTool(ctx: ToolContext) {
|
|||||||
parameters: Issue,
|
parameters: Issue,
|
||||||
execute: execute(async ({ title, body, labels, assignees }) => {
|
execute: execute(async ({ title, body, labels, assignees }) => {
|
||||||
const result = await ctx.octokit.rest.issues.create({
|
const result = await ctx.octokit.rest.issues.create({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
labels: labels ?? [],
|
labels: labels ?? [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetIssueComments = type({
|
export const GetIssueComments = type({
|
||||||
@@ -17,8 +17,8 @@ export function GetIssueCommentsTool(ctx: ToolContext) {
|
|||||||
ctx.toolState.issueNumber = issue_number;
|
ctx.toolState.issueNumber = issue_number;
|
||||||
|
|
||||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number,
|
issue_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetIssueEvents = type({
|
export const GetIssueEvents = type({
|
||||||
@@ -17,8 +17,8 @@ export function GetIssueEventsTool(ctx: ToolContext) {
|
|||||||
ctx.toolState.issueNumber = issue_number;
|
ctx.toolState.issueNumber = issue_number;
|
||||||
|
|
||||||
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number,
|
issue_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const IssueInfo = type({
|
export const IssueInfo = type({
|
||||||
@@ -13,8 +13,8 @@ export function IssueInfoTool(ctx: ToolContext) {
|
|||||||
parameters: IssueInfo,
|
parameters: IssueInfo,
|
||||||
execute: execute(async ({ issue_number }) => {
|
execute: execute(async ({ issue_number }) => {
|
||||||
const issue = await ctx.octokit.rest.issues.get({
|
const issue = await ctx.octokit.rest.issues.get({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number,
|
issue_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const AddLabelsParams = type({
|
export const AddLabelsParams = type({
|
||||||
@@ -15,8 +15,8 @@ export function AddLabelsTool(ctx: ToolContext) {
|
|||||||
parameters: AddLabelsParams,
|
parameters: AddLabelsParams,
|
||||||
execute: execute(async ({ issue_number, labels }) => {
|
execute: execute(async ({ issue_number, labels }) => {
|
||||||
const result = await ctx.octokit.rest.issues.addLabels({
|
const result = await ctx.octokit.rest.issues.addLabels({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
issue_number,
|
issue_number,
|
||||||
labels,
|
labels,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { agentsManifest } from "../external.ts";
|
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { containsSecrets } from "../utils/secrets.ts";
|
import { containsSecrets } from "../utils/secrets.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const PullRequest = type({
|
export const PullRequest = type({
|
||||||
@@ -14,14 +13,11 @@ export const PullRequest = type({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||||
const agentName = ctx.payload.agent;
|
|
||||||
const agentInfo = agentName ? agentsManifest[agentName] : null;
|
|
||||||
|
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : undefined,
|
agent: { displayName: ctx.agent.displayName, url: ctx.agent.url },
|
||||||
workflowRun: ctx.runId
|
workflowRun: ctx.runId
|
||||||
? { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,8 +56,8 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
|||||||
const bodyWithFooter = buildPrBodyWithFooter(ctx, body);
|
const bodyWithFooter = buildPrBodyWithFooter(ctx, body);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.pulls.create({
|
const result = await ctx.octokit.rest.pulls.create({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
title: title,
|
title: title,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
head: currentBranch,
|
head: currentBranch,
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const PullRequestInfo = type({
|
export const PullRequestInfo = type({
|
||||||
@@ -14,8 +14,8 @@ export function PullRequestInfoTool(ctx: ToolContext) {
|
|||||||
parameters: PullRequestInfo,
|
parameters: PullRequestInfo,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+67
-42
@@ -1,9 +1,9 @@
|
|||||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { deleteProgressComment } from "./comment.ts";
|
import { deleteProgressComment } from "./comment.ts";
|
||||||
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
// one-shot review tool
|
// one-shot review tool
|
||||||
@@ -20,7 +20,7 @@ export const CreatePullRequestReview = type({
|
|||||||
comments: type({
|
comments: type({
|
||||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||||
line: type.number.describe(
|
line: type.number.describe(
|
||||||
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)."
|
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
||||||
),
|
),
|
||||||
side: type
|
side: type
|
||||||
.enumerated("LEFT", "RIGHT")
|
.enumerated("LEFT", "RIGHT")
|
||||||
@@ -28,12 +28,17 @@ export const CreatePullRequestReview = type({
|
|||||||
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
body: type.string.describe(
|
body: type.string
|
||||||
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
|
.describe("Explanatory comment text (optional if suggestion is provided)")
|
||||||
),
|
|
||||||
start_line: type.number
|
|
||||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
|
||||||
.optional(),
|
.optional(),
|
||||||
|
suggestion: type.string
|
||||||
|
.describe(
|
||||||
|
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
start_line: type.number.describe(
|
||||||
|
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -48,23 +53,19 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
description:
|
description:
|
||||||
"Submit a review for an existing pull request. " +
|
"Submit a review for an existing pull request. " +
|
||||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
||||||
|
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
||||||
|
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
||||||
|
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }`,
|
||||||
parameters: CreatePullRequestReview,
|
parameters: CreatePullRequestReview,
|
||||||
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||||
// set PR context
|
// set PR context
|
||||||
ctx.toolState.prNumber = pull_number;
|
ctx.toolState.prNumber = pull_number;
|
||||||
|
|
||||||
// get the PR to determine the head commit if commit_id not provided
|
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number,
|
|
||||||
});
|
|
||||||
|
|
||||||
// compose the request
|
// compose the request
|
||||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
event: "COMMENT",
|
event: "COMMENT",
|
||||||
};
|
};
|
||||||
@@ -72,20 +73,34 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
params.commit_id = commit_id;
|
params.commit_id = commit_id;
|
||||||
} else {
|
} else {
|
||||||
|
// get the PR to determine the head commit if commit_id not provided
|
||||||
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
|
owner: ctx.repo.owner,
|
||||||
|
repo: ctx.repo.name,
|
||||||
|
pull_number,
|
||||||
|
});
|
||||||
params.commit_id = pr.data.head.sha;
|
params.commit_id = pr.data.head.sha;
|
||||||
}
|
}
|
||||||
if (comments.length > 0) {
|
if (comments.length > 0) {
|
||||||
type ReviewComment = (typeof params.comments & {})[number];
|
type ReviewComment = (typeof params.comments & {})[number];
|
||||||
// convert comments to the format expected by GitHub API
|
// convert comments to the format expected by GitHub API
|
||||||
params.comments = comments.map((comment) => {
|
params.comments = comments.map((comment) => {
|
||||||
const reviewComment: ReviewComment = {
|
// build comment body with suggestion block if provided
|
||||||
...comment,
|
let commentBody = comment.body || "";
|
||||||
};
|
if (comment.suggestion !== undefined) {
|
||||||
reviewComment.side = comment.side || "RIGHT";
|
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||||
if (comment.start_line) {
|
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||||
reviewComment.start_line = comment.start_line;
|
|
||||||
reviewComment.start_side = comment.side || "RIGHT";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const side = comment.side || "RIGHT";
|
||||||
|
const reviewComment: ReviewComment = {
|
||||||
|
path: comment.path,
|
||||||
|
line: comment.line,
|
||||||
|
body: commentBody,
|
||||||
|
side,
|
||||||
|
start_line: comment.start_line,
|
||||||
|
start_side: side,
|
||||||
|
};
|
||||||
return reviewComment;
|
return reviewComment;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,21 +112,31 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
const reviewId = result.data.id;
|
const reviewId = result.data.id;
|
||||||
|
|
||||||
// build quick links footer and update the review body
|
// build quick links footer and update the review body
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
|
||||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
const customParts: string[] = [];
|
||||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
if (comments.length > 0) {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||||
|
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||||
|
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
|
||||||
|
}
|
||||||
|
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
workflowRun: {
|
||||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
owner: ctx.repo.owner,
|
||||||
|
repo: ctx.repo.name,
|
||||||
|
runId: ctx.runId,
|
||||||
|
jobId: ctx.jobId,
|
||||||
|
},
|
||||||
|
customParts,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBody = (body || "") + footer;
|
const updatedBody = (body || "") + footer;
|
||||||
|
|
||||||
// update the review with the footer
|
// update the review with the footer
|
||||||
await ctx.octokit.rest.pulls.updateReview({
|
await ctx.octokit.rest.pulls.updateReview({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
review_id: reviewId,
|
review_id: reviewId,
|
||||||
body: updatedBody,
|
body: updatedBody,
|
||||||
@@ -171,8 +196,8 @@ async function findPendingReview(
|
|||||||
pull_number: number
|
pull_number: number
|
||||||
): Promise<{ id: number; node_id: string } | null> {
|
): Promise<{ id: number; node_id: string } | null> {
|
||||||
const reviews = await ctx.octokit.rest.pulls.listReviews({
|
const reviews = await ctx.octokit.rest.pulls.listReviews({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
@@ -207,8 +232,8 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// get the PR to get head commit SHA
|
// get the PR to get head commit SHA
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,8 +244,8 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
log.debug(`creating pending review for PR #${pull_number}...`);
|
log.debug(`creating pending review for PR #${pull_number}...`);
|
||||||
try {
|
try {
|
||||||
const result = await ctx.octokit.rest.pulls.createReview({
|
const result = await ctx.octokit.rest.pulls.createReview({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
commit_id: pr.data.head.sha,
|
commit_id: pr.data.head.sha,
|
||||||
// no 'event' = PENDING review
|
// no 'event' = PENDING review
|
||||||
@@ -386,11 +411,11 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// build quick links footer
|
// build quick links footer
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
|
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
|
||||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
|
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
|
||||||
|
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,8 +423,8 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// submit the pending review via REST
|
// submit the pending review via REST
|
||||||
const result = await ctx.octokit.rest.pulls.submitReview({
|
const result = await ctx.octokit.rest.pulls.submitReview({
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number: ctx.toolState.prNumber,
|
pull_number: ctx.toolState.prNumber,
|
||||||
review_id: reviewId,
|
review_id: reviewId,
|
||||||
event: "COMMENT",
|
event: "COMMENT",
|
||||||
|
|||||||
+85
-166
@@ -1,187 +1,108 @@
|
|||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import { log } from "../utils/log.ts";
|
||||||
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
// graphql query to fetch all review threads with comments and replies
|
|
||||||
// note: diffSide and startDiffSide are on the thread, not the comment
|
|
||||||
const REVIEW_THREADS_QUERY = `
|
|
||||||
query ($owner: String!, $repo: String!, $pullNumber: Int!) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
pullRequest(number: $pullNumber) {
|
|
||||||
reviewThreads(first: 100) {
|
|
||||||
nodes {
|
|
||||||
diffSide
|
|
||||||
startDiffSide
|
|
||||||
comments(first: 100) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
databaseId
|
|
||||||
body
|
|
||||||
path
|
|
||||||
line
|
|
||||||
startLine
|
|
||||||
url
|
|
||||||
author {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
pullRequestReview {
|
|
||||||
databaseId
|
|
||||||
}
|
|
||||||
replyTo {
|
|
||||||
databaseId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// graphql response types (nodes arrays can contain nulls per GitHub GraphQL spec)
|
|
||||||
type GraphQLReviewComment = {
|
|
||||||
id: string;
|
|
||||||
databaseId: number;
|
|
||||||
body: string;
|
|
||||||
path: string;
|
|
||||||
line: number | null;
|
|
||||||
startLine: number | null;
|
|
||||||
url: string;
|
|
||||||
author: {
|
|
||||||
login: string;
|
|
||||||
} | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
pullRequestReview: {
|
|
||||||
databaseId: number;
|
|
||||||
} | null;
|
|
||||||
replyTo: {
|
|
||||||
databaseId: number;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GraphQLReviewThread = {
|
|
||||||
diffSide: "LEFT" | "RIGHT";
|
|
||||||
startDiffSide: "LEFT" | "RIGHT" | null;
|
|
||||||
comments: {
|
|
||||||
nodes: (GraphQLReviewComment | null)[] | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type GraphQLResponse = {
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
reviewThreads: {
|
|
||||||
nodes: (GraphQLReviewThread | null)[] | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GetReviewComments = type({
|
export const GetReviewComments = type({
|
||||||
pull_number: type.number.describe("The pull request number"),
|
pull_number: type.number.describe("The pull request number"),
|
||||||
review_id: type.number.describe("The review ID to get comments for"),
|
review_id: type.number.describe("The review ID to get comments for"),
|
||||||
|
approved_by: type.string
|
||||||
|
.describe("Optional GitHub username - only return comments this user gave a 👍 to")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function GetReviewCommentsTool(ctx: ToolContext) {
|
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "get_review_comments",
|
name: "get_review_comments",
|
||||||
description:
|
description:
|
||||||
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
|
"Get review comments for a pull request review, including diff context. " +
|
||||||
|
"When approved_by is provided, only returns comments that user approved with 👍. " +
|
||||||
|
"Returns commentsPath pointing to a file with full comment details.",
|
||||||
parameters: GetReviewComments,
|
parameters: GetReviewComments,
|
||||||
execute: execute(async ({ pull_number, review_id }) => {
|
execute: execute(async ({ pull_number, review_id, approved_by }) => {
|
||||||
// fetch all review threads using graphql
|
// fetch all review comments via REST API (includes diff_hunk)
|
||||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pullNumber: pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pullRequest = response.repository?.pullRequest;
|
// filter to target review
|
||||||
if (!pullRequest) {
|
let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
|
||||||
return {
|
|
||||||
review_id,
|
|
||||||
pull_number,
|
|
||||||
comments: [],
|
|
||||||
count: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadNodes = pullRequest.reviewThreads?.nodes;
|
// filter by thumbs up if approved_by is specified
|
||||||
if (!threadNodes) {
|
if (approved_by) {
|
||||||
return {
|
const approvedIds = new Set<number>();
|
||||||
review_id,
|
for (const comment of reviewComments) {
|
||||||
pull_number,
|
const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
|
||||||
comments: [],
|
owner: ctx.repo.owner,
|
||||||
count: 0,
|
repo: ctx.repo.name,
|
||||||
};
|
comment_id: comment.id,
|
||||||
}
|
});
|
||||||
|
const hasThumbsUp = reactions.data.some(
|
||||||
const allComments: {
|
(r) => r.content === "+1" && r.user?.login === approved_by
|
||||||
id: number;
|
|
||||||
body: string;
|
|
||||||
path: string;
|
|
||||||
line: number | null;
|
|
||||||
side: "LEFT" | "RIGHT";
|
|
||||||
start_line: number | null;
|
|
||||||
start_side: "LEFT" | "RIGHT" | null;
|
|
||||||
user: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
html_url: string;
|
|
||||||
in_reply_to_id: number | null;
|
|
||||||
pull_request_review_id: number | null;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// iterate through all threads (filter out nulls)
|
|
||||||
for (const thread of threadNodes) {
|
|
||||||
if (!thread?.comments?.nodes) continue;
|
|
||||||
|
|
||||||
// filter out null comments
|
|
||||||
const threadComments = thread.comments.nodes.filter(
|
|
||||||
(c): c is GraphQLReviewComment => c !== null
|
|
||||||
);
|
);
|
||||||
if (threadComments.length === 0) continue;
|
if (hasThumbsUp) approvedIds.add(comment.id);
|
||||||
|
|
||||||
// find the root comment (the one with replyTo == null) to determine thread ownership
|
|
||||||
const rootComment = threadComments.find((c) => c.replyTo === null);
|
|
||||||
if (!rootComment) continue;
|
|
||||||
|
|
||||||
// check if this thread belongs to the target review using the root comment
|
|
||||||
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
|
|
||||||
if (!threadBelongsToReview) continue;
|
|
||||||
|
|
||||||
// include all comments from this thread (original + replies)
|
|
||||||
// side info comes from thread level, not comment level
|
|
||||||
for (const comment of threadComments) {
|
|
||||||
allComments.push({
|
|
||||||
id: comment.databaseId,
|
|
||||||
body: comment.body,
|
|
||||||
path: comment.path,
|
|
||||||
line: comment.line,
|
|
||||||
start_line: comment.startLine,
|
|
||||||
side: thread.diffSide,
|
|
||||||
start_side: thread.startDiffSide,
|
|
||||||
user: comment.author?.login ?? null,
|
|
||||||
created_at: comment.createdAt,
|
|
||||||
updated_at: comment.updatedAt,
|
|
||||||
html_url: comment.url,
|
|
||||||
in_reply_to_id: comment.replyTo?.databaseId ?? null,
|
|
||||||
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewComments.length === 0) {
|
||||||
return {
|
return {
|
||||||
review_id,
|
review_id,
|
||||||
pull_number,
|
pull_number,
|
||||||
comments: allComments,
|
count: 0,
|
||||||
count: allComments.length,
|
commentsPath: null,
|
||||||
|
message: approved_by
|
||||||
|
? `No comments with 👍 from ${approved_by}`
|
||||||
|
: "No comments found for this review",
|
||||||
};
|
};
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
// format comments with diff context
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const comment of reviewComments) {
|
||||||
|
lines.push(`${"=".repeat(60)}`);
|
||||||
|
lines.push(`COMMENT #${comment.id} by @${comment.user?.login ?? "unknown"}`);
|
||||||
|
lines.push(`File: ${comment.path}:${comment.line ?? comment.original_line ?? "?"}`);
|
||||||
|
if (comment.in_reply_to_id) {
|
||||||
|
lines.push(`Reply to: #${comment.in_reply_to_id}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
if (comment.diff_hunk) {
|
||||||
|
lines.push("```diff");
|
||||||
|
lines.push(comment.diff_hunk);
|
||||||
|
lines.push("```");
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
lines.push("Comment:");
|
||||||
|
lines.push(comment.body);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = lines.join("\n");
|
||||||
|
|
||||||
|
// write to temp file
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
|
if (!tempDir) {
|
||||||
|
throw new Error("PULLFROG_TEMP_DIR not set");
|
||||||
|
}
|
||||||
|
const filename = approved_by
|
||||||
|
? `review-${review_id}-approved-by-${approved_by}.txt`
|
||||||
|
: `review-${review_id}-comments.txt`;
|
||||||
|
const commentsPath = join(tempDir, filename);
|
||||||
|
writeFileSync(commentsPath, content);
|
||||||
|
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
review_id,
|
||||||
|
pull_number,
|
||||||
|
count: reviewComments.length,
|
||||||
|
commentsPath,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +118,8 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
|
|||||||
parameters: ListPullRequestReviews,
|
parameters: ListPullRequestReviews,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||||
owner: ctx.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,9 +130,7 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
|
|||||||
body: review.body,
|
body: review.body,
|
||||||
state: review.state,
|
state: review.state,
|
||||||
user: review.user?.login,
|
user: review.user?.login,
|
||||||
commit_id: review.commit_id,
|
|
||||||
submitted_at: review.submitted_at,
|
submitted_at: review.submitted_at,
|
||||||
html_url: review.html_url,
|
|
||||||
})),
|
})),
|
||||||
count: reviews.length,
|
count: reviews.length,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const SelectMode = type({
|
export const SelectMode = type({
|
||||||
|
|||||||
+98
-5
@@ -2,8 +2,73 @@ import "./arkConfig.ts";
|
|||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
// this must be imported first
|
// this must be imported first
|
||||||
import { FastMCP, type Tool } from "fastmcp";
|
import { FastMCP, type Tool } from "fastmcp";
|
||||||
|
import type { Agent } from "../agents/index.ts";
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { Mode } from "../modes.ts";
|
||||||
|
import type { PrepResult } from "../prep/index.ts";
|
||||||
|
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||||
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||||
|
import type { RepoData } from "../utils/repoData.ts";
|
||||||
|
|
||||||
|
export type BackgroundProcess = {
|
||||||
|
pid: number;
|
||||||
|
outputPath: string;
|
||||||
|
pidPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolState {
|
||||||
|
prNumber?: number;
|
||||||
|
issueNumber?: number;
|
||||||
|
selectedMode?: string;
|
||||||
|
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||||
|
review?: {
|
||||||
|
id: number;
|
||||||
|
nodeId: string;
|
||||||
|
};
|
||||||
|
dependencyInstallation?: {
|
||||||
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
||||||
|
promise: Promise<PrepResult[]> | undefined;
|
||||||
|
results: PrepResult[] | undefined;
|
||||||
|
};
|
||||||
|
progressComment: {
|
||||||
|
id: number | null;
|
||||||
|
wasUpdated: boolean;
|
||||||
|
};
|
||||||
|
lastProgressBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { ResolveRunResult } from "../utils/workflow.ts";
|
||||||
|
|
||||||
|
interface InitToolStateParams {
|
||||||
|
runInfo: ResolveRunResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initToolState(ctx: InitToolStateParams): ToolState {
|
||||||
|
const progressCommentIdStr = ctx.runInfo.workflowRunInfo.progressCommentId;
|
||||||
|
const progressCommentId = progressCommentIdStr ? parseInt(progressCommentIdStr, 10) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
progressComment: {
|
||||||
|
id: Number.isNaN(progressCommentId) ? null : progressCommentId,
|
||||||
|
wasUpdated: false,
|
||||||
|
},
|
||||||
|
backgroundProcesses: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolContext {
|
||||||
|
repo: RepoData;
|
||||||
|
payload: ResolvedPayload;
|
||||||
|
octokit: OctokitWithPlugins;
|
||||||
|
githubInstallationToken: string;
|
||||||
|
agent: Agent;
|
||||||
|
modes: Mode[];
|
||||||
|
toolState: ToolState;
|
||||||
|
runId: string;
|
||||||
|
jobId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { BashTool, KillBackgroundTool } from "./bash.ts";
|
||||||
import { CheckoutPrTool } from "./checkout.ts";
|
import { CheckoutPrTool } from "./checkout.ts";
|
||||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +94,6 @@ import { CreatePullRequestReviewTool } from "./review.ts";
|
|||||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||||
import { SelectModeTool } from "./selectMode.ts";
|
import { SelectModeTool } from "./selectMode.ts";
|
||||||
import { addTools } from "./shared.ts";
|
import { addTools } from "./shared.ts";
|
||||||
import { BashTool } from "./bash.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port starting from the given port
|
* Find an available port starting from the given port
|
||||||
@@ -60,6 +124,27 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
|||||||
throw new Error(`Could not find available port starting from ${startPort}`);
|
throw new Error(`Could not find available port starting from ${startPort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
||||||
|
const backgroundProcesses = toolState.backgroundProcesses;
|
||||||
|
if (backgroundProcesses.size === 0) return;
|
||||||
|
for (const proc of backgroundProcesses.values()) {
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
for (const proc of backgroundProcesses.values()) {
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backgroundProcesses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the MCP HTTP server and return the URL and close function
|
* Start the MCP HTTP server and return the URL and close function
|
||||||
*/
|
*/
|
||||||
@@ -95,13 +180,20 @@ export async function startMcpHttpServer(
|
|||||||
CreateBranchTool(ctx),
|
CreateBranchTool(ctx),
|
||||||
CommitFilesTool(ctx),
|
CommitFilesTool(ctx),
|
||||||
PushBranchTool(ctx),
|
PushBranchTool(ctx),
|
||||||
BashTool(ctx),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!ctx.payload.disableProgressComment) {
|
// only add BashTool and KillBackgroundTool if bash is not disabled
|
||||||
tools.push(ReportProgressTool(ctx));
|
// - "enabled": native bash + MCP bash
|
||||||
|
// - "restricted": MCP bash only (native blocked by agent)
|
||||||
|
// - "disabled": no bash at all
|
||||||
|
const bash = ctx.payload.bash ?? "enabled";
|
||||||
|
if (bash !== "disabled") {
|
||||||
|
tools.push(BashTool(ctx));
|
||||||
|
tools.push(KillBackgroundTool(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.push(ReportProgressTool(ctx));
|
||||||
|
|
||||||
addTools(ctx, server, tools);
|
addTools(ctx, server, tools);
|
||||||
|
|
||||||
const port = await findAvailablePort(3764);
|
const port = await findAvailablePort(3764);
|
||||||
@@ -122,6 +214,7 @@ export async function startMcpHttpServer(
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
[Symbol.asyncDispose]: async () => {
|
[Symbol.asyncDispose]: async () => {
|
||||||
|
await killBackgroundProcesses(ctx.toolState);
|
||||||
await server.stop();
|
await server.stop();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import type { FastMCP, Tool } from "fastmcp";
|
import type { FastMCP, Tool } from "fastmcp";
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||||
|
import type { ToolContext } from "./server.ts";
|
||||||
|
|
||||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
import { ghPullfrogMcpName } from "./external.ts";
|
import { ghPullfrogMcpName } from "./external.ts";
|
||||||
|
|
||||||
export interface Mode {
|
export interface Mode {
|
||||||
@@ -6,49 +7,48 @@ export interface Mode {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetModesParams {
|
// arktype schema for Mode validation
|
||||||
disableProgressComment: true | undefined;
|
export const ModeSchema = type({
|
||||||
}
|
name: "string",
|
||||||
|
description: "string",
|
||||||
|
prompt: "string",
|
||||||
|
});
|
||||||
|
|
||||||
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`;
|
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`;
|
||||||
|
|
||||||
const dependencyInstallationGuidance = `## Dependency Installation
|
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
|
||||||
|
|
||||||
**IMPORTANT**: Immediately after the working branch is checked out, evaluate whether dependencies will be needed at any point during this task:
|
export function computeModes(): Mode[] {
|
||||||
- Making code changes that will require testing? → Call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW
|
|
||||||
- Running builds, linters, or CLI commands that require installed packages? → Call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW
|
|
||||||
- Only reading code or answering questions? → Skip dependency installation
|
|
||||||
|
|
||||||
Calling \`start_dependency_installation\` early allows dependencies to install in the background while you explore the codebase and make changes. This is a non-blocking call.
|
|
||||||
|
|
||||||
When you need to run tests, builds, or other commands that require dependencies, call \`${ghPullfrogMcpName}/await_dependency_installation\` to ensure they're ready. This will block until installation completes (or auto-start if you forgot to call start earlier).`;
|
|
||||||
|
|
||||||
export function getModes({ disableProgressComment }: GetModesParams): Mode[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "Build",
|
name: "Build",
|
||||||
description:
|
description:
|
||||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||||
prompt: `Follow these steps. THINK HARDER.
|
prompt: `Follow these steps. THINK HARDER.
|
||||||
1. If this is a PR event, the PR branch is already checked out - skip branch creation. Otherwise, create a branch using ${ghPullfrogMcpName}/create_branch. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit directly to main, master, or production. Do NOT use git commands directly (including \`git branch\`, \`git status\`, \`git log\`) - always use ${ghPullfrogMcpName} MCP tools for git operations.
|
1. Determine whether to work on the current branch or create a new one:
|
||||||
|
- **PR event, modifying the existing PR**: The PR branch is probably already checked out. Continue on this branch.
|
||||||
|
- **PR event, but user wants a NEW branch/PR**: Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch from the current HEAD.
|
||||||
|
- As needed use \`${ghPullfrogMcpName}/create_branch\` to create new branches. Always check your current branch status first.
|
||||||
|
|
||||||
|
Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production. Do NOT use git commands directly (\`git branch\`, \`git status\`, \`git log\`, etc.) - always use ${ghPullfrogMcpName} MCP tools.
|
||||||
|
|
||||||
${dependencyInstallationGuidance}
|
2. ${dependencyInstallationStep}
|
||||||
|
|
||||||
2. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists. Skip this step if the prompt is trivial and self-contained.
|
3. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists. Skip this step if the prompt is trivial and self-contained.
|
||||||
|
|
||||||
3. Understand the requirements and any existing plan
|
4. Understand the requirements and any existing plan
|
||||||
|
|
||||||
4. Make the necessary code changes using file operations. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly.
|
5. Make the necessary code changes using file operations. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly.
|
||||||
|
|
||||||
5. Test your changes to ensure they work correctly
|
6. Test your changes to ensure they work correctly
|
||||||
|
|
||||||
6. ${reportProgressInstruction}
|
7. ${reportProgressInstruction}
|
||||||
|
|
||||||
7. When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123").
|
8. When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123").
|
||||||
|
|
||||||
8. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), you still need to use ${ghPullfrogMcpName}/create_pull_request to ensure commits are properly attributed - you can note in the PR description that it's branch-only if needed.
|
9. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), you still need to use ${ghPullfrogMcpName}/create_pull_request to ensure commits are properly attributed - you can note in the PR description that it's branch-only if needed.
|
||||||
|
|
||||||
9. Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
|
10. Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
|
||||||
- A summary of what was accomplished
|
- A summary of what was accomplished
|
||||||
- Links to any artifacts created (PRs, branches, issues)
|
- Links to any artifacts created (PRs, branches, issues)
|
||||||
- If you created a PR, ALWAYS include the PR link. e.g.:
|
- If you created a PR, ALWAYS include the PR link. e.g.:
|
||||||
@@ -65,35 +65,31 @@ ${dependencyInstallationGuidance}
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Address Reviews",
|
name: "AddressReviews",
|
||||||
description:
|
description:
|
||||||
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
|
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
|
||||||
prompt: `Follow these steps. THINK HARDER.
|
prompt: `Follow these steps. THINK HARDER.
|
||||||
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs).
|
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs).
|
||||||
|
|
||||||
${dependencyInstallationGuidance}
|
2. ${dependencyInstallationStep}
|
||||||
|
|
||||||
2. Review the feedback provided. Understand each review comment and what changes are being requested.
|
3. Review the feedback provided. Understand each review comment and what changes are being requested.
|
||||||
- **EVENT DATA may contain review comment details**: If available, \`approved_comments\` are comments to address, \`unapproved_comments\` are for context only. The \`triggerer\` field indicates who initiated this action - prioritize their replies when deciding how to implement fixes.
|
- **EVENT DATA may contain review comment details**: If available, \`approved_comments\` are comments to address, \`unapproved_comments\` are for context only. The \`triggerer\` field indicates who initiated this action - prioritize their replies when deciding how to implement fixes.
|
||||||
- You can use ${ghPullfrogMcpName}/get_pull_request to get PR metadata if needed.
|
- You can use ${ghPullfrogMcpName}/get_pull_request to get PR metadata if needed.
|
||||||
|
|
||||||
3. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
|
4. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
|
||||||
|
|
||||||
4. Make the necessary code changes to address the feedback. Work through each review comment systematically.
|
5. Make the necessary code changes to address the feedback. Work through each review comment systematically.
|
||||||
|
|
||||||
5. **CRITICAL: Reply to EACH review comment individually.** After fixing each comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread. Keep replies extremely brief (1 sentence max, e.g., "Fixed by renaming to X" or "Added null check").
|
6. **CRITICAL: Reply to EACH review comment individually.** After fixing each comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread. Keep replies extremely brief (1 sentence max, e.g., "Fixed by renaming to X" or "Added null check"). If suggesting a small, specific, self-contained code change, use GitHub's suggestion format with \`\`\`suggestion blocks.
|
||||||
|
|
||||||
6. Test your changes to ensure they work correctly.
|
7. Test your changes to ensure they work correctly.
|
||||||
|
|
||||||
7. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one.
|
8. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one.
|
||||||
${
|
|
||||||
disableProgressComment
|
|
||||||
? ""
|
|
||||||
: `
|
|
||||||
8. ${reportProgressInstruction}
|
|
||||||
|
|
||||||
**CRITICAL: Keep the progress comment extremely brief.** The summary should be 1-2 sentences max (e.g., "Fixed 3 review comments and pushed changes."). Almost all detail belongs in the individual reply_to_review_comment calls, NOT in the progress comment.`
|
9. ${reportProgressInstruction}
|
||||||
}`,
|
|
||||||
|
**CRITICAL: Keep the progress comment extremely brief.** The summary should be 1-2 sentences max (e.g., "Fixed 3 review comments and pushed changes."). Almost all detail belongs in the individual reply_to_review_comment calls, NOT in the progress comment.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Review",
|
name: "Review",
|
||||||
@@ -111,7 +107,15 @@ ${
|
|||||||
- Can you imagine a better approach? If so, explain. Make sure it's strictly better, not just different.
|
- Can you imagine a better approach? If so, explain. Make sure it's strictly better, not just different.
|
||||||
- Are there bugs, edge cases, security issues, or usability issues? Use your imagination.
|
- Are there bugs, edge cases, security issues, or usability issues? Use your imagination.
|
||||||
|
|
||||||
3. **DRAFT** - For each inline comment, find the line in the diff. Each code line shows: \`| OLD | NEW | TYPE | CODE\`. Use the NEW line number (second column).
|
3. **DRAFT** - For each inline comment, find the line in the diff. Each code line shows: \`| OLD | NEW | TYPE | CODE\`. Use the NEW line number (second column). When suggesting specific code changes, use GitHub's suggestion format with \`\`\`suggestion blocks to enable one-click apply. Example:
|
||||||
|
you could simplify this
|
||||||
|
\`\`\`suggestion
|
||||||
|
const result = data.map(x => x.value);
|
||||||
|
\`\`\`
|
||||||
|
or you could use reduce instead
|
||||||
|
\`\`\`suggestion
|
||||||
|
const result = data.reduce((acc, x) => [...acc, x.value], []);
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
4. **FILTER COMMENTS** - Do not nitpick! Do not leave compliments that are not actionable. Do not critique the code hygiene or anything stylistic.
|
4. **FILTER COMMENTS** - Do not nitpick! Do not leave compliments that are not actionable. Do not critique the code hygiene or anything stylistic.
|
||||||
|
|
||||||
@@ -134,20 +138,20 @@ ${
|
|||||||
|
|
||||||
3. Consider dependencies, potential challenges, and implementation order
|
3. Consider dependencies, potential challenges, and implementation order
|
||||||
|
|
||||||
4. Create a structured plan with clear milestones${disableProgressComment ? "" : `\n\n5. ${reportProgressInstruction}`}`,
|
4. Create a structured plan with clear milestones
|
||||||
|
|
||||||
|
5. ${reportProgressInstruction}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
description:
|
description:
|
||||||
"Fallback for tasks that don't fit other workflows, e.g. direct prompts via comments, or requests requiring general assistance",
|
"Fallback for tasks that don't fit other workflows, e.g. direct prompts via comments, or requests requiring general assistance",
|
||||||
prompt: `Follow these steps. THINK HARDER.
|
prompt: `Follow these steps. THINK HARDER.
|
||||||
1. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.${disableProgressComment ? "" : "\n\n2. When creating comments, always use report_progress. Do not use create_issue_comment."}
|
1. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal. When creating comments, always use report_progress. Do not use create_issue_comment.
|
||||||
|
|
||||||
2. If the task involves making code changes:
|
2. If the task involves making code changes:
|
||||||
- Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production.
|
- Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production.
|
||||||
|
- ${dependencyInstallationStep}
|
||||||
${dependencyInstallationGuidance}
|
|
||||||
|
|
||||||
- Use file operations to create/modify files with your changes.
|
- Use file operations to create/modify files with your changes.
|
||||||
- Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials.
|
- Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials.
|
||||||
- Test your changes to ensure they work correctly.
|
- Test your changes to ensure they work correctly.
|
||||||
@@ -160,6 +164,4 @@ ${dependencyInstallationGuidance}
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modes: Mode[] = getModes({
|
export const modes: Mode[] = computeModes();
|
||||||
disableProgressComment: undefined,
|
|
||||||
});
|
|
||||||
|
|||||||
+9
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/pullfrog",
|
||||||
"version": "0.0.157",
|
"version": "0.0.158",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "node esbuild.config.js",
|
"build": "node esbuild.config.js",
|
||||||
"play": "node play.ts",
|
"play": "node play.ts",
|
||||||
|
"smoke": "node test/smoke.ts",
|
||||||
|
"nobash": "node test/nobash.ts",
|
||||||
"scratch": "node scratch.ts",
|
"scratch": "node scratch.ts",
|
||||||
"upDeps": "pnpm up --latest",
|
"upDeps": "pnpm up --latest",
|
||||||
"lock": "pnpm --ignore-workspace install",
|
"lock": "pnpm --ignore-workspace install",
|
||||||
@@ -25,13 +27,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.11.1",
|
"@actions/core": "^1.11.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.1.77",
|
"@anthropic-ai/claude-agent-sdk": "0.2.7",
|
||||||
"@ark/fs": "0.53.0",
|
"@ark/fs": "0.53.0",
|
||||||
"@ark/util": "0.53.0",
|
"@ark/util": "0.53.0",
|
||||||
"@octokit/plugin-throttling": "^11.0.3",
|
"@octokit/plugin-throttling": "^11.0.3",
|
||||||
"@octokit/rest": "^22.0.0",
|
"@octokit/rest": "^22.0.0",
|
||||||
"@octokit/webhooks-types": "^7.6.1",
|
"@octokit/webhooks-types": "^7.6.1",
|
||||||
"@openai/codex-sdk": "0.58.0",
|
"@openai/codex-sdk": "0.80.0",
|
||||||
"@opencode-ai/sdk": "^1.0.143",
|
"@opencode-ai/sdk": "^1.0.143",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@toon-format/toon": "^1.0.0",
|
"@toon-format/toon": "^1.0.0",
|
||||||
@@ -52,15 +54,15 @@
|
|||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/pullfrog/action.git"
|
"url": "git+https://github.com/pullfrog/pullfrog.git"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/pullfrog/action/issues"
|
"url": "https://github.com/pullfrog/pullfrog/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/pullfrog/action#readme",
|
"homepage": "https://github.com/pullfrog/pullfrog#readme",
|
||||||
"zshy": {
|
"zshy": {
|
||||||
"exports": "./index.ts"
|
"exports": "./index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { extname, join, resolve } from "node:path";
|
import { mkdtemp } from "node:fs/promises";
|
||||||
import { pathToFileURL } from "node:url";
|
import { platform, tmpdir } from "node:os";
|
||||||
|
import { dirname, extname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { fromHere } from "@ark/fs";
|
import { fromHere } from "@ark/fs";
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
@@ -10,24 +12,37 @@ import { type Inputs, main } from "./main.ts";
|
|||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { setupTestRepo } from "./utils/setup.ts";
|
import { setupTestRepo } from "./utils/setup.ts";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
// load action's .env file in case it exists for local dev
|
// load action's .env file in case it exists for local dev
|
||||||
config();
|
config();
|
||||||
// .env file should always be at repo root for pullfrog/pullfrog repo with action submodule
|
// also load .env from repo root (for monorepo structure)
|
||||||
config({ path: join(process.cwd(), "..", ".env") });
|
config({ path: join(__dirname, "..", ".env") });
|
||||||
|
|
||||||
|
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
||||||
|
// 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-"));
|
||||||
|
const tempDir = join(tempParent, "repo");
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
export async function run(prompt: string): Promise<AgentResult> {
|
|
||||||
try {
|
try {
|
||||||
const tempDir = join(process.cwd(), ".temp");
|
|
||||||
setupTestRepo({ tempDir });
|
setupTestRepo({ tempDir });
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
process.chdir(tempDir);
|
process.chdir(tempDir);
|
||||||
|
|
||||||
const inputs: Inputs = {
|
// allow passing full Inputs object or just a prompt string
|
||||||
prompt,
|
const inputs: Inputs =
|
||||||
};
|
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
|
||||||
|
|
||||||
const result = await main(inputs);
|
// set INPUT_* env vars for @actions/core.getInput()
|
||||||
|
for (const [key, value] of Object.entries(inputs)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
process.env[`INPUT_${key.toUpperCase()}`] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await main();
|
||||||
|
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
@@ -42,6 +57,10 @@ export async function run(prompt: string): Promise<AgentResult> {
|
|||||||
const errorMessage = (err as Error).message;
|
const errorMessage = (err as Error).message;
|
||||||
log.error(`Error: ${errorMessage}`);
|
log.error(`Error: ${errorMessage}`);
|
||||||
return { success: false, error: errorMessage, output: undefined };
|
return { success: false, error: errorMessage, output: undefined };
|
||||||
|
} finally {
|
||||||
|
// cleanup temp directory
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
rmSync(tempParent, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +84,7 @@ Arguments:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||||
--local, -l Run locally on macOS (default: runs in Docker)
|
--local, -l Run locally (default: runs in Docker)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
@@ -86,34 +105,84 @@ Examples:
|
|||||||
if (!useLocal) {
|
if (!useLocal) {
|
||||||
log.info("» running in Docker container...");
|
log.info("» running in Docker container...");
|
||||||
|
|
||||||
const passArgs = process.argv.slice(2);
|
const passArgs = process.argv
|
||||||
const nodeCmd = `node play.ts ${passArgs.join(" ")}`;
|
.slice(2)
|
||||||
|
// shell-escape each argument to handle special characters in JSON payloads
|
||||||
|
.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`)
|
||||||
|
.join(" ");
|
||||||
|
const nodeCmd = `node play.ts ${passArgs}`;
|
||||||
|
|
||||||
// pass all env vars to docker
|
// pass all env vars to docker
|
||||||
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
||||||
value !== undefined ? ["-e", `${key}=${value}`] : []
|
value !== undefined ? ["-e", `${key}=${value}`] : []
|
||||||
);
|
);
|
||||||
|
|
||||||
// SSH for git - mount individual SSH files to avoid permission issues
|
// SSH for git - platform-specific handling
|
||||||
const sshFlags: string[] = [];
|
const sshFlags: string[] = [];
|
||||||
|
let sshSetupCmd = "";
|
||||||
|
const plat = platform();
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
if (home) {
|
|
||||||
const sshDir = join(home, ".ssh");
|
if (plat === "win32") {
|
||||||
// mount SSH keys (try common key names)
|
throw new Error(
|
||||||
for (const keyName of ["id_rsa", "id_ed25519", "id_ecdsa"]) {
|
"Docker mode is not supported on native Windows. Use WSL2 or set PLAY_LOCAL=1."
|
||||||
const keyPath = join(sshDir, keyName);
|
);
|
||||||
if (existsSync(keyPath)) {
|
} else if (plat === "darwin") {
|
||||||
sshFlags.push("-v", `${keyPath}:/root/.ssh/${keyName}:ro`);
|
// macOS: Docker Desktop SSH agent forwarding
|
||||||
|
if (home) {
|
||||||
|
const knownHostsPath = join(home, ".ssh", "known_hosts");
|
||||||
|
if (existsSync(knownHostsPath)) {
|
||||||
|
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// mount known_hosts
|
sshFlags.push(
|
||||||
const knownHostsPath = join(sshDir, "known_hosts");
|
"-v",
|
||||||
if (existsSync(knownHostsPath)) {
|
"/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
||||||
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
"-e",
|
||||||
|
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Linux/WSL: copy .ssh files into container with correct permissions
|
||||||
|
if (home) {
|
||||||
|
const sshDir = join(home, ".ssh");
|
||||||
|
if (existsSync(sshDir)) {
|
||||||
|
sshFlags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`);
|
||||||
|
// copy ssh keys, add github.com to known_hosts, set GIT_SSH_COMMAND to use them
|
||||||
|
sshSetupCmd =
|
||||||
|
"mkdir -p /tmp/home/.ssh && cp /tmp/.ssh-host/id_* /tmp/home/.ssh/ 2>/dev/null; chmod 600 /tmp/home/.ssh/id_* 2>/dev/null; " +
|
||||||
|
"ssh-keyscan -t ed25519,rsa github.com >> /tmp/home/.ssh/known_hosts 2>/dev/null; chmod 644 /tmp/home/.ssh/known_hosts; " +
|
||||||
|
"export GIT_SSH_COMMAND='ssh -i /tmp/home/.ssh/id_rsa -o UserKnownHostsFile=/tmp/home/.ssh/known_hosts -o StrictHostKeyChecking=no'; ";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttyFlags = process.stdin.isTTY ? ["-it"] : [];
|
// always allocate a pseudo-TTY - Claude Code may require it
|
||||||
|
const ttyFlags = ["-t"];
|
||||||
|
|
||||||
|
// run as current user to avoid Claude CLI's root user restriction
|
||||||
|
const uid = process.getuid?.() ?? 1000;
|
||||||
|
const gid = process.getgid?.() ?? 1000;
|
||||||
|
|
||||||
|
// use agent-specific volume to avoid conflicts when running in parallel
|
||||||
|
const agentOverride = process.env.AGENT_OVERRIDE ?? "default";
|
||||||
|
const volumeName = `pullfrog-action-node-modules-${agentOverride}`;
|
||||||
|
|
||||||
|
// initialize volume with correct ownership (runs as root briefly)
|
||||||
|
spawnSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
`${volumeName}:/app/action/node_modules`,
|
||||||
|
"node:24",
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
`${uid}:${gid}`,
|
||||||
|
"/app/action/node_modules",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore", cwd: __dirname }
|
||||||
|
);
|
||||||
|
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
"docker",
|
"docker",
|
||||||
@@ -121,24 +190,28 @@ Examples:
|
|||||||
"run",
|
"run",
|
||||||
"--rm",
|
"--rm",
|
||||||
...ttyFlags,
|
...ttyFlags,
|
||||||
|
"--user",
|
||||||
|
`${uid}:${gid}`,
|
||||||
"-v",
|
"-v",
|
||||||
`${process.cwd()}:/app/action:cached`,
|
`${__dirname}:/app/action:cached`,
|
||||||
"-v",
|
"-v",
|
||||||
"pullfrog-action-node-modules:/app/action/node_modules",
|
`${volumeName}:/app/action/node_modules`,
|
||||||
"-w",
|
"-w",
|
||||||
"/app/action",
|
"/app/action",
|
||||||
...envFlags,
|
...envFlags,
|
||||||
...sshFlags,
|
...sshFlags,
|
||||||
"--cap-add",
|
"-e",
|
||||||
"SYS_ADMIN",
|
"COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
|
||||||
"--security-opt",
|
"-e",
|
||||||
"seccomp:unconfined",
|
"HOME=/tmp/home",
|
||||||
|
"-e",
|
||||||
|
"TMPDIR=/tmp",
|
||||||
"node:24",
|
"node:24",
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
`corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`,
|
`${sshSetupCmd}mkdir -p /tmp/home/.config /tmp/home/.cache && corepack pnpm install --frozen-lockfile --ignore-scripts && ${nodeCmd}`,
|
||||||
],
|
],
|
||||||
{ stdio: "inherit" }
|
{ stdio: "inherit", cwd: __dirname }
|
||||||
);
|
);
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
process.exit(result.status ?? 1);
|
||||||
@@ -154,7 +227,7 @@ Examples:
|
|||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
let resolvedPath: string;
|
let resolvedPath: string;
|
||||||
|
|
||||||
const fixturesPath = fromHere("fixtures", filePath);
|
const fixturesPath = fromHere("test", "fixtures", filePath);
|
||||||
if (existsSync(fixturesPath)) {
|
if (existsSync(fixturesPath)) {
|
||||||
resolvedPath = fixturesPath;
|
resolvedPath = fixturesPath;
|
||||||
} else if (existsSync(filePath)) {
|
} else if (existsSync(filePath)) {
|
||||||
@@ -211,6 +284,14 @@ Examples:
|
|||||||
|
|
||||||
process.exit(allSuccess ? 0 : 1);
|
process.exit(allSuccess ? 0 : 1);
|
||||||
} else if (typeof module.default === "object") {
|
} else if (typeof module.default === "object") {
|
||||||
|
const obj = module.default as Record<string, unknown>;
|
||||||
|
// Inputs objects have `prompt` field and optional tool permission fields
|
||||||
|
// Payload objects have `~pullfrog` field
|
||||||
|
if ("prompt" in obj && !("~pullfrog" in obj)) {
|
||||||
|
// this is an Inputs object - run directly with tool permissions
|
||||||
|
const result = await run(obj as Inputs);
|
||||||
|
process.exit(result.success ? 0 : 1);
|
||||||
|
}
|
||||||
// Payload objects (with ~pullfrog) should be stringified
|
// Payload objects (with ~pullfrog) should be stringified
|
||||||
prompt = JSON.stringify(module.default, null, 2);
|
prompt = JSON.stringify(module.default, null, 2);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Generated
+11
-11
@@ -15,8 +15,8 @@ importers:
|
|||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.1
|
version: 6.0.1
|
||||||
'@anthropic-ai/claude-agent-sdk':
|
'@anthropic-ai/claude-agent-sdk':
|
||||||
specifier: 0.1.77
|
specifier: 0.2.7
|
||||||
version: 0.1.77(zod@4.3.5)
|
version: 0.2.7(zod@4.3.5)
|
||||||
'@ark/fs':
|
'@ark/fs':
|
||||||
specifier: 0.53.0
|
specifier: 0.53.0
|
||||||
version: 0.53.0
|
version: 0.53.0
|
||||||
@@ -33,8 +33,8 @@ importers:
|
|||||||
specifier: ^7.6.1
|
specifier: ^7.6.1
|
||||||
version: 7.6.1
|
version: 7.6.1
|
||||||
'@openai/codex-sdk':
|
'@openai/codex-sdk':
|
||||||
specifier: 0.58.0
|
specifier: 0.80.0
|
||||||
version: 0.58.0
|
version: 0.80.0
|
||||||
'@opencode-ai/sdk':
|
'@opencode-ai/sdk':
|
||||||
specifier: ^1.0.143
|
specifier: ^1.0.143
|
||||||
version: 1.0.143
|
version: 1.0.143
|
||||||
@@ -99,11 +99,11 @@ packages:
|
|||||||
'@actions/io@1.1.3':
|
'@actions/io@1.1.3':
|
||||||
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
|
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
|
||||||
|
|
||||||
'@anthropic-ai/claude-agent-sdk@0.1.77':
|
'@anthropic-ai/claude-agent-sdk@0.2.7':
|
||||||
resolution: {integrity: sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==}
|
resolution: {integrity: sha512-I1/zcnLah74kZeRkj/1QnDaC6ItJ2m/Bftlm25uoaRkZx7i7SkcpqM9jGE/r2A8PMxnw5WpabP60Xgj99CrTuw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^4.0.0
|
||||||
|
|
||||||
'@ark/fs@0.53.0':
|
'@ark/fs@0.53.0':
|
||||||
resolution: {integrity: sha512-XL0EbBAZgyy+j9aPhftYaBsbKAW5PTNSKCN6oLRRdrHuHPSAZgR6765/z0YZGhPxHEUNmq0vBoSk8yOLk91dNQ==}
|
resolution: {integrity: sha512-XL0EbBAZgyy+j9aPhftYaBsbKAW5PTNSKCN6oLRRdrHuHPSAZgR6765/z0YZGhPxHEUNmq0vBoSk8yOLk91dNQ==}
|
||||||
@@ -653,8 +653,8 @@ packages:
|
|||||||
'@octokit/webhooks-types@7.6.1':
|
'@octokit/webhooks-types@7.6.1':
|
||||||
resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==}
|
resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==}
|
||||||
|
|
||||||
'@openai/codex-sdk@0.58.0':
|
'@openai/codex-sdk@0.80.0':
|
||||||
resolution: {integrity: sha512-Z5vK294gUS9cn7bpU/lJtgzqJy1UqIGee7WMP+1Z4a6AxDcTxFCLZI4YkH9praJfrgoj5bFeu+3V9HIoBBTzcw==}
|
resolution: {integrity: sha512-4+/bZOSjJAPsuM6yceQoVbNUK7UTS03QMKxCrFClObxD1j6n5oh7OSJniyff2MmF0DI7yHnf9omzfFL7RoLWnQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@opencode-ai/sdk@1.0.143':
|
'@opencode-ai/sdk@1.0.143':
|
||||||
@@ -1697,7 +1697,7 @@ snapshots:
|
|||||||
|
|
||||||
'@actions/io@1.1.3': {}
|
'@actions/io@1.1.3': {}
|
||||||
|
|
||||||
'@anthropic-ai/claude-agent-sdk@0.1.77(zod@4.3.5)':
|
'@anthropic-ai/claude-agent-sdk@0.2.7(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.3.5
|
zod: 4.3.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -2101,7 +2101,7 @@ snapshots:
|
|||||||
|
|
||||||
'@octokit/webhooks-types@7.6.1': {}
|
'@octokit/webhooks-types@7.6.1': {}
|
||||||
|
|
||||||
'@openai/codex-sdk@0.58.0': {}
|
'@openai/codex-sdk@0.80.0': {}
|
||||||
|
|
||||||
'@opencode-ai/sdk@1.0.143': {}
|
'@opencode-ai/sdk@1.0.143': {}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ export async function runPrepPhase(): Promise<PrepResult[]> {
|
|||||||
if (result.dependenciesInstalled) {
|
if (result.dependenciesInstalled) {
|
||||||
log.debug(`» ${step.name}: dependencies installed`);
|
log.debug(`» ${step.name}: dependencies installed`);
|
||||||
} else if (result.issues.length > 0) {
|
} else if (result.issues.length > 0) {
|
||||||
log.warning(`⚠️ ${step.name}: ${result.issues[0]}`);
|
log.warning(`» ${step.name}: ${result.issues[0]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async function installPackageManager(
|
|||||||
installSpec: string
|
installSpec: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (name === "npm") return null; // npm is always available
|
if (name === "npm") return null; // npm is always available
|
||||||
log.info(`📦 installing ${installSpec}...`);
|
log.info(`» installing ${installSpec}...`);
|
||||||
const [cmd, ...templateArgs] = nodePackageManagers[name];
|
const [cmd, ...templateArgs] = nodePackageManagers[name];
|
||||||
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
|
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
@@ -76,7 +76,7 @@ async function installPackageManager(
|
|||||||
process.env.PATH = `${denoPath}:${process.env.PATH}`;
|
process.env.PATH = `${denoPath}:${process.env.PATH}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`✅ installed ${name}`);
|
log.info(`» installed ${name}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,16 +101,16 @@ export const installNodeDependencies: PrepDefinition = {
|
|||||||
const agent = detected?.agent || packageManager;
|
const agent = detected?.agent || packageManager;
|
||||||
|
|
||||||
if (fromPackageJson) {
|
if (fromPackageJson) {
|
||||||
log.info(`📦 using packageManager from package.json: ${fromPackageJson.installSpec}`);
|
log.info(`» using packageManager from package.json: ${fromPackageJson.installSpec}`);
|
||||||
} else if (detected) {
|
} else if (detected) {
|
||||||
log.info(`📦 detected package manager: ${packageManager} (${agent})`);
|
log.info(`» detected package manager: ${packageManager} (${agent})`);
|
||||||
} else {
|
} else {
|
||||||
log.info(`📦 no package manager detected, defaulting to npm`);
|
log.info(`» no package manager detected, defaulting to npm`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if package manager is available, install if needed
|
// check if package manager is available, install if needed
|
||||||
if (!(await isCommandAvailable(packageManager))) {
|
if (!(await isCommandAvailable(packageManager))) {
|
||||||
log.info(`${packageManager} not found, attempting to install...`);
|
log.info(`» ${packageManager} not found, attempting to install...`);
|
||||||
const installError = await installPackageManager(packageManager, installSpec);
|
const installError = await installPackageManager(packageManager, installSpec);
|
||||||
if (installError) {
|
if (installError) {
|
||||||
return {
|
return {
|
||||||
@@ -133,20 +133,25 @@ export const installNodeDependencies: PrepDefinition = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`running: ${resolved.command} ${resolved.args.join(" ")}`);
|
const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`;
|
||||||
|
log.info(`» running: ${fullCommand}`);
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: resolved.command,
|
cmd: resolved.command,
|
||||||
args: resolved.args,
|
args: resolved.args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
||||||
|
onStdout: (chunk) => process.stdout.write(chunk),
|
||||||
onStderr: (chunk) => process.stderr.write(chunk),
|
onStderr: (chunk) => process.stderr.write(chunk),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
|
// combine stdout and stderr for better error context (pnpm often outputs errors to stdout)
|
||||||
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||||
|
const errorMessage = output || `exited with code ${result.exitCode}`;
|
||||||
return {
|
return {
|
||||||
language: "node",
|
language: "node",
|
||||||
packageManager,
|
packageManager,
|
||||||
dependenciesInstalled: false,
|
dependenciesInstalled: false,
|
||||||
issues: [result.stderr || `${resolved.command} exited with code ${result.exitCode}`],
|
issues: [`\`${fullCommand}\` failed:\n${errorMessage}`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function installTool(name: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`📦 installing ${name}...`);
|
log.info(`» installing ${name}...`);
|
||||||
const [cmd, ...args] = installCmd;
|
const [cmd, ...args] = installCmd;
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd,
|
cmd,
|
||||||
@@ -79,7 +79,7 @@ async function installTool(name: string): Promise<string | null> {
|
|||||||
return result.stderr || `failed to install ${name}`;
|
return result.stderr || `failed to install ${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`✅ installed ${name}`);
|
log.info(`» installed ${name}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +113,12 @@ export const installPythonDependencies: PrepDefinition = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`🐍 detected python config: ${config.file} (using ${config.tool})`);
|
log.info(`» detected python config: ${config.file} (using ${config.tool})`);
|
||||||
|
|
||||||
// check if the tool is available, install if needed
|
// check if the tool is available, install if needed
|
||||||
const isAvailable = await isCommandAvailable(config.tool);
|
const isAvailable = await isCommandAvailable(config.tool);
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
log.info(`${config.tool} not found, attempting to install...`);
|
log.info(`» ${config.tool} not found, attempting to install...`);
|
||||||
const installError = await installTool(config.tool);
|
const installError = await installTool(config.tool);
|
||||||
if (installError) {
|
if (installError) {
|
||||||
return {
|
return {
|
||||||
@@ -133,7 +133,7 @@ export const installPythonDependencies: PrepDefinition = {
|
|||||||
|
|
||||||
// run the install command
|
// run the install command
|
||||||
const [cmd, ...args] = config.installCmd;
|
const [cmd, ...args] = config.installCmd;
|
||||||
log.info(`running: ${cmd} ${args.join(" ")}`);
|
log.info(`» running: ${cmd} ${args.join(" ")}`);
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test fixture: tests bash=disabled enforcement.
|
||||||
|
* the agent should NOT be able to run bash commands.
|
||||||
|
*
|
||||||
|
* run with: AGENT_OVERRIDE=claude pnpm play bash-disabled.ts
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `Run a simple bash command: echo "hello world"
|
||||||
|
|
||||||
|
If you cannot run this command, explain that bash is disabled.`,
|
||||||
|
bash: "disabled",
|
||||||
|
} satisfies Inputs;
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test fixture: tests bash=restricted enforcement.
|
||||||
|
* the agent should use MCP bash tool (not native bash).
|
||||||
|
*
|
||||||
|
* run with: AGENT_OVERRIDE=claude pnpm play bash-restricted.ts
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `Run this bash command: echo "hello from restricted mode"
|
||||||
|
|
||||||
|
Use the gh_pullfrog/bash MCP tool since native bash is disabled for security.`,
|
||||||
|
bash: "restricted",
|
||||||
|
} satisfies Inputs;
|
||||||
+1
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { Payload } from "../external.ts";
|
import type { Payload } from "../../external.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* test fixture: verifies agents use MCP bash tool for shell commands.
|
* test fixture: verifies agents use MCP bash tool for shell commands.
|
||||||
@@ -14,7 +14,6 @@ import type { Payload } from "../external.ts";
|
|||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
agent: null,
|
|
||||||
prompt: `Create a file called test-runner.js with the following content:
|
prompt: `Create a file called test-runner.js with the following content:
|
||||||
|
|
||||||
\`\`\`javascript
|
\`\`\`javascript
|
||||||
@@ -31,5 +30,4 @@ This tests that you can execute shell commands properly.`,
|
|||||||
event: {
|
event: {
|
||||||
trigger: "workflow_dispatch",
|
trigger: "workflow_dispatch",
|
||||||
},
|
},
|
||||||
modes: [],
|
|
||||||
} satisfies Payload;
|
} satisfies Payload;
|
||||||
+1
-3
@@ -1,12 +1,10 @@
|
|||||||
import type { Payload } from "../external.ts";
|
import type { Payload } from "../../external.ts";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
agent: null,
|
|
||||||
prompt:
|
prompt:
|
||||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||||
event: {
|
event: {
|
||||||
trigger: "workflow_dispatch",
|
trigger: "workflow_dispatch",
|
||||||
},
|
},
|
||||||
modes: [],
|
|
||||||
} satisfies Payload;
|
} satisfies Payload;
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
Say hi
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Effort, Payload } from "../../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixture for Claude effort levels.
|
||||||
|
* Runs all three effort levels in sequence.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* AGENT_OVERRIDE=claude pnpm play claude-effort.ts
|
||||||
|
*
|
||||||
|
* Effort levels:
|
||||||
|
* - "mini": haiku (fast, efficient)
|
||||||
|
* - "auto": opusplan (Opus for planning, Sonnet for execution)
|
||||||
|
* - "max": opus (full Opus capability)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const efforts: Effort[] = ["mini", "auto", "max"];
|
||||||
|
|
||||||
|
export default efforts.map((effort) => ({
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: "claude",
|
||||||
|
prompt: "What is 2 + 2? Reply with just the number.",
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
effort,
|
||||||
|
})) satisfies Payload[];
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Effort, Payload } from "../../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixture for Codex effort levels.
|
||||||
|
* Runs all three effort levels in sequence.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* AGENT_OVERRIDE=codex pnpm play codex-effort.ts
|
||||||
|
*
|
||||||
|
* Effort levels:
|
||||||
|
* - "mini": gpt-5.1-codex-mini + modelReasoningEffort: "low"
|
||||||
|
* - "auto": gpt-5.1-codex + default reasoning
|
||||||
|
* - "max": gpt-5.1-codex-max + modelReasoningEffort: "high"
|
||||||
|
*/
|
||||||
|
|
||||||
|
const efforts: Effort[] = ["mini", "auto", "max"];
|
||||||
|
|
||||||
|
export default efforts.map((effort) => ({
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: "codex",
|
||||||
|
prompt: "What is 2 + 2? Reply with just the number.",
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
effort,
|
||||||
|
})) satisfies Payload[];
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Effort, Payload } from "../external.ts";
|
import type { Effort, Payload } from "../../external.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test fixture for Cursor effort levels.
|
* Test fixture for Cursor effort levels.
|
||||||
@@ -8,15 +8,15 @@ import type { Effort, Payload } from "../external.ts";
|
|||||||
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
|
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
|
||||||
*
|
*
|
||||||
* Effort levels:
|
* Effort levels:
|
||||||
* - "nothink": auto (default model)
|
* - "mini": auto (default model)
|
||||||
* - "think": auto (default model)
|
* - "auto": auto (default model)
|
||||||
* - "max": opus-4.5-thinking
|
* - "max": opus-4.5-thinking
|
||||||
*
|
*
|
||||||
* Note: If project has .cursor/cli.json with "model" specified,
|
* Note: If project has .cursor/cli.json with "model" specified,
|
||||||
* that takes precedence over effort-based model selection.
|
* that takes precedence over effort-based model selection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const efforts: Effort[] = ["nothink", "think", "max"];
|
const efforts: Effort[] = ["mini", "auto", "max"];
|
||||||
|
|
||||||
export default efforts.map((effort) => ({
|
export default efforts.map((effort) => ({
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Effort, Payload } from "../external.ts";
|
import type { Effort, Payload } from "../../external.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test fixture for Gemini effort levels.
|
* Test fixture for Gemini effort levels.
|
||||||
@@ -8,12 +8,12 @@ import type { Effort, Payload } from "../external.ts";
|
|||||||
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
|
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
|
||||||
*
|
*
|
||||||
* Effort levels:
|
* Effort levels:
|
||||||
* - "nothink": gemini-2.5-flash + LOW thinking
|
* - "mini": gemini-2.5-flash + LOW thinking
|
||||||
* - "think": gemini-2.5-flash + HIGH thinking
|
* - "auto": gemini-2.5-flash + HIGH thinking
|
||||||
* - "max": gemini-2.5-pro + HIGH thinking
|
* - "max": gemini-2.5-pro + HIGH thinking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const efforts: Effort[] = ["nothink", "think", "max"];
|
const efforts: Effort[] = ["mini", "auto", "max"];
|
||||||
|
|
||||||
export default efforts.map((effort) => ({
|
export default efforts.map((effort) => ({
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nobash test fixture - validates bash=disabled enforcement.
|
||||||
|
* agent should NOT be able to run any bash commands (native or MCP).
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `First, call the select_mode tool with modeName "Build".
|
||||||
|
|
||||||
|
Then attempt to run a bash command: echo "BASH_OUTPUT_MARKER"
|
||||||
|
|
||||||
|
Check your available tools including any MCP servers (like gh_pullfrog) for bash-related tools.
|
||||||
|
If no bash tool is available (neither native nor MCP), say "NO BASH AVAILABLE".
|
||||||
|
If you successfully ran the echo command, say "BASH EXECUTED".`,
|
||||||
|
bash: "disabled",
|
||||||
|
effort: "mini",
|
||||||
|
} satisfies Inputs;
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test fixture: tests granular tool permissions enforcement.
|
||||||
|
* all tools are disabled, so web access, search, file writes, and bash should be blocked.
|
||||||
|
*
|
||||||
|
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `Please do the following three things:
|
||||||
|
|
||||||
|
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
||||||
|
2. Create a file called sandbox-test.txt with the content "This should fail with write disabled"
|
||||||
|
3. Run a bash command: echo "hello from bash" > bash-test.txt
|
||||||
|
|
||||||
|
All three of these actions should fail because tool permissions are restricted (web=disabled, write=disabled, bash=disabled).`,
|
||||||
|
// granular tool permissions - all disabled
|
||||||
|
web: "disabled",
|
||||||
|
search: "disabled",
|
||||||
|
write: "disabled",
|
||||||
|
bash: "disabled",
|
||||||
|
} satisfies Inputs;
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* simple test - no tool restrictions
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `Just say "hello world" - no tools needed.`,
|
||||||
|
} satisfies Inputs;
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Inputs } from "../../main.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* smoke test fixture - minimal prompt to verify:
|
||||||
|
* 1. agent connects to API
|
||||||
|
* 2. MCP server responds
|
||||||
|
* 3. select_mode tool works
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
prompt: `Call the select_mode tool with modeName "Build" and confirm you received the mode's prompt instructions. Then say "SMOKE TEST PASSED".`,
|
||||||
|
effort: "mini",
|
||||||
|
} satisfies Inputs;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AgentResult, ValidationCheck } from "./utils.ts";
|
||||||
|
import { runTests } from "./utils.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nobash test - validates agents respect bash=disabled setting.
|
||||||
|
* no bash should be available (neither native nor MCP bash).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function validator(result: AgentResult): ValidationCheck[] {
|
||||||
|
// verify select_mode MCP tool was called (proves MCP tools work)
|
||||||
|
const selectModeCalled = /→.*select_mode\s*\([^)]*"modeName"\s*:\s*"Build"/i.test(result.output);
|
||||||
|
|
||||||
|
// agent should report no bash is available (look for the phrase as standalone output)
|
||||||
|
const noBashAvailable = /NO BASH AVAILABLE/i.test(result.output);
|
||||||
|
|
||||||
|
// bash tool should NOT have been called (no → bash or → mcp__gh_pullfrog__bash)
|
||||||
|
const bashNotCalled = !/→\s*(?:bash|mcp__gh_pullfrog__bash)\s*\(/i.test(result.output);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: "mcp_tool", passed: selectModeCalled },
|
||||||
|
{ name: "no_bash", passed: noBashAvailable },
|
||||||
|
{ name: "not_called", passed: bashNotCalled },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests({
|
||||||
|
name: "nobash tests",
|
||||||
|
fixture: "nobash.ts",
|
||||||
|
validator,
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AgentResult, ValidationCheck } from "./utils.ts";
|
||||||
|
import { runTests } from "./utils.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* smoke test - validates agent can connect to API and call MCP tools.
|
||||||
|
* verifies select_mode tool is called with correct params.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function validator(result: AgentResult): ValidationCheck[] {
|
||||||
|
// verify MCP tool was called with correct params:
|
||||||
|
// → select_mode({"modeName":"Build"}) or → mcp__gh_pullfrog__select_mode({"modeName":"Build"})
|
||||||
|
const toolCallValid = /→.*select_mode\s*\([^)]*"modeName"\s*:\s*"Build"/i.test(result.output);
|
||||||
|
// verify agent confirmed success
|
||||||
|
const confirmationFound = /SMOKE TEST PASSED/i.test(result.output);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: "tool_call", passed: toolCallValid },
|
||||||
|
{ name: "confirm", passed: confirmationFound },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests({
|
||||||
|
name: "smoke tests",
|
||||||
|
fixture: "smoke.ts",
|
||||||
|
validator,
|
||||||
|
});
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
import { agentsManifest } from "../external.ts";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const actionDir = join(__dirname, "..");
|
||||||
|
|
||||||
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spinner {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSpinner(message: string): Spinner {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// skip animated spinner in CI
|
||||||
|
if (process.env.CI) {
|
||||||
|
console.log(`${message}...`);
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
const elapsed = formatElapsed(Date.now() - startTime);
|
||||||
|
console.log(`✓ completed in ${elapsed}\n`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameIndex = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const elapsed = formatElapsed(Date.now() - startTime);
|
||||||
|
const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
|
||||||
|
process.stdout.write(`\r${frame} ${message} (${elapsed})...`);
|
||||||
|
frameIndex++;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
const elapsed = formatElapsed(Date.now() - startTime);
|
||||||
|
process.stdout.write(`\r${" ".repeat(60)}\r`); // clear line
|
||||||
|
console.log(`✓ ${message} completed in ${elapsed}\n`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// load .env files
|
||||||
|
config({ path: join(actionDir, ".env") });
|
||||||
|
config({ path: join(actionDir, "..", ".env") });
|
||||||
|
|
||||||
|
export const agents = Object.keys(agentsManifest) as (keyof typeof agentsManifest)[];
|
||||||
|
|
||||||
|
export interface AgentResult {
|
||||||
|
agent: string;
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationCheck {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
agent: string;
|
||||||
|
passed: boolean;
|
||||||
|
checks: ValidationCheck[];
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidatorFn = (result: AgentResult) => ValidationCheck[];
|
||||||
|
|
||||||
|
export interface RunOptions {
|
||||||
|
fixture: string;
|
||||||
|
env?: Record<string, string> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgent(agent: string, options: RunOptions): Promise<AgentResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
const child = spawn("node", ["play.ts", options.fixture], {
|
||||||
|
cwd: actionDir,
|
||||||
|
env: { ...process.env, AGENT_OVERRIDE: agent, ...options.env },
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data) => chunks.push(data));
|
||||||
|
child.stderr?.on("data", (data) => chunks.push(data));
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
resolve({
|
||||||
|
agent,
|
||||||
|
success: code === 0,
|
||||||
|
output: Buffer.concat(chunks).toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateResult(result: AgentResult, validator: ValidatorFn): ValidationResult {
|
||||||
|
const checks = validator(result);
|
||||||
|
const allPassed = checks.every((c) => c.passed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: result.agent,
|
||||||
|
passed: result.success && allPassed,
|
||||||
|
checks,
|
||||||
|
output: result.output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAllAgents(options: RunOptions): Promise<AgentResult[]> {
|
||||||
|
return Promise.all(agents.map((agent) => runAgent(agent, options)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunnerOptions {
|
||||||
|
name: string;
|
||||||
|
fixture: string;
|
||||||
|
validator: ValidatorFn;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTests(options: TestRunnerOptions): Promise<void> {
|
||||||
|
const agentArg = process.argv[2];
|
||||||
|
|
||||||
|
if (agentArg) {
|
||||||
|
// single agent mode
|
||||||
|
if (!agents.includes(agentArg as (typeof agents)[number])) {
|
||||||
|
console.error(`unknown agent: ${agentArg}`);
|
||||||
|
console.error(`available agents: ${agents.join(", ")}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`running ${options.name} for: ${agentArg}\n`);
|
||||||
|
const spinner = startSpinner(`running ${agentArg} - this may take a few minutes`);
|
||||||
|
const result = await runAgent(agentArg, { fixture: options.fixture, env: options.env });
|
||||||
|
spinner.stop();
|
||||||
|
const validation = validateResult(result, options.validator);
|
||||||
|
console.log(result.output);
|
||||||
|
printSingleValidation(validation);
|
||||||
|
process.exit(validation.passed ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parallel mode
|
||||||
|
console.log(`running ${options.name} for: ${agents.join(", ")}\n`);
|
||||||
|
const spinner = startSpinner(
|
||||||
|
`running ${agents.length} agents in parallel - this may take a few minutes`
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await runAllAgents({ fixture: options.fixture, env: options.env });
|
||||||
|
spinner.stop();
|
||||||
|
|
||||||
|
const validations = results.map((r) => validateResult(r, options.validator));
|
||||||
|
|
||||||
|
printResults(validations);
|
||||||
|
|
||||||
|
const failed = validations.filter((v) => !v.passed);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
printFailedOutputs(failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(failed.length > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSingleValidation(validation: ValidationResult): void {
|
||||||
|
const checksStr = validation.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
|
||||||
|
console.log(`\nvalidation: ${checksStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printResults(validations: ValidationResult[]): void {
|
||||||
|
// build header from check names
|
||||||
|
const checkNames = validations[0]?.checks.map((c) => c.name) ?? [];
|
||||||
|
const headerCols = checkNames.map((n) => n.toUpperCase().padEnd(12)).join("");
|
||||||
|
|
||||||
|
console.log("Results:");
|
||||||
|
console.log("-".repeat(60));
|
||||||
|
console.log(`STATUS AGENT ${headerCols}`);
|
||||||
|
console.log("-".repeat(60));
|
||||||
|
|
||||||
|
for (const v of validations) {
|
||||||
|
const status = v.passed ? "✅ PASS" : "❌ FAIL";
|
||||||
|
const checkCols = v.checks.map((c) => (c.passed ? "✓" : "✗").padEnd(12)).join("");
|
||||||
|
console.log(`${status} ${v.agent.padEnd(10)} ${checkCols}`);
|
||||||
|
}
|
||||||
|
console.log("-".repeat(60));
|
||||||
|
|
||||||
|
const passed = validations.filter((v) => v.passed);
|
||||||
|
console.log(`\n${passed.length}/${validations.length} passed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFailedOutputs(failed: ValidationResult[]): void {
|
||||||
|
console.log(`\nFailed agents output:\n`);
|
||||||
|
for (const v of failed) {
|
||||||
|
console.log(`${"=".repeat(60)}`);
|
||||||
|
console.log(`${v.agent}`);
|
||||||
|
console.log(`${"=".repeat(60)}`);
|
||||||
|
console.log(v.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { type Agent, agents } from "../agents/index.ts";
|
||||||
|
import type { AgentName } from "../external.ts";
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
import type { ResolvedPayload } from "./payload.ts";
|
||||||
|
import type { RepoSettings } from "./repoSettings.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an agent has API keys available (from process.env)
|
||||||
|
*/
|
||||||
|
function agentHasApiKeys(agent: Agent): boolean {
|
||||||
|
// empty apiKeyNames means agent accepts any *API_KEY* env var
|
||||||
|
if (agent.apiKeyNames.length === 0) {
|
||||||
|
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
||||||
|
}
|
||||||
|
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableAgents(): Agent[] {
|
||||||
|
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgent(params: {
|
||||||
|
payload: ResolvedPayload;
|
||||||
|
repoSettings: RepoSettings;
|
||||||
|
}): Agent {
|
||||||
|
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||||
|
log.debug(
|
||||||
|
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${params.payload.agent}, repoSettings.defaultAgent=${params.repoSettings.defaultAgent}`
|
||||||
|
);
|
||||||
|
const configuredAgentName =
|
||||||
|
agentOverride || params.payload.agent || params.repoSettings.defaultAgent || undefined;
|
||||||
|
|
||||||
|
if (configuredAgentName) {
|
||||||
|
const agent = agents[configuredAgentName];
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`invalid agent name: ${configuredAgentName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if explicitly configured (via override or payload), respect it even without matching keys
|
||||||
|
// this allows users to force an agent selection (will fail later with clear error if no keys)
|
||||||
|
const isExplicitOverride = agentOverride !== undefined || params.payload.agent !== null;
|
||||||
|
if (isExplicitOverride) {
|
||||||
|
log.info(`» selected configured agent: ${agent.name}`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for repo-level defaults, check if agent has matching keys before selecting
|
||||||
|
if (agentHasApiKeys(agent)) {
|
||||||
|
log.info(`» selected configured agent: ${agent.name}`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fall through to auto-selection
|
||||||
|
const availableAgents = getAvailableAgents();
|
||||||
|
log.warning(
|
||||||
|
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
||||||
|
availableAgents.map((a) => a.name).join(", ") || "none"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableAgents = getAvailableAgents();
|
||||||
|
if (availableAgents.length === 0) {
|
||||||
|
throw new Error("no agents available - missing API keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = availableAgents[0];
|
||||||
|
log.info(`» no agent configured, defaulting to first available agent: ${agent.name}`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
-131
@@ -1,131 +0,0 @@
|
|||||||
import type { AgentName } from "../external.ts";
|
|
||||||
import type { RepoContext } from "./github.ts";
|
|
||||||
|
|
||||||
export interface Mode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
prompt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoSettings {
|
|
||||||
defaultAgent: AgentName | null;
|
|
||||||
webAccessLevel: "full_access" | "limited";
|
|
||||||
webAccessAllowTrusted: boolean;
|
|
||||||
webAccessDomains: string;
|
|
||||||
modes: Mode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
|
|
||||||
defaultAgent: null,
|
|
||||||
webAccessLevel: "full_access",
|
|
||||||
webAccessAllowTrusted: false,
|
|
||||||
webAccessDomains: "",
|
|
||||||
modes: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface WorkflowRunInfo {
|
|
||||||
progressCommentId: string | null;
|
|
||||||
issueNumber: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch workflow run info from the Pullfrog API
|
|
||||||
* Returns the pre-created progress comment ID if one exists
|
|
||||||
*/
|
|
||||||
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
|
||||||
|
|
||||||
// add timeout to prevent hanging (30 seconds)
|
|
||||||
const timeoutMs = 30000;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${apiUrl}/api/workflow-run/${runId}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { progressCommentId: null, issueNumber: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as WorkflowRunInfo;
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return { progressCommentId: null, issueNumber: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch repository settings from the Pullfrog API
|
|
||||||
* Returns defaults if repo doesn't exist or fetch fails
|
|
||||||
*/
|
|
||||||
export async function fetchRepoSettings({
|
|
||||||
token,
|
|
||||||
repoContext,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
repoContext: RepoContext;
|
|
||||||
}): Promise<RepoSettings> {
|
|
||||||
const settings = await getRepoSettings(token, repoContext);
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch repository settings from the Pullfrog API with fallback to defaults
|
|
||||||
* Returns agent, permissions, and workflows (excludes triggers)
|
|
||||||
* Returns defaults if repo doesn't exist or fetch fails
|
|
||||||
*/
|
|
||||||
export async function getRepoSettings(
|
|
||||||
token: string,
|
|
||||||
repoContext: RepoContext
|
|
||||||
): Promise<RepoSettings> {
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
|
||||||
|
|
||||||
// Add timeout to prevent hanging (30 seconds)
|
|
||||||
const timeoutMs = 30000;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${apiUrl}/api/repo/${repoContext.owner}/${repoContext.name}/settings`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// If API returns 404 or other error, fall back to defaults
|
|
||||||
return DEFAULT_REPO_SETTINGS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = (await response.json()) as RepoSettings | null;
|
|
||||||
|
|
||||||
// If API returns null (repo doesn't exist), return defaults
|
|
||||||
if (settings === null) {
|
|
||||||
return DEFAULT_REPO_SETTINGS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
// If fetch fails (network error, timeout, etc.), fall back to defaults
|
|
||||||
return DEFAULT_REPO_SETTINGS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Agent } from "../agents/index.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a helpful error message for missing API key with links to repo settings
|
||||||
|
*/
|
||||||
|
function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
|
||||||
|
|
||||||
|
const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
|
||||||
|
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
||||||
|
|
||||||
|
let secretNameList: string;
|
||||||
|
if (params.agent.apiKeyNames.length === 0) {
|
||||||
|
secretNameList =
|
||||||
|
"any API key (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.)";
|
||||||
|
} else {
|
||||||
|
const secretNames = params.agent.apiKeyNames.map((key) => `\`${key}\``);
|
||||||
|
secretNameList =
|
||||||
|
params.agent.apiKeyNames.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Pullfrog is configured to use ${params.agent.displayName}, but the associated API key was not provided.
|
||||||
|
|
||||||
|
To fix this, add the required secret to your GitHub repository:
|
||||||
|
|
||||||
|
1. Go to: ${githubSecretsUrl}
|
||||||
|
2. Click "New repository secret"
|
||||||
|
3. Set the name to ${secretNameList}
|
||||||
|
4. Set the value to your API key
|
||||||
|
5. Click "Add secret"
|
||||||
|
|
||||||
|
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectApiKeys(agent: Agent): Record<string, string> {
|
||||||
|
const apiKeys: Record<string, string> = {};
|
||||||
|
|
||||||
|
// read API keys from environment variables
|
||||||
|
for (const envKey of agent.apiKeyNames) {
|
||||||
|
const value = process.env[envKey];
|
||||||
|
if (value) {
|
||||||
|
apiKeys[envKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty apiKeyNames means agent accepts any *API_KEY* env var
|
||||||
|
if (agent.apiKeyNames.length === 0) {
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||||
|
apiKeys[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): void {
|
||||||
|
const apiKeys = collectApiKeys(params.agent);
|
||||||
|
|
||||||
|
if (Object.keys(apiKeys).length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
buildMissingApiKeyError({
|
||||||
|
agent: params.agent,
|
||||||
|
owner: params.owner,
|
||||||
|
name: params.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-375
@@ -1,384 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* CLI output utilities that work well in both local and GitHub Actions environments
|
* CLI utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import * as core from "@actions/core";
|
|
||||||
import { table } from "table";
|
|
||||||
|
|
||||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
// re-export logging utilities for backward compatibility
|
||||||
const isDebugEnabled = () =>
|
export { formatIndentedField, formatJsonValue, log, writeSummary } from "./log.ts";
|
||||||
process.env.LOG_LEVEL === "debug" ||
|
|
||||||
process.env.ACTIONS_STEP_DEBUG === "true" ||
|
|
||||||
process.env.RUNNER_DEBUG === "1" ||
|
|
||||||
core.isDebug();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
|
||||||
*/
|
|
||||||
function startGroup(name: string): void {
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.startGroup(name);
|
|
||||||
} else {
|
|
||||||
console.group(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End a collapsed group
|
|
||||||
*/
|
|
||||||
function endGroup(): void {
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.endGroup();
|
|
||||||
} else {
|
|
||||||
console.groupEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a callback within a collapsed group
|
|
||||||
*/
|
|
||||||
function group(name: string, fn: () => void): void {
|
|
||||||
startGroup(name);
|
|
||||||
fn();
|
|
||||||
endGroup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a formatted box with text (for console output)
|
|
||||||
*/
|
|
||||||
function boxString(
|
|
||||||
text: string,
|
|
||||||
options?: {
|
|
||||||
title?: string;
|
|
||||||
maxWidth?: number;
|
|
||||||
indent?: string;
|
|
||||||
padding?: number;
|
|
||||||
}
|
|
||||||
): string {
|
|
||||||
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
|
||||||
|
|
||||||
const lines = text.trim().split("\n");
|
|
||||||
const wrappedLines: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.length <= maxWidth - padding * 2) {
|
|
||||||
wrappedLines.push(line);
|
|
||||||
} else {
|
|
||||||
const words = line.split(" ");
|
|
||||||
let currentLine = "";
|
|
||||||
|
|
||||||
for (const word of words) {
|
|
||||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
||||||
if (testLine.length <= maxWidth - padding * 2) {
|
|
||||||
currentLine = testLine;
|
|
||||||
} else {
|
|
||||||
if (currentLine) {
|
|
||||||
wrappedLines.push(currentLine);
|
|
||||||
currentLine = "";
|
|
||||||
}
|
|
||||||
// wrap long words by breaking them into chunks
|
|
||||||
const maxLineLength = maxWidth - padding * 2;
|
|
||||||
let remainingWord = word;
|
|
||||||
while (remainingWord.length > maxLineLength) {
|
|
||||||
wrappedLines.push(remainingWord.substring(0, maxLineLength));
|
|
||||||
remainingWord = remainingWord.substring(maxLineLength);
|
|
||||||
}
|
|
||||||
currentLine = remainingWord;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLine) {
|
|
||||||
wrappedLines.push(currentLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
|
||||||
const contentBoxWidth = maxLineLength + padding * 2;
|
|
||||||
|
|
||||||
// ensure box width is at least as wide as the title line when title exists
|
|
||||||
const titleLineLength = title ? ` ${title} `.length : 0;
|
|
||||||
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
|
||||||
|
|
||||||
let result = "";
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
const titleLine = ` ${title} `;
|
|
||||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
|
||||||
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of wrappedLines) {
|
|
||||||
const paddedLine = line.padEnd(maxLineLength);
|
|
||||||
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a formatted box with text
|
|
||||||
* Works well in both local and GitHub Actions environments
|
|
||||||
*/
|
|
||||||
function box(
|
|
||||||
text: string,
|
|
||||||
options?: {
|
|
||||||
title?: string;
|
|
||||||
maxWidth?: number;
|
|
||||||
}
|
|
||||||
): void {
|
|
||||||
const boxContent = boxString(text, options);
|
|
||||||
core.info(boxContent);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
// Add as markdown code block for summary (no headers)
|
|
||||||
core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a table to GitHub Actions job summary (rich formatting)
|
|
||||||
* Also logs to console. Only use this once at the end of execution.
|
|
||||||
*/
|
|
||||||
async function summaryTable(
|
|
||||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
|
||||||
options?: {
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { title } = options || {};
|
|
||||||
|
|
||||||
// Convert rows to format expected by Job Summaries API
|
|
||||||
const formattedRows = rows.map((row) =>
|
|
||||||
row.map((cell) => {
|
|
||||||
if (typeof cell === "string") {
|
|
||||||
return { data: cell };
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isGitHubActions) {
|
|
||||||
const summary = core.summary;
|
|
||||||
if (title) {
|
|
||||||
summary.addRaw(`**${title}**\n\n`);
|
|
||||||
}
|
|
||||||
summary.addTable(formattedRows);
|
|
||||||
// Note: Don't write immediately, let it accumulate with other summary content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also log to console for visibility
|
|
||||||
if (title) {
|
|
||||||
core.info(`\n${title}`);
|
|
||||||
}
|
|
||||||
const tableData = formattedRows.map((row) => row.map((cell) => cell.data));
|
|
||||||
const tableText = isGitHubActions
|
|
||||||
? tableData.map((row) => row.join(" | ")).join("\n")
|
|
||||||
: table(tableData);
|
|
||||||
core.info(`\n${tableText}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a formatted table using the table package
|
|
||||||
* Also logs to console and GitHub Actions summary
|
|
||||||
*/
|
|
||||||
async function printTable(
|
|
||||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
|
||||||
options?: {
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const { title } = options || {};
|
|
||||||
|
|
||||||
// Convert rows to string arrays for the table package
|
|
||||||
const tableData = rows.map((row) =>
|
|
||||||
row.map((cell) => {
|
|
||||||
if (typeof cell === "string") {
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
return cell.data;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatted = table(tableData);
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
core.info(`\n${title}`);
|
|
||||||
}
|
|
||||||
core.info(`\n${formatted}\n`);
|
|
||||||
|
|
||||||
if (isGitHubActions) {
|
|
||||||
if (title) {
|
|
||||||
core.summary.addRaw(`**${title}**\n\n`);
|
|
||||||
}
|
|
||||||
core.summary.addRaw(`\`\`\`\n${formatted}\n\`\`\`\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a separator line
|
|
||||||
*/
|
|
||||||
function separator(length: number = 50): void {
|
|
||||||
const separatorText = "─".repeat(length);
|
|
||||||
core.info(separatorText);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`---\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main logging utility object - import this once and access all utilities
|
|
||||||
*/
|
|
||||||
export const log = {
|
|
||||||
/**
|
|
||||||
* Print info message
|
|
||||||
*/
|
|
||||||
info: (message: string): void => {
|
|
||||||
core.info(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`${message}\n`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print warning message
|
|
||||||
*/
|
|
||||||
warning: (message: string): void => {
|
|
||||||
core.warning(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`⚠️ ${message}\n`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print error message
|
|
||||||
*/
|
|
||||||
error: (message: string): void => {
|
|
||||||
core.error(message);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`❌ ${message}\n`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print success message
|
|
||||||
*/
|
|
||||||
success: (message: string): void => {
|
|
||||||
const successMessage = `✅ ${message}`;
|
|
||||||
core.info(successMessage);
|
|
||||||
if (isGitHubActions) {
|
|
||||||
core.summary.addRaw(`${successMessage}\n`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print debug message (only if LOG_LEVEL=debug)
|
|
||||||
*/
|
|
||||||
debug: (message: string | unknown): void => {
|
|
||||||
if (isDebugEnabled()) {
|
|
||||||
if (isGitHubActions) {
|
|
||||||
// using this instead of core.debug
|
|
||||||
// because core.debug only logs when ACTIONS_STEP_DEBUG is set to true
|
|
||||||
// we are using LOG_LEVEL
|
|
||||||
core.info(`[DEBUG] ${message}`);
|
|
||||||
} else {
|
|
||||||
core.info(`[DEBUG] ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a formatted box with text
|
|
||||||
*/
|
|
||||||
box,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a table to GitHub Actions job summary (rich formatting)
|
|
||||||
* Only use this once at the end of execution
|
|
||||||
*/
|
|
||||||
summaryTable,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a formatted table using the table package
|
|
||||||
*/
|
|
||||||
table: printTable,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a separator line
|
|
||||||
*/
|
|
||||||
separator,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write all accumulated summary content to the job summary
|
|
||||||
* Call this at the end of execution to finalize the summary
|
|
||||||
*/
|
|
||||||
writeSummary: async (): Promise<void> => {
|
|
||||||
if (isGitHubActions) {
|
|
||||||
await core.summary.write();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
|
||||||
*/
|
|
||||||
startGroup,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End a collapsed group
|
|
||||||
*/
|
|
||||||
endGroup,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a callback within a collapsed group
|
|
||||||
*/
|
|
||||||
group,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log tool call information to console with formatted output
|
|
||||||
*/
|
|
||||||
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
|
||||||
const inputFormatted = formatJsonValue(input);
|
|
||||||
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
|
|
||||||
const output =
|
|
||||||
inputFormatted !== "{}"
|
|
||||||
? `→ ${toolName}(${inputFormatted})${timestamp}`
|
|
||||||
: `→ ${toolName}()${timestamp}`;
|
|
||||||
|
|
||||||
log.info(output.trimEnd());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
|
|
||||||
*/
|
|
||||||
export function formatJsonValue(value: unknown): string {
|
|
||||||
const compact = JSON.stringify(value);
|
|
||||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a multi-line string with proper indentation for tool call output
|
|
||||||
* First line has the label, subsequent lines are indented 4 spaces
|
|
||||||
*/
|
|
||||||
export function formatIndentedField(label: string, content: string): string {
|
|
||||||
if (!content.includes("\n")) {
|
|
||||||
return ` ${label}: ${content}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = content.split("\n");
|
|
||||||
let formatted = ` ${label}: ${lines[0]}\n`;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
formatted += ` ${lines[i]}\n`;
|
|
||||||
}
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a CLI executable path by checking if it's installed globally
|
* Finds a CLI executable path by checking if it's installed globally
|
||||||
|
|||||||
+9
-46
@@ -1,58 +1,21 @@
|
|||||||
import { fetchWorkflowRunInfo } from "./api.ts";
|
import type { ToolState } from "../mcp/server.ts";
|
||||||
import { createOctokit, getGitHubInstallationToken, parseRepoContext } from "./github.ts";
|
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||||
|
import { getGitHubInstallationToken } from "./token.ts";
|
||||||
|
|
||||||
/**
|
interface ReportErrorParams {
|
||||||
* Get progress comment ID from environment variable or database.
|
toolState: ToolState;
|
||||||
*/
|
|
||||||
function getProgressCommentIdFromEnv(): number | null {
|
|
||||||
const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
|
|
||||||
if (envCommentId) {
|
|
||||||
const parsed = parseInt(envCommentId, 10);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reportErrorToComment({
|
|
||||||
error,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
error: string;
|
error: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}): Promise<void> {
|
}
|
||||||
const formattedError = title ? `${title}\n\n${error}` : `❌ ${error}`;
|
|
||||||
|
|
||||||
// try to get comment ID from env var first, then from database if needed
|
export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void> {
|
||||||
let commentId = getProgressCommentIdFromEnv();
|
const formattedError = ctx.title ? `${ctx.title}\n\n${ctx.error}` : ctx.error;
|
||||||
|
|
||||||
// if not in env var, try fetching from database using run ID
|
const commentId = ctx.toolState.progressComment.id;
|
||||||
if (!commentId) {
|
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
|
||||||
if (runId) {
|
|
||||||
try {
|
|
||||||
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
|
||||||
if (workflowRunInfo.progressCommentId) {
|
|
||||||
const parsed = parseInt(workflowRunInfo.progressCommentId, 10);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
commentId = parsed;
|
|
||||||
// cache it in env var for future use
|
|
||||||
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// database fetch failed, continue without comment ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no comment ID available, can't update comment
|
|
||||||
if (!commentId) {
|
if (!commentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update comment directly using GitHub API
|
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const octokit = createOctokit(getGitHubInstallationToken());
|
const octokit = createOctokit(getGitHubInstallationToken());
|
||||||
|
|
||||||
|
|||||||
+3
-53
@@ -230,6 +230,7 @@ const findInstallationId = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// for local development only
|
||||||
async function acquireTokenViaGitHubApp(): Promise<string> {
|
async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
|
|
||||||
@@ -255,57 +256,6 @@ export async function acquireNewToken(opts?: { repos?: string[] }): Promise<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store token in memory instead of process.env
|
|
||||||
let githubInstallationToken: string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup GitHub installation token for the action
|
|
||||||
*/
|
|
||||||
export async function setupGitHubInstallationToken() {
|
|
||||||
assert(!githubInstallationToken, "GitHub installation token is already set.");
|
|
||||||
const acquiredToken = await acquireNewToken();
|
|
||||||
core.setSecret(acquiredToken);
|
|
||||||
githubInstallationToken = acquiredToken;
|
|
||||||
return {
|
|
||||||
token: acquiredToken,
|
|
||||||
[Symbol.asyncDispose]() {
|
|
||||||
githubInstallationToken = undefined;
|
|
||||||
return revokeGitHubInstallationToken(acquiredToken);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the GitHub installation token from memory
|
|
||||||
*/
|
|
||||||
export function getGitHubInstallationToken(): string {
|
|
||||||
assert(
|
|
||||||
githubInstallationToken,
|
|
||||||
"GitHub installation token not set. Call setupGitHubInstallationToken first."
|
|
||||||
);
|
|
||||||
return githubInstallationToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
|
||||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`${apiUrl}/installation/token`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.github+json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
log.debug("» installation token revoked");
|
|
||||||
} catch (error) {
|
|
||||||
log.warning(
|
|
||||||
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoContext {
|
export interface RepoContext {
|
||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -339,10 +289,10 @@ export function createOctokit(token: string): OctokitWithPlugins {
|
|||||||
return new OctokitWithPlugins({
|
return new OctokitWithPlugins({
|
||||||
auth: token,
|
auth: token,
|
||||||
throttle: {
|
throttle: {
|
||||||
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
|
||||||
return retryCount <= 2;
|
return retryCount <= 2;
|
||||||
},
|
},
|
||||||
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
onSecondaryRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
|
||||||
return retryCount <= 2;
|
return retryCount <= 2;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, createWriteStream, existsSync } from "node:fs";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
|
||||||
|
export interface InstallFromNpmTarballParams {
|
||||||
|
packageName: string;
|
||||||
|
version: string;
|
||||||
|
executablePath: string;
|
||||||
|
installDependencies?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallFromCurlParams {
|
||||||
|
installUrl: string;
|
||||||
|
executableName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallFromGithubParams {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
assetName?: string;
|
||||||
|
executablePath?: string;
|
||||||
|
githubInstallationToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallFromGithubTarballParams {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
assetNamePattern: string;
|
||||||
|
executablePath: string;
|
||||||
|
githubInstallationToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NpmRegistryData {
|
||||||
|
"dist-tags": { latest: string };
|
||||||
|
versions: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a CLI tool from an npm package tarball
|
||||||
|
* Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable
|
||||||
|
* The temp directory will be cleaned up by the OS automatically
|
||||||
|
*/
|
||||||
|
export async function installFromNpmTarball(params: InstallFromNpmTarballParams): Promise<string> {
|
||||||
|
// Resolve version if it's a range or "latest"
|
||||||
|
let resolvedVersion = params.version;
|
||||||
|
if (
|
||||||
|
params.version.startsWith("^") ||
|
||||||
|
params.version.startsWith("~") ||
|
||||||
|
params.version === "latest"
|
||||||
|
) {
|
||||||
|
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||||
|
log.debug(`» resolving version for ${params.version}...`);
|
||||||
|
try {
|
||||||
|
const registryResponse = await fetch(`${npmRegistry}/${params.packageName}`);
|
||||||
|
if (!registryResponse.ok) {
|
||||||
|
throw new Error(`Failed to query registry: ${registryResponse.status}`);
|
||||||
|
}
|
||||||
|
const registryData = (await registryResponse.json()) as NpmRegistryData;
|
||||||
|
resolvedVersion = registryData["dist-tags"].latest;
|
||||||
|
log.debug(`» resolved to version ${resolvedVersion}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.warning(
|
||||||
|
`Failed to resolve version from registry: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`» installing ${params.packageName}@${resolvedVersion}...`);
|
||||||
|
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||||
|
const tarballPath = join(tempDir, "package.tgz");
|
||||||
|
|
||||||
|
// Download tarball from npm
|
||||||
|
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||||
|
// Handle scoped packages (e.g., @scope/package -> @scope%2Fpackage/-/package-version.tgz)
|
||||||
|
let tarballUrl: string;
|
||||||
|
if (params.packageName.startsWith("@")) {
|
||||||
|
const [scope, name] = params.packageName.slice(1).split("/");
|
||||||
|
const scopedPackageName = `@${scope}%2F${name}`;
|
||||||
|
tarballUrl = `${npmRegistry}/${scopedPackageName}/-/${name}-${resolvedVersion}.tgz`;
|
||||||
|
} else {
|
||||||
|
tarballUrl = `${npmRegistry}/${params.packageName}/-/${params.packageName}-${resolvedVersion}.tgz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`» downloading from ${tarballUrl}...`);
|
||||||
|
const response = await fetch(tarballUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write tarball to file
|
||||||
|
if (!response.body) throw new Error("Response body is null");
|
||||||
|
const fileStream = createWriteStream(tarballPath);
|
||||||
|
await pipeline(response.body, fileStream);
|
||||||
|
log.debug(`» downloaded tarball to ${tarballPath}`);
|
||||||
|
|
||||||
|
// Extract tarball
|
||||||
|
log.debug(`» extracting tarball...`);
|
||||||
|
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (extractResult.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find executable in the extracted package
|
||||||
|
const extractedDir = join(tempDir, "package");
|
||||||
|
const cliPath = join(extractedDir, params.executablePath);
|
||||||
|
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`Executable not found in extracted package at ${cliPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if requested
|
||||||
|
if (params.installDependencies) {
|
||||||
|
log.debug(`» installing dependencies for ${params.packageName}...`);
|
||||||
|
const installResult = spawnSync("npm", ["install", "--production"], {
|
||||||
|
cwd: extractedDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (installResult.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to install dependencies: ${installResult.stderr || installResult.stdout || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log.debug(`» dependencies installed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the file executable
|
||||||
|
chmodSync(cliPath, 0o755);
|
||||||
|
|
||||||
|
log.debug(`» ${params.packageName} installed at ${cliPath}`);
|
||||||
|
|
||||||
|
return cliPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with retry logic if Retry-After header is present
|
||||||
|
*/
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
errorMessage: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
const retryAfter = response.headers.get("Retry-After") || response.headers.get("retry-after");
|
||||||
|
if (retryAfter) {
|
||||||
|
const waitSeconds = parseInt(retryAfter, 10);
|
||||||
|
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
|
||||||
|
log.info(`» rate limited, waiting ${waitSeconds} seconds before retry...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
||||||
|
const retryResponse = await fetch(url, { headers });
|
||||||
|
if (!retryResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`${errorMessage}: ${retryResponse.status} ${retryResponse.statusText} (retry failed)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return retryResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${errorMessage}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a CLI tool from GitHub releases
|
||||||
|
* Downloads the latest release asset from GitHub and returns the path to the executable
|
||||||
|
* The temp directory will be cleaned up by the OS automatically
|
||||||
|
*/
|
||||||
|
export async function installFromGithub(params: InstallFromGithubParams): Promise<string> {
|
||||||
|
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
|
||||||
|
|
||||||
|
// fetch release from GitHub API (latest)
|
||||||
|
const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`;
|
||||||
|
log.debug(`» fetching release from ${releaseUrl}...`);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (params.githubInstallationToken) {
|
||||||
|
headers.Authorization = `Bearer ${params.githubInstallationToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
|
||||||
|
|
||||||
|
const releaseData = (await releaseResponse.json()) as {
|
||||||
|
tag_name: string;
|
||||||
|
assets: Array<{
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug(`» found release ${releaseData.tag_name}`);
|
||||||
|
|
||||||
|
const asset = releaseData.assets.find((a) => a.name === params.assetName);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(`Asset '${params.assetName}' not found in release ${releaseData.tag_name}`);
|
||||||
|
}
|
||||||
|
const assetUrl = asset.browser_download_url;
|
||||||
|
|
||||||
|
log.debug(`» downloading asset from ${assetUrl}...`);
|
||||||
|
|
||||||
|
// create temp directory
|
||||||
|
const tempDirPrefix = `${params.owner}-${params.repo}-github-`;
|
||||||
|
const tempDirPath = await mkdtemp(join(tmpdir(), tempDirPrefix));
|
||||||
|
|
||||||
|
// determine file extension and download path
|
||||||
|
const urlPath = new URL(assetUrl).pathname;
|
||||||
|
const fileName = urlPath.split("/").pop() || "asset";
|
||||||
|
const downloadPath = join(tempDirPath, fileName);
|
||||||
|
|
||||||
|
// download the asset
|
||||||
|
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
|
||||||
|
|
||||||
|
if (!assetResponse.body) throw new Error("Response body is null");
|
||||||
|
const fileStream = createWriteStream(downloadPath);
|
||||||
|
await pipeline(assetResponse.body, fileStream);
|
||||||
|
log.debug(`» downloaded asset to ${downloadPath}`);
|
||||||
|
|
||||||
|
// determine the executable path
|
||||||
|
let cliPath: string;
|
||||||
|
if (params.executablePath) {
|
||||||
|
cliPath = join(tempDirPath, params.executablePath);
|
||||||
|
} else {
|
||||||
|
// no executablePath, assume the downloaded file is the executable
|
||||||
|
cliPath = downloadPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`Executable not found at ${cliPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
chmodSync(cliPath, 0o755);
|
||||||
|
log.info(`» installed from GitHub release at ${cliPath}`);
|
||||||
|
|
||||||
|
return cliPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a CLI tool from a GitHub release tarball
|
||||||
|
* Downloads the tar.gz from GitHub releases, extracts it, and returns the path to the CLI executable
|
||||||
|
* The temp directory will be cleaned up by the OS automatically
|
||||||
|
*/
|
||||||
|
export async function installFromGithubTarball(
|
||||||
|
params: InstallFromGithubTarballParams
|
||||||
|
): Promise<string> {
|
||||||
|
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
|
||||||
|
|
||||||
|
// determine platform-specific asset name
|
||||||
|
const os = process.platform === "darwin" ? "darwin" : "linux";
|
||||||
|
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||||
|
const assetName = params.assetNamePattern.replace("{os}", os).replace("{arch}", arch);
|
||||||
|
|
||||||
|
// fetch release from GitHub API (latest)
|
||||||
|
const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`;
|
||||||
|
log.info(`» fetching release from ${releaseUrl}...`);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (params.githubInstallationToken) {
|
||||||
|
headers.Authorization = `Bearer ${params.githubInstallationToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
|
||||||
|
|
||||||
|
const releaseData = (await releaseResponse.json()) as {
|
||||||
|
tag_name: string;
|
||||||
|
assets: Array<{
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug(`» found release: ${releaseData.tag_name}`);
|
||||||
|
|
||||||
|
const asset = releaseData.assets.find((a) => a.name === assetName);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
|
||||||
|
}
|
||||||
|
const assetUrl = asset.browser_download_url;
|
||||||
|
|
||||||
|
log.debug(`» downloading asset from ${assetUrl}...`);
|
||||||
|
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||||
|
const tarballPath = join(tempDir, assetName);
|
||||||
|
|
||||||
|
// download the asset
|
||||||
|
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
|
||||||
|
|
||||||
|
if (!assetResponse.body) throw new Error("Response body is null");
|
||||||
|
const fileStream = createWriteStream(tarballPath);
|
||||||
|
await pipeline(assetResponse.body, fileStream);
|
||||||
|
log.debug(`» downloaded tarball to ${tarballPath}`);
|
||||||
|
|
||||||
|
// extract tar.gz
|
||||||
|
log.debug(`» extracting tarball...`);
|
||||||
|
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (extractResult.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// find executable in the extracted tarball
|
||||||
|
const cliPath = join(tempDir, params.executablePath);
|
||||||
|
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make the file executable
|
||||||
|
chmodSync(cliPath, 0o755);
|
||||||
|
|
||||||
|
log.info(`» ${params.owner}/${params.repo} installed at ${cliPath}`);
|
||||||
|
|
||||||
|
return cliPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a CLI tool from a curl-based install script
|
||||||
|
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
|
||||||
|
* The temp directory will be cleaned up by the OS automatically
|
||||||
|
*/
|
||||||
|
export async function installFromCurl(params: InstallFromCurlParams): Promise<string> {
|
||||||
|
log.info(`» installing ${params.executableName}...`);
|
||||||
|
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||||
|
const installScriptPath = join(tempDir, "install.sh");
|
||||||
|
|
||||||
|
// Download the install script
|
||||||
|
log.debug(`» downloading install script from ${params.installUrl}...`);
|
||||||
|
const installScriptResponse = await fetch(params.installUrl);
|
||||||
|
if (!installScriptResponse.ok) {
|
||||||
|
throw new Error(`Failed to download install script: ${installScriptResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installScriptResponse.body) throw new Error("Response body is null");
|
||||||
|
const fileStream = createWriteStream(installScriptPath);
|
||||||
|
await pipeline(installScriptResponse.body, fileStream);
|
||||||
|
log.debug(`» downloaded install script to ${installScriptPath}`);
|
||||||
|
|
||||||
|
// Make install script executable
|
||||||
|
chmodSync(installScriptPath, 0o755);
|
||||||
|
|
||||||
|
log.debug(`» installing to temp directory at ${tempDir}...`);
|
||||||
|
|
||||||
|
const installResult = spawnSync("bash", [installScriptPath], {
|
||||||
|
cwd: tempDir,
|
||||||
|
env: {
|
||||||
|
// Run the install script with HOME set to temp directory
|
||||||
|
// ensuring a fresh install for each run
|
||||||
|
HOME: tempDir,
|
||||||
|
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place
|
||||||
|
XDG_CONFIG_HOME: join(tempDir, ".config"),
|
||||||
|
SHELL: process.env.SHELL,
|
||||||
|
USER: process.env.USER,
|
||||||
|
},
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (installResult.status !== 0) {
|
||||||
|
const errorOutput = installResult.stderr || installResult.stdout || "No output";
|
||||||
|
throw new Error(
|
||||||
|
`Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
|
||||||
|
// Since we set HOME=tempDir, the deterministic path is:
|
||||||
|
const cliPath = join(tempDir, ".local", "bin", params.executableName);
|
||||||
|
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`Executable not found at ${cliPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure binary is executable
|
||||||
|
chmodSync(cliPath, 0o755);
|
||||||
|
log.info(`» ${params.executableName} installed at ${cliPath}`);
|
||||||
|
|
||||||
|
return cliPath;
|
||||||
|
}
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import type { Payload } from "../external.ts";
|
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { getModes } from "../modes.ts";
|
import type { Mode } from "../modes.ts";
|
||||||
|
import type { ResolvedPayload } from "./payload.ts";
|
||||||
|
import type { RepoData } from "./repoData.ts";
|
||||||
|
|
||||||
interface RepoInfo {
|
interface InstructionsContext {
|
||||||
owner: string;
|
payload: ResolvedPayload;
|
||||||
name: string;
|
repoData: RepoData;
|
||||||
defaultBranch: string;
|
modes: Mode[];
|
||||||
isPublic: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||||
* Build runtime context string with git status, repo data, and GitHub Actions variables
|
|
||||||
*/
|
|
||||||
function buildRuntimeContext(repo: RepoInfo): string {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
// working directory
|
|
||||||
lines.push(`working_directory: ${process.cwd()}`);
|
lines.push(`working_directory: ${process.cwd()}`);
|
||||||
lines.push(`log_level: ${process.env.LOG_LEVEL}`);
|
lines.push(`log_level: ${process.env.LOG_LEVEL}`);
|
||||||
|
|
||||||
// git status (try to get it, but don't fail if git isn't available)
|
|
||||||
try {
|
try {
|
||||||
const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim();
|
const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim();
|
||||||
lines.push(`git_status: ${gitStatus || "(clean)"}`);
|
lines.push(`git_status: ${gitStatus || "(clean)"}`);
|
||||||
@@ -29,11 +24,9 @@ function buildRuntimeContext(repo: RepoInfo): string {
|
|||||||
// git not available or not in a repo
|
// git not available or not in a repo
|
||||||
}
|
}
|
||||||
|
|
||||||
// repo data
|
lines.push(`repo: ${ctx.repoData.owner}/${ctx.repoData.name}`);
|
||||||
lines.push(`repo: ${repo.owner}/${repo.name}`);
|
lines.push(`default_branch: ${ctx.repoData.repo.default_branch}`);
|
||||||
lines.push(`default_branch: ${repo.defaultBranch}`);
|
|
||||||
|
|
||||||
// GitHub Actions variables (when running in CI)
|
|
||||||
const ghVars: Record<string, string | undefined> = {
|
const ghVars: Record<string, string | undefined> = {
|
||||||
github_event_name: process.env.GITHUB_EVENT_NAME,
|
github_event_name: process.env.GITHUB_EVENT_NAME,
|
||||||
github_ref: process.env.GITHUB_REF,
|
github_ref: process.env.GITHUB_REF,
|
||||||
@@ -51,31 +44,59 @@ function buildRuntimeContext(repo: RepoInfo): string {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddInstructionsParams {
|
function getShellInstructions(bash: ResolvedPayload["bash"]): string {
|
||||||
payload: Payload;
|
const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`bash({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`;
|
||||||
repo: RepoInfo;
|
|
||||||
|
switch (bash) {
|
||||||
|
case "disabled":
|
||||||
|
return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`;
|
||||||
|
case "restricted":
|
||||||
|
return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security. ${backgroundInstructions}`;
|
||||||
|
case "enabled":
|
||||||
|
return `**Shell commands**: Use your native bash/shell tool for shell command execution. ${backgroundInstructions}`;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = bash;
|
||||||
|
return _exhaustive satisfies never;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
export interface ResolvedInstructions {
|
||||||
// for public repos, always use MCP bash for security (filters secrets)
|
full: string;
|
||||||
// for private repos, agents can use their native bash
|
system: string;
|
||||||
const useNativeBash = !repo.isPublic;
|
user: string;
|
||||||
let encodedEvent = "";
|
repo: string;
|
||||||
|
event: string;
|
||||||
|
runtime: string;
|
||||||
|
}
|
||||||
|
|
||||||
const eventKeys = Object.keys(payload.event);
|
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
|
||||||
if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
|
const event = toonEncode({
|
||||||
// no meaningful event data to encode
|
agent: ctx.payload.agent,
|
||||||
} else {
|
effort: ctx.payload.effort,
|
||||||
// extract only essential fields to reduce token usage
|
permissions: {
|
||||||
// const essentialEvent = payload.event;
|
web: ctx.payload.web,
|
||||||
encodedEvent = toonEncode(payload.event);
|
search: ctx.payload.search,
|
||||||
}
|
write: ctx.payload.write,
|
||||||
|
bash: ctx.payload.bash,
|
||||||
|
},
|
||||||
|
event: ctx.payload.event,
|
||||||
|
});
|
||||||
|
|
||||||
const runtimeContext = buildRuntimeContext(repo);
|
const runtime = buildRuntimeContext(ctx);
|
||||||
|
|
||||||
return (
|
// user prompt is constructed server-side (body if @pullfrog tagged + per-trigger instructions)
|
||||||
`
|
const user = ctx.payload.prompt;
|
||||||
***********************************************
|
|
||||||
|
// repo-level instructions are macro-expanded server-side and passed separately
|
||||||
|
const repo = ctx.payload.repoInstructions ?? "";
|
||||||
|
|
||||||
|
const userQuoted = user
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const system = `***********************************************
|
||||||
************* SYSTEM INSTRUCTIONS *************
|
************* SYSTEM INSTRUCTIONS *************
|
||||||
***********************************************
|
***********************************************
|
||||||
|
|
||||||
@@ -88,6 +109,7 @@ Your code is focused, elegant, and production-ready.
|
|||||||
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
|
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
|
||||||
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
|
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
|
||||||
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
|
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
|
||||||
|
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
|
||||||
You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID).
|
You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID).
|
||||||
Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
|
Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
|
||||||
Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata.
|
Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata.
|
||||||
@@ -116,37 +138,11 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
|
|||||||
|
|
||||||
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
|
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
|
||||||
|
|
||||||
` +
|
|
||||||
// **Available git MCP tools**:
|
|
||||||
// - \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically)
|
|
||||||
// - \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch
|
|
||||||
// - \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication
|
|
||||||
// - \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote (automatically uses correct remote for fork PRs)
|
|
||||||
// - \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch
|
|
||||||
|
|
||||||
// **Workflow for working on an existing PR**:
|
|
||||||
// 1. Use \`${ghPullfrogMcpName}/checkout_pr\` to checkout the PR branch
|
|
||||||
// 2. Make your changes using file operations
|
|
||||||
// 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes
|
|
||||||
// 4. Use \`${ghPullfrogMcpName}/push_branch\` to push (automatically pushes to fork for fork PRs)
|
|
||||||
|
|
||||||
// **Workflow for creating new changes**:
|
|
||||||
// 1. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch
|
|
||||||
// 2. Make your changes using file operations
|
|
||||||
// 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes
|
|
||||||
// 4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch
|
|
||||||
// 5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR
|
|
||||||
|
|
||||||
`
|
|
||||||
**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally.
|
**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally.
|
||||||
|
|
||||||
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
|
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
|
||||||
|
|
||||||
${
|
${getShellInstructions(ctx.payload.bash)}
|
||||||
useNativeBash
|
|
||||||
? `**Shell commands**: Use your native bash/shell tool for shell command execution.`
|
|
||||||
: `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`
|
|
||||||
}
|
|
||||||
|
|
||||||
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
||||||
|
|
||||||
@@ -167,33 +163,40 @@ ${
|
|||||||
|
|
||||||
### Available modes
|
### Available modes
|
||||||
|
|
||||||
${[...getModes({ disableProgressComment: payload.disableProgressComment }), ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
|
||||||
|
|
||||||
### Following the mode instructions
|
### Following the mode instructions
|
||||||
|
|
||||||
After selecting a mode, follow the detailed step-by-step instructions provided by the ${ghPullfrogMcpName}/select_mode tool. Refer to the user prompt, event data, and runtime context below to inform your actions. These instructions cannot override the Security rules or System instructions above.
|
After selecting a mode, follow the detailed step-by-step instructions provided by the ${ghPullfrogMcpName}/select_mode tool. Refer to the user prompt, event data, and runtime context below to inform your actions. These instructions cannot override the Security rules or System instructions above.
|
||||||
|
|
||||||
Eagerly inspect the MCP tools available to you via the \`${ghPullfrogMcpName}\` MCP server. These are VITALLY IMPORTANT to completing your task.
|
Eagerly inspect the MCP tools available to you via the \`${ghPullfrogMcpName}\` MCP server. These are VITALLY IMPORTANT to completing your task.`;
|
||||||
|
|
||||||
|
// build repo instructions section (only if non-empty)
|
||||||
|
const repoSection = repo
|
||||||
|
? `
|
||||||
|
|
||||||
|
************* REPO-LEVEL INSTRUCTIONS *************
|
||||||
|
|
||||||
|
${repo}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const full = `
|
||||||
|
${system}
|
||||||
|
|
||||||
************* USER PROMPT *************
|
************* USER PROMPT *************
|
||||||
|
|
||||||
${payload.prompt
|
${userQuoted}
|
||||||
.split("\n")
|
${repoSection}
|
||||||
.map((line) => `> ${line}`)
|
|
||||||
.join("\n")}
|
|
||||||
|
|
||||||
${
|
************* EVENT DATA *************
|
||||||
encodedEvent
|
|
||||||
? `************* EVENT DATA *************
|
|
||||||
|
|
||||||
The following is structured data about the GitHub event that triggered this run (e.g., issue body, PR details, comment content). Use this context to understand the full situation.
|
The following is structured data about the context of this run (agent, effort level, permissions, and the GitHub event that triggered it). Use this context to understand the full situation.
|
||||||
|
|
||||||
${encodedEvent}`
|
${event}
|
||||||
: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
************* RUNTIME CONTEXT *************
|
************* RUNTIME CONTEXT *************
|
||||||
|
|
||||||
${runtimeContext}`
|
${runtime}`;
|
||||||
);
|
|
||||||
};
|
return { full, system, user, repo, event, runtime };
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Logging utilities that work well in both local and GitHub Actions environments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { table } from "table";
|
||||||
|
|
||||||
|
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||||
|
|
||||||
|
const isDebugEnabled = () =>
|
||||||
|
process.env.LOG_LEVEL === "debug" ||
|
||||||
|
process.env.ACTIONS_STEP_DEBUG === "true" ||
|
||||||
|
process.env.RUNNER_DEBUG === "1" ||
|
||||||
|
core.isDebug();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format arguments into a single string for logging
|
||||||
|
*/
|
||||||
|
function formatArgs(args: unknown[]): string {
|
||||||
|
return args
|
||||||
|
.map((arg) => {
|
||||||
|
if (typeof arg === "string") return arg;
|
||||||
|
if (arg instanceof Error) return `${arg.message}\n${arg.stack}`;
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||||
|
*/
|
||||||
|
function startGroup(name: string): void {
|
||||||
|
if (isGitHubActions) {
|
||||||
|
core.startGroup(name);
|
||||||
|
} else {
|
||||||
|
console.group(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a collapsed group
|
||||||
|
*/
|
||||||
|
function endGroup(): void {
|
||||||
|
if (isGitHubActions) {
|
||||||
|
core.endGroup();
|
||||||
|
} else {
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a callback within a collapsed group
|
||||||
|
*/
|
||||||
|
function group(name: string, fn: () => void): void {
|
||||||
|
startGroup(name);
|
||||||
|
fn();
|
||||||
|
endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a formatted box with text (for console output)
|
||||||
|
*/
|
||||||
|
function boxString(
|
||||||
|
text: string,
|
||||||
|
options?: {
|
||||||
|
title?: string;
|
||||||
|
maxWidth?: number;
|
||||||
|
indent?: string;
|
||||||
|
padding?: number;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
||||||
|
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
const wrappedLines: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length <= maxWidth - padding * 2) {
|
||||||
|
wrappedLines.push(line);
|
||||||
|
} else {
|
||||||
|
const words = line.split(" ");
|
||||||
|
let currentLine = "";
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
if (testLine.length <= maxWidth - padding * 2) {
|
||||||
|
currentLine = testLine;
|
||||||
|
} else {
|
||||||
|
if (currentLine) {
|
||||||
|
wrappedLines.push(currentLine);
|
||||||
|
currentLine = "";
|
||||||
|
}
|
||||||
|
// wrap long words by breaking them into chunks
|
||||||
|
const maxLineLength = maxWidth - padding * 2;
|
||||||
|
let remainingWord = word;
|
||||||
|
while (remainingWord.length > maxLineLength) {
|
||||||
|
wrappedLines.push(remainingWord.substring(0, maxLineLength));
|
||||||
|
remainingWord = remainingWord.substring(maxLineLength);
|
||||||
|
}
|
||||||
|
currentLine = remainingWord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
wrappedLines.push(currentLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||||
|
const contentBoxWidth = maxLineLength + padding * 2;
|
||||||
|
|
||||||
|
// ensure box width is at least as wide as the title line when title exists
|
||||||
|
const titleLineLength = title ? ` ${title} `.length : 0;
|
||||||
|
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const titleLine = ` ${title} `;
|
||||||
|
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||||
|
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of wrappedLines) {
|
||||||
|
const paddedLine = line.padEnd(maxLineLength);
|
||||||
|
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a formatted box with text
|
||||||
|
*/
|
||||||
|
function box(
|
||||||
|
text: string,
|
||||||
|
options?: {
|
||||||
|
title?: string;
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const boxContent = boxString(text, options);
|
||||||
|
core.info(boxContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite the job summary with the given text.
|
||||||
|
*/
|
||||||
|
export function writeSummary(text: string): void {
|
||||||
|
if (!isGitHubActions) return;
|
||||||
|
core.summary.addRaw(text).write({ overwrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a formatted table using the table package
|
||||||
|
*/
|
||||||
|
function printTable(
|
||||||
|
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
||||||
|
options?: {
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const { title } = options || {};
|
||||||
|
|
||||||
|
// Convert rows to string arrays for the table package
|
||||||
|
const tableData = rows.map((row) =>
|
||||||
|
row.map((cell) => {
|
||||||
|
if (typeof cell === "string") {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
return cell.data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatted = table(tableData);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
core.info(`\n${title}`);
|
||||||
|
}
|
||||||
|
core.info(`\n${formatted}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a separator line
|
||||||
|
*/
|
||||||
|
function separator(length: number = 50): void {
|
||||||
|
const separatorText = "─".repeat(length);
|
||||||
|
core.info(separatorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main logging utility object - import this once and access all utilities
|
||||||
|
*/
|
||||||
|
export const log = {
|
||||||
|
/** Print info message */
|
||||||
|
info: (...args: unknown[]): void => {
|
||||||
|
core.info(formatArgs(args));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Print warning message */
|
||||||
|
warning: (...args: unknown[]): void => {
|
||||||
|
core.warning(formatArgs(args));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Print error message */
|
||||||
|
error: (...args: unknown[]): void => {
|
||||||
|
core.error(formatArgs(args));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Print success message */
|
||||||
|
success: (...args: unknown[]): void => {
|
||||||
|
core.info(`» ${formatArgs(args)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Print debug message (only if LOG_LEVEL=debug) */
|
||||||
|
debug: (...args: unknown[]): void => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
core.info(`[DEBUG] ${formatArgs(args)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Print a formatted box with text */
|
||||||
|
box,
|
||||||
|
|
||||||
|
/** Print a formatted table using the table package */
|
||||||
|
table: printTable,
|
||||||
|
|
||||||
|
/** Print a separator line */
|
||||||
|
separator,
|
||||||
|
|
||||||
|
/** Start a collapsed group (GitHub Actions) or regular group (local) */
|
||||||
|
startGroup,
|
||||||
|
|
||||||
|
/** End a collapsed group */
|
||||||
|
endGroup,
|
||||||
|
|
||||||
|
/** Run a callback within a collapsed group */
|
||||||
|
group,
|
||||||
|
|
||||||
|
/** Log tool call information to console with formatted output */
|
||||||
|
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
||||||
|
const inputFormatted = formatJsonValue(input);
|
||||||
|
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
|
||||||
|
const output =
|
||||||
|
inputFormatted !== "{}"
|
||||||
|
? `→ ${toolName}(${inputFormatted})${timestamp}`
|
||||||
|
: `→ ${toolName}()${timestamp}`;
|
||||||
|
|
||||||
|
log.info(output.trimEnd());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
|
||||||
|
*/
|
||||||
|
export function formatJsonValue(value: unknown): string {
|
||||||
|
const compact = JSON.stringify(value);
|
||||||
|
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a multi-line string with proper indentation for tool call output
|
||||||
|
* First line has the label, subsequent lines are indented 4 spaces
|
||||||
|
*/
|
||||||
|
export function formatIndentedField(label: string, content: string): string {
|
||||||
|
if (!content.includes("\n")) {
|
||||||
|
return ` ${label}: ${content}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
let formatted = ` ${label}: ${lines[0]}\n`;
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
formatted += ` ${lines[i]}\n`;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { log } from "./cli.ts";
|
||||||
|
|
||||||
|
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||||
|
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
|
|
||||||
|
function isSensitive(key: string): boolean {
|
||||||
|
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(value: string | undefined) {
|
||||||
|
if (value && typeof value === "string" && value.trim().length > 0) {
|
||||||
|
// ::add-mask::value tells GitHub Actions to mask this value in logs
|
||||||
|
console.log(`::add-mask::${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize environment variables to uppercase.
|
||||||
|
* This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
|
||||||
|
*
|
||||||
|
* If there are conflicts (same key with different capitalizations but different values),
|
||||||
|
* logs a warning and keeps the uppercase version.
|
||||||
|
*
|
||||||
|
* Also registers sensitive values as masks in GitHub Actions.
|
||||||
|
*/
|
||||||
|
export function normalizeEnv(): void {
|
||||||
|
const upperKeys = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// group keys by their uppercase form
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
const upper = key.toUpperCase();
|
||||||
|
const existing = upperKeys.get(upper) || [];
|
||||||
|
existing.push(key);
|
||||||
|
upperKeys.set(upper, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// process each group
|
||||||
|
for (const [upperKey, keys] of upperKeys) {
|
||||||
|
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
|
||||||
|
if (isSensitive(upperKey)) {
|
||||||
|
// mask all values associated with this key group
|
||||||
|
for (const key of keys) {
|
||||||
|
maskValue(process.env[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length === 1) {
|
||||||
|
const key = keys[0];
|
||||||
|
if (key !== upperKey) {
|
||||||
|
// single key, just needs uppercasing
|
||||||
|
process.env[upperKey] = process.env[key];
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiple keys with different capitalizations
|
||||||
|
const values = keys.map((k) => process.env[k]);
|
||||||
|
const uniqueValues = new Set(values);
|
||||||
|
|
||||||
|
if (uniqueValues.size > 1) {
|
||||||
|
// conflict: different values for different capitalizations
|
||||||
|
log.warning(
|
||||||
|
`env var conflict: ${keys.join(", ")} have different values. using uppercase ${upperKey}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefer the uppercase version if it exists, otherwise use the first one
|
||||||
|
const preferredKey = keys.find((k) => k === upperKey) || keys[0];
|
||||||
|
const preferredValue = process.env[preferredKey];
|
||||||
|
|
||||||
|
// delete all variants
|
||||||
|
for (const key of keys) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the uppercase version
|
||||||
|
process.env[upperKey] = preferredValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Inputs } from './payload.ts';
|
||||||
|
|
||||||
|
describe('Inputs schema', () => {
|
||||||
|
it('only prompt is required', () => {
|
||||||
|
const result = Inputs.assert({prompt: 'test prompt'});
|
||||||
|
expect(result).toEqual({prompt: 'test prompt'});
|
||||||
|
expect(() => Inputs.assert({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['web', 'enabled'],
|
||||||
|
['web', 'disabled'],
|
||||||
|
['web', undefined],
|
||||||
|
['search', 'enabled'],
|
||||||
|
['search', 'disabled'],
|
||||||
|
['search', undefined],
|
||||||
|
['write', 'enabled'],
|
||||||
|
['write', 'disabled'],
|
||||||
|
['write', undefined],
|
||||||
|
['bash', 'enabled'],
|
||||||
|
['bash', 'restricted'],
|
||||||
|
['bash', 'disabled'],
|
||||||
|
['bash', undefined],
|
||||||
|
['effort', 'mini'],
|
||||||
|
['effort', 'auto'],
|
||||||
|
['effort', 'max'],
|
||||||
|
['agent', 'claude'],
|
||||||
|
['agent', 'codex'],
|
||||||
|
['agent', 'cursor'],
|
||||||
|
['agent', 'gemini'],
|
||||||
|
['agent', 'opencode'],
|
||||||
|
['agent', null],
|
||||||
|
] as const)('should accept %s for %s', (prop, value) => {
|
||||||
|
const input = {prompt: 'test', [prop]: value};
|
||||||
|
expect(() => Inputs.assert(input)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['web'],
|
||||||
|
['search'],
|
||||||
|
['write'],
|
||||||
|
['bash'],
|
||||||
|
['effort'],
|
||||||
|
['agent'],
|
||||||
|
] as const)('should reject invalid %s values', (prop) => {
|
||||||
|
const input = {prompt: 'test', [prop]: 'invalid' as any};
|
||||||
|
expect(() => Inputs.assert(input)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { isAbsolute, resolve } from "node:path";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
|
||||||
|
import type { RepoSettings } from "./repoSettings.ts";
|
||||||
|
|
||||||
|
// tool permission enum types for inputs
|
||||||
|
const ToolPermissionInput = type.enumerated("disabled", "enabled");
|
||||||
|
const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
||||||
|
|
||||||
|
// schema for JSON payload passed via prompt (internal dispatch invocation)
|
||||||
|
// note: permissions are intentionally NOT included here to prevent injection attacks
|
||||||
|
// permissions are derived from event.authorPermission instead
|
||||||
|
const JsonPayload = type({
|
||||||
|
"~pullfrog": "true",
|
||||||
|
"agent?": AgentName.or("undefined"),
|
||||||
|
"prompt?": "string",
|
||||||
|
"repoInstructions?": "string",
|
||||||
|
"event?": "object",
|
||||||
|
"effort?": Effort.or("undefined"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// permission levels that indicate collaborator status (have push access)
|
||||||
|
const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
|
||||||
|
|
||||||
|
// check if the event author has collaborator-level permissions
|
||||||
|
function isCollaborator(event: PayloadEvent): boolean {
|
||||||
|
const perm = event.authorPermission;
|
||||||
|
return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs schema - action inputs from core.getInput()
|
||||||
|
// note: tool permissions use .or("undefined") because getInput() || undefined
|
||||||
|
// explicitly sets the property to undefined when empty, which is different from
|
||||||
|
// the property being absent. arktype's "prop?" means "optional to include" but
|
||||||
|
// if included, must match the type - so we need to explicitly allow undefined.
|
||||||
|
export const Inputs = type({
|
||||||
|
prompt: "string",
|
||||||
|
"effort?": Effort.or("undefined"),
|
||||||
|
"agent?": AgentName.or("undefined"),
|
||||||
|
"web?": ToolPermissionInput.or("undefined"),
|
||||||
|
"search?": ToolPermissionInput.or("undefined"),
|
||||||
|
"write?": ToolPermissionInput.or("undefined"),
|
||||||
|
"bash?": BashPermissionInput.or("undefined"),
|
||||||
|
"cwd?": type.string.or("undefined"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Inputs = typeof Inputs.infer;
|
||||||
|
|
||||||
|
function isAgentName(value: unknown): value is AgentName {
|
||||||
|
return typeof value === "string" && AgentName(value) instanceof type.errors === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPayloadEvent(value: unknown): value is PayloadEvent {
|
||||||
|
return typeof value === "object" && value !== null && "trigger" in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCwd(cwd: string | undefined): string | undefined {
|
||||||
|
const workspace = process.env.GITHUB_WORKSPACE;
|
||||||
|
if (!cwd) return workspace;
|
||||||
|
if (isAbsolute(cwd)) return cwd;
|
||||||
|
return workspace ? resolve(workspace, cwd) : cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePayload(repoSettings: RepoSettings) {
|
||||||
|
const inputs = Inputs.assert({
|
||||||
|
prompt: core.getInput("prompt", { required: true }),
|
||||||
|
effort: core.getInput("effort") || undefined,
|
||||||
|
agent: core.getInput("agent") || undefined,
|
||||||
|
cwd: core.getInput("cwd") || undefined,
|
||||||
|
web: core.getInput("web") || undefined,
|
||||||
|
search: core.getInput("search") || undefined,
|
||||||
|
write: core.getInput("write") || undefined,
|
||||||
|
bash: core.getInput("bash") || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// validate agent name
|
||||||
|
const agent: AgentName | undefined =
|
||||||
|
inputs.agent !== undefined && isAgentName(inputs.agent) ? inputs.agent : undefined;
|
||||||
|
|
||||||
|
// try to parse prompt as JSON payload (internal invocation)
|
||||||
|
let jsonPayload: typeof JsonPayload.infer | null = null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inputs.prompt);
|
||||||
|
// if it looks like a pullfrog payload but fails validation, that's an error
|
||||||
|
if (parsed && typeof parsed === "object" && "~pullfrog" in parsed) {
|
||||||
|
jsonPayload = JsonPayload.assert(parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// JSON parse error is fine (plain text prompt), but validation error should propagate
|
||||||
|
if (error instanceof type.errors) {
|
||||||
|
throw new Error(`invalid pullfrog payload: ${error.summary}`);
|
||||||
|
}
|
||||||
|
// not JSON, treat as plain string prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
|
||||||
|
const rawEvent = jsonPayload?.event;
|
||||||
|
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
|
||||||
|
|
||||||
|
// resolve agent from jsonPayload with type guard
|
||||||
|
const jsonAgent = jsonPayload?.agent;
|
||||||
|
const resolvedAgent: AgentName | undefined =
|
||||||
|
agent ?? (jsonAgent !== undefined && isAgentName(jsonAgent) ? jsonAgent : undefined);
|
||||||
|
|
||||||
|
// determine if permissions should be restricted based on event author
|
||||||
|
// non-collaborators (read, triage, none, or missing) get restricted bash access
|
||||||
|
const shouldRestrict = !isCollaborator(event);
|
||||||
|
|
||||||
|
// build payload - precedence: inputs > repoSettings > fallbacks
|
||||||
|
// note: modes are NOT in payload - they come from repoSettings in main()
|
||||||
|
return {
|
||||||
|
"~pullfrog": true as const,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
// inverted: jsonPayload.prompt extracts the text from the JSON payload,
|
||||||
|
// whereas inputs.prompt IS the raw JSON string when internally dispatched
|
||||||
|
prompt: jsonPayload?.prompt ?? inputs.prompt,
|
||||||
|
repoInstructions: jsonPayload?.repoInstructions,
|
||||||
|
event,
|
||||||
|
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
|
||||||
|
cwd: resolveCwd(inputs.cwd),
|
||||||
|
|
||||||
|
// permissions: inputs > repoSettings > fallbacks
|
||||||
|
// bash is restricted for non-collaborators regardless of repoSettings
|
||||||
|
web: inputs.web ?? repoSettings.web ?? "enabled",
|
||||||
|
search: inputs.search ?? repoSettings.search ?? "enabled",
|
||||||
|
write: inputs.write ?? repoSettings.write ?? "enabled",
|
||||||
|
bash: inputs.bash ?? (shouldRestrict ? "restricted" : repoSettings.bash) ?? "restricted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedPayload = ReturnType<typeof resolvePayload>;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Octokit } from "@octokit/rest";
|
||||||
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "./github.ts";
|
||||||
|
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
|
||||||
|
|
||||||
|
export interface RepoData {
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||||
|
repoSettings: RepoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveRepoDataParams {
|
||||||
|
octokit: OctokitWithPlugins;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize repo data: parse context, fetch repo info and settings
|
||||||
|
*/
|
||||||
|
export async function resolveRepoData(params: ResolveRepoDataParams): Promise<RepoData> {
|
||||||
|
log.info(`» running Pullfrog v${packageJson.version}...`);
|
||||||
|
|
||||||
|
const { owner, name } = parseRepoContext();
|
||||||
|
|
||||||
|
// fetch repo data and settings in parallel
|
||||||
|
const [repoResponse, repoSettings] = await Promise.all([
|
||||||
|
params.octokit.repos.get({ owner, repo: name }),
|
||||||
|
fetchRepoSettings({ token: params.token, repoContext: { owner, name } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
repo: repoResponse.data,
|
||||||
|
repoSettings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-export for convenience
|
||||||
|
export { createOctokit };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { AgentName, BashPermission, ToolPermission } from "../external.ts";
|
||||||
|
import type { RepoContext } from "./github.ts";
|
||||||
|
|
||||||
|
export interface Mode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoSettings {
|
||||||
|
defaultAgent: AgentName | null;
|
||||||
|
modes: Mode[];
|
||||||
|
repoInstructions: string;
|
||||||
|
web: ToolPermission;
|
||||||
|
search: ToolPermission;
|
||||||
|
write: ToolPermission;
|
||||||
|
bash: BashPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch repository settings from the Pullfrog API
|
||||||
|
* Returns defaults if repo doesn't exist or fetch fails
|
||||||
|
*/
|
||||||
|
export async function fetchRepoSettings(params: {
|
||||||
|
token: string;
|
||||||
|
repoContext: RepoContext;
|
||||||
|
}): Promise<RepoSettings> {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
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}/settings`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${params.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
defaultAgent: null,
|
||||||
|
modes: [],
|
||||||
|
repoInstructions: "",
|
||||||
|
web: "enabled",
|
||||||
|
search: "enabled",
|
||||||
|
write: "enabled",
|
||||||
|
bash: "restricted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = (await response.json()) as RepoSettings | null;
|
||||||
|
if (settings === null) {
|
||||||
|
return {
|
||||||
|
defaultAgent: null,
|
||||||
|
modes: [],
|
||||||
|
repoInstructions: "",
|
||||||
|
web: "enabled",
|
||||||
|
search: "enabled",
|
||||||
|
write: "enabled",
|
||||||
|
bash: "restricted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return {
|
||||||
|
defaultAgent: null,
|
||||||
|
modes: [],
|
||||||
|
repoInstructions: "",
|
||||||
|
web: "enabled",
|
||||||
|
search: "enabled",
|
||||||
|
write: "enabled",
|
||||||
|
bash: "restricted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AgentResult } from "../agents/shared.ts";
|
||||||
|
import type { MainResult } from "../main.ts";
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
|
||||||
|
export async function handleAgentResult(result: AgentResult): Promise<MainResult> {
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error || "Agent execution failed",
|
||||||
|
output: result.output!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log.success("Task complete.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: result.output || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { agentsManifest } from "../external.ts";
|
import { agentsManifest } from "../external.ts";
|
||||||
import { getGitHubInstallationToken } from "./github.ts";
|
import { getGitHubInstallationToken } from "./token.ts";
|
||||||
|
|
||||||
function getAllSecrets(): string[] {
|
function getAllSecrets(): string[] {
|
||||||
const secrets: string[] = [];
|
const secrets: string[] = [];
|
||||||
|
|||||||
+77
-45
@@ -1,8 +1,10 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import type { Payload } from "../external.ts";
|
import { tmpdir } from "node:os";
|
||||||
import type { ToolState } from "../main.ts";
|
import { join } from "node:path";
|
||||||
|
import type { PayloadEvent } from "../external.ts";
|
||||||
import { checkoutPrBranch } from "../mcp/checkout.ts";
|
import { checkoutPrBranch } from "../mcp/checkout.ts";
|
||||||
|
import type { ToolState } from "../mcp/server.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import type { OctokitWithPlugins } from "./github.ts";
|
import type { OctokitWithPlugins } from "./github.ts";
|
||||||
import { $ } from "./shell.ts";
|
import { $ } from "./shell.ts";
|
||||||
@@ -11,6 +13,16 @@ export interface SetupOptions {
|
|||||||
tempDir: string;
|
tempDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a shared temp directory for the action
|
||||||
|
*/
|
||||||
|
export function createTempDirectory(): string {
|
||||||
|
const sharedTempDir = mkdtempSync(join(tmpdir(), "pullfrog-"));
|
||||||
|
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
|
||||||
|
log.info(`» created temp dir at ${sharedTempDir}`);
|
||||||
|
return sharedTempDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup the test repository for running actions
|
* Setup the test repository for running actions
|
||||||
*/
|
*/
|
||||||
@@ -18,31 +30,73 @@ export function setupTestRepo(options: SetupOptions): void {
|
|||||||
const { tempDir } = options;
|
const { tempDir } = options;
|
||||||
const repo = process.env.GITHUB_REPOSITORY;
|
const repo = process.env.GITHUB_REPOSITORY;
|
||||||
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
|
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
|
||||||
if (existsSync(tempDir)) {
|
log.info(`» cloning ${repo} into ${tempDir}...`);
|
||||||
log.info("» removing existing .temp directory...");
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
// use HTTPS with token in CI, SSH locally
|
||||||
|
if (process.env.CI) {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) throw new Error("GITHUB_TOKEN is required in CI");
|
||||||
|
$("git", ["clone", `https://x-access-token:${token}@github.com/${repo}.git`, tempDir]);
|
||||||
|
} else {
|
||||||
|
$("git", ["clone", `git@github.com:${repo}.git`, tempDir]);
|
||||||
}
|
}
|
||||||
log.info(`» cloning ${repo} into .temp...`);
|
}
|
||||||
$("git", ["clone", `git@github.com:${repo}.git`, tempDir]);
|
|
||||||
|
interface SetupGitParams {
|
||||||
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
event: PayloadEvent;
|
||||||
|
octokit: OctokitWithPlugins;
|
||||||
|
toolState: ToolState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup git configuration to avoid identity errors
|
* Setup git configuration and authentication for the repository.
|
||||||
* Uses --local flag to scope config to the current repo only
|
* - Configures git identity (user.email, user.name)
|
||||||
|
* - Sets up authentication via token
|
||||||
|
* - For PR events, checks out the PR branch using shared helper
|
||||||
|
*
|
||||||
|
* FORK PR ARCHITECTURE:
|
||||||
|
* - origin: always points to BASE REPO (where PR targets)
|
||||||
|
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
|
||||||
|
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
|
||||||
*/
|
*/
|
||||||
export function setupGitConfig(): void {
|
export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
|
|
||||||
|
// 1. configure git identity
|
||||||
log.info("» setting up git configuration...");
|
log.info("» setting up git configuration...");
|
||||||
try {
|
try {
|
||||||
// Use --local to scope config to this repo only, preventing leakage to user's global config
|
// check current config - only set defaults if not configured or using generic bot
|
||||||
execSync('git config --local user.email "team@pullfrog.com"', {
|
let currentEmail = "";
|
||||||
cwd: repoDir,
|
try {
|
||||||
stdio: "pipe",
|
currentEmail = execSync("git config user.email", {
|
||||||
});
|
cwd: repoDir,
|
||||||
execSync('git config --local user.name "pullfrog"', {
|
stdio: "pipe",
|
||||||
cwd: repoDir,
|
encoding: "utf-8",
|
||||||
stdio: "pipe",
|
}).trim();
|
||||||
});
|
} catch {
|
||||||
|
// not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldSetDefaults =
|
||||||
|
!currentEmail || currentEmail === "github-actions[bot]@users.noreply.github.com";
|
||||||
|
|
||||||
|
if (shouldSetDefaults) {
|
||||||
|
execSync('git config --local user.email "226033991+pullfrog[bot]@users.noreply.github.com"', {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execSync('git config --local user.name "pullfrog[bot]"', {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
log.debug("» git user configured (using defaults)");
|
||||||
|
} else {
|
||||||
|
log.debug(`» git user already configured (${currentEmail}), skipping`);
|
||||||
|
}
|
||||||
|
|
||||||
// disable credential helper to prevent macOS keychain prompts when using x-access-token
|
// disable credential helper to prevent macOS keychain prompts when using x-access-token
|
||||||
// only needed locally - GitHub Actions doesn't have this issue
|
// only needed locally - GitHub Actions doesn't have this issue
|
||||||
if (!process.env.GITHUB_ACTIONS) {
|
if (!process.env.GITHUB_ACTIONS) {
|
||||||
@@ -51,7 +105,6 @@ export function setupGitConfig(): void {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
log.debug("» git configuration set successfully (scoped to repo)");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If git config fails, log warning but don't fail the action
|
// If git config fails, log warning but don't fail the action
|
||||||
// This can happen if we're not in a git repo or git isn't available
|
// This can happen if we're not in a git repo or git isn't available
|
||||||
@@ -59,29 +112,8 @@ export function setupGitConfig(): void {
|
|||||||
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
interface SetupGitAuthParams {
|
|
||||||
token: string;
|
|
||||||
owner: string;
|
|
||||||
name: string;
|
|
||||||
payload: Payload;
|
|
||||||
octokit: OctokitWithPlugins;
|
|
||||||
toolState: ToolState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup git authentication for the repository.
|
|
||||||
* For PR events, uses the shared checkoutPrBranch helper (also used by checkout_pr MCP tool).
|
|
||||||
*
|
|
||||||
* FORK PR ARCHITECTURE:
|
|
||||||
* - origin: always points to BASE REPO (where PR targets)
|
|
||||||
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
|
|
||||||
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
|
|
||||||
*/
|
|
||||||
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
|
||||||
const repoDir = process.cwd();
|
|
||||||
|
|
||||||
|
// 2. setup authentication
|
||||||
log.info("» setting up git authentication...");
|
log.info("» setting up git authentication...");
|
||||||
|
|
||||||
// remove existing git auth headers that actions/checkout might have set
|
// remove existing git auth headers that actions/checkout might have set
|
||||||
@@ -96,7 +128,7 @@ export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// non-PR events: set up origin with token, stay on default branch
|
// non-PR events: set up origin with token, stay on default branch
|
||||||
if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) {
|
if (params.event.is_pr !== true || !params.event.issue_number) {
|
||||||
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
|
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
|
||||||
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
|
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
|
||||||
log.info("» updated origin URL with authentication token");
|
log.info("» updated origin URL with authentication token");
|
||||||
@@ -104,7 +136,7 @@ export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PR event: checkout PR branch using shared helper
|
// PR event: checkout PR branch using shared helper
|
||||||
const prNumber = params.payload.event.issue_number;
|
const prNumber = params.event.issue_number;
|
||||||
|
|
||||||
// ensure origin is configured with auth token before checkout
|
// ensure origin is configured with auth token before checkout
|
||||||
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
|
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|||||||
export interface SpawnOptions {
|
export interface SpawnOptions {
|
||||||
cmd: string;
|
cmd: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: Record<string, string>;
|
env?: NodeJS.ProcessEnv;
|
||||||
input?: string;
|
input?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
import { acquireNewToken } from "./github.ts";
|
||||||
|
|
||||||
|
// re-export for get-installation-token action
|
||||||
|
export { acquireNewToken as acquireInstallationToken };
|
||||||
|
export { revokeGitHubInstallationToken as revokeInstallationToken };
|
||||||
|
|
||||||
|
// store token in memory instead of process.env
|
||||||
|
let githubInstallationToken: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup GitHub installation token for the action
|
||||||
|
*/
|
||||||
|
export async function resolveInstallationToken() {
|
||||||
|
assert(!githubInstallationToken, "GitHub installation token is already set.");
|
||||||
|
const acquiredToken = await acquireNewToken();
|
||||||
|
core.setSecret(acquiredToken);
|
||||||
|
githubInstallationToken = acquiredToken;
|
||||||
|
return {
|
||||||
|
token: acquiredToken,
|
||||||
|
[Symbol.asyncDispose]() {
|
||||||
|
githubInstallationToken = undefined;
|
||||||
|
return revokeGitHubInstallationToken(acquiredToken);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GitHub installation token from memory
|
||||||
|
*/
|
||||||
|
export function getGitHubInstallationToken(): string {
|
||||||
|
assert(
|
||||||
|
githubInstallationToken,
|
||||||
|
"GitHub installation token not set. Call resolveInstallationToken first."
|
||||||
|
);
|
||||||
|
return githubInstallationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
||||||
|
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${apiUrl}/installation/token`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.debug("» installation token revoked");
|
||||||
|
} catch (error) {
|
||||||
|
log.warning(
|
||||||
|
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { log } from "./cli.ts";
|
||||||
|
import type { OctokitWithPlugins } from "./github.ts";
|
||||||
|
import { fetchWorkflowRunInfo, type WorkflowRunInfo } from "./workflowRun.ts";
|
||||||
|
|
||||||
|
interface ResolveRunParams {
|
||||||
|
octokit: OctokitWithPlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveRunResult {
|
||||||
|
runId: string;
|
||||||
|
jobId: string | undefined;
|
||||||
|
workflowRunInfo: WorkflowRunInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve GitHub Actions workflow run context.
|
||||||
|
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
|
||||||
|
*/
|
||||||
|
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
|
||||||
|
const runId = process.env.GITHUB_RUN_ID || "";
|
||||||
|
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||||
|
if (!githubRepo || !githubRepo.includes("/")) {
|
||||||
|
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
|
||||||
|
}
|
||||||
|
const [owner, repo] = githubRepo.split("/");
|
||||||
|
|
||||||
|
const workflowRunInfo = runId ? await fetchWorkflowRunInfo(runId) : { progressCommentId: null };
|
||||||
|
|
||||||
|
if (workflowRunInfo.progressCommentId) {
|
||||||
|
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobId: string | undefined;
|
||||||
|
const jobName = process.env.GITHUB_JOB;
|
||||||
|
if (jobName && runId) {
|
||||||
|
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
run_id: parseInt(runId, 10),
|
||||||
|
});
|
||||||
|
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
||||||
|
if (matchingJob) {
|
||||||
|
jobId = String(matchingJob.id);
|
||||||
|
log.debug(`» found job ID: ${jobId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runId, jobId, workflowRunInfo };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface WorkflowRunInfo {
|
||||||
|
progressCommentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch workflow run info from the Pullfrog API.
|
||||||
|
* Returns the pre-created progress comment ID if one exists.
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
const timeoutMs = 30000;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/workflow-run/${runId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { progressCommentId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as WorkflowRunInfo;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return { progressCommentId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user