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
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:
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user