Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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
}
})
}