Skip to content

feat(tools): add Tenki cloud sandbox terminal backend - #64190

Open
hashbender wants to merge 19 commits into
NousResearch:mainfrom
hashbender:tenki-sandbox-backend
Open

feat(tools): add Tenki cloud sandbox terminal backend#64190
hashbender wants to merge 19 commits into
NousResearch:mainfrom
hashbender:tenki-sandbox-backend

Conversation

@hashbender

@hashbender hashbender commented Jul 14, 2026

Copy link
Copy Markdown

What does this PR do?

Adds Tenki as a seventh terminal execution backend alongside local, docker, ssh, singularity, modal, and daytona. With terminal.backend: "tenki", Hermes creates Tenki cloud sandboxes on demand for the terminal tool, file tools, and execute_code, and terminates them on cleanup by default. Pause/resume persistence across sessions is opt-in via container_persistent: true.

The integration deliberately follows the existing modal/daytona pattern: an optional extra (tenki>=0.5.1,<0.7) that is lazy-installed on first use, a BaseEnvironment subclass in tools/environments/, and the same setup-wizard / doctor / status / gateway wiring. Along the way it folds the Tenki keys into the shared _container_config_from_config() helper (upstream's dedup of the previously copy-pasted container-config dicts) and repoints the remaining inline copies (file tools, execute_code, prompt builder) at it, so future backends only need to touch one place.

Security hardening is built in rather than bolted on:

  • The supervisor's control-plane Tenki token is not injected into the model-controlled guest environment; host-side SDK auth is unchanged. Nested-sandbox creation is an explicit opt-in via terminal.tenki_forward_env, and forwarding the control-plane token logs a warning.
  • Credentials and forwarded env vars resolve through agent.secret_scope, so an active profile scope wins over process-global os.environ and the shared machine CLI login is skipped when a profile scope is authoritative.
  • TENKI_AUTH_TOKEN / TENKI_API_KEY are stripped from spawned subprocess environments (provider blocklist + always-strip tier), matching modal/daytona.
  • Persistent sandbox identity (name + metadata + reuse match) and the snapshot-store path are namespaced per profile, bound at construction so background-thread cleanup writes to the right home.
  • Durability gate: a non-durable snapshot is never recorded (cleanup pauses and preserves prior state), and a failed pause leaves the sandbox live rather than terminating it. Restore falls back to a base image only for unrecoverable snapshots (gone / non-durable / snapshot-specific invalid state) and preserves the pointer on transient errors.

Known follow-up (pre-existing and backend-agnostic, not introduced here): the process-global terminal environment cache (_active_environments, keyed "default") is not profile-scoped, so under the multiplexing gateway forwarded credentials are not isolated across profiles. Documented in the credential-forwarding notes and tracked separately.

Related Issue

No existing issue — this is a new backend integration in the same vein as the open E2B (#18348) and Sprites (#30112) backend PRs. Happy to open a tracking issue if maintainers prefer.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Core backend

  • tools/environments/tenki.py (new) — TenkiEnvironment: sandbox lifecycle, exec, pause/resume persistence, remote file sync-back
  • tools/tenki_config.py (new) — profile-scope-aware resolution of auth token, workspace, and API endpoint from the Tenki CLI config or environment
  • tools/terminal_tool.py, tools/file_tools.py, tools/code_execution_tool.py — backend wiring; tenki keys folded into the shared _container_config_from_config() helper, with file tools / execute_code / prompt builder repointed to it
  • tools/environments/__init__.py, tools/environments/base.py, tools/environments/local.py — registration and base-class support
  • Container-cwd guards now accept the guest-home subtree (/home/tenki/*) as a valid cwd

Security

  • tools/approval.py, tools/env_probe.py, tools/file_operations.pyTENKI_AUTH_TOKEN / TENKI_API_KEY added to the provider blocklist and always-strip tier
  • Guest env isolation + opt-in terminal.tenki_forward_env (see above)

CLI / UX

  • hermes_cli/setup.py — setup-wizard option for the Tenki backend
  • hermes_cli/doctor.py, hermes_cli/status.py — Tenki auth/SDK checks and backend status
  • cli.py, gateway/run.py, hermes_cli/config.py — config plumbing; blank tenki_api_endpoint default in both config loaders so the documented env/CLI fallback is reachable

Packaging

  • pyproject.toml, uv.lock, tools/lazy_deps.py, nix/packages.nix — optional tenki extra (tenki>=0.5.1,<0.7), lazy-installed like modal/daytona

Docs & config

  • cli-config.yaml.example — "OPTION 7: Tenki cloud execution" block with all tenki_* keys
  • website/docs/ — configuration guide, environment-variable reference, security notes, architecture page
  • AGENTS.md, CONTRIBUTING.md — backend lists updated to include tenki

Tests

  • tests/tools/test_tenki_environment.py (new, ~1,300 lines) — lifecycle, exec, persistence, durability gate, restore classification, profile scoping
  • Extended: test_terminal_config_env_sync.py, test_terminal_requirements.py, test_terminal_tool_requirements.py, test_file_tools_container_config.py, test_parse_env_var.py, test_container_cwd_sanitize.py, test_local_env_blocklist.py, test_hardline_blocklist.py, test_command_guards.py, tests/hermes_cli/test_setup.py, and others

How to Test

  1. Install the extra: pip install 'hermes-agent[tenki]' (or let lazy install handle it on first use) and authenticate via tenki login or TENKI_AUTH_TOKEN / TENKI_API_KEY.
  2. In cli-config.yaml, set terminal.backend: "tenki" (see the new OPTION 7 block in cli-config.yaml.example for all keys).
  3. Run hermes -q "run uname -a in the terminal" — a Tenki sandbox is created on demand and terminated on cleanup. Exercise file tools and execute_code the same way.
  4. hermes doctor and hermes status report Tenki SDK/auth state and backend status.
  5. Persistence: set container_persistent: true, run a session, exit, run again — the sandbox pauses on cleanup and resumes on the next session.
  6. pytest tests/ -q — full suite passes.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass (one environment-specific failure on macOS, test_approval.py::TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerous, fails identically at the merge-base without this change — pre-existing, unrelated)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.5.1 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (execution happens in the remote sandbox over the SDK; no new POSIX-only host syscalls)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (terminal tool description and prompt builder reflect the new backend)

Screenshots / Logs

Targeted run of every test file touched by this PR:

$ pytest tests/tools/test_tenki_environment.py tests/tools/test_terminal_config_env_sync.py \
    tests/tools/test_terminal_requirements.py tests/tools/test_terminal_tool_requirements.py \
    tests/tools/test_file_tools_container_config.py tests/tools/test_parse_env_var.py \
    tests/tools/test_container_cwd_sanitize.py tests/tools/test_docker_network_config.py \
    tests/tools/test_local_env_blocklist.py tests/tools/test_hardline_blocklist.py \
    tests/tools/test_command_guards.py tests/tools/test_modal_sandbox_fixes.py \
    tests/hermes_cli/test_config_env_expansion.py tests/hermes_cli/test_setup.py \
    tests/gateway/test_config_cwd_bridge.py tests/agent/test_prompt_builder.py \
    tests/test_project_metadata.py -q
671 passed, 1 skipped in 36.58s

After the tenki 0.5.1 bump, re-run via scripts/run_tests.sh (per-file isolation, same as CI) over every file this PR touches:

$ scripts/run_tests.sh tests/tools/test_tenki_environment.py \
    tests/tools/test_terminal_requirements.py tests/tools/test_terminal_tool_requirements.py \
    tests/tools/test_file_tools_container_config.py tests/tools/test_container_cwd_sanitize.py \
    tests/tools/test_terminal_config_env_sync.py tests/tools/test_parse_env_var.py \
    tests/tools/test_local_env_blocklist.py tests/tools/test_hardline_blocklist.py \
    tests/tools/test_command_guards.py tests/hermes_cli/test_setup.py \
    tests/gateway/test_config_cwd_bridge.py tests/test_project_metadata.py
=== Summary: 13 files, 462 tests passed, 0 failed ===

Full tests/tools/ via scripts/run_tests.sh on macOS 26.5.1: the only failures are pre-existing ones that reproduce identically with this branch's changes stashed (test_approval.py::TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerous, plus test_base_environment.py and test_file_tools.py). tests/hermes_cli/ and tests/gateway/ likewise: the 10 systemd/WSL/service-manager failures are byte-identical at the merge base.

ty on the touched modules drops from 5 diagnostics to 2 (both pre-existing) — the three it loses are exactly the Attribute 'fs' is not defined on None errors behind the upload race fixed here.

Adds Tenki (tenki.cloud) as a seventh terminal execution backend alongside
local, docker, ssh, singularity, modal, and daytona. Hermes creates Tenki
sandboxes on demand for terminal, file tools, and execute_code, and
terminates them on cleanup by default (opt-in pause/resume persistence via
container_persistent: true).

Core:
- tools/environments/tenki.py: TenkiEnvironment — sandbox lifecycle, exec,
  pause/resume persistence, remote file sync-back
- tools/tenki_config.py: profile-scope-aware auth/workspace/project/endpoint
  resolution from the Tenki CLI config or environment
- Shared _container_config_from_env_config() helper replaces the three
  duplicated container-config dicts (terminal, file tools, execute_code)
- Setup wizard, doctor, status, gateway, and CLI wiring; website docs,
  env-var reference, and cli-config.yaml.example
- Optional tenki extra (tenki-sandbox==0.1.1), lazy-installed like
  modal/daytona

Security & correctness hardening:
- Do not inject the supervisor's control-plane Tenki token into the
  model-controlled guest env; host-side SDK auth is unchanged. Nested-sandbox
  creation is an explicit opt-in via terminal.tenki_forward_env (which also
  forwards the resolved token so `tenki login` credentials work), and logs a
  warning when the control-plane token is forwarded.
- Resolve Tenki credentials and forwarded env through agent.secret_scope so an
  active profile scope wins over process-global os.environ and the shared
  machine CLI login is skipped when a profile scope is authoritative.
- Strip TENKI_AUTH_TOKEN / TENKI_API_KEY from spawned subprocess environments
  (provider blocklist + always-strip tier), matching modal/daytona.
- Namespace persistent sandbox identity by a per-profile token (name +
  metadata + reuse match) and resolve the snapshot-store path per profile,
  bound at construction so background-thread cleanup writes to the right home.
- Durability gate: a non-durable snapshot is not recorded (cleanup pauses and
  preserves prior state); a failed pause leaves the sandbox live rather than
  terminating it. Restore falls back to a base image only for an unrecoverable
  snapshot (gone / non-durable / snapshot-specific invalid state), preserving
  the pointer on transient errors.
- Config: blank tenki_api_endpoint default across both config loaders so the
  documented env/CLI fallback is reachable; allow the guest-home subtree
  (/home/tenki/*) as a valid cwd at all container-cwd guards.

Known follow-up (pre-existing, backend-agnostic): the process-global terminal
environment cache (_active_environments, keyed "default") is not profile-scoped,
so under the multiplexing gateway forwarded credentials are not isolated across
profiles. Tracked separately; documented in the credential-forwarding notes.

Tests: tests/tools/test_tenki_environment.py plus terminal/file/config/scrub
coverage, including profile-scope, durability, restore-classification, and
cwd-subtree regression pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hashbender
hashbender requested a review from a team July 14, 2026 05:49
Required by contributor-check for nick@luxor.tech commits in NousResearch#64190.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/tools Tool registry, model_tools, toolsets tool/terminal Terminal execution and process management area/config Config system, migrations, profiles labels Jul 14, 2026
Nick and others added 3 commits July 13, 2026 23:26
…l race

cancel() can null self._sandbox between _ensure_sandbox() and the
dereference in _start_process/_exec_raw/_transfer_sandbox, turning a
user interrupt into an AttributeError. _require_sandbox() captures the
reference under the lock and raises a typed RuntimeError if the sandbox
was torn down. Found by Tenki Code Reviewer on the mirrored PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two findings from Tenki Code Reviewer on the true-base verification PR:

- _close_client: swallow close() exceptions. cleanup() resets
  _cleanup_in_progress only after closing the client, so an escaping
  network error during teardown left the flag stuck and every later
  _ensure_sandbox() failed with 'Tenki cleanup is in progress'.
- prompt_builder probe: replace the fourth inline container-config copy
  with the shared _container_config_from_env_config() builder; the
  inline dict omitted tenki_sync_hermes_home, tenki_forward_env, and
  docker_network, so probe environments diverged from real ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the PR current with main (248 commits of drift, 22 overlapping
files). Verified locally: clean merge, uv lock --check passes, all
Tenki/terminal tests pass, and the full tests/tools suite shows no
failures beyond those already present on upstream main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@teknium1 teknium1 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.

Thanks for the thorough backend integration. This is a legitimate opt-in feature: current main still has no Tenki backend in tools/terminal_tool.py:1514-1539.

Problems

  • Blocking: tools/environments/tenki.py:769-770 and :787 dereference self._sandbox after _ensure_sandbox(). But the PR's cancel path clears that attribute at :941-946. Capture one reference with _require_sandbox() and use it through the full single/bulk upload operation; otherwise cancellation can raise AttributeError or split a bulk transfer across sandboxes.
  • Blocking: pyproject.toml:159 adds tenki-sandbox==0.1.1. AGENTS.md:561-576 requires bounded PyPI ranges, including a two-minor upper bound for pre-1.0 packages.

Suggested changes

  • Add cancellation-race coverage for _tenki_upload and _tenki_bulk_upload after converting them to use one captured sandbox reference.
  • Use a bounded dependency range and regenerate uv.lock.

Automated hermes-sweeper review.

Comment thread tools/environments/tenki.py Outdated
Comment thread pyproject.toml Outdated
@teknium1 teknium1 added 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 sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 16, 2026
Nick and others added 4 commits July 27, 2026 13:12
_tenki_upload and _tenki_bulk_upload re-read self._sandbox for each
step, but cancel() nulls that field out concurrently. A cancel landing
mid-flow made the mkdir and the upload target different sandboxes, or
dereferenced None outright (AttributeError: 'NoneType' has no attribute
'fs'). The bulk flow was worse: its mkdir/upload/untar/rm could span two
sandboxes, extracting the tar somewhere other than where it landed.

Capture the sandbox once via _require_sandbox() and thread that one
reference through every filesystem and exec call, matching what
_tenki_bulk_download already does with _transfer_sandbox().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenki removed projects from its API: the SDK has no project_id on
sandbox creation, no list_project, and no IdentityProject. The
terminal.tenki_project_id key and its TERMINAL_TENKI_PROJECT_ID /
TENKI_PROJECT_ID env overrides therefore configure nothing.

Remove the config key from both loaders, the gateway env bridge, and
the container-config plumbing, along with the documentation rows that
advertised it. The workspace remains the unit that decides where
sandboxes are created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per AGENTS.md's dependency policy, use a bounded range rather than an
exact runtime pin: pre-1.0 packages get a two-minor ceiling, so
tenki>=0.5.1,<0.7. uv.lock refreshed.

The package was renamed on PyPI: 0.5.1 ships as `tenki` (tenki-sandbox
stops at 0.4.0). The compat `tenki_sandbox` module still ships inside
`tenki`, but new code should import `tenki`, so the imports, the
find_spec probes, and every install hint move over.

0.5 is a real API migration, not just a version string:

- Client.create no longer accepts project_id, and list_project /
  list_workspace folded into list(workspace_id=...). _create_kwargs
  filtered against Sandbox.create, which is a bare **kwargs passthrough
  that names nothing it accepts, so filtering was a no-op and any
  dropped kwarg reached the client as an unexpected keyword. Introspect
  Client.create — the real validator — and pass the client-construction
  kwargs (base_url, auth_token) explicitly, since Sandbox.create pops
  those before forwarding.
- RegistryArtifactNotFoundError was renamed RegistryImageNotFoundError.
  The single combined `from tenki_sandbox import (...)` failed outright
  on the rename, silently dropping the isinstance check for every class
  in it and leaving only the name-based fallback. Resolve each class
  independently and accept both names.

Tests: the fake SDK now mirrors 0.5 — Client.create declares its real
parameter list with no **kwargs catch-all, so an unsupported name
raises TypeError exactly as the real client would. Adds regressions for
the kwarg filtering and for snapshot-error classification across the
rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	hermes_cli/config.py
#	uv.lock
#	website/docs/guides/tips.md
#	website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
Nick and others added 4 commits July 27, 2026 19:39
_terminal_env_type_for_task now reads the terminal env config before the
active-environment lookup, because the environment registry's cache key is
derived from env_type. That hoist put the config read inside the function's
outer try, so any config failure aborted the whole lookup and returned
"local" even when a container backend was registered -- silently resolving
container paths against the host, which is the misrouting this module
exists to prevent.

Give the config read its own handler so it degrades to an empty mapping
instead of pre-empting the live-environment lookup. Restores the precedence
asserted by test_container_path_detection_uses_live_docker_environment,
which was red on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_rewrite_real_sudo_invocations and _count_real_sudo_invocations were the
same shell tokenizer walk -- tracking command_start, skipping comments,
handling the &&/||/;;/;|&() operators and leading env assignments -- with
the counter differing only in dropping the output list. Two copies is two
chances to drift on quote and comment handling, and the rewriter already
returns the count the counter recomputes.

Collapse the walk into _rewrite_sudo_command_words(command, replacement).
Both public names survive as thin callers, so no call site changes.

No behaviour change: the old and new implementations were differentially
compared over roughly 67k generated command strings, covering quotes,
escapes, comments, operators, env-assignment prefixes and subshells, with
identical rewritten output and identical counts throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deduplication pass over the Tenki snapshot and binding registry. No change
to durability, lock ordering, or which errors propagate.

- Adopt the shared _rewrite_sudo_command_words walker and drop Tenki's
  private third copy of it.
- _mutate_store() replaces the resolve-path/lock/load/save preamble that
  was repeated across 11 registry mutators.
- _RemoteBinding replaces an unnamed six-tuple return and the six parallel
  instance attributes that mirrored it.
- One parameterised file-lock helper serves both the blocking snapshot-store
  lock and the non-blocking task-ownership lock, which had each hand-rolled
  the same fcntl/msvcrt branch.
- _record_field() and _fail_ambiguous_lineage() collapse the dict-or-scalar
  unwrap and the set-flag-then-raise pattern.
- Rename _load_json_store to _load_recovery_registry. It shadowed
  base._load_json_store with the opposite error semantics: base returns an
  empty mapping on an unreadable file, this one deliberately raises so a
  corrupt registry cannot erase the only recovery pointer.
- Move the durable atomic-write primitive to base._atomic_save_json_durable
  so other backends can reuse it; Tenki keeps the uncertain-commit policy
  wrapper on top.

Four sites were deliberately left on their existing code because routing
them through a shared helper would have changed when a write becomes
durable relative to a remote RPC: _queue_snapshot_retirement (its early
return must not write), _retire_pending_snapshot_if_unreferenced (holds one
lock across tombstone, remote delete and clear, which is the
non-resurrection guarantee), _confirm_snapshot_store_durable (fsync-only,
not load-mutate-save), and the ownership lock's file-close ownership.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hashbender

Copy link
Copy Markdown
Author

Why the create-attempt journaling is here

The largest chunk of tools/environments/tenki.py is a local, crash-safe recovery journal: a durable record of a create attempt written before the create RPC is issued, reconciled on next startup to work out whether the sandbox actually got made and whether it forked into duplicates.

The obvious review reaction is "why not just retry the create, or look it up by name?" I checked both against the Tenki backend and the answer is that neither works today. Recording it here so the next reader doesn't have to re-derive it.

1. Sandbox create has no server-side idempotency.

CreateSessionRequest has no idempotency key, client token, or request id — verified against the proto and independently against the compiled descriptors in the installed SDK (0.5.1), which are ahead of the backend checkout. The insert is unconditional (CreateSession in sessions.sql.go, no ON CONFLICT), the primary key is a server-minted uuidv7(), and the Temporal workflow id is derived from that already-minted id — so workflow-id reuse gives no cross-request dedup either. A retried create after an uncertain response produces a second sandbox.

This is not an oversight in my reading of the code: the same codebase does implement idempotency keys where it wants them — OpenCode runs (idempotency_key on the request, with AlreadyExists preserved on replay), admin force-pause and session dumps (X-Idempotency-Key, backed by a unique index), and billing grants (documented idempotent on (workspace_id, idempotency_key)). Sandbox create deliberately does not.

Aggravating factor: wait_ready defaults to on in the Python SDK, so the default create is a long-held unary RPC. That widens the uncertain-response window on precisely the call that cannot dedup.

2. Sandbox names are not unique per workspace, and there is no lookup by name.

name is a plain nullable TEXT column. There is no unique index on it anywhere in the migrations, no application-level pre-check in the create path, and no conflict branch. The server's own fallback name generator (adjective-noun-%04x from crypto/rand) has no collision retry, so the server will itself mint duplicates. GetSessionRequest takes a UUID only — there is no get-by-name RPC, and the list query has no name filter.

Same contrast as above: templates, registry images, and volumes all do carry UNIQUE (workspace, name), and volumes return AlreadyExists. Sessions don't. Names are labels here, not identity.

3. list and get read a replica with no read-your-writes guard.

CreateSession writes to the primary pool; ListWorkspaceSandboxes and GetSession both read from the read-replica pool, with no primary fallback on either path. So a just-created sandbox can be transiently absent from a list, and a get on a freshly-returned id can transiently 404.

That is a real hazard rather than a theoretical one — the sandbox domain already works around replica lag in three other places, with comments saying so explicitly and a Prometheus counter tracking the fallback. Those guards just aren't on list or get.

Net: "did my create land, and did it fork?" genuinely cannot be answered by a single API call right now. Deterministic naming plus a list scan is not sufficient, because the name isn't an identity key and the list may not show the sandbox yet. Hence the local journal.

If we want to shrink it later

The one server-side lever that exists is tags: GIN-indexed with a real containment filter (tags @> $1). metadata is write-and-echo-only — stored and returned, never queried or indexed — so reconciling by metadata means listing and scanning client-side, which is what happens today.

Pushing candidate-set narrowing into a tag would make reconciliation cheaper, but it would not remove the journal: you would still need local state to tell your own create apart from a collision, and you would still have to tolerate a just-created sandbox being briefly invisible. Tag constraints if anyone picks this up: max 20 tags, each ≤32 chars, ^[a-z0-9][a-z0-9_:.-]*$ (lowercase only) — a bare 32-hex attempt id fits exactly, a prefixed one does not.

The real fix is server-side: an idempotency key on create would let most of this layer be deleted. Worth raising with the Tenki side.

Caveat on freshness: the backend checkout I read was a couple of weeks behind main at the time. Every load-bearing claim was cross-checked against origin/main and against the installed SDK's compiled protobuf descriptors, which are ahead of both — and since the client cannot encode an idempotency key on the wire at all, server support would be unreachable regardless.

Nick and others added 2 commits July 29, 2026 14:47
Follow-up cleanups from a four-angle review (reuse, simplification,
efficiency, altitude) of this branch. No behaviour change.

- Delete dead code: TenkiEnvironment._max_duration (written, never read),
  _save_snapshots (no callers, and it bypassed _mutate_store's
  read-modify-write discipline while looking like the sanctioned write
  path), a _stdin_mode override restating BaseEnvironment's default, and
  the unused explicit/key parameters in tenki_config.

- Hoist _normalize_forward_env_names into file_sync as
  normalize_forward_env_names, taking a setting_name for the warning text.
  docker and tenki carried verbatim copies of it. Rendered warnings are
  byte-identical to before.

- Add TenkiEnvironment._dispose_remote for the repeated "walk the sandbox
  disposal methods, retrying each" block. Two of the five sites adopt it.
  The other three either clear the remote-binding marker inside the walk
  (so a failed clear deliberately falls through to the next method) or log
  once per failing method, and sharing them would change behaviour. Each
  exclusion is commented at the site.

- Make base._atomic_save_json_durable's five keyword parameters required.
  Its single caller passed all five explicitly, so the defaults and their
  coalescing were unreachable.

- Precompute the expected sandbox-name map in __init__.
  _sandbox_matches_task ran a regex substitution per profile/task candidate
  per listed sandbox -- measured around 205 per environment init against a
  50-sandbox workspace listing, which is itself scanned up to three times
  per create. Now a dict lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves five conflicts accumulated while main moved ~1000 commits ahead.

- hermes_cli/config.py: main extracted DEFAULT_CONFIG and OPTIONAL_ENV_VARS
  into the new hermes_cli/config_defaults.py leaf module while this branch
  edited DEFAULT_CONFIG in place. Took main's import and ported the eleven
  terminal.tenki_* defaults into config_defaults.py, updating the container
  resource-limit comment to include tenki. The branch's
  _normalize_terminal_backend_defaults and apply_terminal_backend_transition
  live outside the moved block and merged cleanly, as did the
  TERMINAL_TENKI_* entries in the env-var map.
- model_tools.py: union of the two import lists. Both _CHECK_FN_TTL_SECONDS
  (this branch) and tool_error (main) are used in the file.
- gateway/run.py: main removed a duplicate _expand_env_vars import that this
  branch had extended. _expand_env_vars is already imported a few lines
  above, so kept only _normalize_terminal_backend_defaults, which is used
  below at the terminal-defaults bridge.
- uv.lock: regenerated from the merged pyproject.toml (tenki 0.5.4, within
  the pinned >=0.5.1,<0.7).
- website/docs/user-guide/configuration.md: both sides rewrote the same
  sync section. Kept main's more detailed text (retry count, 2 GiB archive
  limit, bind-mount note) and preserved both Tenki caveats: participation
  gated on terminal.tenki_sync_hermes_home, and that cleanup terminates the
  sandbox under the default non-persistent mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nick and others added 3 commits July 29, 2026 21:52
Upstream added a vercel_sandbox terminal backend touching the same
membership sets, dispatch branches, wizard indices and docs prose that this
branch touches for tenki, so most of the 70 conflicting hunks across 39
files resolve as a union of both backends. Notable non-union resolutions:

- hermes_cli/web_server.py: upstream extracted the tools routes into
  hermes_cli/web_routers/tools.py. Took upstream's extraction (keeping
  HEAD's block would have double-registered the routes) and ported this
  branch's two changes into the extracted router: the profile secret-scope
  plumbing in get_terminal_backends, and apply_terminal_backend_transition
  in select_terminal_backend. Verified no duplicate routes.
- hermes_cli/web_server.py: added a vercel_sandbox row and probe to the
  backend picker introduced by this branch. Without it the dashboard would
  report a vercel_sandbox config as local and reject selecting it.
- tools/terminal_tool.py: kept _container_config_from_env_config at all
  four call sites and added upstream's vercel_runtime key to it, rather
  than reverting to the inline dicts upstream edited. Kept the runtime-
  scoped getenv accessor over os.getenv, and reconstructed both the
  vercel_sandbox and tenki branches of _create_environment.
- Tests: upstream ran a suite-wide prune. Every conflicted test was
  classified against the merge base — this branch's new tests are kept,
  upstream's deletions of pre-existing tests are honoured.
- hermes_cli/setup.py: wizard indices resolved as vercel_sandbox 5,
  tenki 6.
- uv.lock regenerated from the merged pyproject; both extras retained.
- Backend counts in prose corrected 7 -> 8 in README.md,
  configuration.md and architecture.md (including its ASCII diagram).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	agent/prompt_builder.py
#	hermes_cli/config.py
#	model_tools.py
#	tools/environments/file_sync.py
#	tools/registry.py
#	tools/terminal_tool.py
#	website/docs/user-guide/configuration.md
# Conflicts:
#	hermes_cli/web_routers/tools.py
#	tools/code_execution_tool.py
#	tools/environments/docker.py
#	tools/file_tools.py
#	tools/terminal_tool.py
#	uv.lock
@hashbender

Copy link
Copy Markdown
Author

Merged current main (ed5e17f, ~1,000 commits) — conflicts in 6 files, all resolved at head (5a48519). The semantic reconciliations, since a few went beyond textual merging:

  • Upstream now has its own shared container-config helper (_container_config_from_config()), which supersedes this PR's _container_config_from_env_config(). Dropped ours, folded the tenki_* keys into upstream's, and repointed the remaining inline copies (file tools, execute_code, prompt builder) at it. PR description updated to match.
  • tenki_image added to _ISOLATION_OVERRIDE_KEYS so RL/benchmark per-task tenki image overrides keep their isolated-sandbox semantics under upstream's new single-owner predicate.
  • Upstream's new lazy bring-up (ensure_task_env) converted to the registry protocol (_environment_creation_lock / _select_active_environment / _register_active_environment). Its raw _creation_locks.setdefault(task_id, threading.Lock()) would have planted a plain Lock in what is now a dict of _EnvironmentCreationSlots — an AttributeError for the next creator on the same key — and it neither knew the tenki image nor the profile-namespaced cache key.
  • _resolve_environment_cwd carries upstream's new remap semantics (an unusable container cwd remaps to /workspace when it's this session's mounted workspace, instead of always falling back to config["cwd"]) while keeping the guest-home-subtree exception tenki needs.
  • Dashboard backend-picker endpoints: upstream's asyncio.to_thread restructure combined with this PR's profile secret-scope handling (contextvars propagate into the worker thread, so the scope token set/reset stays correct).

Verification: uv lock --check green; scripts/run_tests.sh (per-file isolation, same as CI) over the 19 test files this PR touches or that cover the reconciled areas: 589 passed, 0 failed, 2 skipped. The two test_file_tools.py failures on macOS (/tmp/private/tmp realpath) reproduce byte-identically on bare origin/main — pre-existing, not merge-induced.

Resolves two conflicts against upstream's interrupted-command cwd fix
(16a173a):

- tools/environments/base.py: keep upstream's expanded
  _extract_cwd_from_output docstring (cwd_observed semantics) with Tenki
  added to the backend list.
- tools/file_tools.py: keep this branch's environment-identity cache
  check (_select_active_environment already touches _last_activity) and
  adopt upstream's fill-only session-cwd rescue — only record the cached
  snapshot when the session has no record of its own.

TenkiEnvironment inherits the base marker parsing, so it picks up
result["cwd_observed"] without changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ad0661d0-082c-448f-b3a7-a3dd875b7444

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@hashbender

Copy link
Copy Markdown
Author

Merged current main (1f1b4d9) — conflicts in 2 files, both against the interrupted-command cwd fix (16a173a), resolved at head (1d7b5ab):

  • tools/environments/base.py — kept upstream's expanded _extract_cwd_from_output docstring (the new cwd_observed semantics) with Tenki in the backend list. TenkiEnvironment inherits the base marker parsing, so it picks up result["cwd_observed"] with no backend changes.
  • tools/file_tools.py — kept this branch's environment-identity cache check (_select_active_environment already refreshes _last_activity, covering upstream's cache-hit touch) and adopted upstream's fill-only session-cwd rescue: the cached cwd snapshot is only recorded when the session has no record of its own, never overwriting one it wrote for itself.

Verification: uv lock --check green; scripts/run_tests.sh (per-file isolation, same as CI) over the 15 test files this PR touches plus upstream's new cwd-fix suites (test_interrupted_command_cwd.py, test_session_cwd_store.py): 502 passed, 0 failed, 1 skipped.

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/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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/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.

3 participants