Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edd240f535 | |||
| cd1ea5267c | |||
| 4f1e4a2e7a | |||
| 73836d9c8f | |||
| da72d0d6ee | |||
| b472aa1ba9 |
@@ -39,6 +39,8 @@ jobs:
|
|||||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||||
|
OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }}
|
||||||
|
OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- test preview system --> <!-- test bypass 2 -->
|
<!-- test preview system --> <!-- test bypass 2 --> <!-- trigger preview repo creation -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
+218
-22
@@ -1,7 +1,7 @@
|
|||||||
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
// 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 tool permissions should be reflected in wiki/granular-tools.md
|
||||||
// changes to web search configuration should be reflected in wiki/websearch.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 { join } from "node:path";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
@@ -38,6 +38,159 @@ function detectProviderError(text: string): string | null {
|
|||||||
return 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> {
|
async function installOpencode(): Promise<string> {
|
||||||
return await installFromNpmTarball({
|
return await installFromNpmTarball({
|
||||||
packageName: "opencode-ai",
|
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).
|
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
|
||||||
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
||||||
|
|
||||||
// only override model when OPENCODE_MODEL is set (e.g., test environments with
|
// resolve model override from environment.
|
||||||
// restricted API quotas). in production, OpenCode auto-selects the best available
|
// precedence:
|
||||||
// model based on which provider API keys are present.
|
// 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX)
|
||||||
const modelOverride = process.env.OPENCODE_MODEL;
|
// 2) OPENCODE_MODEL fallback
|
||||||
|
// 3) OpenCode auto-select
|
||||||
|
const modelOverride = resolveModelOverride({
|
||||||
|
effort: ctx.payload.effort,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
args.push("--model", modelOverride);
|
args.push("--model", modelOverride.model);
|
||||||
log.info(`» model: ${modelOverride} (override)`);
|
log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`);
|
||||||
} else {
|
} else {
|
||||||
log.info(`» model: auto-selected by OpenCode`);
|
log.info(`» model: auto-selected by OpenCode`);
|
||||||
}
|
}
|
||||||
@@ -89,6 +247,31 @@ export const opencode = agent({
|
|||||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_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
|
// OpenCode doesn't support GitHub App installation tokens
|
||||||
delete env.GITHUB_TOKEN;
|
delete env.GITHUB_TOKEN;
|
||||||
|
|
||||||
@@ -194,7 +377,7 @@ export const opencode = agent({
|
|||||||
lastProviderError = providerError;
|
lastProviderError = providerError;
|
||||||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||||
} else {
|
} 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
|
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
||||||
// call logs in the GitHub Actions step output.
|
// call logs in the GitHub Actions step output.
|
||||||
log.debug(trimmed);
|
log.debug(trimmed);
|
||||||
@@ -301,28 +484,41 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
|||||||
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
const configPath = join(configDir, "opencode.json");
|
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
|
// build MCP servers config
|
||||||
const opencodeMcpServers = {
|
const opencodeMcpServers: Record<string, unknown> = {};
|
||||||
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
|
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
|
// build permission object based on tool permissions
|
||||||
// note: OpenCode has no built-in web search tool
|
// note: OpenCode has no built-in web search tool
|
||||||
const shell = ctx.payload.shell;
|
const shell = ctx.payload.shell;
|
||||||
const permission = {
|
const permission: Record<string, unknown> = {};
|
||||||
edit: "deny",
|
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
|
||||||
read: "deny",
|
if (repoPermission) {
|
||||||
bash: shell !== "enabled" ? "deny" : "allow",
|
Object.assign(permission, repoPermission);
|
||||||
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
}
|
||||||
external_directory: "deny",
|
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
|
// build complete config in one object
|
||||||
const config = {
|
const config: OpenCodeConfig = {};
|
||||||
mcp: opencodeMcpServers,
|
if (repoConfig) {
|
||||||
permission,
|
Object.assign(config, repoConfig);
|
||||||
};
|
}
|
||||||
|
config.mcp = opencodeMcpServers;
|
||||||
|
config.permission = permission;
|
||||||
|
|
||||||
const configJson = JSON.stringify(config, null, 2);
|
const configJson = JSON.stringify(config, null, 2);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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) {
|
"node_modules/.pnpm/undici@7.16.0/node_modules/undici/lib/mock/snapshot-recorder.js"(exports, module) {
|
||||||
"use strict";
|
"use strict";
|
||||||
var { writeFile, readFile, mkdir } = __require("node:fs/promises");
|
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 { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = __require("node:timers");
|
||||||
var { InvalidArgumentError, UndiciError } = require_errors4();
|
var { InvalidArgumentError, UndiciError } = require_errors4();
|
||||||
var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
|
var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
|
||||||
@@ -56498,7 +56498,7 @@ var require_snapshot_recorder = __commonJS({
|
|||||||
throw new InvalidArgumentError("Snapshot path is required");
|
throw new InvalidArgumentError("Snapshot path is required");
|
||||||
}
|
}
|
||||||
const resolvedPath = resolve3(path3);
|
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]) => ({
|
const data = Array.from(this.#snapshots.entries()).map(([hash2, snapshot2]) => ({
|
||||||
hash: hash2,
|
hash: hash2,
|
||||||
snapshot: snapshot2
|
snapshot: snapshot2
|
||||||
@@ -97612,6 +97612,7 @@ var require_semver2 = __commonJS({
|
|||||||
|
|
||||||
// entry.ts
|
// entry.ts
|
||||||
var core5 = __toESM(require_core(), 1);
|
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
|
// node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
|
||||||
var liftArray = (data) => Array.isArray(data) ? data : [data];
|
var liftArray = (data) => Array.isArray(data) ? data : [data];
|
||||||
@@ -141626,7 +141627,10 @@ function DelegateTool(ctx) {
|
|||||||
if (!ctx.toolState.selectedMode) {
|
if (!ctx.toolState.selectedMode) {
|
||||||
log.info(`\xBB warning: delegating without calling select_mode first (mode=${mode})`);
|
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 taskEntries = params.tasks.map((task) => {
|
||||||
const effort = task.effort ?? "auto";
|
const effort = task.effort ?? "auto";
|
||||||
const subagent = createSubagentState({ ctx, mode, label: task.label });
|
const subagent = createSubagentState({ ctx, mode, label: task.label });
|
||||||
@@ -141646,10 +141650,12 @@ function DelegateTool(ctx) {
|
|||||||
const results = taskEntries.map((entry, i) => {
|
const results = taskEntries.map((entry, i) => {
|
||||||
const outcome = settled[i];
|
const outcome = settled[i];
|
||||||
const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
||||||
log.debug(
|
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49);
|
||||||
`\xBB task "${entry.task.label}" result: output=${entry.subagent.output !== void 0}, status=${entry.subagent.status}`
|
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;
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
log.info(`\xBB delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`);
|
log.info(`\xBB delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`);
|
||||||
@@ -142071,12 +142077,15 @@ var installNodeDependencies = {
|
|||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: resolved.command,
|
cmd: resolved.command,
|
||||||
args: resolved.args,
|
args: resolved.args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }
|
||||||
onStdout: (chunk) => process.stdout.write(chunk),
|
|
||||||
onStderr: (chunk) => process.stderr.write(chunk)
|
|
||||||
});
|
});
|
||||||
|
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) {
|
if (result.exitCode !== 0) {
|
||||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
||||||
const errorMessage = output || `exited with code ${result.exitCode}`;
|
const errorMessage = output || `exited with code ${result.exitCode}`;
|
||||||
return {
|
return {
|
||||||
language: "node",
|
language: "node",
|
||||||
@@ -142213,20 +142222,26 @@ var installPythonDependencies = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [cmd, ...args2] = config3.installCmd;
|
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({
|
const result = await spawn({
|
||||||
cmd,
|
cmd,
|
||||||
args: args2,
|
args: args2,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
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) {
|
if (result.exitCode !== 0) {
|
||||||
return {
|
return {
|
||||||
language: "python",
|
language: "python",
|
||||||
packageManager: config3.tool,
|
packageManager: config3.tool,
|
||||||
configFile: config3.file,
|
configFile: config3.file,
|
||||||
dependenciesInstalled: false,
|
dependenciesInstalled: false,
|
||||||
issues: [result.stderr || `${cmd} exited with code ${result.exitCode}`]
|
issues: [output || `${cmd} exited with code ${result.exitCode}`]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -143955,13 +143970,14 @@ function detectSandboxMethod() {
|
|||||||
log.info("PID namespace isolation not available - falling back to env filtering only");
|
log.info("PID namespace isolation not available - falling back to env filtering only");
|
||||||
return "none";
|
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) {
|
function spawnShell(params) {
|
||||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||||
const sandboxMethod = detectSandboxMethod();
|
const sandboxMethod = detectSandboxMethod();
|
||||||
if (sandboxMethod === "unshare") {
|
if (sandboxMethod === "unshare") {
|
||||||
return spawn2(
|
return spawn2(
|
||||||
"unshare",
|
"unshare",
|
||||||
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
|
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
|
||||||
spawnOpts
|
spawnOpts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143983,10 +143999,9 @@ function spawnShell(params) {
|
|||||||
"--mount-proc",
|
"--mount-proc",
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
params.command
|
`${PROC_CLEANUP} ${params.command}`
|
||||||
],
|
],
|
||||||
{ ...spawnOpts, env: {} }
|
{ ...spawnOpts, env: {} }
|
||||||
// empty env since we pass via sudo env
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return spawn2("bash", ["-c", params.command], spawnOpts);
|
return spawn2("bash", ["-c", params.command], spawnOpts);
|
||||||
@@ -144238,43 +144253,26 @@ function isAddressInUse(error49) {
|
|||||||
const message = getErrorMessage(error49).toLowerCase();
|
const message = getErrorMessage(error49).toLowerCase();
|
||||||
return message.includes("eaddrinuse") || message.includes("address already in use");
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
||||||
}
|
}
|
||||||
function buildSubagentTools(ctx) {
|
function buildCommonTools(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) {
|
|
||||||
const tools = [
|
const tools = [
|
||||||
StartDependencyInstallationTool(ctx),
|
StartDependencyInstallationTool(ctx),
|
||||||
AwaitDependencyInstallationTool(ctx),
|
AwaitDependencyInstallationTool(ctx),
|
||||||
|
CreateCommentTool(ctx),
|
||||||
|
EditCommentTool(ctx),
|
||||||
|
ReplyToReviewCommentTool(ctx),
|
||||||
|
IssueTool(ctx),
|
||||||
IssueInfoTool(ctx),
|
IssueInfoTool(ctx),
|
||||||
GetIssueCommentsTool(ctx),
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool(ctx),
|
GetIssueEventsTool(ctx),
|
||||||
|
CreatePullRequestReviewTool(ctx),
|
||||||
PullRequestInfoTool(ctx),
|
PullRequestInfoTool(ctx),
|
||||||
CommitInfoTool(ctx),
|
CommitInfoTool(ctx),
|
||||||
CheckoutPrTool(ctx),
|
CheckoutPrTool(ctx),
|
||||||
GetReviewCommentsTool(ctx),
|
GetReviewCommentsTool(ctx),
|
||||||
ListPullRequestReviewsTool(ctx),
|
ListPullRequestReviewsTool(ctx),
|
||||||
|
ResolveReviewThreadTool(ctx),
|
||||||
GetCheckSuiteLogsTool(ctx),
|
GetCheckSuiteLogsTool(ctx),
|
||||||
|
AddLabelsTool(ctx),
|
||||||
GitTool(ctx),
|
GitTool(ctx),
|
||||||
GitFetchTool(ctx),
|
GitFetchTool(ctx),
|
||||||
UploadFileTool(ctx),
|
UploadFileTool(ctx),
|
||||||
@@ -144284,14 +144282,17 @@ function buildOrchestratorTools(ctx) {
|
|||||||
FileEditTool(ctx),
|
FileEditTool(ctx),
|
||||||
FileDeleteTool(ctx),
|
FileDeleteTool(ctx),
|
||||||
ListDirectoryTool(ctx),
|
ListDirectoryTool(ctx),
|
||||||
CreateCommentTool(ctx),
|
ReportProgressTool(ctx)
|
||||||
EditCommentTool(ctx),
|
];
|
||||||
ReplyToReviewCommentTool(ctx),
|
if (ctx.payload.shell === "restricted") {
|
||||||
CreatePullRequestReviewTool(ctx),
|
tools.push(ShellTool(ctx));
|
||||||
ResolveReviewThreadTool(ctx),
|
tools.push(KillBackgroundTool(ctx));
|
||||||
IssueTool(ctx),
|
}
|
||||||
AddLabelsTool(ctx),
|
return tools;
|
||||||
ReportProgressTool(ctx),
|
}
|
||||||
|
function buildOrchestratorTools(ctx) {
|
||||||
|
return [
|
||||||
|
...buildCommonTools(ctx),
|
||||||
SelectModeTool(ctx),
|
SelectModeTool(ctx),
|
||||||
DelegateTool(ctx),
|
DelegateTool(ctx),
|
||||||
AskQuestionTool(ctx),
|
AskQuestionTool(ctx),
|
||||||
@@ -144301,11 +144302,9 @@ function buildOrchestratorTools(ctx) {
|
|||||||
CreatePullRequestTool(ctx),
|
CreatePullRequestTool(ctx),
|
||||||
UpdatePullRequestBodyTool(ctx)
|
UpdatePullRequestBodyTool(ctx)
|
||||||
];
|
];
|
||||||
if (ctx.payload.shell === "restricted") {
|
}
|
||||||
tools.push(ShellTool(ctx));
|
function buildSubagentTools(ctx) {
|
||||||
tools.push(KillBackgroundTool(ctx));
|
return buildCommonTools(ctx);
|
||||||
}
|
|
||||||
return tools;
|
|
||||||
}
|
}
|
||||||
async function tryStartMcpServer(ctx, tools, port) {
|
async function tryStartMcpServer(ctx, tools, port) {
|
||||||
const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" });
|
const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" });
|
||||||
@@ -144639,7 +144638,7 @@ import { join as join11 } from "node:path";
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.171",
|
version: "0.0.172",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -146040,7 +146039,7 @@ function configureGeminiSettings(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// agents/opencode.ts
|
// 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 { join as join15 } from "node:path";
|
||||||
import { performance as performance7 } from "node:perf_hooks";
|
import { performance as performance7 } from "node:perf_hooks";
|
||||||
var OPENCODE_CLI_VERSION = "1.1.56";
|
var OPENCODE_CLI_VERSION = "1.1.56";
|
||||||
@@ -146061,6 +146060,101 @@ function detectProviderError(text) {
|
|||||||
}
|
}
|
||||||
return null;
|
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() {
|
async function installOpencode() {
|
||||||
return await installFromNpmTarball({
|
return await installFromNpmTarball({
|
||||||
packageName: "opencode-ai",
|
packageName: "opencode-ai",
|
||||||
@@ -146079,10 +146173,13 @@ var opencode = agent({
|
|||||||
mkdirSync9(configDir, { recursive: true });
|
mkdirSync9(configDir, { recursive: true });
|
||||||
configureOpenCode(ctx);
|
configureOpenCode(ctx);
|
||||||
const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
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) {
|
if (modelOverride) {
|
||||||
args2.push("--model", modelOverride);
|
args2.push("--model", modelOverride.model);
|
||||||
log.info(`\xBB model: ${modelOverride} (override)`);
|
log.info(`\xBB model: ${modelOverride.model} (override via ${modelOverride.source})`);
|
||||||
} else {
|
} else {
|
||||||
log.info(`\xBB model: auto-selected by OpenCode`);
|
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)
|
// 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
|
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;
|
delete env2.GITHUB_TOKEN;
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
log.debug(`\xBB starting OpenCode: ${cliPath} ${args2.join(" ")}`);
|
log.debug(`\xBB starting OpenCode: ${cliPath} ${args2.join(" ")}`);
|
||||||
@@ -146246,21 +146365,34 @@ function configureOpenCode(ctx) {
|
|||||||
const configDir = join15(ctx.tmpdir, ".config", "opencode");
|
const configDir = join15(ctx.tmpdir, ".config", "opencode");
|
||||||
mkdirSync9(configDir, { recursive: true });
|
mkdirSync9(configDir, { recursive: true });
|
||||||
const configPath = join15(configDir, "opencode.json");
|
const configPath = join15(configDir, "opencode.json");
|
||||||
const opencodeMcpServers = {
|
const repoConfigPath = join15(process.cwd(), "opencode.json");
|
||||||
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
|
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 shell = ctx.payload.shell;
|
||||||
const permission = {
|
const permission = {};
|
||||||
edit: "deny",
|
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
|
||||||
read: "deny",
|
if (repoPermission) {
|
||||||
bash: shell !== "enabled" ? "deny" : "allow",
|
Object.assign(permission, repoPermission);
|
||||||
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
}
|
||||||
external_directory: "deny"
|
permission.edit = "deny";
|
||||||
};
|
permission.read = "deny";
|
||||||
const config3 = {
|
permission.bash = shell !== "enabled" ? "deny" : "allow";
|
||||||
mcp: opencodeMcpServers,
|
permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow";
|
||||||
permission
|
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);
|
const configJson = JSON.stringify(config3, null, 2);
|
||||||
try {
|
try {
|
||||||
writeFileSync12(configPath, configJson, "utf-8");
|
writeFileSync12(configPath, configJson, "utf-8");
|
||||||
@@ -147580,6 +147712,7 @@ ${instructions.user}` : null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// entry.ts
|
// entry.ts
|
||||||
|
process.env.PATH = `${dirname2(process.execPath)}:${process.env.PATH}`;
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
const result = await main();
|
const result = await main();
|
||||||
|
|||||||
@@ -4,9 +4,15 @@
|
|||||||
* entry point for pullfrog/pullfrog - unified action
|
* entry point for pullfrog/pullfrog - unified action
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { dirname } from "node:path";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { main } from "./main.ts";
|
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> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await main();
|
const result = await main();
|
||||||
|
|||||||
+8
-4
@@ -77,7 +77,10 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// matched by delegate test validators — update tests if changed
|
// 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 taskEntries = params.tasks.map((task) => {
|
||||||
const effort = task.effort ?? "auto";
|
const effort = task.effort ?? "auto";
|
||||||
@@ -100,10 +103,11 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
|
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
|
||||||
const outcome = settled[i];
|
const outcome = settled[i];
|
||||||
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
||||||
log.debug(
|
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
|
||||||
`» task "${entry.task.label}" result: output=${entry.subagent.output !== undefined}, status=${entry.subagent.status}`
|
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;
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
|||||||
+47
-47
@@ -107,6 +107,24 @@ export interface ToolContext {
|
|||||||
tmpdir: string;
|
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 { log } from "../utils/cli.ts";
|
||||||
import type { RunContextData } from "../utils/runContextData.ts";
|
import type { RunContextData } from "../utils/runContextData.ts";
|
||||||
import { AskQuestionTool } from "./askQuestion.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");
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
||||||
}
|
}
|
||||||
|
|
||||||
// subagent tools: file ops, shell, read-only GitHub, upload, set_output.
|
// tools shared by both orchestrator and subagent servers
|
||||||
// no git/checkout (mutates shared state), no dependencies (shared state),
|
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
|
||||||
// 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>[] {
|
|
||||||
const tools: Tool<any, any>[] = [
|
const tools: Tool<any, any>[] = [
|
||||||
StartDependencyInstallationTool(ctx),
|
StartDependencyInstallationTool(ctx),
|
||||||
AwaitDependencyInstallationTool(ctx),
|
AwaitDependencyInstallationTool(ctx),
|
||||||
|
CreateCommentTool(ctx),
|
||||||
|
EditCommentTool(ctx),
|
||||||
|
ReplyToReviewCommentTool(ctx),
|
||||||
|
IssueTool(ctx),
|
||||||
IssueInfoTool(ctx),
|
IssueInfoTool(ctx),
|
||||||
GetIssueCommentsTool(ctx),
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool(ctx),
|
GetIssueEventsTool(ctx),
|
||||||
|
CreatePullRequestReviewTool(ctx),
|
||||||
PullRequestInfoTool(ctx),
|
PullRequestInfoTool(ctx),
|
||||||
CommitInfoTool(ctx),
|
CommitInfoTool(ctx),
|
||||||
CheckoutPrTool(ctx),
|
CheckoutPrTool(ctx),
|
||||||
GetReviewCommentsTool(ctx),
|
GetReviewCommentsTool(ctx),
|
||||||
ListPullRequestReviewsTool(ctx),
|
ListPullRequestReviewsTool(ctx),
|
||||||
|
ResolveReviewThreadTool(ctx),
|
||||||
GetCheckSuiteLogsTool(ctx),
|
GetCheckSuiteLogsTool(ctx),
|
||||||
|
AddLabelsTool(ctx),
|
||||||
GitTool(ctx),
|
GitTool(ctx),
|
||||||
GitFetchTool(ctx),
|
GitFetchTool(ctx),
|
||||||
UploadFileTool(ctx),
|
UploadFileTool(ctx),
|
||||||
@@ -241,22 +236,7 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|||||||
FileEditTool(ctx),
|
FileEditTool(ctx),
|
||||||
FileDeleteTool(ctx),
|
FileDeleteTool(ctx),
|
||||||
ListDirectoryTool(ctx),
|
ListDirectoryTool(ctx),
|
||||||
CreateCommentTool(ctx),
|
|
||||||
EditCommentTool(ctx),
|
|
||||||
ReplyToReviewCommentTool(ctx),
|
|
||||||
CreatePullRequestReviewTool(ctx),
|
|
||||||
ResolveReviewThreadTool(ctx),
|
|
||||||
IssueTool(ctx),
|
|
||||||
AddLabelsTool(ctx),
|
|
||||||
ReportProgressTool(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"
|
// only add ShellTool when shell is "restricted"
|
||||||
@@ -271,6 +251,26 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|||||||
return tools;
|
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 = {
|
type McpStartResult = {
|
||||||
server: FastMCP;
|
server: FastMCP;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
+12
-11
@@ -82,27 +82,28 @@ function detectSandboxMethod(): SandboxMethod {
|
|||||||
return "none";
|
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 {
|
function spawnShell(params: SpawnParams): ChildProcess {
|
||||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||||
const sandboxMethod = detectSandboxMethod();
|
const sandboxMethod = detectSandboxMethod();
|
||||||
|
|
||||||
if (sandboxMethod === "unshare") {
|
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(
|
return spawn(
|
||||||
"unshare",
|
"unshare",
|
||||||
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
|
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
|
||||||
spawnOpts
|
spawnOpts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sandboxMethod === "sudo-unshare") {
|
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[] = [];
|
const envArgs: string[] = [];
|
||||||
for (const [k, v] of Object.entries(params.env)) {
|
for (const [k, v] of Object.entries(params.env)) {
|
||||||
if (v !== undefined) {
|
if (v !== undefined) {
|
||||||
@@ -120,9 +121,9 @@ function spawnShell(params: SpawnParams): ChildProcess {
|
|||||||
"--mount-proc",
|
"--mount-proc",
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
params.command,
|
`${PROC_CLEANUP} ${params.command}`,
|
||||||
],
|
],
|
||||||
{ ...spawnOpts, env: {} } // empty env since we pass via sudo env
|
{ ...spawnOpts, env: {} }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/pullfrog",
|
"name": "@pullfrog/pullfrog",
|
||||||
"version": "0.0.171",
|
"version": "0.0.172",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max");
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.171",
|
version: "0.0.172",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -159,13 +159,16 @@ export const installNodeDependencies: PrepDefinition = {
|
|||||||
cmd: resolved.command,
|
cmd: resolved.command,
|
||||||
args: resolved.args,
|
args: resolved.args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
||||||
onStdout: (chunk) => process.stdout.write(chunk),
|
|
||||||
onStderr: (chunk) => process.stderr.write(chunk),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
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}`;
|
const errorMessage = output || `exited with code ${result.exitCode}`;
|
||||||
return {
|
return {
|
||||||
language: "node",
|
language: "node",
|
||||||
|
|||||||
@@ -162,21 +162,28 @@ export const installPythonDependencies: PrepDefinition = {
|
|||||||
|
|
||||||
// run the install command
|
// run the install command
|
||||||
const [cmd, ...args] = config.installCmd;
|
const [cmd, ...args] = config.installCmd;
|
||||||
log.info(`» running: ${cmd} ${args.join(" ")}`);
|
const fullCommand = `${cmd} ${args.join(" ")}`;
|
||||||
|
log.info(`» running: ${fullCommand}`);
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
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) {
|
if (result.exitCode !== 0) {
|
||||||
return {
|
return {
|
||||||
language: "python",
|
language: "python",
|
||||||
packageManager: config.tool,
|
packageManager: config.tool,
|
||||||
configFile: config.file,
|
configFile: config.file,
|
||||||
dependenciesInstalled: false,
|
dependenciesInstalled: false,
|
||||||
issues: [result.stderr || `${cmd} exited with code ${result.exitCode}`],
|
issues: [output || `${cmd} exited with code ${result.exitCode}`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const expectedAgentEnvVars = [
|
|||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
||||||
"GEMINI_MODEL",
|
"GEMINI_MODEL",
|
||||||
|
"OPENCODE_MODEL_MAX",
|
||||||
|
"OPENCODE_MODEL_MINI",
|
||||||
"OPENCODE_MODEL",
|
"OPENCODE_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const testEnvAllowList = new Set([
|
|||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
"CURSOR_API_KEY",
|
"CURSOR_API_KEY",
|
||||||
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
|
"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
|
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
|
|||||||
Reference in New Issue
Block a user