Execution Backend Selection
Gitmoot names where a job's runtime subprocess executes through the
execution backend seam (internal/execbackend, issue #1535 contract,
#1536, #1537). The seam is deliberately distinct from Landlock local confinement
(internal/sandbox, the sandbox CLI, agent path grants), which restricts
what a locally-running subprocess may touch and is unaffected by backend
selection.
[remote_exec]
backend = "local"
# Optional privilege drop; both fields are required together.
local_uid = 1000
local_gid = 1000
# Use a traversable root when the Gitmoot home is below /root.
local_root = "/var/tmp/gitmoot-local"
# Remote E2B templates. OMP needs a dedicated template with at least 2 GiB RAM.
# e2b_api_key_file = "/run/secrets/e2b-api-key"
# e2b_template = "gitmoot-shell"
# e2b_omp_template = "gitmoot-omp"
# For opt-in broker access from a remote shell, configure both:
# credential_gateway_listen = "0.0.0.0:8443"
# credential_gateway_url = "https://broker.example.com:8443"
Versioned OMP review template
The credential-free OMP review image is defined in
templates/e2b/omp-review/Dockerfile. It pins both the E2B base-image digest
and the Go archive checksum. Root is used only for image construction; the
final image user and its runtime directories are UID/GID 1000 (user).
Gitmoot uploads the OMP binary and job-scoped gateway identity after
provisioning.
Build a new immutable version from the template directory:
cd templates/e2b/omp-review
npm ci --ignore-scripts
E2B_TEMPLATE_NAME=gitmoot-omp-go126-v3 \
E2B_CPU_COUNT=4 E2B_MEMORY_MB=4096 npm run build
E2B_API_KEY and a new E2B_TEMPLATE_NAME are required.
E2B_CPU_COUNT defaults to 4 and E2B_MEMORY_MB defaults to 4096. The command
prints the template ID and build ID; record both with the selected resources in
the workflow evidence before changing e2b_omp_template. Never put the API
key, GitHub credentials, repository data, runtime sessions, or job state in
this directory.
Every Dockerfile, base digest, Go version, or checksum change gets a new versioned template name. Verify a fresh sandbox by reading the pinned tool versions without installation, uploading an exact-head source archive, running build, vet, and focused tests, and checking that no credential paths or secret environment variables exist.
Activation changes only the operator config:
[remote_exec]
e2b_omp_template = "gitmoot-omp-go126-v2"
Gitmoot reloads this value while provisioning each job, so the next remote OMP
dispatch uses the new template without a daemon restart. Rollback restores the
previously recorded template name (gitmoot-omp-full-review for the initial
build); the next dispatch uses it. Do not delete the rollback template until
the replacement canary is complete. Listener-coordinate changes remain
process-bound and still require a restart.
local is the default. remote provisions the selected provider for engine-driven shell and OMP
review jobs -- implement remains on the allowlist but #2203 removed every way to
dispatch one, so review is the type that actually reaches the backend.
Remote OMP uploads the host OMP executable into the instance, requires
e2b_omp_template with at least 2 GiB RAM, and refuses before provider
allocation when that template or the credential gateway is missing.
Unsupported job types and other model runtimes also refuse before allocation.
Cloud E2B is the default remote provider. A home may also declare the Mac
Studio's private sandboxd (Linux ARM64 VMs, the same E2B-compatible client)
in a [remote_exec.sandboxd] section with api_key_file, template,
omp_template, HTTPS-origin base_url and envd_base_url,
omp_linux_arm64_file (a verified Linux AArch64 OMP), the guests'
credential_gateway_url, and an optional max_concurrent ceiling. A review
runs there only when requested with --exec-provider sandboxd or when its
repository sets checks_provider = "sandboxd"; the provider is stored on the
job and survives retries (the disk guard and remote_routing_enabled policy
use E2B only). An unknown provider, or sandboxd without its section, is
refused at request time. sandboxd attempts reserve no dollars; E2B attempts
count only against the E2B caps. Reconciliation lists and settles each provider
separately. One credential gateway listener serves every provider's origin, and
its certificate covers each advertised host.
A second sandboxd gateway, sandboxd-linux (Linux AMD64 Firecracker VMs on the
Gitmoot host), is declared in [remote_exec."sandboxd-linux"] with the same
keys, and is selected with --exec-provider sandboxd-linux or
checks_provider = "sandboxd-linux". Its endpoint may be plain HTTP on
loopback (http://127.0.0.1:43190), it sets omp_linux_amd64_file instead of
omp_linux_arm64_file (the key declares the guest architecture; setting both is
a load error), and its guests reach the credential gateway at
https://10.0.2.2:8443. Its capacity, ceiling and ledger rows are its own.
gitmoot doctor refuses a missing or wrong-architecture OMP file.
sandboxd's concurrency follows its GET /sandboxd/capacity report, read before
each reservation and cached for 10 seconds (errors are not cached; a sandboxd
create 409 drops the entry): the provider-wide cap is the reported cluster
totalSlots, lowered to max_concurrent when that is set above 0 (negative is
a load error), and attempts of one template also count against that template's
reported slots. A report with no slots or no online worker for the template, a
transport error, timeout, 5xx, 429 or malformed reply is a transient capacity
refusal and the review waits. A 404 (sandboxd older than the endpoint) falls
back to max_concurrent, or fails fast naming both fixes (upgrade sandboxd or
set max_concurrent) when it is unset; 401/403 fail fast. Running attempts are
never cancelled when capacity drops.
The provider was named mac before: [remote_exec.mac] is now a load error
naming [remote_exec.sandboxd], and --exec-provider mac,
checks_provider = "mac" and queued mac jobs are refused, naming
sandboxd. Keep max_concurrent set until GET /sandboxd/capacity answers
200 with the expected templates. See docs/remote-exec.md for the sandboxd
configuration and operator runbook.
Automatic review routing is opt-in and job-scoped. With no policy, even a process-wide remote backend setting does not reroute reviews. For example:
[review]
remote_routing_enabled = true
remote_purposes = ["security"] # code, security, ui, or architecture
remote_final_reviews = false # opt in to every ready, green exact head
[repos."owner/repo".review]
remote_routing_enabled = false # repo override; keep this repo local
For background reviews, a configured purpose or high-risk path/risk:high
label selects E2B only when the PR is open, not a draft, the requested head is
current, and at least one current-head CI check exists with no pending or failed
checks. Auth/security, credentials, sandbox/execbackend, deployment/release,
lifecycle paths and their matching CLI files are protected. risk:routine
overrides any automatic remote route, including remote_final_reviews.
If no purpose, label, final-review rule, or known high-risk path qualifies,
the review stays local. With
remote_final_reviews = true, any ready exact-head review with green CI is
eligible. Drafts, stale heads, missing/red CI, unsupported runtimes, and
foreground reviews stay local. An explicit per-job --exec-backend always
wins. Each dispatch persists a review_backend_route_selected event with its
reason and the chosen backend. The remote worker rechecks current head and CI
immediately before reserving capacity; a later red result refuses the cloud job.
Per-repository checks routing
A repository whose review checks need a toolchain the local review seat lacks (Swift, Rust, Node, Flutter) can send every review to a remote provider and image (#2316):
[repos."owner/repo".review]
checks_backend = "remote" # the only value
checks_provider = "e2b" # or "sandboxd" / "sandboxd-linux"; default "e2b"
checks_template = "gitmoot-swift" # optional; replaces the provider's template
- Every review of that repository runs remotely on
checks_providerwithchecks_template, whichever producer enqueued it: policy-routed,agent review,review request, native PR fan-out, comment-triggered, heartbeat and pipeline review stages. The dispatch verbs record the route when they enqueue; the worker applies it to any other review before it resolves the job's backend, with the samereview_backend_route_selectedevent. The provider and template are stored in the job payload (exec_provider,exec_template) so retries and model fallbacks keep them. The template replaces the provider'se2b_template/e2b_omp_templatefor that job only. - The
remote_routing_enabledgates (CI-green, risk labels, purposes, stale head at admission) are skipped: they control spend, and this routing is for capability. Repositories withoutchecks_backendare unchanged. - A review without
--exec-provider(orexec_provider) adoptschecks_provider. Refused, with the repository and the setting named, at dispatch or when the worker picks the job up: an explicit--exec-backend local, an--exec-providerother thanchecks_provider, a foreground review, and a reviewer whose runtime cannot run remotely (onlyshellandompcan). There is no per-job override; to review the repository locally, removechecks_backend. checks_provideris validated when the config loads: an unknown provider, or one with no[remote_exec]/[remote_exec.sandboxd]API key file or no template at all, makes every review of that repository refuse with that reason. Other repositories keep loading. Thechecks_*keys are repository-scoped; in the global[review]section they are an error.- Without
checks_template, the template depends on the reviewer's runtime:ompprovisions the provider'se2b_omp_template,shellitse2b_template. A provider that sets only one of them serves only that runtime; a review on the other is refused naming the missing key. - When the provider's cost or concurrency cap is full, the review returns to
the queue instead of failing: a
remote_review_cap_waitingjob event names the provider and the reason, the next retry time and the wait so far, and the scheduler retries on its normal tick onceblocker_retry_atpasses (every 30 seconds). When a slot frees, the review provisions andremote_review_cap_admittedrecords how long it waited. The wait is bounded by the job timeout (one hour when none is resolved); past it the job fails with the cap refusal as its reason. An unconfigured cap fails immediately, because it never frees. Every sandboxd review, routed here or not, waits the same way on a full cap, acapacityrefusal or a sandboxd create 409; a cloud E2B 409 is unchanged. - A remote review that declares
executedevidence while itstests_runreports a missing toolchain (command not found, exit 127, "toolchain unavailable", "not installed") or names nothing that ran is recorded asstatic_only, with aremote_checks_evidence_clampedjob event.
Actual cost: [remote_exec].cost_per_hour_usd is the provider's price for one
sandbox-hour (for a 2 vCPU / 4 GiB E2B template, about 0.166). When set, each
destroyed attempt records execbackend_attempts.cost_actual_usd as its
lifetime, from reservation to teardown, times that rate. The E2B API reports no
per-sandbox cost, so without the key cost_actual_usd stays NULL. The sandboxd
provider has no dollar cost and records none. Attempts settled by
reconciliation, whose teardown time is unknown, also stay NULL.
Sandbox lifetime: cloud E2B retires a sandbox one hour after it is created.
Creation refuses a longer timeout ("Timeout cannot be greater than 1 hours"),
and a later extension is silently capped at that hour, so the TTL keepalive
cannot carry a cloud sandbox past it. A job whose timeout is longer (the
default is four hours) therefore has its run deadline clamped to end five
minutes before its sandbox does, recorded as a job_timeout_clamped job
event, and it times out cleanly instead of losing its stream. A run that still
fails once its sandbox's hour is up fails with a sandbox_ttl_exceeded job
event and the same reason in its PR comment, and is never retried
automatically: a fresh sandbox has the same limit. sandboxd sandboxes have no
fixed lifetime (each extension is bounded by the worker's -max-ttl from the
time of the request), so neither applies to them.
Before reserving cost or calling E2B, remote review admission re-reads the pull
request head and refuses stale jobs, duplicate repo/PR/head/purpose subjects,
unsupported runtime/backend pairs, and unapproved repeat attempts. One accepted
active or terminal review owns its exact-head subject. A cancelled review
releases that ownership. The first cloud attempt is the default; a later
lifecycle generation needs either gitmoot job retry or a provider create
conflict classified as authoritative proof that nothing was allocated.
For explicitly selected remote reviews, current-head CI is optional and off by default. Enable it globally or override it per repository:
[review]
remote_require_ci_green = true
[repos."owner/repo".review]
remote_require_ci_green = false
When enabled, admission requires at least one reported check and every check must
be passing, skipped, or neutral. Policy-routed remote reviews always require
this, regardless of remote_require_ci_green.
Refusals record durable remote_review_admission_avoided_<reason> job events,
where reason is stale, duplicate, unsupported, red_ci,
ci_pending (current-head checks still running, none failed), or retry.
For an engine-driven daemon job, Gitmoot provisions one job-scoped instance, syncs the selected host
checkout into a distinct detached Git worktree, streams runtime commands there,
collects changes, and destroys the instance after the job. The same instance
survives Mailbox repair deliveries. Only an implement job imports changes, and none can be dispatched; a review
returns its FINDINGS in the result envelope and needs no change set. An
implement job's changes would return through
the bounded transactional BuildChangeSet / ImportChangeSet transport before
result observation; host Git commands and the finalizer still run against the
host worktree, and backend-created commits are refused.
On the remote backend the instance workspace is a Git repository whose HEAD
is the exact host commit, not a copy re-committed under a new SHA. Gitmoot
fetches that commit and its history back to the review diff base into a
throwaway host repository and uploads only its object store and shallow
boundary, plus a patch of any uncommitted host changes. No host .git
directory, config, remote, credential helper or GitHub token is uploaded, and
the instance has no origin. For a pull-request review the diff base is the
merge base with the refreshed origin/<base> (or the prior reviewed head for a
bounded re-review); the instance gets it as the local branch
gitmoot-review-base, which the checked-out gitmoot-head branch tracks, so
git status, git log @{upstream}.. and git diff @{upstream} show the
review scope. A remote review also receives the same rendered, repo-scoped
prior-verdict list a host read-only seat gets, including each verdict's
individual findings (finding_details) as recorded, at
/home/user/.gitmoot/runtime/evidence/prior-verdicts.json, named by
GITMOOT_PRIOR_VERDICTS. If the list cannot be rendered the review still runs
and the job records a remote_review_evidence_unavailable event.
local_uid and local_gid opt agent commands into a kernel-enforced non-root
identity. Gitmoot never guesses a host account: both numeric values must be set
together and both must be non-zero. After sync, Gitmoot hands the independent
workspace to that identity; collection still runs in the daemon, temporarily
reclaiming the clone for Git's ownership check, and import creates host files as
the daemon user. Credential-application errors fail the command and never retry
as the daemon user.
The configured identity must be able to traverse every operator-managed parent
of the local backend root. The default root is below the Gitmoot home, so a root
daemon whose home is under /root should also set absolute local_root to a
dedicated directory beneath a suitable operator-managed parent such as
/var/tmp. A filesystem root is rejected. Gitmoot keeps its backend root and
per-instance roots owned by the daemon, assigns their group to local_gid, and
uses mode 0710 so the configured command group can traverse them without an
execute bit for the Unix other class. This is a group boundary: every process
carrying local_gid can traverse, so use a dedicated group whose only member is
the configured execution account when unrelated local users must be excluded.
Gitmoot validates that the numeric UID/GID are paired and non-zero, but cannot
portably prove group exclusivity across host account databases, NSS, or
containers. With no uid/gid configured, local execution retains the daemon
identity for backward compatibility.
The runtime executable and any child-readable file-backed state must also be
reachable by the configured identity. In particular, a root daemon whose
claude or codex resolves below /root must install the runtime at a
traversable location and put that location first in the daemon's PATH; do not
make /root traversable just to satisfy this requirement. An inaccessible
runtime fails command startup rather than falling back to root.
For daemon jobs that own an execution-backend lifecycle, runtime contract
preflight evaluates UID-dependent requirements against configured local_uid.
This lets a root daemon dispatch Claude with danger-full-access to a non-root
local backend without weakening Claude's root refusal. Host-only paths such as
gitmoot job run do not provision that lifecycle and still evaluate the host
process identity. When local_uid is absent, daemon jobs do the same.
The local worktree's .git file points at an absolute gitdir in the source
repository. That pointer resolves on the same filesystem, so local needs no
history hydration; the remote backend ships exact history as described above. Cancel
kills active command groups and destroys the instance. On daemon restart, the
remote reaper positively observes provider inventory and matches the complete
sandbox identity (job, attempt, generation, fencing token, boot ID, and sandbox
ID) against the durable local ledger before deleting an old-boot or dead-owner
sandbox. A foreign account sandbox with no matching ledger row is never deleted.
An incomplete E2B inventory cannot prove a missing sandbox was destroyed; only
provider-confirmed deletion releases its cost reservation. The one exception is
an attempt left in destroying (its teardown DELETE was inconclusive): once a
successful inventory pass no longer lists its sandbox AND the attempt is past
its ttl_expires_at plus a 15-minute grace, E2B's own timeout has killed the
sandbox, so the attempt settles to destroyed with an
execbackend_destroy_confirmed job event and frees its cap slot. A per-ID 404
never settles it (E2B returns that for an inaccessible live sandbox too), and a
failed inventory keeps the slot. A provider with a complete inventory settles
on absence alone. A partially-created non-empty directory that Git never registered remains the
known orphaned-but-present cleanup limitation tracked in #1572.
Proving a Parallel Local Wave
Run the proof with a dedicated --home; never point it at the live Gitmoot
home. Keep the source checkout under a path the configured identity can
traverse, set backend = "local", and give every Claude leg a distinct
fresh:<suffix> session:
gitmoot agent subscribe gate-a --runtime claude --session fresh:gate-a \
--role reviewer --repo OWNER/REPO --policy danger-full-access \
--capability ask --home "$PROOF_HOME"
Keep the daemon running in its own shell:
gitmoot daemon run --repo OWNER/REPO --parallel 4 --poll 1s \
--home "$PROOF_HOME"
From the dispatch shell, submit every leg together. Gitmoot no longer
dispatches implementation (#2203), so the legs are background ask jobs; a
danger-full-access ask still writes, which is all this gate needs — it proves
the execution backend's identity and isolation, not the implement finalizer:
gitmoot agent ask gate-a "record uid, gid, pwd, start, end, and visible markers" \
--repo OWNER/REPO --background --home "$PROOF_HOME"
Dispatch the other legs together, not after the first settles. Every leg's
gitmoot_result should include its UID, GID, workspace, start and end
timestamps, and visible marker set in summary; its marker in changes_made;
and the read-back and visibility checks in tests_run.
The proof passes only when all of these are independently observed:
- every runtime delivery returns a parsed
gitmoot_result; - the reported UID/GID equal the configured non-root identity;
- the intervals overlap with measured peak concurrency equal to the leg count;
- every workspace path is distinct, and each leg sees only its own marker;
- the isolated ledger contains zero remote execution attempts, and no E2B
credential or
remoteselector is present.
One criterion is weaker than it was when this gate dispatched implement legs.
A background taskless ask is auto-isolated into a detached committed-tip
worktree only when same-repo readers are contended, and that isolation is
deliberately fail-open — allocation failure records
readonly_worktree_skipped and falls back to the registered checkout. Four
simultaneous legs do contend, so in practice the paths are distinct, but read
the reported workspace from each gitmoot_result and treat distinctness as
OBSERVED, never assumed. When the gate needs guaranteed writable per-job
isolation instead, run the legs as pipeline action: produce stages with
declared writes: roots: produce is mutating, live, and sandboxed per job.
Gitmoot reads [remote_exec] when it dispatches a job. Once the process starts
a remote credential listener for a home, those listener coordinates are
immutable: a later dispatch with changed coordinates fails loudly until the
daemon restarts instead of silently reusing a stale endpoint. Foreground
dispatch refuses remote because it has no daemon-owned lifecycle, ledger, or
reaper.
When [credentials].model_gateway = true, a remote shell or OMP job receives the
non-secret route in GITMOOT_CREDENTIAL_GATEWAY_URL and a path to an owner-only
curl configuration in GITMOOT_CREDENTIAL_GATEWAY_CURL_CONFIG for the
sandbox-reachable credential gateway. The second listener requires a per-job
mTLS certificate and an opaque
capability bound to the sandbox id, runtime, job lease expiry,
and the exact upstream allowlist. Provider keys remain host-side and are loaded
only after those checks pass. The route is revoked before sandbox teardown.
Before release, the host refuses an initial residual Content-Encoding or an
unexpected HTTP/2 response. It drops response field names containing the key,
redacts key bytes from remaining field values, and incrementally filters body
bytes with bounded carry-over so matches split across transport chunks are
removed without delaying streamed responses until EOF. Standard reversible
URL/base encodings remain best-effort defense in depth. The contract covers an
accidental exact-byte reflection by the trusted, operator-selected upstream;
malicious upstreams and transformed application payloads, including
application-layer compression without Content-Encoding, are out of scope.
Standalone Claude, Codex, and Kimi runtimes remain unsupported on remote
until their clients can target this mTLS path. OMP uses an instance-local HTTP
forwarder for Anthropic and a job-scoped models.yml that routes the
openai-codex, xai-oauth and devin providers (for example
openai-codex/gpt-6-sol, xai-oauth/grok-4.7 and devin/swe-2) through OMP's
native gateway transport. The sandbox receives only an mTLS identity, capability,
loopback route, and inert placeholder. Provider credentials must exist in the
host OMP broker, which also performs any OAuth refresh; Gitmoot never supplies
a raw provider key as a fallback. Any other provider has no credential in the
sandbox, so a remote review's model fallback skips such pool entries (event
review_model_remote_skipped) and leaves them to local reviews.
For these models, [credentials].model_gateway_key must name a proxied
host key whose upstream is the host OMP auth-gateway, backed by broker
credentials for Codex, xAI and Devin. Allow that upstream host in
model_gateway_allow_hosts; a loopback upstream also requires
model_gateway_allow_loopback_upstream = true. The default
api.anthropic.com upstream cannot serve OMP's /v1/pi/stream route.
The uploaded host OMP binary must support pi-native (verified with 18.2.11).
A job payload's exec_backend field overrides the config value for that one
job. When either selector is explicitly present its value must be non-blank;
an absent selector defaults to local, while backend = "" or an explicit
"exec_backend":"" fails loudly.
Any value outside the allowed set (local, remote) fails the job
loudly at dispatch: the failure names the offending value and the allowed
set (for example unknown execution backend "e2b" (allowed: local, remote)).
There is no silent fallback.