diff --git a/agents/claude.ts b/agents/claude.ts index 59d7079..ffe4f95 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -2,7 +2,7 @@ import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import packageJson from "../package.json" with { type: "json" }; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, installFromNpmTarball } from "./shared.ts"; +import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts"; export const claude = agent({ name: "claude", @@ -15,7 +15,7 @@ export const claude = agent({ }); }, run: async ({ payload, mcpServers, apiKey, cliPath }) => { - process.env.ANTHROPIC_API_KEY = apiKey; + setupProcessAgentEnv({ ANTHROPIC_API_KEY: apiKey }); const prompt = addInstructions(payload); console.log(prompt); diff --git a/agents/codex.ts b/agents/codex.ts index cadd537..83938fa 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -4,7 +4,12 @@ import { join } from "node:path"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + installFromNpmTarball, + setupProcessAgentEnv, +} from "./shared.ts"; export const codex = agent({ name: "codex", @@ -15,16 +20,16 @@ export const codex = agent({ executablePath: "bin/codex.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { - process.env.OPENAI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - + run: async ({ payload, mcpServers, apiKey, cliPath }) => { // create config directory for codex before setting HOME const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "codex"); mkdirSync(configDir, { recursive: true }); - process.env.HOME = tempHome; + setupProcessAgentEnv({ + OPENAI_API_KEY: apiKey, + HOME: tempHome, + }); configureCodexMcpServers({ mcpServers, cliPath }); @@ -35,10 +40,6 @@ export const codex = agent({ }; const codex = new Codex(codexOptions); - // Configure thread options to match Claude's permissions (bypassPermissions) - // approvalPolicy: "never" = no approval needed (equivalent to bypassPermissions) - // sandboxMode: "workspace-write" = allow file writes - // networkAccessEnabled: true = allow network access (needed for GitHub API calls) const thread = codex.startThread({ approvalPolicy: "never", sandboxMode: "workspace-write", @@ -46,10 +47,8 @@ export const codex = agent({ }); try { - // Use runStreamed to get streaming events similar to claude.ts const streamedTurn = await thread.runStreamed(addInstructions(payload)); - // Stream events and handle them let finalOutput = ""; for await (const event of streamedTurn.events) { const handler = messageHandlers[event.type]; @@ -58,7 +57,6 @@ export const codex = agent({ handler(event as never); } - // Capture final response from agent messages if (event.type === "item.completed" && event.item.type === "agent_message") { finalOutput = event.item.text; } diff --git a/agents/cursor.ts b/agents/cursor.ts index df5213c..687070b 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -4,7 +4,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + createAgentEnv, + installFromCurl, +} from "./shared.ts"; // cursor cli event types inferred from stream-json output interface CursorSystemEvent { @@ -138,10 +143,7 @@ export const cursor = agent({ executableName: "cursor-agent", }); }, - run: async ({ payload, apiKey, cliPath, githubInstallationToken, mcpServers }) => { - process.env.CURSOR_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - + run: async ({ payload, apiKey, cliPath, mcpServers }) => { configureCursorMcpServers({ mcpServers, cliPath }); try { @@ -165,16 +167,9 @@ export const cursor = agent({ ], { cwd: process.cwd(), - env: { + env: createAgentEnv({ CURSOR_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL, - NODE_ENV: process.env.NODE_ENV, - HOME: process.env.HOME, - PATH: process.env.PATH, - // Don't override HOME - Cursor CLI needs access to macOS keychain - // MCP config is written to tempDir/.cursor/mcp.json which Cursor will find - }, + }), stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr } ); diff --git a/agents/gemini.ts b/agents/gemini.ts index 948a5d8..827af04 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -2,7 +2,12 @@ import { spawnSync } from "node:child_process"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + createAgentEnv, + installFromGithub, +} from "./shared.ts"; // gemini cli event types inferred from stream-json output (NDJSON format) interface GeminiInitEvent { @@ -152,16 +157,12 @@ export const gemini = agent({ assetName: "gemini.js", }); }, - run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => { + run: async ({ payload, apiKey, mcpServers, cliPath }) => { configureGeminiMcpServers({ mcpServers, cliPath }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - // Set environment variables for Gemini CLI and MCP servers - process.env.GEMINI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - const sessionPrompt = addInstructions(payload); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); @@ -170,15 +171,9 @@ export const gemini = agent({ const result = await spawn({ cmd: "node", args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt], - env: { - PATH: process.env.PATH || "", - HOME: process.env.HOME || "", - TMPDIR: process.env.TMPDIR || "/tmp", + env: createAgentEnv({ GEMINI_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL!, - NODE_ENV: process.env.NODE_ENV!, - }, + }), timeout: 600000, // 10 minutes onStdout: async (chunk) => { const text = chunk.toString(); diff --git a/agents/shared.ts b/agents/shared.ts index c173acf..e38a9d9 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -8,6 +8,7 @@ import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk"; import type { show } from "@ark/util"; import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts"; import { log } from "../utils/cli.ts"; +import { getGitHubInstallationToken } from "../utils/github.ts"; /** * Result returned by agent execution @@ -24,7 +25,6 @@ export interface AgentResult { */ export interface AgentConfig { apiKey: string; - githubInstallationToken: string; payload: Payload; mcpServers: Record; cliPath: string; @@ -38,6 +38,35 @@ export interface ConfigureMcpServersParams { cliPath: string; } +/** + * Add agent-specific vars to a whitelisted environment object for agent subprocesses. + * + * @param agentSpecificVars - Object containing agent-specific environment variables to include + * @returns Whitelisted environment object safe for subprocess spawning + */ +export function createAgentEnv(agentSpecificVars: Record): Record { + return { + PATH: process.env.PATH, + HOME: process.env.HOME, + LOG_LEVEL: process.env.LOG_LEVEL, + NODE_ENV: process.env.NODE_ENV, + GITHUB_TOKEN: getGitHubInstallationToken(), + ...agentSpecificVars, + // values could be undefined but will be ignored + } as never; +} + +/** + * Set up whitelisted environment variables in the current process. + * Used for SDKs that run in the same process (e.g., Claude SDK, Codex SDK). + * Includes agent-agnostic vars (PATH, HOME, LOG_LEVEL, NODE_ENV) plus agent-specific vars. + * + * @param agentSpecificVars - Object containing agent-specific environment variables to include + */ +export function setupProcessAgentEnv(agentSpecificVars: Record): void { + Object.assign(process.env, createAgentEnv(agentSpecificVars)); +} + /** * Parameters for installing from npm tarball */ @@ -296,17 +325,14 @@ export async function installFromCurl({ log.info(`Installing to temp directory at ${tempDir}...`); - // Run the install script with HOME set to temp directory - // The Cursor install script installs to $HOME/.local/bin/{executableName} - // By setting HOME=tempDir, we ensure it installs to tempDir/.local/bin/{executableName} const installResult = spawnSync("bash", [installScriptPath], { cwd: tempDir, env: { - HOME: tempDir, // Cursor install script uses HOME for installation path - PATH: process.env.PATH || "", - SHELL: process.env.SHELL || "/bin/bash", - USER: process.env.USER || "", - TMPDIR: process.env.TMPDIR || "/tmp", + // Run the install script with HOME set to temp directory + // ensuring a fresh install for each run + HOME: tempDir, + SHELL: process.env.SHELL, + USER: process.env.USER, }, stdio: "pipe", encoding: "utf-8", diff --git a/main.ts b/main.ts index 3b3c2d5..0c44afa 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { flatMorph } from "@ark/util"; @@ -18,7 +17,7 @@ import { log } from "./utils/cli.ts"; import { parseRepoContext, type RepoContext, - revokeInstallationToken, + revokeGitHubInstallationToken, setupGitHubInstallationToken, } from "./utils/github.ts"; import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; @@ -49,9 +48,7 @@ export interface MainResult { } export async function main(inputs: Inputs): Promise { - let pollInterval: NodeJS.Timeout | null = null; let mcpServerClose: (() => Promise) | undefined; - let githubInstallationToken: string | undefined; try { // parse payload early to extract agent @@ -59,15 +56,12 @@ export async function main(inputs: Inputs): Promise { const partialCtx = await initializeContext(inputs, payload); const ctx = partialCtx as MainContext; - githubInstallationToken = ctx.githubInstallationToken; setupGitAuth({ githubInstallationToken: ctx.githubInstallationToken, repoContext: ctx.repoContext, }); await setupTempDirectory(ctx); - setupMcpLogPolling(ctx); - pollInterval = ctx.pollInterval; setupGitBranch(ctx.payload); await startMcpServer(ctx); @@ -87,15 +81,10 @@ export async function main(inputs: Inputs): Promise { error: errorMessage, }; } finally { - if (pollInterval) { - clearInterval(pollInterval); - } if (mcpServerClose) { await mcpServerClose(); } - if (githubInstallationToken) { - await revokeInstallationToken(githubInstallationToken); - } + await revokeGitHubInstallationToken(); } } @@ -170,8 +159,6 @@ interface MainContext { agentName: AgentNameType; agent: (typeof agents)[AgentNameType]; sharedTempDir: string; - mcpLogPath: string; - pollInterval: NodeJS.Timeout | null; payload: Payload; mcpServerUrl: string; mcpServerClose: () => Promise; @@ -210,8 +197,6 @@ async function initializeContext( agent, payload: resolvedPayload, sharedTempDir: "", - mcpLogPath: "", - pollInterval: null, }; } @@ -262,25 +247,9 @@ async function setupTempDirectory( ): Promise { ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; - ctx.mcpLogPath = join(ctx.sharedTempDir, "mcpLog.txt"); - await writeFile(ctx.mcpLogPath, "", "utf-8"); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); } -function setupMcpLogPolling(ctx: MainContext): void { - let lastSize = 0; - ctx.pollInterval = setInterval(() => { - if (existsSync(ctx.mcpLogPath)) { - const content = readFileSync(ctx.mcpLogPath, "utf-8"); - if (content.length > lastSize) { - const newContent = content.slice(lastSize); - process.stdout.write(newContent); - lastSize = content.length; - } - } - }, 100); -} - function parsePayload(inputs: Inputs): Payload { try { const parsedPrompt = JSON.parse(inputs.prompt); @@ -307,7 +276,6 @@ async function startMcpServer(ctx: MainContext): Promise { const githubRepository = `${repoContext.owner}/${repoContext.name}`; const allModes = [...modes, ...(ctx.payload.modes || [])]; - process.env.GITHUB_INSTALLATION_TOKEN = ctx.githubInstallationToken; process.env.GITHUB_REPOSITORY = githubRepository; process.env.PULLFROG_MODES = JSON.stringify(allModes); process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload); @@ -352,7 +320,6 @@ async function runAgent(ctx: MainContext): Promise { return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, - githubInstallationToken: ctx.githubInstallationToken, apiKey: ctx.apiKey, cliPath: ctx.cliPath, }); diff --git a/mcp/shared.ts b/mcp/shared.ts index d92ad17..c6bb750 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { FastMCP, Tool } from "fastmcp"; import type { Payload } from "../external.ts"; -import { parseRepoContext, type RepoContext } from "../utils/github.ts"; +import { getGitHubInstallationToken, parseRepoContext, type RepoContext } from "../utils/github.ts"; export interface ToolResult { content: { @@ -28,15 +28,10 @@ export function getPayload(): Payload { } export function getMcpContext(): McpContext { - const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; - if (!githubInstallationToken) { - throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required"); - } - return { ...parseRepoContext(), octokit: new Octokit({ - auth: githubInstallationToken, + auth: getGitHubInstallationToken(), }), payload: getPayload(), }; @@ -56,9 +51,10 @@ export const addTools = (server: FastMCP, tools: Tool[]) => { return server; }; -export const contextualize = - (executor: (params: T, ctx: McpContext) => Promise>) => - async (params: T): Promise => { +export const contextualize = ( + executor: (params: T, ctx: McpContext) => Promise> +) => { + return async (params: T): Promise => { try { const ctx = getMcpContext(); const result = await executor(params, ctx); @@ -67,6 +63,7 @@ export const contextualize = return handleToolError(error); } }; +}; const handleToolSuccess = (data: Record): ToolResult => { return { diff --git a/utils/cli.ts b/utils/cli.ts index c5736c7..13febec 100644 --- a/utils/cli.ts +++ b/utils/cli.ts @@ -3,8 +3,7 @@ */ import { spawnSync } from "node:child_process"; -import { appendFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync } from "node:fs"; import * as core from "@actions/core"; import { table } from "table"; @@ -325,41 +324,8 @@ export const log = { log.info(output.trimEnd()); }, - - /** - * Log MCP tool call information to mcpLog.txt in the temp directory - */ - toolCallToFile: ({ - toolName, - request, - result, - error, - }: { - toolName: string; - request: unknown; - result?: string; - error?: string; - }): void => { - const logPath = getMcpLogPath(); - const params: Parameters[0] = { toolName, request }; - if (error) { - params.error = error; - } else if (result) { - params.result = result; - } - const logEntry = formatToolCall(params); - appendFileSync(logPath, logEntry, "utf-8"); - }, }; -/** - * Get the path to the MCP log file in the temp directory - */ -function getMcpLogPath(): string { - const tempDir = process.env.PULLFROG_TEMP_DIR!; - return join(tempDir, "mcpLog.txt"); -} - /** * Format a value as JSON, using compact format for simple values and pretty-printed for complex ones */ @@ -385,59 +351,6 @@ export function formatIndentedField(label: string, content: string): string { return formatted; } -/** - * Format the input field for a tool call - */ -function formatToolInput(request: unknown): string { - const requestFormatted = formatJsonValue(request); - if (requestFormatted === "{}") { - return ""; - } - return formatIndentedField("input", requestFormatted); -} - -/** - * Format the result field for a tool call, parsing JSON if possible - */ -function formatToolResult(result: string): string { - try { - const parsed = JSON.parse(result); - const formatted = formatJsonValue(parsed); - return formatIndentedField("result", formatted); - } catch { - // Not JSON, display as-is - return formatIndentedField("result", result); - } -} - -/** - * Format a complete tool call entry with tool name, input, result, and error - */ -function formatToolCall({ - toolName, - request, - result, - error, -}: { - toolName: string; - request: unknown; - result?: string; - error?: string; -}): string { - let logEntry = `→ ${toolName}\n`; - - logEntry += formatToolInput(request); - - if (error) { - logEntry += formatIndentedField("error", error); - } else if (result) { - logEntry += formatToolResult(result); - } - - logEntry += "\n"; - return logEntry; -} - /** * Finds a CLI executable path by checking if it's installed globally * @param name The name of the CLI executable to find diff --git a/utils/github.ts b/utils/github.ts index f1649a5..c1f9156 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -241,21 +241,37 @@ async function acquireNewToken(): Promise { } } +// Store token in memory instead of process.env +let githubInstallationToken: string | undefined; + /** * Setup GitHub installation token for the action */ export async function setupGitHubInstallationToken(): Promise { const acquiredToken = await acquireNewToken(); core.setSecret(acquiredToken); - process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken; - + githubInstallationToken = acquiredToken; return acquiredToken; } /** - * Revoke GitHub installation token + * Get the GitHub installation token from memory */ -export async function revokeInstallationToken(token: string): Promise { +export function getGitHubInstallationToken(): string { + if (!githubInstallationToken) { + throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first."); + } + return githubInstallationToken; +} + +export async function revokeGitHubInstallationToken(): Promise { + if (!githubInstallationToken) { + return; + } + + const token = githubInstallationToken; + githubInstallationToken = undefined; + const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; try {