feat(tools): add Sprites stateful sandbox backend (salvage of #30112) by benbarclay · Pull Request #93523 · NousResearch/hermes-agent · GitHub
Skip to content

feat(tools): add Sprites stateful sandbox backend (salvage of #30112) - #93523

Closed
benbarclay wants to merge 50 commits into
mainfrom
salvage/sprites-backend-30112
Closed

feat(tools): add Sprites stateful sandbox backend (salvage of #30112)#93523
benbarclay wants to merge 50 commits into
mainfrom
salvage/sprites-backend-30112

Conversation

@benbarclay

@benbarclay benbarclay commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Salvages #30112 (feat(tools): add Sprites terminal backend by @kylemclaren) onto current main and 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 via git merge-tree) plus focused fix commits, one per finding.

What the salvage fixes on top of #30112

Security / trust-boundary

  • Sprite identity is injective, DNS-bounded, and fails closed (tools/environments/sprites.py). This hardened across rounds as reviewers broke each prior scheme by execution:
    • Named-profile / custom-HERMES_HOME names are 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+c vs a+b-c) are all closed and pinned by tests.
    • Every generated name fits a 63-char DNS label (the name feeds the {name}-random.sprites.app hostname); 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.
    • Default-profile names stay byte-identical to the historical hermes-{task_id} (legacy Sprites keep resolving; pinned by test).
    • Profile identity is derived from the HOME paths directly and raises on resolution failure — the previous approach delegated to a file-safety helper that swallows OSError/RuntimeError into "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_TOKEN routes through agent.secret_scope.get_secret in the requirements check and the environment constructor (raw os.getenv could read another profile's token under a multiplexed gateway). Registered in local.py::_ALWAYS_STRIP_KEYS alongside MODAL_*/DAYTONA_API_KEY so spawned subprocesses never inherit it.
  • Setup wizard installs sprites-py>=0.5.0,<0.6 instead 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

  • Dashboard terminal-backend picker (hermes_cli/web_server.py): sprites was in the schema enum but had no _TERMINAL_BACKENDS row and no probe branch — selecting it returned "Unknown backend". Added row + probe (SDK presence + token), mirroring Daytona.
  • Desktop attachment routing (apps/desktop/.../use-prompt-actions/index.ts): sprites added to CONTAINER_TERMINAL_BACKENDS so desktop attachments cross as bytes instead of dangling host paths.

Correctness / robustness

  • uv.lock regenerated for the [sprites] extra (uv lock --check passes; previously failed the locked-dependency contract).
  • Live integration suite made safe and honest (tests/integration/test_sprites_terminal.py):
    • Every test now runs in a run-unique hermes-test-{uuid8} Sprite namespace — previously, task-id collapse + ephemeral teardown meant the suite would resume and delete the operator's real hermes-{profile}-default Sprite, filesystem and all.
    • Fixed a module-identity bug where assertions read 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).
    • The recycle test now proves a genuine API-level resume (recycles via the collapsed container key, asserts the env was dropped and re-created) and its teardown genuinely deletes the test Sprite instead of leaking a billing VM.
  • Bounded exec deadlines: absent/nonpositive timeouts get a 3600s fallback instead of an unbounded SDK exec — there is no kill hook on a running Cmd, so a runaway command in a persistent VM would bill until the Sprite died. Background workloads are unaffected (they detach via nohup inside the VM).
  • Docs corrected: gateway/WebUI sessions carry a session key and each get their own Sprite; delegate_task children 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.
  • sprites added to the terminal env help listing; contributor emails mapped (mclaren@fly.io → kylemclaren, noreply@sprites.dev → sprites-dev).

Review provenance

  • Round 1 (original feat(tools): add Sprites stateful sandbox backend 👾 #30112): verified the author's responses to the July automated review — profile scoping and all seven cited classification sites were done and test-pinned; found the residual edges plus two classification sites added to main after the author's Aug 18 sweep.
  • Round 2 (first salvage batch): reviewers broke the round-1 identity fix by execution (slug collision via real resolver semantics, component-boundary collision, fail-open beneath the raise) and caught the integration suite's wrong-registry assertions and real-Sprite deletion hazard.
  • Round 3 (round-2 fixes): reviewers constructed a working 24-bit digest collision between two valid profile names, a separator-forgery collision, and the unbounded-name/DNS problem — all closed by the final scheme above, each with a pinned regression test.
  • Accepted residues are documented in code rather than papered over: within-one-component clean/hash impersonation (task ids are not attacker-supplied across trust domains), ~/.hermes/profiles itself 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:

  1. uv lock --check → passes.
  2. Dashboard → Terminal backend picker → Sprites row appears with a real probe status.
  3. Unit: ./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.
  4. Live (needs SPRITES_TOKEN): pytest tests/integration/test_sprites_terminal.py -m integration — runs entirely in a run-unique hermes-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 across test_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 current main (test_approval ×1, test_file_tools ×2) reproduce identically on a clean main checkout 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

  • New optional dependency: sprites-py>=0.5.0,<0.6 (extra [sprites], lazy-installed, locked in uv.lock).
  • New secret: SPRITES_TOKEN (SPRITE_TOKEN accepted) — profile-scoped via secret_scope, stripped from spawned subprocesses.
  • Behavior changes vs feat(tools): add Sprites stateful sandbox backend 👾 #30112:
    • Profile-resolution failure now fails backend init (fail-closed) instead of silently using the default profile's Sprite.
    • Named-profile Sprite names move to 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 should sprite list and clean up after upgrading.
    • Commands with absent/nonpositive timeouts now stop at 3600s inside the Sprite instead of running unbounded.
  • No new model tools; no prompt-cache impact.

Supersedes #30112 (author's commits preserved on this branch).

Infographic

sprites-backend-salvage

kylemclaren and others added 30 commits May 22, 2026 12:17
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).
@alt-glitch alt-glitch added comp/cli CLI entry point, hermes_cli/, setup wizard tool/terminal Terminal execution and process management tool/code-exec execute_code sandbox tool/file File tools (read, write, patch, search) tool/skills Skills system (list, view, manage) area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 24, 2026
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 andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. container_persistent: false is not actually an isolation boundary for Sprites. Current terminal_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 wires container_persistent into SpritesEnvironment, 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 deterministic hermes-default (profile-scoped), and SpritesEnvironment.__init__() unconditionally get_sprite()s and resumes it even when _persistent is 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.

  2. _sprite_delete() defeats FileSyncManager's transactional deletion/rollback contract, which can leave stale credentials in a durable Sprite permanently. The callback does unlink(missing_ok=True) and then catches every exception and only logs it. But FileSyncManager.sync() advances _synced_files after _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. Because iter_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 / commit 1f1f2975 (remote deletion + full rollback on failure). missing_ok=True already 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.

  3. Persistent first-use creation is a cross-process TOCTOU. The constructor performs GET(name) → on 404 POST create(name). The Sprites API documents names as unique and returns HTTP 400 when a create name already exists; pinned sprites-py==0.5.x maps any non-success other than 401/404 to SpriteError. 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:false as 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.
@teknium1

Copy link
Copy Markdown
Contributor

@teknium1 teknium1 closed this Aug 25, 2026
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 25, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/code-exec execute_code sandbox tool/file File tools (read, write, patch, search) tool/skills Skills system (list, view, manage) tool/terminal Terminal execution and process management type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants