Compare commits

...

6 Commits

Author SHA1 Message Date
Colin McDonnell edd240f535 bump action to 0.0.172 (fix version regression from #354 squash merge)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 15:36:51 +00:00
Colin McDonnell cd1ea5267c fix node24 PATH propagation and improve action logging (#381)
Add node24 binary directory to PATH in action entry point so spawned
processes (pnpm, npm, etc.) resolve to the correct node version instead
of the runner's default v20. Improve delegate task result logging with
success/failure status and summaries. Use collapsible log groups for
dependency install output instead of raw streaming.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 15:32:32 +00:00
Colin McDonnell 4f1e4a2e7a fix formatting in shell.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 14:41:56 +00:00
Colin McDonnell 73836d9c8f harden proc isolation and filter cross-fork check suite PRs
shell sandbox: double-umount + remount /proc to prevent exfiltration
when agent peels off --mount-proc overlay.

webhooks: filter check_suite.pull_requests to same-repo PRs only
(excludes cross-fork sync PRs) and skip merged/closed PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 14:39:39 +00:00
Mateusz Burzyński da72d0d6ee Fixed semantic conflict between #377 and #354 (#380)
* Move out checking out PRs from `etupGit` (#377 intent)

* Keep subagent-related changes from #377
2026-02-24 14:20:59 +00:00
Colin McDonnell b472aa1ba9 fix(opencode): add effort-aware model overrides and OpenRouter guidance (#354)
* chore: create empty commit for PR

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

* chore(action): trigger preview repo creation workflow

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

* fix(opencode): merge repo opencode config and log provider key presence

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

* fix(opencode): add effort-aware model override precedence

Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior.

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

* docs(effort): clarify OpenCode precedence and add section link

Add a concise OpenCode precedence callout in the summary area and link directly to the OpenCode section for full details and examples.

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

* Restructure dash (#372)

* Restructure dash

* WIP

* WIP

* refactor trigger UI: extract PR summary card, add mentions section, rename labels

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

* clean up console UI: remove info icons from section descriptions, rename mentions trigger

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

* fix review feedback: layout, terminology, form scope

- extract console sidebar sections to module-level constant
- align three-column layout breakpoints to xl (match sidebar visibility)
- fix mixed shell/bash terminology in beta page
- scope FormProvider to trigger sections only, restore autoComplete="off"

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

* Bump

---------

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

* chore: create empty commit for PR

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

* fix(opencode): merge repo opencode config and log provider key presence

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

* fix(opencode): add effort-aware model override precedence

Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior.

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

* chore: update agent rules and OpenCode config docs

Align AGENTS guidance with current preferences and apply review-driven wording updates in OpenCode-related files.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 02:05:26 +00:00
14 changed files with 524 additions and 168 deletions
+2
View File
@@ -39,6 +39,8 @@ jobs:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }}
OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- test preview system --> <!-- test bypass 2 -->
<!-- test preview system --> <!-- test bypass 2 --> <!-- trigger preview repo creation -->
<p align="center">
<h1 align="center">
<picture>
+218 -22
View File
@@ -1,7 +1,7 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { ghPullfrogMcpName } from "../external.ts";
@@ -38,6 +38,159 @@ function detectProviderError(text: string): string | null {
return null;
}
type OpenCodeConfig = {
mcp?: Record<string, unknown>;
permission?: Record<string, unknown>;
provider?: Record<string, unknown>;
model?: string;
enabled_providers?: string[];
[key: string]: unknown;
};
type RecordPropertyContext = {
value: unknown;
key: string;
};
type RepoConfigLoadContext = {
repoConfigPath: string;
};
type ProviderFromModelContext = {
model: string;
};
type InlineConfigOverrideContext = {
model: string;
};
type InlineConfigOverride = {
providerId: string;
content: string;
};
type ModelOverrideResolutionContext = {
effort: AgentRunContext["payload"]["effort"];
env: NodeJS.ProcessEnv;
};
type ModelOverrideResolution = {
model: string;
source: "OPENCODE_MODEL_MINI" | "OPENCODE_MODEL_MAX" | "OPENCODE_MODEL";
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getRecordProperty(ctx: RecordPropertyContext): Record<string, unknown> | undefined {
if (!isRecord(ctx.value)) {
return undefined;
}
const propertyValue = ctx.value[ctx.key];
if (!isRecord(propertyValue)) {
return undefined;
}
return propertyValue;
}
function loadRepoOpenCodeConfig(ctx: RepoConfigLoadContext): OpenCodeConfig | undefined {
if (!existsSync(ctx.repoConfigPath)) {
log.info(`» repo opencode.json not found at ${ctx.repoConfigPath}`);
return undefined;
}
try {
const rawConfig = readFileSync(ctx.repoConfigPath, "utf-8");
const parsedConfig = JSON.parse(rawConfig);
if (!isRecord(parsedConfig)) {
log.warning(`» repo opencode.json is not an object: ${ctx.repoConfigPath}`);
return undefined;
}
const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" });
if (providerConfig) {
const providerNames = Object.keys(providerConfig);
log.info(`» repo opencode provider config detected: ${providerNames.join(", ")}`);
}
const result: OpenCodeConfig = parsedConfig;
log.info(`» loaded repo opencode.json from ${ctx.repoConfigPath}`);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.warning(`» failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`);
return undefined;
}
}
function parseProviderFromModel(ctx: ProviderFromModelContext): string | undefined {
const trimmedModel = ctx.model.trim();
const slashIndex = trimmedModel.indexOf("/");
if (slashIndex <= 0) {
return undefined;
}
const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase();
if (!providerId) {
return undefined;
}
return providerId;
}
function buildInlineConfigOverride(
ctx: InlineConfigOverrideContext
): InlineConfigOverride | undefined {
const providerId = parseProviderFromModel({ model: ctx.model });
if (!providerId) {
return undefined;
}
const inlineConfig: OpenCodeConfig = {
model: ctx.model,
enabled_providers: [providerId],
};
return {
providerId,
content: JSON.stringify(inlineConfig),
};
}
function readNonEmptyEnvVar(ctx: { env: NodeJS.ProcessEnv; name: string }): string | undefined {
const value = ctx.env[ctx.name];
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return trimmed;
}
function resolveModelOverride(
ctx: ModelOverrideResolutionContext
): ModelOverrideResolution | undefined {
if (ctx.effort === "mini") {
const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" });
if (miniModel) {
return { model: miniModel, source: "OPENCODE_MODEL_MINI" };
}
}
if (ctx.effort === "max") {
const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" });
if (maxModel) {
return { model: maxModel, source: "OPENCODE_MODEL_MAX" };
}
}
const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" });
if (!baseModel) {
return undefined;
}
return { model: baseModel, source: "OPENCODE_MODEL" };
}
async function installOpencode(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
@@ -66,13 +219,18 @@ export const opencode = agent({
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
// only override model when OPENCODE_MODEL is set (e.g., test environments with
// restricted API quotas). in production, OpenCode auto-selects the best available
// model based on which provider API keys are present.
const modelOverride = process.env.OPENCODE_MODEL;
// resolve model override from environment.
// precedence:
// 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX)
// 2) OPENCODE_MODEL fallback
// 3) OpenCode auto-select
const modelOverride = resolveModelOverride({
effort: ctx.payload.effort,
env: process.env,
});
if (modelOverride) {
args.push("--model", modelOverride);
log.info(`» model: ${modelOverride} (override)`);
args.push("--model", modelOverride.model);
log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`);
} else {
log.info(`» model: auto-selected by OpenCode`);
}
@@ -89,6 +247,31 @@ export const opencode = agent({
GOOGLE_GENERATIVE_AI_API_KEY:
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
};
if (modelOverride) {
const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model });
if (inlineOverride) {
env.OPENCODE_CONFIG_CONTENT = inlineOverride.content;
log.info(
`» OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}`
);
} else {
log.warning(
`» skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"`
);
}
}
const hasOpenRouterKey = Boolean(env.OPENROUTER_API_KEY);
const hasAnthropicKey = Boolean(env.ANTHROPIC_API_KEY);
const hasOpenAiKey = Boolean(env.OPENAI_API_KEY);
const hasGoogleKey = Boolean(
env.GOOGLE_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_GENERATIVE_AI_API_KEY
);
log.info(
`» provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}`
);
// OpenCode doesn't support GitHub App installation tokens
delete env.GITHUB_TOKEN;
@@ -194,7 +377,7 @@ export const opencode = agent({
lastProviderError = providerError;
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else {
// OpenCode's --print-logs output goes to stderr. demote internal
//agent OpenCode's --print-logs output goes to stderr. demote internal
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
// call logs in the GitHub Actions step output.
log.debug(trimmed);
@@ -301,28 +484,41 @@ function configureOpenCode(ctx: AgentRunContext): void {
const configDir = join(ctx.tmpdir, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "opencode.json");
const repoConfigPath = join(process.cwd(), "opencode.json");
const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath });
if (repoConfig?.model) {
log.info(`» repo opencode model configured: ${repoConfig.model}`);
}
// build MCP servers config
const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
};
const opencodeMcpServers: Record<string, unknown> = {};
const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" });
if (repoMcpServers) {
Object.assign(opencodeMcpServers, repoMcpServers);
}
opencodeMcpServers[ghPullfrogMcpName] = { type: "remote" as const, url: ctx.mcpServerUrl };
// build permission object based on tool permissions
// note: OpenCode has no built-in web search tool
const shell = ctx.payload.shell;
const permission = {
edit: "deny",
read: "deny",
bash: shell !== "enabled" ? "deny" : "allow",
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
external_directory: "deny",
};
const permission: Record<string, unknown> = {};
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
if (repoPermission) {
Object.assign(permission, repoPermission);
}
permission.edit = "deny";
permission.read = "deny";
permission.bash = shell !== "enabled" ? "deny" : "allow";
permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow";
permission.external_directory = "deny";
// build complete config in one object
const config = {
mcp: opencodeMcpServers,
permission,
};
const config: OpenCodeConfig = {};
if (repoConfig) {
Object.assign(config, repoConfig);
}
config.mcp = opencodeMcpServers;
config.permission = permission;
const configJson = JSON.stringify(config, null, 2);
try {
+207 -74
View File
@@ -56274,7 +56274,7 @@ var require_snapshot_recorder = __commonJS({
"node_modules/.pnpm/undici@7.16.0/node_modules/undici/lib/mock/snapshot-recorder.js"(exports, module) {
"use strict";
var { writeFile, readFile, mkdir } = __require("node:fs/promises");
var { dirname: dirname2, resolve: resolve3 } = __require("node:path");
var { dirname: dirname3, resolve: resolve3 } = __require("node:path");
var { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = __require("node:timers");
var { InvalidArgumentError, UndiciError } = require_errors4();
var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
@@ -56498,7 +56498,7 @@ var require_snapshot_recorder = __commonJS({
throw new InvalidArgumentError("Snapshot path is required");
}
const resolvedPath = resolve3(path3);
await mkdir(dirname2(resolvedPath), { recursive: true });
await mkdir(dirname3(resolvedPath), { recursive: true });
const data = Array.from(this.#snapshots.entries()).map(([hash2, snapshot2]) => ({
hash: hash2,
snapshot: snapshot2
@@ -97612,6 +97612,7 @@ var require_semver2 = __commonJS({
// entry.ts
var core5 = __toESM(require_core(), 1);
import { dirname as dirname2 } from "node:path";
// node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
var liftArray = (data) => Array.isArray(data) ? data : [data];
@@ -141626,7 +141627,10 @@ function DelegateTool(ctx) {
if (!ctx.toolState.selectedMode) {
log.info(`\xBB warning: delegating without calling select_mode first (mode=${mode})`);
}
log.info(`\xBB delegating ${params.tasks.length} task(s) in parallel (mode=${mode})`);
const n = params.tasks.length;
log.info(
`\xBB delegating ${n} task${n === 1 ? "" : "s"}${n > 1 ? " in parallel" : ""} (mode=${mode})`
);
const taskEntries = params.tasks.map((task) => {
const effort = task.effort ?? "auto";
const subagent = createSubagentState({ ctx, mode, label: task.label });
@@ -141646,10 +141650,12 @@ function DelegateTool(ctx) {
const results = taskEntries.map((entry, i) => {
const outcome = settled[i];
const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
log.debug(
`\xBB task "${entry.task.label}" result: output=${entry.subagent.output !== void 0}, status=${entry.subagent.status}`
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49);
log.info(
`\xBB task "${entry.task.label}" ${result.success ? "succeeded" : "failed"}:
${result.summary}`
);
return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49);
return result;
});
const succeeded = results.filter((r) => r.success).length;
log.info(`\xBB delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`);
@@ -142071,12 +142077,15 @@ var installNodeDependencies = {
const result = await spawn({
cmd: resolved.command,
args: resolved.args,
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk)
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
if (output) {
log.startGroup(`${fullCommand} output`);
log.info(output);
log.endGroup();
}
if (result.exitCode !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
const errorMessage = output || `exited with code ${result.exitCode}`;
return {
language: "node",
@@ -142213,20 +142222,26 @@ var installPythonDependencies = {
}
}
const [cmd, ...args2] = config3.installCmd;
log.info(`\xBB running: ${cmd} ${args2.join(" ")}`);
const fullCommand = `${cmd} ${args2.join(" ")}`;
log.info(`\xBB running: ${fullCommand}`);
const result = await spawn({
cmd,
args: args2,
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
onStderr: (chunk) => process.stderr.write(chunk)
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
if (output) {
log.startGroup(`${fullCommand} output`);
log.info(output);
log.endGroup();
}
if (result.exitCode !== 0) {
return {
language: "python",
packageManager: config3.tool,
configFile: config3.file,
dependenciesInstalled: false,
issues: [result.stderr || `${cmd} exited with code ${result.exitCode}`]
issues: [output || `${cmd} exited with code ${result.exitCode}`]
};
}
return {
@@ -143955,13 +143970,14 @@ function detectSandboxMethod() {
log.info("PID namespace isolation not available - falling back to env filtering only");
return "none";
}
var PROC_CLEANUP = "umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
function spawnShell(params) {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const sandboxMethod = detectSandboxMethod();
if (sandboxMethod === "unshare") {
return spawn2(
"unshare",
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
spawnOpts
);
}
@@ -143983,10 +143999,9 @@ function spawnShell(params) {
"--mount-proc",
"bash",
"-c",
params.command
`${PROC_CLEANUP} ${params.command}`
],
{ ...spawnOpts, env: {} }
// empty env since we pass via sudo env
);
}
return spawn2("bash", ["-c", params.command], spawnOpts);
@@ -144238,43 +144253,26 @@ function isAddressInUse(error49) {
const message = getErrorMessage(error49).toLowerCase();
return message.includes("eaddrinuse") || message.includes("address already in use");
}
function buildSubagentTools(ctx) {
const tools = [
IssueInfoTool(ctx),
GetIssueCommentsTool(ctx),
GetIssueEventsTool(ctx),
PullRequestInfoTool(ctx),
CommitInfoTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
GetCheckSuiteLogsTool(ctx),
UploadFileTool(ctx),
SetOutputTool(ctx),
FileReadTool(ctx),
FileWriteTool(ctx),
FileEditTool(ctx),
FileDeleteTool(ctx),
ListDirectoryTool(ctx)
];
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
tools.push(KillBackgroundTool(ctx));
}
return tools;
}
function buildOrchestratorTools(ctx) {
function buildCommonTools(ctx) {
const tools = [
StartDependencyInstallationTool(ctx),
AwaitDependencyInstallationTool(ctx),
CreateCommentTool(ctx),
EditCommentTool(ctx),
ReplyToReviewCommentTool(ctx),
IssueTool(ctx),
IssueInfoTool(ctx),
GetIssueCommentsTool(ctx),
GetIssueEventsTool(ctx),
CreatePullRequestReviewTool(ctx),
PullRequestInfoTool(ctx),
CommitInfoTool(ctx),
CheckoutPrTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
ResolveReviewThreadTool(ctx),
GetCheckSuiteLogsTool(ctx),
AddLabelsTool(ctx),
GitTool(ctx),
GitFetchTool(ctx),
UploadFileTool(ctx),
@@ -144284,14 +144282,17 @@ function buildOrchestratorTools(ctx) {
FileEditTool(ctx),
FileDeleteTool(ctx),
ListDirectoryTool(ctx),
CreateCommentTool(ctx),
EditCommentTool(ctx),
ReplyToReviewCommentTool(ctx),
CreatePullRequestReviewTool(ctx),
ResolveReviewThreadTool(ctx),
IssueTool(ctx),
AddLabelsTool(ctx),
ReportProgressTool(ctx),
ReportProgressTool(ctx)
];
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
tools.push(KillBackgroundTool(ctx));
}
return tools;
}
function buildOrchestratorTools(ctx) {
return [
...buildCommonTools(ctx),
SelectModeTool(ctx),
DelegateTool(ctx),
AskQuestionTool(ctx),
@@ -144301,11 +144302,9 @@ function buildOrchestratorTools(ctx) {
CreatePullRequestTool(ctx),
UpdatePullRequestBodyTool(ctx)
];
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
tools.push(KillBackgroundTool(ctx));
}
return tools;
}
function buildSubagentTools(ctx) {
return buildCommonTools(ctx);
}
async function tryStartMcpServer(ctx, tools, port) {
const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" });
@@ -144639,7 +144638,7 @@ import { join as join11 } from "node:path";
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.171",
version: "0.0.172",
type: "module",
files: [
"index.js",
@@ -146040,7 +146039,7 @@ function configureGeminiSettings(ctx) {
}
// agents/opencode.ts
import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync12 } from "node:fs";
import { existsSync as existsSync7, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync12 } from "node:fs";
import { join as join15 } from "node:path";
import { performance as performance7 } from "node:perf_hooks";
var OPENCODE_CLI_VERSION = "1.1.56";
@@ -146061,6 +146060,101 @@ function detectProviderError(text) {
}
return null;
}
function isRecord(value2) {
return typeof value2 === "object" && value2 !== null && !Array.isArray(value2);
}
function getRecordProperty(ctx) {
if (!isRecord(ctx.value)) {
return void 0;
}
const propertyValue = ctx.value[ctx.key];
if (!isRecord(propertyValue)) {
return void 0;
}
return propertyValue;
}
function loadRepoOpenCodeConfig(ctx) {
if (!existsSync7(ctx.repoConfigPath)) {
log.info(`\xBB repo opencode.json not found at ${ctx.repoConfigPath}`);
return void 0;
}
try {
const rawConfig = readFileSync7(ctx.repoConfigPath, "utf-8");
const parsedConfig = JSON.parse(rawConfig);
if (!isRecord(parsedConfig)) {
log.warning(`\xBB repo opencode.json is not an object: ${ctx.repoConfigPath}`);
return void 0;
}
const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" });
if (providerConfig) {
const providerNames = Object.keys(providerConfig);
log.info(`\xBB repo opencode provider config detected: ${providerNames.join(", ")}`);
}
const result = parsedConfig;
log.info(`\xBB loaded repo opencode.json from ${ctx.repoConfigPath}`);
return result;
} catch (error49) {
const errorMessage = error49 instanceof Error ? error49.message : String(error49);
log.warning(`\xBB failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`);
return void 0;
}
}
function parseProviderFromModel(ctx) {
const trimmedModel = ctx.model.trim();
const slashIndex = trimmedModel.indexOf("/");
if (slashIndex <= 0) {
return void 0;
}
const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase();
if (!providerId) {
return void 0;
}
return providerId;
}
function buildInlineConfigOverride(ctx) {
const providerId = parseProviderFromModel({ model: ctx.model });
if (!providerId) {
return void 0;
}
const inlineConfig = {
model: ctx.model,
enabled_providers: [providerId]
};
return {
providerId,
content: JSON.stringify(inlineConfig)
};
}
function readNonEmptyEnvVar(ctx) {
const value2 = ctx.env[ctx.name];
if (!value2) {
return void 0;
}
const trimmed = value2.trim();
if (!trimmed) {
return void 0;
}
return trimmed;
}
function resolveModelOverride(ctx) {
if (ctx.effort === "mini") {
const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" });
if (miniModel) {
return { model: miniModel, source: "OPENCODE_MODEL_MINI" };
}
}
if (ctx.effort === "max") {
const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" });
if (maxModel) {
return { model: maxModel, source: "OPENCODE_MODEL_MAX" };
}
}
const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" });
if (!baseModel) {
return void 0;
}
return { model: baseModel, source: "OPENCODE_MODEL" };
}
async function installOpencode() {
return await installFromNpmTarball({
packageName: "opencode-ai",
@@ -146079,10 +146173,13 @@ var opencode = agent({
mkdirSync9(configDir, { recursive: true });
configureOpenCode(ctx);
const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
const modelOverride = process.env.OPENCODE_MODEL;
const modelOverride = resolveModelOverride({
effort: ctx.payload.effort,
env: process.env
});
if (modelOverride) {
args2.push("--model", modelOverride);
log.info(`\xBB model: ${modelOverride} (override)`);
args2.push("--model", modelOverride.model);
log.info(`\xBB model: ${modelOverride.model} (override via ${modelOverride.source})`);
} else {
log.info(`\xBB model: auto-selected by OpenCode`);
}
@@ -146094,6 +146191,28 @@ var opencode = agent({
// set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set)
GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
};
if (modelOverride) {
const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model });
if (inlineOverride) {
env2.OPENCODE_CONFIG_CONTENT = inlineOverride.content;
log.info(
`\xBB OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}`
);
} else {
log.warning(
`\xBB skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"`
);
}
}
const hasOpenRouterKey = Boolean(env2.OPENROUTER_API_KEY);
const hasAnthropicKey = Boolean(env2.ANTHROPIC_API_KEY);
const hasOpenAiKey = Boolean(env2.OPENAI_API_KEY);
const hasGoogleKey = Boolean(
env2.GOOGLE_API_KEY || env2.GEMINI_API_KEY || env2.GOOGLE_GENERATIVE_AI_API_KEY
);
log.info(
`\xBB provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}`
);
delete env2.GITHUB_TOKEN;
const repoDir = process.cwd();
log.debug(`\xBB starting OpenCode: ${cliPath} ${args2.join(" ")}`);
@@ -146246,21 +146365,34 @@ function configureOpenCode(ctx) {
const configDir = join15(ctx.tmpdir, ".config", "opencode");
mkdirSync9(configDir, { recursive: true });
const configPath = join15(configDir, "opencode.json");
const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
};
const repoConfigPath = join15(process.cwd(), "opencode.json");
const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath });
if (repoConfig?.model) {
log.info(`\xBB repo opencode model configured: ${repoConfig.model}`);
}
const opencodeMcpServers = {};
const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" });
if (repoMcpServers) {
Object.assign(opencodeMcpServers, repoMcpServers);
}
opencodeMcpServers[ghPullfrogMcpName] = { type: "remote", url: ctx.mcpServerUrl };
const shell = ctx.payload.shell;
const permission = {
edit: "deny",
read: "deny",
bash: shell !== "enabled" ? "deny" : "allow",
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
external_directory: "deny"
};
const config3 = {
mcp: opencodeMcpServers,
permission
};
const permission = {};
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
if (repoPermission) {
Object.assign(permission, repoPermission);
}
permission.edit = "deny";
permission.read = "deny";
permission.bash = shell !== "enabled" ? "deny" : "allow";
permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow";
permission.external_directory = "deny";
const config3 = {};
if (repoConfig) {
Object.assign(config3, repoConfig);
}
config3.mcp = opencodeMcpServers;
config3.permission = permission;
const configJson = JSON.stringify(config3, null, 2);
try {
writeFileSync12(configPath, configJson, "utf-8");
@@ -147580,6 +147712,7 @@ ${instructions.user}` : null,
}
// entry.ts
process.env.PATH = `${dirname2(process.execPath)}:${process.env.PATH}`;
async function run() {
try {
const result = await main();
+6
View File
@@ -4,9 +4,15 @@
* entry point for pullfrog/pullfrog - unified action
*/
import { dirname } from "node:path";
import * as core from "@actions/core";
import { main } from "./main.ts";
// GitHub Actions runs the action entry point with the node24 binary specified
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
async function run(): Promise<void> {
try {
const result = await main();
+8 -4
View File
@@ -77,7 +77,10 @@ export function DelegateTool(ctx: ToolContext) {
}
// matched by delegate test validators — update tests if changed
log.info(`» delegating ${params.tasks.length} task(s) in parallel (mode=${mode})`);
const n = params.tasks.length;
log.info(
`» delegating ${n} task${n === 1 ? "" : "s"}${n > 1 ? " in parallel" : ""} (mode=${mode})`
);
const taskEntries = params.tasks.map((task) => {
const effort = task.effort ?? "auto";
@@ -100,10 +103,11 @@ export function DelegateTool(ctx: ToolContext) {
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
const outcome = settled[i];
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
log.debug(
`» task "${entry.task.label}" result: output=${entry.subagent.output !== undefined}, status=${entry.subagent.status}`
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
log.info(
`» task "${entry.task.label}" ${result.success ? "succeeded" : "failed"}:\n${result.summary}`
);
return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
return result;
});
const succeeded = results.filter((r) => r.success).length;
+47 -47
View File
@@ -107,6 +107,24 @@ export interface ToolContext {
tmpdir: string;
}
/**
* tool names that are only available to the orchestrator.
* subagent MCP servers are started with these tools excluded.
*
* - delegation tools: only the orchestrator can spawn/manage subagents
* - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs
*/
export const ORCHESTRATOR_ONLY_TOOLS = [
"select_mode",
"delegate",
"ask_question",
"push_branch",
"push_tags",
"delete_branch",
"create_pull_request",
"update_pull_request_body",
] as const;
import { log } from "../utils/cli.ts";
import type { RunContextData } from "../utils/runContextData.ts";
import { AskQuestionTool } from "./askQuestion.ts";
@@ -188,50 +206,27 @@ function isAddressInUse(error: unknown): boolean {
return message.includes("eaddrinuse") || message.includes("address already in use");
}
// subagent tools: file ops, shell, read-only GitHub, upload, set_output.
// no git/checkout (mutates shared state), no dependencies (shared state),
// no GitHub-write (user-facing side effects), no delegation/remote-mutating.
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
const tools: Tool<any, any>[] = [
IssueInfoTool(ctx),
GetIssueCommentsTool(ctx),
GetIssueEventsTool(ctx),
PullRequestInfoTool(ctx),
CommitInfoTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
GetCheckSuiteLogsTool(ctx),
UploadFileTool(ctx),
SetOutputTool(ctx),
FileReadTool(ctx),
FileWriteTool(ctx),
FileEditTool(ctx),
FileDeleteTool(ctx),
ListDirectoryTool(ctx),
];
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
tools.push(KillBackgroundTool(ctx));
}
return tools;
}
// orchestrator gets everything: file ops, shell, git, GitHub, delegation, remote-mutating
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
// tools shared by both orchestrator and subagent servers
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
const tools: Tool<any, any>[] = [
StartDependencyInstallationTool(ctx),
AwaitDependencyInstallationTool(ctx),
CreateCommentTool(ctx),
EditCommentTool(ctx),
ReplyToReviewCommentTool(ctx),
IssueTool(ctx),
IssueInfoTool(ctx),
GetIssueCommentsTool(ctx),
GetIssueEventsTool(ctx),
CreatePullRequestReviewTool(ctx),
PullRequestInfoTool(ctx),
CommitInfoTool(ctx),
CheckoutPrTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
ResolveReviewThreadTool(ctx),
GetCheckSuiteLogsTool(ctx),
AddLabelsTool(ctx),
GitTool(ctx),
GitFetchTool(ctx),
UploadFileTool(ctx),
@@ -241,22 +236,7 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
FileEditTool(ctx),
FileDeleteTool(ctx),
ListDirectoryTool(ctx),
CreateCommentTool(ctx),
EditCommentTool(ctx),
ReplyToReviewCommentTool(ctx),
CreatePullRequestReviewTool(ctx),
ResolveReviewThreadTool(ctx),
IssueTool(ctx),
AddLabelsTool(ctx),
ReportProgressTool(ctx),
SelectModeTool(ctx),
DelegateTool(ctx),
AskQuestionTool(ctx),
PushBranchTool(ctx),
PushTagsTool(ctx),
DeleteBranchTool(ctx),
CreatePullRequestTool(ctx),
UpdatePullRequestBodyTool(ctx),
];
// only add ShellTool when shell is "restricted"
@@ -271,6 +251,26 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
return tools;
}
// orchestrator gets common tools + delegation + remote-mutating tools
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
return [
...buildCommonTools(ctx),
SelectModeTool(ctx),
DelegateTool(ctx),
AskQuestionTool(ctx),
PushBranchTool(ctx),
PushTagsTool(ctx),
DeleteBranchTool(ctx),
CreatePullRequestTool(ctx),
UpdatePullRequestBodyTool(ctx),
];
}
// subagent gets only common tools (no delegation, no remote mutation)
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
return buildCommonTools(ctx);
}
type McpStartResult = {
server: FastMCP;
url: string;
+12 -11
View File
@@ -82,27 +82,28 @@ function detectSandboxMethod(): SandboxMethod {
return "none";
}
// strip inherited proc mount that sits underneath --mount-proc's overlay.
// --mount-proc mounts fresh proc on top, but `umount /proc` peels it off and exposes the
// host's proc with all host PIDs — allowing /proc/<pid>/environ exfiltration.
// double-umount removes both layers, then a clean mount gives only sandbox PIDs.
// on unprivileged systems where umount fails, --mount-proc still provides isolation
// (the agent also can't umount in that case).
const PROC_CLEANUP =
"umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
function spawnShell(params: SpawnParams): ChildProcess {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const sandboxMethod = detectSandboxMethod();
if (sandboxMethod === "unshare") {
// use PID namespace isolation to prevent reading /proc/$PPID/environ
// this creates a new PID namespace where:
// 1. the subprocess becomes PID 1 in its namespace
// 2. parent PIDs are not visible (PPID = 0)
// 3. fresh /proc is mounted showing only sandbox PIDs
// combined with resolveEnv("restricted"), this prevents all /proc-based secret theft
return spawn(
"unshare",
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
spawnOpts
);
}
if (sandboxMethod === "sudo-unshare") {
// on GHA runners, unprivileged namespaces are blocked but sudo works
// pass filtered env via sudo env command since sudo clears environment
const envArgs: string[] = [];
for (const [k, v] of Object.entries(params.env)) {
if (v !== undefined) {
@@ -120,9 +121,9 @@ function spawnShell(params: SpawnParams): ChildProcess {
"--mount-proc",
"bash",
"-c",
params.command,
`${PROC_CLEANUP} ${params.command}`,
],
{ ...spawnOpts, env: {} } // empty env since we pass via sudo env
{ ...spawnOpts, env: {} }
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.171",
"version": "0.0.172",
"type": "module",
"files": [
"index.js",
+1 -1
View File
@@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.171",
version: "0.0.172",
type: "module",
files: [
"index.js",
+7 -4
View File
@@ -159,13 +159,16 @@ export const installNodeDependencies: PrepDefinition = {
cmd: resolved.command,
args: resolved.args,
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
if (output) {
log.startGroup(`${fullCommand} output`);
log.info(output);
log.endGroup();
}
if (result.exitCode !== 0) {
// combine stdout and stderr for better error context (pnpm often outputs errors to stdout)
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
const errorMessage = output || `exited with code ${result.exitCode}`;
return {
language: "node",
+10 -3
View File
@@ -162,21 +162,28 @@ export const installPythonDependencies: PrepDefinition = {
// run the install command
const [cmd, ...args] = config.installCmd;
log.info(`» running: ${cmd} ${args.join(" ")}`);
const fullCommand = `${cmd} ${args.join(" ")}`;
log.info(`» running: ${fullCommand}`);
const result = await spawn({
cmd,
args,
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
onStderr: (chunk) => process.stderr.write(chunk),
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
if (output) {
log.startGroup(`${fullCommand} output`);
log.info(output);
log.endGroup();
}
if (result.exitCode !== 0) {
return {
language: "python",
packageManager: config.tool,
configFile: config.file,
dependenciesInstalled: false,
issues: [result.stderr || `${cmd} exited with code ${result.exitCode}`],
issues: [output || `${cmd} exited with code ${result.exitCode}`],
};
}
+2
View File
@@ -65,6 +65,8 @@ const expectedAgentEnvVars = [
"GITHUB_TOKEN",
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
"GEMINI_MODEL",
"OPENCODE_MODEL_MAX",
"OPENCODE_MODEL_MINI",
"OPENCODE_MODEL",
].sort();
+2
View File
@@ -120,6 +120,8 @@ const testEnvAllowList = new Set([
"GOOGLE_GENERATIVE_AI_API_KEY",
"CURSOR_API_KEY",
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
"OPENCODE_MODEL_MINI", // effort-specific OpenCode model override for mini effort
"OPENCODE_MODEL_MAX", // effort-specific OpenCode model override for max effort
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
"LOG_LEVEL",
"DEBUG",