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,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
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user