feat(tools): add Sprites stateful sandbox backend (salvage of #30112) - #93523
feat(tools): add Sprites stateful sandbox backend (salvage of #30112)#93523benbarclay wants to merge 50 commits into
Conversation
Adds a new TERMINAL_ENV=sprites option backed by the sprites-py SDK
(Fly.io). Persistent by default; sprites are keyed by hermes-{task_id}
so sessions resume cleanly across restarts. Verified end-to-end against
api.sprites.dev (exec, cwd tracking, env persistence, stdin heredoc,
exit codes, file sync, ephemeral vs persistent cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprites does not yet expose region or per-sandbox compute sizing (CPU/memory/disk) to API consumers, so the SpriteConfig and the setup flow are simplified to match: sprite creation no longer passes a SpriteConfig at all, the setup wizard no longer prompts for region or container resources, and the docs YAML example drops the container_cpu/memory/disk knobs with a note that they are ignored on this backend. Adds the per-backend section (mirrors Daytona/Vercel pattern), the SPRITES_TOKEN and SPRITES_BASE_URL env-var rows, and the troubleshooting bullet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per sprites.dev, a Sprite is a hardware-isolated, stateful Firecracker VM on Fly.io with checkpoint & restore — not just a generic "cloud sandbox." This normalizes the wording across the user guide, env-var reference, setup wizard, and module docstrings: - "Sprite" (singular, capitalized) for an instance; "Sprites" for the service/product - Backend description leans on Firecracker / Fly.io / stateful framing instead of the generic "cloud sandbox / cloud VM" labels - Compute-sizing note is reworded to match the platform's dynamic allocation model (up to 8 CPU / 16 GB RAM) rather than implying static defaults Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ud sandbox" Per maintainer preference, the public-facing description shouldn't lean on the underlying hypervisor name. Keeps the "stateful sandbox / checkpoint & restore" framing aligned with sprites.dev but reverts the implementation detail to the generic "cloud sandbox" wording used by Modal/Daytona/Vercel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the Daytona section structure: requirements, what it's good for, the dynamic-compute-allocation caveat, and a minimal YAML stanza. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Sprites API endpoint is static (api.sprites.dev) — there is no self-hosted deployment story to support, so exposing a base-URL override in setup, .env, and docs was just noise. SpritesClient is now constructed with no base_url kwarg (lets the SDK use its own default). Setup keeps a one-line cleanup that removes any previously-saved SPRITES_BASE_URL from existing users' .env on next `hermes setup terminal` run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Sprites backend deliberately doesn't copy agent-modified files back to ~/.hermes/cache/remote-syncs/... on cleanup the way SSH/Modal/Daytona do. Those backends need it because their sandboxes are torn down or reset between sessions; Sprites' ext4 filesystem is persistent and the same Sprite (by task_id) is resumed on the next session with all state intact, so a sync_back would just duplicate the canonical store. - sprites.py cleanup() drops the no-op sync_manager.sync_back() call and replaces it with a comment explaining the design choice. - configuration.md splits "Credential files" into push (still applies) and a new "No sync-back, by design" note; the Remote-to-Host File Sync section calls out the Sprites carve-out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprites tokens default to full-account access, but the dashboard
(Account → Tokens → ⚙ → Restricted Token Options) can mint tokens
scoped to a name prefix and a max-sprites cap. Pair this with our
deterministic hermes-{task_id} naming by creating a hermes-prefixed
token — the token can manage everything Hermes spawns and nothing else.
- configuration.md: new "Restricted tokens" subsection under Sprites
authentication, explaining the two restriction knobs and why the
hermes prefix is the right default for CI / shared envs.
- setup.py: surface the same tip inline when the wizard prompts for
the token so first-time users see it before pasting an unrestricted
one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CONTRIBUTING.md (post Mar/May 2026 supply-chain rules) requires every new PyPI dependency to declare a `<next_major` ceiling rather than an exact pin. `0.0.1rc37` falls under the pre-1.0 rule: floor must include the rc tag so pip opts in to the pre-release; ceiling is `<0.(current_minor + 2) = <0.2`. Future 0.0.x / 0.1.x patches resolve; a hostile 0.2.0 doesn't. pip dry-run confirms the new spec still resolves to 0.0.1rc37 today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unit tests (tests/tools/test_sprites_environment.py): 18 cases against a mocked sprites-py SDK — no token, no network. Cover construction (missing-token error, persistent get-first, create-when-not-found, no compute kwargs, no base_url kwarg), cwd resolution (default /root → detected home, ~ rewrite, explicit cwd preserved), cleanup (persistent leaves the Sprite alive, ephemeral deletes it, idempotency, client.close), _run_bash exit-code surfacing (zero, ExitError → 7, TimeoutError → 124), filesystem push (write_bytes + parent.mkdir, unlink per path), and the _stdin_mode = heredoc declaration. Integration tests (tests/integration/test_sprites_terminal.py): 8 cases against the live api.sprites.dev — gated by SPRITES_TOKEN and @pytest.mark.integration. Module-level skip when the token is absent. Token is captured at import time and re-injected via an autouse fixture because the project conftest's hermetic env wipes everything ending in _TOKEN. Covers basic exec / non-zero exit / OS info / Python availability, write+read, env var persistence across calls, the sprite-env info identity check (asserts hermes-default substring and that the in-Sprite boot_id differs from the host's), and filesystem persistence across a session recycle. Verified locally via scripts/run_tests.sh — 24,007/24,033 pass (26 pre-existing failures in unrelated test files: acp, gateway systemd, browser binary lookup, etc.). 18 unit tests pass under per-file isolation in ~5 min; integration tests pass against the live API in ~80s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps "Seven" → "Eight" and adds a one-sentence framing for Sprites: stateful Fly.io sandboxes with native checkpoint & restore that resume session-to-session (vs. Modal/Daytona, which hibernate-and-wake). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the styling of every other backend name in the same sentence — none of the others link out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stence Sprites' hibernate-when-idle / wake-on-demand cost model is the same as Daytona's and Modal's, so the single grouped sentence carries it without needing a dedicated callout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Sprites backend was already wired through the agent runtime
(_create_environment, requirements check) but missing from the
diagnostic CLI surfaces, so users with sprites configured got a bare
"Backend: sprites" with no token/SDK detail and `hermes doctor` had no
proactive check.
- hermes_cli/status.py: new branch reporting sprites-py install status
and whether SPRITES_TOKEN is set.
- hermes_cli/doctor.py: dedicated block mirroring the Daytona/Vercel
pattern — checks SPRITES_TOKEN presence, SDK install, and prints the
persistence semantics ("Sprite stays alive" vs "Sprite is deleted on
cleanup").
- hermes_cli/config.py: new branch in `hermes config show` reporting
whether the token is configured.
- AGENTS.md: add sprites (and the previously-missed vercel_sandbox)
to the project-structure backends listing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several cross-cutting registrations only listed the prior sandboxed backends (docker / singularity / modal / daytona / vercel_sandbox); Sprites is also a remote, hardware-isolated sandbox and needs the same treatment. Without this, the agent path on a Sprites backend hits false dangerous-command approval prompts, leaks SPRITES_TOKEN to local- backend subprocesses, and silently drops container_persistent overrides from the code_execution_tool / file_tools dispatch paths. - tools/approval.py: add "sprites" to both sandboxed-backend skip sets (the agent's command is running inside the Sprite, not on the host — same isolation guarantee as the other cloud backends). - tools/environments/local.py: add SPRITES_TOKEN / SPRITE_TOKEN to the provider env blocklist so they are stripped from local-backend child process environments (matches the VERCEL_*, DAYTONA_API_KEY, and MODAL_TOKEN_* treatment). - tools/skills_tool.py: add "sprites" to _REMOTE_ENV_BACKENDS so the skills tool routes its remote/local distinction correctly. - tools/file_tools.py: add "sprites" to the container_config dispatch set so container_persistent: false can take effect through the file-tool code path. - tools/code_execution_tool.py: same dispatch fix (I had removed it in 015e4fe5b on the grounds that sprites ignores CPU/memory/disk — but container_persistent IS honored). - hermes_cli/web_server.py: add "sprites" to the dashboard's terminal.backend select-control options. Surfaced by comparing this branch against #17445 (the Vercel Sandbox backend PR), which had to make every one of these registrations explicitly. Same audit applies here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the placement vercel_sandbox got in PR #17445: - features/tools.md: row in the backend comparison table, "sprites" added to the backend-enum comment, and a dedicated "Sprites (Fly.io)" subsection covering install + auth, the hermes-{task_id} resume model, the restricted-token recommendation for CI / shared envs, the persistence semantics, and the "no sync-back, by design" rationale. - security.md: container-bypass info note and production-tip paragraph both mention sprites; comparison table gains a row showing dangerous-command checks are skipped (because the Sprite is the security boundary). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # README.md # hermes_cli/config.py # hermes_cli/doctor.py # hermes_cli/setup.py # hermes_cli/status.py # hermes_cli/web_server.py # pyproject.toml # tools/approval.py # tools/code_execution_tool.py # tools/environments/__init__.py # tools/environments/local.py # tools/file_operations.py # tools/file_tools.py # tools/lazy_deps.py # tools/skills_tool.py # tools/terminal_tool.py # website/docs/reference/environment-variables.md # website/docs/user-guide/configuration.md # website/docs/user-guide/features/tools.md # website/docs/user-guide/security.md
…rrent backend classifications Addresses the hermes-sweeper salvage review on #30112. Problem 1 — durable state shared across sessions: the Sprite name was `hermes-{task_id}`, and the task-id resolver collapses ordinary sessions to `default`, so every session shared one live `hermes-default` Sprite (its processes, sockets, and PID space — not just a filesystem snapshot). Scope the name by the active Hermes profile via `_resolve_sprite_name` (`hermes-{profile}-{task_id}`; unchanged `hermes-{task_id}` on the default profile for backward compatibility) so independent profiles never resume into one another's live Sprite, while the same (profile, task_id) still resumes. Names are slugified to a Fly/DNS-safe form. Problem 2 — branch predated current classification paths: register `sprites` in the shared backend classifications main grew after this branch forked — `_REMOTE_TERMINAL_BACKENDS` + `_BACKEND_FALLBACK_DESCRIPTIONS` (host-info suppression / live probe in the system prompt), `_CONTAINER_BACKENDS` (cwd sanitization), and the container_config builder (so `container_persistent` reaches the backend and ephemeral mode works). Tests: add TestSpriteNaming (resume + cross-profile isolation + slugification + resolver-failure fallback); the integration identity test now derives the expected name instead of hard-coding `hermes-default`; extend the container / prompt set-pinning guards to include sprites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed contract - TestDispatchWiring drives the real terminal_tool() body and asserts the container_config builder includes sprites (container_config=None would silently discard container_persistent: false, making ephemeral mode unreachable), plus pins the _create_environment → SpritesEnvironment kwarg handoff (persistent_filesystem, task_id, cwd). - _sprite_name is now read at runtime (cleanup-failure log) and asserted in the construction tests instead of being a write-only attribute. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l-backend # Conflicts: # README.md # agent/prompt_builder.py # hermes_cli/config.py # hermes_cli/doctor.py # hermes_cli/setup.py # hermes_cli/status.py # hermes_cli/web_server.py # pyproject.toml # tests/agent/test_prompt_builder.py # tests/tools/test_container_cwd_sanitize.py # tools/approval.py # tools/code_execution_tool.py # tools/environments/__init__.py # tools/environments/local.py # tools/file_operations.py # tools/file_tools.py # tools/lazy_deps.py # tools/skills_tool.py # tools/terminal_tool.py # website/docs/reference/environment-variables.md # website/docs/user-guide/configuration.md # website/docs/user-guide/features/tools.md # website/docs/user-guide/security.md
…ed since July Upstream grew new shared classification sites while this branch aged; sweep them so sprites keeps remote/container semantics everywhere: - tools/env_probe.py _REMOTE_BACKENDS (host Python-state probe line must not leak into a sprites session's prompt; explicitly kept in sync with prompt_builder._REMOTE_TERMINAL_BACKENDS) - tools/file_tools.py _CONTAINER_PATH_BACKENDS_FALLBACK + class-name sniff in _terminal_env_type_for_task - tools/terminal_tool.py container_backend env-var parse gate - agent/prompt_builder.py _probe_remote_backend container_config set - tools/credential_files.py cache-path translation (sprites homes are ~/.hermes like ssh/daytona/vercel, not host paths) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rc37-era pin predates the SDK's stable series. Full live integration suite (8 e2e tests vs api.sprites.dev) verified against 0.5.0; client, sprite, filesystem, and exception surfaces are all compatible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clean merge (no textual conflicts). Brings PR #30112's Sprites backend onto current main for the salvage round.
pyproject.toml added sprites-py>=0.5.0,<0.6 under [project.optional-dependencies] but uv.lock was never regenerated, failing `uv lock --check` and the repo's locked-dependency contract. Locks sprites-py 0.5.0 (+ client-signals 0.4.4).
mclaren@fly.io -> kylemclaren (PR #30112 author's work email) noreply@sprites.dev -> sprites-dev (Sprite co-author bot)
Round-2 review (verified by execution) found the round-1 identity fix
incomplete on three edges:
- Component-boundary collision: profile 'a-b' + task 'c' and profile
'a' + task 'b-c' both produced hermes-a-b-c. Named-profile names now
append a 6-hex digest over the raw (profile, task) pair separated by
an unambiguous 0x1F delimiter — hermes-{profile}-{task}-{digest} —
making the full identity injective across component boundaries and
across profiles whose display slugs collide.
- Real fail-open path: file_safety._resolve_active_profile_name
swallows OSError/RuntimeError and returns 'default', so the previous
raise-on-failure never fired for the failures that actually occur.
Sprite naming now derives profile identity from the HOME paths
directly (_resolve_profile_identity) and raises on resolution
failure. A custom HERMES_HOME outside the profiles tree gets its own
'home:<path>' identity instead of silently sharing the default
profile's Sprite.
- Docstring overstated injectivity (a crafted clean value can equal a
lossy value's slug+hash within one component); scoped the claim and
documented the accepted residue.
Default-profile names are unchanged (hermes-{task}, legacy resume
intact). Named-profile names change scheme once — previously created
named-profile Sprites are orphaned, stated in the PR body.
Tests: naming literals now pinned exactly (digest included) so a
digest/scheme change fails loudly; new component-boundary collision
case; path-based resolver cases (default homes, named profile, custom
HERMES_HOME); fails-closed test now breaks the underlying path
resolution rather than mocking the wrapper.
Round-2 review findings against the previous test fix (both verified by
execution):
- Wrong registry: the file loads terminal_tool.py as a standalone
importlib module, but the recycle test and fixture imported
_active_environments/_resolve_container_task_id from
tools.terminal_tool — a DIFFERENT module object with its own registry.
The assertions would fail spuriously on the first real-token run. All
helpers/globals are now bound from the one terminal_module instance.
- Real-Sprite destruction hazard: task-id collapse + ephemeral teardown
meant every test resumed the operator's genuine
hermes-{profile}-default Sprite and deleted it, filesystem and all.
The autouse fixture now pins Sprite naming to a run-unique
hermes-test-{run} namespace, so the suite can never touch a
production Sprite name; the identity test asserts against the test
namespace (production naming stays covered by the unit suite).
- Persistent-Sprite leak: the recycle test's second env baked in
_persistent=True, so the final 'force-delete' cleanup actually left
the test Sprite running (billing forever). Teardown now flips the
live env's persistence flag before cleanup so the Sprite is deleted.
- configuration.md claimed every normal session in a profile shares one
Sprite. Wrong for gateway/WebUI: _resolve_container_task_id returns
session:{HERMES_SESSION_KEY} when a session key exists, so those
sessions each get their OWN Sprite. Now documents both cases and the
per-session billing implication for gateway operators; the naming
line also reflects the named-profile digest scheme.
- terminal env help listing gains sprites.
- Reword the exec-deadline comment: 3600s is a fallback for
absent/nonpositive timeouts, not a ceiling on explicit values.
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head 5156d98d629d177537da516c1b54d2775fe9510f against base 6ed8bcee8dc7c27965a2ee1fb8e8370b0bfd6169 and current main 91e867631e9d2eb9fbd69edd4459475d38070979 (20 commits ahead of the PR base). Exact-head CI 32692363207, Docker 32692362931, and Nix 32692362804 are green. I also traced the direct donor #30112 and its July review: this salvage does close the old profile-scoping and backend-classification blockers, so I am not repeating those.
I still see three merge blockers in the new/current shape:
-
container_persistent: falseis not actually an isolation boundary for Sprites. Currentterminal_tool.py::_docker_session_isolation_enabled()explicitly codifies the repository contract established by merged #82731: non-persistent means state must not survive/share across sessions, so Docker keys the sandbox per session instead of collapsing to"default". #93523 wirescontainer_persistentintoSpritesEnvironment, but_resolve_container_task_id()still only applies that per-session branch to Docker. In CLI/no-session-key mode Sprites therefore still resolves to"default";_resolve_sprite_name()produces the same deterministichermes-default(profile-scoped), andSpritesEnvironment.__init__()unconditionallyget_sprite()s and resumes it even when_persistentis false. Two independent non-persistent Hermes processes can attach the same live Sprite, and either cleanup can delete the VM out from under the other. A crashed/failed-cleanup ephemeral run is also resumed by the next supposedly-fresh run. That is the exact other side of #82731's stale-sandbox defect class, now on a new backend.Required fix: make non-persistent Sprites generation/session-owned. Extend the shared isolation/keying authority (rather than creating a contradictory backend-local meaning for
container_persistent:false) so independent sessions/processes get distinct ephemeral Sprite identities, while delegated children can still alias to their parent where that is the intended contract. An ephemeral constructor must never adopt a pre-existing durable Sprite. Add adversarial coverage for two independent non-persistent sessions/processes plus a stale survivor from a crashed prior run; prove no cross-adoption and no teardown-under-peer. -
_sprite_delete()defeatsFileSyncManager's transactional deletion/rollback contract, which can leave stale credentials in a durable Sprite permanently. The callback doesunlink(missing_ok=True)and then catches every exception and only logs it. ButFileSyncManager.sync()advances_synced_filesafter_delete_fn()returns; it rolls back/retries only when the callback raises. So a transient remote unlink failure is falsely committed as success. On the next sync the removed path is no longer in_synced_files, therefore it is never retried. Becauseiter_sync_files()includes credential material, deleting/rotating a host credential can leave the old copy in the persistent Sprite indefinitely. This directly violates the unified file-sync contract introduced by #6308 / commit1f1f2975(remote deletion + full rollback on failure).missing_ok=Truealready handles the benign absent-file case; swallowing everything else removes the transaction boundary.Required fix: propagate non-benign delete failures so the manager rolls state back and retries. Add a Sprites regression that makes remote
unlink()fail once, removes a synced credential/skill locally, proves the manager does not commit the deletion, then proves the next sync retries and clears it. -
Persistent first-use creation is a cross-process TOCTOU. The constructor performs
GET(name)→ on 404POST create(name). The Sprites API documents names as unique and returns HTTP 400 when a create name already exists; pinnedsprites-py==0.5.xmaps any non-success other than 401/404 toSpriteError. Two Hermes processes first-opening the same persistent(profile, task)can both observe 404, one create successfully, and the loser fail construction on the duplicate create instead of adopting the winner. The in-process terminal creation locks do not serialize separate Hermes processes.Required fix: make this create-or-adopt race-safe without parsing error prose: if create fails, re-GET the exact deterministic name; adopt it if it now exists, otherwise re-raise the original create error. Add a two-client/concurrent first-create regression proving both callers converge on the same Sprite and a genuine invalid-create error is not masked.
Architecture / ownership notes:
- #93523 is the current-main salvage/superseding publication of #30112 by @kylemclaren; preserve that authorship/credit. The July #30112 findings around profile namespace and current backend classifications are resolved here.
- Merged #82731 is complementary foundation, not duplicate work: it owns the repository meaning of
container_persistent:falseas per-session isolation. Sprites needs to participate in that same authority. - #6308 by @alt-glitch (landed as
1f1f2975) is the shared file-sync transaction foundation. The Sprites callback must honor its rollback semantics rather than silently narrowing them. - Current main also merged #93488 after this PR's base, consolidating task-id/path sanitization across backends with credit to @HexLab98, @salch-cred, @Parker-Fawcett, and @chelsealong. Sprites has a DNS-specific resource-name problem rather than a host-path problem, so I am not calling the helpers duplicates, but the final composition should preserve #93488 and rerun the backend suite on current main rather than treating this exact-head green as evidence for the 20-commit-ahead merge tree.
Once those ownership/lifecycle edges are closed, the rest of the reviewed surface looks coherent: profile-scoped token lookup, subprocess token scrubbing, remote backend classification, credential-cache path translation, bounded exec deadlines, live integration coverage, and the direct #30112 salvage lineage are all wired consistently on this head.
Round-3 review (both reviewers, findings verified by execution) broke
the round-2 digest scheme three ways:
- 24-bit digest: a real collision between two valid 29-char profile
names was demonstrated — 6 hex chars cannot carry a trust boundary.
Digest widened to 12 hex chars (48 bits) and made the authoritative
identity, with the display slug demoted to cosmetic prefix.
- Separator forgery: sha256(f'{profile}\x1f{task}') is ambiguous when a
component contains 0x1F — ('a\x1fb','c') and ('a','b\x1fc') collided.
_identity_digest now length-prefixes each component before hashing,
making the encoding unambiguous for arbitrary bytes.
- Unbounded names: a custom HERMES_HOME produced an 87-char name (168
with a session task) against the ~63-char DNS label the
{name}-random.sprites.app hostname implies; server-side truncation
would have chopped off exactly the trailing digest. _bounded_name now
caps every generated name at 63 chars, truncating only the display
prefix — the digest always survives intact. Short default-profile
names remain byte-identical legacy (hermes-{task}); oversized
default-profile tasks (session keys) fall to the bounded form.
Tests: all pinned literals recomputed for the 12-hex scheme; new cases
for the demonstrated profile-collision pair class (via boundary test),
separator forgery, DNS bound with digest-tail preservation, and
legacy-name stability under the bound. make_env now pins the
_resolve_profile_identity seam the naming actually uses.
- sprites.py module docstring, tools.md, and the integration-test
header still described the pre-digest hermes-{profile}-{task_id}
scheme; updated to the digest-bearing contract.
- configuration.md: 'all delegate_task children collapse to default'
was wrong under a session-keyed parent — children inherit the
parent's HERMES_SESSION_KEY via contextvars and share the parent's
per-session Sprite. Now says children always share their parent's
Sprite.
- Integration-test safety claim softened from 'can never' to the
actual guarantee (production collision requires spelling out the
run's random uuid8).
PR-review finding (andrexibiza, blocker 2): _sprite_delete caught every exception and only logged it, but FileSyncManager commits a deletion (drops it from _synced_files, never retries) when the callback RETURNS — it rolls back and retries only when it RAISES. A transient remote unlink failure was therefore falsely committed, and because iter_sync_files covers credential material, rotating/removing a host credential could leave the stale copy in a durable Sprite permanently, violating the unified file-sync transaction contract (#6308). missing_ok=True keeps the benign absent-file case; every other failure now propagates. Regression tests: the callback raises on non-benign unlink failure, and an end-to-end FileSyncManager case proves a failed credential deletion is not committed and the next cycle retries and clears it.
PR-review finding (andrexibiza, blocker 3): the constructor's GET(name) -> 404 -> CREATE(name) sequence is a cross-process TOCTOU. Sprite names are unique server-side; two Hermes processes first-opening the same persistent (profile, task) can both observe 404, one create wins, and the loser failed construction on the duplicate-name error instead of adopting the winner. In-process creation locks do not serialize separate processes. Create-or-adopt without parsing error prose: if create raises SpriteError, re-GET the exact deterministic name — adopt it if it now exists, otherwise re-raise the original create error. Regression tests: concurrent first-create converges both callers on one Sprite (re-GET called, winner adopted), and a genuine create failure (re-GET still 404) surfaces unmasked.
PR-review finding (andrexibiza, blocker 1): container_persistent: false is the repository-wide per-session isolation contract (#82731), but the per-session branch in _resolve_container_task_id applied to Docker only. Non-persistent Sprites still collapsed to 'default', resolved the same deterministic profile-scoped name, and unconditionally get_sprite()d it — so two independent ephemeral runs could attach one live VM and either cleanup could delete it out from under the other, and a crashed run's stale survivor was silently resumed. - terminal_tool: the isolation authority generalizes to _session_isolation_enabled() ({docker, sprites} + non-persistent); _docker_session_isolation_enabled() remains as the docker-gated view so docker-only paths (workspace mount selection, session-scoped container teardown) are unchanged. Delegated children still alias to their parent via the existing alias registry. - SpritesEnvironment: an ephemeral constructor now mints a unique hermes-eph-{task}-{nonce12} name and only ever CREATES — it never adopts a pre-existing Sprite. Persistent mode keeps resume-by-name with the race-safe create-or-adopt. - Tests: ephemeral-never-adopts, unique-per-construction, DNS-bounded ephemeral names, and the terminal_tool keying contract for sprites (per-session when non-persistent, shared 'default' when persistent; docker-only helper stays False for sprites). #82731's own suite is unchanged and green (27/27). The live-suite fixture pins the ephemeral naming path into the run-unique test namespace too. Docs state the single-use ephemeral behavior.
Clean automerge. Brings in the ~25 commits merged since the previous base (incl. #93488's task-id/path sanitization consolidation) so the review-requested backend suites run against the real merge tree.
Round-4 review finding (verified by execution): the persistent recycle test computed env_key while the fixture's non-persistent isolation was still active (per-task keys), then flipped to persistent mode where the actual registration key is 'default'. With a real token the assertion would fail after creating the live Sprite, and the finally block would clean the wrong key — leaving the persistent test Sprite stored and billing. env_key is now derived after setting persistence (and before the try, so teardown can never see it unbound). Refreshed the fixture and recycle docstrings, which still described the pre-session-isolation 'always collapses to default' contract.
Convert the hardened Sprites backend from NousResearch#93523 into a self-contained native Hermes plugin built on the generic terminal environment provider API from NousResearch#94400. Includes directory and pip entry points, provider-owned setup/doctor metadata, profile-qualified durable naming, race-safe adoption, bounded paid execution, ephemeral isolation, secret stripping, transactional file-sync semantics, 47 unit tests, opt-in live coverage, pinned CI, and migration/attribution documentation.

What does this PR do?
Salvages #30112 (
feat(tools): add Sprites terminal backendby @kylemclaren) onto currentmainand closes out the findings from three rounds of dual independent review (architecture/correctness + style/robustness reviewers run in parallel, each round adjudicated with every load-bearing finding verified by execution before fixing).The base work is kylemclaren's branch, built on top of unchanged — all #30112 commits and authorship are preserved; this branch adds a clean merge of current
main(verified byte-identical automerge viagit merge-tree) plus focused fix commits, one per finding.What the salvage fixes on top of #30112
Security / trust-boundary
tools/environments/sprites.py). This hardened across rounds as reviewers broke each prior scheme by execution:hermes-{display}-{digest12}: the 12-hex (48-bit) digest over the raw(profile, task)pair — length-prefixed encoding, so embedded separators cannot forge a component boundary — is the authoritative identity. A demonstrated 24-bit collision between two valid profile names, a\x1f-forgery collision, and a component-boundary collision (a-b+cvsa+b-c) are all closed and pinned by tests.{name}-random.sprites.apphostname); only the cosmetic display prefix truncates — the digest tail always survives. Previously a custom HERMES_HOME could emit 87–168-char names, and server-side truncation would have chopped off exactly the disambiguator.hermes-{task_id}(legacy Sprites keep resolving; pinned by test).OSError/RuntimeErrorinto"default", i.e. a fail-open into another trust domain. A custom HERMES_HOME outside the profiles tree gets its own identity instead of silently sharing the default profile's Sprite.SPRITES_TOKENroutes throughagent.secret_scope.get_secretin the requirements check and the environment constructor (rawos.getenvcould read another profile's token under a multiplexed gateway). Registered inlocal.py::_ALWAYS_STRIP_KEYSalongsideMODAL_*/DAYTONA_API_KEYso spawned subprocesses never inherit it.sprites-py>=0.5.0,<0.6instead of a bare unpinned install that bypassed the reviewed supply-chain bound via the documented primary setup path.Classification sites added to main after the PR's Aug 18 re-merge
hermes_cli/web_server.py):spriteswas in the schema enum but had no_TERMINAL_BACKENDSrow and no probe branch — selecting it returned "Unknown backend". Added row + probe (SDK presence + token), mirroring Daytona.apps/desktop/.../use-prompt-actions/index.ts):spritesadded toCONTAINER_TERMINAL_BACKENDSso desktop attachments cross as bytes instead of dangling host paths.Correctness / robustness
uv.lockregenerated for the[sprites]extra (uv lock --checkpasses; previously failed the locked-dependency contract).tests/integration/test_sprites_terminal.py):hermes-test-{uuid8}Sprite namespace — previously, task-id collapse + ephemeral teardown meant the suite would resume and delete the operator's realhermes-{profile}-defaultSprite, filesystem and all.tools.terminal_tool's registry while commands executed through a standalone importlib copy (distinct dicts — the recycle test would have false-failed on its first real-token run).Cmd, so a runaway command in a persistent VM would bill until the Sprite died. Background workloads are unaffected (they detach vianohupinside the VM).delegate_taskchildren always share their parent's Sprite (they inherit the parent's session key); only key-less flows (CLI, cron) share the profile-default Sprite. Includes the per-session billing implication for gateway operators. Stale pre-digest naming examples swept from all docs and docstrings.spritesadded to the terminal env help listing; contributor emails mapped (mclaren@fly.io→ kylemclaren,noreply@sprites.dev→ sprites-dev).Review provenance
~/.hermes/profilesitself as HOME classifying as default (matches file_safety), macOS case-variant HOME over-isolating (safe direction).Type of Change
How to Test
Same recipe as #30112 (
pip install 'hermes-agent[sprites]',SPRITES_TOKEN,terminal.backend: sprites), plus:uv lock --check→ passes../scripts/run_tests.sh tests/tools/test_sprites_environment.py→ 34/34, including the demonstrated collision classes (profile-pair, separator forgery, component boundary), DNS bound with digest-tail preservation, legacy-name stability, and fail-closed resolution.SPRITES_TOKEN):pytest tests/integration/test_sprites_terminal.py -m integration— runs entirely in a run-uniquehermes-test-…namespace; the recycle test proves a genuine resume.Test evidence
Canonical runner (
./scripts/run_tests.sh), suites touching this change: 302 passed, 0 failed, 1 skipped acrosstest_sprites_environment(34),test_container_cwd_sanitize,test_prompt_builder,test_terminal_tool,test_web_server,test_shared_container_task_id. Full CI green on every pushed head. Pre-existing failures on currentmain(test_approval×1,test_file_tools×2) reproduce identically on a cleanmaincheckout and are unrelated.Live integration suite not run in CI (gated by
SPRITES_TOKEN); last full live run by the author 2026-08-18, 8/8 green on sprites-py 0.5.0 — note those runs predate the salvage's naming changes, so a fresh live pass before or after merge is worth doing (one create call also settles the server's actual name-length limit, which public docs don't publish).Ops flags
sprites-py>=0.5.0,<0.6(extra[sprites], lazy-installed, locked inuv.lock).SPRITES_TOKEN(SPRITE_TOKENaccepted) — profile-scoped viasecret_scope, stripped from spawned subprocesses.hermes-{display}-{digest12}. Any Sprite previously created under a named profile (or a lossy/messy name) is orphaned — it stays running and billing until manually deleted, and a new Sprite is created on next use. Default-profile names with ordinary task ids are byte-identical to before; legacy resume is intact. Operators with named-profile Sprites shouldsprite listand clean up after upgrading.Supersedes #30112 (author's commits preserved on this branch).
Infographic