+
+ 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(params).not.toHaveProperty('skills')
+ expect(params).not.toHaveProperty('initialMessages')
+ expect(mockResolveSkills).not.toHaveBeenCalled()
+ expect(mockLoadMemory).not.toHaveBeenCalled()
+ expect(mockAppendMemory).not.toHaveBeenCalled()
+ 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 Dev', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
- ).rejects.toThrow(/Local mode requires/)
+ ).rejects.toThrow(/Local Dev requires/)
})
- it('requires repo + token in cloud mode', async () => {
+ it('requires repo + token in Create PR', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' })
- ).rejects.toThrow(/Cloud mode requires/)
+ ).rejects.toThrow(/Create PR 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(/Review Code requires/)
+ })
+
+ it.each(['0', '-1', '1.5'])('rejects invalid pull request number %s', async (pullNumber) => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ pullNumber,
+ })
+ ).rejects.toThrow(/pullNumber/)
+ })
+
+ it('rejects autonomous approval reviews', async () => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ pullNumber: '7',
+ reviewEvent: 'APPROVE',
+ })
+ ).rejects.toThrow(/COMMENT or REQUEST_CHANGES/)
+ expect(mockRunCloudReview).not.toHaveBeenCalled()
})
it('streams text when the block is selected for streaming output', async () => {
diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts
index 986ab4a211c..a46637c70bd 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,
@@ -34,10 +36,13 @@ import type {
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
+import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers'
+import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
-const DEFAULT_MODEL = 'claude-sonnet-5'
+const DEFAULT_MODEL = 'claude-sonnet-4-6'
+const REVIEW_EVENTS = ['COMMENT', 'REQUEST_CHANGES'] as const
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
@@ -49,6 +54,15 @@ function asRawString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
+function isReviewEvent(value: string): value is PiCloudReviewRunParams['reviewEvent'] {
+ return REVIEW_EVENTS.some((event) => event === value)
+}
+
+function parsePiMode(value: unknown): PiRunParams['mode'] {
+ if (value === 'cloud' || value === 'cloud_review' || value === 'local') return value
+ throw new Error(`Invalid Pi mode: ${String(value)}`)
+}
+
export class PiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.PI
@@ -62,42 +76,77 @@ export class PiBlockHandler implements BlockHandler {
const task = asOptString(inputs.task)
if (!task) throw new Error('Task is required')
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
+ const mode = parsePiMode(inputs.mode)
- // 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') {
- throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
+ const providerId = getProviderFromModel(model)
+ if (!isPiSupportedProvider(providerId)) {
+ throw new Error(`Pi provider "${providerId}" is not supported`)
+ }
+ const piModel = resolvePiModelId(providerId, model)
+ if (!piModel) {
+ throw new Error(
+ `Pi model "${model}" is not available for provider "${providerId}" in the installed Pi catalog`
+ )
}
- const mode: 'cloud' | 'local' = inputs.mode
- const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
+ const { apiKey, isBYOK } = await resolvePiModelKey({
+ providerId,
model,
mode,
workspaceId: ctx.workspaceId,
- userId: ctx.userId,
apiKey: asRawString(inputs.apiKey),
- vertexCredential: asOptString(inputs.vertexCredential),
})
- const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId)
- const memoryConfig: PiMemoryConfig = {
- memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
- conversationId: asOptString(inputs.conversationId),
- slidingWindowSize: asOptString(inputs.slidingWindowSize),
- slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
- model,
- }
- const initialMessages = await loadPiMemory(ctx, memoryConfig)
-
const base = {
model,
+ piModel,
providerId,
apiKey,
isBYOK,
task,
thinkingLevel: asOptString(inputs.thinkingLevel),
- skills,
- initialMessages,
+ }
+
+ 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(
+ 'Review Code requires repository owner, name, a GitHub token, and a pull request number'
+ )
+ }
+ const reviewEventRaw = asOptString(inputs.reviewEvent) ?? 'COMMENT'
+ if (!isReviewEvent(reviewEventRaw)) {
+ throw new Error(`Invalid review event: ${reviewEventRaw}. Use COMMENT or REQUEST_CHANGES.`)
+ }
+ const params: PiCloudReviewRunParams = {
+ ...base,
+ mode: 'cloud_review',
+ owner,
+ repo,
+ githubToken,
+ pullNumber,
+ reviewEvent: reviewEventRaw,
+ }
+ return this.runPi(ctx, block, runCloudReviewPi, params)
+ }
+
+ const memoryConfig: PiMemoryConfig = {
+ memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
+ conversationId: asOptString(inputs.conversationId),
+ slidingWindowSize: asOptString(inputs.slidingWindowSize),
+ slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
+ model,
+ }
+ const contextualBase = {
+ ...base,
+ skills: await resolvePiSkills(inputs.skills, ctx.workspaceId),
+ initialMessages: await loadPiMemory(ctx, memoryConfig),
}
if (mode === 'local') {
@@ -105,13 +154,13 @@ export class PiBlockHandler implements BlockHandler {
const username = asOptString(inputs.username)
const repoPath = asOptString(inputs.repoPath)
if (!host || !username || !repoPath) {
- throw new Error('Local mode requires host, username, and repository path')
+ throw new Error('Local Dev requires host, username, and repository path')
}
const usePrivateKey = inputs.authMethod === 'privateKey'
const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22
const tools = await buildSimToolSpecs(ctx, inputs.tools)
const params: PiLocalRunParams = {
- ...base,
+ ...contextualBase,
mode: 'local',
repoPath,
tools,
@@ -127,29 +176,25 @@ export class PiBlockHandler implements BlockHandler {
return this.runPi(ctx, block, runLocalPi, params, memoryConfig)
}
- if (mode === 'cloud') {
- const owner = asOptString(inputs.owner)
- const repo = asOptString(inputs.repo)
- const githubToken = asRawString(inputs.githubToken)
- if (!owner || !repo || !githubToken) {
- throw new Error('Cloud mode requires repository owner, name, and a GitHub token')
- }
- const params: PiCloudRunParams = {
- ...base,
- mode: 'cloud',
- owner,
- repo,
- githubToken,
- baseBranch: asOptString(inputs.baseBranch),
- branchName: asOptString(inputs.branchName),
- draft: inputs.draft !== false,
- prTitle: asOptString(inputs.prTitle),
- prBody: asOptString(inputs.prBody),
- }
- return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
+ const owner = asOptString(inputs.owner)
+ const repo = asOptString(inputs.repo)
+ const githubToken = asRawString(inputs.githubToken)
+ if (!owner || !repo || !githubToken) {
+ throw new Error('Create PR requires repository owner, name, and a GitHub token')
}
-
- throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
+ const params: PiCloudRunParams = {
+ ...contextualBase,
+ mode: 'cloud',
+ owner,
+ repo,
+ githubToken,
+ baseBranch: asOptString(inputs.baseBranch),
+ branchName: asOptString(inputs.branchName),
+ draft: inputs.draft !== false,
+ prTitle: asOptString(inputs.prTitle),
+ prBody: asOptString(inputs.prBody),
+ }
+ return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
}
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
@@ -178,6 +223,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,
@@ -197,7 +246,7 @@ export class PiBlockHandler implements BlockHandler {
block: SerializedBlock,
backend: PiBackendRun,
params: P,
- memoryConfig: PiMemoryConfig
+ memoryConfig?: PiMemoryConfig
): Promise {
const startTime = Date.now()
const startTimeISO = new Date(startTime).toISOString()
@@ -231,7 +280,9 @@ export class PiBlockHandler implements BlockHandler {
output,
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
)
- await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ if (memoryConfig) {
+ await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ }
controller.close()
} catch (error) {
controller.error(error)
@@ -256,7 +307,9 @@ export class PiBlockHandler implements BlockHandler {
if (result.totals.errorMessage) {
throw new Error(result.totals.errorMessage)
}
- await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ if (memoryConfig) {
+ await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ }
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
}
}
diff --git a/apps/sim/executor/handlers/pi/pi-sdk.ts b/apps/sim/executor/handlers/pi/pi-sdk.ts
new file mode 100644
index 00000000000..7f6c0146f50
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/pi-sdk.ts
@@ -0,0 +1,56 @@
+import { InMemoryCredentialStore } from '@earendil-works/pi-ai'
+import type { ModelRuntime, ResourceLoader } from '@earendil-works/pi-coding-agent'
+
+/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
+export type PiSdk = typeof import('@earendil-works/pi-coding-agent')
+
+let sdkPromise: Promise | undefined
+
+/** Loads the Pi SDK while preserving Next.js standalone dependency tracing. */
+export function loadPiSdk(): Promise {
+ if (!sdkPromise) {
+ sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
+ sdkPromise = undefined
+ throw error
+ })
+ }
+ return sdkPromise
+}
+
+/** Creates a host-only Pi model runtime without reading credentials or models from disk. */
+export function createPiModelRuntime(sdk: PiSdk): Promise {
+ return sdk.ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath: null,
+ allowModelNetwork: false,
+ })
+}
+
+/** Resolves only model definitions that the installed Pi SDK declares exactly. */
+export function resolvePiSdkModel(modelRuntime: ModelRuntime, provider: string, modelId: string) {
+ return modelRuntime.getModel(provider, modelId)
+}
+
+/**
+ * Creates an isolated resource-discovery boundary for untrusted repositories. No project
+ * files, extensions, skills, prompt templates, themes, or settings are loaded.
+ */
+export function createSealedPiResourceLoader(sdk: PiSdk, systemPrompt: string): ResourceLoader {
+ const extensions = {
+ extensions: [],
+ errors: [],
+ runtime: sdk.createExtensionRuntime(),
+ }
+
+ return {
+ getExtensions: () => extensions,
+ getSkills: () => ({ skills: [], diagnostics: [] }),
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
+ getThemes: () => ({ themes: [], diagnostics: [] }),
+ getAgentsFiles: () => ({ agentsFiles: [] }),
+ getSystemPrompt: () => systemPrompt,
+ getAppendSystemPrompt: () => [],
+ extendResources: () => {},
+ reload: async () => {},
+ }
+}
diff --git a/apps/sim/executor/handlers/pi/redaction.test.ts b/apps/sim/executor/handlers/pi/redaction.test.ts
new file mode 100644
index 00000000000..529deaf9b2e
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/redaction.test.ts
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ createScrubbedPiError,
+ getScrubbedPiErrorMessage,
+ scrubPiEvent,
+ scrubPiSecrets,
+} from '@/executor/handlers/pi/redaction'
+
+describe('Pi secret redaction', () => {
+ it('redacts literal and URL-encoded secret representations', () => {
+ expect(
+ scrubPiSecrets('literal sk-hosted/secret encoded sk-hosted%2Fsecret', ['sk-hosted/secret'])
+ ).toBe('literal *** encoded ***')
+ })
+
+ it('redacts longer overlapping secrets before their prefixes', () => {
+ expect(scrubPiSecrets('ghp_secret and ghp_', ['ghp_', 'ghp_secret'])).toBe('*** and ***')
+ })
+
+ it('redacts all string-bearing Pi event variants', () => {
+ expect(scrubPiEvent({ type: 'thinking', text: 'saw sk-hosted' }, ['sk-hosted'])).toEqual({
+ type: 'thinking',
+ text: 'saw ***',
+ })
+ expect(
+ scrubPiEvent({ type: 'tool_end', toolName: 'sk-hosted', isError: true }, ['sk-hosted'])
+ ).toEqual({ type: 'tool_end', toolName: '***', isError: true })
+ expect(scrubPiEvent({ type: 'error', message: 'failed sk-hosted' }, ['sk-hosted'])).toEqual({
+ type: 'error',
+ message: 'failed ***',
+ })
+ })
+
+ it('creates sanitized errors without retaining the raw cause', () => {
+ const raw = new Error('provider exposed sk-hosted')
+ const scrubbed = createScrubbedPiError(raw, ['sk-hosted'])
+
+ expect(getScrubbedPiErrorMessage(raw, ['sk-hosted'])).toBe('provider exposed ***')
+ expect(scrubbed.message).toBe('provider exposed ***')
+ expect(scrubbed.cause).toBeUndefined()
+ expect(String(scrubbed.stack)).not.toContain('sk-hosted')
+ })
+})
diff --git a/apps/sim/executor/handlers/pi/redaction.ts b/apps/sim/executor/handlers/pi/redaction.ts
new file mode 100644
index 00000000000..904d9ae9f8d
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/redaction.ts
@@ -0,0 +1,51 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import type { PiEvent } from '@/executor/handlers/pi/events'
+
+/** Redacts exact secret values and their URL-encoded forms from surfaced text. */
+export function scrubPiSecrets(text: string, secrets: readonly string[]): string {
+ let scrubbed = text
+ const representations = new Set(
+ secrets.flatMap((secret) => (secret ? [secret, encodeURIComponent(secret)] : []))
+ )
+ for (const representation of [...representations].sort(
+ (left, right) => right.length - left.length
+ )) {
+ scrubbed = scrubbed.split(representation).join('***')
+ }
+ return scrubbed
+}
+
+/** Redacts secrets from every string-bearing normalized Pi event. */
+export function scrubPiEvent(event: PiEvent | null, secrets: readonly string[]): PiEvent | null {
+ if (!event) return event
+ switch (event.type) {
+ case 'text':
+ case 'thinking':
+ return { ...event, text: scrubPiSecrets(event.text, secrets) }
+ case 'tool_start':
+ case 'tool_end':
+ return { ...event, toolName: scrubPiSecrets(event.toolName, secrets) }
+ case 'error':
+ return { ...event, message: scrubPiSecrets(event.message, secrets) }
+ default:
+ return event
+ }
+}
+
+/** Extracts an unknown error message without allowing exact secrets to escape. */
+export function getScrubbedPiErrorMessage(
+ error: unknown,
+ secrets: readonly string[],
+ fallback = 'Pi run failed'
+): string {
+ return scrubPiSecrets(getErrorMessage(error, fallback), secrets)
+}
+
+/** Creates a boundary-safe error without retaining a potentially secret-bearing cause. */
+export function createScrubbedPiError(
+ error: unknown,
+ secrets: readonly string[],
+ fallback?: string
+): Error {
+ return new Error(getScrubbedPiErrorMessage(error, secrets, fallback))
+}
diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts
index 0fb6a3b632e..8956b4a7dcb 100644
--- a/apps/sim/executor/handlers/pi/sim-tools.ts
+++ b/apps/sim/executor/handlers/pi/sim-tools.ts
@@ -1,6 +1,6 @@
/**
* Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that
- * Pi can call in local mode. Each spec carries the tool's JSON-schema parameters
+ * Pi can call in Local Dev. Each spec carries the tool's JSON-schema parameters
* and an `execute` that runs the real Sim tool through `executeTool`, so the
* agent's calls go through the same credential-access checks as any block.
*
diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/ssh-tools.ts
index c625ba7bcb3..0fa471e2190 100644
--- a/apps/sim/executor/handlers/pi/ssh-tools.ts
+++ b/apps/sim/executor/handlers/pi/ssh-tools.ts
@@ -98,7 +98,7 @@ async function guard(run: () => Promise): Promise {
/**
* Best-effort working-tree snapshot of the repo over the run's SSH session, for
- * the block's `changedFiles`/`diff` outputs — Local mode edits in place rather
+ * the block's `changedFiles`/`diff` outputs — Local Dev edits in place rather
* than opening a PR. `changedFiles` covers both tracked modifications and untracked
* (newly created) files so files the agent created are reported; `diff` reflects
* tracked changes against HEAD. Returns empty on any failure (not a git repo, git
diff --git a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
index b3fd9a6c58a..a7588ba7bac 100644
--- a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
+++ b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
@@ -37,103 +37,146 @@ interface UseMcpOauthPopupProps {
workspaceId: string
}
+/**
+ * Bounds how long a row shows "Connecting…" without a result. Matches the server-side OAuth
+ * start TTL: once it lapses the authorization state has expired and the flow can no longer
+ * complete, so a still-pending flow is safe to drop.
+ */
+const OAUTH_FLOW_TIMEOUT_MS = 10 * 60 * 1000
+
export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
const queryClient = useQueryClient()
const { mutateAsync: startOauth } = useStartMcpOauth()
const [connectingServers, setConnectingServers] = useState>(() => new Set())
- const popupIntervalsRef = useRef