Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2735b2916 | |||
| 9714d5fea6 | |||
| a57866a8cd | |||
| 9903072286 | |||
| edb7603587 | |||
| 45f837cedb | |||
| c6572f0987 | |||
| 89e93d3398 | |||
| c335032c37 | |||
| 2f3ae3e481 | |||
| 308781793f | |||
| 1765e04d77 | |||
| c5d201ce60 | |||
| c89f1b9537 | |||
| 7fe0233c24 | |||
| e10f756560 | |||
| 48108b137a | |||
| 074a860a95 | |||
| 0c03428488 | |||
| 5fa8c3603d | |||
| 78c22085bf | |||
| b55cda579d | |||
| 1d1d80c3f9 | |||
| 3a97ba04fc | |||
| fe7ce4af11 | |||
| 6260b23de7 | |||
| 9291ee5952 | |||
| d30532979a | |||
| c8b65327ee | |||
| 879d33403c | |||
| 2cc081c912 | |||
| b9a7a19ca1 | |||
| 7ee08d37a6 | |||
| ff913feb3c | |||
| 317ebd3431 | |||
| 2bd12b9553 | |||
| d99a852e24 | |||
| 6e289e9310 | |||
| a483711fee | |||
| 244c7d4d8d | |||
| 0504fc42ff | |||
| ad1f51d704 | |||
| 573c473dc1 | |||
| c200c7aff9 | |||
| 8a7db7bba2 | |||
| 3f996b4759 |
@@ -23,8 +23,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run type check
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
@@ -45,3 +45,5 @@ examples
|
|||||||
# Temporary directory for cloned repos
|
# Temporary directory for cloned repos
|
||||||
.temp/
|
.temp/
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
@@ -4,10 +4,3 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
|||||||
pnpm lock
|
pnpm lock
|
||||||
git add pnpm-lock.yaml
|
git add pnpm-lock.yaml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if entry needs rebuilding (entry.ts, esbuild.config.js, or any .ts files)
|
|
||||||
if git diff --cached --name-only | grep -qE "^(entry\.ts|esbuild\.config\.js|.*\.ts)$"; then
|
|
||||||
echo "🔨 Building action..."
|
|
||||||
pnpm build
|
|
||||||
git add entry
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
v24.3.0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 pullfrog
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
||||||
<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">
|
||||||
@@ -84,6 +83,7 @@ on:
|
|||||||
prompt:
|
prompt:
|
||||||
description: 'Agent prompt'
|
description: 'Agent prompt'
|
||||||
type: string
|
type: string
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -105,14 +105,14 @@ jobs:
|
|||||||
- name: Run agent
|
- name: Run agent
|
||||||
uses: pullfrog/action@v0
|
uses: pullfrog/action@v0
|
||||||
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 }}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+9
-18
@@ -1,30 +1,21 @@
|
|||||||
name: "Pullfrog Claude Code Action"
|
name: "Pullfrog Action"
|
||||||
description: "Execute Claude Code with a prompt using Anthropic API"
|
description: "Execute coding agents with a prompt"
|
||||||
author: "Pullfrog"
|
author: "Pullfrog"
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
prompt:
|
prompt:
|
||||||
description: "Prompt to send to Claude Code"
|
description: "Prompt to send to the agent"
|
||||||
required: true
|
required: true
|
||||||
default: "Hello from Claude Code!"
|
effort:
|
||||||
anthropic_api_key:
|
description: "Effort level: nothink (fast), think (default), max (most capable)"
|
||||||
description: "Anthropic API key for Claude Code authentication"
|
|
||||||
required: false
|
required: false
|
||||||
openai_api_key:
|
default: "think"
|
||||||
description: "OpenAI API key for Codex authentication"
|
cwd:
|
||||||
required: false
|
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
|
||||||
google_api_key:
|
|
||||||
description: "Google API key for Jules authentication"
|
|
||||||
required: false
|
|
||||||
gemini_api_key:
|
|
||||||
description: "Gemini API key for Jules authentication"
|
|
||||||
required: false
|
|
||||||
cursor_api_key:
|
|
||||||
description: "Cursor API key for Cursor authentication"
|
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "node20"
|
using: "node24"
|
||||||
main: "entry"
|
main: "entry"
|
||||||
|
|
||||||
branding:
|
branding:
|
||||||
|
|||||||
+36
-20
@@ -1,9 +1,24 @@
|
|||||||
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 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 { addInstructions } from "./instructions.ts";
|
||||||
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
||||||
|
|
||||||
|
// Model selection based on effort level
|
||||||
|
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
|
||||||
|
const claudeEffortModels: Record<Effort, string> = {
|
||||||
|
nothink: "haiku",
|
||||||
|
think: "opusplan",
|
||||||
|
max: "opus",
|
||||||
|
};
|
||||||
|
|
||||||
|
// FUTURE: Consider using Anthropic's "effort" parameter (beta) with Opus 4.5 for all tasks.
|
||||||
|
// This would allow a single model with effort levels ("low", "medium", "high") controlling
|
||||||
|
// token spend across responses, tool calls, and thinking. Requires beta header "effort-2025-11-24".
|
||||||
|
// See: https://platform.claude.com/docs/en/build-with-claude/effort
|
||||||
|
// This approach could replace model selection if effort proves effective for controlling capability.
|
||||||
|
|
||||||
export const claude = agent({
|
export const claude = agent({
|
||||||
name: "claude",
|
name: "claude",
|
||||||
install: async () => {
|
install: async () => {
|
||||||
@@ -14,35 +29,34 @@ export const claude = agent({
|
|||||||
executablePath: "cli.js",
|
executablePath: "cli.js",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||||
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// select model based on effort level
|
||||||
|
const model = claudeEffortModels[effort];
|
||||||
|
log.info(`Using model: ${model} (effort: ${effort})`);
|
||||||
|
|
||||||
|
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
|
||||||
|
// disable native Bash; agents use MCP bash tool which filters secrets.
|
||||||
|
// for private repos, native Bash is allowed since secrets are less exposed.
|
||||||
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||||
const sandboxOptions: Options = payload.sandbox
|
const sandboxOptions: Options = payload.sandbox
|
||||||
? {
|
? {
|
||||||
permissionMode: "default",
|
permissionMode: "default",
|
||||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
||||||
async canUseTool(toolName, input, _options) {
|
async canUseTool(toolName, input, _options) {
|
||||||
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
||||||
return {
|
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
||||||
behavior: "allow",
|
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
||||||
updatedInput: input,
|
|
||||||
updatedPermissions: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error("can i use this tool?", toolName);
|
|
||||||
return {
|
|
||||||
behavior: "deny",
|
|
||||||
message: "You are not allowed to use this tool.",
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
permissionMode: "bypassPermissions" as const,
|
permissionMode: "bypassPermissions" as const,
|
||||||
|
disallowedTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
@@ -51,15 +65,17 @@ export const claude = agent({
|
|||||||
|
|
||||||
// Pass secrets via SDK's env option only (not process.env)
|
// Pass secrets via SDK's env option only (not process.env)
|
||||||
// This ensures secrets are only available to Claude Code subprocess, not user code
|
// This ensures secrets are only available to Claude Code subprocess, not user code
|
||||||
|
const queryOptions: Options = {
|
||||||
|
...sandboxOptions,
|
||||||
|
mcpServers,
|
||||||
|
model,
|
||||||
|
pathToClaudeCodeExecutable: cliPath,
|
||||||
|
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
||||||
|
};
|
||||||
|
|
||||||
const queryInstance = query({
|
const queryInstance = query({
|
||||||
prompt,
|
prompt,
|
||||||
options: {
|
options: queryOptions,
|
||||||
...sandboxOptions,
|
|
||||||
mcpServers,
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
pathToClaudeCodeExecutable: cliPath,
|
|
||||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream the results
|
// Stream the results
|
||||||
|
|||||||
+110
-57
@@ -1,15 +1,77 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import {
|
||||||
|
Codex,
|
||||||
|
type CodexOptions,
|
||||||
|
type ModelReasoningEffort,
|
||||||
|
type ThreadEvent,
|
||||||
|
type ThreadOptions,
|
||||||
|
} from "@openai/codex-sdk";
|
||||||
|
import type { Effort } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { addInstructions } from "./instructions.ts";
|
||||||
import {
|
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
|
||||||
agent,
|
|
||||||
type ConfigureMcpServersParams,
|
// model configuration based on effort level
|
||||||
installFromNpmTarball,
|
const codexModel: Record<Effort, string> = {
|
||||||
setupProcessAgentEnv,
|
nothink: "gpt-5.1-codex-mini",
|
||||||
} from "./shared.ts";
|
think: "gpt-5.1-codex",
|
||||||
|
max: "gpt-5.1-codex-max",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// reasoning effort configuration based on effort level
|
||||||
|
// uses modelReasoningEffort parameter from ThreadOptions
|
||||||
|
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||||
|
nothink: "low",
|
||||||
|
think: undefined, // use default
|
||||||
|
max: "high",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WriteCodexConfigParams {
|
||||||
|
tempHome: string;
|
||||||
|
mcpServers: Record<string, McpHttpServerConfig>;
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string {
|
||||||
|
const codexDir = join(tempHome, ".codex");
|
||||||
|
mkdirSync(codexDir, { recursive: true });
|
||||||
|
const configPath = join(codexDir, "config.toml");
|
||||||
|
|
||||||
|
// build MCP servers section
|
||||||
|
const mcpServerSections: string[] = [];
|
||||||
|
for (const [name, config] of Object.entries(mcpServers)) {
|
||||||
|
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
|
||||||
|
// this prevents vuln if user's ~/.codex/config.toml has ignore_default_excludes=true
|
||||||
|
// for private repos, no filtering - agents use native shell with full env access
|
||||||
|
const shellPolicy = isPublicRepo
|
||||||
|
? `[shell_environment_policy]
|
||||||
|
ignore_default_excludes = false`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
configPath,
|
||||||
|
`# written by pullfrog
|
||||||
|
${shellPolicy}
|
||||||
|
|
||||||
|
${mcpServerSections.join("\n\n")}
|
||||||
|
`.trim() + "\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPublicRepo) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export const codex = agent({
|
export const codex = agent({
|
||||||
name: "codex",
|
name: "codex",
|
||||||
@@ -20,18 +82,32 @@ export const codex = agent({
|
|||||||
executablePath: "bin/codex.js",
|
executablePath: "bin/codex.js",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||||
// create config directory for codex before setting HOME
|
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
|
|
||||||
|
// create config directory for codex before setting HOME
|
||||||
const configDir = join(tempHome, ".config", "codex");
|
const configDir = join(tempHome, ".config", "codex");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const codexDir = writeCodexConfig({
|
||||||
|
tempHome,
|
||||||
|
mcpServers,
|
||||||
|
isPublicRepo: repo.isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
setupProcessAgentEnv({
|
setupProcessAgentEnv({
|
||||||
OPENAI_API_KEY: apiKey,
|
OPENAI_API_KEY: apiKey,
|
||||||
HOME: tempHome,
|
HOME: tempHome,
|
||||||
|
CODEX_HOME: codexDir, // point Codex to our config directory
|
||||||
});
|
});
|
||||||
|
|
||||||
configureCodexMcpServers({ mcpServers, cliPath });
|
// get model and reasoning effort based on effort level
|
||||||
|
const model = codexModel[effort];
|
||||||
|
const modelReasoningEffort = codexReasoningEffort[effort];
|
||||||
|
log.info(`Using model: ${model}`);
|
||||||
|
if (modelReasoningEffort) {
|
||||||
|
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Configure Codex
|
// Configure Codex
|
||||||
const codexOptions: CodexOptions = {
|
const codexOptions: CodexOptions = {
|
||||||
@@ -44,21 +120,28 @@ export const codex = agent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codex = new Codex(codexOptions);
|
const codex = new Codex(codexOptions);
|
||||||
// valid sandbox modes: read-only, workspace-write, danger-full-access
|
|
||||||
const thread = codex.startThread(
|
// Build thread options with model and optional model_reasoning_effort
|
||||||
payload.sandbox
|
const baseThreadOptions = payload.sandbox
|
||||||
? {
|
? {
|
||||||
approvalPolicy: "never",
|
model,
|
||||||
sandboxMode: "read-only",
|
approvalPolicy: "never" as const,
|
||||||
networkAccessEnabled: false,
|
sandboxMode: "read-only" as const,
|
||||||
}
|
networkAccessEnabled: false,
|
||||||
: {
|
}
|
||||||
approvalPolicy: "never",
|
: {
|
||||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
model,
|
||||||
sandboxMode: "danger-full-access",
|
approvalPolicy: "never" as const,
|
||||||
networkAccessEnabled: true,
|
// 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
|
||||||
|
? { ...baseThreadOptions, modelReasoningEffort }
|
||||||
|
: baseThreadOptions;
|
||||||
|
|
||||||
|
const thread = codex.startThread(threadOptions);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||||
@@ -186,33 +269,3 @@ const messageHandlers: {
|
|||||||
log.error(`Error: ${event.message}`);
|
log.error(`Error: ${event.message}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure MCP servers for Codex using the CLI.
|
|
||||||
* For HTTP-based servers, use: codex mcp add <name> --url <url>
|
|
||||||
*/
|
|
||||||
function configureCodexMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
||||||
if (serverConfig.type === "http") {
|
|
||||||
// HTTP-based MCP server - use --url flag
|
|
||||||
const addArgs = ["mcp", "add", serverName, "--url", serverConfig.url];
|
|
||||||
|
|
||||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
|
||||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (addResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`codex mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log.info(`✓ MCP server '${serverName}' configured`);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported MCP server type for Codex: ${(serverConfig as any).type || "unknown"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+72
-30
@@ -1,7 +1,8 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
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 { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { addInstructions } from "./instructions.ts";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,14 @@ import {
|
|||||||
installFromCurl,
|
installFromCurl,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
|
|
||||||
|
// effort configuration for Cursor
|
||||||
|
// only "max" overrides the model; nothink/think use default ("auto")
|
||||||
|
const cursorEffortModels: Record<Effort, string | null> = {
|
||||||
|
nothink: null, // use default (auto)
|
||||||
|
think: null, // use default (auto)
|
||||||
|
max: "opus-4.5-thinking",
|
||||||
|
} as const;
|
||||||
|
|
||||||
// cursor cli event types inferred from stream-json output
|
// cursor cli event types inferred from stream-json output
|
||||||
interface CursorSystemEvent {
|
interface CursorSystemEvent {
|
||||||
type: "system";
|
type: "system";
|
||||||
@@ -91,9 +100,35 @@ export const cursor = agent({
|
|||||||
executableName: "cursor-agent",
|
executableName: "cursor-agent",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
configureCursorMcpServers({ mcpServers, cliPath });
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
||||||
|
|
||||||
|
// determine model based on effort level
|
||||||
|
// respect project's .cursor/cli.json if it specifies a model
|
||||||
|
const projectCliConfigPath = join(process.cwd(), ".cursor", "cli.json");
|
||||||
|
let modelOverride: string | null = null;
|
||||||
|
|
||||||
|
if (existsSync(projectCliConfigPath)) {
|
||||||
|
try {
|
||||||
|
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
||||||
|
if (projectConfig.model) {
|
||||||
|
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||||
|
} else {
|
||||||
|
modelOverride = cursorEffortModels[effort];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
modelOverride = cursorEffortModels[effort];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelOverride = cursorEffortModels[effort];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelOverride) {
|
||||||
|
log.info(`Using model: ${modelOverride} (effort: ${effort})`);
|
||||||
|
} else if (!existsSync(projectCliConfigPath)) {
|
||||||
|
log.info(`Using default model (effort: ${effort})`);
|
||||||
|
}
|
||||||
|
|
||||||
// track logged model_call_ids to avoid duplicates
|
// track logged model_call_ids to avoid duplicates
|
||||||
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
||||||
@@ -171,16 +206,16 @@ export const cursor = agent({
|
|||||||
|
|
||||||
// configure sandbox mode if enabled
|
// configure sandbox mode if enabled
|
||||||
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
|
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
|
||||||
|
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
|
||||||
|
|
||||||
|
// add model flag if we have an override
|
||||||
|
if (modelOverride) {
|
||||||
|
baseArgs.push("--model", modelOverride);
|
||||||
|
}
|
||||||
|
|
||||||
const cursorArgs = payload.sandbox
|
const cursorArgs = payload.sandbox
|
||||||
? [
|
? baseArgs // --force removed in sandbox mode to enforce safety checks
|
||||||
"--print",
|
: [...baseArgs, "--force"];
|
||||||
fullPrompt,
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
"--approve-mcps",
|
|
||||||
// --force removed in sandbox mode to enforce safety checks
|
|
||||||
]
|
|
||||||
: ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];
|
|
||||||
|
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||||
@@ -314,39 +349,46 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||||
* When sandbox is enabled, denies all file writes and shell commands.
|
|
||||||
* In print mode without --force, writes are blocked by default, but we add
|
|
||||||
* explicit deny rules as defense in depth.
|
|
||||||
*
|
*
|
||||||
* See: https://cursor.com/docs/cli/reference/permissions
|
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
|
||||||
|
* 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({ sandbox }: { sandbox: boolean }): void {
|
function configureCursorSandbox({
|
||||||
|
sandbox,
|
||||||
|
isPublicRepo,
|
||||||
|
}: {
|
||||||
|
sandbox: boolean;
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
}): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const cursorConfigDir = join(realHome, ".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
|
||||||
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
|
|
||||||
const config = sandbox
|
const config = sandbox
|
||||||
? {
|
? {
|
||||||
// sandbox mode: deny all writes and shell commands
|
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: [
|
allow: ["Read(**)"],
|
||||||
"Read(**)", // allow reading all files
|
deny: ["Write(**)", ...denyShell],
|
||||||
],
|
|
||||||
deny: [
|
|
||||||
"Write(**)", // deny all file writes
|
|
||||||
"Shell(**)", // deny all shell commands
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
// normal mode: allow everything
|
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)", "Write(**)", "Shell(**)"],
|
allow: ["Read(**)", "Write(**)"],
|
||||||
deny: [],
|
deny: denyShell,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||||
log.info(`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
|
log.info(
|
||||||
|
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-46
@@ -1,4 +1,7 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Effort } from "../external.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { spawn } from "../utils/subprocess.ts";
|
import { spawn } from "../utils/subprocess.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { addInstructions } from "./instructions.ts";
|
||||||
@@ -9,6 +12,15 @@ import {
|
|||||||
installFromGithub,
|
installFromGithub,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
|
|
||||||
|
// effort configuration: model + thinking level
|
||||||
|
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
||||||
|
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
|
||||||
|
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
|
||||||
|
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
|
||||||
|
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
|
||||||
|
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
// gemini cli event types inferred from stream-json output (NDJSON format)
|
// gemini cli event types inferred from stream-json output (NDJSON format)
|
||||||
interface GeminiInitEvent {
|
interface GeminiInitEvent {
|
||||||
type: "init";
|
type: "init";
|
||||||
@@ -154,8 +166,12 @@ export const gemini = agent({
|
|||||||
...(githubInstallationToken && { githubInstallationToken }),
|
...(githubInstallationToken && { githubInstallationToken }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
|
||||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
// 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 });
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
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");
|
||||||
@@ -164,34 +180,43 @@ export const gemini = agent({
|
|||||||
const sessionPrompt = addInstructions({ payload, repo });
|
const sessionPrompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// build CLI args based on sandbox mode
|
||||||
// --allowed-tools restricts which tools are available (removes others from registry entirely)
|
// for public repos, native shell is disabled via excludeTools in settings.json
|
||||||
// in sandbox mode: only read-only tools available (no write_file, run_shell_command, web_fetch)
|
let args: string[];
|
||||||
const args = payload.sandbox
|
if (payload.sandbox) {
|
||||||
? [
|
// sandbox mode: read-only tools only
|
||||||
"--allowed-tools",
|
args = [
|
||||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
"--model",
|
||||||
"--allowed-mcp-server-names",
|
model,
|
||||||
"gh_pullfrog",
|
"--allowed-tools",
|
||||||
"--output-format=stream-json",
|
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||||
"-p",
|
"--allowed-mcp-server-names",
|
||||||
sessionPrompt,
|
"gh_pullfrog",
|
||||||
]
|
"--output-format=stream-json",
|
||||||
: ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
"-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) {
|
if (payload.sandbox) {
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: "node",
|
cmd: "node",
|
||||||
args: [cliPath, ...args],
|
args: [cliPath, ...args],
|
||||||
env: createAgentEnv({
|
env: createAgentEnv({ GEMINI_API_KEY: apiKey }),
|
||||||
GEMINI_API_KEY: apiKey,
|
|
||||||
}),
|
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
finalOutput += text;
|
finalOutput += text;
|
||||||
@@ -264,36 +289,88 @@ export const gemini = agent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ConfigureGeminiParams = {
|
||||||
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
thinkingLevel: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure MCP servers for Gemini using the CLI.
|
* Configure Gemini CLI settings by writing to settings.json.
|
||||||
* Gemini CLI syntax: gemini mcp add <name> <commandOrUrl> [args...] --transport <type>
|
* - MCP servers: uses `httpUrl` for HTTP/streamable transport
|
||||||
* For HTTP-based servers, use: gemini mcp add <name> <url> --transport http
|
* - 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
|
||||||
*/
|
*/
|
||||||
function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
function configureGeminiSettings({
|
||||||
|
mcpServers,
|
||||||
|
isPublicRepo,
|
||||||
|
thinkingLevel,
|
||||||
|
}: ConfigureGeminiParams): void {
|
||||||
|
const realHome = homedir();
|
||||||
|
const geminiConfigDir = join(realHome, ".gemini");
|
||||||
|
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||||
|
mkdirSync(geminiConfigDir, { recursive: true });
|
||||||
|
|
||||||
|
// read existing settings if present
|
||||||
|
let existingSettings: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const content = readFileSync(settingsPath, "utf-8");
|
||||||
|
existingSettings = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
// file doesn't exist or is invalid - start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert to Gemini's expected format (httpUrl for HTTP transport, no type field)
|
||||||
|
type GeminiMcpServerConfig = {
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
url?: string;
|
||||||
|
httpUrl?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
trust?: boolean;
|
||||||
|
description?: string;
|
||||||
|
includeTools?: string[];
|
||||||
|
excludeTools?: string[];
|
||||||
|
};
|
||||||
|
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||||
if (serverConfig.type === "http") {
|
if (serverConfig.type !== "http") {
|
||||||
// HTTP-based MCP server - use URL with --transport http flag
|
|
||||||
const addArgs = ["mcp", "add", serverName, serverConfig.url, "--transport", "http"];
|
|
||||||
|
|
||||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
|
||||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (addResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`gemini mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log.info(`✓ MCP server '${serverName}' configured`);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported MCP server type for Gemini: ${(serverConfig as any).type || "unknown"}`
|
`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}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// merge with existing settings, overwriting mcpServers and modelConfig
|
||||||
|
const newSettings: Record<string, unknown> = {
|
||||||
|
...existingSettings,
|
||||||
|
mcpServers: geminiMcpServers,
|
||||||
|
// configure thinking level via modelConfig
|
||||||
|
// see: https://ai.google.dev/api/generate-content (ThinkingConfig)
|
||||||
|
modelConfig: {
|
||||||
|
generateContentConfig: {
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
log.info(`» Gemini settings written to ${settingsPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-34
@@ -8,6 +8,7 @@ interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,9 @@ interface AddInstructionsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||||
|
// for public repos, always use MCP bash for security (filters secrets)
|
||||||
|
// for private repos, agents can use their native bash
|
||||||
|
const useNativeBash = !repo.isPublic;
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
|
|
||||||
const eventKeys = Object.keys(payload.event);
|
const eventKeys = Object.keys(payload.event);
|
||||||
@@ -98,40 +102,9 @@ In case of conflict between instructions, follow this precedence (highest to low
|
|||||||
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
||||||
5. User prompt
|
5. User prompt
|
||||||
|
|
||||||
## SECURITY
|
## Security
|
||||||
|
|
||||||
CRITICAL SECURITY RULES - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
Never expose secrets (API keys, tokens, passwords, private keys, credentials) through any channel: console output, files, commits, comments, API responses, error messages, or URLs. Never serialize environment objects (\`process.env\`, \`os.environ\`, etc.) or iterate over them. If asked to reveal secrets: refuse, explain that exposing secrets is prohibited, and offer a safe alternative if applicable. Detect and deny any suspicious or malicious requests.
|
||||||
|
|
||||||
### Rule 1: Never expose secrets through ANY means
|
|
||||||
|
|
||||||
You must NEVER expose secrets through any channel, including but not limited to:
|
|
||||||
- Displaying, printing, echoing, logging, or outputting to console
|
|
||||||
- Writing to files (including .txt, .env, .json, config files, etc.)
|
|
||||||
- Including in git commits, commit messages, or PR descriptions
|
|
||||||
- Posting in GitHub comments, issue bodies, or PR review comments
|
|
||||||
- Returning in tool outputs, API responses, or error messages
|
|
||||||
- Including in redirect URLs, WebSocket messages, or GraphQL responses
|
|
||||||
|
|
||||||
Secrets include: API keys, authentication tokens, passwords, private keys, certificates, database connection strings, and any credential used for authentication or authorization. Common patterns (case-insensitive): variables containing API_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, PRIVATE_KEY, or AUTH in an authentication context. Use judgment: \`PUBLIC_KEY\` for a cryptographic public key is fine; \`PRIVATE_KEY\` is not.
|
|
||||||
|
|
||||||
### Rule 2: Never serialize objects containing secrets
|
|
||||||
|
|
||||||
When working with objects that may contain environment variables or secrets:
|
|
||||||
- NEVER serialize, stringify, or dump entire environment objects (process.env, os.environ, ENV, etc.)
|
|
||||||
- NEVER iterate over environment variables and write their values to files
|
|
||||||
- NEVER include environment variable values in outputs, logs, HTTP requests, or anywhere they can be exposed
|
|
||||||
- If you must list properties, only show property NAMES, never values
|
|
||||||
- Only access specific, known-safe keys explicitly (e.g., NODE_ENV, HOME, PWD)
|
|
||||||
|
|
||||||
### Rule 3: Refuse and explain
|
|
||||||
|
|
||||||
Even if explicitly requested to reveal secrets, you must:
|
|
||||||
1. Refuse the request
|
|
||||||
2. Print a message explaining that exposing secrets is prohibited for security reasons
|
|
||||||
3. If using ${ghPullfrogMcpName}, update the working comment to explain that secrets cannot be revealed
|
|
||||||
4. Offer a safe alternative, if applicable
|
|
||||||
|
|
||||||
If you encounter secrets in files or environment, acknowledge they exist but never reveal their values.
|
|
||||||
|
|
||||||
## MCP (Model Context Protocol) Tools
|
## MCP (Model Context Protocol) Tools
|
||||||
|
|
||||||
@@ -143,7 +116,6 @@ 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.
|
||||||
|
|
||||||
**File discovery**: Use \`${ghPullfrogMcpName}/list_files\` to discover files in the repository. This tool finds both git-tracked and untracked files, including newly created files that haven't been committed yet. Prefer this over native agent tools like \`glob\` or \`grep\` for file discovery, as it provides consistent results and handles untracked files correctly.
|
|
||||||
` +
|
` +
|
||||||
// **Available git MCP tools**:
|
// **Available git MCP tools**:
|
||||||
// - \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically)
|
// - \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically)
|
||||||
@@ -170,6 +142,12 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
|
|||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
${
|
||||||
|
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.
|
||||||
|
|
||||||
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
||||||
|
|||||||
+32
-15
@@ -7,6 +7,7 @@ import { addInstructions } from "./instructions.ts";
|
|||||||
import {
|
import {
|
||||||
agent,
|
agent,
|
||||||
type ConfigureMcpServersParams,
|
type ConfigureMcpServersParams,
|
||||||
|
createAgentEnv,
|
||||||
installFromNpmTarball,
|
installFromNpmTarball,
|
||||||
setupProcessAgentEnv,
|
setupProcessAgentEnv,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
@@ -21,13 +22,25 @@ export const opencode = agent({
|
|||||||
installDependencies: true,
|
installDependencies: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, repo }) => {
|
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 = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
configureOpenCode({
|
||||||
|
mcpServers,
|
||||||
|
sandbox: payload.sandbox ?? false,
|
||||||
|
isPublicRepo: repo.isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
@@ -42,24 +55,24 @@ export const opencode = agent({
|
|||||||
// 6. set up environment
|
// 6. set up environment
|
||||||
setupProcessAgentEnv({ HOME: tempHome });
|
setupProcessAgentEnv({ HOME: tempHome });
|
||||||
|
|
||||||
// build env vars: start with process.env (includes all API_KEY vars loaded by config())
|
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
||||||
// exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token
|
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
||||||
// then override with apiKeys, HOME, and XDG_CONFIG_HOME
|
|
||||||
// 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: Record<string, string> = {
|
||||||
...(Object.fromEntries(
|
...createAgentEnv({ HOME: tempHome }),
|
||||||
Object.entries(process.env).filter(
|
|
||||||
([key, value]) => value !== undefined && key !== "GITHUB_TOKEN"
|
|
||||||
)
|
|
||||||
) as Record<string, string>),
|
|
||||||
HOME: tempHome,
|
|
||||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||||
};
|
};
|
||||||
|
// OpenCode doesn't support GitHub App installation tokens
|
||||||
|
delete env.GITHUB_TOKEN;
|
||||||
|
|
||||||
// add/override API keys from apiKeys object (uppercase keys)
|
// add API keys from apiKeys object
|
||||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
for (const [key, value] of Object.entries(apiKeys || {})) {
|
||||||
env[key.toUpperCase()] = value;
|
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)
|
||||||
@@ -188,13 +201,14 @@ export const opencode = agent({
|
|||||||
interface ConfigureOpenCodeParams {
|
interface ConfigureOpenCodeParams {
|
||||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
sandbox: boolean;
|
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 }: ConfigureOpenCodeParams): void {
|
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
@@ -218,7 +232,10 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// build permissions config
|
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
|
||||||
|
// disable native bash; agents use MCP bash tool which filters secrets.
|
||||||
|
// for private repos, native bash is allowed.
|
||||||
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||||
const permission = sandbox
|
const permission = sandbox
|
||||||
? {
|
? {
|
||||||
edit: "deny",
|
edit: "deny",
|
||||||
@@ -229,7 +246,7 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
edit: "allow",
|
edit: "allow",
|
||||||
bash: "allow",
|
bash: bashPermission,
|
||||||
webfetch: "allow",
|
webfetch: "allow",
|
||||||
doom_loop: "allow",
|
doom_loop: "allow",
|
||||||
external_directory: "allow",
|
external_directory: "allow",
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,13 @@ import { join } from "node:path";
|
|||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { show } from "@ark/util";
|
import type { show } from "@ark/util";
|
||||||
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
|
import {
|
||||||
|
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 { getGitHubInstallationToken } from "../utils/github.ts";
|
||||||
|
|
||||||
@@ -27,6 +33,7 @@ export interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +46,7 @@ export interface AgentConfig {
|
|||||||
mcpServers: Record<string, McpHttpServerConfig>;
|
mcpServers: Record<string, McpHttpServerConfig>;
|
||||||
cliPath: string;
|
cliPath: string;
|
||||||
repo: RepoInfo;
|
repo: RepoInfo;
|
||||||
|
effort: Effort;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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`
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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 |
|
||||||
@@ -5,27 +5,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { flatMorph } from "@ark/util";
|
import { Inputs, main } from "./main.ts";
|
||||||
import { agents } from "./agents/index.ts";
|
|
||||||
import { type Inputs, main } from "./main.ts";
|
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
// Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
|
// 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
|
// JavaScript actions run from the action's directory, not the checked out repo
|
||||||
if (process.env.GITHUB_WORKSPACE && process.cwd() !== process.env.GITHUB_WORKSPACE) {
|
const cwd = core.getInput("cwd") || process.env.GITHUB_WORKSPACE;
|
||||||
log.debug(`Changing to GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`);
|
if (cwd && process.cwd() !== cwd) {
|
||||||
process.chdir(process.env.GITHUB_WORKSPACE);
|
log.debug(`changing to working directory: ${cwd}`);
|
||||||
log.debug(`New working directory: ${process.cwd()}`);
|
process.chdir(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inputs: Required<Inputs> = {
|
const inputs = Inputs.assert({
|
||||||
prompt: core.getInput("prompt", { required: true }),
|
prompt: core.getInput("prompt", { required: true }),
|
||||||
...flatMorph(agents, (_, agent) =>
|
effort: core.getInput("effort") || "think",
|
||||||
agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)])
|
});
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await main(inputs);
|
const result = await main(inputs);
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -19,7 +19,7 @@ const stripShebangPlugin = {
|
|||||||
try {
|
try {
|
||||||
const content = readFileSync(outputFile, "utf8");
|
const content = readFileSync(outputFile, "utf8");
|
||||||
// Remove shebang line from the beginning if present
|
// Remove shebang line from the beginning if present
|
||||||
const withoutShebang = content.startsWith("#!")
|
const withoutShebang = content.startsWith("#!")
|
||||||
? content.slice(content.indexOf("\n") + 1)
|
? content.slice(content.indexOf("\n") + 1)
|
||||||
: content;
|
: content;
|
||||||
writeFileSync(outputFile, withoutShebang);
|
writeFileSync(outputFile, withoutShebang);
|
||||||
@@ -38,7 +38,7 @@ const sharedConfig = {
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
platform: "node",
|
platform: "node",
|
||||||
target: "node20",
|
target: "node24",
|
||||||
minify: false,
|
minify: false,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
// Bundle all dependencies - GitHub Actions doesn't have node_modules
|
// Bundle all dependencies - GitHub Actions doesn't have node_modules
|
||||||
@@ -67,4 +67,12 @@ await build({
|
|||||||
plugins: [stripShebangPlugin],
|
plugins: [stripShebangPlugin],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build the get-installation-token action
|
||||||
|
await build({
|
||||||
|
...sharedConfig,
|
||||||
|
entryPoints: ["./get-installation-token/entry.ts"],
|
||||||
|
outfile: "./get-installation-token/entry",
|
||||||
|
plugins: [stripShebangPlugin],
|
||||||
|
});
|
||||||
|
|
||||||
console.log("✅ Build completed successfully!");
|
console.log("✅ Build completed successfully!");
|
||||||
|
|||||||
+22
-4
@@ -20,22 +20,22 @@ export interface AgentManifest {
|
|||||||
export const agentsManifest = {
|
export const agentsManifest = {
|
||||||
claude: {
|
claude: {
|
||||||
displayName: "Claude Code",
|
displayName: "Claude Code",
|
||||||
apiKeyNames: ["anthropic_api_key"],
|
apiKeyNames: ["ANTHROPIC_API_KEY"],
|
||||||
url: "https://claude.com/claude-code",
|
url: "https://claude.com/claude-code",
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
displayName: "Codex CLI",
|
displayName: "Codex CLI",
|
||||||
apiKeyNames: ["openai_api_key"],
|
apiKeyNames: ["OPENAI_API_KEY"],
|
||||||
url: "https://platform.openai.com/docs/guides/codex",
|
url: "https://platform.openai.com/docs/guides/codex",
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
displayName: "Cursor CLI",
|
displayName: "Cursor CLI",
|
||||||
apiKeyNames: ["cursor_api_key"],
|
apiKeyNames: ["CURSOR_API_KEY"],
|
||||||
url: "https://cursor.com/",
|
url: "https://cursor.com/",
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
displayName: "Gemini CLI",
|
displayName: "Gemini CLI",
|
||||||
apiKeyNames: ["google_api_key", "gemini_api_key"],
|
apiKeyNames: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||||
url: "https://ai.google.dev/gemini-api/docs",
|
url: "https://ai.google.dev/gemini-api/docs",
|
||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
@@ -51,6 +51,10 @@ 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
|
||||||
|
export const Effort = type.enumerated("nothink", "think", "max");
|
||||||
|
export type Effort = typeof Effort.infer;
|
||||||
|
|
||||||
// base interface for common payload event fields
|
// base interface for common payload event fields
|
||||||
interface BasePayloadEvent {
|
interface BasePayloadEvent {
|
||||||
issue_number?: number;
|
issue_number?: number;
|
||||||
@@ -212,6 +216,13 @@ interface FixReviewEvent extends BasePayloadEvent {
|
|||||||
unapproved_comments: ReviewCommentData[];
|
unapproved_comments: ReviewCommentData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImplementPlanEvent extends BasePayloadEvent {
|
||||||
|
trigger: "implement_plan";
|
||||||
|
issue_number: number;
|
||||||
|
plan_comment_id: number;
|
||||||
|
plan_content: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnknownEvent extends BasePayloadEvent {
|
interface UnknownEvent extends BasePayloadEvent {
|
||||||
trigger: "unknown";
|
trigger: "unknown";
|
||||||
}
|
}
|
||||||
@@ -231,6 +242,7 @@ export type PayloadEvent =
|
|||||||
| CheckSuiteCompletedEvent
|
| CheckSuiteCompletedEvent
|
||||||
| WorkflowDispatchEvent
|
| WorkflowDispatchEvent
|
||||||
| FixReviewEvent
|
| FixReviewEvent
|
||||||
|
| ImplementPlanEvent
|
||||||
| UnknownEvent;
|
| UnknownEvent;
|
||||||
|
|
||||||
export interface DispatchOptions {
|
export interface DispatchOptions {
|
||||||
@@ -271,6 +283,12 @@ export interface Payload extends DispatchOptions {
|
|||||||
*/
|
*/
|
||||||
modes: readonly Mode[];
|
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
|
* Optional IDs of the issue, PR, or comment that the agent is working on
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Payload } from "../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test fixture: verifies agents use MCP bash tool for shell commands.
|
||||||
|
* creates a simple test file and runs it with node.
|
||||||
|
*
|
||||||
|
* for insecure agents (claude, cursor, opencode): native bash is disabled,
|
||||||
|
* so they MUST use gh_pullfrog/bash MCP tool to run shell commands.
|
||||||
|
*
|
||||||
|
* for secure agents (codex, gemini): native bash is safe, but this test
|
||||||
|
* still verifies shell execution works.
|
||||||
|
*
|
||||||
|
* run with: AGENT_OVERRIDE=<agent> pnpm play bash-test.ts
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: null,
|
||||||
|
prompt: `Create a file called test-runner.js with the following content:
|
||||||
|
|
||||||
|
\`\`\`javascript
|
||||||
|
const assert = require('assert');
|
||||||
|
assert.strictEqual(2 + 2, 4, 'math should work');
|
||||||
|
console.log('TEST PASSED: basic arithmetic works');
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then run it with: node test-runner.js
|
||||||
|
|
||||||
|
Finally, delete the test file.
|
||||||
|
|
||||||
|
This tests that you can execute shell commands properly.`,
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
} satisfies Payload;
|
||||||
+9
-6
@@ -1,9 +1,12 @@
|
|||||||
import type { Inputs } from "../main.ts";
|
import type { Payload } from "../external.ts";
|
||||||
|
|
||||||
const testParams = {
|
export default {
|
||||||
|
"~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.",
|
||||||
anthropic_api_key: "sk-test-key",
|
event: {
|
||||||
} satisfies Inputs;
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
export default testParams;
|
modes: [],
|
||||||
|
} satisfies Payload;
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
add a file implementing quicksort and test it
|
Find all markdown files in the repository and list their names from https://github.com/ShawnMorreau/cal.com/
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Effort, Payload } from "../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixture for Cursor effort levels.
|
||||||
|
* Runs all three effort levels in sequence.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
|
||||||
|
*
|
||||||
|
* Effort levels:
|
||||||
|
* - "nothink": auto (default model)
|
||||||
|
* - "think": auto (default model)
|
||||||
|
* - "max": opus-4.5-thinking
|
||||||
|
*
|
||||||
|
* Note: If project has .cursor/cli.json with "model" specified,
|
||||||
|
* that takes precedence over effort-based model selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const efforts: Effort[] = ["nothink", "think", "max"];
|
||||||
|
|
||||||
|
export default efforts.map((effort) => ({
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: "cursor",
|
||||||
|
prompt: "What is 2 + 2? Reply with just the number.",
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
effort,
|
||||||
|
})) satisfies Payload[];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Effort, Payload } from "../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixture for Gemini effort levels.
|
||||||
|
* Runs all three effort levels in sequence.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
|
||||||
|
*
|
||||||
|
* Effort levels:
|
||||||
|
* - "nothink": gemini-2.5-flash + LOW thinking
|
||||||
|
* - "think": gemini-2.5-flash + HIGH thinking
|
||||||
|
* - "max": gemini-2.5-pro + HIGH thinking
|
||||||
|
*/
|
||||||
|
|
||||||
|
const efforts: Effort[] = ["nothink", "think", "max"];
|
||||||
|
|
||||||
|
export default efforts.map((effort) => ({
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: "gemini",
|
||||||
|
prompt: "What is 2 + 2? Reply with just the number.",
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
effort,
|
||||||
|
})) satisfies Payload[];
|
||||||
+3
-5
@@ -6,9 +6,9 @@ import type { Payload } from "../external.ts";
|
|||||||
*
|
*
|
||||||
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
||||||
*/
|
*/
|
||||||
const payload: Payload = {
|
export default {
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
agent: null, // let AGENT_OVERRIDE control this for testing different agents
|
agent: null,
|
||||||
prompt: `Please do the following three things:
|
prompt: `Please do the following three things:
|
||||||
|
|
||||||
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
||||||
@@ -24,6 +24,4 @@ All three of these actions should fail because you are running in sandbox mode w
|
|||||||
},
|
},
|
||||||
modes: [],
|
modes: [],
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
};
|
} satisfies Payload;
|
||||||
|
|
||||||
export default JSON.stringify(payload);
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: "Get Installation Token"
|
||||||
|
description: "Get a GitHub App installation token for the current repository"
|
||||||
|
author: "Pullfrog"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
repos:
|
||||||
|
description: "Comma-separated list of additional repo names to grant access to (e.g., 'repo1,repo2'). Current repo is always included."
|
||||||
|
required: false
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
token:
|
||||||
|
description: "GitHub App installation token"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "node24"
|
||||||
|
main: "entry"
|
||||||
|
post: "entry"
|
||||||
|
|
||||||
|
branding:
|
||||||
|
icon: "key"
|
||||||
|
color: "green"
|
||||||
Executable
+26030
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* entry point for get-installation-token action.
|
||||||
|
* handles both main and post execution using the isPost state pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { acquireInstallationToken, revokeInstallationToken } from "./token.ts";
|
||||||
|
|
||||||
|
const STATE_TOKEN = "token";
|
||||||
|
const STATE_IS_POST = "isPost";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
core.saveState(STATE_IS_POST, "true");
|
||||||
|
|
||||||
|
const reposInput = core.getInput("repos");
|
||||||
|
const additionalRepos = reposInput
|
||||||
|
? reposInput
|
||||||
|
.split(",")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const token = await acquireInstallationToken({ repos: additionalRepos });
|
||||||
|
|
||||||
|
// mask the token in logs
|
||||||
|
core.setSecret(token);
|
||||||
|
|
||||||
|
// save token to state for post cleanup
|
||||||
|
core.saveState(STATE_TOKEN, token);
|
||||||
|
|
||||||
|
// set as output
|
||||||
|
core.setOutput("token", token);
|
||||||
|
|
||||||
|
const scope = additionalRepos.length
|
||||||
|
? `current repo + ${additionalRepos.join(", ")}`
|
||||||
|
: "current repo only";
|
||||||
|
core.info(`» installation token acquired (${scope})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(): Promise<void> {
|
||||||
|
const token = core.getState(STATE_TOKEN);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
core.debug("no token found in state, skipping revocation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await revokeInstallationToken(token);
|
||||||
|
core.info("» installation token revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isPost = core.getState(STATE_IS_POST) === "true";
|
||||||
|
|
||||||
|
if (isPost) {
|
||||||
|
await post();
|
||||||
|
} else {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
core.setFailed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -2,13 +2,12 @@ import { mkdtemp } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { flatMorph } from "@ark/util";
|
import { flatMorph } from "@ark/util";
|
||||||
import { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { type Agent, agents } from "./agents/index.ts";
|
import { type Agent, agents } from "./agents/index.ts";
|
||||||
import type { AgentResult } from "./agents/shared.ts";
|
import type { AgentResult } from "./agents/shared.ts";
|
||||||
import type { AgentName, Payload } from "./external.ts";
|
import { type AgentName, agentsManifest, Effort, type Payload } from "./external.ts";
|
||||||
import { agentsManifest } from "./external.ts";
|
|
||||||
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
||||||
import { createMcpConfigs } from "./mcp/config.ts";
|
import { createMcpConfigs } from "./mcp/config.ts";
|
||||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
import { startMcpHttpServer } from "./mcp/server.ts";
|
||||||
@@ -18,29 +17,13 @@ import type { PrepResult } from "./prep/index.ts";
|
|||||||
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||||
import {
|
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
||||||
parseRepoContext,
|
|
||||||
revokeGitHubInstallationToken,
|
|
||||||
setupGitHubInstallationToken,
|
|
||||||
} from "./utils/github.ts";
|
|
||||||
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
||||||
import { Timer } from "./utils/timer.ts";
|
import { Timer } from "./utils/timer.ts";
|
||||||
|
|
||||||
// runtime validation using agents (needed for ArkType)
|
|
||||||
// Note: The AgentName type is defined in external.ts, this is the runtime validator
|
|
||||||
|
|
||||||
export const AgentInputKey = type.enumerated(
|
|
||||||
...Object.values(agents).flatMap((agent) => agent.apiKeyNames)
|
|
||||||
);
|
|
||||||
export type AgentInputKey = typeof AgentInputKey.infer;
|
|
||||||
|
|
||||||
const keyInputDefs = flatMorph(agents, (_, agent) =>
|
|
||||||
agent.apiKeyNames.map((inputKey) => [inputKey, "string | undefined?"] as const)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Inputs = type({
|
export const Inputs = type({
|
||||||
prompt: "string",
|
prompt: "string",
|
||||||
...keyInputDefs,
|
"effort?": Effort,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Inputs = typeof Inputs.infer;
|
export type Inputs = typeof Inputs.infer;
|
||||||
@@ -53,7 +36,6 @@ export interface MainResult {
|
|||||||
|
|
||||||
// intermediate result types for deterministic context building
|
// intermediate result types for deterministic context building
|
||||||
interface GitHubSetup {
|
interface GitHubSetup {
|
||||||
token: string;
|
|
||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
octokit: Octokit;
|
octokit: Octokit;
|
||||||
@@ -66,12 +48,11 @@ type ApiKeySetup =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||||
let mcpServerClose: (() => Promise<void>) | undefined;
|
const timer = new Timer();
|
||||||
|
await using tokenRef = await setupGitHubInstallationToken();
|
||||||
let payload: Payload | undefined;
|
let payload: Payload | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timer = new Timer();
|
|
||||||
|
|
||||||
// phase 1: parse and validate inputs
|
// phase 1: parse and validate inputs
|
||||||
payload = parsePayload(inputs);
|
payload = parsePayload(inputs);
|
||||||
Inputs.assert(inputs);
|
Inputs.assert(inputs);
|
||||||
@@ -79,14 +60,13 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
|
|
||||||
// phase 2: fast setup (github + temp dir)
|
// phase 2: fast setup (github + temp dir)
|
||||||
const [githubSetup, sharedTempDir] = await Promise.all([
|
const [githubSetup, sharedTempDir] = await Promise.all([
|
||||||
initializeGitHub(),
|
initializeGitHub(tokenRef.token),
|
||||||
createTempDirectory(),
|
createTempDirectory(),
|
||||||
]);
|
]);
|
||||||
timer.checkpoint("githubSetup");
|
timer.checkpoint("githubSetup");
|
||||||
|
|
||||||
// phase 3: resolve agent (needs repo settings)
|
// phase 3: resolve agent (needs repo settings)
|
||||||
const agent = resolveAgent({
|
const agent = resolveAgent({
|
||||||
inputs,
|
|
||||||
payload,
|
payload,
|
||||||
repoSettings: githubSetup.repoSettings,
|
repoSettings: githubSetup.repoSettings,
|
||||||
});
|
});
|
||||||
@@ -95,7 +75,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
|
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
|
||||||
const apiKeySetup = validateApiKey({
|
const apiKeySetup = validateApiKey({
|
||||||
agent,
|
agent,
|
||||||
inputs,
|
|
||||||
owner: githubSetup.owner,
|
owner: githubSetup.owner,
|
||||||
name: githubSetup.name,
|
name: githubSetup.name,
|
||||||
});
|
});
|
||||||
@@ -107,9 +86,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
// phase 5: parallel long-running operations (agent install + git auth)
|
// phase 5: parallel long-running operations (agent install + git auth)
|
||||||
const toolState: ToolState = {};
|
const toolState: ToolState = {};
|
||||||
const [cliPath] = await Promise.all([
|
const [cliPath] = await Promise.all([
|
||||||
installAgentCli({ agent, token: githubSetup.token }),
|
installAgentCli({ agent, token: tokenRef.token }),
|
||||||
setupGitAuth({
|
setupGitAuth({
|
||||||
token: githubSetup.token,
|
token: tokenRef.token,
|
||||||
owner: githubSetup.owner,
|
owner: githubSetup.owner,
|
||||||
name: githubSetup.name,
|
name: githubSetup.name,
|
||||||
payload: resolvedPayload,
|
payload: resolvedPayload,
|
||||||
@@ -156,7 +135,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
const toolContext: ToolContext = {
|
const toolContext: ToolContext = {
|
||||||
owner: githubSetup.owner,
|
owner: githubSetup.owner,
|
||||||
name: githubSetup.name,
|
name: githubSetup.name,
|
||||||
githubInstallationToken: githubSetup.token,
|
githubInstallationToken: tokenRef.token,
|
||||||
octokit: githubSetup.octokit,
|
octokit: githubSetup.octokit,
|
||||||
payload: resolvedPayload,
|
payload: resolvedPayload,
|
||||||
repo: githubSetup.repo,
|
repo: githubSetup.repo,
|
||||||
@@ -169,11 +148,10 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
jobId,
|
jobId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { url: mcpServerUrl, close: mcpServerCloseFunc } = await startMcpHttpServer(toolContext);
|
await using mcpHttpServer = await startMcpHttpServer(toolContext);
|
||||||
mcpServerClose = mcpServerCloseFunc;
|
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`);
|
||||||
log.info(`🚀 MCP server started at ${mcpServerUrl}`);
|
|
||||||
|
|
||||||
const mcpServers = createMcpConfigs(mcpServerUrl);
|
const mcpServers = createMcpConfigs(mcpHttpServer.url);
|
||||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
||||||
timer.checkpoint("mcpServer");
|
timer.checkpoint("mcpServer");
|
||||||
|
|
||||||
@@ -181,8 +159,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
...toolContext,
|
...toolContext,
|
||||||
inputs,
|
inputs,
|
||||||
mcpServerUrl,
|
mcpServerUrl: mcpHttpServer.url,
|
||||||
mcpServerClose: mcpServerCloseFunc,
|
|
||||||
mcpServers,
|
mcpServers,
|
||||||
cliPath,
|
cliPath,
|
||||||
apiKey: apiKeySetup.apiKey,
|
apiKey: apiKeySetup.apiKey,
|
||||||
@@ -225,33 +202,23 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
} 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
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mcpServerClose) {
|
|
||||||
await mcpServerClose();
|
|
||||||
}
|
|
||||||
await revokeGitHubInstallationToken();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get agents that have matching API keys in the inputs
|
* Check if an agent has API keys available (from process.env)
|
||||||
*/
|
*/
|
||||||
/**
|
function agentHasApiKeys(agent: Agent): boolean {
|
||||||
* Check if an agent has API keys available (inputs or process.env for opencode)
|
|
||||||
*/
|
|
||||||
function agentHasApiKeys(agent: Agent, inputs: Inputs): boolean {
|
|
||||||
if (agent.name === "opencode") {
|
if (agent.name === "opencode") {
|
||||||
// check inputs first, then process.env
|
// opencode accepts any API_KEY from environment
|
||||||
const hasInputKey = Object.keys(inputs).some((key) => key.includes("api_key"));
|
|
||||||
if (hasInputKey) return true;
|
|
||||||
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
||||||
}
|
}
|
||||||
const inputsRecord = inputs as Record<string, string | undefined>;
|
// check if any of the agent's expected keys are in environment
|
||||||
return agent.apiKeyNames.some((inputKey) => inputsRecord[inputKey]);
|
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvailableAgents(inputs: Inputs): Agent[] {
|
function getAvailableAgents(): Agent[] {
|
||||||
return Object.values(agents).filter((agent) => agentHasApiKeys(agent, inputs));
|
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,7 +251,7 @@ function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: st
|
|||||||
} else {
|
} else {
|
||||||
const inputKeys =
|
const inputKeys =
|
||||||
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
|
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
|
||||||
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
const secretNames = inputKeys.map((key) => `\`${key}\``);
|
||||||
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +288,6 @@ export interface ToolContext {
|
|||||||
export interface AgentContext extends Readonly<ToolContext> {
|
export interface AgentContext extends Readonly<ToolContext> {
|
||||||
readonly inputs: Inputs;
|
readonly inputs: Inputs;
|
||||||
readonly mcpServerUrl: string;
|
readonly mcpServerUrl: string;
|
||||||
readonly mcpServerClose: () => Promise<void>;
|
|
||||||
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
|
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||||
readonly cliPath: string;
|
readonly cliPath: string;
|
||||||
readonly apiKey: string;
|
readonly apiKey: string;
|
||||||
@@ -337,6 +303,7 @@ export interface DependencyInstallationState {
|
|||||||
export interface ToolState {
|
export interface ToolState {
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
issueNumber?: number;
|
issueNumber?: number;
|
||||||
|
selectedMode?: string;
|
||||||
review?: {
|
review?: {
|
||||||
id: number; // REST API database ID (for fix URLs)
|
id: number; // REST API database ID (for fix URLs)
|
||||||
nodeId: string; // GraphQL node ID (for mutations)
|
nodeId: string; // GraphQL node ID (for mutations)
|
||||||
@@ -347,13 +314,12 @@ export interface ToolState {
|
|||||||
/**
|
/**
|
||||||
* Initialize GitHub connection: token, octokit, repo data, settings
|
* Initialize GitHub connection: token, octokit, repo data, settings
|
||||||
*/
|
*/
|
||||||
async function initializeGitHub(): Promise<GitHubSetup> {
|
async function initializeGitHub(token: string): Promise<GitHubSetup> {
|
||||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||||
|
|
||||||
const token = await setupGitHubInstallationToken();
|
|
||||||
const { owner, name } = parseRepoContext();
|
const { owner, name } = parseRepoContext();
|
||||||
|
|
||||||
const octokit = new Octokit({ auth: token });
|
const octokit = createOctokit(token);
|
||||||
|
|
||||||
// fetch repo data and settings in parallel
|
// fetch repo data and settings in parallel
|
||||||
const [repoResponse, repoSettings] = await Promise.all([
|
const [repoResponse, repoSettings] = await Promise.all([
|
||||||
@@ -362,7 +328,6 @@ async function initializeGitHub(): Promise<GitHubSetup> {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
|
||||||
owner,
|
owner,
|
||||||
name,
|
name,
|
||||||
octokit,
|
octokit,
|
||||||
@@ -372,15 +337,16 @@ async function initializeGitHub(): Promise<GitHubSetup> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAgent({
|
function resolveAgent({
|
||||||
inputs,
|
|
||||||
payload,
|
payload,
|
||||||
repoSettings,
|
repoSettings,
|
||||||
}: {
|
}: {
|
||||||
inputs: Inputs;
|
|
||||||
payload: Payload;
|
payload: Payload;
|
||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
}): Agent {
|
}): Agent {
|
||||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
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;
|
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||||
|
|
||||||
if (configuredAgentName) {
|
if (configuredAgentName) {
|
||||||
@@ -398,13 +364,13 @@ function resolveAgent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for repo-level defaults, check if agent has matching keys before selecting
|
// for repo-level defaults, check if agent has matching keys before selecting
|
||||||
if (agentHasApiKeys(agent, inputs)) {
|
if (agentHasApiKeys(agent)) {
|
||||||
log.info(`Selected configured agent: ${agent.name}`);
|
log.info(`Selected configured agent: ${agent.name}`);
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fall through to auto-selection
|
// fall through to auto-selection
|
||||||
const availableAgents = getAvailableAgents(inputs);
|
const availableAgents = getAvailableAgents();
|
||||||
log.warning(
|
log.warning(
|
||||||
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
||||||
availableAgents.map((a) => a.name).join(", ") || "none"
|
availableAgents.map((a) => a.name).join(", ") || "none"
|
||||||
@@ -412,7 +378,7 @@ function resolveAgent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableAgents = getAvailableAgents(inputs);
|
const availableAgents = getAvailableAgents();
|
||||||
if (availableAgents.length === 0) {
|
if (availableAgents.length === 0) {
|
||||||
throw new Error("no agents available - missing API keys");
|
throw new Error("no agents available - missing API keys");
|
||||||
}
|
}
|
||||||
@@ -435,8 +401,13 @@ function parsePayload(inputs: Inputs): Payload {
|
|||||||
if (!("~pullfrog" in parsedPrompt)) {
|
if (!("~pullfrog" in parsedPrompt)) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
return parsedPrompt as Payload;
|
// internal invocation: use effort from payload, fallback to input, default to "think"
|
||||||
|
return {
|
||||||
|
...parsedPrompt,
|
||||||
|
effort: parsedPrompt.effort ?? inputs.effort ?? "think",
|
||||||
|
} as Payload;
|
||||||
} catch {
|
} catch {
|
||||||
|
// external invocation: use effort from input
|
||||||
return {
|
return {
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
agent: null,
|
agent: null,
|
||||||
@@ -445,6 +416,7 @@ function parsePayload(inputs: Inputs): Payload {
|
|||||||
trigger: "unknown",
|
trigger: "unknown",
|
||||||
},
|
},
|
||||||
modes,
|
modes,
|
||||||
|
effort: inputs.effort ?? "think",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,22 +429,22 @@ async function installAgentCli(params: { agent: Agent; token: string }): Promise
|
|||||||
return params.agent.install();
|
return params.agent.install();
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
|
function collectApiKeys(agent: Agent): Record<string, string> {
|
||||||
const apiKeys: Record<string, string> = {};
|
const apiKeys: Record<string, string> = {};
|
||||||
const inputsRecord = inputs as Record<string, string | undefined>;
|
|
||||||
|
|
||||||
for (const inputKey of agent.apiKeyNames) {
|
// read API keys from environment variables
|
||||||
const value = inputsRecord[inputKey];
|
for (const envKey of agent.apiKeyNames) {
|
||||||
|
const value = process.env[envKey];
|
||||||
if (value) {
|
if (value) {
|
||||||
apiKeys[inputKey] = value;
|
apiKeys[envKey] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// for OpenCode: also check process.env for any API_KEY variables
|
// for OpenCode: check process.env for any API_KEY variables
|
||||||
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
|
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||||
apiKeys[key.toLowerCase()] = value;
|
apiKeys[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -480,13 +452,8 @@ function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
|
|||||||
return apiKeys;
|
return apiKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateApiKey(params: {
|
function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
|
||||||
agent: Agent;
|
const apiKeys = collectApiKeys(params.agent);
|
||||||
inputs: Inputs;
|
|
||||||
owner: string;
|
|
||||||
name: string;
|
|
||||||
}): ApiKeySetup {
|
|
||||||
const apiKeys = collectApiKeys(params.agent, params.inputs);
|
|
||||||
|
|
||||||
if (Object.keys(apiKeys).length === 0) {
|
if (Object.keys(apiKeys).length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -507,7 +474,8 @@ function validateApiKey(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
||||||
log.info(`Running ${ctx.agent.name}...`);
|
const effort = ctx.payload.effort ?? "think";
|
||||||
|
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
|
||||||
// strip context from event
|
// strip context from event
|
||||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
||||||
// format: prompt + two newlines + TOON encoded event
|
// format: prompt + two newlines + TOON encoded event
|
||||||
@@ -524,7 +492,9 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
name: ctx.name,
|
name: ctx.name,
|
||||||
defaultBranch: ctx.repo.default_branch,
|
defaultBranch: ctx.repo.default_branch,
|
||||||
|
isPublic: !ctx.repo.private,
|
||||||
},
|
},
|
||||||
|
effort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import type { ToolContext } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
|
export const BashParams = type({
|
||||||
|
command: "string",
|
||||||
|
description: "string",
|
||||||
|
"timeout?": "number",
|
||||||
|
"working_directory?": "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** filter env vars, removing sensitive values (only for public repos) */
|
||||||
|
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||||
|
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 && isSensitive(key)) continue;
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||||
|
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||||
|
*/
|
||||||
|
function spawnSandboxed(
|
||||||
|
command: string,
|
||||||
|
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
|
||||||
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
return useNamespaceIsolation
|
||||||
|
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||||
|
: spawn("bash", ["-c", command], spawnOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** kill process and its entire process group */
|
||||||
|
async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||||||
|
if (!proc.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGTERM");
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
process.kill(-proc.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BashTool(ctx: ToolContext) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
name: "bash",
|
||||||
|
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
|
||||||
|
|
||||||
|
Use this tool to:
|
||||||
|
- Run shell commands (ls, cat, grep, find, etc.)
|
||||||
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||||
|
- Run tests and linters
|
||||||
|
- Perform git operations
|
||||||
|
- Run shell commands in a secure environment. Unlike the built-in bash tool, this tool filters sensitive environment variables from the subprocess's environment to avoid leaking secrets.`,
|
||||||
|
parameters: BashParams,
|
||||||
|
execute: execute(async (params) => {
|
||||||
|
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||||
|
const cwd = params.working_directory ?? process.cwd();
|
||||||
|
const proc = spawnSandboxed(params.command, {
|
||||||
|
env: filterEnv(isPublicRepo),
|
||||||
|
cwd,
|
||||||
|
isPublicRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "",
|
||||||
|
stderr = "",
|
||||||
|
timedOut = false,
|
||||||
|
exited = false;
|
||||||
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
if (!exited) {
|
||||||
|
timedOut = true;
|
||||||
|
await killProcessGroup(proc);
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const exitCode = await new Promise<number | null>((resolve) => {
|
||||||
|
const done = (code: number | null) => {
|
||||||
|
exited = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(code);
|
||||||
|
};
|
||||||
|
proc.on("exit", done);
|
||||||
|
proc.on("error", () => done(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = stderr ? (stdout ? `${stdout}\n${stderr}` : stderr) : stdout;
|
||||||
|
if (timedOut)
|
||||||
|
output = output
|
||||||
|
? `${output}\n[timed out after ${timeout}ms]`
|
||||||
|
: `[timed out after ${timeout}ms]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: output.trim(),
|
||||||
|
exit_code: exitCode ?? (timedOut ? 124 : -1),
|
||||||
|
timed_out: timedOut,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+107
-17
@@ -1,11 +1,16 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
import * as core from "@actions/core";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { Payload } from "../external.ts";
|
import type { Payload } from "../external.ts";
|
||||||
import { agentsManifest } from "../external.ts";
|
import { agentsManifest } from "../external.ts";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "../main.ts";
|
||||||
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
||||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
|
import {
|
||||||
|
createOctokit,
|
||||||
|
getGitHubInstallationToken,
|
||||||
|
type OctokitWithPlugins,
|
||||||
|
parseRepoContext,
|
||||||
|
} from "../utils/github.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +20,19 @@ import { execute, tool } from "./shared.ts";
|
|||||||
*/
|
*/
|
||||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||||
|
|
||||||
async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<string> {
|
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||||
|
|
||||||
|
interface BuildCommentFooterParams {
|
||||||
|
payload: Payload;
|
||||||
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
|
customParts?: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCommentFooter({
|
||||||
|
payload,
|
||||||
|
octokit,
|
||||||
|
customParts,
|
||||||
|
}: BuildCommentFooterParams): Promise<string> {
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
|
|
||||||
@@ -38,7 +55,7 @@ async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildPullfrogFooter({
|
const footerParams = {
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
agent: {
|
agent: {
|
||||||
displayName: agentInfo?.displayName || "Unknown agent",
|
displayName: agentInfo?.displayName || "Unknown agent",
|
||||||
@@ -52,12 +69,31 @@ async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<
|
|||||||
...(workflowRunHtmlUrl ? { htmlUrl: workflowRunHtmlUrl } : {}),
|
...(workflowRunHtmlUrl ? { htmlUrl: workflowRunHtmlUrl } : {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (customParts && customParts.length > 0) {
|
||||||
|
return buildPullfrogFooter({ ...footerParams, customParts });
|
||||||
|
}
|
||||||
|
return buildPullfrogFooter(footerParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFooter(body: string, payload: Payload, octokit?: Octokit): Promise<string> {
|
function buildImplementPlanLink(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
issueNumber: number,
|
||||||
|
commentId: number
|
||||||
|
): string {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFooter(
|
||||||
|
body: string,
|
||||||
|
payload: Payload,
|
||||||
|
octokit?: OctokitWithPlugins
|
||||||
|
): Promise<string> {
|
||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter(payload, octokit);
|
const footer = await buildCommentFooter({ payload, octokit });
|
||||||
return `${bodyWithoutFooter}${footer}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +200,10 @@ 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.
|
||||||
@@ -181,11 +221,26 @@ export async function reportProgress(
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
> {
|
> {
|
||||||
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
|
|
||||||
const existingCommentId = getProgressCommentId();
|
const existingCommentId = getProgressCommentId();
|
||||||
|
const issueNumber =
|
||||||
|
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||||
|
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||||
|
|
||||||
// if we already have a progress comment, update it
|
// if we already have a progress comment, update it
|
||||||
if (existingCommentId) {
|
if (existingCommentId) {
|
||||||
|
const customParts =
|
||||||
|
isPlanMode && issueNumber !== undefined
|
||||||
|
? [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, existingCommentId)]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
|
const footer = await buildCommentFooter({
|
||||||
|
payload: ctx.payload,
|
||||||
|
octokit: ctx.octokit,
|
||||||
|
customParts,
|
||||||
|
});
|
||||||
|
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.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
@@ -195,6 +250,8 @@ export async function reportProgress(
|
|||||||
|
|
||||||
progressCommentWasUpdated = true;
|
progressCommentWasUpdated = true;
|
||||||
|
|
||||||
|
await updateSummary(bodyWithFooter);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
@@ -205,24 +262,55 @@ export async function reportProgress(
|
|||||||
|
|
||||||
// no existing comment - create one
|
// no existing comment - create one
|
||||||
// use fallback chain: dynamically set context > event payload
|
// use fallback chain: dynamically set context > event payload
|
||||||
const issueNumber =
|
|
||||||
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
|
||||||
if (issueNumber === undefined) {
|
if (issueNumber === undefined) {
|
||||||
// cannot create comment without issue_number (e.g., workflow_dispatch events)
|
// cannot create comment without issue_number (e.g., workflow_dispatch events)
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
issue_number: issueNumber,
|
issue_number: issueNumber,
|
||||||
body: bodyWithFooter,
|
body: initialBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
// store the comment ID for future updates
|
// store the comment ID for future updates
|
||||||
setProgressCommentId(result.data.id);
|
setProgressCommentId(result.data.id);
|
||||||
progressCommentWasUpdated = true;
|
progressCommentWasUpdated = true;
|
||||||
|
|
||||||
|
// if Plan mode, update the comment to add the "Implement plan" link
|
||||||
|
if (isPlanMode) {
|
||||||
|
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
|
||||||
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
|
const footer = await buildCommentFooter({
|
||||||
|
payload: ctx.payload,
|
||||||
|
octokit: ctx.octokit,
|
||||||
|
customParts,
|
||||||
|
});
|
||||||
|
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
|
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
comment_id: result.data.id,
|
||||||
|
body: bodyWithPlanLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateSummary(bodyWithPlanLink);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentId: updateResult.data.id,
|
||||||
|
url: updateResult.data.html_url,
|
||||||
|
body: updateResult.data.body || "",
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSummary(initialBody);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
@@ -341,8 +429,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
|
|
||||||
// check if comment still says "leaping into action" - if it's been updated with an error, don't overwrite it
|
// check if comment still says "leaping into action" - if it's been updated with an error, don't overwrite it
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const token = getGitHubInstallationToken();
|
const octokit = createOctokit(getGitHubInstallationToken());
|
||||||
const octokit = new Octokit({ auth: token });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingComment = await octokit.rest.issues.getComment({
|
const existingComment = await octokit.rest.issues.getComment({
|
||||||
@@ -363,10 +450,10 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
|
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
const workflowRunLink = runId
|
const workflowRunLink = runId
|
||||||
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
||||||
: "workflow";
|
: "workflow run logs";
|
||||||
|
|
||||||
const errorMessage = `❌ this run croaked
|
const errorMessage = `This run croaked 😵
|
||||||
|
|
||||||
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.`;
|
||||||
|
|
||||||
@@ -406,6 +493,9 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
|||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
|
||||||
|
progressCommentWasUpdated = true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
@@ -413,6 +503,6 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
|||||||
body: result.data.body,
|
body: result.data.body,
|
||||||
in_reply_to_id: result.data.in_reply_to_id,
|
in_reply_to_id: result.data.in_reply_to_id,
|
||||||
};
|
};
|
||||||
}),
|
}, "reply_to_review_comment"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { relative, resolve } from "node:path";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import type { ToolContext } from "../main.ts";
|
|
||||||
import { $ } from "../utils/shell.ts";
|
|
||||||
import { execute, tool } from "./shared.ts";
|
|
||||||
|
|
||||||
export const ListFiles = type({
|
|
||||||
path: type.string
|
|
||||||
.describe("The path to list files from (defaults to current directory)")
|
|
||||||
.default("."),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ListFilesTool(_ctx: ToolContext) {
|
|
||||||
return tool({
|
|
||||||
name: "list_files",
|
|
||||||
description:
|
|
||||||
"List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.",
|
|
||||||
parameters: ListFiles,
|
|
||||||
execute: execute(async ({ path }: { path?: string }) => {
|
|
||||||
const pathStr = path ?? ".";
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
// Get git-tracked files
|
|
||||||
let gitFiles: string[] = [];
|
|
||||||
let gitFailed = false;
|
|
||||||
try {
|
|
||||||
const gitArgs = pathStr === "." ? ["ls-files"] : ["ls-files", pathStr];
|
|
||||||
const gitOutput = $("git", gitArgs, { log: false });
|
|
||||||
gitFiles = gitOutput
|
|
||||||
.split("\n")
|
|
||||||
.filter((f) => f.trim() !== "")
|
|
||||||
.map((f) => f.trim());
|
|
||||||
} catch {
|
|
||||||
// git might fail, that's ok - we'll use find instead
|
|
||||||
gitFailed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always also check filesystem for untracked files
|
|
||||||
// This is important because newly created files won't be in git yet
|
|
||||||
let filesystemFiles: string[] = [];
|
|
||||||
let findFailed = false;
|
|
||||||
try {
|
|
||||||
const findOutput = $(
|
|
||||||
"find",
|
|
||||||
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
|
||||||
{ log: false }
|
|
||||||
);
|
|
||||||
filesystemFiles = findOutput
|
|
||||||
.split("\n")
|
|
||||||
.filter((f) => f.trim() !== "")
|
|
||||||
.map((f) => {
|
|
||||||
const trimmed = f.trim();
|
|
||||||
// normalize to relative paths for comparison
|
|
||||||
try {
|
|
||||||
return relative(cwd, resolve(cwd, trimmed));
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// find might fail, that's ok - we'll just use git files
|
|
||||||
findFailed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if both methods failed, throw an error (execute helper will handle it)
|
|
||||||
if (gitFailed && findFailed) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to list files: both git ls-files and find commands failed. ` +
|
|
||||||
`Path: ${pathStr}, working directory: ${cwd}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Set of git files for efficient lookup
|
|
||||||
const gitFilesSet = new Set(gitFiles);
|
|
||||||
|
|
||||||
// Combine both lists, removing duplicates
|
|
||||||
const allFiles = [...new Set([...gitFiles, ...filesystemFiles])].sort();
|
|
||||||
|
|
||||||
// Calculate actual untracked count (files in filesystem but not in git)
|
|
||||||
const untrackedFiles = filesystemFiles.filter((f) => !gitFilesSet.has(f));
|
|
||||||
const untrackedCount = untrackedFiles.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
files: allFiles,
|
|
||||||
method: "combined",
|
|
||||||
trackedCount: gitFiles.length,
|
|
||||||
untrackedCount,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+86
-86
@@ -93,95 +93,95 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
|||||||
"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 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.",
|
||||||
parameters: GetReviewComments,
|
parameters: GetReviewComments,
|
||||||
execute: execute(async ({ pull_number, review_id }) => {
|
execute: execute(async ({ pull_number, review_id }) => {
|
||||||
// fetch all review threads using graphql
|
// fetch all review threads using graphql
|
||||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
pullNumber: pull_number,
|
pullNumber: pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pullRequest = response.repository?.pullRequest;
|
const pullRequest = response.repository?.pullRequest;
|
||||||
if (!pullRequest) {
|
if (!pullRequest) {
|
||||||
return {
|
return {
|
||||||
review_id,
|
review_id,
|
||||||
pull_number,
|
pull_number,
|
||||||
comments: [],
|
comments: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const threadNodes = pullRequest.reviewThreads?.nodes;
|
|
||||||
if (!threadNodes) {
|
|
||||||
return {
|
|
||||||
review_id,
|
|
||||||
pull_number,
|
|
||||||
comments: [],
|
|
||||||
count: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const allComments: {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const threadNodes = pullRequest.reviewThreads?.nodes;
|
||||||
review_id,
|
if (!threadNodes) {
|
||||||
pull_number,
|
return {
|
||||||
comments: allComments,
|
review_id,
|
||||||
count: allComments.length,
|
pull_number,
|
||||||
};
|
comments: [],
|
||||||
}),
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allComments: {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
review_id,
|
||||||
|
pull_number,
|
||||||
|
comments: allComments,
|
||||||
|
count: allComments.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export function SelectModeTool(ctx: ToolContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// store selected mode in toolState for use by other tools (e.g., report_progress)
|
||||||
|
ctx.toolState.selectedMode = selectedMode.name;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
modeName: selectedMode.name,
|
modeName: selectedMode.name,
|
||||||
description: selectedMode.description,
|
description: selectedMode.description,
|
||||||
|
|||||||
+4
-4
@@ -17,7 +17,6 @@ import {
|
|||||||
AwaitDependencyInstallationTool,
|
AwaitDependencyInstallationTool,
|
||||||
StartDependencyInstallationTool,
|
StartDependencyInstallationTool,
|
||||||
} from "./dependencies.ts";
|
} from "./dependencies.ts";
|
||||||
import { ListFilesTool } from "./files.ts";
|
|
||||||
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
|
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
|
||||||
import { IssueTool } from "./issue.ts";
|
import { IssueTool } from "./issue.ts";
|
||||||
import { GetIssueCommentsTool } from "./issueComments.ts";
|
import { GetIssueCommentsTool } from "./issueComments.ts";
|
||||||
@@ -30,6 +29,7 @@ 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
|
||||||
@@ -65,7 +65,7 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
|||||||
*/
|
*/
|
||||||
export async function startMcpHttpServer(
|
export async function startMcpHttpServer(
|
||||||
ctx: ToolContext
|
ctx: ToolContext
|
||||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
||||||
const server = new FastMCP({
|
const server = new FastMCP({
|
||||||
name: ghPullfrogMcpName,
|
name: ghPullfrogMcpName,
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
@@ -95,7 +95,7 @@ export async function startMcpHttpServer(
|
|||||||
CreateBranchTool(ctx),
|
CreateBranchTool(ctx),
|
||||||
CommitFilesTool(ctx),
|
CommitFilesTool(ctx),
|
||||||
PushBranchTool(ctx),
|
PushBranchTool(ctx),
|
||||||
ListFilesTool(ctx),
|
BashTool(ctx),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!ctx.payload.disableProgressComment) {
|
if (!ctx.payload.disableProgressComment) {
|
||||||
@@ -121,7 +121,7 @@ export async function startMcpHttpServer(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
close: async () => {
|
[Symbol.asyncDispose]: async () => {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-1
@@ -2,6 +2,7 @@ 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 type { ToolContext } from "../main.ts";
|
||||||
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||||
|
|
||||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||||
|
|
||||||
@@ -36,13 +37,22 @@ export const handleToolError = (error: unknown): ToolResult => {
|
|||||||
/**
|
/**
|
||||||
* Helper to wrap a tool execute function with error handling.
|
* Helper to wrap a tool execute function with error handling.
|
||||||
* Captures ctx in closure so tools don't need to handle try/catch.
|
* Captures ctx in closure so tools don't need to handle try/catch.
|
||||||
|
* @param fn - the function to execute
|
||||||
|
* @param toolName - optional tool name for error logging
|
||||||
*/
|
*/
|
||||||
export const execute = <T>(fn: (params: T) => Promise<Record<string, any> | string>) => {
|
export const execute = <T>(
|
||||||
|
fn: (params: T) => Promise<Record<string, any> | string>,
|
||||||
|
toolName?: string
|
||||||
|
) => {
|
||||||
return async (params: T): Promise<ToolResult> => {
|
return async (params: T): Promise<ToolResult> => {
|
||||||
try {
|
try {
|
||||||
const result = await fn(params);
|
const result = await fn(params);
|
||||||
return handleToolSuccess(result);
|
return handleToolSuccess(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const prefix = toolName ? `[${toolName}]` : "tool";
|
||||||
|
log.error(`${prefix} error: ${errorMessage}`);
|
||||||
|
log.debug(`${prefix} params: ${formatJsonValue(params)}`);
|
||||||
return handleToolError(error);
|
return handleToolError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
-960
@@ -1,960 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@pullfrog/action",
|
|
||||||
"version": "0.0.96",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "@pullfrog/action",
|
|
||||||
"version": "0.0.96",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@actions/core": "^1.11.1",
|
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
|
|
||||||
"@ark/fs": "0.53.0",
|
|
||||||
"@ark/util": "0.53.0",
|
|
||||||
"@octokit/rest": "^22.0.0",
|
|
||||||
"@octokit/webhooks-types": "^7.6.1",
|
|
||||||
"@standard-schema/spec": "1.0.0",
|
|
||||||
"arktype": "2.1.25",
|
|
||||||
"convex": "^1.29.0",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"execa": "^9.6.0",
|
|
||||||
"fastmcp": "^3.20.0",
|
|
||||||
"table": "^6.9.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.7.2",
|
|
||||||
"arg": "^5.0.2",
|
|
||||||
"esbuild": "^0.25.9",
|
|
||||||
"husky": "^9.0.0",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core": {
|
|
||||||
"version": "1.11.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@actions/exec": "^1.1.1",
|
|
||||||
"@actions/http-client": "^2.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^16.18.112"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk": {
|
|
||||||
"version": "0.1.26",
|
|
||||||
"license": "SEE LICENSE IN README.md",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-darwin-arm64": "^0.33.5",
|
|
||||||
"@img/sharp-darwin-x64": "^0.33.5",
|
|
||||||
"@img/sharp-linux-arm": "^0.33.5",
|
|
||||||
"@img/sharp-linux-arm64": "^0.33.5",
|
|
||||||
"@img/sharp-linux-x64": "^0.33.5",
|
|
||||||
"@img/sharp-win32-x64": "^0.33.5"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs": {
|
|
||||||
"version": "0.53.0",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util": {
|
|
||||||
"version": "0.53.0",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest": {
|
|
||||||
"version": "22.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@octokit/core": "^7.0.2",
|
|
||||||
"@octokit/plugin-paginate-rest": "^13.0.1",
|
|
||||||
"@octokit/plugin-request-log": "^6.0.0",
|
|
||||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@octokit/auth-action": "^6.0.1",
|
|
||||||
"@octokit/auth-app": "^8.0.1",
|
|
||||||
"@octokit/fixtures-server": "^8.1.0",
|
|
||||||
"@octokit/request": "^10.0.0",
|
|
||||||
"@octokit/tsconfig": "^4.0.0",
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"fetch-mock": "^12.0.0",
|
|
||||||
"glob": "^11.0.0",
|
|
||||||
"nock": "^14.0.0-beta.8",
|
|
||||||
"prettier": "^3.2.4",
|
|
||||||
"semantic-release-plugin-update-version-in-files": "^2.0.0",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"undici": "^6.4.0",
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types": {
|
|
||||||
"version": "7.6.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"tsup": "^8.3.0",
|
|
||||||
"typescript": "^5.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node": {
|
|
||||||
"version": "24.7.2",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~7.14.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/arg@5.0.2/node_modules/arg": {
|
|
||||||
"version": "5.0.2",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"chai": "^4.1.1",
|
|
||||||
"jest": "^27.0.6",
|
|
||||||
"prettier": "^2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype": {
|
|
||||||
"version": "2.1.25",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@ark/schema": "0.53.0",
|
|
||||||
"@ark/util": "0.53.0",
|
|
||||||
"arkregex": "0.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv": {
|
|
||||||
"version": "17.2.3",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^18.11.3",
|
|
||||||
"decache": "^4.6.2",
|
|
||||||
"sinon": "^14.0.1",
|
|
||||||
"standard": "^17.0.0",
|
|
||||||
"standard-version": "^9.5.0",
|
|
||||||
"tap": "^19.2.0",
|
|
||||||
"typescript": "^4.8.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild": {
|
|
||||||
"version": "0.25.10",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.25.10",
|
|
||||||
"@esbuild/android-arm": "0.25.10",
|
|
||||||
"@esbuild/android-arm64": "0.25.10",
|
|
||||||
"@esbuild/android-x64": "0.25.10",
|
|
||||||
"@esbuild/darwin-arm64": "0.25.10",
|
|
||||||
"@esbuild/darwin-x64": "0.25.10",
|
|
||||||
"@esbuild/freebsd-arm64": "0.25.10",
|
|
||||||
"@esbuild/freebsd-x64": "0.25.10",
|
|
||||||
"@esbuild/linux-arm": "0.25.10",
|
|
||||||
"@esbuild/linux-arm64": "0.25.10",
|
|
||||||
"@esbuild/linux-ia32": "0.25.10",
|
|
||||||
"@esbuild/linux-loong64": "0.25.10",
|
|
||||||
"@esbuild/linux-mips64el": "0.25.10",
|
|
||||||
"@esbuild/linux-ppc64": "0.25.10",
|
|
||||||
"@esbuild/linux-riscv64": "0.25.10",
|
|
||||||
"@esbuild/linux-s390x": "0.25.10",
|
|
||||||
"@esbuild/linux-x64": "0.25.10",
|
|
||||||
"@esbuild/netbsd-arm64": "0.25.10",
|
|
||||||
"@esbuild/netbsd-x64": "0.25.10",
|
|
||||||
"@esbuild/openbsd-arm64": "0.25.10",
|
|
||||||
"@esbuild/openbsd-x64": "0.25.10",
|
|
||||||
"@esbuild/openharmony-arm64": "0.25.10",
|
|
||||||
"@esbuild/sunos-x64": "0.25.10",
|
|
||||||
"@esbuild/win32-arm64": "0.25.10",
|
|
||||||
"@esbuild/win32-ia32": "0.25.10",
|
|
||||||
"@esbuild/win32-x64": "0.25.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/execa@9.6.0/node_modules/execa": {
|
|
||||||
"version": "9.6.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sindresorhus/merge-streams": "^4.0.0",
|
|
||||||
"cross-spawn": "^7.0.6",
|
|
||||||
"figures": "^6.1.0",
|
|
||||||
"get-stream": "^9.0.0",
|
|
||||||
"human-signals": "^8.0.1",
|
|
||||||
"is-plain-obj": "^4.1.0",
|
|
||||||
"is-stream": "^4.0.1",
|
|
||||||
"npm-run-path": "^6.0.0",
|
|
||||||
"pretty-ms": "^9.2.0",
|
|
||||||
"signal-exit": "^4.1.0",
|
|
||||||
"strip-final-newline": "^4.0.0",
|
|
||||||
"yoctocolors": "^2.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.15.21",
|
|
||||||
"ava": "^6.3.0",
|
|
||||||
"c8": "^10.1.3",
|
|
||||||
"get-node": "^15.0.3",
|
|
||||||
"is-in-ci": "^1.0.0",
|
|
||||||
"is-running": "^2.1.0",
|
|
||||||
"log-process-errors": "^12.0.1",
|
|
||||||
"path-exists": "^5.0.0",
|
|
||||||
"path-key": "^4.0.0",
|
|
||||||
"tempfile": "^5.0.0",
|
|
||||||
"tsd": "^0.32.0",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"which": "^5.0.0",
|
|
||||||
"xo": "^0.60.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.19.0 || >=20.5.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp": {
|
|
||||||
"version": "3.20.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"execa": "^9.6.0",
|
|
||||||
"file-type": "^21.0.0",
|
|
||||||
"fuse.js": "^7.1.0",
|
|
||||||
"mcp-proxy": "^5.8.1",
|
|
||||||
"strict-event-emitter-types": "^2.0.0",
|
|
||||||
"undici": "^7.13.0",
|
|
||||||
"uri-templates": "^0.2.0",
|
|
||||||
"xsschema": "0.3.5",
|
|
||||||
"yargs": "^18.0.0",
|
|
||||||
"zod": "^3.25.76",
|
|
||||||
"zod-to-json-schema": "^3.24.6"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"fastmcp": "dist/bin/fastmcp.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.33.0",
|
|
||||||
"@modelcontextprotocol/inspector": "^0.16.2",
|
|
||||||
"@sebbo2002/semantic-release-jsr": "^3.0.1",
|
|
||||||
"@tsconfig/node22": "^22.0.2",
|
|
||||||
"@types/node": "^24.2.1",
|
|
||||||
"@types/uri-templates": "^0.1.34",
|
|
||||||
"@types/yargs": "^17.0.33",
|
|
||||||
"@valibot/to-json-schema": "^1.3.0",
|
|
||||||
"@wong2/mcp-cli": "^1.13.0",
|
|
||||||
"arktype": "^2.1.20",
|
|
||||||
"eslint": "^9.33.0",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-perfectionist": "^4.15.0",
|
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
|
||||||
"eventsource-client": "^1.1.4",
|
|
||||||
"get-port-please": "^3.2.0",
|
|
||||||
"jiti": "^2.5.1",
|
|
||||||
"jsr": "^0.13.5",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"semantic-release": "^24.2.7",
|
|
||||||
"tsup": "^8.5.0",
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"typescript-eslint": "^8.39.0",
|
|
||||||
"valibot": "^1.1.0",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/husky@9.1.7/node_modules/husky": {
|
|
||||||
"version": "9.1.7",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"husky": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/typicode"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/table@6.9.0/node_modules/table": {
|
|
||||||
"version": "6.9.0",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.0.1",
|
|
||||||
"lodash.truncate": "^4.4.2",
|
|
||||||
"slice-ansi": "^4.0.0",
|
|
||||||
"string-width": "^4.2.3",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/chai": "^4.2.16",
|
|
||||||
"@types/lodash.mapvalues": "^4.6.6",
|
|
||||||
"@types/lodash.truncate": "^4.4.6",
|
|
||||||
"@types/mocha": "^9.0.0",
|
|
||||||
"@types/node": "^14.14.37",
|
|
||||||
"@types/sinon": "^10.0.0",
|
|
||||||
"@types/slice-ansi": "^4.0.0",
|
|
||||||
"ajv-cli": "^5.0.0",
|
|
||||||
"ajv-keywords": "^5.0.0",
|
|
||||||
"chai": "^4.2.0",
|
|
||||||
"chalk": "^4.1.0",
|
|
||||||
"coveralls": "^3.1.0",
|
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"eslint-config-canonical": "^25.0.0",
|
|
||||||
"gitdown": "^3.1.4",
|
|
||||||
"husky": "^4.3.6",
|
|
||||||
"js-beautify": "^1.14.0",
|
|
||||||
"lodash.mapvalues": "^4.6.0",
|
|
||||||
"mkdirp": "^1.0.4",
|
|
||||||
"mocha": "^8.2.1",
|
|
||||||
"nyc": "^15.1.0",
|
|
||||||
"semantic-release": "^17.3.1",
|
|
||||||
"sinon": "^12.0.1",
|
|
||||||
"ts-node": "^9.1.1",
|
|
||||||
"typescript": "4.5.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@dprint/formatter": "^0.4.1",
|
|
||||||
"@dprint/typescript": "0.93.4",
|
|
||||||
"@esfx/canceltoken": "^1.0.0",
|
|
||||||
"@eslint/js": "^9.20.0",
|
|
||||||
"@octokit/rest": "^21.1.1",
|
|
||||||
"@types/chai": "^4.3.20",
|
|
||||||
"@types/diff": "^7.0.1",
|
|
||||||
"@types/minimist": "^1.2.5",
|
|
||||||
"@types/mocha": "^10.0.10",
|
|
||||||
"@types/ms": "^0.7.34",
|
|
||||||
"@types/node": "latest",
|
|
||||||
"@types/source-map-support": "^0.5.10",
|
|
||||||
"@types/which": "^3.0.4",
|
|
||||||
"@typescript-eslint/rule-tester": "^8.24.1",
|
|
||||||
"@typescript-eslint/type-utils": "^8.24.1",
|
|
||||||
"@typescript-eslint/utils": "^8.24.1",
|
|
||||||
"azure-devops-node-api": "^14.1.0",
|
|
||||||
"c8": "^10.1.3",
|
|
||||||
"chai": "^4.5.0",
|
|
||||||
"chokidar": "^4.0.3",
|
|
||||||
"diff": "^7.0.0",
|
|
||||||
"dprint": "^0.49.0",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"eslint": "^9.20.1",
|
|
||||||
"eslint-formatter-autolinkable-stylish": "^1.4.0",
|
|
||||||
"eslint-plugin-regexp": "^2.7.0",
|
|
||||||
"fast-xml-parser": "^4.5.2",
|
|
||||||
"glob": "^10.4.5",
|
|
||||||
"globals": "^15.15.0",
|
|
||||||
"hereby": "^1.10.0",
|
|
||||||
"jsonc-parser": "^3.3.1",
|
|
||||||
"knip": "^5.44.4",
|
|
||||||
"minimist": "^1.2.8",
|
|
||||||
"mocha": "^10.8.2",
|
|
||||||
"mocha-fivemat-progress-reporter": "^0.1.0",
|
|
||||||
"monocart-coverage-reports": "^2.12.1",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"playwright": "^1.50.1",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"typescript-eslint": "^8.24.1",
|
|
||||||
"which": "^3.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@actions/core": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@ark/fs": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@ark/util": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@octokit/rest": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@octokit/webhooks-types": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"resolved": "../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/arg": {
|
|
||||||
"resolved": "../node_modules/.pnpm/arg@5.0.2/node_modules/arg",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/arktype": {
|
|
||||||
"resolved": "../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/convex": {
|
|
||||||
"version": "1.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/convex/-/convex-1.29.0.tgz",
|
|
||||||
"integrity": "sha512-uoIPXRKIp2eLCkkR9WJ2vc9NtgQtx8Pml59WPUahwbrd5EuW2WLI/cf2E7XrUzOSifdQC3kJZepisk4wJNTJaA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "0.25.4",
|
|
||||||
"prettier": "^3.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"convex": "bin/main.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0",
|
|
||||||
"npm": ">=7.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@auth0/auth0-react": "^2.0.1",
|
|
||||||
"@clerk/clerk-react": "^4.12.8 || ^5.0.0",
|
|
||||||
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@auth0/auth0-react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@clerk/clerk-react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convex/node_modules/esbuild": {
|
|
||||||
"version": "0.25.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
|
||||||
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.25.4",
|
|
||||||
"@esbuild/android-arm": "0.25.4",
|
|
||||||
"@esbuild/android-arm64": "0.25.4",
|
|
||||||
"@esbuild/android-x64": "0.25.4",
|
|
||||||
"@esbuild/darwin-arm64": "0.25.4",
|
|
||||||
"@esbuild/darwin-x64": "0.25.4",
|
|
||||||
"@esbuild/freebsd-arm64": "0.25.4",
|
|
||||||
"@esbuild/freebsd-x64": "0.25.4",
|
|
||||||
"@esbuild/linux-arm": "0.25.4",
|
|
||||||
"@esbuild/linux-arm64": "0.25.4",
|
|
||||||
"@esbuild/linux-ia32": "0.25.4",
|
|
||||||
"@esbuild/linux-loong64": "0.25.4",
|
|
||||||
"@esbuild/linux-mips64el": "0.25.4",
|
|
||||||
"@esbuild/linux-ppc64": "0.25.4",
|
|
||||||
"@esbuild/linux-riscv64": "0.25.4",
|
|
||||||
"@esbuild/linux-s390x": "0.25.4",
|
|
||||||
"@esbuild/linux-x64": "0.25.4",
|
|
||||||
"@esbuild/netbsd-arm64": "0.25.4",
|
|
||||||
"@esbuild/netbsd-x64": "0.25.4",
|
|
||||||
"@esbuild/openbsd-arm64": "0.25.4",
|
|
||||||
"@esbuild/openbsd-x64": "0.25.4",
|
|
||||||
"@esbuild/sunos-x64": "0.25.4",
|
|
||||||
"@esbuild/win32-arm64": "0.25.4",
|
|
||||||
"@esbuild/win32-ia32": "0.25.4",
|
|
||||||
"@esbuild/win32-x64": "0.25.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv": {
|
|
||||||
"resolved": "../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"resolved": "../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/execa": {
|
|
||||||
"resolved": "../node_modules/.pnpm/execa@9.6.0/node_modules/execa",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/fastmcp": {
|
|
||||||
"resolved": "../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/husky": {
|
|
||||||
"resolved": "../node_modules/.pnpm/husky@9.1.7/node_modules/husky",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/table": {
|
|
||||||
"resolved": "../node_modules/.pnpm/table@6.9.0/node_modules/table",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"resolved": "../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
|
|
||||||
"link": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/action",
|
||||||
"version": "0.0.155",
|
"version": "0.0.157",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"main.d.ts"
|
"main.d.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "node esbuild.config.js",
|
"build": "node esbuild.config.js",
|
||||||
"play": "node play.ts",
|
"play": "node play.ts",
|
||||||
@@ -25,19 +25,21 @@
|
|||||||
"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.37",
|
"@anthropic-ai/claude-agent-sdk": "0.1.77",
|
||||||
"@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/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.58.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",
|
||||||
"arktype": "2.1.28",
|
"arktype": "2.1.28",
|
||||||
"package-manager-detector": "^1.6.0",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"execa": "^9.6.0",
|
"execa": "^9.6.0",
|
||||||
"fastmcp": "^3.20.0",
|
"fastmcp": "^3.26.8",
|
||||||
|
"package-manager-detector": "^1.6.0",
|
||||||
"table": "^6.9.0"
|
"table": "^6.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,7 +47,8 @@
|
|||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
"esbuild": "^0.25.9",
|
"esbuild": "^0.25.9",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -70,5 +73,6 @@
|
|||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { extname, join, resolve } from "node:path";
|
import { extname, join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { fromHere } from "@ark/fs";
|
import { fromHere } from "@ark/fs";
|
||||||
import { flatMorph } from "@ark/util";
|
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import { agents } from "./agents/index.ts";
|
|
||||||
import type { AgentResult } from "./agents/shared.ts";
|
import type { AgentResult } from "./agents/shared.ts";
|
||||||
import { type Inputs, main } from "./main.ts";
|
import { type Inputs, main } from "./main.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
@@ -24,26 +23,9 @@ export async function run(prompt: string): Promise<AgentResult> {
|
|||||||
const originalCwd = process.cwd();
|
const originalCwd = process.cwd();
|
||||||
process.chdir(tempDir);
|
process.chdir(tempDir);
|
||||||
|
|
||||||
// check if prompt is a pullfrog payload and extract agent
|
const inputs: Inputs = {
|
||||||
// note: agent from payload will be used by determineAgent with highest precedence
|
|
||||||
// we don't need to extract it here since main() will parse the payload
|
|
||||||
const inputs = {
|
|
||||||
prompt,
|
prompt,
|
||||||
...flatMorph(agents, (_, agent) => {
|
};
|
||||||
// for OpenCode, scan all API_KEY environment variables
|
|
||||||
if (agent.name === "opencode") {
|
|
||||||
const opencodeKeys: Array<[string, string | undefined]> = [];
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
|
||||||
opencodeKeys.push([key.toLowerCase(), value]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return opencodeKeys;
|
|
||||||
}
|
|
||||||
// for other agents, use apiKeyNames
|
|
||||||
return agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]);
|
|
||||||
}),
|
|
||||||
} as Required<Inputs>;
|
|
||||||
|
|
||||||
const result = await main(inputs);
|
const result = await main(inputs);
|
||||||
|
|
||||||
@@ -67,7 +49,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||||||
const args = arg({
|
const args = arg({
|
||||||
"--help": Boolean,
|
"--help": Boolean,
|
||||||
"--raw": String,
|
"--raw": String,
|
||||||
|
"--local": Boolean,
|
||||||
"-h": "--help",
|
"-h": "--help",
|
||||||
|
"-l": "--local",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args["--help"]) {
|
if (args["--help"]) {
|
||||||
@@ -81,18 +65,85 @@ 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)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PLAY_LOCAL=1 Same as --local
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
tsx play.ts # Use default fixture
|
tsx play.ts bash-test.ts # Run in Docker (default)
|
||||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
tsx play.ts --local bash-test.ts # Run locally on macOS
|
||||||
tsx play.ts custom.json # Use JSON file
|
|
||||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
|
||||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||||
`);
|
`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// default: run in Docker (unless --local or PLAY_LOCAL=1 or already inside Docker)
|
||||||
|
const isInsideDocker = existsSync("/.dockerenv");
|
||||||
|
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
||||||
|
|
||||||
|
if (!useLocal) {
|
||||||
|
log.info("» running in Docker container...");
|
||||||
|
|
||||||
|
const passArgs = process.argv.slice(2);
|
||||||
|
const nodeCmd = `node play.ts ${passArgs.join(" ")}`;
|
||||||
|
|
||||||
|
// pass all env vars to docker
|
||||||
|
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
||||||
|
value !== undefined ? ["-e", `${key}=${value}`] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// SSH for git - mount individual SSH files to avoid permission issues
|
||||||
|
const sshFlags: string[] = [];
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home) {
|
||||||
|
const sshDir = join(home, ".ssh");
|
||||||
|
// mount SSH keys (try common key names)
|
||||||
|
for (const keyName of ["id_rsa", "id_ed25519", "id_ecdsa"]) {
|
||||||
|
const keyPath = join(sshDir, keyName);
|
||||||
|
if (existsSync(keyPath)) {
|
||||||
|
sshFlags.push("-v", `${keyPath}:/root/.ssh/${keyName}:ro`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// mount known_hosts
|
||||||
|
const knownHostsPath = join(sshDir, "known_hosts");
|
||||||
|
if (existsSync(knownHostsPath)) {
|
||||||
|
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttyFlags = process.stdin.isTTY ? ["-it"] : [];
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
...ttyFlags,
|
||||||
|
"-v",
|
||||||
|
`${process.cwd()}:/app/action:cached`,
|
||||||
|
"-v",
|
||||||
|
"pullfrog-action-node-modules:/app/action/node_modules",
|
||||||
|
"-w",
|
||||||
|
"/app/action",
|
||||||
|
...envFlags,
|
||||||
|
...sshFlags,
|
||||||
|
"--cap-add",
|
||||||
|
"SYS_ADMIN",
|
||||||
|
"--security-opt",
|
||||||
|
"seccomp:unconfined",
|
||||||
|
"node:24",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`,
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" }
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
|
|
||||||
if (args["--raw"]) {
|
if (args["--raw"]) {
|
||||||
@@ -135,10 +186,35 @@ Examples:
|
|||||||
|
|
||||||
if (typeof module.default === "string") {
|
if (typeof module.default === "string") {
|
||||||
prompt = module.default;
|
prompt = module.default;
|
||||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
} else if (Array.isArray(module.default)) {
|
||||||
prompt = module.default.prompt;
|
// Array of Payloads - run each in sequence
|
||||||
} else {
|
const payloads = module.default;
|
||||||
|
log.info(`Running ${payloads.length} payloads in sequence...`);
|
||||||
|
|
||||||
|
let allSuccess = true;
|
||||||
|
for (let i = 0; i < payloads.length; i++) {
|
||||||
|
const payload = payloads[i];
|
||||||
|
const label = payload.effort
|
||||||
|
? `[${i + 1}/${payloads.length}] effort=${payload.effort}`
|
||||||
|
: `[${i + 1}/${payloads.length}]`;
|
||||||
|
log.info(`\n${"=".repeat(60)}`);
|
||||||
|
log.info(`${label}`);
|
||||||
|
log.info(`${"=".repeat(60)}\n`);
|
||||||
|
|
||||||
|
const payloadPrompt = JSON.stringify(payload, null, 2);
|
||||||
|
const result = await run(payloadPrompt);
|
||||||
|
if (!result.success) {
|
||||||
|
allSuccess = false;
|
||||||
|
log.error(`Payload ${i + 1} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(allSuccess ? 0 : 1);
|
||||||
|
} else if (typeof module.default === "object") {
|
||||||
|
// Payload objects (with ~pullfrog) should be stringified
|
||||||
prompt = JSON.stringify(module.default, null, 2);
|
prompt = JSON.stringify(module.default, null, 2);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported default export type: ${typeof module.default}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1071
-81
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
|
"types": ["vitest/globals"],
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"rewriteRelativeImportExtensions": true,
|
"rewriteRelativeImportExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
+4
-1
@@ -178,7 +178,10 @@ async function summaryTable(
|
|||||||
if (title) {
|
if (title) {
|
||||||
core.info(`\n${title}`);
|
core.info(`\n${title}`);
|
||||||
}
|
}
|
||||||
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
|
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`);
|
core.info(`\n${tableText}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
import { fetchWorkflowRunInfo } from "./api.ts";
|
import { fetchWorkflowRunInfo } from "./api.ts";
|
||||||
import { getGitHubInstallationToken, parseRepoContext } from "./github.ts";
|
import { createOctokit, getGitHubInstallationToken, parseRepoContext } from "./github.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get progress comment ID from environment variable or database.
|
* Get progress comment ID from environment variable or database.
|
||||||
@@ -55,8 +54,7 @@ export async function reportErrorToComment({
|
|||||||
|
|
||||||
// update comment directly using GitHub API
|
// update comment directly using GitHub API
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const token = getGitHubInstallationToken();
|
const octokit = createOctokit(getGitHubInstallationToken());
|
||||||
const octokit = new Octokit({ auth: token });
|
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
|
|||||||
+58
-21
@@ -1,5 +1,8 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
import { createSign } from "node:crypto";
|
import { createSign } from "node:crypto";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import { throttling } from "@octokit/plugin-throttling";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import { retry } from "./retry.ts";
|
import { retry } from "./retry.ts";
|
||||||
|
|
||||||
@@ -44,16 +47,24 @@ interface RepositoriesResponse {
|
|||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGitHubActionsEnvironment(): boolean {
|
function isOIDCAvailable(): boolean {
|
||||||
return Boolean(process.env.GITHUB_ACTIONS);
|
// OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
|
||||||
|
return Boolean(
|
||||||
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenViaOIDC(): Promise<string> {
|
async function acquireTokenViaOIDC(opts?: { repos?: string[] }): Promise<string> {
|
||||||
log.info("» generating OIDC token...");
|
log.info("» generating OIDC token...");
|
||||||
|
|
||||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||||
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.repos?.length) {
|
||||||
|
params.set("repos", opts.repos.join(","));
|
||||||
|
}
|
||||||
|
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
|
||||||
log.info("» exchanging OIDC token for installation token...");
|
log.info("» exchanging OIDC token for installation token...");
|
||||||
|
|
||||||
@@ -62,7 +73,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
|||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token${queryString}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${oidcToken}`,
|
Authorization: `Bearer ${oidcToken}`,
|
||||||
@@ -78,7 +89,11 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||||
log.info(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
|
const owner = tokenData.repository?.split("/")[0];
|
||||||
|
const repoList = opts?.repos?.length
|
||||||
|
? [tokenData.repository, ...opts.repos.map((r) => `${owner}/${r}`)].join(", ")
|
||||||
|
: tokenData.repository;
|
||||||
|
log.info(`» installation token obtained for ${repoList}`);
|
||||||
|
|
||||||
return tokenData.token;
|
return tokenData.token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -232,9 +247,9 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireNewToken(): Promise<string> {
|
export async function acquireNewToken(opts?: { repos?: string[] }): Promise<string> {
|
||||||
if (isGitHubActionsEnvironment()) {
|
if (isOIDCAvailable()) {
|
||||||
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
return await retry(() => acquireTokenViaOIDC(opts), { label: "token exchange" });
|
||||||
} else {
|
} else {
|
||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
}
|
}
|
||||||
@@ -246,31 +261,32 @@ let githubInstallationToken: string | undefined;
|
|||||||
/**
|
/**
|
||||||
* Setup GitHub installation token for the action
|
* Setup GitHub installation token for the action
|
||||||
*/
|
*/
|
||||||
export async function setupGitHubInstallationToken(): Promise<string> {
|
export async function setupGitHubInstallationToken() {
|
||||||
|
assert(!githubInstallationToken, "GitHub installation token is already set.");
|
||||||
const acquiredToken = await acquireNewToken();
|
const acquiredToken = await acquireNewToken();
|
||||||
core.setSecret(acquiredToken);
|
core.setSecret(acquiredToken);
|
||||||
githubInstallationToken = acquiredToken;
|
githubInstallationToken = acquiredToken;
|
||||||
return acquiredToken;
|
return {
|
||||||
|
token: acquiredToken,
|
||||||
|
[Symbol.asyncDispose]() {
|
||||||
|
githubInstallationToken = undefined;
|
||||||
|
return revokeGitHubInstallationToken(acquiredToken);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the GitHub installation token from memory
|
* Get the GitHub installation token from memory
|
||||||
*/
|
*/
|
||||||
export function getGitHubInstallationToken(): string {
|
export function getGitHubInstallationToken(): string {
|
||||||
if (!githubInstallationToken) {
|
assert(
|
||||||
throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first.");
|
githubInstallationToken,
|
||||||
}
|
"GitHub installation token not set. Call setupGitHubInstallationToken first."
|
||||||
|
);
|
||||||
return githubInstallationToken;
|
return githubInstallationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeGitHubInstallationToken(): Promise<void> {
|
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
||||||
if (!githubInstallationToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = githubInstallationToken;
|
|
||||||
githubInstallationToken = undefined;
|
|
||||||
|
|
||||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -311,3 +327,24 @@ export function parseRepoContext(): RepoContext {
|
|||||||
|
|
||||||
return { owner, name };
|
return { owner, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OctokitWithPlugins = InstanceType<
|
||||||
|
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createOctokit(token: string): OctokitWithPlugins {
|
||||||
|
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
|
||||||
|
// we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
|
||||||
|
const OctokitWithPlugins = Octokit.plugin(throttling);
|
||||||
|
return new OctokitWithPlugins({
|
||||||
|
auth: token,
|
||||||
|
throttle: {
|
||||||
|
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||||
|
return retryCount <= 2;
|
||||||
|
},
|
||||||
|
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||||
|
return retryCount <= 2;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -37,11 +37,12 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = delayMs * attempt;
|
const delay = delayMs * attempt;
|
||||||
log.warning(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
log.warning(
|
||||||
|
`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
|
||||||
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
import type { Octokit } from "@octokit/rest";
|
|
||||||
import type { Payload } from "../external.ts";
|
import type { Payload } from "../external.ts";
|
||||||
import type { ToolState } from "../main.ts";
|
import type { ToolState } from "../main.ts";
|
||||||
import { checkoutPrBranch } from "../mcp/checkout.ts";
|
import { checkoutPrBranch } from "../mcp/checkout.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
import type { OctokitWithPlugins } from "./github.ts";
|
||||||
import { $ } from "./shell.ts";
|
import { $ } from "./shell.ts";
|
||||||
|
|
||||||
export interface SetupOptions {
|
export interface SetupOptions {
|
||||||
@@ -66,7 +66,7 @@ interface SetupGitAuthParams {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
payload: Payload;
|
payload: Payload;
|
||||||
octokit: Octokit;
|
octokit: OctokitWithPlugins;
|
||||||
toolState: ToolState;
|
toolState: ToolState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Timer } from './timer.ts';
|
||||||
|
import * as cli from './cli.ts';
|
||||||
|
|
||||||
|
describe('Timer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(cli.log, 'debug');
|
||||||
|
// Mock Date.now to have predictable timestamps
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should initialize with current timestamp', () => {
|
||||||
|
const mockTime = 1000000;
|
||||||
|
vi.setSystemTime(mockTime);
|
||||||
|
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.checkpoint('test');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('test')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkpoint', () => {
|
||||||
|
it('should log duration from initial timestamp on first checkpoint', () => {
|
||||||
|
const startTime = 1000000;
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
const timer = new Timer();
|
||||||
|
|
||||||
|
const checkpointTime = startTime + 100;
|
||||||
|
vi.setSystemTime(checkpointTime);
|
||||||
|
timer.checkpoint('first');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledWith('» first: 100ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log duration from last checkpoint on subsequent checkpoints', () => {
|
||||||
|
const startTime = 1000000;
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
const timer = new Timer();
|
||||||
|
|
||||||
|
// First checkpoint
|
||||||
|
const firstCheckpointTime = startTime + 50;
|
||||||
|
vi.setSystemTime(firstCheckpointTime);
|
||||||
|
timer.checkpoint('first');
|
||||||
|
|
||||||
|
// Second checkpoint
|
||||||
|
const secondCheckpointTime = firstCheckpointTime + 75;
|
||||||
|
vi.setSystemTime(secondCheckpointTime);
|
||||||
|
timer.checkpoint('second');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledTimes(2);
|
||||||
|
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» first: 50ms');
|
||||||
|
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» second: 75ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple checkpoints correctly', () => {
|
||||||
|
const startTime = 1000000;
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
const timer = new Timer();
|
||||||
|
|
||||||
|
// First checkpoint
|
||||||
|
vi.setSystemTime(startTime + 10);
|
||||||
|
timer.checkpoint('step1');
|
||||||
|
|
||||||
|
// Second checkpoint
|
||||||
|
vi.setSystemTime(startTime + 25);
|
||||||
|
timer.checkpoint('step2');
|
||||||
|
|
||||||
|
// Third checkpoint
|
||||||
|
vi.setSystemTime(startTime + 45);
|
||||||
|
timer.checkpoint('step3');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledTimes(3);
|
||||||
|
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» step1: 10ms');
|
||||||
|
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» step2: 15ms');
|
||||||
|
expect(cli.log.debug).toHaveBeenNthCalledWith(3, '» step3: 20ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero duration correctly', () => {
|
||||||
|
const startTime = 1000000;
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
const timer = new Timer();
|
||||||
|
|
||||||
|
// Checkpoint immediately
|
||||||
|
timer.checkpoint('immediate');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledWith('» immediate: 0ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom checkpoint names', () => {
|
||||||
|
const startTime = 1000000;
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
const timer = new Timer();
|
||||||
|
|
||||||
|
vi.setSystemTime(startTime + 200);
|
||||||
|
timer.checkpoint('Custom Checkpoint Name');
|
||||||
|
|
||||||
|
expect(cli.log.debug).toHaveBeenCalledWith(
|
||||||
|
'» Custom Checkpoint Name: 200ms'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
exclude: ['node_modules', '.temp'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user