chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* The seam between the Pi handler and its execution environments. The handler
* resolves keys, skills, memory, and tools, then hands a {@link PiRunParams} to
* one backend ({@link PiBackendRun}) selected by `mode`. Backends own only the
* environment-specific execution (SSH vs E2B) and report progress through
* {@link PiRunContext.onEvent}.
*/
import type { SSHConnectionConfig } from '@/app/api/tools/ssh/utils'
import type { Message } from '@/executor/handlers/agent/types'
import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/events'
/** A conversation message seeded into the Pi run (subset of the Agent block's message). */
export type PiMessage = Pick<Message, 'role' | 'content'>
/** A resolved skill (name + full content) made available to Pi. */
export interface PiSkill {
name: string
content: string
}
/** SSH connection parameters for local mode (subset of the shared SSH config). */
export type PiSshConnection = Pick<
SSHConnectionConfig,
'host' | 'port' | 'username' | 'password' | 'privateKey' | 'passphrase'
>
/** Result of invoking a tool Pi called. */
export interface PiToolResult {
text: string
isError: boolean
}
/**
* A tool exposed to Pi in a backend-neutral shape (the SSH file/bash tools and
* adapted Sim tools both use it). The local backend converts these into Pi
* `customTools`; keeping them Pi-SDK-free keeps this seam typed.
*/
export interface PiToolSpec {
name: string
description: string
parameters: Record<string, unknown>
execute: (args: Record<string, unknown>) => Promise<PiToolResult>
}
interface PiRunBaseParams {
model: string
providerId: string
apiKey: string
isBYOK: boolean
task: string
thinkingLevel?: string
skills: PiSkill[]
initialMessages: PiMessage[]
}
/** Parameters for a local (SSH) Pi run. */
export interface PiLocalRunParams extends PiRunBaseParams {
mode: 'local'
ssh: PiSshConnection
repoPath: string
tools: PiToolSpec[]
}
/** Parameters for a cloud (E2B) Pi run. */
export interface PiCloudRunParams extends PiRunBaseParams {
mode: 'cloud'
owner: string
repo: string
githubToken: string
baseBranch?: string
branchName?: string
draft: boolean
prTitle?: string
prBody?: string
}
export type PiRunParams = PiLocalRunParams | PiCloudRunParams
/** Progress callbacks and cancellation passed into a backend run. */
export interface PiRunContext {
onEvent: (event: PiEvent) => void
signal?: AbortSignal
}
/** Final result of a Pi run. */
export interface PiRunResult {
totals: PiRunTotals
changedFiles?: string[]
diff?: string
prUrl?: string
branch?: string
}
/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */
export type PiBackendRun<P extends PiRunParams = PiRunParams> = (
params: P,
context: PiRunContext
) => Promise<PiRunResult>
@@ -0,0 +1,281 @@
/**
* @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 { PiCloudRunParams } from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
function baseParams(overrides: Partial<PiCloudRunParams> = {}): PiCloudRunParams {
return {
mode: 'cloud',
model: 'claude',
providerId: 'anthropic',
apiKey: 'sk-byok',
isBYOK: true,
task: 'do it',
skills: [],
initialMessages: [],
owner: 'octo',
repo: 'demo',
githubToken: 'ghp_secret',
branchName: 'feature-x',
draft: true,
...overrides,
}
}
describe('runCloudPi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY')
mockReadFile.mockResolvedValue('diff content')
mockExecuteTool.mockResolvedValue({
success: true,
output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1' } },
})
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({
stdout: '__BASE_SHA__=abc123\n__DEFAULT_BRANCH__=main',
stderr: '',
exitCode: 0,
})
}
if (command.includes('pi -p')) {
options.onStdout?.(
'{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}\n'
)
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '__PUSHED__=1', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
})
it('isolates secrets per command: token only in clone/push, model key only in the Pi loop', async () => {
const onEvent = vi.fn()
await runCloudPi(baseParams(), { onEvent })
const [cloneCmd, cloneOpts] = mockRun.mock.calls[0]
const [piCmd, piOpts] = mockRun.mock.calls[1]
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
const [pushCmd, pushOpts] = mockRun.mock.calls[3]
expect(cloneCmd).toContain('git clone')
expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(piCmd).toContain('pi -p')
expect(piCmd).toContain('--provider')
expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok')
expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(piOpts.envs.PI_MODEL).toBe('claude')
expect(piOpts.envs.PI_PROVIDER).toBe('anthropic')
// PREPARE (add/commit/diff) must NOT carry the token: a repo-config-driven
// program the agent may have planted (clean filter, fsmonitor, textconv) runs
// on these commands and `core.hooksPath` does not stop it, so the credential
// must simply be absent.
expect(prepareCmd).toContain('add -A')
expect(prepareCmd).toContain('core.hooksPath=/dev/null')
expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(prepareOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
// PUSH is the only token-bearing command, hardened against planted git-config
// program execution (hooks, credential.helper, fsmonitor).
expect(pushCmd).toContain('push')
expect(pushCmd).toContain('core.hooksPath=/dev/null')
expect(pushCmd).toContain('credential.helper=')
expect(pushCmd).toContain('core.fsmonitor=')
expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'done' })
})
it('delivers the prompt and commit message via files, never the command line', async () => {
await runCloudPi(baseParams(), { onEvent: vi.fn() })
// Untrusted text is written through the sandbox FS API, not interpolated into a shell command.
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT')
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: do it')
const [piCmd, piOpts] = mockRun.mock.calls[1]
// Prompt arrives on stdin from a fixed path; never a CLI arg or env value.
expect(piCmd).toContain('< /workspace/pi-prompt.txt')
expect(piCmd).not.toContain('PROMPT')
expect(piOpts.envs.PI_TASK).toBeUndefined()
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
// Commit message is read from a file, not passed as -m "...".
expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt')
expect(prepareCmd).not.toContain('commit -m')
expect(prepareOpts.envs.COMMIT_MSG).toBeUndefined()
})
it('opens a PR from the pushed branch and returns its URL', async () => {
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).toHaveBeenCalledWith(
'github_create_pr',
expect.objectContaining({
owner: 'octo',
repo: 'demo',
head: 'feature-x',
base: 'main',
draft: true,
apiKey: 'ghp_secret',
})
)
expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1')
expect(result.branch).toBe('feature-x')
expect(result.changedFiles).toEqual(['src/x.ts'])
expect(result.diff).toBe('diff content')
})
it('skips the PR when nothing was pushed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 })
})
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result.prUrl).toBeUndefined()
// No changes => the token-bearing push command must never run.
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('rejects a non-BYOK key (no Sim-owned key in the sandbox)', async () => {
await expect(runCloudPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow(
/BYOK/
)
})
it('rejects providers that cannot run via a single key', async () => {
mockProviderEnvVar.mockReturnValue(null)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/not supported/)
})
it('fails when the Pi CLI exits non-zero (no PR opened)', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: 'model not found', exitCode: 1 })
}
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/Pi agent failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('does not commit, push, or open a PR when the run reports an error on a zero exit', async () => {
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
options.onStdout?.('{"type":"error","error":"model exploded"}\n')
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('fails (no PR) when finalize reports neither no-changes nor a push', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
// PREPARE aborted before emitting a marker (e.g. the repo dir vanished).
return Promise.resolve({
stdout: '',
stderr: 'cd: /workspace/repo: No such file or directory',
exitCode: 1,
})
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/finalize failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('surfaces the real git push error when the push fails, with the token scrubbed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
})
// The push step writes its stderr to a file; the backend reads + scrubs it.
mockReadFile.mockResolvedValue(
"remote: Permission to octo/demo.git denied.\nfatal: unable to access 'https://x-access-token:ghp_secret@github.com/octo/demo.git/': 403"
)
const error = (await runCloudPi(baseParams(), { onEvent: vi.fn() }).catch((e) => e)) as Error
expect(error.message).toMatch(/git push failed/)
expect(error.message).toMatch(/Permission to octo\/demo\.git denied/)
expect(error.message).not.toContain('ghp_secret')
expect(mockExecuteTool).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,353 @@
/**
* Cloud-mode 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
* BYOK model key only. The model key is never a Sim-owned hosted key (S1).
*
* Untrusted text (the assembled prompt, which folds in workspace-shared skills
* and memory, and the commit message) is never placed on a shell command line.
* It is written into sandbox files via the E2B filesystem API and read back from
* fixed paths (Pi's prompt on stdin, `git commit -F <file>`), so a collaborator-
* authored skill cannot inject shell into the Pi step where the model key lives.
*/
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 { buildPiPrompt } from '@/executor/handlers/pi/context'
import {
applyPiEvent,
createPiTotals,
type PiRunTotals,
parseJsonLine,
} from '@/executor/handlers/pi/events'
import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys'
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
const PR_SUMMARY_MAX = 2000
const PUSH_ERROR_MAX = 1000
// The agent only edits files; Sim commits, pushes, and opens the PR after the run.
// Without this, the coding agent tries to git push / open a PR / run the test
// toolchain itself and fails — the sandbox has no GitHub auth (the token is
// stripped from the remote after clone) and may lack the project's tooling.
const CLOUD_GUIDANCE =
'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' +
'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' +
'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' +
"pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " +
'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.'
const CLONE_SCRIPT = `set -e
rm -rf ${REPO_DIR}
git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR}
cd ${REPO_DIR}
if [ -n "$BASE_BRANCH" ]; then git checkout "$BASE_BRANCH"; fi
git rev-parse HEAD | sed "s/^/__BASE_SHA__=/"
DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed "s#^origin/##" || true)
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`
// (on add/diff), and `diff.external`/textconv (on diff). The untrusted Pi loop can
// plant `.gitattributes` + `.git/config` to run code during these. Keeping the
// token out of PREPARE's env means a planted program has no credential to steal;
// hooks are disabled too as defense-in-depth. Commit runs unconditionally
// (`|| true` tolerates an empty commit); the push decision is gated on HEAD
// advancing past base, so commits the agent made itself are still pushed.
const PREPARE_SCRIPT = `set -e
cd ${REPO_DIR}
git -c core.hooksPath=/dev/null add -A
git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true
git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/"
git diff "$BASE_SHA" HEAD > ${DIFF_PATH} 2>/dev/null || true
if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "__NEEDS_PUSH__=1"; fi`
// The only token-bearing command. The agent-planted `.git/config` is still active,
// so neutralize every config key that could run a program during push: hooks
// (pre-push), `credential.helper` (runs during auth), and `core.fsmonitor`.
// Filters/textconv don't run on push (no checkout/add/diff here).
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<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise
if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
return new Promise<T>((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)
: 'Automated changes by the Pi Coding Agent.'
return `## Task\n\n${task}\n\n## Summary\n\n${summary}`
}
/** The commit message and PR title share one default, derived from the PR title or task. */
function defaultTitle(params: PiCloudRunParams): string {
return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX)
}
async function openPullRequest(
params: PiCloudRunParams,
branch: string,
detectedBase: string | undefined,
totals: PiRunTotals
): Promise<string | undefined> {
const base = params.baseBranch?.trim() || detectedBase
if (!base) {
throw new Error(
`Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.`
)
}
const title = defaultTitle(params)
const body = params.prBody?.trim() || buildPrBody(params.task, totals.finalText)
const result = await executeTool('github_create_pr', {
owner: params.owner,
repo: params.repo,
title,
head: branch,
base,
body,
draft: params.draft,
apiKey: params.githubToken,
})
if (!result.success) {
throw new Error(
`Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}`
)
}
const output = result.output as { metadata?: { html_url?: string } } | undefined
return output?.metadata?.html_url
}
export const runCloudPi: PiBackendRun<PiCloudRunParams> = 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 branch = params.branchName?.trim() || `pi/${generateShortId(8)}`
const commitMessage = defaultTitle(params)
const prompt = buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: CLOUD_GUIDANCE,
})
const totals = createPiTotals()
const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium'
return withPiSandbox(async (runner) => {
try {
const clone = await raceAbort(
runner.run(CLONE_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BASE_BRANCH: params.baseBranch?.trim() ?? '',
BRANCH: branch,
},
timeoutMs: CLONE_TIMEOUT_MS,
}),
context.signal
)
if (clone.exitCode !== 0) {
throw new Error(
`git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}`
)
}
const baseSha = extractMarkerValues(clone.stdout, '__BASE_SHA__=')[0]
if (!baseSha) {
throw new Error('Clone did not report a base commit')
}
const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0]
// Deliver the prompt as a file (read back on Pi's stdin), not a CLI
// arg/env, so its skill/memory content can't be parsed by the shell that
// launches the Pi loop.
await runner.writeFile(PROMPT_PATH, prompt)
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}`)
}
// Same rationale as the prompt: keep the commit message off the command line.
await runner.writeFile(COMMIT_MSG_PATH, commitMessage)
// PREPARE stages, commits, and diffs WITHOUT the GitHub token in scope, so a
// repo-config-driven program the agent may have planted can't exfiltrate it.
const prepare = await raceAbort(
runner.run(PREPARE_SCRIPT, {
envs: { BASE_SHA: baseSha },
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=')
const noChanges = prepare.stdout.includes('__NO_CHANGES__=1')
const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1')
// PREPARE (`set -e`) emits exactly one of the two markers on success. Neither
// means the finalize step itself failed (e.g. the repo dir vanished mid-run) —
// surface that rather than silently reporting success with no push.
if (!noChanges && !needsPush) {
const reason = (prepare.stderr || prepare.stdout || 'no status reported').trim()
throw new Error(`Pi finalize failed: ${truncate(reason, PUSH_ERROR_MAX)}`)
}
let diff: string | undefined
try {
const raw = await runner.readFile(DIFF_PATH)
diff =
raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw
} catch {
diff = undefined
}
if (noChanges) {
logger.info('Pi cloud run produced no changes to push', {
owner: params.owner,
repo: params.repo,
})
return { totals, changedFiles, diff }
}
// PUSH is the only command that carries the token, hardened against any
// git-config program execution the agent may have planted.
const push = await raceAbort(
runner.run(PUSH_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BRANCH: branch,
},
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
if (!push.stdout.includes('__PUSHED__=1')) {
let reason = push.stderr?.trim()
try {
const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim()
if (pushErr) reason = pushErr
} catch {}
const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken)
throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`)
}
const prUrl = await openPullRequest(params, branch, detectedBase, totals)
return { totals, changedFiles, diff, prUrl, branch }
} catch (error) {
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (local mode mirrors this).
if (context.signal?.aborted) {
logger.info('Pi cloud run aborted', { owner: params.owner, repo: params.repo })
}
throw error
}
})
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Reuses the Agent block's skills and memory subsystems for Pi runs. Skills
* resolve to full `{ name, content }` entries (so a backend can surface them as
* Pi skills), and multi-turn memory goes through the shared `memoryService`
* keyed by `memoryType`/`conversationId` — seeding the run and persisting the
* user task plus the agent's final message.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { memoryService } from '@/executor/handlers/agent/memory'
import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver'
import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types'
import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
const logger = createLogger('PiContext')
/** Memory configuration — the Agent block's memory input fields, reused as-is. */
export type PiMemoryConfig = Pick<
AgentInputs,
'memoryType' | 'conversationId' | 'slidingWindowSize' | 'slidingWindowTokens' | 'model'
>
function isMemoryEnabled(config: PiMemoryConfig): boolean {
return !!config.memoryType && config.memoryType !== 'none'
}
/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */
export async function resolvePiSkills(
skillInputs: unknown,
workspaceId: string | undefined
): Promise<PiSkill[]> {
if (!Array.isArray(skillInputs) || !workspaceId) return []
const skills: PiSkill[] = []
for (const input of skillInputs as SkillInput[]) {
if (!input?.skillId) continue
try {
const resolved = await resolveSkillContentById(input.skillId, workspaceId)
if (resolved) skills.push({ name: resolved.name, content: resolved.content })
} catch (error) {
logger.warn('Failed to resolve skill for Pi', {
skillId: input.skillId,
error: getErrorMessage(error),
})
}
}
return skills
}
/** Loads prior conversation messages to seed the Pi run. */
export async function loadPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig
): Promise<PiMessage[]> {
if (!isMemoryEnabled(config)) return []
try {
const messages = await memoryService.fetchMemoryMessages(ctx, config)
return messages.map((message: Message) => ({ role: message.role, content: message.content }))
} catch (error) {
logger.warn('Failed to load Pi memory', { error: getErrorMessage(error) })
return []
}
}
/**
* Builds the prompt: optional operating `guidance` (mode-specific constraints),
* then skills, prior memory, and the task.
*/
export function buildPiPrompt(input: {
skills: PiSkill[]
initialMessages: PiMessage[]
task: string
guidance?: string
}): string {
const parts: string[] = []
if (input.guidance) {
parts.push(`# Operating instructions\n${input.guidance}`)
}
if (input.skills.length > 0) {
parts.push('# Available skills')
for (const skill of input.skills) {
parts.push(`## ${skill.name}\n${skill.content}`)
}
}
if (input.initialMessages.length > 0) {
parts.push('# Prior conversation')
for (const message of input.initialMessages) {
parts.push(`${message.role}: ${message.content}`)
}
}
parts.push('# Task')
parts.push(input.task)
return parts.join('\n\n')
}
/** Persists the user task and the agent's final message to memory. */
export async function appendPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig,
task: string,
finalText: string
): Promise<void> {
if (!isMemoryEnabled(config)) return
try {
await memoryService.appendToMemory(ctx, config, { role: 'user', content: task })
if (finalText) {
await memoryService.appendToMemory(ctx, config, { role: 'assistant', content: finalText })
}
} catch (error) {
logger.warn('Failed to append Pi memory', { error: getErrorMessage(error) })
}
}
@@ -0,0 +1,116 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
applyPiEvent,
createPiTotals,
normalizePiEvent,
parseJsonLine,
streamTextForEvent,
} from '@/executor/handlers/pi/events'
describe('normalizePiEvent', () => {
it('maps a text_delta message_update to a text event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'hello' },
})
).toEqual({ type: 'text', text: 'hello' })
})
it('maps a thinking_delta message_update to a thinking event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' },
})
).toEqual({ type: 'thinking', text: 'hmm' })
})
it('maps tool execution start and end', () => {
expect(normalizePiEvent({ type: 'tool_execution_start', toolName: 'bash' })).toEqual({
type: 'tool_start',
toolName: 'bash',
})
expect(
normalizePiEvent({ type: 'tool_execution_end', toolName: 'bash', isError: true })
).toEqual({
type: 'tool_end',
toolName: 'bash',
isError: true,
})
})
it('extracts usage from turn_end via message.usage and direct usage', () => {
expect(
normalizePiEvent({ type: 'turn_end', message: { usage: { input: 5, output: 7 } } })
).toEqual({ type: 'usage', inputTokens: 5, outputTokens: 7 })
expect(
normalizePiEvent({ type: 'turn_end', usage: { prompt_tokens: 3, completion_tokens: 2 } })
).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 })
})
it('maps agent_end to final and error to error', () => {
expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' })
expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({
type: 'error',
message: 'boom',
})
})
it('returns other for unknown types and null for non-objects', () => {
expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' })
expect(normalizePiEvent('nope')).toBeNull()
expect(normalizePiEvent(null)).toBeNull()
})
})
describe('parseJsonLine', () => {
it('parses a valid json line', () => {
expect(parseJsonLine('{"type":"agent_end"}')).toEqual({ type: 'final' })
})
it('returns null for blank or malformed lines', () => {
expect(parseJsonLine(' ')).toBeNull()
expect(parseJsonLine('{not json')).toBeNull()
})
})
describe('applyPiEvent', () => {
it('accumulates text, sums usage, records tool calls and errors', () => {
const totals = createPiTotals()
applyPiEvent(totals, { type: 'text', text: 'a' })
applyPiEvent(totals, { type: 'text', text: 'b' })
applyPiEvent(totals, { type: 'usage', inputTokens: 3, outputTokens: 4 })
applyPiEvent(totals, { type: 'usage', inputTokens: 1, outputTokens: 1 })
applyPiEvent(totals, { type: 'tool_end', toolName: 'read', isError: false })
applyPiEvent(totals, { type: 'error', message: 'boom' })
expect(totals.finalText).toBe('ab')
expect(totals.inputTokens).toBe(4)
expect(totals.outputTokens).toBe(5)
expect(totals.toolCalls).toEqual([{ name: 'read', isError: false }])
expect(totals.errorMessage).toBe('boom')
})
it('uses final text only when no streamed text was seen', () => {
const empty = createPiTotals()
applyPiEvent(empty, { type: 'final', text: 'fallback' })
expect(empty.finalText).toBe('fallback')
const streamed = createPiTotals()
applyPiEvent(streamed, { type: 'text', text: 'streamed' })
applyPiEvent(streamed, { type: 'final', text: 'fallback' })
expect(streamed.finalText).toBe('streamed')
})
})
describe('streamTextForEvent', () => {
it('returns text for text events and null otherwise', () => {
expect(streamTextForEvent({ type: 'text', text: 'x' })).toBe('x')
expect(streamTextForEvent({ type: 'thinking', text: 'x' })).toBeNull()
expect(streamTextForEvent({ type: 'final' })).toBeNull()
})
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Normalization layer for the Pi agent event stream. Both backends produce the
* same logical events — the local backend via the SDK `session.subscribe`
* callback, the cloud backend via `pi --mode json` stdout lines — so this module
* maps either source into a single {@link PiEvent} union and accumulates the
* run totals (final text, token usage, tool calls) the handler reports.
*/
/** A single normalized event emitted during a Pi run. */
export type PiEvent =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_start'; toolName: string }
| { type: 'tool_end'; toolName: string; isError: boolean }
| { type: 'usage'; inputTokens: number; outputTokens: number }
| { type: 'final'; text?: string }
| { type: 'error'; message: string }
| { type: 'other' }
/** A tool invocation observed during the run. */
export interface PiToolCallRecord {
name: string
isError?: boolean
}
/** Running totals accumulated across a Pi run. */
export interface PiRunTotals {
finalText: string
inputTokens: number
outputTokens: number
toolCalls: PiToolCallRecord[]
errorMessage?: string
}
/** Creates an empty totals accumulator. */
export function createPiTotals(): PiRunTotals {
return { finalText: '', inputTokens: 0, outputTokens: 0, toolCalls: [] }
}
/**
* Folds a normalized event into the totals. Text deltas accumulate into
* `finalText`; usage events sum (Pi reports per-turn usage on `turn_end`).
*/
export function applyPiEvent(totals: PiRunTotals, event: PiEvent): void {
switch (event.type) {
case 'text':
totals.finalText += event.text
break
case 'final':
if (event.text && totals.finalText.length === 0) {
totals.finalText = event.text
}
break
case 'usage':
totals.inputTokens += event.inputTokens
totals.outputTokens += event.outputTokens
break
case 'tool_end':
totals.toolCalls.push({ name: event.toolName, isError: event.isError })
break
case 'error':
totals.errorMessage = event.message
break
default:
break
}
}
/** Returns the text to enqueue onto the content stream for an event, if any. */
export function streamTextForEvent(event: PiEvent): string | null {
return event.type === 'text' ? event.text : null
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function asNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
/**
* Extracts token usage from an event, tolerating the field names Pi and common
* provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`,
* `prompt_tokens`/`completion_tokens`), checked on the event and on a nested
* `message`/`usage` object.
*/
function extractUsage(
ev: Record<string, unknown>
): { inputTokens: number; outputTokens: number } | null {
const candidates: Array<Record<string, unknown>> = []
const direct = asRecord(ev.usage)
if (direct) candidates.push(direct)
const message = asRecord(ev.message)
if (message) {
const messageUsage = asRecord(message.usage)
if (messageUsage) candidates.push(messageUsage)
}
for (const usage of candidates) {
const input =
asNumber(usage.input) || asNumber(usage.inputTokens) || asNumber(usage.prompt_tokens)
const output =
asNumber(usage.output) || asNumber(usage.outputTokens) || asNumber(usage.completion_tokens)
if (input > 0 || output > 0) {
return { inputTokens: input, outputTokens: output }
}
}
return null
}
/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */
export function normalizePiEvent(raw: unknown): PiEvent | null {
const ev = asRecord(raw)
if (!ev) return null
switch (asString(ev.type)) {
case 'message_update': {
const assistantEvent = asRecord(ev.assistantMessageEvent)
const deltaType = assistantEvent ? asString(assistantEvent.type) : ''
const delta = assistantEvent ? asString(assistantEvent.delta) : ''
if (deltaType === 'text_delta') return { type: 'text', text: delta }
if (deltaType === 'thinking_delta') return { type: 'thinking', text: delta }
return { type: 'other' }
}
case 'tool_execution_start':
return { type: 'tool_start', toolName: asString(ev.toolName) }
case 'tool_execution_end':
return { type: 'tool_end', toolName: asString(ev.toolName), isError: ev.isError === true }
case 'turn_end': {
const usage = extractUsage(ev)
return usage ? { type: 'usage', ...usage } : { type: 'other' }
}
case 'agent_end':
return { type: 'final' }
case 'error':
return {
type: 'error',
message: asString(ev.error) || asString(ev.message) || 'Pi run failed',
}
default:
return { type: 'other' }
}
}
/** Parses one `pi --mode json` stdout line into a {@link PiEvent}. */
export function parseJsonLine(line: string): PiEvent | null {
const trimmed = line.trim()
if (!trimmed) return null
try {
return normalizePiEvent(JSON.parse(trimmed))
} catch {
return null
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetApiKeyWithBYOK,
mockGetBYOKKey,
mockGetProviderFromModel,
mockCalculateCost,
mockShouldBill,
mockResolveVertex,
} = vi.hoisted(() => ({
mockGetApiKeyWithBYOK: vi.fn(),
mockGetBYOKKey: vi.fn(),
mockGetProviderFromModel: vi.fn(),
mockCalculateCost: vi.fn(),
mockShouldBill: vi.fn(),
mockResolveVertex: vi.fn(),
}))
vi.mock('@/lib/api-key/byok', () => ({
getApiKeyWithBYOK: mockGetApiKeyWithBYOK,
getBYOKKey: mockGetBYOKKey,
}))
vi.mock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
calculateCost: mockCalculateCost,
shouldBillModelUsage: mockShouldBill,
}))
vi.mock('@/executor/utils/vertex-credential', () => ({
resolveVertexCredential: mockResolveVertex,
}))
vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 }))
import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys'
describe('providerApiKeyEnvVar', () => {
it('maps key-based providers and rejects unsupported ones', () => {
expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY')
expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY')
expect(providerApiKeyEnvVar('vertex')).toBeNull()
expect(providerApiKeyEnvVar('bedrock')).toBeNull()
expect(providerApiKeyEnvVar('something-else')).toBeNull()
})
})
describe('computePiCost', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns zero cost for BYOK keys without billing', () => {
expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('returns zero cost for non-billable models', () => {
mockShouldBill.mockReturnValue(false)
expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('computes billed cost with the cost multiplier', () => {
mockShouldBill.mockReturnValue(true)
mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 })
expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 })
expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2)
})
})
describe('resolvePiModelKey', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves Vertex credentials when the provider is vertex', async () => {
mockGetProviderFromModel.mockReturnValue('vertex')
mockResolveVertex.mockResolvedValue('vertex-token')
const result = await resolvePiModelKey({
model: 'gemini-pro',
mode: 'local',
userId: 'user-1',
vertexCredential: 'cred-1',
})
expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false })
const result = await resolvePiModelKey({
model: 'claude',
mode: 'local',
workspaceId: 'ws-1',
apiKey: 'sk-test',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false })
expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test')
})
it('cloud mode uses the block API Key field directly as a BYOK key', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
const result = await resolvePiModelKey({
model: 'claude',
mode: 'cloud',
workspaceId: 'ws-1',
apiKey: 'sk-user',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
expect(mockGetBYOKKey).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key when the field is empty', async () => {
mockGetProviderFromModel.mockReturnValue('openai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true })
const result = await resolvePiModelKey({
model: 'gpt-5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key for xAI', async () => {
mockGetProviderFromModel.mockReturnValue('xai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true })
const result = await resolvePiModelKey({
model: 'grok-4.5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode rejects when no user key is available (never a hosted key)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetBYOKKey.mockResolvedValue(null)
await expect(
resolvePiModelKey({ model: 'claude', mode: 'cloud', workspaceId: 'ws-1' })
).rejects.toThrow(/your own provider API key/)
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* 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.
*/
import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent'
import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok'
import { getCostMultiplier } from '@/lib/core/config/env-flags'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { isPiSupportedProvider, type PiSupportedProvider } from '@/providers/pi-providers'
import { calculateCost, getProviderFromModel, shouldBillModelUsage } from '@/providers/utils'
import type { BYOKProviderId } from '@/tools/types'
/** Resolved provider, key, and BYOK flag for a Pi run. */
export interface PiKeyResolution {
providerId: string
apiKey: string
isBYOK: boolean
}
interface ResolvePiModelKeyParams {
model: string
mode: 'cloud' | 'local'
workspaceId?: string
userId?: string
apiKey?: string
vertexCredential?: string
}
/** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */
const WORKSPACE_BYOK_PROVIDERS = new Set<string>([
'anthropic',
'openai',
'google',
'mistral',
'xai',
])
/** Resolves the provider and a usable API key for the selected model. */
export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise<PiKeyResolution> {
const providerId = getProviderFromModel(params.model)
if (providerId === 'vertex' && params.vertexCredential) {
const apiKey = await resolveVertexCredential(
params.vertexCredential,
params.userId,
'vertex-pi'
)
return { providerId, apiKey, isBYOK: true }
}
// 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 (params.apiKey) {
return { providerId, apiKey: params.apiKey, isBYOK: true }
}
if (params.workspaceId && WORKSPACE_BYOK_PROVIDERS.has(providerId)) {
const byok = await getBYOKKey(params.workspaceId, providerId as BYOKProviderId)
if (byok) {
return { providerId, apiKey: byok.apiKey, isBYOK: true }
}
}
throw new Error(
WORKSPACE_BYOK_PROVIDERS.has(providerId)
? 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.'
: 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field.'
)
}
const { apiKey, isBYOK } = await getApiKeyWithBYOK(
providerId,
params.model,
params.workspaceId,
params.apiKey
)
return { providerId, apiKey, isBYOK }
}
/** Run cost, zeroed for BYOK keys and models Sim does not bill. */
export function computePiCost(
model: string,
inputTokens: number,
outputTokens: number,
isBYOK: boolean
) {
if (isBYOK || !shouldBillModelUsage(model)) {
return { input: 0, output: 0, total: 0 }
}
const multiplier = getCostMultiplier()
return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier)
}
/**
* Env var the Pi CLI reads each provider's key from in the cloud sandbox. Keyed
* by {@link PiSupportedProvider}, so this map and the shared support set (which
* also drives the block's model dropdown) cannot drift — adding a provider to the
* set forces adding its env var here.
*/
const PROVIDER_API_KEY_ENV_VARS: Record<PiSupportedProvider, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
xai: 'XAI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
mistral: 'MISTRAL_API_KEY',
groq: 'GROQ_API_KEY',
cerebras: 'CEREBRAS_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
}
/**
* Env var name a provider's API key is exposed under for the Pi CLI in the cloud
* sandbox, or `null` when Pi cannot run the provider via a single key. The cloud
* backend rejects `null` providers with a clear error rather than guessing.
*/
export function providerApiKeyEnvVar(providerId: string): string | null {
return isPiSupportedProvider(providerId) ? PROVIDER_API_KEY_ENV_VARS[providerId] : null
}
/** Maps a Sim thinking level to Pi's `ThinkingLevel` (shared by both backends). */
export function mapThinkingLevel(level?: string): CreateAgentSessionOptions['thinkingLevel'] {
if (!level || level === 'none') return 'off'
if (level === 'max') return 'xhigh'
if (level === 'low' || level === 'medium' || level === 'high') return level
return undefined
}
@@ -0,0 +1,204 @@
/**
* Local-mode backend: runs the Pi harness embedded in Sim with its built-in
* tools disabled and replaced by SSH-backed file/bash tools (plus any adapted
* Sim tools), all over a single reused SSH connection. The provider key stays in
* Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash
* operations cross to the target machine.
*
* The Pi SDK is imported dynamically and externalized from the bundle, mirroring
* how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime.
*/
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent'
import { createLogger } from '@sim/logger'
import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events'
import { mapThinkingLevel } from '@/executor/handlers/pi/keys'
import {
buildSshToolSpecs,
captureRepoChanges,
openSshSession,
} from '@/executor/handlers/pi/ssh-tools'
const logger = createLogger('PiLocalBackend')
const MAX_DIFF_BYTES = 200_000
// Local mode edits in place and reports the working-tree diff. The agent must not
// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR.
const LOCAL_GUIDANCE =
'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' +
'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' +
'working tree; Sim reports them after you finish.'
/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
type PiSdk = typeof import('@earendil-works/pi-coding-agent')
let sdkPromise: Promise<PiSdk> | undefined
function loadPiSdk(): Promise<PiSdk> {
if (!sdkPromise) {
// A static specifier (not a variable) is required so Next's dependency tracer
// copies the package + its transitive deps into the standalone Docker output,
// the same way `@e2b/code-interpreter` is handled. Clear the cache on failure
// so a transient import error doesn't permanently break later local runs.
sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
sdkPromise = undefined
throw error
})
}
return sdkPromise
}
function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition {
return sdk.defineTool({
name: spec.name,
label: spec.name,
description: spec.description,
// double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema
parameters: spec.parameters as unknown as ToolDefinition['parameters'],
execute: async (_toolCallId, params) => {
const result = await spec.execute(params as Record<string, unknown>)
return {
content: [{ type: 'text', text: result.text }],
details: { isError: result.isError },
}
},
})
}
/**
* Builds a model definition for a provider Pi supports but whose bundled catalog
* doesn't list this exact id (e.g. a newer model Pi wires to a different
* provider). Mirrors the cloud CLI's passthrough: clone one of the provider's
* models as a template, swap in the requested id, and force reasoning when a
* thinking level is requested. Returns undefined only when the provider has no
* models at all, so even passthrough can't route it.
*/
function buildPiFallbackModel(
modelRegistry: ModelRegistry,
provider: string,
modelId: string,
thinkingLevel: ReturnType<typeof mapThinkingLevel>
) {
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider)
if (providerModels.length === 0) return undefined
const fallback = { ...providerModels[0], id: modelId, name: modelId }
return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback
}
export const runLocalPi: PiBackendRun<PiLocalRunParams> = async (params, context) => {
// Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader
// from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings.
const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-'))
// Clean up the scratch dir if the SSH connection fails — the try/finally below
// is only entered once the session is open, so an early handshake failure would
// otherwise orphan the directory.
const session = await openSshSession(params.ssh).catch(async (error) => {
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
throw error
})
try {
const sdk = await loadPiSdk()
const authStorage = sdk.AuthStorage.create()
authStorage.setRuntimeApiKey(params.providerId, params.apiKey)
const modelRegistry = sdk.ModelRegistry.create(authStorage)
const thinkingLevel = mapThinkingLevel(params.thinkingLevel)
// Parity with cloud: when the model isn't in Pi's bundled catalog under the
// resolved provider, pass it through on that provider instead of failing.
const model =
modelRegistry.find(params.providerId, params.model) ??
buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel)
if (!model) {
throw new Error(
`Pi has no models for provider "${params.providerId}" (cannot run ${params.model})`
)
}
const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools]
const customTools = specs.map((spec) => toPiTool(sdk, spec))
const { session: agentSession } = await sdk.createAgentSession({
cwd: isolatedDir,
agentDir: isolatedDir,
model,
thinkingLevel,
noTools: 'builtin',
customTools,
authStorage,
modelRegistry,
sessionManager: sdk.SessionManager.inMemory(isolatedDir),
})
const totals = createPiTotals()
const unsubscribe = agentSession.subscribe((raw) => {
const event = normalizePiEvent(raw)
if (!event) return
applyPiEvent(totals, event)
context.onEvent(event)
})
const onAbort = () => {
void agentSession.abort()
}
if (context.signal?.aborted) {
onAbort()
} else {
context.signal?.addEventListener('abort', onAbort, { once: true })
}
let runErrorMessage: string | undefined
try {
await agentSession.prompt(
buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: LOCAL_GUIDANCE,
})
)
// Pi has no error event; a failed run surfaces on the agent state. Capture
// it before `dispose()` so the failure can't be missed by a later read.
runErrorMessage = agentSession.agent.state.errorMessage
} finally {
unsubscribe()
context.signal?.removeEventListener('abort', onAbort)
try {
agentSession.dispose()
} catch (error) {
logger.warn('Failed to dispose Pi session', { error })
}
}
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (cloud mode mirrors this).
// Pi resolves `prompt()` on abort rather than rejecting, so check explicitly.
if (context.signal?.aborted) {
throw new Error('Pi run aborted')
}
if (runErrorMessage) {
totals.errorMessage = runErrorMessage
return { totals }
}
// Local mode edits in place (no PR), so report what changed via the repo's
// working-tree diff over the same SSH session.
const { changedFiles, diff } = await captureRepoChanges(
session,
params.repoPath,
MAX_DIFF_BYTES
)
return { totals, changedFiles, diff }
} finally {
session.close()
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
}
}
@@ -0,0 +1,153 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({
mockRunLocal: vi.fn(),
mockRunCloud: vi.fn(),
mockResolveKey: vi.fn(),
}))
vi.mock('@/executor/handlers/pi/keys', () => ({
resolvePiModelKey: mockResolveKey,
computePiCost: () => ({ input: 0, output: 0, total: 0 }),
}))
vi.mock('@/executor/handlers/pi/context', () => ({
resolvePiSkills: vi.fn().mockResolvedValue([]),
loadPiMemory: vi.fn().mockResolvedValue([]),
appendPiMemory: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/executor/handlers/pi/sim-tools', () => ({
buildSimToolSpecs: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal }))
vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud }))
vi.mock('@/blocks/utils', () => ({
parseOptionalNumberInput: (value: unknown) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
},
}))
import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const block = { id: 'blk', metadata: { id: 'pi' } } as unknown as SerializedBlock
function ctx(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'wf',
workspaceId: 'ws',
userId: 'user',
...overrides,
} as ExecutionContext
}
function localInputs(extra: Record<string, unknown> = {}) {
return {
mode: 'local',
task: 'do the thing',
model: 'claude',
host: 'box.example.com',
username: 'deploy',
authMethod: 'password',
password: 'pw',
repoPath: '/srv/repo',
...extra,
}
}
describe('PiBlockHandler', () => {
const handler = new PiBlockHandler()
beforeEach(() => {
vi.clearAllMocks()
mockResolveKey.mockResolvedValue({ providerId: 'anthropic', apiKey: 'k', isBYOK: true })
mockRunLocal.mockResolvedValue({
totals: { finalText: 'hi', inputTokens: 1, outputTokens: 2, toolCalls: [] },
})
mockRunCloud.mockResolvedValue({
totals: { finalText: 'done', inputTokens: 0, outputTokens: 0, toolCalls: [] },
prUrl: 'https://github.com/o/r/pull/1',
branch: 'pi/abc',
changedFiles: ['a.ts'],
diff: 'diff',
})
})
it('canHandle matches the pi block type', () => {
expect(handler.canHandle(block)).toBe(true)
expect(
handler.canHandle({ id: 'x', metadata: { id: 'agent' } } as unknown as SerializedBlock)
).toBe(false)
})
it('throws when the task is missing', async () => {
await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/)
})
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()
const params = mockRunLocal.mock.calls[0][0]
expect(params.mode).toBe('local')
expect(params.ssh.host).toBe('box.example.com')
expect(params.repoPath).toBe('/srv/repo')
expect((output as Record<string, unknown>).content).toBe('hi')
})
it('routes cloud mode to the cloud backend and surfaces PR output', async () => {
const output = (await handler.execute(ctx(), block, {
mode: 'cloud',
task: 'do it',
model: 'claude',
owner: 'o',
repo: 'r',
githubToken: 'ghp',
})) as Record<string, unknown>
expect(mockRunCloud).toHaveBeenCalledTimes(1)
expect(output.prUrl).toBe('https://github.com/o/r/pull/1')
expect(output.branch).toBe('pi/abc')
})
it('requires SSH fields in local mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
).rejects.toThrow(/Local mode requires/)
})
it('requires repo + token in cloud mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' })
).rejects.toThrow(/Cloud 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' })
return { totals: { finalText: 'streamed', inputTokens: 0, outputTokens: 0, toolCalls: [] } }
})
const result = (await handler.execute(
ctx({ stream: true, selectedOutputs: ['blk'] }),
block,
localInputs()
)) as StreamingExecution
expect('stream' in result).toBe(true)
const reader = result.stream.getReader()
const decoder = new TextDecoder()
let text = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value)
}
expect(text).toContain('streamed')
expect(result.execution.output.content).toBe('streamed')
})
})
+262
View File
@@ -0,0 +1,262 @@
/**
* Executor handler for the Pi Coding Agent block. Resolves the model key,
* skills, and memory, selects a backend by `mode`, and runs it — streaming the
* agent's text to the client when the block is selected for streaming output,
* otherwise returning a plain block output. The handler depends only on the
* {@link PiBackendRun} seam and never reaches into backend internals.
*/
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { parseOptionalNumberInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
PiBackendRun,
PiCloudRunParams,
PiLocalRunParams,
PiRunParams,
PiRunResult,
} from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
import {
appendPiMemory,
loadPiMemory,
type PiMemoryConfig,
resolvePiSkills,
} from '@/executor/handlers/pi/context'
import { streamTextForEvent } from '@/executor/handlers/pi/events'
import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys'
import { runLocalPi } from '@/executor/handlers/pi/local-backend'
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type {
BlockHandler,
ExecutionContext,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
const DEFAULT_MODEL = 'claude-sonnet-5'
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
function asRawString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
export class PiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.PI
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
const task = asOptString(inputs.task)
if (!task) throw new Error('Task is required')
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
// 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 mode: 'cloud' | 'local' = inputs.mode
const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
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,
providerId,
apiKey,
isBYOK,
task,
thinkingLevel: asOptString(inputs.thinkingLevel),
skills,
initialMessages,
}
if (mode === 'local') {
const host = asOptString(inputs.host)
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')
}
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,
mode: 'local',
repoPath,
tools,
ssh: {
host,
port,
username,
password: usePrivateKey ? undefined : asRawString(inputs.password),
privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined,
passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined,
},
}
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)
}
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
if (!ctx.stream) return false
return (
ctx.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
}) ?? false
)
}
private buildOutput(
result: PiRunResult,
model: string,
isBYOK: boolean,
startTime: number,
startTimeISO: string
): NormalizedBlockOutput {
const { totals } = result
const endTime = Date.now()
return {
content: totals.finalText,
model,
changedFiles: result.changedFiles ?? [],
diff: result.diff ?? '',
...(result.prUrl ? { prUrl: result.prUrl } : {}),
...(result.branch ? { branch: result.branch } : {}),
tokens: {
input: totals.inputTokens,
output: totals.outputTokens,
total: totals.inputTokens + totals.outputTokens,
},
cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK),
providerTiming: {
startTime: startTimeISO,
endTime: new Date(endTime).toISOString(),
duration: endTime - startTime,
},
}
}
private async runPi<P extends PiRunParams>(
ctx: ExecutionContext,
block: SerializedBlock,
backend: PiBackendRun<P>,
params: P,
memoryConfig: PiMemoryConfig
): Promise<BlockOutput | StreamingExecution> {
const startTime = Date.now()
const startTimeISO = new Date(startTime).toISOString()
logger.info('Executing Pi block', {
blockId: block.id,
mode: params.mode,
model: params.model,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
})
if (this.isContentSelectedForStreaming(ctx, block)) {
const output: NormalizedBlockOutput = { content: '', model: params.model }
const stream = new ReadableStream<Uint8Array>({
start: async (controller) => {
const encoder = new TextEncoder()
try {
const result = await backend(params, {
onEvent: (event) => {
const text = streamTextForEvent(event)
if (text) controller.enqueue(encoder.encode(text))
},
signal: ctx.abortSignal,
})
if (result.totals.errorMessage) {
controller.error(new Error(result.totals.errorMessage))
return
}
Object.assign(
output,
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
)
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
controller.close()
} catch (error) {
controller.error(error)
}
},
})
return {
stream,
execution: {
success: true,
output,
blockId: block.id,
logs: [],
metadata: { startTime: startTimeISO, duration: 0 },
isStreaming: true,
} as StreamingExecution['execution'] & { blockId: string },
}
}
const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal })
if (result.totals.errorMessage) {
throw new Error(result.totals.errorMessage)
}
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
}
}
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({
mockTransformBlockTool: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/tools/utils', () => ({ getTool: vi.fn() }))
vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() }))
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type { ExecutionContext } from '@/executor/types'
const ctx = { workspaceId: 'ws-1' } as ExecutionContext
describe('buildSimToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('names the Pi tool with the snake_case tool id, not the human label', async () => {
// transformBlockTool returns a human label with a space, which the model
// provider rejects (tool names must match /^[a-zA-Z0-9_-]{1,128}$/).
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: {},
parameters: { type: 'object', properties: {} },
})
const specs = await buildSimToolSpecs(ctx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
expect(specs).toHaveLength(1)
expect(specs[0].name).toBe('exa_search')
expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/)
})
it('skips mcp, custom, and usage-none tools without adapting them', async () => {
const specs = await buildSimToolSpecs(ctx, [
{ type: 'mcp', usageControl: 'auto' },
{ type: 'custom-tool', usageControl: 'auto' },
{ type: 'exa', usageControl: 'none' },
])
expect(specs).toHaveLength(0)
expect(mockTransformBlockTool).not.toHaveBeenCalled()
})
it('forwards a trusted _context that an LLM-supplied _context cannot override', async () => {
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: { apiKey: 'k' },
parameters: { type: 'object', properties: {} },
})
mockExecuteTool.mockResolvedValue({ success: true, output: 'ok' })
const trustedCtx = {
workspaceId: 'ws-1',
workflowId: 'wf-1',
userId: 'user-1',
} as ExecutionContext
const [spec] = await buildSimToolSpecs(trustedCtx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
// An attacker-influenced tool arg tries to spoof the execution context.
await spec.execute({ query: 'cats', _context: { userId: 'attacker', workspaceId: 'evil' } })
const [toolId, callParams] = mockExecuteTool.mock.calls[0]
expect(toolId).toBe('exa_search')
expect(callParams._context.userId).toBe('user-1')
expect(callParams._context.workspaceId).toBe('ws-1')
expect(callParams._context.workflowId).toBe('wf-1')
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* 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
* 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.
*
* MCP and custom tools are skipped in v1; block/integration tools are supported.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAllBlocks } from '@/blocks/registry'
import type { ToolInput } from '@/executor/handlers/agent/types'
import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
import { transformBlockTool } from '@/providers/utils'
import { executeTool } from '@/tools'
import type { ToolResponse } from '@/tools/types'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
const logger = createLogger('PiSimTools')
function toToolResult(result: ToolResponse): PiToolResult {
if (result.success) {
const text =
typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {})
return { text, isError: false }
}
return { text: result.error || 'Tool execution failed', isError: true }
}
/**
* Builds the Sim tool specs exposed to Pi for a local run. Only tools the user
* added to the block are included, and `usageControl: 'none'` tools are dropped.
*/
export async function buildSimToolSpecs(
ctx: ExecutionContext,
inputTools: unknown
): Promise<PiToolSpec[]> {
if (!Array.isArray(inputTools)) return []
const specs: PiToolSpec[] = []
for (const tool of inputTools as ToolInput[]) {
if ((tool.usageControl || 'auto') === 'none') continue
if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue
try {
const provider = await transformBlockTool(tool, {
selectedOperation: tool.operation,
getAllBlocks,
getTool,
getToolAsync,
})
if (!provider?.id) continue
const toolId = provider.id
const preseededParams = provider.params || {}
specs.push({
name: toolId,
description: provider.description || '',
parameters: (provider.parameters as Record<string, unknown>) || {
type: 'object',
properties: {},
},
execute: async (args) => {
try {
const result = await executeTool(
toolId,
{
...preseededParams,
...args,
// Trusted execution context, spread last so an LLM-supplied
// `_context` arg can't override it. executeTool reads this directly
// for OAuth-credential resolution and internal-route identity, the
// same way the Agent block's tool calls do.
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
return toToolResult(result)
} catch (error) {
return { text: getErrorMessage(error, 'Tool execution failed'), isError: true }
}
},
})
} catch (error) {
logger.warn('Failed to adapt Sim tool for Pi', {
type: tool.type,
error: getErrorMessage(error),
})
}
}
return specs
}
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteSSHCommand } = vi.hoisted(() => ({
mockExecuteSSHCommand: vi.fn(),
}))
vi.mock('@/app/api/tools/ssh/utils', () => ({
createSSHConnection: vi.fn(),
executeSSHCommand: mockExecuteSSHCommand,
escapeShellArg: (value: string) => value.replace(/'/g, "'\\''"),
sanitizeCommand: (value: string) => value,
sanitizePath: (value: string) => {
if (value.split(/[/\\]/).includes('..')) {
throw new Error('Path contains invalid path traversal sequences')
}
return value.trim()
},
}))
import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools'
import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools'
function createSession(files: Record<string, string>): PiSshSession {
const sftp = {
readFile: (path: string, cb: (err: Error | undefined, data: Buffer) => void) => {
if (!(path in files)) {
cb(new Error(`no such file: ${path}`), Buffer.from(''))
return
}
cb(undefined, Buffer.from(files[path]))
},
writeFile: (path: string, data: string, cb: (err?: Error) => void) => {
files[path] = data
cb(undefined)
},
}
return {
client: {} as PiSshSession['client'],
sftp: sftp as unknown as PiSshSession['sftp'],
close: vi.fn(),
}
}
function getTool(repoPath: string, files: Record<string, string>, name: string) {
const tools = buildSshToolSpecs(createSession(files), repoPath)
const tool = tools.find((t) => t.name === name)
if (!tool) throw new Error(`tool not found: ${name}`)
return tool
}
describe('buildSshToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reads a file resolved against repoPath', async () => {
const read = getTool('/repo', { '/repo/a.txt': 'contents' }, 'read')
expect(await read.execute({ path: 'a.txt' })).toEqual({ text: 'contents', isError: false })
})
it('writes a file resolved against repoPath', async () => {
const files: Record<string, string> = {}
const write = getTool('/repo', files, 'write')
const result = await write.execute({ path: 'b.txt', content: 'hello' })
expect(result.isError).toBe(false)
expect(files['/repo/b.txt']).toBe('hello')
})
it('edits the first occurrence of old_string', async () => {
const files = { '/repo/c.txt': 'foo bar foo' }
const edit = getTool('/repo', files, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'foo', new_string: 'baz' })
expect(result.isError).toBe(false)
expect(files['/repo/c.txt']).toBe('baz bar foo')
})
it('reports an error when old_string is absent', async () => {
const edit = getTool('/repo', { '/repo/c.txt': 'nothing here' }, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'missing', new_string: 'x' })
expect(result.isError).toBe(true)
})
it('runs bash scoped to the repo directory', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: 'out', stderr: '', exitCode: 0 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'ls -la' })
expect(result).toEqual({ text: 'out', isError: false })
expect(mockExecuteSSHCommand).toHaveBeenCalledWith(expect.anything(), "cd '/repo' && ls -la")
})
it('marks a non-zero bash exit as an error', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: '', stderr: 'boom', exitCode: 2 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'false' })
expect(result.isError).toBe(true)
})
it('rejects path traversal and paths outside the repo', async () => {
const read = getTool('/repo', {}, 'read')
expect((await read.execute({ path: '../etc/passwd' })).isError).toBe(true)
expect((await read.execute({ path: '/outside/repo' })).isError).toBe(true)
})
})
+229
View File
@@ -0,0 +1,229 @@
/**
* SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2`
* connection is opened per run and reused across every tool call: `read`/`write`/
* `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory.
* All paths are sanitized and confined to the configured `repoPath` (S4).
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { Client, SFTPWrapper } from 'ssh2'
import {
createSSHConnection,
escapeShellArg,
executeSSHCommand,
sanitizeCommand,
sanitizePath,
} from '@/app/api/tools/ssh/utils'
import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
const logger = createLogger('PiSshTools')
/** An open SSH session reused for the duration of a local Pi run. */
export interface PiSshSession {
client: Client
sftp: SFTPWrapper
close: () => void
}
/** Opens one SSH connection plus an SFTP channel for the run. */
export async function openSshSession(connection: PiSshConnection): Promise<PiSshSession> {
const client = await createSSHConnection({
host: connection.host,
port: connection.port,
username: connection.username,
password: connection.password ?? null,
privateKey: connection.privateKey ?? null,
passphrase: connection.passphrase ?? null,
})
const close = () => {
try {
client.end()
} catch (error) {
logger.warn('Failed to close SSH session', { error: getErrorMessage(error) })
}
}
// The TCP/SSH connection is already open here, so close it if opening the SFTP
// channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise
// the connection is orphaned when this function throws.
try {
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, channel) => (err ? reject(err) : resolve(channel)))
})
return { client, sftp, close }
} catch (error) {
close()
throw error
}
}
function readRemoteFile(sftp: SFTPWrapper, path: string): Promise<string> {
return new Promise((resolve, reject) => {
sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8'))))
})
}
function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve()))
})
}
/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */
function resolveRepoPath(repoPath: string, candidate: string): string {
const clean = sanitizePath(candidate)
const root = repoPath.replace(/\/+$/, '')
if (clean.startsWith('/')) {
if (clean !== root && !clean.startsWith(`${root}/`)) {
throw new Error(`Path is outside the repository: ${candidate}`)
}
return clean
}
return `${root}/${clean}`
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
async function guard(run: () => Promise<PiToolResult>): Promise<PiToolResult> {
try {
return await run()
} catch (error) {
return { text: getErrorMessage(error, 'SSH tool failed'), isError: true }
}
}
/**
* 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
* 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
* missing, non-zero exit).
*/
export async function captureRepoChanges(
session: PiSshSession,
repoPath: string,
maxDiffBytes: number
): Promise<{ changedFiles: string[]; diff: string }> {
const scoped = `cd '${escapeShellArg(repoPath)}'`
try {
const tracked = await executeSSHCommand(
session.client,
`${scoped} && git diff --name-only HEAD`
)
const untracked = await executeSSHCommand(
session.client,
`${scoped} && git ls-files --others --exclude-standard`
)
const fileSet = new Set<string>()
for (const result of [tracked, untracked]) {
if (result.exitCode !== 0) continue
for (const line of result.stdout.split('\n')) {
const file = line.trim()
if (file) fileSet.add(file)
}
}
const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`)
const out = raw.exitCode === 0 ? raw.stdout : ''
const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out
return { changedFiles: [...fileSet], diff }
} catch {
return { changedFiles: [], diff: '' }
}
}
/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */
export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] {
const { client, sftp } = session
return [
{
name: 'read',
description: 'Read the full contents of a file in the repository.',
parameters: {
type: 'object',
properties: { path: { type: 'string', description: 'File path within the repository' } },
required: ['path'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path))
return { text: content, isError: false }
}),
},
{
name: 'write',
description: 'Write (create or overwrite) a file in the repository.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
content: { type: 'string', description: 'Full file contents to write' },
},
required: ['path', 'content'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const resolved = resolveRepoPath(repoPath, path)
await writeRemoteFile(sftp, resolved, asString(args.content))
return { text: `Wrote ${resolved}`, isError: false }
}),
},
{
name: 'edit',
description: 'Replace the first occurrence of old_string with new_string in a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
old_string: { type: 'string', description: 'Exact text to replace' },
new_string: { type: 'string', description: 'Replacement text' },
},
required: ['path', 'old_string', 'new_string'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const oldString = asString(args.old_string)
const resolved = resolveRepoPath(repoPath, path)
const current = await readRemoteFile(sftp, resolved)
if (!current.includes(oldString)) {
return { text: `old_string not found in ${resolved}`, isError: true }
}
const updated = current.replace(oldString, asString(args.new_string))
await writeRemoteFile(sftp, resolved, updated)
return { text: `Edited ${resolved}`, isError: false }
}),
},
{
name: 'bash',
description: 'Run a shell command in the repository directory and return its output.',
parameters: {
type: 'object',
properties: { command: { type: 'string', description: 'Shell command to run' } },
required: ['command'],
},
execute: (args) =>
guard(async () => {
const command = asString(args.command)
if (!command) return { text: 'command is required', isError: true }
const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}`
const result = await executeSSHCommand(client, scoped)
const text = [result.stdout, result.stderr].filter(Boolean).join('\n')
return {
text: text || `Exited with code ${result.exitCode}`,
isError: result.exitCode !== 0,
}
}),
},
]
}