This is a task-based runbook; you do not need to read it from beginning to end. Use it to provision hosts, deploy Cloud Agent, protect retained workspaces, and diagnose job or network failures. The control plane owns the job API, database, accounts and admission, the MCP and GitHub relays, and admin settings. Every turn runs on a model connection its owner added with their own provider key.
Start with the task you need:
| Task | Go to |
|---|---|
| Prepare a host and its credentials | Prerequisites, credentials |
| Start or inspect the host service | Running self-hosted |
| Enable isolation for untrusted repositories | Provisioning Kata |
| Change application behavior, integrations, or appearance | Admin Settings |
| Open PostgreSQL administration | pgAdmin |
| Browse and filter database rows | Database browser |
| Change concurrency, sandbox settings, or placement | How many runners, which machine runs the jobs |
| Add a host on another machine | Tunnels and sandbox manager routing |
| Upgrade an existing deployment | Upgrade notes |
| Deploy or roll back a release | Rolling deployment order, deployment reliability |
| Archive an old database or apply a maintenance migration | Archiving a Gateway-era database, maintenance migrations |
| Protect workspaces and monitor disk use | Sandbox archives, retained Docker storage, storage measurement |
| Diagnose connectivity | The sandbox network, refusal messages |
| Configure or troubleshoot tools and repository access | MCP servers, repository entitlements |
| Answer a waiting agent question | Native agent questions |
| Investigate a task | Recorded events and audit |
| Choose the tests for a runtime or MCP change | Runtime verification |
| Let owners reach their own servers | SSH servers |
| Let owners run tasks on their own inference endpoint | Owner model connections |
| Operate environments and team environments | Reusable cloud environments |
ENVIRONMENT.md documents configuration, and the
compute-resource runbook covers administrator-managed
SSH and CloudLab hosts. For pre-drain checks, migration regression, failed peers,
and queue alerts, use deployment reliability.
Replace every <...> placeholder before running a command.
Use Admin → Settings to manage application and integration configuration, web appearance, operational intervals, upload and skill limits, task-title generation, sandbox/workspace limits, and sandbox archives. These settings are stored in the Agent database.
The application API uses GET /admin/agent/settings and
PUT /admin/agent/settings. Send a partial body such as
{"settings": {"max_attempts": 3}}; omitted keys keep their current values.
Add "expected_revision" with the revision you read to have the write
refused with 409 if anyone saved settings since then. The editor always sends
it, so applying a review never overwrites a change the review did not show.
Writes use the existing administrator guard and appear in the Audit log.
Application settings are read-only until you choose Edit on a group.
Change the fields, select Review changes, check the current and proposed
values, then choose Apply changes. The group is saved in one partial request;
Cancel discards its drafts. Switching categories or refreshing settings
preserves unsaved edits.
Credential fields show only whether a value is configured. Choose Replace secret (or Set secret) in edit mode to enter a new value; the review keeps it hidden. An empty replacement leaves the saved credential unchanged. In edit mode, each field's More actions menu offers Restore default… or Clear secret…, with a separate confirmation. An empty default clears that setting. If a required field is missing or an optional integration is only partly configured, the Administrator setup required prompt links to the fields that still need attention.
Admin Settings is operator access, and agent.admin should be granted only to
people trusted to operate the deployment. It edits the host signing key and
the account settings (sign-up, approval, mail transport and administrator
addresses), and the GitHub API URL that the App's and users' own GitHub
credentials are sent to. Saved credentials stay write-only.
Apply migrations before an upgrade as described in the upgrade notes, then start the main control-plane listener with its existing complete environment. It imports registered environment settings once, while the MCP-only listener waits for initialization and then reads the same settings. Newly migrated runtime identity, service keys, sandbox manager credentials, and policy fields are imported only when absent, under the same atomic preservation rule.
Later deployments keep the saved admin values, and resetting a field never
restores an old .env value. Listeners synchronize saved changes every five
seconds. The web server reads saved public appearance fields on every request,
with a 400 ms budget for the response and its body. A timeout, unavailable
backend, or invalid response uses the last valid public appearance read by that
web process for the same control-plane URL; before its first successful read,
it uses neutral display defaults. Successful reads show saved edits on the next
page request. This fallback contains no session or access decisions and does
not survive a web-process restart.
Apply changes to subsequent operations as described beside each setting.
The import of registered environment settings refuses an
AGENT_CONTROL_TOKEN_SECRET or AGENT_SOURCE_CONTROL_ENCRYPTION_KEY shorter
than 32 bytes: the listener stops with an error naming the variable before
anything is written, so rolling back is safe. Check their lengths before the
first deployment of this release. Replacing one has its usual cost: a new
control key needs hosts re-enrolled, and a new encryption key orphans saved
user connections. Values
already saved in Admin Settings keep working. The same import refuses an
AGENT_GITHUB_APP_INSTALL_URL that is not an https://github.com URL and an
AGENT_GITLAB_OAUTH_REDIRECT_URI that is not HTTPS (or HTTP on localhost),
which their connect flows could never use.
A release can narrow what a setting accepts. A value saved earlier that it
refuses does not stop the listeners: each logs
saved setting not applied as stored: …, naming the key but never the value,
once per settings revision, and uses the built-in default in its place. The
exception is a signing key, encryption key or service token shorter than a
newly required minimum, which stays in use until it is replaced. The editor
shows the value in effect; save a valid value or confirm Restore default
to clear the warning. Undecryptable or unencrypted stored credentials still
stop the listener.
For a new installation, copy deploy/control-plane/.env.example to .env and
adjust only the web path and network lines if the defaults do not fit. Nothing
secret is filled in by hand. Run the deployer's preparation phase once; on a
root that has never deployed, with the bundled database and no Docker volume
or container of the Compose project yet, it writes a random database password
and matching AGENT_DATABASE_URL, AGENT_LOCAL_SESSION_SECRET and
AGENT_SETTINGS_ENCRYPTION_KEY into .env. From the deployment checkout:
SOURCE_REPO="$PWD" DEPLOY_ROOT="$PWD" \
deploy/control-plane/deploy.sh --phase prepare "$(git rev-parse HEAD)"An artifact deployment's prepare phase does the same
(release artifacts).
To use another database, set AGENT_DATABASE_URL and AGENT_POSTGRES_PASSWORD
yourself first; the deployer then cannot prove the database is new and asks for
the account key and settings root key as well. It never replaces a value that
is set. Back up .env with the database from now on: the settings root key in
it decrypts every saved secret.
Bootstrap the web application before enrolling execution hosts or running the normal deployment readiness gate:
AGENT_DEPLOYMENT_SHA=$(git rev-parse HEAD) docker compose -p cloudagent \
--env-file .env -f deploy/control-plane/docker-compose.yml \
up -d --build control-plane mcp-relay webOn its first start the control plane creates its settings with a random host
signing key and user and compute credential encryption keys, and logs a
one-time setup code (agent_local_setup_pending). Create the first
administrator at /setup/administrator with that code, sign
in, and complete the remaining required settings in Admin → Settings: the
public web address and the sandbox manager credential your hosts share. Then
enroll hosts and run the usual deployment command.
Optional integrations need setup only when their configuration has been started.
The shared-live deployment gate continues to require qualified host readiness.
/setup is the recovery door when no administrator can sign in.
It needs ADMIN_TOKEN, which a deployment sets only when it wants that door.
The route is outside the sign-in guard. It uses the ordinary administrator API
and audit trail; unauthenticated visitors cannot read or change settings.
Reloading or locking the page clears its in-memory token.
Existing installations can retain the environment AGENT_SESSION_SECRET
fallback for the settings root. Before removing that legacy environment entry,
set AGENT_SETTINGS_ENCRYPTION_KEY to the same original value. Both listeners must receive the
same effective key. Back it up with the database. Before changing that key,
re-encrypt stored settings from the old key to the new one and coordinate the
listener rollout; replacing the environment value alone makes existing
ciphertext unreadable. The saved user/compute encryption keys continue to protect
their separate credential stores; the API rejects replacing or resetting an
existing encryption key without a separate credential re-encryption procedure.
Database access, the settings encryption root, recovery authentication, host provisioning, networking/listeners, actual host isolation, build routing, mounted files, and trusted compute deployment profiles remain deployment configuration. These bootstrap names appear on the page with hidden values. See the environment inventory.
This section configures the archive required by the explicit Archive to S3 and delete control. Automatic lifecycle archives use the separate policy under Admin → Settings → Sandbox, described in Sandbox archives.
Configure Admin → Settings → Application → Sandbox archives before enabling stopped
sandbox deletion. Set the S3 bucket and, optionally, its region, HTTPS
S3-compatible endpoint and object prefix. The matching
AGENT_SANDBOX_ARCHIVE_S3_* environment variables import once when these fields
are first introduced; later environment changes do not override saved settings.
An empty bucket leaves deletion unavailable.
The bucket field also accepts an S3 URI in the browser. Saving
s3://example-bucket/cloud-agent/sandbox sets the bucket to example-bucket
and the prefix to cloud-agent/sandbox in one update. A bucket-root URI clears the
prefix; entering only a bucket name leaves the prefix unchanged. Direct API
updates and initial environment imports require the bucket name and prefix in
their separate settings.
Provision the main control plane's AWS SDK credential chain, preferably with an
IAM workload role. Its archive role needs s3:PutObject and s3:GetObject on the
configured archive prefix; HeadObject uses s3:GetObject. If SSE-KMS is applied
by the bucket, also grant the required KMS permissions, including
kms:GenerateDataKey and kms:Decrypt so checksum-enabled HEAD can verify the
object. The application does not need s3:DeleteObject for archive storage.
Provision these manual-deletion credentials only on the main control plane;
the owning sandbox manager receives a request-specific presigned upload URL. The base Compose
does not forward AWS credential variables. For environment credentials, create
a private override restricted to the main service, for example:
services:
control-plane:
environment:
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:?Set the archive access key}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:?Set the archive secret key}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}Keep the override outside the checkout and set its absolute path as
AGENT_CONTROL_PLANE_COMPOSE_OVERRIDE_FILE in the deployment root's private
.env. The deployer validates that it is a readable regular file and appends
it after release-owned overlays for every stack operation in both source and
artifact modes. Leaving the setting empty uses only the reviewed deployment
files. For shared profiles, mount the credentials and configuration files read-only
in this service and set AWS_SHARED_CREDENTIALS_FILE, AWS_CONFIG_FILE and
AWS_PROFILE to their container paths and selected profile. Use the saved
archive region setting or provision AWS_DEFAULT_REGION in the same override.
Apply these manual-deletion credential mounts and variables only to the main
control plane. The automatic lifecycle archive policy separately uses the
trusted sandbox manager's workload identity; neither path provides AWS
credentials to sandbox containers, the web application or the MCP relay.
The host receives a 15-minute presigned PUT for exactly one request object, with
a fixed byte length, full-object SHA-256 checksum and If-None-Match: *. The
single PUT archive limit is 5 GiB; an oversized archive keeps its container.
A retry may encounter HTTP 412 if the upload already succeeded. The control
plane independently reads HeadObject with ChecksumMode=ENABLED and requires
the exact service-verified SHA-256 and byte length before deletion can proceed.
ETags, supplied metadata, missing checksums and composite checksums cannot
substitute for that verification. S3-compatible storage must implement these
conditional PUT and checksum semantics.
Use a private bucket, block public access, and configure encryption, retention and recovery access for these archives. Conditional PUT prevents the issued capability from overwriting an existing request object; the archive role and other principals must preserve this immutability. Bucket policies can require conditional writes, and versioning/Object Lock provide stronger protection against later administrative changes. Do not expire an archive while deletion or recovery may still depend on it. Persisted receipts include its original S3 URI, SHA-256, byte length and object version when available, so changing settings does not redirect verification of an existing deletion request.
Open Admin → Database to inspect the Agent database directly in the dashboard. Select a table, inspect its columns and types, add filters, choose a sort column and page size, then select Run query. Previous and next controls page through the results. Filters are combined with AND; the Contains filter matches a literal case-insensitive substring. The browser uses the control plane's existing database connection and works without enabling pgAdmin.
The built-in browser exposes reviewed metadata columns from Agent tables. Stored
credentials, raw user content, and opaque configuration documents are omitted.
It supports read-only queries with parameterized filters, stable ordering,
bounded results, and a statement timeout. Each request checks current
agent.admin permission. Query outcomes appear under Audit log → Database;
the trail records query metadata without filter values or returned data.
The API is GET /admin/agent/database/tables for the visible schema and
POST /admin/agent/database/query for filtered rows. Database writes and arbitrary
SQL remain available through the separately configured pgAdmin service below.
Open Admin → Database → Open pgAdmin to manage PostgreSQL. The page checks
the private service before offering the link. The existing same-origin API
proxy carries pgAdmin pages, assets, forms, downloads, and SQL requests, so the
public edge needs no new route or port. Every request
requires current agent.admin permission. pgAdmin retains its own login and
CSRF checks; Agent cookies and bearer credentials are never sent upstream.
pgAdmin's pages run on the Agent's own origin, where the administrator's Agent
session authorizes every admin route. The proxy therefore adds a second
Content-Security-Policy to each pgAdmin response, enforced alongside
pgAdmin's own: pgAdmin may fetch, submit forms, open frames and start workers
only under the public pgAdmin path. It also sends
Cross-Origin-Opener-Policy: same-origin, so a pgAdmin page holds no handle on
another Agent page it opens, and the proxy's own error answers are sandboxed.
This narrows what a script injected into pgAdmin could do with the session; it
is not an origin boundary. pgAdmin still shares the origin's browser storage,
and an error page that an edge in front of the control plane serves under the
pgAdmin path without these headers is outside the policy. Only a separate
origin, with its own hostname, TLS and edge route and a proxy credential other
than the Agent session cookie, would remove the shared session; this release
does not provide one. Until then, keep the pinned pgAdmin image and its default
security headers (its own policy refuses inline scripts without its per-request
nonce), enable the profile only where it is used, and grant agent.admin only
to operators.
Enable the optional service in the control-plane deployment's .env:
COMPOSE_PROFILES=pgadmin
AGENT_PGADMIN_BOOTSTRAP_URL=http://pgadmin:5050
AGENT_PGADMIN_BOOTSTRAP_DIR=/var/lib/cloud-agent/pgadmin-bootstrapRetain any existing profiles, for example COMPOSE_PROFILES=edge,pgadmin.
Run the normal control-plane deployment workflow. An existing stack can start
the service and apply its new control-plane environment with:
docker compose --env-file .env -f <selected-compose-file> up -d pgadmin control-planeUse the release-generated Compose file for an artifact deployment. Its bundled
server definitions are resolved from the selected recipe. First-time setup also
saves the connection in Admin → Settings → Database tools if no private
pgAdmin URL has been saved. The default public URL is the saved web base URL
followed by /api/admin/agent/pgadmin/; set AGENT_PGADMIN_BOOTSTRAP_PUBLIC_URL
before setup if the API has a different public prefix. Later changes use
Private pgAdmin URL and Public pgAdmin proxy URL in Admin Settings.
Existing AGENT_PGADMIN_URL and
AGENT_PGADMIN_PUBLIC_URL values are imported once along with other runtime settings.
Private pgAdmin URL selects a pgAdmin service the deployment names; it
cannot name any other address. The bundled http://pgadmin:5050 is always
allowed, and so are the scheme, host and port of AGENT_PGADMIN_URL and
AGENT_PGADMIN_BOOTSTRAP_URL in the control plane's environment. Any other
saved URL makes the dashboard, the launch link and the proxy answer 503 without
contacting it: the proxy forwards every method, path and body and returns the
reply, so an address an administrator could choose freely would reach a cloud
metadata endpoint or any other private service the control plane can reach.
To run pgAdmin elsewhere, set its address as AGENT_PGADMIN_URL in the
control plane's environment, restart the control plane, and select the same
address in Private pgAdmin URL. Keep the variable set after it has been
imported.
Open Admin → Database and choose an administrator email and a password of
at least 12 characters. Only a current Agent administrator can complete setup.
The service waits for that first setup; the page then checks readiness and
offers Open pgAdmin. No administrator account or default password is shipped.
Concurrent setup attempts cannot replace the first credentials. The private
agent-pgadmin-bootstrap volume holds the initial credentials; preserve it and
agent-pgadmin during deployments. pgAdmin mounts the bootstrap volume read-only.
Sign in with the pgAdmin email and password you chose. The initial server
definition points to postgres:5432, database/user cloudagent. Enter the
deployment's PostgreSQL password when connecting. If the database name or user
differs, supply a custom AGENT_PGADMIN_SERVERS_FILE before initialization, or
edit the connection in pgAdmin. Database passwords are not preloaded or exposed
in the dashboard. Saved accounts and connections persist in the separate
agent-pgadmin volume. Setup does not reset an existing account, including on a
container restart. Manage later accounts and password changes in pgAdmin.
For the HTTP-only local stack, use AGENT_PGADMIN_COOKIE_SECURE=False alongside
the existing local Agent-cookie setting. Keep the default True for HTTPS.
If the dashboard reports unavailable, inspect docker compose logs pgadmin
and its health state. Before setup, a waiting container is healthy and logs
Waiting for first-time setup in Admin -> Database. After setup, unreadable
bootstrap storage or unavailable PostgreSQL prevent startup. Setup records
agent.database.pgadmin.setup. A setup that cannot write the bootstrap volume
is recorded as failed; fix the volume and set up again from the same page.
Opening pgAdmin records agent.database.pgadmin.open in the Agent audit log,
both for the launch link and for the first request of a new pgAdmin session at
any URL. The Agent audit log does not record SQL: pgAdmin connects as the
database owner, which can change any table, the audit log included, so treat
pgAdmin access as full database access. The proxy adds frame-ancestors 'self', nosniff and X-Frame-Options: SAMEORIGIN to pgAdmin's responses;
pgAdmin's own policy still allows its inline scripts. Resetting Private
pgAdmin URL in Admin Settings disables
access after the listeners synchronize; removing the profile and stopping
pgAdmin preserves its volume.
See pgAdmin's container documentation for account and server initialization behavior.
POST /v1/agent/jobs owner queues work
→ enrolled host claims exact shared-live capability attestation
→ runner creates/starts one retained Docker group and its writable layer
→ native Agent CLI one live process/session per member chat
→ normalized events streamed to the owner over SSE
→ model calls the owner's provider, on their own key
→ push and pull request git/gh as the owner, through the GitHub relay
→ turn settles the workspace stays in the retained sandbox
The platform never pushes or opens a pull request itself. When the owner asks for one, the agent commits, pushes a branch and opens the pull request as the owner, through the GitHub relay, and the pull requests it opens are recorded for the conversation; see Resume, Fork, and pull requests. Watch PR and merge asks it to merge once the PR satisfies its required checks and reviews. Branch protection and other GitHub rules on the repository decide what may merge without a person.
Keep these credential boundaries intact:
- No repository credential enters the sandbox. The owner's GitHub token
stays in the control plane, where only the GitHub relay uses it. The runner
holds a clone credential, but it is read-only, scoped to the one repository, and
deliberately never recorded in
.git/config:originis recorded as the plain HTTPS URL, the credential travels in git's environment rather than its argv, and the runner refuses to start the agent if it finds the token anywhere under.git/. - git and
ghin the sandbox speak as the owner, without the owner's token. With the GitHub relay configured, a member'sGH_TOKENis its residency's placeholder, which GitHub refuses. The control plane's GitHub relay exchanges it for the owner's own token while the residency serves a running turn, for the conversation's repository only, and refuses branch deletion, tags, GraphQL and repository administration. Nothing on the platform scans what it pushes. See the GitHub relay. - The sandbox also holds two narrow, non-interchangeable credentials. The
member's CLI runs on its owner's provider key from the model connection the
turn names, handed over only in that owner's claim and residency (see
owner model connections). This service
separately mints an attempt-fenced
mcptoken that reaches only the MCP relay and only the servers authorized by the conversation's current bindings. There is no per-job spend cap and no local quota; the owner's provider keeps the key's quota and bill. Neither credential can write the event log or move the job to a terminal state; the trusted runner holds the token that can.
A job created with "repo": null starts a conversation without a repository.
Any user with agent.use may create one; there is no repository entitlement to
check, and the claim carries no clone or GH_TOKEN credential. The host builds
/workspace as a new Git repository on main with one empty commit, identical
on every host (77bbec2039abcd5c7663078db4f90f3c860a751a), and the control
plane records that commit as the job's base. A request that also names
base_ref, base_sha or environment_id is refused: each names something in
a repository. The environment is the platform base, the owner's global
variables, and the task's own overrides.
It has no repository for the GitHub relay to act for, so the relay sends its
agent's GitHub requests without anyone's credential and records no pull
request for it. Everything else is an
ordinary conversation: archive, delete, Fork, Side Chat, goals, MCP, retention,
checkpoints and S3 archiving behave as they do for a repository chat, and
project memory is not built from it. temporary=true on GET /v1/agent/jobs,
/conversations and /sessions lists only these conversations and cannot be
combined with repo; the project and facet lists report them as repo: null.
GET /v1/agent/jobs/{job_id}/workspace/archive downloads a chat's whole
/workspace as one gzipped tar, for any retained chat, repository chats
included. The owning host compresses it to its own disk first and answers only
once it is complete, so a workspace larger than the host's
workspace_download_max_mib (default 1024 MiB, measured before compression)
is refused with 413 instead of arriving truncated. Moving the files to GitHub
is left to the agent: with the GitHub MCP server connected at its writable
endpoint, it can create a repository and push to it.
While a configured GitHub App cannot be loaded (see
the credentials section), creating a temporary workspace
answers 503 like any other job.
New conversations use the first meaningful prompt line, shortened to 36 characters, and the owner can rename them; a manual rename records its provenance. Follow-ups keep the conversation's name, and forks inherit it. There is no generated title and no prompt-rewriting endpoint: both needed platform inference, which was removed with the Gateway.
The conversation UI shows four states: Queued, Running, Idle, and
Failed. Any running turn takes precedence over queued turns; when no turn
is active, the latest turn determines whether the conversation is failed or
idle. Completed and user-stopped turns both leave the conversation idle.
Opening an older turn does not change this conversation status. The owner API
reports it as conversation_state, independently of the selected job's state.
Each turn keeps its own completion, failure, or cancellation result. Execution history is collapsed by default and appears only when that turn has multiple attempts. Admin run details show the server and timestamps directly, with past attempts available for inspection. Selecting history changes the records being viewed; it does not restart a task or change its server. These display rules do not change task scheduling or retry behavior.
Claude Code AskUserQuestion, Codex user-input requests, and OpenCode/Kilo
question requests open a question dialog while the turn waits. Choose an
option or enter a custom answer when offered, then submit to continue the
same native tool call. Answer later dismisses the dialog without answering;
Review question reopens it. Skip explicitly declines the request.
Claude Code ExitPlanMode uses the same dialog to present its plan for
approval. Select Approve plan and submit to let Claude exit plan mode and
continue implementation. Skip declines the plan approval and asks the
agent to keep planning; Answer later leaves the native approval request
waiting until the owner answers it. Claude handles entering plan mode and
reading or updating its plan file within the native session.
The page reads pending questions from the resident agent, so reloading the conversation rediscovers unanswered requests. An answer names the job, attempt, and request it was read from. The owner API and host both fence that identity; stopped, completed, or replaced attempts cannot take a stale answer, and question answers are never queued as follow-up turns.
Deploy the runner, Sandbox Manager and rebuilt sandbox image together. The
http-agent-live-bridge helper pin is 5 (see
native conversation recall); helper 2 and earlier cannot
execute native question replies, and anything older than the pin does not
satisfy the turn-time attestation. Before starting a
replacement OpenCode/Kilo process, the host installs its trusted bridge into
the retained container, including conversations without MCP connections. This
updates the helper while preserving the workspace and native session state.
A repository needs at least one entitlement: the user's GitHub connection or the deployment allowlist. Job creation fails closed when neither applies.
User connection. The user authorizes the GitHub App through the configured
frontend callback. The control plane exchanges the code for a user token,
records the GitHub account in agent_github_accounts, and stores only the
installations GitHub reports in agent_repo_grants. Reconnecting removes grants
for installations the user can no longer reach.
The composer, job creation, claims and workspace reads all check
repositories as the user, through /user/installations/{id}/repositories.
Only repositories the user can push to qualify, because the result is a
branch and pull request. The response is cached for one minute per user. Do not
replace this check with an installation-token repository listing: GitHub can
report an organization's installation to a collaborator with access to only
one of its repositories, and the installation token could expose the rest.
The user token stays in the control plane, encrypted with
AGENT_SOURCE_CONTROL_ENCRYPTION_KEY. Expiring App user tokens last eight
hours and have a six-month, single-use refresh token. Refresh begins five
minutes before expiry under a row lease so concurrent callers cannot spend the
same refresh token. A connection without a stored token, including a legacy
connection or one GitHub has refused, grants no access. The composer asks the
user to Connect GitHub again; GitHub skips consent for an existing
authorization.
Connecting an account and installing the App are separate steps. If the App is not installed on the user's own account, the connect flow offers GitHub's installation page even when an organization's installation already grants access. The user can select an account and repositories or choose Not now. A connected user with no repositories goes to that page instead of repeating OAuth. Configure repositories in Integrations and Configure repository access in the composer open the same page.
Deployment allowlist. AGENT_REPO_ALLOWLIST supports single-tenant use
against the operator's repositories. It is not per user: every user admitted to
Cloud Agent may have the platform clone every listed repository with the
deployment's GitHub App, so list only repositories all of them may work on. It
grants no push: the GitHub relay pushes and opens pull requests only with the
owner's own GitHub connection, and GitHub decides what that account may do. When the App's OAuth credentials and
AGENT_GITHUB_APP_INSTALL_URL are configured, the composer still requires a
connection before any repository can be selected, allowlisted ones included;
until then a task runs in a temporary workspace.
The create endpoint independently honors the allowlist.
Disconnecting. Disconnect GitHub in the Integrations Manage menu deletes the account, encrypted token, and installation grants and asks GitHub to withdraw the authorization. Uninstalling the App on GitHub revokes the platform's installation access.
The admin console's Sandboxes tab initially lists every group recorded as
running, including containers retained between turns. Use the state filter to
include stopped, starting, stopping, or failed groups. Fork members share one
container row. The inventory refreshes every 30 seconds and follows every page
of GET /admin/agent/sandboxes, which requires admin access. The default API
filter remains state=running; state=all includes every lifecycle state.
Search matches session titles, repositories, owners, hosts, agent versions, and container identifiers across the complete loaded inventory. Combine it with host and activity filters. Column headings toggle sorting; Sort by also offers host, state, agent, and creation time. CPU, memory, session counts, and timestamps sort by their raw values, with unavailable values last. Uncheck Auto-refresh to pause both inventory and expanded-detail polling; manual refresh and measurement-age indicators remain active.
Open a sandbox's details to inspect its member sessions, host, runtime, container identity, and lifecycle. Member details include session links and activity states, without exposing native session identifiers, credentials, prompts, or raw container diagnostics.
Stop idle sandbox requests a stop of the shared container, preserving its workspace and native sessions for a later turn. It applies to every member of the group. The API checks the reviewed container, generation, and last activity before accepting the request, and refuses a busy group or a group with open terminals or stale terminal observations. The request is recorded in the admin audit log. The host performs its own idle check before stopping the container. When the terminal report is zero, missing, or older than 90 seconds, opening or refreshing details queries the owning sandbox manager for that group's census directly. This inspects only the selected container and counts terminals across all its members; unrelated containers and host-wide reconciliation do not delay the check. Full inventory reconciliation remains on the host heartbeat path. Stop requests also verify terminal activity before closing admission: older sandbox managers may report only the origin session's terminals even when another member is active. This also supports runners that omit terminal counts from their heartbeats. The report must match the reviewed container and generation; an unavailable sandbox manager or unknown activity keeps stopping disabled and shows the required recovery step. Resource-stat failures do not discard an otherwise valid terminal report. The page shows the pending request until a host observation confirms the stop; an offline host cannot complete it. New turns wait while the stop is pending. This action does not delete the container or reclaim its retained disk space.
Archive to S3 and delete removes a stopped or dead, idle container after its complete retained filesystem has been uploaded and verified in S3. Configure the bucket, region, optional S3 endpoint, and object prefix under Admin → Settings → Application → Sandbox archives. S3 storage setup describes permissions, credential provisioning and compatible storage requirements.
Deletion blocks new turns, Fork, and workspace operations while the owning
sandbox manager prepares the archive. The archive contains manifest.json and
container-rootfs.tar, including workspace files and retained native session
files. Archives larger than 5 GiB are refused before deletion. An immutable S3
object must match both SHA-256 and byte length, and its receipt must commit to
the database before the host may remove the exact reviewed stopped container.
If the host reports that this container is missing or changed before archive
verification, the request fails for operator review; that response never counts
as a successful deletion.
Upload failures retain the container. Once an archive is verified, uncertain
deletion acknowledgements keep admission blocked and retry the same request.
The sandbox manager stores its deletion receipts on its own persistent volume so an
acknowledgement can survive a sandbox manager restart. After a rollback past #763,
a Sandbox Manager cannot upload a request that a newer one prepared, and two of
its files stay on the host; see rollback compatibility.
Delete without archive removes the same exact stopped or dead, idle container without
uploading workspace files or native session history. It does not require an S3
bucket. The host still refuses a running, busy, or changed container, and the
control plane marks native resume lost only after the host confirms removal.
While that host is offline or removed from the pool, the details say so and
what ends the wait: the host reconnecting, or being enrolled again. The one
exception is a host whose server its provider confirmed terminated:
once that host has been silent for 10 minutes, the container is taken as gone
with the machine and the deletion finishes without it, the rule that marks the
machine's other sandboxes lost. Its trajectory and audit record
host_reclaimed. Chat history and the lifecycle audit remain. There is no S3
object to restore from. An archive deletion that is already pending or verified
is left unchanged; wait for it to finish or fail before choosing this action.
If automatic retries have stopped for a discard, whether its host-attempt
budget is exhausted or its failure requires operator action, the worker still
checks for this provider-confirmed outcome. That recovery makes no host call and
does not consume another retry; it requires the same provider termination and
10-minute silence conditions.
One deletion worker handles requests one at a time, in the order they became due. Only transient failures retry automatically, with exponential backoff from one minute to one hour; retries stop after eight claims or 24 hours from the request. Permanent and operator-action failures stop automatic retries immediately. A stopped request stays fenced in its pending or archived state; the admin view offers Resume deletion after the issue is resolved, starting a new retry window for the same request. It preserves the original archive/discard choice, request identity, and any verified archive receipt. The admin sandbox details also show an attempt history for each request: attempt number, host, last confirmed stage, outcome, and a bounded failure code. This history is diagnostic and best-effort; its writes have a short deadline and do not prevent a fenced deletion claim or lifecycle transition. A stalled or unavailable ledger can leave an attempt missing or unfinished. Provider response bodies and credential-bearing diagnostics are not stored. When a claim expires, its unfinished attempt is recorded as lease-expired when the ledger is available before the same fenced deletion request can be claimed again.
Each claim pass marks at most 50 exhausted requests, oldest request first, before claiming due deletion work. Later worker passes continue through any remaining exhausted requests. Exhaustion state and trajectory events commit together with the request's result on the audit log; updating the diagnostic attempt history is best-effort.
Sandboxes of deleted chats are removed automatically. When an owner
permanently deletes the last chat in a sandbox, the deletion worker asks the
owning host to stop it, then queues its deletion through the same two paths:
archived to S3 first when a bucket is configured, discarded otherwise. Only
sandboxes on online hosts are considered, a sweep runs every minute, and its
events and audit rows name user-deletion-sweeper as the actor with source
system. Database records of the deleted chats remain. A sandbox that another
chat still uses is never touched. The first sweep after an upgrade also picks
up every older sandbox whose chats were all deleted before, so expect a batch
of stops and deletions; configure the archive bucket first if those sandboxes
should be kept in S3.
Verify stopped and force delete recovers a sandbox still recorded as running
only when the owning sandbox manager confirms that the exact reviewed container is
stopped or dead, and idle. It does not stop a running container or bypass unavailable
host proof. If verification fails, the error identifies missing host registration,
an absent or refused advertised URL, a missing or rejected sandbox manager credential,
a connection failure, a timeout, or unavailable sandbox status. Check the
indicated configuration or sandbox manager readiness before retrying. For a connection
failure, verify the reverse sandbox manager route.
Control-plane logs record agent_sandbox_delete_verification_failed with the
sandbox, owning host, status code, and failure type without raw sandbox manager errors.
Remove stale entry is available when the owning host has no heartbeat or has not reported for more than 90 seconds. It removes the entry from the default admin inventory without contacting the sandbox manager, so a retired or unreachable host does not leave an entry that can never be cleared. The server rechecks host freshness and the reviewed identity, and refuses active turns, workspace operations, snapshots, or a pending deletion. The action is audited and reversible through Include removed and Restore to inventory. It preserves the recorded lifecycle state, container identity, native state, and chat history, without deleting any retained container or files, creating an S3 archive, or reclaiming host disk. A matching running host observation or new admitted work makes the entry visible again. Restore the sandbox manager connection to complete S3 archival and physical deletion.
The details show progress, archive location, checksum, size, and any failure. Chat history, memberships, execution records, and audit receipts remain in the database. Deleted native sessions cannot resume; this operation does not add automatic recovery from S3. Remove from inventory is a separate reversible visibility control for stopped sandboxes. Use Include removed sandboxes with All states, then Restore to inventory, to inspect retained entries. Resuming a sandbox removed only from inventory makes it visible again.
For batch operations, select individual inventory rows or Select all visible sandboxes, choose an action, then Review selected sandboxes. Selection applies only to the current state, search, host, and activity filters; hidden rows are excluded. The review reads current details for each selected sandbox and lists any blockers before a single confirmation applies the action to eligible entries. Stop, archive-and-delete, deletion without an archive, inventory removal, and inventory restoration use the same checks as individual controls. Up to four requests run at once, and each result is reported separately. A stop or deletion result means the request was accepted; host completion is still asynchronous. Failed or skipped entries remain selected for a new explicit review. Expired reviews and changed containers are refused, without automatically retrying a new container identity. No batch feature flag or additional migration is required.
CPU is measured in occupied cores over the interval between Docker samples; memory is current usage with inactive file cache removed. Network and block I/O show cumulative container counters and interval rates. All measurements cover the whole shared container. The details also show process and terminal counts, session capacity, active operations, snapshot state, and sandbox age. Sandbox age starts at group creation and does not measure container uptime.
The sandbox manager samples Docker in a bounded background worker, then sends cached observations with the host heartbeat. Each sample retains its original age through repeated heartbeats. Readings older than 90 seconds are marked stale and excluded from the fleet CPU and memory totals; those totals show their measured coverage. Counters Docker or Kata does not report remain unavailable. A current host heartbeat alone does not imply current resource measurements.
Sandbox controls require migration 0037_admin_sandbox_stop and matching
control-plane and host releases. Apply alembic upgrade head before starting
the updated API. Inventory removal additionally requires
0044_admin_sandbox_archive, and S3-backed container deletion requires
0045_admin_sandbox_deletion plus the matching sandbox manager release.
The append-only admin trajectory requires 0057_admin_sandbox_trajectory.
Resource persistence requires migration
0034_sandbox_resource_usage and a host running the updated sandbox manager.
Older hosts still appear in the inventory with resource usage unavailable.
Only the latest observation is stored on the sandbox; this view does not
provide resource history.
Settings has a Usage page (/settings/usage) that shows each owner their
own containers, what the running ones use now, and what their work used over
the last 7 or 30 UTC days. It reads three owner-scoped routes, none of which names a host,
container ID, image or generation:
| Route | What it reads |
|---|---|
GET /v1/agent/usage/containers |
Containers behind chats the owner has not deleted, active first, with limits and the latest sample of a running one. Fresh samples (90 seconds) are summed into current CPU and memory. |
GET /v1/agent/usage/resources?days= |
Per UTC day: running time from the lifecycle events in agent_sandbox_events, and measured CPU and memory from agent_sandbox_usage_days. |
GET /v1/agent/usage/tokens?days= |
The admin Usage aggregation, filtered to the owner's runs: reported tokens and turns by day and by model (see Usage). |
Running time counts the starting, running and stopping states and has
history back to 0046_admin_sandbox_trajectory. CPU and memory history starts
with migration 0077_sandbox_usage_days: each accepted sample adds the time
since the previous accepted sample of the same running container (at most
120 seconds; after a start or a longer gap, only the sample's own CPU window),
and its CPU cores and memory bytes over that time, to the container's rows for
the UTC days that time falls on: time that spans 00:00 UTC is split there, so
the part before midnight counts on the day before. The page states how much of
the running time the measurements cover. Disk usage is not measured.
Days are UTC days, and the page says so and shows that moment on the reader's clock. It shows no cost: each owner's provider bills its own connection.
The owner's Usage page (/v1/agent/usage/tokens), the admin Usage page
(/admin/agent/usage), a run's usage footer (/v1/agent/jobs/{id}/usage) and
the admin run detail show one thing: the tokens each turn's agent reported
about its own model calls. Responses say so (source: "runtime_reported";
the per-job read's runtime.source is "runtime"). It is not a provider bill.
Cloud Agent holds no pricing, quota or billing: the owner's provider keeps
those for its key, and several Cloud Agent accounts using keys of one provider
account still share that account's allowance.
Each attempt's events are reduced once into an agent_attempt_thread_stats
rollup (reducer version 2), which the thread statistics read too. A model
call is counted once even when its report repeats; a retry's attempt is its
own report; a Fork's copied history is not counted again. A turn whose agent
reported nothing is shown as pending while it runs and unavailable after,
never as zero, and a metric no reporting attempt carried is unknown, not 0.
| Agent | What it reports | Input total |
|---|---|---|
| Claude Code | Anthropic counters per response: uncached input, cache reads, cache writes, output | The three input counters added |
| pi | The same counters per assistant message | The three input counters added |
| Codex 0.145.0 | thread/tokenUsage/updated per model call: inputTokens (cached included), cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens |
inputTokens |
| OpenCode, Kilo | No verified counter shape; any counter they emit is shown as reported | Unknown |
Reasoning tokens are reported by Codex alone. They are part of output, shown beside it and never added to it. No agent reports a cost or a request count, so cost and model calls are always null.
What is not counted. Codex reports every model call of its thread with the thread's running total. A call is credited to a turn only when the notification names that running turn, or comes from a subagent thread whose spawn was seen during that turn. Everything else stays a raw event and counts nowhere: a subagent's calls after the turn that spawned it has ended, a subagent spawned in an earlier turn or an earlier process, a notification replayed when a thread is resumed, and a malformed report. Allocating those calls to some turn would be a guess, so the totals are the calls an agent's report can tie to a turn, and a provider can bill more than they show. The Usage pages and the run footer say this where the numbers are shown.
The admin console shows tool-call measurements in Resources and the agent's reported token usage. It has no route to a task's raw runtime events: owners read their own through the session routes.
Do not read agent_job_events.payload directly. A psql query returns
prompt text, tool arguments and tool output with no audit record, no size bound
and no column allowlist. If an investigation needs a task's raw events, that is
a reason to build an audited route, not a reason to go around the audit.
Reading the trail. Admin → Audit log (GET /admin/agent/audit-log)
answers who did what, to whom, with what result, newest first. Each action is
declared once in cloud_agent/domain/audit.py, which files it under a
category (accounts, settings, servers, sandboxes, measurements,
sensitive_access, access_control), names what it acted on (target_type
and target_id) and the user it affected (target_user_id: the account
itself, or the owner of a sandbox), and says whether its work runs later.
Pages follow before, the oldest id of the previous page.
- Who acted (
actor_type) is derived by the database fromactor_id:admin(an administrator's session),break_glass(access that belongs to no account:ADMIN_TOKEN, first-run setup, the bootstrap command),service(a named automation),system(the control plane's own policy and workers),user(an account changing itself) oranonymous. The page showsadminandbreak_glassunless asked for everything (actors=all); break-glass access is always shown, in red. - Names are kept as they were when the row was written, with a short id beside them: erasing an account frees its login name for a new account. Erasing an account deletes no audit row.
- What came of it.
outcomeisapplied,refused,failed,replayed, orrequestedfor work that runs later. A requested sandbox deletion carries the deletion worker's final result; a requested compute operation carries the operation's own state. The worker's steps are in the sandbox's trajectory, not the audit log. - One account or object.
user=<account id>shows everything about an account (the users page links to it);target_type=withtarget_id=everything about one host, sandbox or server operation. - Recording never fails an action. A row that cannot be written is logged
as
agent_admin_audit_write_failed, counted inagent_admin_audit_write_failures, and reported at the top of the page for seven days; the action itself goes ahead.
Reads are not recorded, nor are connection checks. Revision
0114_admin_audit_attribution removed the polling reads older releases
recorded.
Owners and admins manage their own teams from Settings → Teams. Platform administrators see every team under Admin → Users → Teams, read-only:
- The list (
GET /admin/agent/teams) shows teams newest first, with the owner, active members, admins besides the owner, and join requests waiting for a decision.stateisactive(default),deletedorall;qmatches part of a team name, a team id, or the user id of anyone who has been a member. It pages withbefore, the id of the last team the previous page showed; the response'snext_beforeis that id for the next page. A page starts after that team rather than after a row count, so a team created or deleted while an administrator reads is neither shown twice nor skipped. - A team's page (
GET /admin/agent/teams/{team_id}) lists members in every state, including those who left or were removed and when, plus open join requests and how many invitation links are open, expired, claimed or revoked. Departures are kept for good, so it lists every active member and the 50 who left or were removed most recently;departed_member_countcounts them all. That bounds the account email reads one view makes. Open requests are the 50 oldest likewise, and the team'spending_request_countcounts them all.GET /admin/agent/teams/{team_id}/audit-eventspages the team's own audit history withbefore.
A team's member count in the list, and Show in People on its page, open its members in Admin → Users → People, where each row names the teams that person is in; each person on a team's page opens their account there.
People are named by their profile name and account email, read on each request and never stored; an email that cannot be read is left out. Nothing on these pages changes a team, so they leave no admin audit record. Moving ownership of a team whose owner lost access is not available yet.
MCP calls pass through the control plane. The sandbox holds a scoped relay credential; upstream credentials stay outside it:
sandbox ──[residency mcp credential]──▶ MCP-only relay ──[owner credential]──▶ MCP server
A chat member's CLI process serves several turns, so it holds its residency's
credential (amr.…), returned when the residency is established. On every
call the relay resolves it to the attempt of that chat running under that
residency, then fences that attempt: it must be current and leased, its job
running and not stopped, its host enrolled. Between turns, or once the
residency rotates, expires or is released, the credential authorizes nothing.
A follow-up whose MCP servers did not change therefore keeps the live process;
adding or removing a connection still restarts it, because a CLI reads its
servers once at start.
Each request keeps the attempt and residency generation captured at authentication.
After waiting for OAuth, an approval or an upstream session, the relay checks
both again; it never moves an in-flight request to a later turn. Memory writes,
Web Search admission and approval claims also check the residency inside their
database transactions. Migration 0076_mcp_residency_fence adds the nullable
residency generation to approval records, so another residency or an attempt
token cannot consume that approval. Existing attempt-token approvals keep a
null generation.
The MCP listener is a separate FastAPI app on port 8001. It exposes only the
MCP tool surface; job, host, session, admin, and inference APIs stay elsewhere. The sandbox authenticates to
/v1/agent/mcp over its second fixed relay, the MCP tunnel attached to its
group bridge. The upstream address and the owner's credential stay in the
control plane.
Keep the tool relay off the public main API. The public edge forwards
/api/* to port 8000, where no tool route is mounted. A leaked mcp token
therefore cannot be replayed through that API. Browser connection management,
OAuth callbacks, and approvals share /v1/agent/mcp and must remain on 8000.
Do not block the whole prefix at the edge. Point the sandbox MCP tunnel at the
MCP-only listener on 8001.
Users add connections through /v1/agent/mcp: a remote URL, an imported
mcpServers snippet, or a stdio launch specification. agent_mcp_connections
stores the configuration and credential; credentials are encrypted with
AGENT_SOURCE_CONTROL_ENCRYPTION_KEY under an MCP-specific domain separator.
Discovery stores a capability snapshot. Templates prefill the form; they do
not define a deployment-wide registry or server catalogue.
MCP OAuth uses AGENT_MCP_PUBLIC_BASE_URL for the public API callback and
AGENT_WEB_BASE_URL for the browser's return to /integrations, for example
https://agents.example.org/api and https://agents.example.org. The page reports the authorization
result, verifies a stored credential before showing success, and directs the
user to Test to discover the connection's tools.
The relay, connection API, discovery path and all five coding-agent adapters are implemented and tested for remote MCP. The remaining stdio and optional protocol capabilities are described below and in the conformance record.
The relay enforces these rules:
- Nothing is reachable by name. A tool name's prefix is a binding alias — a name one conversation gave one connection — and a token names a job, so the relay resolves the alias against that job's own bindings. A connection somebody else added cannot be named into existence.
- A session means exactly what it says. Each capability carries a policy:
deny,confirmorallow. An omitted capability is denied, including one the server adds after the session was configured, and a policy whose tool description or schema changed goes back to the owner rather than being treated as an aged approval. confirmholds the original call. The relay records a pending invocation and waits for the owner's decision in the conversation's Tools panel or the account's Approvals inbox. Approval dispatches that request once under the current attempt and permission fence; the CLI receives the result without asking the model to retry. Denial, expiry or changed authorization prevents dispatch.- Credentials are per user and fail closed. A missing encryption key, unreadable ciphertext, expired connection with no refresh, or disconnected connection removes that server from the owner's reachable set and says why on the Integrations page.
- A connection is not a job. Bindings are read fresh on every tool call rather than copied onto a job, so narrowing a session takes effect on its next call instead of at its next turn.
- One endpoint.
/v1/agent/mcpis the merged host: it answersinitialize, lists the job's connections as one catalogue, and routes each invocation to its connection. All five adapters receive it in one entry namedsession, because a fixed name and identity fit runtime allowlists. It answers requests only and opens no server-initiated stream. A legacy SSE server is spoken to by the relay itself, so its stream never reaches the sandbox.
Tools and prompts use <alias>__<name> on the merged surface. Routing looks up
that name in the catalogue; it never splits on __, because aliases can contain
that separator. Ambiguous or long names receive a stable, bounded suffix.
Resources use mcp-resource://<connection-id>/<original-uri> so equal upstream
URIs remain distinct. Reading strips only that prefix and preserves the original
URI and its percent encoding. Resources and prompts have their own permission
scopes and pass through the same decide function as tools. An approval in the
Tools panel releases the held invocation under the same policy.
An unresolved connection is logged and skipped in merged listings so other connections remain usable.
The merged host also serves the platform's own tools, which no connection
provides. web_search is listed when a search provider is configured; the
memory tools memory_save, memory_update and memory_delete are listed for
every task. Both are named in the claim by non-secret markers
(:platform:web-search, :platform:memory), so every MCP-capable runtime loads
the merged endpoint even in a conversation with no connections. A memory tool
writes for the owner and task the fenced token names, never for ones its
arguments name, and only to that task's repository or the owner's user scope.
tests/external/test_agent_mcp_end_to_end.py crosses a real listener, migrated
Postgres database, the image's pi 0.73.1 CLI (or the release named by
AGENT_PI_TEST_VERSION, from the package admin updates download), and shipped
bridge. Its model and
upstream MCP server are threaded HTTP stubs; this is transport and authorization
coverage, not a live-provider acceptance result.
The recorded run seeded one connection with a static credential and discovery
snapshot and a job with a live attempt fence. The model saw
session__github__get_issue. The upstream received server/discover,
initialize, and tools/call, each with the connection credential instead of
the sandbox token. The tool arrived as get_issue with unchanged model
arguments, and its text result returned to the model. The test also verifies
fallback to a handshake when the era probe returns -32601; the namespaced tool
name never reaches the upstream.
The test runs in seconds but needs Postgres, npm, and network access to install the pinned CLI. It skips when those prerequisites are absent and belongs to the external tier. Run it deliberately after changes to the merged endpoint, host, or bridge; it crosses seams the per-layer suites do not.
What the owner added decides where it runs: an address is a remote service
behind this relay, and a command is a sandbox program
(sandbox MCP servers). Nothing else runs one: there is
no connector on the owner's machine and no managed worker, and the API refuses
both locations. 0115_mcp_retire_dead_locations moved every connection stored
at managed-worker or user-connector to sandbox, with its command as
stored (upgrade notes). The
connector tables and agent_mcp_connections.connector_id are unused, and a
later migration drops them.
Apply the schema before replacing the API, resolve legacy bindings, then deploy matching runner and sandbox artifacts.
Schema first. 0008_generic_mcp_connections drops
UNIQUE(user_id, server), renames server to template, adds the connection's
transport, location, endpoint / launch spec and auth profile, moves the legacy
agent_threads.mcp_servers list onto explicit bindings. It retains an empty,
write-constrained mcp_servers column and a generated, always-NULL server
column so the previous API can still list conversations, build the composer
configuration and accept ordinary queued submissions during the host rollout.
Neither column can authorize MCP access. Existing selections survive in the
new bindings; their execution requires the updated API and relay. New
connections are never offered to the old provider-name authorizer.
0011_mcp_rolling_compat adds the same inert columns to databases that already
applied the original, destructive 0008. Apply migrations through head before
replacing the API. This is a forward repair: it preserves connections,
credentials and permissions, and does not require a database downgrade.
Resolve legacy bindings. The revision maps a legacy name only where the thread's
owner has exactly one connection for that provider, and writes everything else
into agent_mcp_legacy_bindings with no_connection or ambiguous_connection.
Review those rows as the list of sessions requiring manual tool selection.
They are retained until reviewed; neither an ambiguous selection nor a missing
connection is silently discarded or assigned to a different connection.
Deploy the sandbox and runner artifacts from the same release. Codex receives
its root-owned system allowlist before its first MCP-enabled launch. The sandbox
contains Kilo 7.4.17 built from the source revision and patch recorded beside
build-kilo.sh; adding a different Kilo version requires a reviewed build.
The runner upgrades retained containers' adapter files and Kilo executables from
these trusted artifacts before launching members, preserving native session
files. Updates require no host bind mount or additional Linux capability.
MCP is enabled on every supported runtime through the normal deployment. There is no feature switch or preview tier. Rollbacks must retain a schema-compatible service and leave the migrated connection and binding data in place; fix forward when a schema downgrade would discard those records.
MCP is independent of the owner's model connection. Credential
rotation uses the existing AGENT_SOURCE_CONTROL_ENCRYPTION_KEY; domain
separation prevents source-control and MCP ciphertext from being read as each
other.
Claude Code retries transient inference failures within the native request,
including HTTP 429 and 5xx responses. The resident launch sets
CLAUDE_CODE_MAX_RETRIES=15 and CLAUDE_CODE_RETRY_WATCHDOG=0: up to fifteen
retries after the initial request, with the pinned CLI's exponential delay
starting at 500 ms, capped at 32 seconds, and adding up to 25% jitter. The CLI
honors integer Retry-After values; its ordinary mode stops if the required
delay exceeds 60 seconds. The runner's execution timeout remains the overall
turn limit, and owner cancellation still interrupts the query during backoff.
Retry notifications appear in the chat with the attempt and wait time.
Retries reuse the pending inference request, preserving completed tool calls, the native session and the single platform prompt. Exhaustion remains a failed native turn with retained context; the runner does not send the prompt again. Ordinary permanent client errors such as HTTP 400 and 403 are not retried; HTTP 401 retains the CLI's native credential-refresh recovery. Deploy the updated runner to apply the launch settings; existing resident CLI processes pick them up when restarted or restored.
An adapter declares MCP support only after both behaviors are checked against the pinned binary: it directs the CLI to the platform relay and prevents every other MCP configuration source from adding servers. Runtime family alone does not establish that boundary.
| Runtime | Pin | How the relay reaches it | Why the repository cannot add servers |
|---|---|---|---|
claude-code |
2.1.220 | --mcp-config with the platform's servers |
--strict-mcp-config, verified against the CLI: without it a committed .mcp.json is loaded alongside ours with no approval step |
opencode |
1.18.9 | the reviewed opencode-freeinference wrapper merges the relay endpoints into the OPENCODE_CONFIG it writes |
the wrapper exports OPENCODE_DISABLE_PROJECT_CONFIG, which covers both opencode.json and .opencode/ |
kilo |
7.4.17 | the reviewed wrapper configures the authenticated merged relay for run and serve; the live bridge verifies connection before reporting ready | the pinned build patches the legacy MCP loader to honor KILO_DISABLE_PROJECT_CONFIG, covering .kilo/mcp.json and .kilocode/mcp.json alongside the regular project configuration |
codex |
0.145.0 | exec and app-server share the merged relay URL and token environment reference; app-server verifies MCP startup | trusted guest-root bootstrap atomically installs the deployment's exact relay URL as the sole server identity in /etc/codex/requirements.toml before launch; repository and user configuration cannot expand that system allowlist. After AGENT_SANDBOX_MCP_URL changes, a retained container's next Codex launch replaces that one-relay policy with the new URL, unless another Codex member of the group is still running; any other policy shape is refused, never replaced |
pi |
0.73.1 | the reviewed pi-mcp-bridge.ts shipped at /usr/local/libexec/, loaded with an explicit -e |
--no-extensions disables every other extension source — the project's .pi/extensions/, the global directory, and anything a settings file names — so the explicit path is the only way in. Verified against the pinned binary and against 1.2.0 from @earendil-works/pi-coding-agent, the package pi moved to: a repository's own extension does not load, the relay's tools are registered as <alias>__<tool>, and a call reaches the upstream. On 1.2.0 the flag also keeps pi's built-in MCP client off, so neither .pi/mcp.json nor the config directory's mcp.json starts a server, with the project trusted or not |
A job naming connections on a runtime that has not declared them is refused at creation rather than run with the tools silently missing.
Codex's system policy is installed from the trusted deployment address without a per-deployment image build.
Use the runtime's credential-reference syntax. Claude Code expands
${AGENT_MCP_TOKEN}. The OpenCode family substitutes {env:AGENT_MCP_TOKEN}
in the raw configuration before parsing it. The wrong syntax sends a literal
placeholder as the bearer token, causing 401 responses. Both forms were checked
against the pinned binaries. The adapters in
apps/backend/cloud_agent_host/runtimes/ choose the address; the adjacent CLI adapter in deploy/sandbox/ writes the reference.
The token travels in the environment, never in argv or the configuration file.
Always pass --strict-mcp-config alongside --mcp-config, including an empty
platform configuration. Strict mode makes that configuration the only source
of MCP servers.
The measurement recorded for hybridInference#1129 used the job's exact CLI
configuration: headless -p, bypassPermissions, and a checkout as the working
directory. A directory containing
{
"mcpServers": {
"evil_repo_server": { "command": "/bin/echo", "args": ["pwned"] }
}
}produces these two system/init lines, differing only in the flag:
| invocation | MCP servers reported by the CLI |
|---|---|
--mcp-config <ours> |
evil_repo_server, github |
--mcp-config <ours> --strict-mcp-config |
github |
The repository server loads without a prompt or approval step under that permission mode. Keep the flag unconditional, including for jobs with no MCP bindings.
The sandbox image ships a headless Chromium (Debian's chromium package) and
cloud-agent-browser, a root-owned launcher that runs one browser per sandbox.
An agent runs cloud-agent-browser start and gets a CDP WebSocket URL that
Playwright (connectOverCDP, Playwright MCP --cdp-endpoint), Puppeteer
(browserWSEndpoint) and Chrome DevTools MCP (--wsEndpoint) connect to. The
design records
why it is built this way and what comes next.
- Shared by the sandbox. A chat and its Forks share one browser, its tabs
and its sign-ins, as they share
/workspace. It runs as its own user,agent-browser(uid 10002), so a member's turn-end reap never stops it. The host runscloud-agent-browser service startas that user before each member launch; a failure is logged and never blocks the chat. - Isolation. Chromium speaks CDP only over a pipe to the launcher, never on
a TCP port. The launcher serves it on loopback behind a random token, in
/run/agent-browser, which onlyagent-browserwrites and only the sandbox group reads. Each client gets its own CDP sessions and cannot reach another client's. - Chromium's own sandbox is off. Docker's default seccomp profile refuses
the unprivileged user namespaces it needs, under runc and Kata alike, so the
launcher starts Chromium with
--no-sandbox. The container and the Kata VM stay the boundary.cloud-agent-browser statusreportschromium_sandbox. Where a probe passes but Chromium still cannot use its sandbox (Ubuntu's AppArmor user-namespace restriction), the service falls back on its own and says so in its log. - Downloads. Chromium cannot write into a member's private download
directory, so the service stages each download and the member's courier,
started by
cloud-agent-browser start, copies it there. A download the service could not hand over reaches the tool as canceled, and the service log says why. - Lifecycle. The service starts Chromium when the first client connects
and stops it after 15 minutes without one (
CLOUD_AGENT_BROWSER_IDLE_S). A crashed Chromium comes back with the next connection. The profile (/home/agent-browser/profile) stays in the retained container until it is removed.cloud-agent-browser stopcloses Chromium for every member. - Resources. Measured with the sandbox's limits: about 174 MiB and 140 threads idle, 321 MiB and 213 threads with four pages open. Both count against the group's memory limit and its 512-process limit, which counts threads. The launcher allows at most four renderer processes.
- Screenshots. Tools write them to
/workspace/.cloud-agent-artifacts(CLOUD_AGENT_ARTIFACTS_DIR). It shows in the workspace files and is added to.git/info/exclude, so it never reaches a diff or a published branch. A turn shows the images written there while it ran (GET /v1/agent/jobs/{job_id}/artifacts/images, at most 200, newest first), and an image opens throughGET /v1/agent/jobs/{job_id}/files/image, which answers PNG, JPEG, GIF and WebP files, recognized by their bytes, up to 10 MiB, past the text preview's bound.
The image build starts the service and runs cloud-agent-browser self-test as
the unprivileged user: two clients connect, one loads and screenshots a page,
and the other must fail to reach its session. Run cloud-agent-browser status
and self-test in a sandbox to check a host; /run/agent-browser/daemon.log
has the service's log. A retained container created from an image without the
browser has none until it is recreated.
An MCP connection can run in each chat's sandbox (location sandbox) instead
of behind the relay, so it reaches the task's localhost, its files and the
sandbox browser. The Playwright and Chrome DevTools presets drive that browser.
The design
explains it.
- What may run there. One exactly pinned package started by
npxoruvx, with no mount or working directory. An import pins a bare package name, or a tag such aslatest, to the release the public registry names then (cloud_agent/mcp/package_pins.py); a range, or a package the registry cannot find, is refused. It may use the owner's credentials, as any command the agent runs may: keys pasted with it are sealed at rest and namedsecret://NAME, and${NAME}is filled from the chat's environment. A sealed key shorter than 8 characters is refused when it is added, imported or set by an edit, as a short environment Secret is: the runner masks only longer keys in the chat's output. One stored before that rule still runs, unmasked. The host hands both only to the slot's runner, in a file only that runner's user reads (/run/agent-mcp-slots/<slot>/secrets.json). The admission names variables, never values. An edit keeps every stored key it does not set, or sets blank, and removes one whosesecret://NAMEits launch no longer names. Stored keys stay only with the same program: the same command and the same arguments, of which only the package's release may change. An edit that changes anything else, such as the package, the command, a runner option or an argument, is refused until it sends them again. - How it runs. Before it launches a member's CLI, the host writes the
slot's admission as root, then starts the image's MCP gateway,
cloud-agent-mcp, asagent-mcp(uid 10003) onhttp://127.0.0.1:47801/mcp. Last, it starts the slot's runner asagent-mcp-<slot>(uid 12001 + slot), which runs that slot's servers. The CLI reaches them as the MCP serversandboxwith its relay token. A server can affect only its own slot. A failed step is logged and leaves the chat without those tools, not without its turn. - Changes. Turning a server off, adding one or changing its version takes effect at the next turn, which starts a new CLI that resumes the session.
- No approval step. The relay's per-tool policies do not apply: a sandbox server can do what the agent's shell can.
- Tools in settings. When a chat's CLI lists a slot's tools, the gateway
reports each server's list to the relay (JSON-RPC method
cloud_agent/sandbox_tools, with the member's relay token). The control plane stores a list only when it changed, and the connection's card shows it. - Where to look.
/run/agent-mcp/gateway.logrecords the methods each slot called, readable asagent-mcp. A slot's runner logs its servers' starts and stops in/run/agent-mcp-slots/<slot>/runner.log, and each server's own output is in/run/agent-mcp-slots/<slot>/<alias>.log, readable asagent-mcp-<slot>. Admissions are in/run/agent-mcp-admissions.
A host records what goes wrong in each retained sandbox as incidents, in the sandbox's event list on the admin sandbox page; the sandbox list shows the newest one within a day under the sandbox's name. The design explains it.
- What is recorded.
out_of_memory(Docker'soom: the OOM killer killed some process in the container),container_exited(adiewith nokillbefore it, so nobody asked for it),memory_highandpids_high(a reading at 90% of the limit),browser_crashed(Chromium exited with no stop requested), andmcp_server_failed(a program could not start, or exited on its own; it names the chat and the server). A stop, idle stop or deletion the platform made is not an incident. - Repeats. The first of a kind goes out at once; repeats within the next
minute become one
.repeatedrow with a count. A sandbox records at most 60 rows an hour, and onedroppedrow counts the rest. - What people see. A chat shows one line in a running turn for
out_of_memory,container_exitedand its ownmcp_server_failed. The agent's next prompt lists what happened since it was last told, and a chat's first prompt says the sandbox has a browser. - What it carries. Kinds, times, exit codes, limits and MCP aliases only: no stderr, URL, command line or environment.
- Where to look. The browser service journals to
/run/agent-browser/events.jsonl, each slot's MCP runner to/run/agent-mcp-slots/<slot>/events.jsonl. The sandbox manager reads them every minute and holds the rows until a heartbeat answer saysincidents: "accepted"; a control plane older than this release never does, and the rows wait.
Complete host provisioning, network checks, enrollment, and credentials before enabling job claims. Use this checklist for each deployment.
Every sandbox group runs on one routable bridge of its own — setup, the agent
and each Fork sibling alike — and this deployment's host nftables tables
decide what it reaches: public IPv4 on TCP 80/443, the one resolver in
AGENT_EGRESS_DNS_SERVER, and the group's MCP relay for tools — and nothing
else. Model calls go to the owner's provider over the public TCP 443 rule. There is no mode to
choose and no setting that opens it further; changing what a sandbox can reach
is a design change. The sandbox network below has the
whole rule set, how to read it on a host, and what each refusal means.
A host therefore needs, before it can run a job:
AGENT_EGRESS_CHAT_NETWORK_POOLandAGENT_EGRESS_DNS_SERVER. Neither has a default: the pool must be this deployment's own, and the resolver is the only DNS a sandbox can use.- The MCP relay running on the relay network,
AGENT_RELAY_NETWORK, which the host compose derives as<COMPOSE_PROJECT>-agent-relays. It is declaredinternal: trueand no sandbox group ever joins it: lifecycle code finds the MCP tunnel there by alias and attaches it, under the same alias, to each group's bridge. If the alias has no running container there, or more than one, the group network is refused before the Agent CLI starts. - A kernel with nftables, bridge family included. Every runner's preflight installs this deployment's tables and reads them back before it claims anything.
- Docker 28 or newer, client and daemon. The relay joins a group bridge with
a gateway priority of -1: a container on several networks takes its default
route from the highest priority and then by network name, and
freeinference-chat-net-*sorts before<COMPOSE_PROJECT>_internal, so at the default priority the first group a relay joined would become its way out — and its SSH session would meet the sandbox rules and die for every group sharing it. Preflight refuses an older Docker, and a relay found on a bridge without the priority is attached again with it.
Checks run at four points:
sandbox_host.sh preparevalidates network settings with the candidate runner image's parser before draining any runner.sandbox_host.sh checksends requests from containers on the relay network. It requires HTTP 200 from MCP/healthzbefore publishing the exact-SHA ready marker.- Each runner's preflight installs and reads back the host nftables tables.
- Each group cold start checks inside the guest, as the sandbox user, for a default route, the configured resolver, and the relay's TCP port. Failure stops the sandbox, preserves its workspace, and fails the turn.
DNS resolution alone does not establish relay health: a tunnel can retain its name while its upstream is down.
For a runner host on another machine, see Running runners on another machine: the MCP relay is an SSH tunnel to the MCP-only listener.
Let Compose create the relay network. A network created by hand under the same
name is refused because its labels do not match. The
freeinference-chat-net-* networks are different: lifecycle code creates them
through the Docker API, deriving their names and labels from the sandbox group
and generation and their host interface names from this deployment's prefix.
It allocates one /29 from AGENT_EGRESS_CHAT_NETWORK_POOL and lets Docker
arbitrate concurrent claims. A group's bridge is disconnected and deleted on
idle or lost stop while Docker keeps the container writable layer, and wake
creates it again. Sandbox Manager reconciliation also removes labelled bridges left by an
interrupted create after a five-minute grace period. The grace prevents status
reconciliation from deleting a bridge between creation and sandbox attachment
while still reclaiming bridge interfaces and IPAM. A bridge that an environment
test or prebuild still runs on is that run's, and the run removes it when it
ends. A bridge reconciliation cannot remove, such as one with an unknown
container attached, is logged as chat_network_prune_failed and left for the
next pass; it does not fail the host's sandbox listing.
Jobs run on enrolled hosts. The deleted agent-job-runner Actions workflow
used an unisolated process backend and shared dispatcher credential; it is
not an execution or fallback path in this service.
Deploy at least one host through deploy/host/deploy.sh, enroll it with the
control plane, and let its runner service poll /v1/agent/worker/claim. A
runner cannot claim until its exact replica attests the retained protocol,
container image/runtime pins, sandbox manager configuration, execution profile, and
host capacity.
Standalone, the host stack needs more than an enrolment credential. Start from
deploy/host/.env.example: COMPOSE_PROJECT, AGENT_SANDBOX_IMAGE,
AGENT_EGRESS_CHAT_NETWORK_POOL and AGENT_EGRESS_DNS_SERVER are required
(docker-compose.sandbox.yml interpolates each with :?, so an unset value
stops the render), alongside AGENT_SANDBOX_MANAGER_TOKEN, the two tunnel
destinations with their own keys and known-hosts files, and the sandbox manager
reverse-forward trio for the separate control-plane and host Compose stacks.
Host credential — mint one short-lived enrollment token for a named host:
# Admin API; the response shows the single-use token once.
POST /admin/agent/runner-hosts/enroll-tokenPut that value in AGENT_ENROLL_TOKEN for first start. Registration replaces
it with a long-lived credential bound to that host and persists the credential
under AGENT_HOST_STATE_ROOT; every heartbeat and claim then authenticates
with it. A host name in a request is never accepted as identity by itself.
Sandbox manager credential — generate one
AGENT_SANDBOX_MANAGER_TOKEN per environment and set the same value on the
control plane and every host's Sandbox Manager. It authorizes file, terminal, Git, and
session-lifecycle calls. It is not a claim credential and never enters a chat
container.
GitHub credential — install a GitHub App and give the control plane its private
key. Nothing needs to be minted or rotated by hand: the platform signs a
nine-minute App JWT (github_app.py; GitHub refuses more than ten) and
exchanges it for an hour-long token scoped to the installation covering the
repository, which the runner's clone credential, branch lookups and
repository reads use.
The key reaches the container through a read-only bind mount, at a fixed path. Two variables, and they are not the same variable:
# The *host* file that holds the PEM. This is the mount source.
AGENT_GITHUB_APP_PRIVATE_KEY_FILE=/etc/cloud-agent/github-app.pem# compose: $AGENT_GITHUB_APP_PRIVATE_KEY_FILE ──▶ /etc/cloud-agent/github-app.pem:roAGENT_GITHUB_APP_PRIVATE_KEY_PATH names the location inside the
container and defaults to that mounted path in
deploy/control-plane/docker-compose.yml, so the Compose deployment sets only
AGENT_GITHUB_APP_PRIVATE_KEY_FILE. Overriding the path variable without
mounting anything there produces a service that starts with GitHub
unavailable — see below. Put the file on the host, owned by the service user
and chmod 600. The control-plane image does not carry the deployment's
config/, distributions/, or var/data/ directories.
Whether GitHub is configured is decided by the saved GitHub App ID, imported
from AGENT_GITHUB_APP_ID at the first settings initialization. The
key path always has a default in this stack, and /dev/null is mounted there
when no App exists, so the path says nothing. With the App ID set, a key that
cannot be loaded — missing, empty, unreadable by the service user, or not a
valid key — does not stop the control plane (issue #47):
- Integrations and the task composer say GitHub is temporarily unavailable.
Creating a job (a temporary workspace's too), a runner's claim, or a branch
lookup answers
503 source_control_unavailablewithRetry-After: 30; queued jobs and goal wakeups wait without spending any retries. - The log carries one
ERRORevent,source_control_load_failed, with areason(key_file_unreadable,key_missing,key_invalid,unexpected) and, for a file, its path and the OS error. It repeats only if the reason changes. If jobs are queued and none can start, the existingqueue_not_startingalert fires as well. - The service reads the key again every 30 seconds. Fixing the file in place —
its owner, mode or content — recovers the same process within a minute and
logs
source_control_load_recovered. Change the App ID and inline credentials through Admin → Settings. Changing the host path of the key bind mount still needs the container recreated.
Alternatively save an inline PEM in the Settings page; it is encrypted in the
database and takes precedence over the mounted key. The legacy
AGENT_GITHUB_APP_PRIVATE_KEY environment value is accepted for the one-time
import in deployments that pass it through. The mounted file remains available
when the saved inline key is empty.
For checkout, and for the pushes and pull requests agents make as their
owners, the App needs contents: write and pull_requests: write: an owner's
GitHub App user token holds only what both the App and the owner may do. To enable Create repository in the workspace and
new-task pickers, also configure the GitHub App's repository permission
Repository creation: write. GitHub also accepts
Administration: Read and write, but Repository creation grants only the
creation operations this feature needs. See GitHub's
repository creation permissions.
Existing installations may need their owners to approve the updated
permissions on GitHub. Without that permission,
repository creation reports that the App cannot create repositories; existing
tasks continue to use their existing permissions. The App does not need
workflows; without it, GitHub refuses a push that changes a file under
.github/workflows/. Revocation is uninstalling the App.
Creation calls GitHub's POST /user/repos with the connected user's encrypted,
refreshable GitHub App user token, kept in the control plane. It creates only
in that user's personal account, defaults to private, and initializes a README.
No long-lived credential enters a sandbox. Creation does not add a local
repository grant or bypass the deployment allowlist: if the App installation
covers only selected repositories, the owner grants access to the new one
through Configure repository access and refreshes the picker. A transient
access-check failure after creation is handled as a created repository awaiting
access, rather than another creation attempt.
For the connect flow (a user authorizing their own repositories) the App also needs its OAuth half, with the callback pointing at this deployment's frontend:
AGENT_GITHUB_APP_CLIENT_ID=Iv1.xxxxxxxx
AGENT_GITHUB_APP_CLIENT_SECRET=...
AGENT_GITHUB_APP_INSTALL_URL=https://github.com/apps/<app-slug>/installations/new
# App setting "Callback URL": <agent web origin>/connected?provider=github
# for example https://agents.example.org/connected?provider=githubSet the callback to the agent web's own origin, where the callback page and
session cookie live. The authorize link sends no
redirect_uri, so GitHub uses the App's first Callback URL. Update that setting
whenever the web origin moves.
A callback left on an origin the web app no longer serves breaks the connect flow without any error from GitHub, so move it together with the web app.
The composer and the Integrations page derive the Connect link from
AGENT_GITHUB_APP_CLIENT_ID and GitHub's web OAuth endpoint. The install URL
is offered after connecting whenever the App is not on the user's own account.
Keeping those two URLs separate is required for reconnects: GitHub sends an
already-installed App's /installations/new link to its settings page without
invoking the OAuth callback.
Configure repositories uses the installation URL; GitHub selects the user
or organization settings page. Set AGENT_GITHUB_APP_INSTALL_URL to
/apps/<slug>/installations/new. Pointing it at the OAuth endpoint leaves a
user with no installation looping through authorization.
The same App also supplies the runner's clone credential, and the two are
not the same token. An installation token inherits every permission the App
holds unless it asks for less, so the clone token is requested as
contents: read on the single repository being worked on. Checkout does not
inherit the App's broader authority.
No GitHub token reaches a sandbox. Each member's residency carries a
placeholder (agh.) that git and gh present to GitHub, and the owner as
the commits' author (GIT_AUTHOR_*, from the owner's GitHub noreply
address) with the deployment's AGENT_GITHUB_COAUTHOR as co-author. Every
group bridge resolves github.com and api.github.com to the host's
github-tunnel, so every process in the sandbox reaches the relay, and
every process trusts the deployment's GitHub relay CA, whether or not it holds
a placeholder: a setup script cloning a public repository goes through the
relay too, as nobody. Before the first process starts, a root exec writes the
CA and a bundle of the image's roots plus the CA to /etc/freeinference/,
root-owned and read-only. The tunnel forwards TLS unopened to the control
plane's GitHub relay, a listener of its own that holds the certificate.
The relay accepts the placeholder only while its residency is the chat's hot one, serving a running, leased, unstopped turn on an enrolled host. It then sends the request with the owner's own GitHub App user token if the request is about the conversation's repository and on its allowlist, with no credential at all if it is about anything else, and refuses repository administration, secrets, GraphQL, branch deletion and tags. A pull request it sees created is recorded for the conversation. The owner's token is renewed in the control plane, so a long turn does not outlive it.
To turn it on:
- Run
deploy/control-plane/github-relay-certs.sh <dir>. Keepca.keyoffline; giverelay.pemandrelay.keyto the control plane (AGENT_GITHUB_RELAY_TLS_DIR) andca.pemto every host (AGENT_SANDBOX_GITHUB_RELAY_CA_FILE). - Give
relay.keyto the relay's user. Thegithub-relaycontainer runs as uid 10001 and reads the directory through a read-only bind mount, which keeps host ownership, so a key the operator owns at0600fails withPermissionError. Run the script as root, which sets the key to10001:10001and0400, or runsudo chown 10001:10001 <dir>/relay.keyas the script prints. Moveca.keyout of<dir>before you mount it. - Enable the
github-relayCompose profile on the control plane and on each host, and give each host'sgithub-tunnelits own SSH key restricted withpermitopento the relay's port (AGENT_GITHUB_TUNNEL_*). - Set
AGENT_GITHUB_COAUTHORto the App bot's noreply address.
Nothing on the platform scans what goes through the relay: there is no workflow-file or secret check of its own. Repository branch protection, GitHub's push protection and the repository's CI still apply, and a push that changes a workflow needs the App's Workflows permission. The design is in agent-native pull requests.
With no App configured the claim returns no clone token and the runner clones anonymously, which is enough for a public repository. A private repository needs the App.
Without the App and the relay, agents cannot push or open pull requests: jobs
still run, and their changes stay in the retained workspace, where the owner
can read them in the Git pane or download them as an archive. There is no
static-token fallback; AGENT_GITHUB_TOKEN is no longer read.
Administrators can also attach an SSH-accessible server or provision a CloudLab experiment from Servers, then select the ready Host as the global location for new sandboxes. The compute-resource runbook covers operator profiles, credentials, automatic renewal and guarded resource release. Existing retained sandboxes remain on their original Host when this selection changes; neither Host Remove nor Forget destroys the underlying machine.
Run the host agent as a standing service so queued jobs can be claimed after
crashes and reboots. Configure enrollment and the shared sandbox manager token in .env.
The example below includes the shared-kernel setting for trusted internal
repositories. For untrusted repositories, use the default kata backend and
complete Kata provisioning.
AGENT_ENROLL_TOKEN="<single-use token for this host>"
AGENT_SANDBOX_MANAGER_TOKEN="<same environment value as the control plane>"
AGENT_SANDBOX_BACKEND=container # explicit trusted-repository downgradeThe AGENT_SANDBOX_BACKEND=container line is a deliberate downgrade: sandbox
groups share the host kernel. Before enabling Kata for new groups, provision
the host and verify the guest runtime and relay paths with the checks below.
Existing retained runc containers need the separate migration treatment
described there.
Compose uses restart: unless-stopped; enable the host's Docker daemon to carry
the service across reboots. The host lifecycle commands are:
deploy/host/sandbox_host.sh up # build images, tunnel + runners
deploy/host/sandbox_host.sh preflight # validate this host against an existing image
deploy/host/sandbox_host.sh check # probe all relay/control paths
deploy/host/sandbox_host.sh status # containers + recent log tail
deploy/host/sandbox_host.sh down # stop them
# `up` in its two halves, for a deployer that has to put something between
# them. Nothing in `prepare` replaces a container that is serving.
deploy/host/sandbox_host.sh prepare # pull or build, Kata smoke test, candidate check
deploy/host/sandbox_host.sh activate # drain, replace, proveUse the standalone host stack even on the control plane's machine, with tunnel
destinations pointing to its local listeners. The old co-located
agent_runner.sh overlay did not move; the repository extraction record records
that decision. preflight checks Docker, retained-session configuration, host
isolation, and the configured image. up repeats those checks against the image
it deploys.
The script validates the required environment before starting and surfaces the runner's own preflight verdict (control plane reachable, retained image/runtime usable, Docker data-root correct, relay network present and route-less, host network rules installed and read back) instead of leaving it in a detached log. Equivalent by hand:
# Put a tested alternate version in the corresponding *_EXTRA_VERSIONS build
# argument, then select it in Admin. Default versions are shared protocol pins
# and change in source together with SHARED_SANDBOX_RUNTIME_PINS.
docker build -f deploy/sandbox/Dockerfile.agent-sandbox \
--build-arg CLAUDE_CODE_EXTRA_VERSIONS="<exact-version>" \
-t freeinference-cloud-agent-job:latest .
AGENT_SANDBOX_IMAGE=freeinference-cloud-agent-job:latest \
AGENT_HOST_SLOTS=4 \
docker compose -f deploy/host/docker-compose.sandbox.yml \
up -dAGENT_SANDBOX_IMAGE is required by the host compose rather than defaulted
(both the runner and the Sandbox Manager interpolate it with :?), so name it
explicitly; deployments tag
it per environment (…-job:staging, …-job:prod) so a rebuild on one side
cannot replace the other's image.
claim_job uses FOR UPDATE SKIP LOCKED, so every host shares one queue
with no leader and no sharding.
| Variable | Default | Notes |
|---|---|---|
AGENT_SANDBOX_BACKEND |
kata |
process (no isolation) refuses to start unless AGENT_SANDBOX_ALLOW_UNISOLATED=1. On the default kata, deploy refuses until the host has the shim and one real container proves it gets its own kernel; container is an explicit, and loudly restated, shared-kernel downgrade |
AGENT_SANDBOX_IMAGE |
required | Image built from Dockerfile.agent-sandbox or an equivalent deployment-owned image |
*_EXTRA_VERSIONS |
blank | Comma-separated tested versions to bake beside each source-reviewed default. Select one for new chats in Admin; retained chats keep the version they started with. The five defaults are not host settings because runner/control-plane capability admission attests them as one protocol unit |
AGENT_RUNTIME_DOWNLOAD_NETWORK |
bridge |
Docker network for the download step of an agent update from the admin Settings page. Only npm runs there, with package scripts off; the scripts and the check run with no network (design) |
AGENT_RUNTIME_REGISTRY_URL |
public npm | Registry a host downloads updated agents from before its first heartbeat answer. Afterwards the host uses Agent package registry in Admin → Settings → Agent versions, which the control plane also lists versions from. pi updates come from @earendil-works/pi-coding-agent; a private mirror must carry that package as well as the image's @mariozechner one |
AGENT_EGRESS_CHAT_NETWORK_POOL |
required | This deployment's RFC1918 IPv4 pool, one daemon-arbitrated /29 per active group bridge. No default: it must be disjoint from every other deployment's pool on the machine — of two that overlap, the host rules refuse the one installed later — and from the host's Docker, VPN and private-service routes. A group's bridge is removed on stop and rebuilt on wake |
AGENT_EGRESS_DNS_SERVER |
required | The one IPv4 resolver sandboxes may use, on TCP and UDP 53. It is the only exception to the protected ranges, so a site resolver on a private address or on this host works. Not loopback, and not inside the pool; see Choosing the resolver below |
AGENT_EGRESS_PROTECTED_CIDRS |
— | Comma-separated IPv4 blocks to refuse on top of the built-in host, private, link-local/metadata, CGNAT, multicast and reserved ranges — an institution's services on public addresses, for example. It can only add protection. Set the same value on the control plane, where an owner's SSH server and model connection checks refuse these blocks too |
AGENT_RELAY_NETWORK |
derived | Where lifecycle finds the MCP relay; the host stack derives <COMPOSE_PROJECT>-agent-relays. It must exist and be internal, or preflight refuses, and no sandbox group ever joins it. Set it only to name an existing network |
AGENT_EGRESS_SETUP_TIER, _AGENT_TIER |
removed | Retired with the network tiers and the Squid proxy, together with AGENT_EGRESS_ALLOWLIST, AGENT_EGRESS_NETWORK_*, AGENT_EGRESS_PROXY_*, AGENT_EGRESS_STOP_GRACE, AGENT_SANDBOX_NETWORK and AGENT_SANDBOX_ALLOW_OPEN_NETWORK. Nothing reads them, so setting one changes nothing; delete them from .env |
AGENT_RUNNER_HOST |
host name | Enrolled execution environment, shared by every turn it runs — the unit the admin placement switch picks between. deploy/host/sandbox_host.sh fills it from the host's own name. Never derived inside the container, where the hostname is a container id. After enrolment the persisted credential is authoritative, so editing this renames nothing. Unset keeps the environment out of the pool and changes nothing else. Distinct from AGENT_PHYSICAL_HOST, which is the display-only inventory name grouping environments that share one server |
AGENT_PHYSICAL_HOST |
host name | Physical server this environment runs on, reported at enrolment as the physical_host label and used to group the admin inventory. sandbox_host.sh defaults it to hostname -s; set it explicitly when invoking Compose directly. Never a container hostname or a runtime suffix |
AGENT_HOST_SLOTS |
3 |
Turns at once until an admin sets a number on the Servers page; at least two, at most 128. AGENT_RUNNER_REPLICAS is no longer read |
AGENT_RETAINED_DOCKER_DATA_ROOT |
/var/lib/docker |
Expected daemon DockerRootDir. Keep this default. Put the durable disk under it by bind-mounting <disk>/docker and <disk>/containerd onto /var/lib/docker and /var/lib/containerd. A custom daemon data root is rejected. Verified by the host and sandbox manager, and never mounted into a chat container |
AGENT_HOST_STORAGE_PROBE |
overlay_root |
Which probe measures the filesystem holding the sandbox writable layers, shown as the Servers page's Storage column. off stops measuring storage on that host and is the rollback; an unrecognized name reports unsupported rather than measuring something else. A reading is published only for a topology the host deployment has attested; see How full is the sandbox storage? |
AGENT_STORAGE_MIN_AVAILABLE_MIB |
1024 |
Bootstrap value for Storage reserve for new sandboxes; the admin runtime setting replaces it after the host's first settings heartbeat. Fresh verified available space below this reserve declines a new sandbox allocation without spending the turn. Existing sandboxes may resume; unknown capacity does not establish a pressure verdict. This is an admission guard, not a disk quota |
AGENT_SANDBOX_UID / _GID |
10001 |
Only for a custom sandbox image; must match its user |
AGENT_REPO_ALLOWLIST |
— | Comma-separated owner/name, or owner/*, for the single-tenant dogfood. Every admitted user may have the platform clone the listed repositories with the deployment's App; pushing still needs the user's own GitHub connection. Users who connect the App themselves do not need it; unset simply means the only entitlement is a user's own connection |
AGENT_GITHUB_APP_CLIENT_ID / _CLIENT_SECRET |
— | The App's OAuth half. Only the user-facing connect flow needs it; minting installation tokens uses the private key alone |
AGENT_GITHUB_APP_INSTALL_URL |
— | GitHub App installation URL, https://github.com/apps/<slug>/installations/new. Offered after connecting whenever the App is not on the user's own account, and behind Configure repositories. The control plane adds a short-lived, user-bound state; set the App's Callback URL to the agent web's own origin, <origin>/connected?provider=github (see 2. Credentials) |
AGENT_GITLAB_OAUTH_CLIENT_ID / _CLIENT_SECRET |
— | GitLab.com OAuth application credentials, kept on the control plane. Configure only read_user and read_api scopes |
AGENT_GITLAB_OAUTH_REDIRECT_URI |
— | Exact registered callback, normally <frontend>/agents/connected?provider=gitlab. HTTPS is required except for localhost development |
AGENT_SANDBOX_MCP_URL |
— | Host only: MCP-only relay URL as this host's sandboxes see it. The standalone host compose derives it from the MCP tunnel alias, and the control plane no longer reads it; never point it at inference or the full control API |
AGENT_MCP_TUNNEL_* |
— | Remote MCP-only listener destination and independent restricted SSH key/known-hosts; see ENVIRONMENT.md for the complete set |
The authenticated /agents/integrations page is the only supported place to
start either OAuth flow. OAuth state is unpredictable, bound to the signed-in
user and provider, expires after ten minutes, and is consumed once. GitLab also
uses PKCE. GitLab access and refresh tokens are encrypted before persistence,
rotated on refresh, never returned to the browser, and revoked best-effort on
disconnect.
GitHub repositories retain the complete Agent lifecycle. Checkout and branch discovery use short-lived GitHub App installation tokens; the agent pushes and opens pull requests as the owner through the GitHub relay. The agent writes the branch, commits, pull-request title and description itself, and the platform adds nothing to them. GitLab is intentionally narrower in this release: it verifies the authenticated GitLab user and shows their accessible projects for discovery. GitLab projects do not appear in the Agent task composer, and the control plane does not claim to read their source or publish GitLab merge requests yet.
Kata gives each sandbox group a lightweight VM with its own kernel. Use it for untrusted repositories, whose build and test scripts execute arbitrary code. Plain containers share the host kernel and are limited to trusted internal repositories.
Install the runtime on the host. The runner image contains a Docker client;
the host daemon must resolve io.containerd.kata.v2. Run once per host as root:
sudo deploy/host/setup_kata_runtime.shIt is idempotent, checks the host can actually start VMs (/dev/kvm, CPU
virtualization extensions) before downloading anything, verifies the release
against a pinned SHA-256, and links the shim into /usr/local/bin so
containerd finds it without a unit-file edit. The version is pinned in the
script; upgrading is a two-line change there, and --check then fails on every
host still carrying the old one.
The setup also selects the release's packaged vmlinuz-debug.container for
the default Kata configuration and sets runtime.disable_guest_seccomp=false.
Kata's packaged default otherwise ignores the OCI seccomp filter even when
Docker accepted the container security options. That kernel is built with Kata's reviewed
eBPF/BTF debug fragment; the ordinary guest kernel is not sufficient for the
process observer. --check verifies both the asset and the active config, and
runner preflight goes further: it starts a disposable sandbox with the same
non-root, capability-dropped and no-new-privileges shape as a real retained
guest, then exercises the same fixed privileged exec used to start the bundled
observer. Its self-test requires all three lifecycle probes to attach, executes
one short-lived child, and requires that child's fork, exec, and exit records to
arrive through the ring buffer. A missing BPF syscall, BTF image, tracepoint,
event-delivery path, or exec-time permission is therefore a deployment failure
before the runner can claim work, not an empty process list under a completed
turn. The same probe also requires the fixed tool-marker helper used by Claude's
exact boundaries. See Kata's
debug-kernel build contract.
The fixed observer and platform lifecycle helpers run as guest root. Agent CLIs and
repository code still run under their per-member UIDs with all capabilities
dropped and no-new-privileges; they cannot read, edit, or directly inject
records into the root-only journal. Claude's fixed non-root marker helper talks
to it over a credential-checked Unix socket. A same-UID tool can still execute
that helper, so exact per-tool association is diagnostic, never a security or
billing authority; the eBPF lifecycle census itself is independent of marks.
Kata containers retain BPF, PERFMON, SETUID, SETGID, CHOWN,
DAC_OVERRIDE, KILL and SYS_PTRACE in the bounding set for the fixed guest-root helpers:
tracing, the UID-filter self-test, socket ownership, member cleanup and verifying
the marker sender's /proc/<pid>/exe across member UIDs.
Kata privileged exec cannot acquire capabilities outside that original set.
Ordinary exec explicitly enters through setpriv --no-new-privs, because
Kata 3.32 does not inherit PID 1's setting for Docker exec. Preflight requires
non-root execution, zero effective/permitted capabilities, NoNewPrivs=1
and Seccomp=2, then runs the observer and marker self-tests and requires a
real non-root pre/post pair to reach the trusted observer journal. Privileged
helpers also verify the inspected container's Kata runtime and execute by
that verified container ID; changing a runner setting cannot promote an old
runc container into a guest-root target. A shared
container's periodic Docker statistics are group totals, so current member
turns deliberately report aggregate CPU/memory/I/O/network as unavailable
(coverage=0, sampler_version=shared-member-v1) instead of duplicating a
sibling's work. Process existence is separate, event-driven telemetry:
fork/exec/exit survives even
when a process begins and ends entirely between two statistics samples, and
the kernel's records carry what that process consumed — the group's peak RSS
and the bytes it read from and dirtied to storage at exit, and its scheduler
runtime once it left the CPU for the last time (tick-based CPU time reads
zero for most sub-millisecond processes, so it is not used). Those are the
per-call CPU, memory and I/O numbers under Kata: a process is charged to the
tool call whose window alone contains its start, and a process that
overlapping calls could both own, one whose thread group has not fully
settled, or any process in a census that lost events, is charged to no call
and marks those calls as partial. Work done inside the Agent CLI's own
process belongs to no call. The self-test in preflight also requires a
sub-millisecond child's records to carry non-zero CPU, a non-zero peak RSS
and a group-exit flag, so a guest kernel whose BTF does not relocate them
fails deployment rather than reporting zeros; storage and read/write byte
accounting are kernel configuration and arrive as unavailable when absent.
The per-turn table is therefore a member-UID lifecycle census of process starts observed
after that turn's cursor, not a point-in-time snapshot of resident processes
that were already running before the turn and not a whole-container total.
Two settings may shorten the VM boot that every new or resumed sandbox group waits for. Both stay off until they are qualified on a Kata host (#1054). To try them on one host, pass them to the installer:
sudo KATA_STATIC_SANDBOX_RESOURCES=true KATA_DEFAULT_MEMORY_MIB=1024 \
deploy/host/setup_kata_runtime.shKATA_STATIC_SANDBOX_RESOURCES=truesetsruntime.static_sandbox_resource_mgmt. Kata then boots each VM with its container's--cpusand--memoryalready added todefault_vcpusanddefault_memory, instead of hot-plugging them after boot. The VM cannot grow afterwards. Nothing on the platform resizes a running sandbox, and a group keeps the limits it was created with, so this loses nothing. Nothing caps the VM at the host's memory either: a VM larger than the host's memory plus 1 GiB, the most Kata lets QEMU address, fails to start, where hot-plug would stop adding memory at the host's limit.KATA_DEFAULT_MEMORY_MIBsetshypervisor.qemu.default_memory, otherwise Kata's 2048 MiB. Each VM gets it on top of its container's memory limit, with or without static sizing. It must hold what that limit does not cover: the guest kernel, its page structures for all of the VM's memory (64 bytes per 4 KiB page), and the Kata agent. A container started without a limit, such as a deployment probe, gets nothing else. The installer refuses less than 256 MiB, which Kata would replace with 2048 MiB without saying so.
The installer writes them to one drop-in, config.d/cloud-agent-vm-boot.toml
beside the runtime configuration, and --check reads them back from it, so
deployment passes nothing. Kata applies every file in that directory over the
main configuration, so --check refuses any other file there and any change
to this one. An install without the settings deletes the drop-in, says so,
and restores Kata's defaults. The control plane's automated deploy of a
managed compute host is such an install whenever it installs prerequisites,
which it does by default, so each of those deploys reverts the settings until
they are passed to the installer again. They are for qualification runs until
the defaults switch. Every install starts a new runtime generation, so
runners requalify the guest either way.
VM templating stays off: it shares kernel memory and RNG/KASLR state across tenants. The release's uncompressed copy of the same debug kernel is not offered either. QEMU boots it directly, without the decompressor that randomizes the kernel's address (KASLR), so every guest would run its kernel at the same published addresses.
Docker's embedded DNS resolver (127.0.0.11) is outside the guest. After each
Kata activation a trusted guest-root helper therefore writes two files: a relay
block in /etc/hosts with the relays' addresses on that group's bridge, and
/etc/resolv.conf naming AGENT_EGRESS_DNS_SERVER, the only resolver the host
rules let a group reach. Under runc neither is touched: the container was
created with --dns set to that same server, and Docker's resolver forwards
to it. There is no network switch and no guest restart for networking — setup
and the agent share the group's bridge. A running guest is never rewired
underneath it either, because Docker's live network changes alone do not update
Kata 3.32's guest interfaces: a running sandbox that is not on exactly its
group bridge is refused until it has been stopped.
When switching a host from runc, keep its retained runc groups on a compatible sandbox manager or migrate them separately. A backend setting does not change the runtime of a stopped Docker container; the privileged helper fence rejects those old containers. Do not delete retained workspaces as part of enabling Kata for new jobs.
Requirements: an x86_64 Linux host, bare metal or with nested virtualization enabled. Kata cannot run without KVM.
Installing the shim is necessary, not sufficient. A working guest kernel
does not prove the sandbox can reach either relay. Before changing the backend
for new groups, check the provisioned runtime and the running stack from the
host checkout, using its .env:
AGENT_SANDBOX_BACKEND=kata deploy/host/sandbox_host.sh preflight
AGENT_SANDBOX_BACKEND=kata deploy/host/sandbox_host.sh checkpreflight verifies the configured image and guest kernel. check starts relay
probes under Kata on the relay network (AGENT_RELAY_NETWORK) and requires
HTTP 200 from MCP /healthz. It also checks the live
runner's full sandbox preflight — which installs and reads back the host
tables — sandbox manager, and control-plane paths. The relay network is only where
lifecycle finds the MCP relay; retained groups get their own bridges during
activation, and the in-guest check at each cold start covers the path from
there. The compose ..._internal network is the host services' network and
cannot substitute for either check.
The Kata relay probes resolve only the configured relay endpoints through
Docker inspection and pass their addresses into the guest, matching the
runtime's fixed-host approach. A bare alpine getent probe depends on Docker's
embedded DNS and does not verify this path. A failed check needs diagnosis of
the reported guest, relay, or service path before switching new groups.
After those checks pass and existing runc groups have been accounted for, drop
AGENT_SANDBOX_BACKEND=container from the host's .env and redeploy.
deploy/host/sandbox_host.sh up repeats the checks for the image it builds:
- with the default
katabackend, the shim must be installed, and one real container started from the sandbox image must report a kernel that is not the host's. A runtime the daemon accepts while the sandbox still lands on the host kernel fails this — that is the case every cheaper signal misses. - there is no automatic fallback to a shared kernel. Accepting one stays
possible and stays explicit: set
AGENT_SANDBOX_BACKEND=container, and every deploy restates what it costs.
The control plane independently enforces the same fact at scheduling time.
AGENT_DEFAULT_EXECUTION_PROFILE is already untrusted-kata-v1 in the
control-plane compose (deploy/control-plane/docker-compose.yml), so this is
the default rather
than an opt-in — and the consequence of that default is that a deployment
whose hosts have not been through Kata provisioning claims no secure work at
all, rather than falling back to a shared kernel. Set it explicitly
anyway so the value is visible in the deployment's .env, after at least one
enrolled Kata Host passes
preflight. A secure job is claimable only when all of the following are true in
the same claim transaction:
- the caller presents that Host's current enrollment-issued per-host credential; there is no shared dispatcher-token claim path;
- the exact runner replica reports
io.containerd.kata.v2; - Python preflight started the configured sandbox image under that runtime and observed a non-empty guest kernel different from the Host kernel.
The claim, not the Host's mutable heartbeat row, supplies the attempt's runtime,
image digest, CLI version and kernel stamp. This matters during rolling deploys,
when runc and Kata replicas can temporarily share one Host row. A missing probe,
an old runner, a custom runtime, a self-declared kata capability or a revoked
Host credential simply cannot claim the secure job; there is no fallback to
runc. trusted-v1 selects the reviewed shared-kernel boundary for trusted
internal repositories; it does not select another workspace/session executor.
Every follow-up and fork inherits the execution profile stored on its thread.
Retained chats carry the original image digest in immutable container labels
and use that content-addressed image for a retry before Docker returned a
container id; a moved image tag cannot change the chat's actual image while its
database row still reports the old one.
The retained shared-session topology is unconditional. An ordinary chat is a one-member group with one container/workspace and one live CLI transport. Fork immediately invokes the pinned CLI's native fork-only surface and adds a second chat/session/process to that same container. Parent and child may execute concurrently without a group turn lock.
Each member has a distinct fixed UID. Before handing over a turn, the host
captures that UID's (pid, /proc starttime) census through the root-owned,
mode-0555 member-process-reaper installed in the sandbox image. Cleanup sends
that baseline over stdin to a fresh helper, rechecks identity immediately
before every signal, escalates TERM→KILL, rescans, and fails closed if anything
survives. The baseline is never a member-writable file, so repository code
cannot edit it to preserve a process or redirect cleanup at a reused PID.
shared-sandbox-v1 is a claim attestation, not a feature switch. A host must
advertise it from the exact process that claims the job. A host's limit is at
least two, because a fork's sibling runs beside its parent; AGENT_HOST_SLOTS
(default three) is that limit until an admin sets one on the Servers page.
Historical Fork additionally requires native-fork-at-anchor-v1, and for the
retained group either the host's current sandbox image being the group's own,
or the group's claude-session-fork helper matching the reviewed version
(recorded in agent_sandboxes.fork_helper_version when the container was created) while
the host's image still ships the exact CLI version the container runs. A
release therefore does not take Fork away from existing chats: it rebuilds the
image, but the image's helpers and pinned CLIs stay the same unless the release
changed them. An older, unknown or unreviewed fork helper keeps the exact-image
rule, so it is never assumed to understand a historical anchor; nor is a host's
fork driver pointed at a CLI version its own image no longer ships. Sending a
message into a running turn follows the same CLI-version rule and does not
depend on the fork helper.
Migration 0026_fork_helper_version records version 2 for existing groups
created under this schema: both the old protocol-v2 claim gate and the newer
helper-version gate required that same at-anchor implementation. Groups copied
from a legacy database stay unrecorded. A runner creating a group reports the
helper version in its reservation; a later reservation cannot overwrite it
with a rebuilt host image's version. An older API ignores the optional field,
and an older runner omits it, leaving new rows unrecorded until their image's
capability can be proved rather than inferred from the host's current image.
The Docker socket (or reviewed mTLS Engine endpoint) is the only host control/data interface for chat state. No repository, workspace, Agent CLI home, or session file is mapped from the host. Historical rows without a shared retained sandbox are non-runnable and fail closed, including an old in-flight parent observed during rollout. A waiting tail inserted by the old API before cutover is failed when that parent settles; it is never promoted into a newly fabricated native session.
All submitted turns that have not started use queued, including messages
sent while an earlier turn is still running. A runner can claim a queued
turn only after every earlier turn in the same chat is terminal. Cancelling
an intermediate queued turn does not let later messages overtake an active
earlier turn. There is no separate promotion step or waiting job state.
Terminal freezing is also member-concurrent: each protected job phase owns a separate Sandbox Manager suspension token for the shared workspace. Completing or recovering one sibling releases only that token; interactive PTYs are resumed only when no sibling remains in a protected phase.
Set concurrency from the admin Servers page. The runner container runs one service per host. It enrolls, heartbeats and samples capacity — the things a machine does once — and it claims a turn only while it is under its limit, starting a process for that turn as it claims it. That process runs the turn to completion and exits. So the limit is how many turns the host runs at once, and everything else waits in the queue. Each turn's sandbox is capped at 4g / 2 cpus, so budget roughly that per concurrent turn.
Set it on the Servers page (Execution slots, the field under the numbers), or
with PUT /admin/agent/runner-hosts/<host>/slots and {"slots": N}; null
returns the host to its AGENT_HOST_SLOTS default. Nothing is started or
stopped to apply it: the host reads the number off its next heartbeat answer
and serves it from then on.
Saving is not applying, and the page distinguishes them. The heartbeat
reports the limit the host is serving back as slots_total, and the claim
admits against the lower of that and the saved number. Lowering therefore takes
effect immediately, before the host has heard about it, and turns already
running are untouched — they finish, and the host claims nothing more until it
is under the new limit; the page counts those as finishing. Raising takes
effect when the host confirms, one heartbeat away, and until then the row says
applying N. A host that is offline says N not applied, and one whose runner
predates remote limits says redeploy to change.
The bounds are two through 128. Two is the shared-live floor; 128 is a typo guard the host checks again, and neither is a statement about what the machine can carry — the CPU and memory on the same page are. The saved number survives heartbeats, restarts and re-enrolment: it is a decision about the named machine, and only Host default returns it to the environment.
Open a server's details on the admin Servers page and choose Remove server from its action menu. Confirm permanent removal to delete its allocation, nodes, registered hosts, host settings and capacity history, node registration tokens, admission holds, and compute activity. Shared connections and deployment profiles remain available. User conversations, runs, and deleted-sandbox audit records are preserved.
Release cloud resources before removing their records. Attached servers without release support can be removed after their hosts are disabled. Remove retained sandboxes through the sandbox deletion flow and wait for cleanup to finish; clear the default placement pin, running attempts, and deployment drains first. Removal checks these dependencies in the same transaction as cleanup. An acquisition that never submitted to its provider can be removed even when resource observation is unavailable.
Removal also refuses with The external operation is unresolved in three cases. The server's Activity tab lists the operations involved:
- An operation that is running, or waiting while it confirms a provider call (an acquisition waiting for its experiment, an extension or a release waiting for the provider's answer), is still in progress. Wait for it to finish or stop.
- An operation whose outcome is
unknown, such as an acquisition whose create request timed out, waits for an administrator, and a release requested meanwhile waits behind it. Open it and retry it or, for an acquisition, adopt the experiment it created. - A server that never recorded its provider resource is refused while one of its operations suggests the provider has one: an answer that named it, an acquisition past submission, a registered node, or a call never confirmed. Release the server. The release deletes a resource an operation named, and otherwise records that there is none, so first check the provider for one the request created.
Once the provider has confirmed the machine gone, an operation that stopped (blocked, failed or cancelled) no longer keeps the server, even one that stopped while it was confirming a provider call: that call can change nothing now.
The compute API is DELETE /admin/agent/servers/<allocation_id> with
{"expected_revision": N}. A revision conflict requires refreshing the server.
For hosts without an allocation, the action uses the existing guarded
DELETE /admin/agent/runner-hosts/<host> endpoint. Stop an unmanaged host agent
before removal; inventory cleanup does not shut down its machine or remove files.
Use the admin settings pages for sandbox and workspace limits. The turn time
limit, repository size limit, container CPU, memory and disk limits, sandbox
idle timeout, sessions per sandbox,
workspace command/file limits and terminal resource limits sit in the
Sandbox card on the Settings page, once
for every server, and in the Sandbox table of each server's details on the
Servers page for that server alone; the API is PUT /admin/agent/host-settings and
PUT /admin/agent/runner-hosts/<host>/settings with
{"settings": {"<key>": N}}, where null clears a key. A server's own value
wins over the global one, and a setting set in neither place uses its built-in
default. Migration 0036_host_settings_import imports existing valid nondefault
.env observations as per-server settings without replacing an administrator's
choices. Hosts without a complete report finish this import through their
initial heartbeats. Admin edits and resets exclude only the changed keys from
pending imports, so unrelated legacy customizations are preserved. Global
exclusions also protect newly enrolled hosts. Subsequent .env edits have no
effect, and clearing an override never restores them.
ENVIRONMENT.md §Sandbox settings from the admin pages lists the precedence
and initial variables. They ride the heartbeat
answer like the slot limit, so nothing is redeployed, and the same rule holds:
saving is not applying. Each host reports what it applies, the runner's share
as it hands each turn over and the sandbox manager's as the sandbox manager confirms
it, and the page shows applying until the report matches. Nothing running is
cut short: a turn keeps the time limit it started with, an existing sandbox
keeps its CPU, memory, disk quota and chat capacity, and a shortened terminal lifetime
closes terminals already past it on the sandbox manager's next sweep. A runner that
does not advertise host-settings-v1 keeps its .env values until it is
redeployed.
The container size controls apply to each new sandbox group, shared by its
conversation and forks. Defaults are 2 vCPU, 4 GiB memory, and disk 0 (no
per-container quota). CPU accepts fractional vCPU; memory and disk accept MiB
or GiB. A positive disk limit bounds the writable layer, including the
workspace and installed files, using Docker's --storage-opt size=. It
requires overlay2 on XFS mounted with project quotas (pquota or
prjquota); Docker rejects creation on an unsupported host. Existing groups
keep their original limits through follow-ups, restarts, snapshots and Fork.
See ENVIRONMENT.md for bootstrap variables and storage requirements.
Recommended for servers that run untrusted work. An untrusted-kata-v1
sandbox's writable layer holds its workspace and every installed file, survives
docker stop, and shares one disk with every other sandbox on the server, so
without a disk limit one tenant can fill that disk for all of them. Set
Container disk above 0 on each Kata server once its Docker data root is on
XFS with project quotas. This is a recommendation, not a requirement: scheduling
and admission do not change, and a server left at 0 keeps taking untrusted
work. Each claim reports the limit a new group would get (the claim
attestation's sandbox_disk_limit_mib). The control plane logs
agent_untrusted_sandbox_without_disk_quota when it hands such a server a new
untrusted group, the runner logs the same event as it starts that group, and
the Servers page shows No container disk quota under that server's
Storage. Existing groups keep the limit they were created with and are not
flagged.
This policy captures automatic initial and final lifecycle archives. The explicit Archive to S3 and delete control has its own control-plane storage configuration, documented in Sandbox archive storage.
Use the Sandbox archives card under Admin → Settings → Sandbox to store
the deployment-wide archive policy in the Agent database. Archives go to
s3://<bucket>/cloud-agent/sandbox. The bucket is part of the policy and
defaults to freeinference; the prefix is fixed, and the IAM policy below names
both. An administrator atomically saves the
bucket, enabled state, AWS region, maximum compressed archive size, multipart
threshold, and part size. An object above the threshold is uploaded in parts
of that size, eight at a time, so an upload holds up to eight parts in memory:
64 MiB at the default 8 MiB part size. Authentication is either an AWS access key or the
workload-identity pair: a customer-managed KMS key ARN and the expected
12-digit bucket owner. An access key makes that pair optional; uploads then
use SSE-S3 unless a KMS key is also set. The secret is write-only, encrypted
at rest, and omitted from the audit row. Updates use the revision returned by
the last read, so two administrators cannot silently overwrite each other.
Every change is recorded in the Audit log.
The dashboard uses admin-only GET and PUT
/admin/agent/sandbox-archive-settings. To inspect the same policy and host
rollout directly in Postgres, use read-only queries such as:
SELECT sandbox_archive_settings,
sandbox_archive_revision,
sandbox_archive_updated_at
FROM agent_runner_policy
WHERE id IS TRUE;
SELECT host,
sandbox_archive_settings_applied_revision
FROM agent_runner_hosts
ORDER BY host;Do not put archive policy, the destination, or an AWS access key in .env.
Enter an access key on the Sandbox archives card when the sandbox manager has no
workload identity. The control plane encrypts the secret before storing it
and sends it to the sandbox manager on the authenticated heartbeat channel. The sandbox manager
keeps it in memory for S3. Sandbox markers do not contain it, so it does not
enter the container. A sandbox manager that predates access-key support rejects that
heartbeat payload and does not confirm the revision; upgrade those sandbox managers
before relying on the key. With no access key, the sandbox manager uses the
host's AWS workload identity and the ordinary SDK credential chain. That
identity needs the narrow S3 multipart/write and exact object-metadata
permissions for the fixed prefix, plus encrypt access to the configured KMS
key. The bucket must have versioning enabled. Configure lifecycle retention
and access logging on the bucket itself.
Concretely, grant the sandbox manager role s3:GetBucketVersioning on
arn:aws:s3:::<bucket>; s3:ListBucket on that bucket constrained to the
cloud-agent/sandbox/* prefix (so an exact-key HeadObject can distinguish a
missing immutable initial object from an authorization failure); and
s3:GetObject, s3:GetObjectVersion, s3:PutObject, and
s3:AbortMultipartUpload on arn:aws:s3:::<bucket>/cloud-agent/sandbox/*.
Checkpoint restores and snapshot loads need s3:GetObjectVersion: they read
the exact object version their record names. The configured KMS key policy
must permit kms:GenerateDataKey and kms:Decrypt for SSE-KMS multipart
uploads. An access key needs those same S3 actions; SSE-S3 uploads do not call
KMS. Snapshot cleanup also uses s3:DeleteObject and s3:DeleteObjectVersion
on arn:aws:s3:::<bucket>/cloud-agent/sandbox/env/*. Those two are optional:
a refused delete is logged and retried, and the bucket lifecycle rule described
under Environment installation snapshots
removes the objects instead. Add an AbortIncompleteMultipartUpload bucket
lifecycle rule as defense in depth for a host that disappears before its
bounded abort request completes.
Changing the bucket changes where new archives go and where restores read. Checkpoints and environment snapshots are read back by object version, which a copy to another bucket does not keep, so those stored in the previous bucket are not restored after a change. Change it only when no retained group still needs a checkpoint from the previous bucket.
A ready environment snapshot in the previous bucket is still offered to new
groups. A host that already holds its image, because it built the snapshot or
loaded it before the change, starts the group from that image without a
download, and that counts as a use. On any other host, loading it fails and the
group installs in full; the failure is reported as unavailable, which neither
retires the snapshot nor queues a new build. A failed load is not a use, so the
snapshot stays on offer until 7 days after its last use on any host, or until
it is 25 days old if that comes first. Until then each new group of that
environment on another host pays for a failed download and a full
installation, unless its owner rebuilds the snapshot. Snapshot cleanup deletes
objects in the current bucket only; those in the previous bucket are left to
that bucket's lifecycle rule.
Redeploy every host before changing the bucket. Only a host that advertises
sandbox-archive-destination-v1 can archive to a bucket other than
freeinference, and the settings card names the others only once archives are
enabled with another bucket. This read-only query lists them, leaving out
removed (disabled) hosts as the card does:
SELECT host, status
FROM agent_runner_hosts
WHERE status <> 'disabled'
AND NOT capabilities ? 'sandbox-archive-destination-v1'
ORDER BY host;While archives are disabled, such a host is sent the policy with the default bucket and keeps taking work. A disabled policy starts no new archive; a final already fenced under an earlier enabled revision still uploads under that revision. While archives are enabled, the host is sent no policy, and the card lists it as needing a redeploy. Until it is redeployed, its sandbox manager keeps the last policy it applied: it keeps archiving the groups it holds into the previous bucket, and the checkpoints it stores there are recorded, so a host that later restores one of those groups reads the new bucket and fails. With no confirmed revision, the host takes no work at all.
Redeployed hosts write to the previous bucket for a while too. A stop that any host fenced before it applied the new revision finishes there: the sandbox manager pins the policy in force when it fences a stop, and stores that stop's final archive and checkpoint under it, also when it retries a failed final or, after a restart, recovers a pending one. The control plane records that checkpoint as the group's restore point, and a host that later has to download it to restore the group reads the new bucket and fails. If the previous bucket can no longer be written, such a final never completes. Nothing gives up on it: until the upload succeeds, its group refuses every start and every deletion, so its chats stay blocked.
Keep the previous bucket writable by every host's workload identity, and by the saved access key if there is one, until every host reports the new revision and no final archive is pending. Such a final keeps the region, KMS key and bucket owner it was fenced with, so keep that KMS key usable as well. A final retried by the same sandbox manager signs with the access key in force when it was fenced, and one recovered after a restart with the access key saved now, so if the access key changes with the bucket, both keys need that access.
Nothing in the database or on the admin pages shows a pending final: its group
is recorded as stopped, like one whose final is stored. The sandbox manager
logs sandbox_final_archive_failed, with the sandbox id and generation, each
time a final fails, and keeps retrying a pending one, including after an
offline host returns. A final still in progress logs nothing until it is
stored or fails, within 12 minutes of its container stopping, and its
checkpoint can take 12 minutes more. So wait until every host is online and
has reported the new revision, then until no sandbox manager has logged that
event for an hour.
Nothing records which bucket a checkpoint is in either:
agent_sandbox_checkpoints keeps its key and version, not its bucket. Treat
every restore point captured before the end of that wait as stored in the
previous bucket. A group gets one in the new bucket at its next clean stop;
until then, a move to another host cannot restore it. This read-only query
lists those groups, with the end of the wait as the cutoff:
SELECT g.id, g.host, g.state, c.captured_at
FROM agent_sandboxes g
JOIN agent_sandbox_checkpoints c ON c.id = g.restore_checkpoint_id
WHERE c.captured_at < '<cutoff>'::timestamptz
AND g.deletion_state NOT IN ('pending', 'archived', 'deleted')
ORDER BY g.host, c.captured_at;A control plane older than this release refuses a stored bucket other than the
default: its heartbeats and admin archive routes fail, and no turn is claimed.
Reset the bucket to freeinference before rolling the control plane back past
this release.
Enabling archives requires migration 0042_sandbox_archive_settings and a
runner/sandbox manager advertising sandbox-archive-settings-v1. The settings
card distinguishes the saved database revision from the revision each sandbox manager
has confirmed applying. While archives are enabled, new claims fail closed on
hosts or exact runner replicas that have not applied that revision; disabling
the policy preserves compatibility with older hosts.
This is a lifecycle archive, not a per-turn transcript log:
- the initial object captures
/workspaceonce after source/setup and before the first Agent process receives a prompt; - each sandbox-manager-managed clean stop flushes native Agent state and stores a final
object containing
/workspace,/agent-state, and the optional/home/agent/.freeinference-platformstate; - repeated turns while the sandbox remains live do not create objects. A later wake followed by another clean stop replaces the deterministic final key; S3 bucket versioning retains its earlier object versions according to the bucket's lifecycle policy.
Policy changes are forward-only. Enabling archives cannot reconstruct the true pre-prompt baseline of a sandbox that already ran while the policy was disabled, so such a sandbox can have a final object without an initial object. Disabling prevents new archive preparations and can leave an earlier initial object without a later final. It does not abandon durable work that was already prepared while enabled: an initial baseline whose upload result is unresolved, or a clean stop already fenced for final capture, remains pinned to its original revision and finishes under that revision. For paired before/after archives, enable the policy and wait for the settings page to show the revision applied on every compatible server before admitting new work; stop or drain those sandboxes before disabling it.
The initial object is immutable and retry-safe. Final capture is synchronous with the stop hook, bounded by the configured size and operation deadline, and an exhausted upload is reported as a failed clean stop instead of being called successful. The initial and final archives are an export/forensics boundary: nothing lists, downloads or replays them. Restoring a sandbox on another host uses the separate restorable checkpoints below, and the retained Docker writable layer remains authoritative for normal stop/start continuity.
The first prompt waits for the initial capture, not for S3. The Sandbox Manager
writes the bundle under
AGENT_SANDBOX_ARCHIVE_STATE_DIR/initial-uploads/<sha256(namespace)>/, fsyncs
it, writes the sandbox's initial marker naming those bytes, and returns. A
background upload then stores the object under the policy revision the marker
pinned and removes the directory (event sandbox_initial_archive_uploaded,
deferred: true). A failed attempt is retried after 5 seconds, 30 seconds, 2
minutes, then every 10 minutes until S3 holds the object
(sandbox_initial_archive_deferred_retry), and again after a Sandbox Manager
restart; a later attempt of the same chat that probes the marker uploads from
the same directory. Until then that directory holds the only copy of the chat's
pre-prompt workspace, so keep it on the host's persistent state disk and drain
it before removing a host. When it is full (8 GiB, counting one new bundle's
worst case) or cannot be written, the capture stores the object before the
prompt starts, as it always did (sandbox_initial_archive_handoff_unavailable
when unwritable). A bundle that no longer matches its marker is dropped and
logged as sandbox_initial_archive_handoff_corrupt.
A stop the Sandbox Manager did not perform cleanly has no final archive. This
covers a host crash or reboot, a Sandbox Manager that exited while sandboxes
kept running, a docker stop issued behind it, and a native transport that
failed to close. Native history cannot be proven flushed in those cases, so the
Sandbox Manager records a skipped final marker (event sandbox_final_archive_skipped)
instead of uploading, releases the bridge, and lets the chat continue: the next
turn resumes through the ordinary native-resume checks, exactly as with
archives disabled. The next clean stop archives normally, so S3 then holds no
final for the skipped stop. Groups that an older Sandbox Manager marked blocked are
treated the same way after the upgrade. Only a group whose skip cannot be
written durably, or one found before any policy has been delivered, stays
fenced with its bridge until reconciliation succeeds.
While archives are enabled, every clean archived stop also stores one more
object: a checkpoint of the container's changes over its sandbox image. This
is on by default; clear Save restorable checkpoints on the same settings
card to opt out, and disabling archives disables checkpoints. The Sandbox
Manager runs docker container commit on the stopped container and uploads an OCI image
archive that holds only the commit's manifest, config and new layers to
cloud-agent/sandbox/v2/<sha256(sandbox id)>/g<generation>/<checkpoint id>.oci.tar.
The key names the sandbox, not the host, so another server can restore it;
see Restoring a removed host's sandboxes.
- Checkpoints require archives and use the same bucket, credentials, size limit
and IAM permissions on
cloud-agent/sandbox/*. Each object is written once (If-None-Match); a retry after a lost response must find identical bytes. - A checkpoint never fails a stop. It runs after the final archive is durable;
a failure is logged as
sandbox_checkpoint_failedand the stop completes. It shares the final archive's capture slot and deadline, and adds a commit, an export and an upload to each clean stop. - The Sandbox Manager reads the new layers straight from containerd's content
store, mounted read-only at
AGENT_CONTAINERD_CONTENT_DIR. Host Compose bindsAGENT_HOST_CONTAINERD_CONTENT_DIR(default/var/lib/containerd/io.containerd.content.v1.content, the system containerd behind Docker's containerd image store) there. Blobs are named by digest and every byte copied is checked against it; a damaged blob fails the checkpoint. The mount grants nothing the Docker socket does not already grant. When the mount is missing, empty, or belongs to another daemon, the Sandbox Manager logssandbox_checkpoint_content_unavailableand falls back todocker image save, which streams the whole image (once per image for the base scan, then once per checkpoint). It keeps only the blobs the committed image uses: a classic (overlay2) image store's save also emits metadata blobs no manifest names, which a restore would refuse. On a CloudLab Kata host the save path took about 90 seconds per export for a 12 MiB change; the content-store path writes only the change. - The Sandbox Manager keeps the latest receipt per sandbox under
AGENT_SANDBOX_ARCHIVE_STATE_DIR/checkpointsand repeats it on every heartbeat. The control plane records it inagent_sandbox_checkpointsonly when it names the group's current host, generation and container. The admin sandbox page shows the latest one. Receipts are recorded asreportedand becomeverifiedonce a restore has loaded and started them. - Each receipt names the control plane's start fence (
start_seq) for the run its stop ended. Every path that may start the container (a turn, a workspace file, terminal, Git or environment request, or a Fork) advances the fence first and sends the value it took with the request itself: in its body, or for a workspace request in anX-Agent-Start-Seqheader. An open terminal's input, resize and close cannot start the container, so they take no fence. The Sandbox Manager labels a run only with the fences of the requests it admitted to that run. Once the run stops, a request at or below its label that would start the container again is refused (409), and the control plane advances the fence and retries. A running container clears the restore point too. A receipt covers its group only while its fence is still current; receipts from sandbox managers that predate restore carry none and never cover a group. - The policy omits the default and carries only an opt-out
(
checkpoints_enabled: false), which is sent only to hosts that advertisesandbox-checkpoints-v1. Older sandbox managers parse the policy unchanged and keep archiving without checkpoints. - The archive holds the container's writable layer: workspace, native session
state, and anything installed outside
/workspace. Credential tmpfs mounts are not part of a commit. It also holds the Sandbox Manager's own archive markers for the generation that stopped; a restored container reads a marker from an earlier generation of its group as absent and writes its own. Bucket lifecycle and retention rules apply to thev2/prefix like the rest of the archives.
When a host leaves the pool, through Remove on the Servers page or the
autoscaler releasing its node, its credential is revoked in one transaction
that also looks at its stopped groups. A group becomes cold when its latest
checkpoint still covers it: the same container, generation and start fence,
every member's last settled turn recorded, and a base image another host can
pull by digest. Every other group stays bound to the removed host, as before,
and continues only if that host is enrolled again; the admin sandbox page shows
which is which. A checkpoint carried by a heartbeat still in flight when the
credential was revoked is handed on by the reaper's next pass.
Nothing starts a group on a removed host, since that host never reports the
stop that would cover it again. A workspace request (Files, Terminal, Git or
an environment retry) answers workspace_unavailable (409) and a Fork is
refused. A checkpoint still in flight therefore covers its group when it lands, and a
request or Fork that finds the group covered hands it on at once: the request
answers workspace_restoring.
Two other paths turn covered stopped groups cold without revoking the host:
a provider that confirms the node terminated hands over all of them, and a
host moving out before its lease ends
hands over each group as its final checkpoint arrives. The host row, its
credential and any deployment drain fence are left to the removal flow.
A cold group does not keep its old host from being forgotten, by Forget
or by permanently removing the server: it no longer needs that machine. It
keeps naming the host it last ran on until a claim moves it to the claiming
host. Every other undeleted group still blocks forgetting.
The next turn of any chat in a cold group is claimed by any host that
advertises sandbox-restore-v1 with archives enabled, except the host that
still holds the group's container: container names carry no generation, so
that host, or one enrolled again under its name, would collide with it. A
failed restore leaves no container, so the host that tried can try again. The
claim rehomes the
group to that host and advances its generation (restoring), so anything the
old host still holds is fenced out. The new host's Sandbox Manager:
- pulls the group's base image by digest if it lacks it;
- downloads the checkpoint version the receipt names and checks its size and SHA-256;
- checks the archive: one image, every blob verified, every layer either the
base image's or carried in the archive, nothing else. It then rewrites
index.jsonandmanifest.jsonitself, so loading can create no name but the checkpoint's own; - loads it, creates the container from it with the group's own limits and network, labelled with the checkpoint id, and resumes each native session as after any restart.
The download and the checked archive are unnamed files in
AGENT_SANDBOX_ARCHIVE_STATE_DIR/checkpoint-spool, and Docker reads the
archive on standard input, so a restore or snapshot load cut short by a
reboot, a forced stop or the OOM killer leaves nothing in that durable
directory. Sandbox Managers that predate this named them restore-*; any such
file is an interrupted restore's and can be deleted while no restore is
running.
The loaded image, freeinference-checkpoint/<sandbox hash>:<checkpoint id>,
is that container's alone, and it goes with the container: when the group is
deleted or discarded, and when a later move back onto the same host replaces
the older container. A move back that restores the older container's own
checkpoint uses its image and loads nothing again. A restore that fails before
the group starts removes the container it created, with any anonymous volume,
and the image it loaded: any host may claim the next try, so a retry on the
same host downloads the checkpoint again. A failure to remove the image is
logged as sandbox_checkpoint_image_remove_failed and one to remove the
container as sandbox_create_cleanup_failed; docker image rm and
docker rm --volumes clear what they leave.
A restore that fails before the turn is admitted returns the group to cold
and the turn to the queue without spending an attempt. The group is not
claimed again until 30 seconds after its first failure, 5 minutes after its
second and 30 minutes after its third, so the attempts outlast a short
registry or S3 outage; the fourth failed restore marks the group lost and fails
its queued turns: the chat whose turn ran it out at once, and its Fork
siblings' within one reaper pass. A claim that ends before its host reserved
the group never reached the checkpoint, however it ends: handed back because
the control plane could not mint the turn's credentials or the host could not
launch the turn, failed because the hand-over refused the turn (its owner lost
Agent access, or its model or repository is no longer allowed) or the host
could not start it, or reaped because the host died right after claiming. It
is not a failed restore and starts no wait. The checkpoint is never changed. Workspace
requests to a cold or restoring group return workspace_restoring (409)
until a turn has restored it. Restore reads with
the archive credentials hosts already receive: s3:GetObject,
s3:GetObjectVersion and kms:Decrypt on the archive prefix.
A turn that was running when its host disappeared is not covered and still
ends as LOST. A group created from a locally built sandbox image cannot move,
because no other host can fetch that image; autoscaled hosts should pull the
published image with AGENT_SANDBOX_IMAGE_PULL=true (see ENVIRONMENT.md).
A host that is enrolled again after its groups moved cannot start or report
them: their generation has moved on. Remove those old containers with the
admin Discard action.
A new task whose environment has an install script can start from a saved installation instead of running the script again. A snapshot is a container after the environment's installation, without the repository's own files, stored like a checkpoint. Design: install snapshots.
- On with checkpoints. Snapshots need archives and checkpoints enabled (Save restorable checkpoints). With either off, every task installs in full, the environment list shows no snapshot state, and nothing is queued.
- Where.
cloud-agent/sandbox/env/v1/<sha256(owner)>/<snapshot id>.oci.tar, under the prefix hosts already write, with the same size limit, KMS andIf-None-Matchwrites as checkpoints. A building host keeps the committed image locally asfreeinference-environment/<owner hash>:<snapshot id>; any other host loads it on first use after the same checks as a restore. - Who builds. Snapshots are built only by
prebuildruns, in disposable environment-run containers, never from a chat's workspace. The control plane queues one when a new task had none, its lockfiles changed, the owner asked, or an environment test passed. A test's own container is never saved: its start and verify scripts ran with runtime values, which the setup fingerprint does not cover. Prebuilds use environment-run slots, are handed only to hosts that advertiseenvironment-snapshot-v1, and never count as the owner's one running test. After a failed build, tasks queue no new one for that installation for 6 hours; a passing test or an owner's rebuild still does. Deleting snapshots cancels their pending prebuilds, and a receipt is accepted only while its run's lease is live. A prebuild no host has started a day after it was queued is given up on: maintenance fails its snapshot and then its run, which no claim would take any more, so it neither waits for good nor counts as queued work for the autoscaler. - Who uses. A claim names a snapshot only for a new group on a host whose
sandbox image is the snapshot's base image, and only when the binding's setup
fingerprint (an HMAC of the install script and setup-time variables, keyed by
AGENT_SOURCE_CONTROL_ENCRYPTION_KEY) matches. The runner also compares the checkout's lockfiles (Git object ids). Any mismatch or failure creates the group from the sandbox image and installs in full; the turn never fails because of a snapshot. Without the encryption key, bindings carry no fingerprint and nothing is snapshotted. The runner's report of what the new group did with its snapshot, which keeps a used one alive, deletes one that failed its checks and queues the builds above, is accepted only from the attempt holding the task's live lease on a host still enrolled; any other worker token gets409 lease_lostand changes nothing. - Retention. The newest snapshot of an installation supersedes older ones. Changing an installation — its install script, or a setup-time variable or Secret, a global one included — supersedes the snapshots of the earlier one, and a build that finishes after the change is superseded on receipt; a snapshot built for a task's own install script is checked against that script. Maintenance also compares each owner's ready snapshots with their current configuration, 20 owners per 30-second pass, so a snapshot that went stale without a publish to notice it (one already stale before these checks, say) is superseded too. Superseded snapshots are no longer handed out and are deleted after 24 hours. A snapshot is not handed out after 25 days, and is deleted after 7 days unused, when its environment is archived or deleted by the owner, and beyond 20 GiB of ready snapshots per owner (least recently used first).
- Deletion. Hosts poll
/v1/agent/worker/environment-snapshots/deletionsevery 10 minutes and delete the object versions it names, least recently tried first, so objects the bucket refuses to delete never hold back the rest. Each host also lists its ownfreeinference-environment/*images, asks/v1/agent/worker/environment-snapshots/retiredwhich belong to retired snapshots, and drops those. Deleting objects needss3:DeleteObjectands3:DeleteObjectVersiononcloud-agent/sandbox/env/*, which nothing else requires. Without them the delete is logged asenvironment_snapshot_delete_failedand retried; add a bucket lifecycle rule on thecloud-agent/sandbox/env/prefix that expires current versions 30 days after creation and noncurrent versions after 1 day as the backstop, which is later than the 25-day limit on use. - Preparation records. Installation writes a root-only marker,
/var/lib/freeinference-broker/environment-setup-v1.json, into the container. A container this host has no record of (created from a snapshot, or restored from a checkpoint) counts its installation as done when the marker's fingerprint matches, so a restore no longer reruns the install script. A snapshot's container then runs the environment's optional update script. - Team environments. A team environment's snapshot serves every active member, whoever's task or test queued it, and every member's use keeps it alive. It counts toward the 20 GiB of the member who queued it. Only the team's owner and admins can delete or rebuild it. Their change to its install script or install-time values retires every member's snapshot of it; a member's own global variables never do, since a team environment installs without them.
If a turn process cannot start or exits before taking delivery, the host
returns it through POST /v1/agent/worker/jobs/<job>/release. That spends no
attempt and avoids waiting for an unowned lease. Release is refused once the
CLI has admitted the turn: replay could execute the prompt twice. Lease expiry
then follows the retained-session rules and can end the conversation.
Consecutive launch failures pause claims, trigger backoff, and are reported in
the heartbeat so the Servers page can explain unused capacity.
Run one runner container per host. AGENT_HOST_SLOTS provides the initial
limit until an administrator overrides it; the deployment no longer reads a
replica count. Scaling the service would enroll the same host twice. Deployment
checks start a turn process with empty stdin and require preflight followed by
a clean refusal to run, exercising launch before any prompt is admitted.
Queue order is global FIFO. Per-user fairness and concurrency quotas remain deferred, so a batch from one user can occupy every slot.
AGENT_CHAT_IDLE_TIMEOUT_S defaults to 1800 seconds (30 minutes) and is
the group's sole inactivity timeout. After the latest activity across every
member has durably settled and no member or sandbox manager operation is active, the
Shared Session Supervisor stops the container and removes its bridge. It never
issues docker rm. A later acquire reinstalls host rules, recreates the bridge,
and starts and inspects the same labelled container. Supervisor shutdown leaves
chat containers untouched.
At sandbox manager startup, reconciliation stops running orphans before readmission.
It never adopts resident processes on the strength of an unproven in-memory
lease or infers an active turn from an empty lease table. Docker observations
travel in the enrolled heartbeat. A group the host listing cannot reconcile,
because its container was lost or relabelled or another call changed it while
its archive marker was read, is logged as shared_sandbox_reconcile_group_failed
and reported as it stands; it never costs the host's other groups their
observations. The control plane checks the durable attempt
fence and idle deadline before returning a generation-scoped stop command; the
next heartbeat records Docker's STOPPED state.
For a proven activation, the heartbeat also reports active turn, terminal, file, or Git operations and the remaining host-monotonic idle delay. A terminal close carries its last activity time: manual close counts as fresh activity; reaping an already-idle PTY continues the original group window without adding a second timeout. A reconstructed generation-zero RUNNING container with no active attempt or deadline receives one fresh 30-minute recovery window. The database accepts it only once, preventing later heartbeats from extending it or stopping an active turn.
Before a prompt reaches Agent CLI stdin, the worker persists its job_id as
the sandbox's admission receipt. If that retained activation's attempt lease
expires, the system stops it, marks the context LOST, and fails the job; it
does not automatically retry even when the crash may have happened just before
stdin. Any later messages already waiting behind that turn are failed in the
same database transaction, and a new follow-up receives
sandbox_context_lost instead of becoming a permanently unclaimable queued
job. That conservative choice prevents duplicate prompt/tool execution.
On the host, the reaper's close of that member's residency also ends the activation that ran on it, as an abandon would, so a Fork sibling's group can still go idle. A turn that was running when the runner died was already aborted when the runner's turn stream to the sandbox manager closed. A runner that hung instead still holds that stream: the close interrupts its turn, and the activation ends once the stream has closed the turn. A stream its runner no longer reads may never get that far, so the sandbox manager's sweep closes a turn still open 60 seconds after the loss. The reaper waits for the host's sandbox manager at most 5 seconds per member for its close of a residency, the members one pass reaps side by side, and releases each member's residency either way: the host's next heartbeat is told the generation is lost and closes the process itself, so a host that no longer answers, usually the reason the lease expired, does not hold up the pass for every other host.
These guards serve separate purposes; they do not create extra idle states or deactivate a healthy group:
AGENT_TIMEOUT_Sdefaults to 3600 seconds. It is a hard watchdog for one hung agent turn. A turn that reaches it is cancelled/fenced; it is never interpreted as an ordinary settled-idle transition;AGENT_TERMINAL_IDLE_TTL_SECONDS(30 minutes) closes a stale PTY whose client and process have stopped producing activity. For retained chats the effective PTY idle limit is the earlier of this guard and the chat clock; releasing it preserves the last activity timestamp and adds no second wait;AGENT_TERMINAL_HARD_TTL_SECONDS(8 hours) bounds one PTY even if it stays noisy forever. It is a resource/security guard, not sandbox deactivation;AGENT_RESIDENCY_TTL_Sdefaults to 1800 seconds and fences each member's live-process ownership. Losing that lease demotes/restarts the member, and ends with LOST context an unfinished activation that had taken on its process, once any turn it was running has closed; it does not itself stop a healthy chat group or create another inactivity clock;- no TTL removes chat data. A retained sandbox is removed only by an administrator's deletion or once its owner permanently deletes every chat using it (see sandbox inventory); operators monitor Docker storage separately.
A bounded workspace command's timeout or output overflow kills its own verified process group and preserves the sandbox's other resident sessions and terminals. Host output readers use fixed-size chunks, byte-bounded diagnostic tails, and bounded queues; oversized native CLI records stop the affected member. Resource checkpoints continue to reach the control plane while CLI output is silent. See Host execution limits for the limits and boundaries.
There is no retained/shared feature flag or runtime allowlist switch. Claude
Code, Codex, Pi, OpenCode, and Kilo are claimable only after exact
image/runtime/helper attestation. Compatible follow-ups send only the new
prompt to the same resident process; recovery resumes the pinned native
session in a replacement process. Fork from any successfully settled native
turn with a persisted native ordinal, matching image pin, and capable owner host
is immediate inside the same container/workspace. The UI offers it only on the
final assistant/tool boundary of that turn. Edit & rewind resolves the nearest
earlier such boundary for the selected user prompt and puts the replacement
text in the child composer without sending it. The frontend treats NEW,
LIVE, RESTORED, and LOST as context truth; hot/warm/cold/replay remains
diagnostic telemetry and never chooses another executor. Current retained
runners never emit replay; the API accepts it only so an attempt admitted by
the previous generation can finish during a parallel rollout, and historical
rows remain readable in the UI.
A settled turn leaves its changes in the retained workspace; the platform neither captures nor publishes them. Every follow-up starts from the conversation's own base, so the workspace's Git view shows every change the conversation made. A settled workspace holds nothing back: it stops when idle and its chat can be deleted. Its changes live in the retained sandbox, and in its checkpoint once it stops cleanly.
When the owner asks for a pull request, the agent opens it through
the GitHub relay, and pushing again to that pull
request's branch updates it, as it would locally. Each pull request the relay
sees created is recorded in agent_thread_pull_requests for the
conversation. A job's published_pr_url is the first pull request its own
turn opened, and workspace_pr_url the conversation's newest. A Fork does not
inherit its parent's records, and a pull request someone opens on GitHub
directly is not recorded; paste its link into the conversation instead. See
the design.
Admission checks free space only through the storage reserve; disk-pressure alerting and a non-mutating pre-prompt context fingerprint remain deferred. Local functional checks do not establish production Kata, live-provider, or operational acceptance.
Downstream deployment automation coordinates the host and control-plane scripts. This repository publishes neutral artifacts; it does not contain a named environment's deployment Actions jobs. The two deployment tracks can run in parallel under the exact-SHA readiness fence described here. See the release contract for artifact deployments.
The host prepares the replacement sandbox/runner artifacts and proves Kata
before invalidating its ready marker. It resolves the old runner's exact
Compose container IDs, then asks Docker Engine for an unbounded graceful stop.
The runner stops claiming and waits for every admitted turn. Graceful shutdown
does not signal its turn children: each child finishes its one turn and exits
naturally, including a child whose acceptance handover is still uncertain. The
sandbox manager and tunnels remain available until those turns settle. Only then does
activation replace the sandbox manager and runner. After the isolation, tunnel, runtime
and retained Docker-root checks pass, it records .deployed-sha and only then
publishes an exact shared-sandbox-deployment:<sha> capability. In parallel,
the control-plane deployment prepares its artifacts and runs migrations. Before
compose up can replace a running API, the deployment waits for a heartbeat
newer than the start of its readiness check and reverse-probes every sandbox manager
eligible for new-chat placement. The claim route enforces that same exact deployment
capability. A pending runner registers and heartbeats its base protocol so the
host checks can inspect them, but does not poll; this also prevents the previous
API from assigning work before it knows the new claim field. A runner with a
pending/failed marker therefore cannot take work even during a cold-start API
bootstrap. When placement has moved between hosts, the check also probes every
online host that still owns a retained group; changing the pin must not make old
chats disappear from rollout safety. SHARED_READY_TIMEOUT controls that wait
and defaults to 900 seconds.
A Host of a compute allocation the control plane manages, not just observes, is not waited for. Only the new control plane rolls it forward (see Compute resources), so requiring the target first would time out every rollout while such a Host is online. The check treats it like a retained owner instead: if it owns a retained group it must send a fresh compatible heartbeat and answer its sandbox manager. The claim route still keeps it from starting new chats until it reports the target commit. A pin to a managed Host therefore needs the deployment's draining host to prove the target.
The deploy user writes .shared-live-ready in the deployment checkout, separate
from the container-owned enrollment directory. Runners mount only that file
read-only at /run/cloud-agent/shared-live-ready. The host script preserves its
inode when changing pending:<sha> to <sha>, so existing file mounts see the
transition; an empty or partial write cannot advertise an exact target SHA.
No credential ownership or permission changes are needed for a rollout.
The deployment orchestrator runs separately from the target checkout. In a
GitHub Actions deployment, it comes from the workflow commit. An online rollback
cannot execute a pre-fence target's old scripts; commits without the current
deploy/DEPLOY_PROTOCOL, or with an incompatible protocol value, are refused
before changing Docker state. The current protocol,
shared-live-exact-sha-v4, marks the one sandbox network: a v3 deployer cannot
install it, and no deployment rolls back across it (a host that ran the
previous network needs the
one-time cleanup). It
also excludes ancestors that predate the rewritten schema baseline
(0001_schema_baseline, 2026-09-10): their Alembic chain is one the database
no longer records, so they are not schema-compatible rollback targets.
Database changes are expand-first. Never downgrade columns underneath a
still-running new API; roll code back to a schema-compatible commit first and
treat any later contract migration as a separate reviewed operation.
For downstream Actions triggered by workflow_run, the workflow definition
comes from that repository's default branch. Synchronize orchestrator changes
there before relying on a new rollback fence, and do not dispatch an older
default-branch workflow against an environment during that rollout window.
Migration 0049_host_drain_fence introduced durable drain ownership and its V1
recipe marker. Additive migration 0051_host_drain_fence_epoch establishes the
current permanent compatibility floor. Before activating the V2 control plane,
the deployment orchestrator must reject every candidate whose control-plane
recipe lacks AGENT_DEPLOY_DRAIN_FENCE_V2; after activation, do not downgrade
the application or schema below that release. The recipe retains
AGENT_DEPLOY_DRAIN_FENCE_V1 only so an older orchestrator can perform the
bridge upgrade. V1 alone is not a valid rollback target once V2 has run.
Every host now has a non-null drain_fence_epoch. The API returns its canonical
UUID spelling and requires the same value, together with the expected owner
token, when acquiring or transferring a fence. A successful state change
rotates the epoch. An exact requested-token retry is checked first, so a lost
success response remains idempotent even though that success changed the epoch;
a different token carrying the old epoch is stale.
The database enforces the generation outside the API as well. Its BEFORE UPDATE trigger rotates the epoch when status, credential_hash,
drain_fence_token, drain_fence_status_before, drain_fence_active, or
compute_node_id changes, and ignores an attempt to assign the epoch directly.
Its BEFORE DELETE guard rejects deletion while a drain fence is active. This
means an operator, reaper, old writer, or placement change invalidates every
previous snapshot rather than recreating a token-only ABA state. An older V1
control plane does not compare the epoch and is therefore not operationally
compatible, even though its writes still cause rotation.
A host that misses heartbeats and is marked offline keeps its inactive
receipt. When it returns, the next rollout still finds the token its
predecessor released; the epoch has already rotated, so delayed requests stay
fenced.
Inspect fence state before rollback or recovery:
SELECT host, status, slots_used, drain_fence_token,
drain_fence_status_before, drain_fence_active, drain_fence_epoch
FROM agent_runner_hosts
WHERE drain_fence_token IS NOT NULL;The compatibility floor remains even when this query is empty: the current
control plane may create a receipt on its next automated deployment. If an
active fence is abandoned, redeploy the V2 fence-capable release and use its
token-conditional release (or the audited force override). If that is
impossible, stop the control plane and every deployment workflow, begin a
transaction, lock exactly one host row, and verify the expected token, epoch,
and slots_used = 0. Only then may an operator clear that one active fence:
BEGIN;
SELECT host, status, slots_used, drain_fence_token, drain_fence_active,
drain_fence_epoch
FROM agent_runner_hosts
WHERE host = '<exact-host>'
FOR UPDATE;
UPDATE agent_runner_hosts
SET status = 'online',
drain_fence_token = NULL,
drain_fence_status_before = NULL,
drain_fence_active = FALSE
WHERE host = '<exact-host>'
AND status = 'draining'
AND slots_used = 0
AND drain_fence_token = '<expected-token>'::uuid
AND drain_fence_epoch = '<expected-epoch>'::uuid
AND drain_fence_active
RETURNING host, drain_fence_epoch;
COMMIT;Require exactly one returned row. The returned epoch is the newly rotated
value; do not try to assign it yourself. To clear only an inactive receipt, use
the same exact host, token, and epoch predicates with NOT drain_fence_active;
change status only when the separately verified recovery requires it. Never
bulk-clear fence fields. If the owner, token, or epoch is uncertain, leave the
host draining and restore the V2 fence-capable control plane.
A Sandbox Manager rolled back past #763 cannot upload a deletion archive that
a newer one prepared. For Archive to S3 and delete, a Sandbox Manager
first prepares the archive in the request's own directory under
AGENT_SANDBOX_ARCHIVE_STATE_DIR. Since #763 it keeps the container's export
there as container-rootfs.tar and the tar's bytes before it as
archive.head, and sends the archive from the two. An older Sandbox Manager
keeps the whole archive as one archive.tar and reads nothing else, so its
upload of a request that a newer one prepared, and had not yet uploaded, fails
before anything is sent: it answers HTTP 500 and logs a FileNotFoundError for
archive.tar. The control plane then aborts the request on the host and marks
the deletion failed, without retrying it. The sandbox's details say that the
container has not been deleted, and its deletion attempt shows the archive
upload stage, failure sandbox_manager_unavailable and Sandbox Manager HTTP
500. Nothing is lost: the container stays as it was, its chats can continue,
and requests the older Sandbox Manager prepares itself upload as before.
The older Sandbox Manager's abort removes only its own files, rootfs.partial
and archive.tar, and so does its completion of a deletion that a newer one
had already uploaded. container-rootfs.tar and archive.head stay in the
request's directory, and neither version removes them later; the export can be
as large as 5 GiB. They do not hold up a later deletion, which gets a new
request and directory, and a finished request does not count towards the four
pending archives a Sandbox Manager allows. Remove them by hand.
With the supported host Compose, the state directory is
${AGENT_HOST_STATE_ROOT}/sandbox-archives on the host, mounted at
/var/lib/hybridinference/broker-archives in the Sandbox Manager. Each request
has a directory there named by the SHA-256 of its request ID, next to the
checkpoints/ and checkpoint-spool/ of restorable checkpoints. As root on
each host whose Sandbox Manager was rolled back, remove the two files from
every request directory that holds abort.json or deleted.json. Leave
everything else: the receipts, and a request directory with neither, whose
request is still in progress. If the host's .env sets AGENT_HOST_STATE_ROOT,
export that value first; the commands otherwise use the default.
archives="${AGENT_HOST_STATE_ROOT:-/var/lib/hybridinference/agent-host}/sandbox-archives"
for request in "$archives"/*/; do
if [ -e "${request}abort.json" ] || [ -e "${request}deleted.json" ]; then
rm -fv "${request}container-rootfs.tar" "${request}archive.head"
fi
doneTo retry the deletion, refresh the sandbox's details and, once it is stopped
and idle, choose Archive to S3 and delete again
(Retry archive and delete on the admin sandbox page). The retry is a new
request, which the running Sandbox Manager prepares in its own layout, so it
works whether or not the files are gone. Rolling forward again needs nothing
more: the current Sandbox Manager still uploads a request that an older one
prepared as archive.tar, and its abort and its deletion remove either layout.
This fence is unconditional because retained/shared-live is the only executor.
It cannot enable a legacy path and it cannot be bypassed by stale
AGENT_RESTARTABLE_* or AGENT_SHARED_LIVE_* values. On failure, the existing
control plane remains running and the deploy stops for operator repair. If
either the API or MCP relay is missing, the host cannot complete all readiness
probes (a true cold start has neither). After migration, the control script
independently bootstraps only the missing service or services. A failed gate
stops only those started by that invocation, leaving a pre-existing peer
running for diagnosis. Web does not start until the same exact-SHA fence
succeeds. Once it does, the bootstrap peers belong to the desired target stack:
a later final API/web probe failure leaves that stack running for diagnosis but
does not write .deployed-sha.
Every database from before the independent-accounts release, whether on the retired pre-2026-09-10 Alembic chain or on the current one, is keyed by HybridInference Gateway owner IDs. This release does not serve one, convert one, or map its owners to accounts: the identity-namespace check refuses it before anything is migrated. Keep such a database untouched as the archive and rollback source, with its dump, checksum, configuration and root keys, and start the release on a new, empty database (cutover).
scripts/convert_legacy_database_copy.py still converts a separately restored
copy of a 0025_retained_followup_gate database, keeping every original row in
legacy_archive, for an operator who needs to read old history with the
current schema. The converted copy is still Gateway-owned and is refused like
the original; never point a deployment at it. The conversion contract is
the legacy database copy plan.
The selected-state rebuild this section used to describe carried host registrations, user connections and the Gateway identity binding into a rewritten schema. With accounts of its own, a deployment has nothing to carry forward from a Gateway-era database: its owners, connections and MCP grants belong to Gateway user IDs that no account holds. Archive it as described above and start fresh. Re-enroll hosts with new enrollment tokens; owners re-register, apply and add their model connections again.
deploy.sh migrates before replacing the API. Each migration must therefore
remain compatible with the running version until replacement. The transitions
listed below require a maintenance window when upgrading from their older API
versions. This list describes those schema transitions; it does not mean every
later additive migration requires downtime. Review compatibility against the
actual source and target versions before choosing a rolling deploy.
-
0002_request_keys_and_actorsdrops the default onagent_admin_audit_log.actor_id. The API that predates it inserts audit rows without the column, andlog_admin_actionswallows the failure by design, so the window would silently lose administrator audit rows rather than fail. -
0003_thread_level_configdropsagent_jobs.repo,setup_script,execution_profileandmcp_servers, which the older API selects on every job read. -
0004_residency_on_membershipdropsagent_thread_residencies, which the older API writes on every residency heartbeat. -
0005_queued_follow_upsconvertswaitingjobs toqueuedand removeswaitingfrom the job-state constraint. The new scheduler leaves queued turns unclaimed until all earlier turns in the same chat are terminal. An older API can still insertwaitingand claim queued turns without checking their predecessors, so it must be stopped before this migration. Downgrades also require stopped writers; the downgrade restores dependent queued turns towaiting. If a terminal intermediate turn hides an unfinished ancestor, it refuses the downgrade until those queued follow-ups are drained or cancelled because the older scheduler cannot preserve their execution order. -
0008_generic_mcp_connectionsempties and write-constrains the legacyagent_threads.mcp_serverscolumn and turnsagent_mcp_connectionsinto a self-describing connection: transport, execution location, endpoint or launch spec and auth profile, with the provider name kept as an optionaltemplate. The legacy name list is mapped ontoagent_mcp_thread_bindingswhere the owner has exactly one connection for that provider, and recorded inagent_mcp_legacy_bindingswhere they do not — an ambiguous mapping would let a connection created later inherit a session authorized against a different one. It follows0007_attempt_turn_resources, so the chain stays linear;0009_mcp_refresh_leasefollows it with the lease that fences a credential refresh between workers.0010_mcp_policy_revisionsadds the permission revision allocator, so saving or reattaching a connection cannot reuse an old approval's policy version.0011_mcp_rolling_compatrestores inert compatibility columns on databases upgraded by the original0008, which dropped/renamed them. Apply through head before starting the updated control plane and MCP relay. -
0058_mcp_connector_invariantrepairs the singular connector route for MCP connections and adds a unique constraint on each connection's authorization mapping. Treat this revision as a stopped-writer boundary. Older binaries can recreate a missing mapping, leave a stale mapping after disconnect, or retain connector authority after changing a connection away fromuser-connector. Do not use a mixed-version rolling deploy for this revision. Instead:- stop every control-plane, MCP relay and web-tier database writer;
- run
alembic upgrade 0058_mcp_connector_invariant(or upgrade through0058to the intended later revision); - deploy the invariant-aware control-plane, MCP relay and web-tier release; and
- resume writers only after every old binary has been replaced.
The runner holds no database connection — its heartbeats are HTTP calls to the control plane — so stopping the control plane, the MCP relay and the web tier leaves the database with no writer.
-
Drain the hosts, keep the generated
cloudagent-undrain-*.sql, and wait for two counts to reach zero. Neither can be settled once the writers stop, so both are waited out here, as in step 1 of the rebuild above:docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \ "SELECT count(*) FROM agent_attempts WHERE status = 'running'; \ SELECT count(*) FROM agent_session_forks WHERE state IN ('preparing', 'materialized')"
A fork still
preparingormaterializedis one its coordinator has not finished.0004refuses to run over it rather than tombstone a fork the reaper might still finalize, and the reaper runs inside the API — so it has to settle before anything is stopped. A stalepreparingoperation is failed after the stall window (15 minutes by default). -
Stop the writers:
docker stop cloudagent-control-plane-1 cloudagent-mcp-relay-1 cloudagent-web-1
-
Build and run the target's
migrateimage with nothing writing. As in step 3 of the rebuild above, this runs in the deploy root with the target commit checked out, and the image is built before the database is touched:export AGENT_DEPLOYMENT_SHA="$(git rev-parse HEAD)" docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml build migrate docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml \ run --rm --no-deps migrate
-
Undrain the hosts recorded in step 1, then deploy the target commit as usual. Its migration is a no-op, and readiness can now see a host that is allowed to claim:
docker exec -i cloudagent-postgres-1 psql -U cloudagent -d cloudagent -v ON_ERROR_STOP=1 < cloudagent-undrain-$(date +%F).sql deploy/control-plane/deploy.sh "${AGENT_DEPLOYMENT_SHA}"
-
Run the acceptance sweep in step 7 of the rebuild above.
Retained chat state is the Docker container writable layer. On a daemon that
uses the containerd image store, the images live beside it under containerd.
Durable same-host operation puts both stores on a mounted data disk and leaves
the daemon paths at their defaults. docker info must keep reporting
DockerRootDir=/var/lib/docker, and AGENT_RETAINED_DOCKER_DATA_ROOT stays
/var/lib/docker. A custom data-root, graph, or containerd root is
rejected before the host starts.
Automatic SSH bootstrap does this from the server's sandbox storage path. See Sandbox storage path. It refuses to move a nonempty store that is not already this bind.
A manual host uses the same layout. <disk> is an existing writable mount,
for example /scratch/hybridInference-cloud-agent/sandbox:
<disk>/docker bind -> /var/lib/docker
<disk>/containerd bind -> /var/lib/containerd
Persist the binds in /etc/fstab:
<disk>/docker /var/lib/docker none bind,nofail,x-systemd.requires-mounts-for=<disk> 0 0
<disk>/containerd /var/lib/containerd none bind,nofail,x-systemd.requires-mounts-for=<disk> 0 0
Give each daemon a drop-in, /etc/systemd/system/docker.service.d/cloud-agent-storage.conf
and /etc/systemd/system/containerd.service.d/cloud-agent-storage.conf:
[Unit]
RequiresMountsFor=<disk> /var/lib/dockerThe containerd file names /var/lib/containerd instead of /var/lib/docker.
Run systemctl daemon-reload. A missing disk then keeps that daemon stopped
instead of opening an empty store at the bare path.
To move a store that already has data, copy it while the daemons are running,
stop Docker and containerd, and copy again with --delete until that copy
exits 0. Move the old directories aside, create empty mount points, bind-mount,
then start the daemons. Do not point daemon.json at the new path. Containers
with restart=no stay stopped across that restart and must be started by
hand. Delete the copies left aside only after a reboot has come back on these
binds and the host is serving.
The sandbox manager compares AGENT_RETAINED_DOCKER_DATA_ROOT with the
daemon-reported DockerRootDir and refuses a mismatch. It deliberately cannot
prove findmnt itself: giving the sandbox manager a host-root mount just to inspect
storage would violate the Docker-only boundary. The mount check belongs in
host provisioning, as the RequiresMountsFor= drop-ins above.
Do not bind <disk>, /var/lib/docker, /var/lib/containerd, /workspace,
or /agent-state into the sandbox manager or sandbox. Do not use generic docker system prune, container TTL cleanup, docker rm, or image garbage collection against
org.freeinference.chat-sandbox=true objects. Inspect and operate them through
Docker (docker inspect, docker exec, docker cp, and the sandbox manager RPC), not by
walking overlay directories. Back up or move both graphs only with Docker and
containerd stopped, preserving ownership, hard links, extended attributes and
sparse files.
This makes docker stop/docker start survive runner, Sandbox Manager,
Docker daemon, and host reboots as long as the same binds return. Another host
can continue a group only from its
restorable checkpoint, and only once
the group is handed over while a current checkpoint covers it: its host is
removed from the pool, its node is confirmed terminated, or the host moves out
before its lease ends. Without
one, loss of these graphs is context LOST, and the control-plane database
still supplies the authoritative thread→host→container identity needed to
locate it.
A missed heartbeat, an offline/disabled host row, or a temporarily unreachable
sandbox manager is not evidence that this Docker state vanished. Keep the chat pinned
and unavailable, recover or re-enrol the same named host, and reconcile its
exact labelled container. Only an inspection on that owning Docker daemon that
proves the expected container/data-root is gone may transition the context to
LOST; never create a blank replacement on another host.
The Servers table has a Storage column beside CPU and Memory: how much of
the filesystem holding the retained sandbox writable layers is in use, in df
terms. It answers a question the sandbox count cannot — 25 stopped says
containers are retained, not how much disk they hold — and it observes one
filesystem, not one chat: a repository, its dependencies, uncommitted changes
and the native CLI session all live in a container's writable layer, and
stopping a container does not release it.
The same reading feeds Storage reserve for new sandboxes (default 1024 MiB): below it, the runner declines a turn that would create a sandbox and returns it to the queue untouched. Free Docker storage or lower the reserve to restore new allocation. Host runtime settings describes the guard and its limits.
A runner is an ordinary container under
the same Docker daemon and storage driver as the sandboxes, so OverlayFS's
statfs semantics make os.statvfs("/") inside it describe the upper layer's
filesystem — the disk the writable layers are on. No host mount, no Docker
query, no child process. The figures are df's: used is total - free,
available is what an unprivileged writer may still use, and the percentage is
taken over used + available. The blocks the filesystem reserves for root that
are still free are in neither figure — which is why the two do not add up to
the total, and why total - available is not used space.
Docker's overlay2.size (and a
container's --storage-opt size= on XFS with pquota) is implemented with project quotas,
and a quota is what statfs returns for a directory inside the project. A
container reading its own quota, and a host running df <upperdir>, would then
report the quota — 10 GiB, say — with the same device number, so comparing
those two proves nothing. sandbox_host.sh therefore compares the container's
numbers against df of the mount root of the filesystem containing the
upper directory, which no container project covers, and writes the verdict to
.storage-probe/verdict.json beside .shared-live-ready:
- the mount root's size must equal the container's own
total_bytesexactly —sizedoes not move under writes, so this is the topology test; df <upperdir>anddf <mount_root>must agree on size, anddaemon.json/ thedockerdcommand line must not mentionoverlay2.size; either disagreement is recorded asunsupported(project quota);usedandavailableare compared withinmax(1 GiB, 1% of size)and only as a diagnosis, because builds and cleanup interleave and nothing pauses the world for monitoring.
A non-root deployment user may be unable to traverse Docker/containerd's root-owned snapshot directories. When direct mount lookup fails, the deployment uses the running replica's immutable image ID for a temporary metadata helper. Docker resolves two non-recursive, read-only directory binds: the candidate mount root and that replica's upper directory. The helper checks the actual binds' device, filesystem root and type against the host mount-table plan before reading counters. A guessed path or mismatched mount never becomes verified.
The helper has no network, capabilities, host PID namespace, Docker socket or
credentials. Its root filesystem is read-only; it only reads its own mount
metadata and calls fstatvfs on the two bound directories. It shares the
attestation's remaining deadline, has its own maximum five-second alarm, and is
removed on exit. Failed starts are cleaned up within the remaining budget. This
is a deployment-only mount exception; collectors and job containers do not get
these host views. No sudo rule or host directory permission change is needed.
Each runner reads that file every sampling round and publishes a number only for
its own upper directory, with a matching size. Anything else — no file, no
entry, an entry that says unsupported, a resized filesystem — is reported as
unverified / unsupported and the page says so instead of drawing a number.
A rebuilt container gets a new upper directory, so an old attestation stops
applying on its own; there is no time-based expiry.
Compose binds only the dedicated .storage-probe/ directory read-only at
/run/cloud-agent/storage-probe. Each sample opens verdict.json by name, so
deleting it reports unverified on the next sample; sandbox_host.sh check
recreates it and sampling resumes without restarting replicas. Keep the bound
directory itself in place. The file inside it is written beside itself and
renamed over, never truncated first, so a replica sampling during a refresh
reads either the old document or the new one and never an empty one. Upgrade from the old .storage-probe-verdict file
bind with a normal up, which recreates runners with the new mount; check
alone cannot change an existing container's mount configuration. The legacy
file is ignored and is not used as a fallback.
Where the helper is used, it needs Docker 25 or newer on the host. It binds
its two directories non-recursively, which --mount spells
bind-recursive=disabled — parsed by the CLI itself, and added in 25. The
older spelling is not an alternative to fall back on: a current CLI refuses it
outright — 29.2.1 answers bind-nonrecursive=true by naming its replacement
and exiting 125 — so a build that reaches for it to support older hosts loses
every host that is not old. Below 25 the helper call fails, the host reports
unverified, and the warning quotes the CLI's own complaint. Hosts whose
deployment user can traverse the storage root never take this path at all.
Storage verification never blocks a deployment. A metric that cannot be
established is not a reason to keep a working host out of the pool: every
failure above is a warning and a verdict entry, and up continues. The two
operational consequences:
# Re-run the checks without redeploying — this also refreshes the verdict.
deploy/host/sandbox_host.sh check
# Turn the metric off entirely (the rollback). The `.env` is the deploy
# checkout root's, beside `.deployed-sha`, which is where every reader looks.
echo 'AGENT_HOST_STORAGE_PROBE=off' >> .env
deploy/host/sandbox_host.sh upThe up is not optional. A container's environment is fixed when it is
created, so replicas started before the edit keep measuring until they are
replaced; up replaces them. A check in between is still worth running — it
rewrites the verdict with no entries, which stops those replicas publishing a
figure on their next sample even though they are still measuring. The variable
takes overlay_root or off and nothing else: any other spelling is refused
at configuration time rather than leaving the script and the runner to disagree
about what it meant.
The reading measures block space, so available does not promise
that any particular container can still write — inodes, quotas and a read-only
remount still fail. It includes every other writer on that filesystem, so it is
not the sum of Docker objects and it does not predict how many more sandboxes
fit. On the default host deployment the rollout also stops reporting if the
mount root itself is inside a size-limited project quota, which df cannot
distinguish; the configuration check is best effort. Rootless, remote-Docker,
btrfs/ZFS and devicemapper hosts are unverified: the probe reports
unsupported rather than guessing. On Docker Desktop the host shell cannot see
the VM's mounts, so local development shows unverified.
Admin → Servers. The list is every enrolled execution environment with authenticated heartbeats, grouped by the physical server it reported, and the New chats selector picks where a chat with no sandbox may be materialized. Its default option is Automatic, which means whoever polls first takes a new chat. Once a retained group exists, its turns can be claimed only by that group's owning environment.
Runners pull work. Placement is enforced when a runner claims a job, rather than through a dispatch request from the control plane. These rules apply:
- The switch is not preemptive, and not a drain. The new host starts claiming chats that do not have a sandbox yet; jobs already running on the old one keep running to the end, holding their leases. Existing retained chats stay hard-pinned to the host that owns their Docker writable layer, so later follow-ups can still be claimed there. A group moves only from a checkpoint that covers it, once its host is removed, terminates or moves out before its lease ends (restoring a removed host's sandboxes). The two hosts can therefore overlap after the switch — a drain would mean waiting for the old host to empty first, and this does not do that.
- An unenrolled runner cannot claim. The control plane derives the host from its persisted host credential; a request-body name cannot impersonate a pinned machine.
- A machine joins through one-time enrollment.
AGENT_ENROLL_TOKENis bound to the chosen host name and redeemed once. Replicas then share the persisted host credential and heartbeat independently of claim traffic. - There is no storage failover. If the selected host stops, new chats wait rather than materializing somewhere else. Existing retained chats likewise wait while their own host is enrolled but unavailable; changing the selection cannot move their Docker layer. The admin page warns when the selected host has not polled recently, but the authoritative operational signals are its enrolled heartbeat and sandbox manager probe, not whether one runner happened to claim recently.
Adding a second machine is not only this switch. The MCP endpoint uses the relay on the sandbox's group bridge, with private addresses otherwise blocked by the host rules; model calls go to each owner's provider over public HTTPS. A runner box on another machine needs the two tunnels described next, and everything in §0 above. An owner's provider key in a sandbox is an accepted exposure. Preflight refuses at startup rather than failing jobs at their first model or MCP call, but it refuses there, on that machine, not here.
A second machine is worth adding for capacity, or because it is the one with the hardware. What makes it more than "run the script over there" is how a sandbox reaches the platform. Its group bridge routes to the public internet, but only on TCP 80/443, and the host rules refuse private addresses except for the group's fixed MCP relay, an SSH tunnel that lifecycle code finds on the relay network and attaches to each group's bridge. Model calls go to the owner's provider over public HTTPS. A separate control-plane relay remains visible only to the runner:
runner host service host(s)
┌──────────────────────────────────────┐
│ [sandbox, its own group bridge] │
│ ├→ mcp-tunnel ─────────────────────┼──ssh──→ MCP-only :8001
│ └→ host rules and NAT ─────────────┼────────→ public IPv4, TCP 80/443 only
│ │ (the owner's model provider)
│ │
│ [runner, internal network] │
│ └→ control-plane-tunnel ───────────┼──ssh──→ control API :8000
└──────────────────────────────────────┘
Each tunnel is a relay, not a proxy — no CONNECT and no caller-chosen destination — so nothing a sandbox sends can widen either route. Setup and the agent share the group's bridge, so setup reaches package registries over the same public TCP 80/443 as everything else. The control-plane tunnel never joins a group bridge, and its address on the runner's network is a private one the host rules refuse from every group.
Why ssh rather than exposing the service ports. Sandboxes carry scoped MCP tokens and runners carry enrolled-host credentials. Binding either service to the lab network puts those capabilities in cleartext on a shared segment. The tunnel keeps each service listening on loopback.
deploy/host/sandbox_host.sh up # relays, sandbox manager and the runner
deploy/host/sandbox_host.sh check # probe relays and runner/Sandbox Manager paths
deploy/host/sandbox_host.sh status
deploy/host/sandbox_host.sh downdeploy/host/docker-compose.sandbox.yml contains the runner, sandbox manager,
two purpose-scoped tunnels (MCP and control plane), and two networks: the
stack's own network and the route-less relay network. Runner and sandbox
manager create group bridges through Docker. This is the supported standalone
topology. A host environment that still configures the removed inference
tunnel (AGENT_GATEWAY_URL, AGENT_GATEWAY_HOSTNAME, AGENT_TUNNEL_*) is
refused.
| Variable | Notes |
|---|---|
AGENT_MCP_TUNNEL_* |
MCP-only relay's independent destination and SSH material; remote default 127.0.0.1:8001. Do not point it at the full control API on 8000 |
AGENT_CONTROL_PLANE_TUNNEL_* |
Runner-only control API relay; remote default 127.0.0.1:8000. It is deliberately absent from sandbox networks |
AGENT_*_TUNNEL_SSH_KEY |
Private key, mounted read-only. Each relay has its own key and its own account on the far machine, restricted as described below; no relay needs a shell or permission to open another relay's port |
AGENT_*_TUNNEL_SSH_KNOWN_HOSTS |
Required host-key file for each relay. Host checking is not disableable: an unverified hop would hand scoped tokens and tunnel traffic to whatever answers there |
AGENT_MCP_HOSTNAME |
The name a sandbox resolves on its group bridge, agent-mcp by default. Do not replace it with a service address: a relay named by IP is refused at startup |
AGENT_CONTROL_PLANE_HOSTNAME |
Runner-only control relay name, agent-control-plane by default |
Generate the known-hosts entry and compare its key fingerprint with a trusted record for the service host:
ssh-keyscan -p 22 "<control-plane-host>" > /etc/agent-tunnel/known_hostsPrefer Admin → Servers → Server setup → Automatic setup (compute runbook). It creates the accounts below, writes and reads back their sshd policy, and refuses an existing policy that would widen it. Authorize a tunnel by hand only when that is not available, and then reproduce exactly what it generates.
Give each relay a dedicated account with no shell, the MCP and control
accounts on the control-plane machine. Each account's authorized_keys holds
one line:
# control-plane machine, MCP account
command="",restrict,port-forwarding,permitopen="127.0.0.1:8001" ssh-ed25519 AAAA... mcp
# control-plane machine, control account
command="",restrict,port-forwarding,permitopen="127.0.0.1:8000",permitlisten="172.24.0.1:18092" ssh-ed25519 AAAA... control
After restrict, port-forwarding re-enables remote (-R) as well as local
forwarding, and permitopen limits only the local destination. On its own,
any of these keys could open listeners on the far machine. So the control
key's permitlisten names its single reverse listener — the control-plane
machine's Docker bridge gateway and this host's AGENT_SANDBOX_MANAGER_REVERSE_PORT —
and each account gets its Match User block from
deploy/host/sshd-tunnels.conf.example.
The MCP block sets AllowTcpForwarding local and
PermitListen none. The control block allows that one PermitListen with
GatewayPorts clientspecified. Install the blocks before any other Match
block that could apply to these accounts, run sshd -t, and confirm each
account with sshd -T -C user=<account>,host=localhost,addr=127.0.0.1 before
reloading sshd.
Run sandbox_host.sh check from the host checkout. Sandbox-runtime containers
on the relay network must receive HTTP 200 from MCP /healthz. The live runner must pass full sandbox preflight, including host
table read-back, and reach the sandbox manager and control plane. Requests from the
host shell do not exercise these container paths.
Once it is up, the machine appears in Admin → Servers and can be selected there.
The control plane also needs a route back to this host's sandbox manager.
File listings, terminal traffic, and git status go control plane → workspace
sandbox manager. The sandbox manager runs beside the Docker daemon owning the workspace; the
host row holds the address reported at enrollment. The integrated local stack
shares a network, so
http://sandbox-manager:8092 works there. The supported standalone host stack
does not share a network with the control-plane stack—even when the two
projects run on one physical machine—so that service name resolves nowhere.
An unreachable sandbox manager address produces 503, including on
terminals/suspend during normal execution. The historical first deployment
failed three attempts at that point. Verify the route before admitting jobs.
The route cannot be opened from the other side: the control plane's machine generally cannot reach this one at all. So it rides the connection this machine already opens — a reverse forward on the control-plane tunnel:
runner host control plane host
┌──────────────────────────┐ ┌──────────────────────────┐
│ sandbox-manager │◀─── -R ──────│ 172.24.0.1:18092 │
│ :8092 │ │ (docker bridge gateway) │
│ │ │ ▲ │
│ control-plane-tunnel ────┼──── ssh ────▶│ │ control-plane │
└──────────────────────────┘ └────────┴─────────────────┘
| variable | |
|---|---|
AGENT_SANDBOX_MANAGER_REVERSE_BIND |
The control plane host's docker bridge gateway. Not 127.0.0.1, which its containers cannot see; not 0.0.0.0, which publishes the sandbox manager to that machine's whole network behind one shared token. Find it with docker network inspect <net> --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' |
AGENT_SANDBOX_MANAGER_REVERSE_PORT |
A free port on that host |
AGENT_SANDBOX_MANAGER_ADVERTISE_URL |
http://<bind>:<port> — what the runner reports, and what the control plane will dial |
The control plane host's sshd must apply GatewayPorts clientspecified to
the control account, as its block in
deploy/host/sshd-tunnels.conf.example
does, together with that account's PermitListen and the key's permitlisten
naming this exact address and port. Never set it to yes: that binds every
remote forward to the wildcard address whatever the client asked for, and
reports success, so each host's sandbox manager is published on all of that machine's
interfaces behind one shared token. With the default, no, sshd binds the
forward to loopback instead, where the control plane's containers cannot reach
it. A listener sshd refuses outright (a port in use, or one PermitListen does
not allow) makes ssh exit under ExitOnForwardFailure, and the container
restarts; the two wrong GatewayPorts values do not. Confirm on that machine
with ss -ltn that the port listens on the bridge address and nowhere else.
Also limit the reverse ports in that machine's firewall to its Docker bridge
interface. The bind address alone does not: Linux accepts a packet for any
local address on any interface, so a neighbour routing the bridge address
through the machine's LAN interface would still reach the listener. Find the
interface with ip -o -4 addr show to <bind>, then, for each reverse port (or
the range you assign), for example:
iptables -I INPUT -p tcp --dport 18092 ! -i br-0123456789ab -j DROPPersist the rule the way that machine persists its firewall.
Set all three. The supported standalone host stack never publishes its sandbox manager and does not share a Compose network with the control plane, even when both stacks happen to run on the same physical machine. An advertise URL with no tunnel behind it is an address nothing answers on, which is the same 503 with a longer explanation.
Check from inside the control-plane container:
docker exec "<control-plane-container>" \
python -c "import urllib.request; print(urllib.request.urlopen('http://<bind>:<port>/health', timeout=5).status)"HTTP 401 proves this route is reachable. urllib reports it as an HTTPError
because the sandbox manager refuses an unauthenticated request. A DNS error or timeout
means the route failed.
The runner is not the control-plane image. It builds from
Dockerfile.agent-runner, which adds a Docker client to create/inspect the
retained chat container and git to prepare a credential-scrubbed source
archive. The control-plane image has neither, and a runner built from it fails
preflight on every host.
There is no host worktree mapping. For the first turn, the trusted runner
uses a temporary directory only long enough to fetch the requested commit,
removes all repository credentials, normalizes ownership in a tar stream, and
imports that stream into /workspace in the retained container. The temporary
clone is then deleted. All subsequent edits, provider state, terminals, setup
results, and fork siblings remain in that same Docker writable layer.
The Docker socket is the host authority. Runner and sandbox manager can create, start, stop, inspect, and exec in chat containers, so isolating those services from users matters more than changing their Unix uid. The security boundary is the retained sandbox (Kata for untrusted multi-tenant work; plain Docker only for trusted repositories), not a host bind mount or a second per-turn container.
A chat may carry a setup_script. It runs inside the retained container before
the first Agent CLI turn, on the same network the agent gets afterwards. Its
files persist in that container's Docker writable layer across follow-ups, idle
stop/start, and host service restarts; there is no host-side setup cache or
portable setup archive. Package managers reach their public registries directly
over HTTPS — there is no proxy to configure — and anything that needs another
port, Git over SSH for one, is refused. The one exception is an owner's own
checked SSH servers, each reachable only from the chats that enabled it
(SSH servers). Long-lived platform credentials stay outside
the sandbox; the owner's provider key and member-scoped MCP tokens enter their
CLI processes. The sandbox runs unprivileged, so apt install
does not work — use pip --user, a virtualenv, npm ci.
New chats can also select up to eight runtime plugins through + → Plugins
or the create request's plugins field. This is thread configuration: follow-ups
keep the selections, and Fork copies them to the new member. Claude Code and
Codex use entries such as
{"plugin": "review@example", "marketplace": "example/plugins"}; their
marketplace source may be a GitHub repository or a credential-free public HTTPS
source (a Git repository for Codex). pi, OpenCode, and Kilo use npm package
entries such as {"plugin": "@example/plugin@1.2.3"} with no marketplace.
Exact npm versions are optional; ranges, tags, local paths, arbitrary install
commands, and credentials in source URLs are rejected.
Installation runs during member runtime initialization, before its resident CLI accepts a prompt, using the pinned runtime version and the member's private home/configuration. Successful installation is retained across process and container restarts; failed installs can retry and never mark the selection complete. Errors and timeouts fail startup with a bounded diagnostic. Plugins use the group's existing network and unprivileged member identity. Project configuration and automatic plugin discovery remain disabled where the runtime supports those guards; explicitly selected plugins are loaded through the native runtime mechanism, alongside the platform's MCP configuration.
There is one network, the same for every task and every phase, and nothing
selects it (design).
Each sandbox group — setup, the agent and every Fork sibling — gets one
routable Docker bridge, a /29 from AGENT_EGRESS_CHAT_NETWORK_POOL. Its host
interface is named with this deployment's prefix, ca and five hex digits
derived from AGENT_SANDBOX_NAMESPACE, followed by eight derived from the
group. Docker routes and masquerades the bridge; this deployment's host
nftables tables decide what it reaches.
Allowed:
- public IPv4 on TCP 80 and 443 — curl, Python HTTP clients, pip, npm, Git over HTTPS;
- the one resolver in
AGENT_EGRESS_DNS_SERVER, on TCP and UDP 53. It is checked before the protected ranges, so a resolver on a private address or on this host works — on port 53 only; - the group's own MCP relay, by exact bridge, address and port;
- the exact public IPv4 address and port of each SSH server the group's owner
registered, checked and enabled for one of its chats, by exact bridge, in the
ssh_targetsset. It is consulted after the protected ranges, so a protected address stays refused whatever an element names (SSH servers).
Refused:
- every address of this host, the bridge's own gateway included, except the resolver above when it runs here;
- every IPv4 address that is not ordinary public unicast — IANA's
special-purpose blocks
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16(cloud metadata among them),172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,224.0.0.0/4and240.0.0.0/4— plus Azure's WireServer,168.63.129.16, a platform service on a public address, and whateverAGENT_EGRESS_PROTECTED_CIDRSadds, on any port; - other groups, this deployment's or another's;
- every other port and protocol: UDP 443 (QUIC), ping, all IPv6;
- connections started from outside. A reply is accepted only for a connection the group opened, and one from a protected address is dropped even then; there is no blanket "established" accept.
The rules judge the address a packet is actually sent to, so a name that resolves — or is rebound — to a private address is refused like the address itself. Most refusals are answered at once, with a TCP reset or an ICMP "administratively prohibited", so a blocked request fails immediately instead of timing out; IPv6 is dropped silently.
What it does not promise (design §2). TCP 80/443 is a port rule: nothing inspects what travels inside, so DNS over HTTPS or a tunnel on those ports is not prevented. Nothing stops a sandbox sending the repository, or its own short-lived tokens or its owner's provider key, to a public host. And the platform's own public addresses may be reachable. MCP tool calls remain absent from the public main API; the owner-key exposure is accepted as described below.
MCP isolation remains required. Done in #327: the main API no longer mounts the tool-call router, so the MCP tunnel must point at 8001 (see MCP servers).
Model calls go to the owner's provider. The member's CLI holds its owner's provider key for the turn it runs (see owner model connections). Code or instructions encountered by a task may cause that key to leave the sandbox. Calls made with it spend the owner's own provider quota; they acquire no other user's identity, MCP tools or platform-management rights. The owner revokes it at the provider; deleting the connection here does not revoke it upstream.
This decision does not waive Kata/network acceptance, member isolation, long-lived credential restrictions or other release gates.
The rules are two nftables tables of this deployment's: an inet table for
routed traffic — to the internet, the host, another bridge — and a bridge
table for frames between a sandbox and its relays on the same bridge. Both are
named cloud_agent_ca plus the first five hex digits of the SHA-256 of
AGENT_SANDBOX_NAMESPACE, which on a host stack is the Compose project, so
with COMPOSE_PROJECT set:
digits="$(printf '%s' "$COMPOSE_PROJECT" | sha256sum | cut -c1-5)"
table="cloud_agent_ca$digits"The runner and the sandbox manager hold no NET_ADMIN and are not in the host's network
namespace. They change host rules through a helper: their own image, run in
the host's network namespace with NET_ADMIN and no other capability and a
read-only root filesystem — the host_nft function below has the same shape.
Each guard step is a short Python step that runs nft for the install and its
read-back together, or reads, compares and writes one bridge's relay
exceptions. Starting a helper costs far more than the nft calls it makes, so
the runner and the sandbox manager each keep one helper running while they use
it and hand it one step at a time on its standard input; a turn's own process
starts a helper per step. That helper has the same flags and a name of its
own, listens on nothing, reads the kernel afresh for every step, and exits when
its process does or after 15 seconds without a step; a fresh one replaces it
every five minutes. A step that finds it busy runs in a helper of its own, as
every step once did. A step it fails or answers wrongly runs again in a helper
of its own only once the failed helper's docker run client has been killed
and its container removed by name and confirmed gone, and within the minute a
step always had; if that cannot be confirmed, the step fails, and later steps
try the removal again. The process then logs
network_guard_helper_discarded and runs every step in a helper of its own for
five minutes. Every helper runs under runc whatever the daemon's default
runtime is, because a Kata VM would change its own kernel's rules, not the
host's.
- Installed before anything joins a bridge. The runner's and the sandbox manager's
startup preflight install the tables and read them back — every chain, its
hook and its rule count, the protected set, the registered pool — and any
difference stops the runner claiming and the sandbox manager starting. Group network
work repeats the check and trusts a pass for 60 seconds. The sandbox manager's
sweep renews that pass after 45 seconds, whether anything of this deployment
runs or not, so a start or wake never waits for the install and read-back
itself; on a host where nothing runs, that is one install and read-back about
every 45 seconds. The runner renews its own pass between claims once it is 35
seconds old, so a turn it hands over does not wait for it either; a pass still
45 seconds old at a handover, after a failed renewal say, is renewed then. The
turn's process normally runs no helper of its own: it takes the runner's pass,
and declines the turn if the runner's check failed or its pass is missing or
unreadable. A
process that starts so slowly that the pass is 60 seconds old when it reads
it logs
network_guard_verification_aged_out, runs the install and read-back itself, and accepts the turn only if that check passes. - Scoped to this deployment. The tables recognise traffic by the bridge interface prefix. They never flush or edit another deployment's rules, so several deployments can share a host.
- Relays by exact endpoint. When a group's relay is attached, its
bridge, address and port are written to a
relaysset in both tables, and removed with the bridge. Every activation and workspace command re-syncs its bridge's exceptions; an update the guard already made for that same Docker network, and saw still held at a read-back in the last 10 seconds (two sweep passes), is not made again. A reinstall, a failed check or a read-back that shows anything else forgets it, and the next update reads the kernel. The relay's own default route stays on its stack network, because it joins every group bridge at gateway priority -1. - Watched while groups run. While any group of this deployment is
running, the sandbox manager reads the tables back on every five-second sweep, logs
network_guard_rules_changedwhen they differ from what it installed, and reinstalls whatever is missing. A reinstalled table holds no group's exceptions, so the same pass gives each running group whose bridge lost them its relays back, from what Docker reports, and its SSH targets as the sandbox manager last wrote them for that same Docker network; a group that cannot get them back logschat_group_exceptions_not_restoredand is synced again by its next activation. If the check fails twice in a row, it logsshared_chat_network_unguardedand stops every running group — workspaces and native sessions kept — rather than let one go on without the boundary it was started behind. Waking a group reinstalls and verifies the rules first, and fails while they cannot be. - Kept by the kernel. A runner exiting leaves the tables in place; a reboot clears them, and no group starts until they have been installed again.
To look at them, read-only, use the same shape of helper:
image="$(docker inspect --format '{{.Image}}' "${COMPOSE_PROJECT}-runner-1")"
host_nft() {
docker run --rm --network host --runtime runc --cap-drop ALL \
--cap-add NET_ADMIN --read-only --entrypoint nft "$image" "$@"
}
host_nft list table inet "$table" # chains; each refusal has a counter
host_nft list table bridge "$table"
host_nft list set inet "$table" relays # relay endpoints, per live bridge
host_nft list tables # every deployment's tables hereKeep --runtime runc: on a host whose Docker default runtime is Kata the
listing would otherwise come from a VM. The counters are totals across all of
this deployment's groups. No per-connection log is kept (design §10).
Each deployment registers its pool in its own inet table, and after
installing it compares its pool with those of the cloud_agent_* tables the
kernel created before its own. Of two whose pools overlap, the later one
refuses, with this deployment's pool … overlaps …, registered earlier by …
and a request to give each deployment its own AGENT_EGRESS_CHAT_NETWORK_POOL.
The earlier one keeps running, so a neighbour started with a copied pool cannot
take a working deployment down, and because each checks after installing, two
starting together cannot both miss each other.
The pool must also stay clear of every network the host already routes.
Docker takes an explicit subnet as given — it checks other Docker networks,
never the host's own routes — so a group bridge inside a LAN, a VPN route or a
private service's network would add a more specific route and draw the host's
traffic for those addresses into a sandbox bridge. After installing, the guard
reads every IPv4 route in every table (ip -4 route show table all, through
the same helper with no capability at all) and refuses with
this deployment's pool … overlaps the host route … on …. The default route,
catch-alls wider than a /8 such as a full-tunnel VPN's two /1 halves, and
this deployment's own bridges are not conflicts. A network reached only
through the default gateway has no route of its own to find, so choose the pool
with the site's addressing in mind as well.
A retired deployment's tables stay until the host reboots, and keep its pool
registered. Once that deployment is gone for good, delete its two tables — its
own and no one else's — with the helper above:
host_nft delete table inet cloud_agent_ca<its digits>, then the same for
bridge. Never delete or flush a table a running deployment uses: Docker
routes and masquerades every group bridge, and the table is what limits it.
Anything that flushes the host's whole ruleset removes these tables too; they
are reinstalled at the next check, with each running group's relay and SSH
exceptions, but do not run such a reload on a host with active groups.
AGENT_EGRESS_DNS_SERVER is one IPv4 address, and the only DNS a sandbox can
use. A public resolver works anywhere. A site resolver on a private address, or
on this host, works too, and becomes the one private address a sandbox can
reach — on port 53 only. It cannot be loopback (a sandbox cannot reach the
host's 127.0.0.53), unspecified, or inside the pool.
Under runc, a sandbox is created with --dns set to it: Docker's embedded
resolver at 127.0.0.11 answers the relay aliases and forwards every other
name there. That setting is fixed when the container is created. A Kata guest
cannot see Docker's resolver, so after every activation the trusted guest-root
helper writes the address into the guest's /etc/resolv.conf, beside the
relay block in /etc/hosts. The in-guest check at every cold start confirms
both that the guest is configured with this resolver and that it answers: one
query for the root's name servers, which any working recursive resolver
answers, within three seconds.
AGENT_EGRESS_PROTECTED_CIDRS takes comma-separated IPv4 blocks to refuse on
top of the built-in ones — an institution's services on public addresses, for
example. It can only add protection. The resolver stays reachable on port 53
even inside an added block, because DNS is checked first. A change reaches the
host when the runner and sandbox manager are redeployed with it: every install replaces
the protected set, and the read-back compares it with the configuration.
Settings first. sandbox_host.sh prepare reads them with the candidate
image's own parser before any runner is drained, and a refusal prints
the sandbox network settings were refused (see the error above) under one of
these:
AGENT_EGRESS_CHAT_NETWORK_POOL must name this deployment's group address poolor… must be RFC1918 space holding at least one /29— unset, outside10.0.0.0/8,172.16.0.0/12and192.168.0.0/16(link-local, loopback and the other special-purpose blocks are refused too), or smaller than a/29;AGENT_EGRESS_DNS_SERVER='…' must be one IPv4 resolver addressor… must be reachable from a group bridge— unset, not IPv4, loopback, unspecified, or inside the pool;AGENT_SANDBOX_MCP_URL='…' names a relay by IP; use its network alias— each group bridge gives the relay a fresh address, so only its name carries over.
Preflight — until it passes, the runner claims nothing and the sandbox manager does not start:
the relay network '…' does not exist— the stack is not up, orAGENT_RELAY_NETWORKnames something else;the relay network '…' is not `internal`— it must not route anywhere; let Compose recreate it as declared;nft failed: …ornetwork guard helper could not run: …— the helper could not install the rules: the host kernel lacks nftables support, or Docker refused the helper container;the network guard runs its helper from this container's own image— the runner finds its image by inspecting its own container, named byHOSTNAME; a customhostname:breaks that;… is missing chain …,… holds N rules, expected M,… is not hooked on …,… protects …, not the configured setor… registers pool …, not …, each naming the table — the tables changed after they were installed, or a runner and a sandbox manager with different settings are overwriting each other. Redeploy both from one.env;this deployment's pool … overlaps …— another deployment's pool, or… overlaps the host route … on …— a network the host already has; see Overlapping pools;the sandbox network needs Docker 28 or newer, client and daemon— the relay's gateway priority needs both; see §0.
Group lifecycle. These fail the turn or operation that needed the group's network; the workspace is kept:
-
relay alias '…' has no running container on relay network '…'(ormultiple running containers) — a tunnel is down or restarting, or two stacks share one relay network; -
AGENT_EGRESS_CHAT_NETWORK_POOL … has no free /29 subnet— every/29in the pool is taken, by this deployment's groups or by other Docker networks inside the same range; -
group network check failed inside the sandbox: …— the in-guest check after a cold start found one of:no default route,resolver is […], expected […];resolver … did not answer: …or… did not answer the query usefully— the resolver is down, refuses recursion, or (under runc) the container was created with a resolver the rules no longer allow;relay host:port unreachable: …;the host at …:9/udp was refused by ECONNREFUSED, not by the network rules,the metadata address 169.254.169.254:80 answered; the network rules are not in this guest's path(or the same forthe platform address 168.63.129.16:80), or… was not refused within 2s— the host and both platform addresses must be refused at once by the rules' own ICMP answer, and were not: the guest's traffic is not passing this deployment's tables.
The sandbox is stopped, not deleted. Reaching the public internet is not part of the check — an outside outage is not a broken boundary;
-
fixed relay joined the chat network without its gateway priority— the Docker daemon ignored--gw-priority; it needs Docker 28 or newer; -
Kata guest network sync failed: …— the guest-root helper could not write/etc/hostsor/etc/resolv.conf; -
retained chat is attached to network '…', which is not its group bridge, endingneeds the one-time cleanup— a chat from the previous network; at sandbox manager startup it keeps the sandbox manager from starting. See the next section; -
a running retained chat is not on exactly its group bridge— a running guest is never rewired underneath it; stop it first.
In the sandbox manager's log, chat_legacy_network_found names a bridge of this
deployment from the previous per-phase topology; it is reported, never adopted
or repaired. chat_group_relay_exceptions_left means a removed bridge's relay
entries could not be withdrawn from the tables. They match nothing once the
bridge is gone, and if the helper still cannot run, the next activation fails.
Only for a host that ran the previous network — per-phase bridges and the Squid proxy. A host that never did needs none of this. The new version adopts nothing the old one left. It refuses a retained chat still attached to an old network, with the message above, and because the sandbox manager stops every retained chat of its deployment when it starts, one such container keeps the new sandbox manager from starting at all; old bridges are only logged. The design treats the switch as a step for development and test hosts with no users to carry over (§8), so these steps discard the old retained chats. Export any workspace you need before step 4.
Remove only this deployment's resources. Every listing below filters by
this deployment's own labels: the Compose project for the stack's own
containers, networks and volumes, and org.freeinference.sandbox-namespace —
the same Compose project, on a host stack — for chat containers and group
bridges. Read each listing before removing anything. A resource carrying
another project or namespace belongs to another deployment on this machine;
leave it, and leave anything you cannot attribute.
-
Drain the host (Admin → Servers) and wait until it runs nothing, then stop this stack:
export COMPOSE_PROJECT="<this stack's Compose project>" deploy/host/sandbox_host.sh down
If the checkout already holds the new release, add the two settings from step 7 to
.envfirst: the new compose file does not render without them. -
List this deployment's chat containers:
docker ps -a \ --filter label=org.freeinference.chat-sandbox=true \ --filter "label=org.freeinference.sandbox-namespace=$COMPOSE_PROJECT" \ --format '{{.ID}} {{.Names}} {{.Status}} {{.Networks}}'
Stopping the stack leaves chat containers as they were, so some may still be running. Every one of them predates the upgrade. The new version refuses any that is still attached to an old network, and none of them was created with
AGENT_EGRESS_DNS_SERVER— under runc a container's resolver is fixed when it is created. So all of them go. -
Export what you want to keep. A chat's workspace exists only in its container's writable layer, and
docker cpreads a stopped container:docker cp "<container>:/workspace" "./<container>-workspace"
-
Remove the containers from step 2, by ID:
docker rm -f "<id>" …This is the one sanctioned
docker rmof chat containers; everywhere else Retained Docker storage on a host forbids it. Their chats cannot continue (their context readsLOST); new tasks get new containers. -
List this deployment's chat networks, and remove the old ones:
docker network ls -q \ --filter label=org.freeinference.chat-sandbox-network=true \ --filter "label=org.freeinference.sandbox-namespace=$COMPOSE_PROJECT" | xargs -r docker network inspect --format \ '{{.Name}} {{.Internal}} {{index .Options "com.docker.network.bridge.name"}}' docker network rm "<name>" …
Each line is a name, whether the network is internal, and its bridge interface name. An old
freeinference-chat-net-*network is internal (true) and has no interface name; a group bridge of the new version is not internal and has aca…one. Before the new version has run, every one listed is old. -
Remove the proxy and the old Compose networks and volume. List what Compose created for this project:
docker ps -a --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ --format '{{.ID}} {{.Names}} {{.Label "com.docker.compose.service"}}' docker network ls --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ --format '{{.Name}} {{.Label "com.docker.compose.network"}}' docker volume ls --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ --format '{{.Name}} {{.Label "com.docker.compose.volume"}}'
Remove, with
docker rm -f,docker network rmanddocker volume rm: the containers of the servicesagent-egress-proxyandagent-egress-proxy-config; the networks whose Compose key isagent-egress,agent-egress-trustedoragent-egress-uplink(namedagent-egressor the oldAGENT_EGRESS_NETWORK_PLATFORM_ONLY, and<COMPOSE_PROJECT>-agent-egress-trustedand-uplinkunless.envnamed them); and theagent-egress-proxy-configvolume, which held only the generated Squid configuration. Nothing else in these listings is part of the cleanup. Adownrun from the previous release will already have removed some of them. -
Update
.envfrom the newdeploy/host/.env.example. SetAGENT_EGRESS_CHAT_NETWORK_POOL, which no longer has a default, andAGENT_EGRESS_DNS_SERVER. The old default pool was10.240.0.0/16: if two deployments on this machine both kept it, give one of them another, or the later one refuses to start. Delete the retired settings —AGENT_EGRESS_SETUP_TIER,AGENT_EGRESS_AGENT_TIER,AGENT_EGRESS_ALLOWLIST,AGENT_EGRESS_NETWORK_*,AGENT_EGRESS_PROXY_*,AGENT_EGRESS_STOP_GRACE,AGENT_SANDBOX_NETWORKandAGENT_SANDBOX_ALLOW_OPEN_NETWORK. Nothing reads them. -
Undrain the host (Admin → Servers, or
POST /admin/agent/runner-hosts/<host>/undrain) — with its runners stopped it claims nothing yet, and a drain is never cleared by a heartbeat — then deploy the new release as usual, with a deployer atshared-live-exact-sha-v4. Every runner's preflight installs and reads back the host tables before it claims. Confirm thathost_nft list tables(from The host tables) shows this deployment'sinetandbridgetables, then run a new task whose setup installs a package.
Run the adapter contract tests after changing runtime integration code. They replay recorded and synthetic events to pin normalized mapping without a live model or deployment credential:
uv run pytest tests/unit/test_agent_runtimes.pyThe MCP suites live here with the catalogue, per-user registry, relay, filters, API and runtime adapter. After changing that path, run at least:
uv run pytest tests/unit/test_mcp_connection_config.py \
tests/unit/test_mcp_protocol_and_authorization.py \
tests/servers/test_mcp_relay.py tests/servers/test_agent_jobs_api.py \
tests/unit/test_agent_runtimes.pyThese tests do not invoke the installed agent binaries. An installed-CLI smoke test and full live-model matrix belong to the deployment operating those binaries, models, and credentials. Keep that conformance configuration in the deployment overlay, not in the neutral upstream repository.
What the first staging and production runs exercised, and what they left unverified, is kept as a dated record.
An owner registers their own Linux servers under Settings → SSH
connections (/settings/ssh-connections), checks each one, and turns
servers on per chat from + → SSH servers. The agent then runs ssh, scp and rsync against them directly
from the sandbox; the platform never runs a command on a server. Design and
accepted risks:
SSH server design,
and the owner SSH servers entry in AGENTS.md §7.
- Migration and key.
0080_user_ssh_serversaddsagent_ssh_servers,agent_ssh_thread_bindings,agent_ssh_server_eventsand theagent_ssh_access_revision_seqsequence. Each server's Ed25519 private key is encrypted withAGENT_SOURCE_CONTROL_ENCRYPTION_KEYunder its ownhybridinference/ssh-server-key/v1domain; without that key the section says so and nothing can be added. - Rollout. Runners and sandbox managers advertise
ssh-access-v1, and the sandbox image now shipsopenssh-client. A claim for a group whose chats ever selected a server is released, without spending an attempt, to any runner that does not attest the capability, and a sandbox manager without thePUT /workspaces/{workspace_id}/ssh-accessroute fails the turn rather than starting an agent without its servers. Roll hosts out before owners enable servers on them; a group already pinned to an old host waits. - What a turn does. Before setup and the agent start, the runner sends the
sandbox manager the group's whole SSH state from the claim, marked as this
member's starting turn. The manager compares it with the newest state it
recorded in a root-owned file,
/run/agent-members/.ssh-access-state(revision and per-chat entry digests, no keys). A state at least as new is recorded and sets the bridge'sssh_targets; an older one leaves the bridge alone and is trimmed to what the newest allows rather than skipped. The manager then removes the SSH directory of every member slot the newest state allows nothing, and, as the starting member's UID, writes that member's servers into its private tmpfs (one directory per server with key,known_hosts,Hostblock and digest) with oneIncludeline at the top of its~/.ssh/config. Every other member only loses servers. A group none of whose chats ever selected a server costs nothing: no state is sent. - Taking access away is immediate; granting it waits for the next turn. Turning a server on or off for a chat, editing, checking, rekeying or deleting a server pushes the new state to every running group it reaches, on the host the group is on. A push names the group by its origin chat, which a restarted sandbox manager re-learns from the container; if the manager does not know it, as after the origin chat was deleted and gave up its slot, the push is retried through another chat of the group that holds a slot. A push only removes: deleting an element cuts a live SSH session on its next packet, and a member's directory for a server it may no longer use, or whose key changed, is deleted. A server turned on, or a new key, arrives with the chat's next turn, whose runner masks the key. If a newest state fails after it was recorded, the manager clears the bridge's SSH elements and the call fails, so the group stays closed until a state applies whole. A push looks only for running groups, and a claim's state is read before its group is reserved, so a change made while a new, stopped or cold group starts reaches neither. When the runner marks the group running, the control plane pushes the group's state once more; it is newer than the claim's, and the manager keeps it whichever of the two arrives first. A host the control plane cannot reach gets the state with its next turn's claim.
- Checks dial out from the control plane. A check resolves the address,
refuses it whole if any answer is private, reserved or protected, reads the
host keys with
ssh-keyscan, and after the owner confirms a fingerprint logs in once with the platform key and runstrue. SetAGENT_EGRESS_PROTECTED_CIDRSon the control plane to the hosts' value so a check refuses the same blocks a sandbox would. The control plane needs outbound TCP to owners' SSH ports. Each owner may run 20 checks an hour. A check reads at most 64 KB of each output stream and kills the client past that, so a server that floods it fails the check instead of filling the control plane's memory. - Owner firewalls. A server that limits who may connect must allow both the control plane (for checks) and every host its chats may run on.
An owner adds their own inference endpoint under Settings → My settings →
Personal model connections (/settings/my-settings): a name, a Base URL,
an API key and up to eight model ids. Verify and save verifies every model
before anything is stored, and Cloud Agent works out which APIs each model
serves. The models then appear in the composer, for the agents that can use
them; they are the only models anything runs on. A turn runs on the owner's
key and bill. Design, protocol matrix and accepted risks:
model connection design,
and the owner model connections entry in AGENTS.md §7.
- Turning it off.
AGENT_MODEL_CONNECTIONS_ENABLEDis on unless it is set to0,falseorno, and connections are the only inference, so with it off no new turn starts. To hold new turns until every host and control-plane replica runs this release, set it tofalsebefore the rollout and unset it afterwards. The setting gates admission only. Turning it off stops new connections, re-enabling one and new turns or messages on one; turns already admitted still run on their connection or fail. It is not a way to stop running work. - Rolling back. Do not run a release from before independent accounts
against this database: it would read neither the accounts nor the
connection sources. Downgrading
0106refuses while any connection, any turn or waiting message that names one, or any member resident on one exists — finished conversations included. Removing them is a data decision to make first. - Migration and key.
0106_user_model_connectionsadds the tables and the source columns. Keys are encrypted withAGENT_SOURCE_CONTROL_ENCRYPTION_KEYunder their ownhybridinference/model-connection-key/v1domain; without that key the page says so and nothing can be saved. No owner route returns a key, and a refused request names the field without echoing it. The host's authorized claim and residency responses do carry it: that is how the member's CLI gets it. - Verification and protocols. Owners are not asked which APIs their
provider offers, and cannot declare them. Verifying a model streams a tool
call and its result on
each of OpenAI Chat Completions, OpenAI Responses and Anthropic Messages,
side by side, and stores, per model, each API's native outcome:
supported,unsupported(the provider answered and could not) orfailed(it could not be determined: unreachable, key refused, rate limited, 5xx, timeout). A failure is never read as the API existing or as it missing. OpenCode, Kilo and pi need Chat Completions; Codex needs Responses; Claude Code needs Anthropic Messages. An agent's own API is used whenever it is supported for that model; otherwise Codex and Claude Code run on a model whose Chat Completions is supported, through the member's translator (below); no other pair is translated. Translated availability is derived from the stored facts, never stored. A saved model always has facts and at least one agent; one without current facts (a backstop that should not occur) runs on nothing, and the composer does not offer it. A turn on a connection runs at its provider's default reasoning effort: a task or message naming an effort is refused (model_connection_unusable, reasoneffort_unsupported), the composer offers no effort control, and nothing is stored for it. A pairing a model cannot serve is refused when the task or message is submitted, and again at every claim and residency; nothing falls back to another connection, model or API, and there is no trial and error at inference time. - Verify before saving. Creating a connection, and an edit that changes
its key or Base URL or adds models, verifies inside the saving request
before anything is written: every model the connection would list for a
new endpoint, or only the added models otherwise. All the checks those
models need are reserved from the owner's hourly budget first, all or none,
so a save never runs part of its verification. The save commits only if
every model has at least one agent that can use it; otherwise it answers
422 model_connection_verification_failedwith every model's result and writes nothing: a refused create leaves no connection and no key, and a refused edit leaves the previous endpoint, key, results and running processes untouched. A model that fails is never dropped to save the others. An edit commits only while the row still has the credential revision, config revision, model list and stored facts its verification read; a rotation, disable, delete or model edit made meanwhile wins, and the late save answers409 model_connection_changed. A rename, removing models, enabling or disabling makes no provider call. The request carries the normalized endpoint, key and models it commits, so nothing a client sends can stand in for a verification. At most eight models per connection keep one save to at most 48 provider requests and two waves of four models. Verification has a 240-second overall deadline; exceeding it answers504 model_connection_verification_timeoutwithout writing anything. The deployment edge permits this duration; any additional proxy must permit it too. Cancelling the form is disabled while saving because the server may still commit the request. - Where requests go. The OpenAI CLIs append
/chat/completionsor/responsesto the Base URL exactly as written. Claude Code's SDK appends/v1/messagestoANTHROPIC_BASE_URL, so one trailing/v1of the Base URL is dropped first and any other path is kept:https://host,https://host/v1andhttps://host/anthropic/v1all reach…/v1/messages. The CLI authenticates withx-api-keyandanthropic-version: 2023-06-01; verification posts to the same URL with the same headers. The CLI may also callcount_tokens,/v1/modelsandHEAD /api/helloon the same host; a provider that lacks them still works. - Model ID suggestions. The add and edit forms can load the provider's
model list on request to pick IDs from:
POST /v1/agent/model-connections/discover-modelswith the form'sbase_urland either theapi_keyjust entered or, for an edit, theconnection_idwhose saved key to use. The saved key is used only whilebase_urlis still the one saved with it (otherwise422 model_connection_key_required), and an entered key is never replaced by it. The route needs the owner and the deployment gate, reserves one of the owner's hourly checks before any call, and dials under the same address rules as verification. It first reads the OpenAI-styleGET /modelsand, only if that listing is missing or refused, the Anthropic-style…/v1/modelswithx-api-key(at most two requests). Failures answer only a category. It writes nothing, and a listed ID means nothing until the form's save verifies it; listing is optional, and any ID can be typed. - Host rollout. Runners advertise
model-connections-v1, andmodel-protocol-translation-v1when they can run a translated member. A claim for a turn on a connection, or for a translated one, is released, without spending an attempt, to a runner that does not attest what it needs, so roll hosts out first. A disabled, deleted or no longer suitable connection fails the turn at its claim with that reason. - What a turn does. The claim carries the connection's exact Base URL,
protocol, credential revision and key; the runner masks the key. The
residency binds the member to that revision and hands it the key. The member's CLI is configured with that URL (for Claude
Code,
ANTHROPIC_BASE_URLas above) and the key in its environment and activation tmpfs, never in argv; an OpenAI CLI is never given a Messages connection's key, nor Claude Code an OpenAI one. A later turn after the key, Base URL or the model's verified path changed starts a new process, because the plan and token are part of the process's reuse identity. A claim and its residency must agree on both the credential revision and the plan (client>upstream); a change of path while a turn starts fails that start rather than mixing the two. - Scan history. When a key is bound to a residency, an encrypted scanning copy is still recorded under a separate domain against that sandbox group and connection revision, but nothing reads it any more: the workspace snapshot scan that used it was removed with the platform publisher, and nothing on the platform scans what an agent pushes. The copy stays with the sandbox group record after the connection is deleted; it is still a real provider key, and deleting the connection here does not revoke it at the provider.
- Translated turns. For Codex or Claude Code on a model whose only usable
API is Chat Completions, the host installs
/usr/local/libexec/model-protocol-translator.pyz(built fromcloud_agent_host/protocol_translator.pyandcloud_agent_shared/protocol_compat, digest-checked, root-owned) into the container before the member starts, so a retained container runs this host's code. The member's command becomespython3 -I model-protocol-translator.pyz <CLI argv>: it binds127.0.0.1on a free port, forks the translator as the same member UID, and execs the CLI, which keeps the PID the host recorded. The translator dies with the CLI (parent-death signal and a parent check), and member reaping and container stop remove it as they remove the CLI. It forwards only to the connection's/chat/completionsover HTTPS, answers only the member's own token (issued with the claim and keyed by the platform, not by the provider key, so a sibling on the same key cannot compute it), refuses requests it cannot carry (hosted tools such as web search, files, statefulprevious_response_id) and turns a provider stream that did not finish into a failure, never a success. A tool's image result (Claude Code reading a screenshot, Codex'sview_image) is sent as image parts in a user message after that run of tool results, since a Chat tool message holds text only; a provider without vision refuses it with its own error rather than the image being dropped. Codex starts with web search disabled and Claude Code without its WebSearch tool on this path; their other tools, Codex sub-agents and MCP tools included, work. Prior reasoning or thinking is not sent to a Chat Completions provider. TheAGENT_COMPAT_*variables are the launcher's and never reach the CLI. - Changes take effect at once. A verified new key or Base URL, disabling or deleting a connection releases every residency started on the old state and closes its process, the same path a chat deletion takes; a turn running on it stops. A new key or Base URL replaces every model's facts. A field saved with its stored value (including a re-entered identical key) is not a change: renaming a connection or removing models closes nothing and keeps the other models' results. A host the control plane cannot reach closes it when its next residency heartbeat is refused.
- Verification dials out from the control plane. It runs only on the
owner's explicit save, never when a page opens or a field is typed in. For
each model, three native checks run side by side, each a streaming request
offering one tool and a second carrying its result back in that API's own
format (for Messages,
tool_useandtool_resultblocks, signed thinking carried back, and a stream that must end withmessage_deltaandmessage_stop). That is at most six provider requests per model, each check under the check timeout, and one of the owner's 20 hourly checks and model-list reads, reserved atomically across replicas whether or not the connection exists yet. No database transaction is open during the calls. It dials only public IPv4 HTTPS on port 443: every resolved address must pass the MCP connection policy and the sandbox's protected ranges (includingAGENT_EGRESS_PROTECTED_CIDRS), the connection is pinned to the checked addresses, redirects are not followed, no proxy is used, and no upstream body or key is echoed or logged. - Usage. The owner's provider bills these turns. A run's usage footer, the admin run detail and the Usage pages show the tokens the agent reported and say the provider bills it; there is no cost. What is and is not counted is in Usage.
Migration 0065_environments adds owner configuration, immutable encrypted
versions, fixed workspace bindings, isolated test leases and action results.
Control plane values use AGENT_SOURCE_CONTROL_ENCRYPTION_KEY with a separate
hybridinference/environment-variable/v1 domain. Back up this key with the
existing credential store; changing it without re-encryption makes old bindings
unreadable. Secrets are not available through owner read/history APIs.
Repository environments are a standard product feature, available without an
admin switch. Settings, task selection and the Environment tab are always shown;
/config always returns environment limits. Legacy saved or environment values
for repository_environments_enabled / AGENT_REPOSITORY_ENVIRONMENTS_ENABLED
are ignored and no longer appear in the settings editor. Unconfigured tasks keep
base-environment behavior without preparation; configured workspaces still require
actual runner and sandbox manager support for environment-v1.
Upgrade every control-plane replica before creating any new environment-bound workspace; mixed old/new admission, Fork and terminal handlers may silently lose binding information. After the first binding, do not roll back to binaries that cannot read it. To roll back, close those execution entrances, drain environment tests/preparation, and keep bound workspaces on compatible hosts or fenced off.
Roll out the environment-capable runner and sandbox manager together. A claiming replica
must attest environment-v1, and the actual authenticated sandbox manager must advertise
it before receiving configuration. An older host releases jobs that require
configuration without consuming an attempt; it must never run them without
variables. Environment tests also obey the deployment pin, compute admission,
replica attestation and host slot limits, and receive read-only source credentials
without inference, MCP or repository write grants.
Preparation receipts live in the host credential directory under
environment-preparation, outside container mounts and source archives. They
contain binding/container/boot identities and masked steps, never variable values.
Keep this host state across sandbox manager restarts. Installation succeeds once per fixed
workspace; startup runs once per actual boot. A failed step remains visible in
the Environment tab and can be retried against the original binding.
Manual-action receipts live next to preparation state under environment-actions.
They reserve the run before launching a command and retain its masked result after
PTY eviction. Keep this directory across sandbox manager restarts; deleting it removes the
host's replay fence for requests whose control-plane acknowledgement was lost.
An interrupted launch without a recoverable result is reported as unknown and
is never automatically replayed. Inspect external effects before starting a new
run with a different idempotency key. These receipts do not promise exactly-once
side effects in external services.
The output of a test, a preparation or an action is stored masked and bounded, with each NUL and unpaired surrogate replaced by U+FFFD: Postgres stores neither, and refusing the output would leave the test or command unfinished.
Empty bindings still pin the original task's selection, but require neither
environment-v1 nor preparation. Each environment test reserves one local slot
on a background worker so other free slots continue claiming coding tasks. Host
orphan queries are read-only and throttled; the control-plane reaper owns lease
expiry and history maintenance.
Cancellation fences the result immediately but keeps the leased slot until cleanup is confirmed or the lease expires. An expired orphan no longer reserves capacity; its returning host cannot claim new work until cleanup is acknowledged. The reaper expires leases/30-minute executions. On reconnect the enrolled host receives only its own expired test IDs and removes the exact labelled containers before acknowledging cleanup. Do not delete unrelated groups or bypass lease/container identity checks to clear capacity. Archived, unreferenced versions are collected after 30 days; existing workspace and test bindings keep their historical encrypted values for redaction.
Owner-provided credentials can be read and sent outside by user code through the existing network. Known-value masking does not cover encoded secrets, raw owner terminals/files, or Git pushes, and nothing on the platform scans a push. Platform-held long-lived credentials, reserved variable names and fixed egress remain protected.
Environment setup, startup and actions share executable package data in
/workspace/.cloud-agent-tools, excluded through Git's local exclude file. Member
credential HOME directories remain private. Explicit runtime refresh retains
installation inputs and applies from the next turn; already-running services keep
their old values. Startup recovery is an explicit allowance for one subsequent
human turn, never a permanent success receipt.
A team's owner and admins can create environments for the team: in Settings → Environments, New environment or Copy offers the teams they own or administer. Every active member sees them there and in the new-task picker, marked with the team's name, and can view, test, copy and choose them. Only the owner and admins can edit, restore, remove, rebuild or delete snapshots; anyone else gets 403. Creating, editing and removing a team environment is recorded in the team's audit log. Design: Teams §6.
- Each member's own container. A team environment is shared configuration, not a shared workspace. A member's task runs in that member's own container with the team's variables and scripts, the member's own model and MCP connections, and the member's personal global variables at run time only (install-only ones are left out; the team's definition of a name wins). The installation is therefore the same for every member, which is what lets the team share snapshots.
- Secrets are shared. Every member who can run a task with a team environment can read its Secrets from inside that task; the dialog says so. Treat them as known to the whole team, and rotate one after a member who should not keep it leaves.
- Leaving. A binding records the team and the member's access generation.
When a member leaves, is removed, or the team is deleted, each of their
conversations on a team environment fails its next turn at claim with
team_environment_revoked(403, logged asagent_job_team_environment_revoked); a turn already running finishes. Terminals, actions, Fork, preparation and runtime refresh on such a conversation return 409; masking still reads its values. Rejoining does not revive old conversations: the generation has moved on. A member's repository default naming a team environment they can no longer use is cleared when they next start a task. - Removal. Removing a team environment clears every member's default that names it. Conversations already bound keep their fixed configuration, as with a personal environment.
- Rollback. Downgrading below
0082_team_environmentsis refused while any team environment exists, archived ones included.