diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index 2bee2901742..59114d04796 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -1,17 +1,18 @@ --- title: Pi Coding Agent -description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — in an isolated cloud sandbox that opens a pull request, or on your own machine over SSH. +description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — opening a pull request, reviewing an existing PR, or editing files on your machine over SSH. pageType: reference --- import { BlockPreview } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it reads, edits, and runs files, then either opens a pull request or changes your files in place. It reuses your models, [skills](/agents/skills), and multi-turn [memory](#memory), and streams its progress as it works. +The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it reads, edits, and runs files, then either opens a pull request, posts a PR review, or changes your files in place. It reuses your models, [skills](/agents/skills), and multi-turn [memory](#memory), and streams its progress as it works. -It has two modes that decide *where* it runs and *how* its changes land: +It has three modes that decide *where* it runs and *how* its work lands: -- **Cloud** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**. +- **Cloud PR** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**. +- **Cloud Code Review** — checks out an existing PR in a sandbox, analyzes the changes with full codebase access, and posts a **GitHub review** (summary + optional inline comments). - **Local** — connects to your own machine over **SSH** and edits files there directly. @@ -20,15 +21,24 @@ It has two modes that decide *where* it runs and *how* its changes land: Pick the mode with the **Mode** dropdown. The fields below it change to match. -### Cloud +### Cloud PR -Cloud runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge. +Cloud PR runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge. -- Requires sandbox execution to be enabled (the Cloud option only appears when it is). +- Requires sandbox execution to be enabled (Cloud options only appear when it is). - Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox. -- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud)). +- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud-pr)). - The deliverable is a **pull request** — nothing is committed to your default branch directly. +### Cloud Code Review + +Cloud Code Review also runs in a disposable sandbox, but it does not create a PR. It fetches an existing pull request, checks out the PR head, and asks the agent to review the changes. Sim then submits one GitHub review with a summary body and optional inline line comments. + +- Same sandbox + BYOK requirements as Cloud PR. +- Needs a **GitHub token** that can clone the repo and submit reviews (see [Setup](#setup-cloud-code-review)). +- Needs the **Pull Request Number** to review. +- The deliverable is a **submitted review** — read `reviewUrl` and `commentsPosted`. + ### Local Local runs the agent against a repository on a machine you control, reached over SSH. Changes are written **in place** — there's no PR; you review them as normal git changes on that machine. @@ -41,7 +51,7 @@ Local runs the agent against a repository on a machine you control, reached over ### Task -What the agent should do, in plain language — for example *"Add input validation to the signup form and a test for it."* Insert a [connection tag](/workflows/connections) to pass an earlier output, like ``. +What the agent should do, in plain language — for example *"Add input validation to the signup form and a test for it."* or *"Review this PR for security and correctness issues."* Insert a [connection tag](/workflows/connections) to pass an earlier output, like ``. ### Model @@ -49,17 +59,25 @@ The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown l ### API Key -Your key for the chosen provider. On hosted Sim it's optional for Local runs (a hosted key is used and metered to your workspace), but **Cloud always requires your own key** — enter it in this field. For OpenAI, Anthropic, Google, and Mistral you can instead store a workspace key in **Settings → BYOK**; other providers must use this field. +Your key for the chosen provider. On hosted Sim it's optional for Local runs (a hosted key is used and metered to your workspace), but **Cloud modes always require your own key** — enter it in this field. For OpenAI, Anthropic, Google, and Mistral you can instead store a workspace key in **Settings → BYOK**; other providers must use this field. + +### Repository (Cloud PR / Cloud Code Review) + +- **Repository Owner / Repository Name** — the GitHub repo (for example `your-org` / `your-repo`). +- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode; see [Setup](#setup-cloud-pr). -### Repository (Cloud) +### Cloud PR fields -- **Repository Owner / Repository Name** — the GitHub repo to clone and open the PR against (for example `your-org` / `your-repo`). -- **GitHub Token** — a personal access token used to clone, push, and open the PR. See [Setup](#setup-cloud) for the exact permissions. - **Base Branch** — the branch the PR is opened against and cloned from. Defaults to the repository's default branch. - **Branch Name** *(advanced)* — the branch to push. Auto-generated when blank. - **Open as Draft PR** *(advanced)* — opens the PR as a draft. On by default. - **PR Title / PR Body** *(advanced)* — generated from the run when blank. +### Cloud Code Review fields + +- **Pull Request Number** — the PR to review (for example `42`). +- **Review Event** — the GitHub review action to submit: `Comment` (default), `Request changes`, or `Approve`. + ### Connection (Local) - **Host** — the public hostname or tunnel for the target machine (for example `2.tcp.ngrok.io`). Not `localhost` or a LAN address. @@ -98,7 +116,7 @@ Reuse the same **Conversation ID** across runs to continue a thread. Each turn s Memory is folded into the agent's first prompt, and two layers keep it within the model's context window: - **Sim trims before the run.** The selected memory type bounds what's injected: **Conversation** is automatically capped to a fraction of the model's context window (for models in Sim's catalog), **Sliding window (messages)** keeps the last N messages, and **Sliding window (tokens)** keeps history up to an explicit token budget. -- **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in both Cloud and Local mode, on by default. You don't need to configure anything for context growth mid-run. +- **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in all modes, on by default. You don't need to configure anything for context growth mid-run. The one case neither layer can rescue is a *first* prompt that already exceeds the window — Pi can only compact once there are older turns to summarize. This is only reachable with **Conversation** memory plus a model typed in manually (not in Sim's catalog), where the automatic cap can't look up a context window. For long histories — and whenever you use a manually entered model — choose **Sliding window (tokens)**: its budget applies regardless of the model, so the first prompt always fits. @@ -109,8 +127,10 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t | `` | The agent's final message / run summary | | `` | The files the agent changed | | `` | A unified diff of the changes | -| `` | URL of the opened pull request *(Cloud)* | -| `` | The branch pushed with the changes *(Cloud)* | +| `` | URL of the opened pull request *(Cloud PR)* | +| `` | The branch pushed with the changes *(Cloud PR)* | +| `` | URL of the submitted GitHub review *(Cloud Code Review)* | +| `` | Number of inline review comments posted *(Cloud Code Review)* | | `` | The model that ran | | `` | Token usage, an object `{ input, output, total }` | | `` | Estimated cost of the run | @@ -118,16 +138,23 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t ## Setup -### Cloud [#setup-cloud] +### Cloud PR [#setup-cloud-pr] -Cloud runs in a sandbox image with the Pi CLI and git baked in. +Cloud PR runs in a sandbox image with the Pi CLI and git baked in. -1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals the Cloud option in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. The Cloud option stays hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. +1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals the Cloud options in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. Cloud options stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. 2. **Bring your own model key.** Set the provider API key in the block's API Key field (or, for OpenAI/Anthropic/Google/Mistral, in **Settings → BYOK**). 3. **Create a GitHub token** with permission to clone, push, and open a PR: - *Fine-grained:* select the repo, then **Contents: Read and write** + **Pull requests: Read and write**. - *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. +### Cloud Code Review [#setup-cloud-code-review] + +Same sandbox and BYOK setup as Cloud PR. The GitHub token needs enough access to clone the repo and submit a review — push permission is not required: + +- *Fine-grained:* select the repo, then **Contents: Read** + **Pull requests: Read and write**. +- *Classic:* the **`repo`** scope (or a narrower token that can read contents and write pull-request reviews). For org repos, authorize the token for SSO. + ### Local [#setup-local] 1. **Enable SSH** on the target machine (on macOS: System Settings → General → Sharing → Remote Login). @@ -137,16 +164,17 @@ Cloud runs in a sandbox image with the Pi CLI and git baked in. ## Best Practices - **Scope the task.** A specific instruction ("fix the failing `auth` test and add a regression case") produces far better results than a vague one. -- **Use Cloud for hands-off PRs, Local for your working tree.** Cloud is safest for unattended changes (everything lands in a reviewable PR); Local is for iterating on a repo you already have checked out. +- **Match the mode to the deliverable.** Cloud PR for unattended changes, Cloud Code Review for feedback on an existing PR, Local for iterating on a repo you already have checked out. - **Prefer key auth and tear down tunnels.** A public SSH tunnel is a real attack surface — use a private key and stop the tunnel when you're done. - **Reuse a Conversation ID for follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index b466ef77587..48a6c848aae 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -17,6 +17,8 @@ interface PiResponse extends ToolResponse { diff?: string prUrl?: string branch?: string + reviewUrl?: string + commentsPosted?: number tokens?: { input?: number output?: number @@ -36,6 +38,14 @@ interface PiResponse extends ToolResponse { } const CLOUD: { field: 'mode'; value: 'cloud' } = { field: 'mode', value: 'cloud' } +const CLOUD_REVIEW: { field: 'mode'; value: 'cloud_review' } = { + field: 'mode', + value: 'cloud_review', +} +const CLOUD_ANY: { field: 'mode'; value: Array<'cloud' | 'cloud_review'> } = { + field: 'mode', + value: ['cloud', 'cloud_review'], +} const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens'] @@ -45,11 +55,12 @@ export const PiBlock: BlockConfig = { description: 'Run an autonomous coding agent on a repo', authMode: AuthMode.ApiKey, longDescription: - 'The Pi Coding Agent runs the Pi harness against a real repository. In Cloud mode it spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a pull request. In Local mode it edits files on your own machine over SSH. Both modes stream progress and reuse your models, skills, and multi-turn memory.', + 'The Pi Coding Agent runs the Pi harness against a real repository. Cloud PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request. Cloud Code Review checks out an existing PR and posts a structured review with optional inline comments. Local mode edits files on your own machine over SSH. All modes stream progress and reuse your models, skills, and multi-turn memory.', bestPractices: ` - - Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. + - Use Cloud PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. + - Use Cloud Code Review to analyze an existing PR and leave summary + inline review comments. - Use Local mode to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH. - - Cloud mode requires your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox. + - Cloud modes require your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox. `, category: 'blocks', integrationType: IntegrationType.AI, @@ -60,7 +71,7 @@ export const PiBlock: BlockConfig = { id: 'mode', title: 'Mode', type: 'dropdown', - // Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled. + // Cloud modes run in an E2B sandbox; only offer them where E2B is enabled. value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'), options: () => { const options = [ @@ -71,11 +82,18 @@ export const PiBlock: BlockConfig = { }, ] if (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))) { - options.unshift({ - label: 'Cloud', - id: 'cloud', - description: 'Runs in an isolated sandbox, clones your repo, and opens a PR', - }) + options.unshift( + { + label: 'Cloud PR', + id: 'cloud', + description: 'Runs in an isolated sandbox, clones your repo, and opens a PR', + }, + { + label: 'Cloud Code Review', + id: 'cloud_review', + description: 'Reviews an existing PR and posts GitHub review comments', + } + ) } return options }, @@ -106,7 +124,7 @@ export const PiBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g., your-org', required: true, - condition: CLOUD, + condition: CLOUD_ANY, }, { id: 'repo', @@ -114,7 +132,7 @@ export const PiBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g., my-repo', required: true, - condition: CLOUD, + condition: CLOUD_ANY, }, { id: 'githubToken', @@ -122,10 +140,11 @@ export const PiBlock: BlockConfig = { type: 'short-input', password: true, paramVisibility: 'user-only', - placeholder: 'GitHub personal access token (repo scope)', - tooltip: 'Personal access token with repo scope, used to clone, push, and open the PR.', + placeholder: 'GitHub personal access token', + tooltip: + 'Personal access token used for GitHub access. Cloud PR needs clone/push/PR permissions; Cloud Code Review needs clone + review permissions.', required: true, - condition: CLOUD, + condition: CLOUD_ANY, }, { id: 'baseBranch', @@ -167,6 +186,27 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: CLOUD, }, + { + id: 'pullNumber', + title: 'Pull Request Number', + type: 'short-input', + placeholder: 'e.g., 42', + required: true, + condition: CLOUD_REVIEW, + }, + { + id: 'reviewEvent', + title: 'Review Event', + type: 'dropdown', + defaultValue: 'COMMENT', + options: [ + { label: 'Comment', id: 'COMMENT' }, + { label: 'Request changes', id: 'REQUEST_CHANGES' }, + { label: 'Approve', id: 'APPROVE' }, + ], + tooltip: 'GitHub review action submitted with the agent findings.', + condition: CLOUD_REVIEW, + }, { id: 'host', @@ -336,17 +376,25 @@ export const PiBlock: BlockConfig = { access: [], }, inputs: { - mode: { type: 'string', description: 'Execution mode: cloud or local' }, + mode: { + type: 'string', + description: 'Execution mode: cloud, cloud_review, or local', + }, task: { type: 'string', description: 'Instruction for the coding agent' }, model: { type: 'string', description: 'AI model to use' }, - owner: { type: 'string', description: 'GitHub repository owner (cloud mode)' }, - repo: { type: 'string', description: 'GitHub repository name (cloud mode)' }, - githubToken: { type: 'string', description: 'GitHub token override (cloud mode)' }, - baseBranch: { type: 'string', description: 'Base branch for the PR (cloud mode)' }, - branchName: { type: 'string', description: 'Branch to create (cloud mode)' }, - draft: { type: 'boolean', description: 'Open the PR as a draft (cloud mode)' }, - prTitle: { type: 'string', description: 'Pull request title (cloud mode)' }, - prBody: { type: 'string', description: 'Pull request body (cloud mode)' }, + owner: { type: 'string', description: 'GitHub repository owner (cloud modes)' }, + repo: { type: 'string', description: 'GitHub repository name (cloud modes)' }, + githubToken: { type: 'string', description: 'GitHub token (cloud modes)' }, + baseBranch: { type: 'string', description: 'Base branch for the PR (Cloud PR)' }, + branchName: { type: 'string', description: 'Branch to create (Cloud PR)' }, + draft: { type: 'boolean', description: 'Open the PR as a draft (Cloud PR)' }, + prTitle: { type: 'string', description: 'Pull request title (Cloud PR)' }, + prBody: { type: 'string', description: 'Pull request body (Cloud PR)' }, + pullNumber: { type: 'number', description: 'Pull request number (Cloud Code Review)' }, + reviewEvent: { + type: 'string', + description: 'GitHub review event: COMMENT, REQUEST_CHANGES, or APPROVE', + }, host: { type: 'string', description: 'SSH host (local mode)' }, port: { type: 'number', description: 'SSH port (local mode)' }, username: { type: 'string', description: 'SSH username (local mode)' }, @@ -379,6 +427,16 @@ export const PiBlock: BlockConfig = { description: 'Branch pushed with the changes', condition: CLOUD, }, + reviewUrl: { + type: 'string', + description: 'URL of the submitted GitHub review', + condition: CLOUD_REVIEW, + }, + commentsPosted: { + type: 'number', + description: 'Number of inline review comments posted', + condition: CLOUD_REVIEW, + }, tokens: { type: 'json', description: 'Token usage statistics' }, cost: { type: 'json', description: 'Cost of the run' }, providerTiming: { type: 'json', description: 'Provider timing information' }, diff --git a/apps/sim/executor/handlers/pi/backend.ts b/apps/sim/executor/handlers/pi/backend.ts index 6da3c48e2b1..bce0dc6064b 100644 --- a/apps/sim/executor/handlers/pi/backend.ts +++ b/apps/sim/executor/handlers/pi/backend.ts @@ -62,7 +62,7 @@ export interface PiLocalRunParams extends PiRunBaseParams { tools: PiToolSpec[] } -/** Parameters for a cloud (E2B) Pi run. */ +/** Parameters for a cloud (E2B) Pi run that opens a PR. */ export interface PiCloudRunParams extends PiRunBaseParams { mode: 'cloud' owner: string @@ -75,7 +75,17 @@ export interface PiCloudRunParams extends PiRunBaseParams { prBody?: string } -export type PiRunParams = PiLocalRunParams | PiCloudRunParams +/** Parameters for a cloud (E2B) Pi run that reviews an existing PR. */ +export interface PiCloudReviewRunParams extends PiRunBaseParams { + mode: 'cloud_review' + owner: string + repo: string + githubToken: string + pullNumber: number + reviewEvent: 'COMMENT' | 'REQUEST_CHANGES' | 'APPROVE' +} + +export type PiRunParams = PiLocalRunParams | PiCloudRunParams | PiCloudReviewRunParams /** Progress callbacks and cancellation passed into a backend run. */ export interface PiRunContext { @@ -90,6 +100,8 @@ export interface PiRunResult { diff?: string prUrl?: string branch?: string + reviewUrl?: string + commentsPosted?: number } /** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */ diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index f635eade0d0..07c53f29f66 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -1,5 +1,5 @@ /** - * Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned + * Cloud PR backend: runs the Pi CLI inside an E2B sandbox against a cloned * GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per * command (S2/KTD10): the GitHub token is present only for the clone and push * commands (and stripped from the cloned remote), while the Pi loop runs with a @@ -15,9 +15,18 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withPiSandbox } from '@/lib/execution/e2b' import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend' +import { + CLONE_TIMEOUT_MS, + extractMarkerValues, + PI_SCRIPT, + PI_TIMEOUT_MS, + PROMPT_PATH, + REPO_DIR, + raceAbort, + scrubGitSecrets, +} from '@/executor/handlers/pi/cloud-shared' import { buildPiPrompt } from '@/executor/handlers/pi/context' import { applyPiEvent, @@ -30,16 +39,9 @@ import { executeTool } from '@/tools' const logger = createLogger('PiCloudBackend') -const REPO_DIR = '/workspace/repo' const DIFF_PATH = '/workspace/pi.diff' - -const PROMPT_PATH = '/workspace/pi-prompt.txt' const COMMIT_MSG_PATH = '/workspace/pi-commit.txt' - const PUSH_ERR_PATH = '/workspace/pi-push-err.txt' -const CLONE_TIMEOUT_MS = 10 * 60 * 1000 - -const PI_TIMEOUT_MS = getMaxExecutionTimeout() const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000 const MAX_DIFF_BYTES = 200_000 const COMMIT_TITLE_MAX = 72 @@ -68,9 +70,6 @@ echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH" git checkout -b "$BRANCH" git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"` -const PI_SCRIPT = `cd ${REPO_DIR} -pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}` - // Finalize is split so the GitHub token is in scope for ONLY the push. `git add`, // `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does // NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor` @@ -95,43 +94,6 @@ if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "_ const PUSH_SCRIPT = `cd ${REPO_DIR} git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"` -function raceAbort(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise - if (signal.aborted) return Promise.reject(new Error('Pi run aborted')) - return new Promise((resolve, reject) => { - const onAbort = () => reject(new Error('Pi run aborted')) - signal.addEventListener('abort', onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error) => { - signal.removeEventListener('abort', onAbort) - reject(error) - } - ) - }) -} - -function extractMarkerValues(stdout: string, prefix: string): string[] { - return stdout - .split('\n') - .filter((line) => line.startsWith(prefix)) - .map((line) => line.slice(prefix.length).trim()) - .filter(Boolean) -} - -/** - * Redacts the GitHub token from git output before it is surfaced in an error. - * Removes the literal token and any URL userinfo (`//user:token@`), so a failure - * message can quote git's real stderr without leaking the credential. - */ -function scrubGitSecrets(text: string, token: string): string { - const withoutToken = token ? text.split(token).join('***') : text - return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@') -} - function buildPrBody(task: string, finalText: string): string { const summary = finalText.trim() ? truncate(finalText.trim(), PR_SUMMARY_MAX) diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts new file mode 100644 index 00000000000..a2c2e3c4f47 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts @@ -0,0 +1,257 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVar } = vi.hoisted( + () => ({ + mockRun: vi.fn(), + mockReadFile: vi.fn(), + mockWriteFile: vi.fn(), + mockExecuteTool: vi.fn(), + mockProviderEnvVar: vi.fn(), + }) +) + +vi.mock('@/lib/execution/e2b', () => ({ + withPiSandbox: (fn: (runner: unknown) => unknown) => + fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }), +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/executor/handlers/pi/keys', () => ({ + providerApiKeyEnvVar: mockProviderEnvVar, + mapThinkingLevel: () => 'medium', +})) +vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' })) + +import type { PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' +import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' + +function baseParams(overrides: Partial = {}): PiCloudReviewRunParams { + return { + mode: 'cloud_review', + model: 'claude', + providerId: 'anthropic', + apiKey: 'sk-byok', + isBYOK: true, + task: 'review this PR', + skills: [], + initialMessages: [], + owner: 'octo', + repo: 'demo', + githubToken: 'ghp_secret', + pullNumber: 7, + reviewEvent: 'COMMENT', + ...overrides, + } +} + +const reviewJson = JSON.stringify({ + body: 'Overall looks solid.', + comments: [{ path: 'src/x.ts', body: 'Consider a null check', line: 12, side: 'RIGHT' }], +}) + +describe('runCloudReviewPi', () => { + beforeEach(() => { + vi.clearAllMocks() + mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY') + mockReadFile.mockResolvedValue(reviewJson) + mockExecuteTool.mockImplementation((toolId: string) => { + if (toolId === 'github_pr_v2') { + return Promise.resolve({ + success: true, + output: { + title: 'Add feature', + body: 'Does the thing', + html_url: 'https://github.com/octo/demo/pull/7', + head: { sha: 'deadbeef' }, + files: [ + { + filename: 'src/x.ts', + status: 'modified', + additions: 3, + deletions: 1, + patch: '@@ -1 +1 @@\n+hello', + }, + ], + }, + }) + } + return Promise.resolve({ + success: true, + output: { + metadata: { html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-9' }, + }, + }) + }) + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone') || command.includes('git fetch')) { + return Promise.resolve({ + stdout: '__HEAD_SHA__=deadbeef', + stderr: '', + exitCode: 0, + }) + } + if (command.includes('pi -p')) { + options.onStdout?.( + '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"reviewing"}}\n' + ) + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + ) + }) + + it('isolates secrets: token only in clone, model key only in the Pi loop, no push', async () => { + const onEvent = vi.fn() + await runCloudReviewPi(baseParams(), { onEvent }) + + expect(mockRun).toHaveBeenCalledTimes(2) + const [cloneCmd, cloneOpts] = mockRun.mock.calls[0] + const [piCmd, piOpts] = mockRun.mock.calls[1] + + expect(cloneCmd).toContain('pull/$PULL_NUMBER/head') + expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + expect(cloneOpts.envs.PULL_NUMBER).toBe('7') + + expect(piCmd).toContain('pi -p') + expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok') + expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined() + + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'reviewing' }) + }) + + it('writes prompt and PR context via files, then posts a review with comments', async () => { + const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT') + expect(mockWriteFile).toHaveBeenCalledWith( + '/workspace/pi-pr-context.md', + expect.stringContaining('Pull request #7') + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_pr_v2', + expect.objectContaining({ + owner: 'octo', + repo: 'demo', + pullNumber: 7, + apiKey: 'ghp_secret', + }) + ) + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr_review', + expect.objectContaining({ + owner: 'octo', + repo: 'demo', + pullNumber: 7, + event: 'COMMENT', + body: 'Overall looks solid.', + commit_id: 'deadbeef', + comments: [{ path: 'src/x.ts', body: 'Consider a null check', line: 12, side: 'RIGHT' }], + apiKey: 'ghp_secret', + }) + ) + expect(result.reviewUrl).toBe('https://github.com/octo/demo/pull/7#pullrequestreview-9') + expect(result.commentsPosted).toBe(1) + expect(result.prUrl).toBeUndefined() + }) + + it('submits against the checked-out SHA when the API head moved', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone') || command.includes('git fetch')) { + return Promise.resolve({ + stdout: '__HEAD_SHA__=clonedsha99', + stderr: '', + exitCode: 0, + }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + }) + + await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr_review', + expect.objectContaining({ commit_id: 'clonedsha99' }) + ) + }) + + it('treats null comments as empty and drops invalid inline comments', async () => { + mockReadFile.mockResolvedValue( + JSON.stringify({ + body: 'Summary only', + comments: [ + null, + { path: 'a.ts', body: 'missing line' }, + { path: 'b.ts', body: 'bad line', line: 0 }, + { path: 'c.ts', body: 'ok', line: 4, side: 'RIGHT' }, + ], + }) + ) + + const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr_review', + expect.objectContaining({ + body: 'Summary only', + comments: [{ path: 'c.ts', body: 'ok', line: 4, side: 'RIGHT' }], + }) + ) + expect(result.commentsPosted).toBe(1) + }) + + it('allows comments: null without aborting the review', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ body: 'No inline notes', comments: null })) + + const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr_review', + expect.objectContaining({ + body: 'No inline notes', + comments: [], + }) + ) + expect(result.commentsPosted).toBe(0) + }) + + it('rejects a non-BYOK key', async () => { + await expect( + runCloudReviewPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() }) + ).rejects.toThrow(/BYOK/) + }) + + it('fails when review JSON is missing or invalid', async () => { + mockReadFile.mockResolvedValue('not-json') + await expect(runCloudReviewPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow( + /not valid JSON/ + ) + expect( + mockExecuteTool.mock.calls.some(([toolId]: [string]) => toolId === 'github_create_pr_review') + ).toBe(false) + }) + + it('scrubs the token from clone failures', async () => { + // Avoid embedding a basic-auth URL (GitGuardian); scrubbing still covers bare tokens. + mockRun.mockResolvedValue({ + stdout: '', + stderr: 'fatal: Authentication failed for token ghp_secret', + exitCode: 1, + }) + + const error = (await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }).catch( + (e) => e + )) as Error + expect(error.message).toMatch(/git clone\/fetch PR failed/) + expect(error.message).not.toContain('ghp_secret') + }) +}) diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.ts new file mode 100644 index 00000000000..16fb3badf6c --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.ts @@ -0,0 +1,365 @@ +/** + * Cloud Code Review backend: runs the Pi CLI inside an E2B sandbox against a + * checked-out PR head, then posts a structured GitHub review (summary + optional + * inline comments). Secrets are isolated per command: the GitHub token is present + * only for the clone/fetch step, while the Pi loop runs with a BYOK model key + * only. Review posting happens on the host via executeTool (never inside the + * sandbox). The agent is read-only — no commit/push. + */ + +import { createLogger } from '@sim/logger' +import { truncate } from '@sim/utils/string' +import { withPiSandbox } from '@/lib/execution/e2b' +import type { PiBackendRun, PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' +import { + CLONE_TIMEOUT_MS, + extractMarkerValues, + PI_SCRIPT, + PI_TIMEOUT_MS, + PROMPT_PATH, + REPO_DIR, + raceAbort, + scrubGitSecrets, +} from '@/executor/handlers/pi/cloud-shared' +import { buildPiPrompt } from '@/executor/handlers/pi/context' +import { applyPiEvent, createPiTotals, parseJsonLine } from '@/executor/handlers/pi/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' +import { executeTool } from '@/tools' +import type { CreatePRReviewComment } from '@/tools/github/types' + +const logger = createLogger('PiCloudReviewBackend') + +const REVIEW_PATH = '/workspace/pi-review.json' +const PR_CONTEXT_PATH = '/workspace/pi-pr-context.md' +const MAX_CONTEXT_BYTES = 400_000 +const REVIEW_BODY_MAX = 65_000 + +const REVIEW_GUIDANCE = + 'You are reviewing an existing pull request inside an automated sandbox. ' + + 'Explore the checked-out PR branch and the PR context file at /workspace/pi-pr-context.md. ' + + 'Do not edit files, do not run git commands that modify state (commit, push, branch, remote), ' + + 'do not configure git credentials, and do not call GitHub APIs — after you finish, Sim posts ' + + 'the review for you. When done, write your findings to /workspace/pi-review.json as JSON with ' + + 'this exact shape: {"body":"","comments":[{"path":"","body":"",' + + '"line":,"side":"RIGHT"}]}. The body is required. comments is optional; omit it or use ' + + '[] when you have no inline comments. Prefer RIGHT-side line numbers from the PR diff. Keep ' + + 'comments specific and actionable.' + +const CLONE_PR_SCRIPT = `set -e +rm -rf ${REPO_DIR} +git clone --no-checkout "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} +cd ${REPO_DIR} +git fetch origin "pull/$PULL_NUMBER/head:pr-$PULL_NUMBER" +git checkout "pr-$PULL_NUMBER" +git rev-parse HEAD | sed "s/^/__HEAD_SHA__=/" +git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"` + +interface PrContext { + headSha: string + title: string + body: string + htmlUrl: string + files: Array<{ + filename?: string + status?: string + additions?: number + deletions?: number + changes?: number + patch?: string + }> +} + +interface ParsedReviewFindings { + body: string + comments: CreatePRReviewComment[] +} + +async function fetchPrContext(params: PiCloudReviewRunParams): Promise { + const result = await executeTool('github_pr_v2', { + owner: params.owner, + repo: params.repo, + pullNumber: params.pullNumber, + apiKey: params.githubToken, + }) + + if (!result.success) { + throw new Error(`Failed to fetch PR #${params.pullNumber}: ${result.error ?? 'unknown error'}`) + } + + const output = result.output as { + title?: string + body?: string | null + html_url?: string + head?: { sha?: string } + files?: PrContext['files'] + } + + const headSha = output.head?.sha?.trim() + if (!headSha) { + throw new Error(`PR #${params.pullNumber} did not include a head commit SHA`) + } + + return { + headSha, + title: output.title?.trim() || `PR #${params.pullNumber}`, + body: typeof output.body === 'string' ? output.body : '', + htmlUrl: output.html_url ?? '', + files: Array.isArray(output.files) ? output.files : [], + } +} + +function buildPrContextMarkdown(params: PiCloudReviewRunParams, pr: PrContext): string { + const fileSections = pr.files.map((file) => { + const name = file.filename || 'unknown' + const stats = `status=${file.status ?? 'unknown'} +${file.additions ?? 0}/-${file.deletions ?? 0}` + const patch = file.patch?.trim() + ? `\n\`\`\`diff\n${truncate(file.patch, 20_000)}\n\`\`\`` + : '\n_(patch omitted)_' + return `### ${name}\n${stats}${patch}` + }) + + const content = [ + `# Pull request #${params.pullNumber}`, + '', + `Title: ${pr.title}`, + pr.htmlUrl ? `URL: ${pr.htmlUrl}` : '', + `Head SHA: ${pr.headSha}`, + '', + '## Description', + '', + pr.body.trim() || '_No description_', + '', + '## Changed files', + '', + fileSections.length > 0 ? fileSections.join('\n\n') : '_No files returned_', + ] + .filter((line) => line !== '') + .join('\n') + + return content.length > MAX_CONTEXT_BYTES + ? `${content.slice(0, MAX_CONTEXT_BYTES)}\n\n[context truncated]` + : content +} + +function isPositiveInt(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 +} + +/** + * Parses agent review JSON. Invalid inline comments are skipped so a usable + * summary body can still be submitted; comments without a valid line are dropped + * because GitHub rejects line-less review comments when commit_id is set. + */ +function parseReviewFindings(raw: string): ParsedReviewFindings { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('Pi review output was not valid JSON at /workspace/pi-review.json') + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Pi review output must be a JSON object with a body field') + } + + const record = parsed as Record + if (typeof record.body !== 'string' || !record.body.trim()) { + throw new Error('Pi review output must include a non-empty body string') + } + + const comments: CreatePRReviewComment[] = [] + // Treat null/undefined as "no comments" — agents often emit null for optional fields. + if (record.comments != null) { + if (!Array.isArray(record.comments)) { + throw new Error('Pi review output comments must be an array when present') + } + for (const item of record.comments) { + if (!item || typeof item !== 'object') continue + const comment = item as Record + if (typeof comment.path !== 'string' || !comment.path.trim()) continue + if (typeof comment.body !== 'string' || !comment.body.trim()) continue + if (!isPositiveInt(comment.line)) continue + + const normalized: CreatePRReviewComment = { + path: comment.path.trim(), + body: comment.body, + line: comment.line, + side: comment.side === 'LEFT' || comment.side === 'RIGHT' ? comment.side : 'RIGHT', + } + if ( + isPositiveInt(comment.start_line) && + comment.start_line < comment.line && + (comment.start_side === undefined || + comment.start_side === 'LEFT' || + comment.start_side === 'RIGHT') + ) { + normalized.start_line = comment.start_line + if (comment.start_side === 'LEFT' || comment.start_side === 'RIGHT') { + normalized.start_side = comment.start_side + } + } + comments.push(normalized) + } + } + + return { + body: truncate(record.body.trim(), REVIEW_BODY_MAX), + comments, + } +} + +async function submitReview( + params: PiCloudReviewRunParams, + headSha: string, + findings: ParsedReviewFindings +): Promise<{ reviewUrl?: string; commentsPosted: number }> { + const result = await executeTool('github_create_pr_review', { + owner: params.owner, + repo: params.repo, + pullNumber: params.pullNumber, + event: params.reviewEvent, + body: findings.body, + commit_id: headSha, + comments: findings.comments, + apiKey: params.githubToken, + }) + + if (!result.success) { + throw new Error( + `Failed to submit review for PR #${params.pullNumber}: ${result.error ?? 'unknown error'}` + ) + } + + const output = result.output as { metadata?: { html_url?: string }; html_url?: string } + const reviewUrl = output.metadata?.html_url ?? output.html_url + return { reviewUrl, commentsPosted: findings.comments.length } +} + +export const runCloudReviewPi: PiBackendRun = async (params, context) => { + if (!params.isBYOK) { + throw new Error( + 'Cloud mode requires your own provider API key (BYOK). Set one in Settings > BYOK.' + ) + } + const keyEnvVar = providerApiKeyEnvVar(params.providerId) + if (!keyEnvVar) { + throw new Error( + `Provider "${params.providerId}" is not supported in cloud mode. Use a key-based provider or run in local mode.` + ) + } + + const pr = await fetchPrContext(params) + const prContextMarkdown = buildPrContextMarkdown(params, pr) + const prompt = buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + guidance: REVIEW_GUIDANCE, + }) + const totals = createPiTotals() + const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' + + return withPiSandbox(async (runner) => { + try { + const clone = await raceAbort( + runner.run(CLONE_PR_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + PULL_NUMBER: String(params.pullNumber), + }, + timeoutMs: CLONE_TIMEOUT_MS, + }), + context.signal + ) + if (clone.exitCode !== 0) { + throw new Error( + `git clone/fetch PR failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}` + ) + } + const clonedHead = extractMarkerValues(clone.stdout, '__HEAD_SHA__=')[0] + if (!clonedHead) { + throw new Error('PR checkout did not report a head commit') + } + + await runner.writeFile(PROMPT_PATH, prompt) + await runner.writeFile(PR_CONTEXT_PATH, prContextMarkdown) + + let buffer = '' + const handleChunk = (chunk: string) => { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const event = parseJsonLine(line) + if (!event) continue + applyPiEvent(totals, event) + context.onEvent(event) + } + } + const piRun = await raceAbort( + runner.run(PI_SCRIPT, { + envs: { + [keyEnvVar]: params.apiKey, + PI_PROVIDER: params.providerId, + PI_MODEL: params.model, + PI_THINKING: thinking, + }, + timeoutMs: PI_TIMEOUT_MS, + onStdout: handleChunk, + }), + context.signal + ) + const remaining = buffer.trim() ? parseJsonLine(buffer) : null + if (remaining) { + applyPiEvent(totals, remaining) + context.onEvent(remaining) + } + if (piRun.exitCode !== 0) { + throw new Error( + `Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim() + ) + } + if (totals.errorMessage) { + throw new Error(`Pi agent failed: ${totals.errorMessage}`) + } + + let reviewRaw: string + try { + reviewRaw = await runner.readFile(REVIEW_PATH) + } catch { + throw new Error( + 'Pi agent did not write /workspace/pi-review.json — ensure the agent ends by writing the review JSON file' + ) + } + + const findings = parseReviewFindings(reviewRaw) + if (!totals.finalText.trim()) { + totals.finalText = findings.body + } + + // Submit against the SHA we actually checked out, not the earlier API fetch — + // the PR head can move between fetchPrContext and clone. + const { reviewUrl, commentsPosted } = await submitReview(params, clonedHead, findings) + + logger.info('Pi cloud review submitted', { + owner: params.owner, + repo: params.repo, + pullNumber: params.pullNumber, + commentsPosted, + }) + + return { totals, reviewUrl, commentsPosted } + } catch (error) { + if (context.signal?.aborted) { + logger.info('Pi cloud review aborted', { + owner: params.owner, + repo: params.repo, + pullNumber: params.pullNumber, + }) + } + throw error + } + }) +} diff --git a/apps/sim/executor/handlers/pi/cloud-shared.ts b/apps/sim/executor/handlers/pi/cloud-shared.ts new file mode 100644 index 00000000000..9896b8e2374 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-shared.ts @@ -0,0 +1,52 @@ +/** + * Shared helpers for Pi cloud backends (Cloud PR and Cloud Code Review). + * Keeps E2B path constants, abort racing, marker parsing, and secret scrubbing + * in one place so the two backends cannot drift on security-sensitive details. + */ + +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' + +export const REPO_DIR = '/workspace/repo' +export const PROMPT_PATH = '/workspace/pi-prompt.txt' +export const CLONE_TIMEOUT_MS = 10 * 60 * 1000 +export const PI_TIMEOUT_MS = getMaxExecutionTimeout() + +export const PI_SCRIPT = `cd ${REPO_DIR} +pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}` + +export function raceAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise + if (signal.aborted) return Promise.reject(new Error('Pi run aborted')) + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Pi run aborted')) + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +export function extractMarkerValues(stdout: string, prefix: string): string[] { + return stdout + .split('\n') + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length).trim()) + .filter(Boolean) +} + +/** + * Redacts the GitHub token from git output before it is surfaced in an error. + * Removes the literal token and any URL userinfo (`//user:token@`), so a failure + * message can quote git's real stderr without leaking the credential. + */ +export function scrubGitSecrets(text: string, token: string): string { + const withoutToken = token ? text.split(token).join('***') : text + return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@') +} diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 17b407cc0ad..ae71f6651e0 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -143,4 +143,28 @@ describe('resolvePiModelKey', () => { ).rejects.toThrow(/your own provider API key/) expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) + + it('cloud_review mode uses the same BYOK path as cloud', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + + const result = await resolvePiModelKey({ + model: 'claude', + mode: 'cloud_review', + workspaceId: 'ws-1', + apiKey: 'sk-user', + }) + + expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true }) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + + it('cloud_review mode rejects when no user key is available', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + mockGetBYOKKey.mockResolvedValue(null) + + await expect( + resolvePiModelKey({ model: 'claude', mode: 'cloud_review', workspaceId: 'ws-1' }) + ).rejects.toThrow(/your own provider API key/) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 9d85eb8a4ee..d9f6d73d7d6 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -1,11 +1,11 @@ /** - * Model, provider-key, and cost resolution shared by both Pi backends. Local - * mode mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a - * Sim-hosted key may be used and billed. Cloud mode requires the user's own key - * (the block's API Key field, or a stored workspace BYOK key) and never a hosted - * key, since the key is handed to an untrusted sandbox. Vertex resolves through - * `resolveVertexCredential`; cost uses the billing multiplier and is zeroed for - * BYOK / non-billable models. + * Model, provider-key, and cost resolution shared by Pi backends. Local mode + * mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a + * Sim-hosted key may be used and billed. Cloud modes (Cloud PR and Cloud Code + * Review) require the user's own key (the block's API Key field, or a stored + * workspace BYOK key) and never a hosted key, since the key is handed to an + * untrusted sandbox. Vertex resolves through `resolveVertexCredential`; cost + * uses the billing multiplier and is zeroed for BYOK / non-billable models. */ import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' @@ -23,9 +23,11 @@ export interface PiKeyResolution { isBYOK: boolean } +type PiKeyMode = 'cloud' | 'cloud_review' | 'local' + interface ResolvePiModelKeyParams { model: string - mode: 'cloud' | 'local' + mode: PiKeyMode workspaceId?: string userId?: string apiKey?: string @@ -35,6 +37,10 @@ interface ResolvePiModelKeyParams { /** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */ const WORKSPACE_BYOK_PROVIDERS = new Set(['anthropic', 'openai', 'google', 'mistral']) +function isCloudSandboxMode(mode: PiKeyMode): boolean { + return mode === 'cloud' || mode === 'cloud_review' +} + /** Resolves the provider and a usable API key for the selected model. */ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise { const providerId = getProviderFromModel(params.model) @@ -51,7 +57,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis // Cloud hands the model key to an untrusted sandbox, so it must be the user's // own key — never a Sim-hosted/rotating key. Prefer the block's API Key field, // then a stored workspace BYOK key; refuse to fall back to a hosted key. - if (params.mode === 'cloud') { + if (isCloudSandboxMode(params.mode)) { if (params.apiKey) { return { providerId, apiKey: params.apiKey, isBYOK: true } } diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 3e1f951f647..c98a3c12606 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -3,9 +3,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({ +const { mockRunLocal, mockRunCloud, mockRunCloudReview, mockResolveKey } = vi.hoisted(() => ({ mockRunLocal: vi.fn(), mockRunCloud: vi.fn(), + mockRunCloudReview: vi.fn(), mockResolveKey: vi.fn(), })) @@ -23,6 +24,9 @@ vi.mock('@/executor/handlers/pi/sim-tools', () => ({ })) vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal })) vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud })) +vi.mock('@/executor/handlers/pi/cloud-review-backend', () => ({ + runCloudReviewPi: mockRunCloudReview, +})) vi.mock('@/blocks/utils', () => ({ parseOptionalNumberInput: (value: unknown) => { const parsed = Number(value) @@ -75,6 +79,11 @@ describe('PiBlockHandler', () => { changedFiles: ['a.ts'], diff: 'diff', }) + mockRunCloudReview.mockResolvedValue({ + totals: { finalText: 'looks good', inputTokens: 0, outputTokens: 0, toolCalls: [] }, + reviewUrl: 'https://github.com/o/r/pull/7#pullrequestreview-1', + commentsPosted: 2, + }) }) it('canHandle matches the pi block type', () => { @@ -88,10 +97,17 @@ describe('PiBlockHandler', () => { await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/) }) + it('throws on an invalid mode', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'spaceship', task: 'x', model: 'claude' }) + ).rejects.toThrow(/Invalid Pi mode/) + }) + it('routes local mode to the local backend with SSH params', async () => { const output = await handler.execute(ctx(), block, localInputs()) expect(mockRunLocal).toHaveBeenCalledTimes(1) expect(mockRunCloud).not.toHaveBeenCalled() + expect(mockRunCloudReview).not.toHaveBeenCalled() const params = mockRunLocal.mock.calls[0][0] expect(params.mode).toBe('local') expect(params.ssh.host).toBe('box.example.com') @@ -109,10 +125,34 @@ describe('PiBlockHandler', () => { githubToken: 'ghp', })) as Record expect(mockRunCloud).toHaveBeenCalledTimes(1) + expect(mockRunCloudReview).not.toHaveBeenCalled() expect(output.prUrl).toBe('https://github.com/o/r/pull/1') expect(output.branch).toBe('pi/abc') }) + it('routes cloud_review mode and surfaces review output', async () => { + const output = (await handler.execute(ctx(), block, { + mode: 'cloud_review', + task: 'review it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + pullNumber: '7', + reviewEvent: 'REQUEST_CHANGES', + })) as Record + + expect(mockRunCloudReview).toHaveBeenCalledTimes(1) + expect(mockRunCloud).not.toHaveBeenCalled() + const params = mockRunCloudReview.mock.calls[0][0] + expect(params.mode).toBe('cloud_review') + expect(params.pullNumber).toBe(7) + expect(params.reviewEvent).toBe('REQUEST_CHANGES') + expect(output.reviewUrl).toBe('https://github.com/o/r/pull/7#pullrequestreview-1') + expect(output.commentsPosted).toBe(2) + expect(output.content).toBe('looks good') + }) + it('requires SSH fields in local mode', async () => { await expect( handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' }) @@ -125,6 +165,19 @@ describe('PiBlockHandler', () => { ).rejects.toThrow(/Cloud mode requires/) }) + it('requires pullNumber in cloud_review mode', async () => { + await expect( + handler.execute(ctx(), block, { + mode: 'cloud_review', + task: 'x', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + }) + ).rejects.toThrow(/Cloud Code Review mode requires/) + }) + it('streams text when the block is selected for streaming output', async () => { mockRunLocal.mockImplementation(async (_params, runCtx) => { runCtx.onEvent({ type: 'text', text: 'streamed' }) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 986ab4a211c..1d12e56b070 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -12,12 +12,14 @@ import { parseOptionalNumberInput } from '@/blocks/utils' import { BlockType } from '@/executor/constants' import type { PiBackendRun, + PiCloudReviewRunParams, PiCloudRunParams, PiLocalRunParams, PiRunParams, PiRunResult, } from '@/executor/handlers/pi/backend' import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' import { appendPiMemory, loadPiMemory, @@ -38,6 +40,7 @@ import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('PiBlockHandler') const DEFAULT_MODEL = 'claude-sonnet-5' +const REVIEW_EVENTS = new Set(['COMMENT', 'REQUEST_CHANGES', 'APPROVE']) function asOptString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined @@ -65,10 +68,10 @@ export class PiBlockHandler implements BlockHandler { // Validate the mode up front so an invalid value reports a mode error rather // than a misattributed credential error from key resolution below. - if (inputs.mode !== 'cloud' && inputs.mode !== 'local') { + if (inputs.mode !== 'cloud' && inputs.mode !== 'cloud_review' && inputs.mode !== 'local') { throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) } - const mode: 'cloud' | 'local' = inputs.mode + const mode: 'cloud' | 'cloud_review' | 'local' = inputs.mode const { providerId, apiKey, isBYOK } = await resolvePiModelKey({ model, @@ -149,6 +152,37 @@ export class PiBlockHandler implements BlockHandler { return this.runPi(ctx, block, runCloudPi, params, memoryConfig) } + if (mode === 'cloud_review') { + const owner = asOptString(inputs.owner) + const repo = asOptString(inputs.repo) + const githubToken = asRawString(inputs.githubToken) + const pullNumber = parseOptionalNumberInput(inputs.pullNumber, 'pullNumber', { + integer: true, + min: 1, + }) + if (!owner || !repo || !githubToken || pullNumber === undefined) { + throw new Error( + 'Cloud Code Review mode requires repository owner, name, a GitHub token, and a pull request number' + ) + } + const reviewEventRaw = asOptString(inputs.reviewEvent) ?? 'COMMENT' + if (!REVIEW_EVENTS.has(reviewEventRaw)) { + throw new Error( + `Invalid review event: ${reviewEventRaw}. Use COMMENT, REQUEST_CHANGES, or APPROVE.` + ) + } + const params: PiCloudReviewRunParams = { + ...base, + mode: 'cloud_review', + owner, + repo, + githubToken, + pullNumber, + reviewEvent: reviewEventRaw as PiCloudReviewRunParams['reviewEvent'], + } + return this.runPi(ctx, block, runCloudReviewPi, params, memoryConfig) + } + throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) } @@ -178,6 +212,10 @@ export class PiBlockHandler implements BlockHandler { diff: result.diff ?? '', ...(result.prUrl ? { prUrl: result.prUrl } : {}), ...(result.branch ? { branch: result.branch } : {}), + ...(result.reviewUrl ? { reviewUrl: result.reviewUrl } : {}), + ...(typeof result.commentsPosted === 'number' + ? { commentsPosted: result.commentsPosted } + : {}), tokens: { input: totals.inputTokens, output: totals.outputTokens, diff --git a/apps/sim/tools/github/create_pr_review.test.ts b/apps/sim/tools/github/create_pr_review.test.ts new file mode 100644 index 00000000000..6f9d261e2d5 --- /dev/null +++ b/apps/sim/tools/github/create_pr_review.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createPRReviewTool } from '@/tools/github/create_pr_review' + +describe('createPRReviewTool request body', () => { + const base = { + owner: 'octo', + repo: 'demo', + pullNumber: 7, + event: 'COMMENT' as const, + apiKey: 'ghp_test', + } + + it('includes comments and commit_id when provided', () => { + const body = createPRReviewTool.request.body!({ + ...base, + body: 'Looks good', + commit_id: 'abc123', + comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }], + }) + + expect(body).toEqual({ + event: 'COMMENT', + body: 'Looks good', + commit_id: 'abc123', + comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }], + }) + }) + + it('parses comments from a JSON string', () => { + const body = createPRReviewTool.request.body!({ + ...base, + commit_id: 'abc123', + comments: JSON.stringify([{ path: 'a.ts', body: 'fix me', line: 1 }]) as any, + }) + + expect(body.comments).toEqual([{ path: 'a.ts', body: 'fix me', line: 1 }]) + }) + + it('requires commit_id when comments are present', () => { + expect(() => + createPRReviewTool.request.body!({ + ...base, + comments: [{ path: 'a.ts', body: 'x', line: 1 }], + }) + ).toThrow(/commit_id is required/) + }) + + it('omits comments when none are provided', () => { + const body = createPRReviewTool.request.body!({ + ...base, + body: 'summary only', + }) + + expect(body).toEqual({ event: 'COMMENT', body: 'summary only' }) + expect(body.comments).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/github/create_pr_review.ts b/apps/sim/tools/github/create_pr_review.ts index 4384205fca4..ad1ccaf92e6 100644 --- a/apps/sim/tools/github/create_pr_review.ts +++ b/apps/sim/tools/github/create_pr_review.ts @@ -1,7 +1,51 @@ -import type { CreatePRReviewParams, PRReviewResponse } from '@/tools/github/types' +import type { + CreatePRReviewComment, + CreatePRReviewParams, + PRReviewResponse, +} from '@/tools/github/types' import { USER_OUTPUT } from '@/tools/github/types' import type { ToolConfig } from '@/tools/types' +function normalizeReviewComments( + comments: CreatePRReviewParams['comments'] | string | undefined +): CreatePRReviewComment[] { + if (!comments) return [] + let parsed: unknown = comments + if (typeof comments === 'string') { + try { + parsed = JSON.parse(comments) + } catch { + throw new Error('comments must be a JSON array of inline review comments') + } + } + if (!Array.isArray(parsed)) { + throw new Error('comments must be an array of inline review comments') + } + return parsed.map((item, index) => { + if (!item || typeof item !== 'object') { + throw new Error(`comments[${index}] must be an object`) + } + const comment = item as Record + if (typeof comment.path !== 'string' || !comment.path.trim()) { + throw new Error(`comments[${index}].path is required`) + } + if (typeof comment.body !== 'string' || !comment.body.trim()) { + throw new Error(`comments[${index}].body is required`) + } + const normalized: CreatePRReviewComment = { + path: comment.path.trim(), + body: comment.body, + } + if (typeof comment.line === 'number') normalized.line = comment.line + if (comment.side === 'LEFT' || comment.side === 'RIGHT') normalized.side = comment.side + if (typeof comment.start_line === 'number') normalized.start_line = comment.start_line + if (comment.start_side === 'LEFT' || comment.start_side === 'RIGHT') { + normalized.start_side = comment.start_side + } + return normalized + }) +} + export const createPRReviewTool: ToolConfig = { id: 'github_create_pr_review', name: 'GitHub Create PR Review', @@ -44,7 +88,15 @@ export const createPRReviewTool: ToolConfig { + const comments = normalizeReviewComments(params.comments) + if (comments.length > 0 && !params.commit_id) { + throw new Error('commit_id is required when posting inline review comments') + } + const body: Record = { event: params.event, } if (params.body) body.body = params.body if (params.commit_id) body.commit_id = params.commit_id + if (comments.length > 0) body.comments = comments return body }, }, diff --git a/apps/sim/tools/github/types.ts b/apps/sim/tools/github/types.ts index 2693ecdf92c..e0aa0946b06 100644 --- a/apps/sim/tools/github/types.ts +++ b/apps/sim/tools/github/types.ts @@ -965,12 +965,24 @@ export interface RequestReviewersParams extends BaseGitHubParams { team_reviewers?: string } +/** Inline review comment attached to a submitted PR review. */ +export interface CreatePRReviewComment { + path: string + body: string + line?: number + side?: 'LEFT' | 'RIGHT' + start_line?: number + start_side?: 'LEFT' | 'RIGHT' +} + // Create PR review parameters export interface CreatePRReviewParams extends BaseGitHubParams { pullNumber: number event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT' body?: string commit_id?: string + /** Inline line comments submitted atomically with the review. */ + comments?: CreatePRReviewComment[] } // Response metadata interfaces