d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
})
|
|
}
|