Skip to content

feat(sandbox): GCP Agent Platform sandbox backend, replacing Cloud Run - #478

Open
ItamarZand88 wants to merge 21 commits into
itamar/alien-75-fix-gcp-sandbox-createfrom
itamar/alien-75-gcp-agent-platform
Open

feat(sandbox): GCP Agent Platform sandbox backend, replacing Cloud Run#478
ItamarZand88 wants to merge 21 commits into
itamar/alien-75-fix-gcp-sandbox-createfrom
itamar/alien-75-gcp-agent-platform

Conversation

@ItamarZand88

Copy link
Copy Markdown
Contributor

Summary

Adds the GCP sandbox backend on Gemini Agent Platform — sessions are sandboxEnvironments
created under a durable reasoning engine and reached through the :execute proxy — and cuts
GCP over to it, removing the Cloud Run launcher implementation entirely. The backend carries
files, egress-deny, suspend/resume, snapshot, and enforced-limits; the Cloud Run subprocess
model (and its binding, launcher preflight, and Worker.sandboxLauncher) is gone.

When a GCP sandbox runs, this is what happens:

  1. At deploy, a preflight mutation synthesizes one reasoning engine per sandbox; its controller
    creates the engine through the Vertex API and records the server-assigned id.
  2. The template controller creates the immutable SandboxEnvironmentTemplate under that engine
    (image, ceilings, egress switch) and waits for it to become ACTIVE.
  3. The binding (engine, template, region) reaches the runtime provider, which builds an Agent
    Platform client against the binding's own region.
  4. A session is created as a sandboxEnvironment under the engine, and every command, file
    operation, and health check flows as one envelope over the :execute proxy.
    ← the heart
  5. suspend / resume / snapshot map to the platform's own verbs; teardown deletes the session,
    then the template, then the engine, in dependency order.

This moves the GCP sandbox backend from a Cloud Run subprocess launcher to a durable Agent
Platform resource.

What I added

  • The agent protocol surface: an explicit envelope on POST /, detached pollable jobs for long
    commands, and a declared isolation model.
  • The gcp-clients Agent Platform client (engines, templates, sessions, :execute, lifecycle).
  • The binding, capability row, provider, and template controller for the backend.
  • A GcpAgentPlatformEngine resource: a preflight mutation + a state-machine controller that
    creates the reasoning engine, persists its id, and deletes it at teardown; the template
    controller reads that id as a dependency.
  • The GCP aiplatform permission grants (provision vs management split).

What I removed (the cutover)

The Cloud Run sandbox provider, binding (sandbox-gcpsandbox-gcp-agent-platform), Terraform
emitter, launcher preflight mutation, host-required check, Worker.sandboxLauncher, and the
GcpSandboxImportData type. The shared Cloud Run worker client stays. No compatibility path —
the sandbox feature has no users yet.

How I tested

  • Unit + integration suites across alien-core, alien-infra, alien-preflights, alien-terraform,
    alien-bindings, alien-gcp-clients — green (the live-GCP Firestore/KV case_4_gcp tests fail
    only on the shared-project database quota, which is environmental).
  • The engine controller's create → poll → record-id → delete state machine, and a
    serialize↔deserialize round-trip guard test for both new controllers (the executor reloads
    controller state between reconciles; a missing by-tag arm fails above the handler layer).
  • The Agent Platform backend validated live on GCP earlier (health, exec, suspend/resume,
    snapshot, egress-deny), and the shared agent changes validated on a real AWS Firecracker MicroVM.

Security review for this diff:

  • Egress — a declared deny sets the template's internet-access switch off; AllowDomains
    is refused at plan time and again in the controller, never coerced to a boolean.
  • Permissions — provision grants reasoningEngines create/delete + sandboxEnvironmentTemplates
    CRUD with no sandboxEnvironments verb, so provisioning cannot reach a live session; the
    per-session verbs live in the management set. Validated against the GCP IAM permission dataset.
  • Regional addressing — the client is built against the binding's own region, not the
    deployment's, so a session is signed against the correct regional endpoint.
  • Secrets — the template carries no per-session environment; env travels per command, because
    the command shares the agent's uid and could read anything placed in the container env.
  • Retry-safetycreate_engine / create_template are single-attempt; a lost response
    fails to CreateFailed rather than creating a duplicate.
  • Nothing turned up.

Breaking changes

  • The GCP sandbox binding shape changes (sandbox-gcpsandbox-gcp-agent-platform); an
    already-deployed GCP sandbox stack must re-provision. No users yet, so no migration.
  • Worker.sandboxLauncher is removed from the public @alienplatform/core Worker type.

