6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
208 lines
10 KiB
TypeScript
208 lines
10 KiB
TypeScript
/**
|
|
* Shared helpers for the Pi sandbox backends.
|
|
* Keeps E2B path constants, the finalize/push scripts, abort racing, marker
|
|
* parsing, and credential-bearing git diagnostic redaction in one place so backends cannot drift on
|
|
* security-sensitive details.
|
|
*/
|
|
|
|
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
|
import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime'
|
|
import { PI_EVENT_FILTER_PATH } from '@/executor/handlers/pi/cloud/event-filter-source'
|
|
import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction'
|
|
|
|
export const REPO_DIR = '/workspace/repo'
|
|
export const PROMPT_PATH = '/workspace/pi-prompt.txt'
|
|
export const DIFF_PATH = '/workspace/pi.diff'
|
|
export const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
|
|
export const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
|
|
export const CLONE_TIMEOUT_MS = 10 * 60 * 1000
|
|
export const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
|
|
export const MAX_DIFF_BYTES = 200_000
|
|
export const PUSH_ERROR_MAX = 1000
|
|
|
|
/**
|
|
* Floor for {@link resolvePiTimeoutMs}. Reachable whenever the sandbox lifetime
|
|
* is too short to reserve every surrounding command's worst-case ceiling —
|
|
* either a configured lifetime, or a run whose own execution deadline is shorter
|
|
* than those reserves. Such a run can still finish, since those ceilings are
|
|
* pessimistic, so the floor leaves a short turn rather than refusing one.
|
|
*/
|
|
export const MIN_PI_TIMEOUT_MS = 60 * 1000
|
|
|
|
/**
|
|
* How long one Pi CLI invocation may run, given the lifetime its sandbox was
|
|
* created with. Without a cap a hung CLI would sit there until the provider
|
|
* reaped the sandbox and surface as an opaque SDK error.
|
|
*
|
|
* The reserve matters as much as the cap. The sandbox clock starts at create,
|
|
* and authoring has three commands around the agent turn: the clone before it,
|
|
* then the commit and push after it, the last two sharing
|
|
* {@link FINALIZE_TIMEOUT_MS}. Plan has no finalize phase and sets that reserve
|
|
* to zero. Capping at the bare lifetime would mean the sandbox died first.
|
|
*
|
|
* Takes the lifetime as an argument rather than reading the provider ceiling
|
|
* itself, because that ceiling is no longer the only lifetime a run can get: a
|
|
* caller that narrowed it to the execution deadline has to reserve against the
|
|
* lifetime it actually asked for, or it re-opens exactly the bug above on every
|
|
* plan whose deadline is shorter than the ceiling.
|
|
*
|
|
* What is reserved is each command's timeout ceiling, not its measured elapsed
|
|
* time — a clone takes seconds in practice — so this is a budget that adds up,
|
|
* not a guarantee that the sandbox outlives the run.
|
|
*
|
|
* Both adapters impose an absolute lifetime: E2B through `timeoutMs` and Daytona
|
|
* through `ttlMinutes`. Reserving the surrounding commands keeps the agent turn
|
|
* inside the lifetime the selected provider actually received.
|
|
*/
|
|
export function resolvePiTimeoutMs(
|
|
lifetimeMs = resolvePiSandboxLifetimeMs(),
|
|
options?: { finalizePhases?: number }
|
|
): number {
|
|
const finalizePhases = options?.finalizePhases ?? 2
|
|
return Math.min(
|
|
getMaxExecutionTimeout(),
|
|
Math.max(
|
|
lifetimeMs - CLONE_TIMEOUT_MS - finalizePhases * FINALIZE_TIMEOUT_MS,
|
|
MIN_PI_TIMEOUT_MS
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Marker carrying a digest of the cloned repository's git config. A clone script
|
|
* emits it as its *last* line, after any `git remote set-url` rewrite — a digest
|
|
* taken before that rewrite mismatches at push time and every push fails.
|
|
*
|
|
* Every phase that clones in order to push emits it. The Babysit continuation
|
|
* verifies it deliberately alone, because verification is not a pure
|
|
* tightening — a run that legitimately writes repo-local config would fail its
|
|
* push.
|
|
*/
|
|
export const GIT_CONFIG_DIGEST_MARKER = '__GIT_CONFIG_DIGEST__='
|
|
|
|
/**
|
|
* Digests the only git-config scope a sandbox agent can still write once
|
|
* `GIT_CONFIG_NOSYSTEM` and `GIT_CONFIG_GLOBAL` neutralize the system and global
|
|
* scopes. One comparison covers every dangerous key — `url.*.insteadOf`,
|
|
* `url.*.pushInsteadOf`, `http.proxy`, `core.sshCommand`, `include.path` —
|
|
* including keys nobody enumerated. Runs with the repository as its working
|
|
* directory, and tolerates a missing `.git/config.worktree`.
|
|
*/
|
|
export const GIT_CONFIG_DIGEST_LINE = `cat .git/config .git/config.worktree 2>/dev/null | sha256sum | cut -d' ' -f1 | sed "s/^/${GIT_CONFIG_DIGEST_MARKER}/"`
|
|
|
|
/**
|
|
* Stages, commits, and diffs without the GitHub token because repository config
|
|
* can execute filters, fsmonitor, external diffs, or textconv during these git
|
|
* operations. Commit tolerates an empty tree; the marker checks whether HEAD
|
|
* advanced before the separately authenticated push.
|
|
*
|
|
* The name-listing diff sets `core.quotePath=false` so a path with a non-ASCII
|
|
* byte reaches the `changedFiles` output as itself rather than as git's
|
|
* `"\303\251"`-escaped rendering.
|
|
*/
|
|
export 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 -c core.quotePath=false 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. It neutralizes repository-configured hooks,
|
|
* credential helpers, and fsmonitor before pushing agent-authored changes, and
|
|
* must be run with `GIT_CONFIG_NOSYSTEM=1` and `GIT_CONFIG_GLOBAL=/dev/null` in
|
|
* its env, which the `-c` flags cannot substitute for.
|
|
*
|
|
* Be precise about what that pair buys. It closes system- and global-scope
|
|
* config, so it removes two of the three places a `url.*.insteadOf` rewrite
|
|
* could send the token's userinfo to another host. Repository-local config —
|
|
* the scope a root agent inside the checkout can actually write — still
|
|
* rewrites the push URL. Babysit compares the
|
|
* {@link GIT_CONFIG_DIGEST_MARKER} digest before pushing; Create PR does not,
|
|
* keeping the exposure it always had.
|
|
*
|
|
* Git is invoked by absolute path so a shim planted earlier on `$PATH` is not
|
|
* what runs. Both sandbox images apt-install git on Debian (see
|
|
* `scripts/pi-sandbox-packages.ts`), so this is an image-shape dependency. It
|
|
* reduces rather than removes exposure — every sandbox command runs as root, so
|
|
* the binary itself is writable too.
|
|
*
|
|
* The refspec is explicit: `HEAD:refs/heads/$BRANCH` pushes the commit that was
|
|
* just verified rather than whatever the local branch ref happens to point at,
|
|
* which differ if the agent left HEAD detached or on another branch.
|
|
*/
|
|
export const PUSH_SCRIPT = `cd ${REPO_DIR}
|
|
/usr/bin/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" "HEAD:refs/heads/$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
|
|
|
|
/**
|
|
* The Pi CLI invocation for the sandbox modes, piped through the sandbox event filter. The command
|
|
* names {@link PI_EVENT_FILTER_PATH}, so every caller must have written `PI_EVENT_FILTER_SOURCE`
|
|
* there first — skipping that write does not fall back to the raw stream, it fails the run on the
|
|
* missing module.
|
|
*
|
|
* Selects `/bin/bash` explicitly because `pipefail` is not portable to `/bin/sh`, and without it
|
|
* the pipeline reports the filter's exit code rather than Pi's, so an upstream crash would read as
|
|
* a clean run. Both dedicated Pi images are Debian-based and provide Bash, so provider
|
|
* default-shell behavior cannot change whether an upstream Pi failure reaches the caller.
|
|
*
|
|
* With no options the repository resources Pi loads are exactly what Create PR always had. With an
|
|
* `extensionPath`, `--no-extensions` drops any extension the cloned repository ships while leaving
|
|
* the explicit `-e` path loaded, so the loaded set is exactly Sim's own extension. That is deliberate —
|
|
* a repository must not be able to register tools into a run holding the workspace's keys — but it
|
|
* does mean enabling search also stops loading a repository's own Pi extensions, which is why the
|
|
* flag is not passed on Create PR's no-search path. Babysit supplies
|
|
* `disableRepositoryResources` in every round, which also disables repository prompt templates,
|
|
* skills, and project trust.
|
|
*/
|
|
export function buildPiScript(
|
|
extensionPath?: string,
|
|
options?: { disableRepositoryResources?: boolean }
|
|
): string {
|
|
const repositoryArgs = options?.disableRepositoryResources
|
|
? ' --no-extensions --no-prompt-templates --no-skills --no-approve'
|
|
: extensionPath
|
|
? ' --no-extensions'
|
|
: ''
|
|
const extensionArgs = extensionPath ? ` -e ${extensionPath}` : ''
|
|
return `/bin/bash -o pipefail -c 'cd ${REPO_DIR}
|
|
pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING"${repositoryArgs}${extensionArgs} < ${PROMPT_PATH} | node ${PI_EVENT_FILTER_PATH}'`
|
|
}
|
|
|
|
export 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)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
export 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.
|
|
*/
|
|
export function scrubGitSecrets(text: string, token: string): string {
|
|
const withoutToken = scrubPiSecrets(text, [token])
|
|
return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
|
|
}
|