🤖 Generated with Claude Code

Add a gcp block to each of the four sandbox permission sets. The split
keeps the one content-reaching verb, sandboxEnvironments.execute, in
execute alone; heartbeat, management and provision never reach a live
session. A test pins that execute appears in sandbox/execute and no
other set, and the sensitive-content invariant covers GCP alongside
AWS and Azure.

The preview sandboxEnvironment(s) permissions are absent from the
upstream IAM dataset, so the validation test allowlists them exactly
while leaving the published reasoningEngines permissions gated.
The GCP Agent Platform proxies its :execute call to POST / with the
request body verbatim and can set neither path nor method, so the
per-operation /v1/* routes are unreachable there. Add a single POST /
endpoint whose required `op` selects exec, readFile, writeFile, mkdir
or health, and whose `v` is reconciled before dispatch. Each arm hands
off to the matching /v1/* handler, so the response — the exec NDJSON
stream included — is byte-identical and authorization stays that
handler's job. An absent or unknown op and an unsupported version are
refused with a typed error rather than defaulted.

Additive: /v1/* is unchanged, so AWS, Kubernetes and Local are
unaffected.
Two backends that report the same supervisorPidNamespace can still differ
on whether the process supervising a command is a separate identity from
it, and that difference is a security property. Publish it as its own bit
so a caller comparing capability sets is not told they are equivalent.
Client for the Vertex AI Agent Platform sandbox REST API: engines,
templates (immutable config), sandboxes, execute, and the pause/resume/
snapshot transitions, over the regional aiplatform host under a parent
reasoning engine.

Retry classification is the load-bearing part. create/execute/pause/
resume/snapshot deliver exactly once through a new single-attempt
transport path, so a network hiccup surfaces to the caller instead of
minting an orphan or repeating a state transition; get/list retry;
delete retries and treats a not-found as done. Execute request bodies
are redacted out of any error chain, and long-running operations are
polled within a bounded budget that reports the last error rather than a
bare timeout.
A single proxied call to POST / stays open only ~30s regardless of the
deadline asked for, so a command that runs longer cannot answer in one
call. Add jobStart/jobPoll/jobCancel — as /v1 routes and as envelope ops —
so a command runs detached under the agent's own deadline while its output
is polled across as many short calls as it takes.

jobPoll returns frames strictly after a caller-supplied sequence so a
retried poll neither duplicates nor drops output. jobCancel reuses the
existing process-group kill path by dropping the collector's receiver.
Retention is bounded: finished jobs are kept for replay until evicted
under a per-session cap or the session ends.

The synchronous exec path is untouched; choosing job vs exec is the
provider's call, not the agent's.
Introduce the GcpAgentPlatform sandbox binding — engine, template and
region reach a durable Agent Engine and its regional endpoint, with an
optional session ttl. The three required fields carry no serde default:
the template is egress-bearing, so a binding missing one must fail to
load rather than deserialize into a session with no image, no limits and
open egress. A test pins that, and that an absent ttl still parses.

Publish the Agent Platform capability row as gcp_agent_platform(), asserted
value by value against measured behaviour. It is deliberately not returned
by for_platform(Platform::Gcp) yet, which still reports the registered Cloud
Run backend; it becomes that arm's body once the backend is swapped in.
reconnect stays false until a stable session generation is wired, and the
test that asserts it is the tripwire that forces both to flip together.
Implement the Sandbox trait against Agent Platform sessions over the
:execute envelope: exec under the proxy window and detached jobs above
it, files and health as envelope ops, lifecycle as polled operations.
Compiled and unit-tested against a mocked client but left out of the
provider factory, so no declaration can select it until the cutover.
…t id

A GCP sandbox can report STATE_RUNNING while its container has been
silently replaced under a stable session name, so resource state is not
health and a session id alone cannot tell a caller's container from a
blank one wearing the same name.

The agent's health op now returns the container's kernel boot id, read
from /proc/sys/kernel/random/boot_id, which is stable across calls and
processes on one kernel and changes when the container is replaced. The
GCP provider derives a numeric generation from it with a deterministic
FNV-1a hash, so a caller comparing generations across two get()s detects
a replacement even across processes. A health reply that omits or empties
the boot id is refused rather than reconnected to a container of unknown
identity, and the probe is bounded by a named budget so a wedged agent
cannot hang get() or create().

With identity wired, the Agent Platform capability row flips reconnect to
true, and its tripwire test asserts the new value against the wiring.
…oller

The Agent Engine is an empty Frozen parent; the SandboxEnvironmentTemplate
carries the image digest, ceilings and egress and warms the session pool, so
it is Live and release-owned. Template config is immutable — there is no update
verb — so reconciliation is replace-not-update: create the new template, wait
for it to reach ACTIVE, and only then reap the old one, so a release never
leaves a session pointing at a template that has already been deleted.

Add list_templates to the client so the reaper can find superseded templates,
and give SandboxEgress::internet_access_switch a single home for the mode->bool
mapping so the provider, the controller and the emitter cannot disagree on what
a mode means. AllowDomains has no switch position and is refused, naming the
sandbox and both accepted modes, at plan time in the emitter and at build time
in the controller.

The emitter and controller stay unregistered; the registered GCP sandbox
backend is still Cloud Run until the cutover moves the registration.
A mocked test can only confirm the request we chose to send. These live
tests drive the client and provider directly against a real project —
the emitter and controller are unregistered, so a deployed stack is not
yet a path — covering create, cross-process reconnect, the detached path
past the ~30s execute cap, suspend/resume, egress-deny, and snapshot
restore, each with teardown that leaves nothing behind and a sweep for
orphans a failed run left. They are #[ignore]d and need credentials, so
CI does not run them.

Two unit gaps are closed alongside: a provider test pinning a failing
mutating :execute as delivered once while a read still retries, and a
job-poll test proving overlapping sinceSeq windows rebuild the stream
with no duplication and no gap.
…kend

Where a backend runs a command under the agent's own user, the command can read
the supervisor's environment and signal it, so the container — not the
supervisor — is the isolation boundary. Say that in the customer-facing sandbox
doc rather than leaving a caller to infer it from `supervisorIsolation: false`,
and record at the template's container spec why no env is set there: a secret
placed in it would be readable by the very code the agent supervises.
…uid-split

The agent refused to start whenever the command's uid equalled its own, which is
right where the agent can drop to a separate uid but impossible where the
platform runs everything as one user — a container that allows no other uid, or
a pod that has dropped every capability. Such a backend could never boot the
agent at all.

Take the model as a declared value, ALIEN_SANDBOX_ISOLATION = uid-split |
platform, required with no default because it selects a security model. uid-split
keeps today's refusal; platform accepts the shared uid because the container is
then the only boundary. Root and the root group are refused in both — the
concession is the shared uid, never root. The AWS image declares uid-split, the
model it already runs.
…missed

The live sweep could only delete engines a run had already recorded, so an
engine killed between creation and its record was invisible to it. Add a
paginating list of reasoning engines and back the sweep with it, reaping every
engine that carries the suite's display-name prefix and nothing else.
Without one, a single stalled request hangs the whole live test indefinitely —
the poll budget bounds the number of attempts, not the wait inside one call, so a
create or execute that never returns is never abandoned. Set a 90s per-request
timeout, above the ~30s :execute proxy window, so a stall fails the test rather
than wedging it.
A tokio TcpListener stops accepting after the GCP sandbox platform detaches and
reattaches the data plane on pause/resume: the process stays alive and the
socket stays in LISTEN, but the reactor's epoll registration for the listen
socket goes stale across the reattach and nothing is ever served again. A plain
blocking accept() re-wakes on the next connection regardless, which is why a
blocking server survives the same transition.

Serve behind a custom axum Listener whose accept blocks in the syscall on a
worker thread; per-connection IO stays a fresh tokio stream, so only the
long-lived listen socket needs this. Measured against the live backend: without
it a resumed sandbox is unreachable indefinitely; with it, reachable at once.
Two AgentState literals behind `#[cfg(target_os = "linux")]` never got the `jobs`
field, so the crate's test build did not compile on Linux — invisible on a macOS
dev machine, where those tests are gated out. Add it so the Linux test build works.
open_beneath resolved a caller's path with openat2 (RESOLVE_BENEATH,
RESOLVE_NO_SYMLINKS), which the gVisor runtime under GCP Agent Platform does not
implement: every file operation returned ENOSYS, so readFile, writeFile and mkdir
all failed there.

Walk the path one component at a time instead, each openat taken relative to the
previous component's descriptor and every step O_NOFOLLOW. The inode a step opens
is the inode the previous step checked, a symlink or magic link at any component
fails the step, and `.`/`..` are refused — so nothing can leave the root and there
is no name left to re-resolve. That is what openat2 gave in one syscall, done in
userspace so it holds on every runtime.

Measured on GCP Agent Platform: mkdir, writeFile and readFile round-trip and a
symlink escape is refused; the confine unit tests pass on a real kernel; the AWS
and Local paths keep the same behaviour.
The live tests wrote markers with a command (`> /tmp/x`, the real /tmp) but read
them back through the agent file API, which confines to the session root
(/sandbox) — so the read looked at /sandbox/tmp/x and never found them. Write and
read the same session-root path instead.

Suspend/resume: assert the load-bearing property — the filesystem survives — and
observe the generation rather than requiring it unchanged, since resume may return
onto a reissued container whose new boot id the generation exists to surface.

Teardown reaps a session's child sandboxes before its engine and retries, because
an engine will not delete while a sandbox remains and a panicked test leaves one
behind.
…andbox

The GCP Agent Platform sandbox hangs every template and session under a
reasoning engine that nothing created — Vertex exposes no Terraform resource
for it, so a controller must create it through the API. Add the engine as a
Live, Alien-owned resource: a preflight mutation synthesizes one engine per
sandbox and makes the sandbox depend on it (so the engine deploys first and
tears down last), and a controller creates it, records the server-assigned id,
and deletes it at teardown. The template controller reads that id from the
engine dependency instead of fabricating one.

Additive: the mutation and the template controller stay unregistered until the
Cloud Run backend is removed, so nothing reaches this path yet.
…oud Run

Register the Gemini Agent Platform provider, emitter, sandbox controller and
engine mutation as the GCP sandbox backend, and delete the Cloud Run
implementation. The runtime loader builds an Agent Platform client against the
binding's own region and creates sessions under the reasoning engine; the
capability table for GCP now reports the Agent Platform row (reconnect,
suspend/resume, snapshot, enforced limits); and a sandbox stack enables
aiplatform at deploy.

Removed: the Cloud Run sandbox provider and binding, its Terraform emitter, the
launcher preflight mutation and sandbox-host-required check, the
Worker.sandboxLauncher field, and the GcpSandbox import type. The shared Cloud
Run worker client stays. No compatibility path: the sandbox feature has no
users yet, so the binding changes from sandbox-gcp to sandbox-gcp-agent-platform
with no migration.
@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces the GCP Cloud Run sandbox launcher with a Vertex AI Agent Platform backend and adds the associated provisioning, lifecycle, execution, permission, and binding support.

  • Adds durable reasoning-engine and sandbox-template controllers.
  • Routes session commands and file operations through the Agent Platform :execute proxy.
  • Adds detached command jobs for operations exceeding the proxy’s synchronous window.
  • Removes the former Cloud Run sandbox launcher path and updates GCP sandbox permissions and bindings.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported job-result eviction and failed-resume-rollback failures no longer remain.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/alien-sandbox-agent/src/jobs.rs Adds bounded detached-job execution and now preserves unread terminal outcomes until a poll has served them.
crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs Implements Agent Platform sandbox lifecycle and execution, including a corrected rollback path that refuses replacement creation when a resumed session cannot be re-suspended.
crates/alien-gcp-clients/src/gcp/agent_platform.rs Adds the typed GCP client operations used for engines, templates, sessions, proxy execution, and lifecycle polling.
crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs Adds reconciliation for durable reasoning-engine creation, persisted identity, and teardown.
crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs Adds reconciliation of immutable Agent Platform sandbox templates and their readiness lifecycle.

Sequence Diagram

sequenceDiagram
    participant W as Worker binding
    participant P as GCP sandbox provider
    participant V as Vertex Agent Platform
    participant A as Sandbox agent
    W->>P: Create session
    P->>V: Create sandboxEnvironment
    V-->>P: Long-running operation
    P->>V: Poll until ready
    P->>A: Health via :execute
    A-->>P: Protocol version and boot ID
    P-->>W: Running session
    W->>P: Run command
    alt Short deadline
        P->>A: exec via :execute
        A-->>P: Buffered output frames and outcome
    else Long deadline
        P->>A: jobStart via :execute
        loop Until terminal outcome
            P->>A: jobPoll via :execute
            A-->>P: New frames and job state
        end
    end
    P-->>W: Command output stream
Loading

Reviews (2): Last reviewed commit: "fix(sandbox): retain unread job results ..." | Re-trigger Greptile

Comment thread crates/alien-sandbox-agent/src/jobs.rs Outdated
Comment thread crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs
…back

Two review findings on the GCP Agent Platform sandbox:

The job cap evicted the oldest finished job whether or not its terminal result
had been read, so a poll racing a new command's start could get JobNotFound and
lose the real exit code. Only evict a finished job whose outcome a poll has
already returned; refuse a new start otherwise.

A get_or_create that woke a suspended session, found it unhealthy, and could not
re-suspend it only logged and fell through to create a replacement — leaving the
woken session live with no id handed back. Fail instead, naming the session so
it stays identifiable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant