-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathenvironment.py
More file actions
2267 lines (2046 loc) · 107 KB
/
Copy pathenvironment.py
File metadata and controls
2267 lines (2046 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The E2B-backed Hermes terminal environment.
Ownership model
---------------
Sandbox identity is derived from the ``task_id`` Hermes already resolved for
its own environment cache, so within one Hermes process one cached
``E2BEnvironment`` serves one sandbox scope. Ownership is still not
exclusive: two Hermes processes with the same scope (two CLI sessions of one
profile, a cron run beside a CLI session), and transiently two environment
objects in one process (eviction/recreation windows), attach to the same
persistent sandbox. Two guarantees make that sharing safe:
* **Lifecycle is non-destructive.** Nothing here pauses or kills a
persistent sandbox; E2B pauses it itself at lease expiry.
* **At most one live owner is the state *synchroniser*.** A per-scope
``WriterLease`` (a flock held from attach until teardown completes) gates
resume recovery, the host push, the per-command sync, and the teardown
pull. Concurrent owners attach as *readers*: their commands run normally
against the state the writer maintains, and each command makes a
non-blocking attempt to take the role over once it is free — promotion
re-runs the writer's full bring-up (recovery, then push) under the same
fail-closed rules as a fresh resume. Only the writer ever creates a
persistent sandbox, so a reader can never conjure an empty one that no
writer is preparing.
Being the synchroniser is **not** exclusive mutation of the sandbox's
``~/.hermes``. Readers execute commands, and a sandbox may still be running
processes an earlier session started; any of them can write there at any
time. What the lease serialises is this plugin's own state protocol.
The guarantee readers get is therefore about **command admission**, not
duration. Two gates decide it: an in-sandbox readiness stamp the writer
refreshes after every completed push, which a session that has not yet
attached waits for, and a host-side per-scope dirty marker
(``sandbox.DirtyState``) set while a writer transition is in flight and
after any failed incremental sync, which every reader consults on every
command — including one that bootstrapped long ago and will never probe the
stamp again. Neither is atomic with the launch that follows: a reader can
be admitted microseconds before a writer marks the scope. What is ruled out
is a reader *knowingly* executing against state the host has recorded as
half-written or stale.
Three residual holes, documented in the README: two *machines* sharing one
scope (same E2B account, identically-pathed profile homes) cannot be
arbitrated by a flock, and that is unsupported; a host missing either
coordination facility — an exclusive lock (Windows, a lock-incapable
filesystem) or a writable marker file — gets a loudly-warned
writer-always fallback, which supports a single persistent session but not
concurrent ones; and a modification made inside the sandbox
to a path the host also has, in the window between a writer generation's
recovery snapshot and the completion of its force-push, is overwritten by
the host copy (files the host does *not* have survive it — a force-push
with no baseline deletes nothing).
What remains is a single race that core does create: the idle reaper pops an
environment and calls ``cleanup()`` from a daemon thread without any lock, and
``_last_activity`` is not refreshed while a foreground command is running — so
a command that runs longer than ``terminal.lifetime_seconds`` can be reaped
mid-flight. Command admission and cleanup are therefore settled under one
per-environment lock:
* ``_run_bash`` takes the lock, checks the environment is not closed, renews
the sandbox lease to cover this command, registers the command, and starts
it — all in one critical section, so no cleanup can interleave between
"the sandbox is ready" and "this command is registered";
* ``cleanup()`` takes the same lock; if commands are in flight it records the
intent and returns, and the last command to finish performs the teardown.
For a persistent sandbox teardown performs no destructive operation on the
sandbox itself — it reconnects if necessary, pulls state back, removes this
session's scratch files, releases the writer lease, and detaches; E2B pauses
the sandbox on its own lease expiry (see ``sandbox.py``). The deferral matters
for both modes: it delays that pull and the lease release for a persistent
sandbox, and the actual destruction for an ephemeral one.
"""
from __future__ import annotations
import functools
import logging
import os
import shlex
import shutil
import tarfile
import tempfile
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from tools.environments import file_sync as hermes_file_sync
from tools.environments.base import BaseEnvironment
from tools.environments.file_sync import (
FileSyncManager,
iter_sync_files,
quoted_rm_command,
)
from . import sandbox as sandbox_api
from .config import API_KEY_ENV, DEFAULT_CWD, E2BSettings, get_api_key
from .errors import EnvironmentConnectionError, connection_error, is_missing_sandbox, redact
from .process import E2BProcessHandle
from .sandbox import _hermes_home
logger = logging.getLogger(__name__)
#: Refuse to stream a sync-back archive larger than this. Mirrors the cap
#: Hermes' own FileSyncManager applies after download; enforcing it *during*
#: the transfer means a misbehaving sandbox cannot fill the host disk first.
MAX_SYNC_BACK_BYTES = 2 * 1024 * 1024 * 1024
#: Subtrees of ``~/.hermes`` the agent authors from inside the sandbox and that
#: are safe to copy back on resume. Credentials are deliberately not here:
#: remote data must never create or revive a host credential file.
RECOVERABLE_ROOTS = ("skills", "memories")
#: Where a remote copy goes when it disagrees with the host's, or when its
#: normal destination would land outside the Hermes home. Outside every tree
#: Hermes loads from *and* outside the closed set of ``cache/<subdir>`` names
#: Hermes mirrors into remote backends, so a quarantined file is inert until a
#: human looks at it.
QUARANTINE_DIR = ("cache", "e2b-recovered")
#: Files per ``write_files`` call. Each entry holds an open file object.
_UPLOAD_BATCH = 64
#: The longest command issued by the sync-back transport. Teardown reconnects
#: with a lease that covers this plus the configured command grace before the
#: manager starts its pull.
_SYNC_BACK_COMMAND_TIMEOUT_SECONDS = 300
# Exceptions that control process or generator lifetime are not operational
# backend failures. Cleanup defers them only long enough to finish mandatory
# lifecycle work, then re-raises them.
_PROCESS_CONTROL_EXCEPTIONS = (KeyboardInterrupt, SystemExit, GeneratorExit)
class _HostOnlyPaths:
"""The places under ``~/.hermes`` that remote data must never write.
"Inside the Hermes home" is not a sufficient boundary for recovery, and
treating it as one is how sandbox-authored bytes end up somewhere Hermes
will later read as something else entirely. The home also holds the
profile's credentials, its config, the cache trees the host owns
outright, and this plugin's own coordination files.
Grounded in Hermes' own mapping rather than a list of guessed names,
because a guessed list is wrong the moment the layout changes:
* ``get_credential_file_mounts()`` is the definitive answer to "what does
Hermes treat as a credential file" — skill-registered files plus the
``terminal.credential_files`` config entries. The **directories holding
them** are host-only too: a tree that holds one credential is a
credentials tree, and a recovery pass that may create files in it can
create the next credential;
* ``get_cache_directory_mounts()`` gives the cache trees Hermes mirrors
into sandboxes. Those flow host-to-sandbox by design, so remote data
restoring into them is always wrong;
* ``config.yaml`` and ``.env`` are read by the profile loader itself;
* ``platforms/`` holds gateway auth and session state — pairing records,
WhatsApp sessions, the Matrix store (``gateway/pairing.py``,
``gateway/whatsapp_identity.py``, ``plugins/platforms/matrix``, all via
``get_hermes_dir("platforms/…")``);
* this plugin's ``cache/e2b`` (lease + admission marker) and
``cache/e2b-recovered`` (quarantine) must not be writable by the data
they are there to arbitrate.
What this cannot do is give meaning to a directory Hermes has none for.
``~/.hermes/credentials`` is not a Hermes location (nothing in core reads
it); it becomes host-only the moment a credential is registered or
configured there, and is then covered. A bare in-home directory with no
Hermes meaning is indistinguishable from the legitimate
``skills -> ~/.hermes/my-skills`` layout, and inventing a name list to
separate them would be exactly the incomplete guess this avoids.
Resolved once per recovery pass: the mapping involves config reads, and
every file in the pass is checked against it.
"""
def __init__(self, physical_home: Path) -> None:
self.home = physical_home
#: Trees that may never overlap a recoverable tree in EITHER
#: direction. A recoverable root pointed at one of them, or holding
#: one of them, is a misconfiguration this must not act on.
self.exclusive_dirs: list[Path] = []
#: Trees a recoverable tree must not be, or be inside. Deliberately
#: not the other direction: a user may legitimately register a
#: credential *inside* skills/, and that must not make the whole
#: skills tree unrecoverable — the individual file is protected by
#: :meth:`forbids` instead.
self.credential_dirs: list[Path] = []
#: Individual files remote data must never create or replace.
self.files: list[Path] = []
#: The quarantine tree, kept separately as well as in
#: ``exclusive_dirs``: nothing may be *restored* into it, but it is by
#: definition where quarantine writes go, so the two questions differ
#: by exactly this path. See :meth:`forbids_quarantine`.
self.quarantine_dir = physical_home.joinpath(*QUARANTINE_DIR).resolve()
for name in (QUARANTINE_DIR, ("cache", "e2b"), ("platforms",)):
self._add(self.exclusive_dirs, physical_home.joinpath(*name))
for host_path in _hermes_cache_mount_paths():
self._add(self.exclusive_dirs, host_path)
for host_path in _hermes_credential_paths():
self._add(self.files, host_path)
parent = host_path.parent
# The home itself is never a "credentials directory": ~/.hermes/.env
# lives directly in it, and treating the home as host-only would
# make every destination unsafe.
if _contained(parent, physical_home) != physical_home:
self._add(self.credential_dirs, parent)
for name in ("config.yaml", ".env"):
self._add(self.files, physical_home / name)
@staticmethod
def _add(into: list[Path], candidate: Path) -> None:
try:
resolved = candidate.resolve()
except OSError:
return
if resolved not in into:
into.append(resolved)
def allows_tree(self, tree: Path) -> bool:
"""Whether recovery may restore into *tree* at all."""
if tree == self.home:
# Every file under a root pointed at the home lands directly in
# ~/.hermes, next to .env and config.yaml.
return False
for other in self.exclusive_dirs:
if tree == other or _within(tree, other) or _within(other, tree):
return False
for other in self.credential_dirs:
if tree == other or _within(tree, other):
return False
return True
def forbids(self, destination: Path) -> bool:
"""Whether one individual destination is host-only.
Catches what the tree check deliberately allows: a credential
registered *inside* a recoverable tree keeps that tree recoverable,
but the credential file itself, and the directory holding it, stay
untouchable.
"""
return self._forbids(destination, self.exclusive_dirs)
def forbids_quarantine(self, destination: Path) -> bool:
"""Whether one *quarantine* destination is host-only.
The same question as :meth:`forbids` minus one path: the quarantine
tree, which is host-only for restores and is where quarantine writes
belong. Everything else still applies — Hermes accepts an arbitrary
registered credential path, so a credential can be registered at a
path that happens to sit inside the quarantine tree, and it is still
a credential. Parking a remote copy over it would be exactly the
"remote data creates or replaces a host credential" outcome the
recovery path refuses everywhere else.
"""
return self._forbids(
destination, [d for d in self.exclusive_dirs if d != self.quarantine_dir]
)
def _forbids(self, destination: Path, exclusive_dirs: list[Path]) -> bool:
if destination in self.files:
return True
for other in self.credential_dirs + exclusive_dirs:
if destination == other or _within(destination, other):
return True
return False
def _hermes_credential_paths() -> list[Path]:
"""Host paths Hermes treats as credential files, from its own mapping."""
paths: list[Path] = []
try:
from tools.credential_files import get_credential_file_mounts
for entry in get_credential_file_mounts() or ():
host_path = entry.get("host_path") if isinstance(entry, dict) else None
if host_path:
paths.append(Path(host_path))
except Exception as exc: # pragma: no cover - core surface moved
logger.warning(
"E2B: could not read Hermes' credential mapping (%s); resume "
"recovery is falling back to the profile's config and env files "
"as the only known host-only paths",
exc,
)
try:
from tools.environments.file_sync import _credential_host_paths
paths.extend(Path(p) for p in _credential_host_paths() or ())
except Exception: # pragma: no cover - private helper moved
pass
return paths
def _hermes_cache_mount_paths() -> list[Path]:
"""Host paths of the cache trees Hermes mirrors into remote backends."""
try:
from tools.credential_files import get_cache_directory_mounts
return [
Path(entry["host_path"])
for entry in get_cache_directory_mounts() or ()
if isinstance(entry, dict) and entry.get("host_path")
]
except Exception as exc: # pragma: no cover - core surface moved
logger.warning(
"E2B: could not read Hermes' cache mount mapping (%s); resume "
"recovery cannot exclude those trees by name",
exc,
)
return []
def _is_plain_absolute_path(value: str) -> bool:
"""Whether *value* is an absolute POSIX path with no traversal in it.
Used on strings the *sandbox* supplies that then become host paths. A
normalising check would be wrong here: the point is to refuse the value,
not to repair it.
"""
if not value.startswith("/") or "\x00" in value:
return False
return not any(part in ("..", ".") for part in value.split("/"))
def _within(candidate: Path, boundary: Path) -> bool:
"""Whether *candidate* is strictly inside *boundary*. Both pre-resolved."""
try:
candidate.relative_to(boundary)
except ValueError:
return False
return candidate != boundary
def _contained(candidate: Path, boundary: Path) -> Path | None:
"""*candidate* resolved, if it physically lies inside *boundary*; else None.
Both sides are resolved, because a symlink anywhere along the way is
enough to leave the boundary. Recovery needs this at three levels, and
each one matters:
* against the physically resolved Hermes **home** — comparing a candidate
only against ``(home / root).resolve()``, its own parent, is not a
boundary check at all: if ``~/.hermes/skills`` is a symlink to
``/tmp/elsewhere``, every path under it "contains" correctly while every
write lands outside the Hermes home;
* against the physically resolved **recoverable tree** — the home alone is
too wide, because ``~/.hermes`` also holds trees remote data must never
write (credentials above all). A symlink under ``skills/`` pointing at
``~/.hermes/credentials/`` never leaves the home;
* against the physically resolved **quarantine tree** — same reason, one
level further in: a symlink *inside* the quarantine tree pointing at
the credentials directory also never leaves the home.
Which tree is safe *as a tree* is a separate question, and a boundary
check cannot answer it — that is :class:`_HostOnlyPaths`.
*boundary* must already be resolved. ``resolve()`` is non-strict, so a
destination that does not exist yet resolves through whatever part of its
prefix does exist — which is exactly what needs checking before creating
the rest of it.
"""
try:
resolved = candidate.resolve()
except OSError:
return None
try:
resolved.relative_to(boundary)
except ValueError:
return None
return resolved
#: ``tools.environments.file_sync`` log messages that mean the watched call
#: ultimately failed. Matched by prefix against the manager's actual wording:
#: ``sync()`` logs exactly one warning on a rolled-back transaction, and
#: ``sync_back()`` logs "all N attempts failed" only after its last retry (the
#: tar-cap message means the downloaded state was discarded unapplied).
_TERMINAL_LOG_PREFIXES = (
"file_sync: sync failed, rolled back state",
"sync_back: all",
"sync_back: remote tar is",
)
#: Messages that are part of a normal, ultimately-successful call: a retried
#: attempt (only "all N attempts failed" is final) and the manager's ordinary
#: last-write-wins conflict notice. Treating these as failures reported a
#: pull that retried-and-succeeded as failed.
_BENIGN_LOG_PREFIXES = (
"sync_back: attempt",
"sync_back: conflict on",
)
class _SyncOutcome:
"""Whether a ``FileSyncManager`` call actually succeeded.
Hermes' manager reports nothing: ``sync()`` and ``sync_back()`` both return
``None`` and swallow every exception, logging a warning and moving on
(``file_sync.py`` — ``"file_sync: sync failed, rolled back state"`` and
``"sync_back: all N attempts failed"``). A backend that wraps those calls in
``try/except`` therefore has dead code: a failed state upload looks exactly
like a successful one.
That silence is not tolerable here. A failed initial upload means the agent
is working in a sandbox with none of its skills or credentials, and a failed
teardown pull means work done inside the sandbox is about to be overwritten
by the host on the next resume.
Two independent signals, because neither alone is complete:
* transport errors recorded by this plugin's own upload/delete/download
callbacks — precise, carries the real exception, but blind to failures
that happen after the download (tar extraction, applying files);
* records emitted by the manager's own logger, classified against the
manager's known messages — a retry warning or a conflict notice is part
of a successful call, not a failure of it.
The verdict depends on which call was watched. ``sync()`` is one
transaction with no internal retries, so any transport error is final.
``sync_back()`` retries internally, so a transport error means one failed
*attempt*; the call as a whole failed only when the manager said so, or
when every attempt it had was seen to fail in transport.
"""
def __init__(self) -> None:
self.transport_errors: list[BaseException] = []
self.terminal_messages: list[str] = []
self.notes: list[str] = []
def record_log(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage()
except Exception: # pragma: no cover - a broken record must not raise
message = "(unformattable log record)"
if message.startswith(_BENIGN_LOG_PREFIXES):
self.notes.append(message)
elif message.startswith(_TERMINAL_LOG_PREFIXES) or record.levelno >= logging.ERROR:
self.terminal_messages.append(message)
else:
# An unrecognised warning is context, not a verdict: inventing a
# failure out of it would abort commands over future benign log
# lines. Transport errors remain the text-independent signal.
self.notes.append(message)
@property
def push_failed(self) -> bool:
"""Verdict for ``sync()``: one transaction, no internal retries."""
return bool(self.transport_errors or self.terminal_messages)
@property
def pull_failed(self) -> bool:
"""Verdict for ``sync_back()``: the manager retries internally.
A lone transport error is a failed *attempt* that may have been
retried successfully, so it does not fail the call. The call failed
when the manager logged its final give-up — or, should that log line
ever disappear from core, when every attempt in the manager's retry
budget was seen to fail in transport.
"""
if self.terminal_messages:
return True
budget = getattr(hermes_file_sync, "_SYNC_BACK_MAX_RETRIES", None)
return isinstance(budget, int) and budget > 0 and len(self.transport_errors) >= budget
@property
def detail(self) -> str:
parts = [
f"{type(exc).__name__}: {exc}" for exc in self.transport_errors
] + self.terminal_messages
return redact("; ".join(parts))
def as_error(self) -> BaseException:
if self.transport_errors:
return self.transport_errors[0]
return RuntimeError(self.detail or "state sync failed")
class _SyncLogCapture(logging.Handler):
"""Route the file-sync logger's records to the innermost active watch.
Attribution has to be nest-aware *per thread*, and the handler itself has
to stay attached. Two lessons are baked in:
* another environment's teardown — including its own ``sync_back()`` — can
run synchronously inside our frame on our thread, so each record goes to
the innermost watch on the *emitting* thread, which is the call that
produced it;
* the handler is installed once and never removed. Removal driven by any
one thread's state is a race: the first thread to finish watching would
detach the process-global handler while another thread's watch is still
relying on it, and that thread's logger-only failure would be lost.
"""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
def emit(self, record: logging.LogRecord) -> None:
stack = getattr(_watch_stack, "stack", None)
if not stack:
return
stack[-1].record_log(record)
_watch_stack = threading.local()
_sync_log_capture = _SyncLogCapture()
_capture_install_lock = threading.Lock()
_capture_installed = False
_sync_back_state = threading.local()
def _ensure_sync_log_capture() -> None:
"""Attach the capture handler to Hermes' file-sync logger, exactly once."""
global _capture_installed
if _capture_installed:
return
with _capture_install_lock:
if _capture_installed:
return
logging.getLogger("tools.environments.file_sync").addHandler(_sync_log_capture)
_capture_installed = True
def _current_watch() -> _SyncOutcome | None:
stack = getattr(_watch_stack, "stack", None)
return stack[-1] if stack else None
#: Refcounted floor on the file-sync logger's level while any watch is active.
#: The manager's warnings are the only signal for its post-download failures,
#: and log records are never *created* when the logger's effective level sits
#: above WARNING — a deployment that silences that logger would silently blind
#: the failure detection. While a sync is being watched, the logger is floored
#: to WARNING (records reach both this plugin's verdict and the user's
#: handlers); the previous level is restored when the last watch exits.
#: ``logging.disable(logging.WARNING)`` or higher is process-global and cannot
#: be counteracted — documented in contract gap 6 as the remaining constraint.
_floor_lock = threading.Lock()
_floor_watchers = 0
_floor_applied = False
_floor_saved_level = logging.NOTSET
def _enter_watch_floor(sync_logger: logging.Logger) -> None:
global _floor_watchers, _floor_applied, _floor_saved_level
with _floor_lock:
_floor_watchers += 1
if not _floor_applied and not sync_logger.isEnabledFor(logging.WARNING):
_floor_saved_level = sync_logger.level
sync_logger.setLevel(logging.WARNING)
_floor_applied = True
def _exit_watch_floor(sync_logger: logging.Logger) -> None:
global _floor_watchers, _floor_applied
with _floor_lock:
_floor_watchers -= 1
if _floor_watchers == 0 and _floor_applied:
sync_logger.setLevel(_floor_saved_level)
_floor_applied = False
@contextmanager
def _watch_sync() -> Iterator[_SyncOutcome]:
"""Run a FileSyncManager call and find out whether it worked."""
_ensure_sync_log_capture()
sync_logger = logging.getLogger("tools.environments.file_sync")
_enter_watch_floor(sync_logger)
outcome = _SyncOutcome()
stack = getattr(_watch_stack, "stack", None)
if stack is None:
stack = []
_watch_stack.stack = stack
stack.append(outcome)
try:
yield outcome
finally:
stack.pop()
_exit_watch_floor(sync_logger)
def _watched(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a sync transport callback so its failure is not lost.
The manager catches whatever these raise; recording it on the way past is
the only way to learn the real cause. The error lands on the innermost
watch of the calling thread — the manager runs its callbacks synchronously,
so that is exactly the call that owns them.
"""
@functools.wraps(fn)
def _wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return fn(*args, **kwargs)
except BaseException as exc:
watch = _current_watch()
if watch is not None:
watch.transport_errors.append(exc)
raise
return _wrapper
def _sync_back_in_flight() -> bool:
return getattr(_sync_back_state, "active", False)
@contextmanager
def _sync_back_guard() -> Iterator[None]:
"""Mark a state pull as running on this thread.
Belt and braces alongside :meth:`E2BEnvironment.__del__`: Hermes' sync-back
takes a cross-process ``flock`` that is not reentrant, so a second pull
entered from inside the first — by any route — would block forever.
"""
_sync_back_state.active = True
try:
yield
finally:
_sync_back_state.active = False
class E2BEnvironment(BaseEnvironment):
"""Runs Hermes terminal commands inside an E2B sandbox.
The class name deliberately contains none of the substrings Hermes'
``file_tools._terminal_env_type_for_task`` sniffs for (``local``, ``ssh``,
``docker``, ``singularity``, ``modal``, ``daytona``) — it would otherwise
be misclassified before the ``_hermes_backend_name`` stamp is consulted.
"""
# Stdin travels over E2B's own stdin channel, not a shell heredoc, so a
# sudo password never appears in the command line inside the sandbox.
_stdin_mode = "pipe"
def __init__(
self,
*,
task_id: str = "default",
settings: E2BSettings | None = None,
cwd: str = "",
timeout: int = 180,
persistent_filesystem: bool = True,
) -> None:
self._settings = settings or E2BSettings()
# The prompt-probe environment is never registered with Hermes and
# never reaped, so it is always ephemeral regardless of configuration.
self._is_probe = sandbox_api.is_probe_scope(task_id)
super().__init__(cwd=cwd or self._settings.cwd, timeout=timeout)
self._task_id = task_id
# Read by tools.terminal_tool.is_persistent_env to decide whether the
# agent loop tears this environment down at end of turn.
self._persistent = bool(persistent_filesystem) and not self._is_probe
self._scope = sandbox_api.scope_id(task_id, self._settings, persistent=self._persistent)
# Read by tools.image_generation_tool to place agent-visible files.
self._remote_home = self._settings.cwd
self._lock = threading.RLock()
self._sandbox: Any = None
self._sandbox_id: str | None = None
self._bootstrapped = False
#: True when this environment adopted a pre-existing sandbox rather
#: than creating one, i.e. when its filesystem may hold state the host
#: has never seen.
self._resumed_existing = False
self._inflight = 0
self._closed = False
self._close_pending = False
self._torn_down = threading.Event()
self._torn_down.set() # nothing to tear down until a sandbox exists
self._sync_manager: FileSyncManager | None = None
#: Whether this environment holds the state-sync writer role. An
#: ephemeral sandbox has exactly one owner, so it is always the
#: writer; a persistent one competes for the per-scope WriterLease at
#: attach time and may run as a reader — see ``_bootstrap``.
self._is_writer = not self._persistent
self._writer_lease = sandbox_api.WriterLease(self._scope) if self._persistent else None
#: Host-side record of whether this scope's sandbox state can be run
#: against at all. Only persistent scopes have readers to hold back.
self._dirty_state = sandbox_api.DirtyState(self._scope) if self._persistent else None
#: Lower bound on the server-side lease deadline. Only moves forward.
self._lease_deadline = 0.0
# ------------------------------------------------------------------
# Introspection
# ------------------------------------------------------------------
@property
def sandbox_id(self) -> str | None:
return self._sandbox_id
@property
def remote_hermes_home(self) -> str:
return f"{self._remote_home.rstrip('/')}/.hermes"
def get_temp_dir(self) -> str:
return "/tmp"
# ------------------------------------------------------------------
# Readiness
# ------------------------------------------------------------------
def _api_key(self) -> str:
key = get_api_key()
if not key:
raise EnvironmentConnectionError(
f"{API_KEY_ENV} is not set for the active Hermes profile",
retry_hint=(
f"Add {API_KEY_ENV}=<your key> to ~/.hermes/.env (or the "
"active profile's .env) and retry."
),
)
return key
def _ensure_ready(self, *, lease_seconds: int | None = None) -> None:
"""Attach a sandbox and bootstrap it. Caller must hold ``_lock``."""
if self._closed:
raise EnvironmentConnectionError(
"This E2B environment was torn down",
retry_hint=(
"The environment was cleaned up (session close or idle "
"reaping). Retry — Hermes will build a fresh one."
),
)
lease = lease_seconds or self._desired_lease(None)
if self._sandbox is None:
self._attach(lease)
else:
self._renew_lease(lease)
if not self._bootstrapped:
# Set before bootstrapping: _bootstrap runs commands, which re-enter
# this method on the same (re-entrant) lock.
self._bootstrapped = True
try:
self._bootstrap()
except BaseException:
self._bootstrapped = False
raise
def _attach(self, lease_seconds: int) -> None:
"""Adopt this scope's sandbox, or create one. Caller holds ``_lock``.
Only ever entered with no sandbox attached (``_ensure_ready`` calls it
when ``_sandbox`` is None; ``_replace_missing_sandbox`` clears it
first), so anything that fails here leaves this environment with
nothing usable — and must therefore not leave it holding the writer
role. Sandbox creation, a malformed create/connect result, and the
lease bookkeeping all sit after the acquisition, so the release is
driven off "did we finish attaching" rather than off any one of them.
"""
sandbox = None
lease_started_at = 0.0
self._resumed_existing = False
attached = False
try:
# Inside the guard: on a replacement attach this environment may
# already hold the lease, and an unresolvable credential here
# would otherwise leave it holding the role with no sandbox.
api_key = self._api_key()
if self._persistent:
with sandbox_api.scope_lock(self._scope):
attached_sandbox = self._adopt_existing(api_key, lease_seconds)
if attached_sandbox is not None:
sandbox, lease_started_at = attached_sandbox
# Compete for the state-sync writer role. Losing it
# means another live owner is synchronising state with
# this sandbox right now; this environment runs as a
# reader until the role is released (see _bootstrap
# and _before_execute). Idempotent when this
# environment already holds the lease (sandbox
# replacement).
self._is_writer = self._writer_lease.try_acquire()
elif self._writer_lease.try_acquire():
self._is_writer = True
sandbox, lease_started_at = self._create(api_key, lease_seconds)
else:
# No sandbox exists, and another live session holds the
# writer role for this scope — its sandbox was destroyed
# out from under it, or it is still creating one. A
# reader-created replacement would be an empty sandbox no
# writer is preparing: commands would run against no
# Hermes state at all. Fail closed; the writer rebuilds
# on its own next command, or releases the role when it
# ends, and either way a retry here converges.
raise EnvironmentConnectionError(
"No sandbox exists for this scope and another live "
"Hermes session holds its state-sync writer role",
retry_hint=(
"The session owning this scope will rebuild the "
"sandbox on its next command, or release the role "
"when it ends. Retry the same command."
),
)
else:
# Ephemeral sandboxes are never discovered or adopted: a
# nominally throwaway sandbox must not be shared with another
# session or another Hermes process, either of which could kill
# it mid-command.
sandbox, lease_started_at = self._create(api_key, lease_seconds)
# Publish the sandbox reference before anything else can fail, so a
# later error still leaves cleanup() able to reach it.
self._sandbox = sandbox
self._sandbox_id = sandbox_api.sandbox_id_of(sandbox)
self._torn_down.clear()
self._track_lease(lease_seconds, started_at=lease_started_at)
attached = True
except BaseException:
if not attached:
# A lease held by an environment that never attached is a
# phantom writer: no sandbox to prepare, no push to complete,
# no readiness stamp coming — and every other session locked
# out of the role for the rest of this process's life. Release
# it, so the retry (this session's or another's) converges on
# whoever can actually do the work.
self._release_writer_lease()
raise
def _adopt_existing(self, api_key: str, lease_seconds: int) -> tuple[Any, float] | None:
try:
existing_id = sandbox_api.find_existing(self._scope, api_key, self._settings.template)
except EnvironmentConnectionError:
raise
except Exception as exc:
raise connection_error("sandbox lookup", exc) from exc
if not existing_id:
return None
try:
lease_started_at = time.monotonic()
sandbox = sandbox_api.connect(existing_id, lease_seconds, api_key)
except Exception as exc:
if is_missing_sandbox(exc):
logger.info(
"E2B: sandbox %s vanished between listing and connect; creating a fresh one",
existing_id,
)
return None
raise connection_error(
f"resuming sandbox {existing_id} with a {lease_seconds}s lifecycle lease",
exc,
) from exc
logger.info("E2B: resumed sandbox %s for task %s", existing_id, self._task_id)
self._resumed_existing = True
return sandbox, lease_started_at
def _create(self, api_key: str, lease_seconds: int) -> tuple[Any, float]:
try:
lease_started_at = time.monotonic()
sandbox = sandbox_api.create(
self._scope,
self._settings,
persistent=self._persistent,
lease_seconds=lease_seconds,
api_key=api_key,
)
except Exception as exc:
raise connection_error(
f"sandbox creation with a {lease_seconds}s lifecycle lease",
exc,
) from exc
logger.info(
"E2B: created %s sandbox %s for task %s",
"persistent" if self._persistent else "ephemeral",
# Not sandbox_id_of: that raises on a malformed result, and doing
# it here would lose the reference to a sandbox E2B really did
# create before the caller can publish it for cleanup. The id is
# validated once, in _attach, after the reference is reachable.
getattr(sandbox, "sandbox_id", None) or "<no id reported>",
self._task_id,
)
return sandbox, lease_started_at
def _bootstrap(self) -> None:
"""Resolve the remote home, sync Hermes state, capture the env snapshot.
Only the state-sync writer touches state. A reader — a second live
owner of the same persistent scope — skips recovery, the push, and
(via the manager's own empty-state check) the eventual teardown pull:
commands run normally against the state the live writer maintains,
and the reader promotes itself once the writer releases the role
(see ``_before_execute``).
"""
self._resolve_remote_paths()
self._sync_manager = FileSyncManager(
get_files_fn=lambda: iter_sync_files(self.remote_hermes_home),
upload_fn=_watched(self._upload_one),
delete_fn=_watched(self._delete_many),
bulk_upload_fn=_watched(self._upload_many),
bulk_download_fn=_watched(self._download_hermes_tar_for_sync_back),
)
took_the_role_over = False
if (
not self._is_writer
and self._writer_lease is not None
and self._writer_lease.try_acquire()
):
# The role freed up between attach and bootstrap — possibly
# because this environment's own earlier bring-up failed and
# released it. Take it rather than waiting, blocked, behind a
# readiness stamp nobody is going to write.
self._is_writer = True
took_the_role_over = True
if not self._is_writer:
# Host-side first: it is a local stat, it needs no working sandbox
# transport, and it is the only one of the two gates that sees a
# writer whose *incremental* sync failed (that leaves the stamp
# from its bring-up in place while the sandbox's state goes stale).
self._require_clean_state()
if not self._probe_exists("-f", self._state_ready_marker(), action="reader readiness"):
# The writer holding the lease has not (yet) completed a push
# to THIS sandbox filesystem: it is mid-bootstrap, its push
# failed, or the sandbox is a fresh replacement it has not
# prepared. Running a command now would execute against a
# sandbox with no (or half-written) Hermes state. Fail closed
# — core's command retries re-enter this bootstrap, so the
# command proceeds as soon as the writer finishes, and if the
# writer failed and released the role, the re-acquire above
# takes over on the next attempt.
raise EnvironmentConnectionError(
f"Sandbox {self._sandbox_id} is not ready: the session "
"holding this scope's state-sync writer role has not "
"completed its state push",
retry_hint=(
"Another Hermes session is still preparing (or failed "
"to prepare) this sandbox's Hermes state. Retry the "
"same command."
),
)
logger.info(
"E2B: attached to sandbox %s as a reader — another live Hermes "
"session holds the state-sync writer role for this scope. "
"Commands run normally; host state propagates through the "
"writer, and this session takes the role over once it is "
"released.",
self._sandbox_id,
)
else:
try:
# Mark the scope before anything is touched. From here until
# the push completes, this sandbox's Hermes state is neither
# the previous generation's nor yet this one's, and a reader
# in another process that bootstrapped earlier — and so will
# never probe the readiness stamp again — must not execute
# against it.
self._mark_state_dirty("a writer session is preparing this sandbox's state")
# ``_resumed_existing`` records what the last _attach did, so
# it is not on its own the question "is this generation a
# takeover?". Re-acquiring the role above does not re-attach,
# so an environment that created this sandbox, failed its
# bring-up, released the role, and then took it back would
# otherwise force-push with no recovery pass — burying
# whatever the sandbox gained in between, including another
# session's completed work.
if self._resumed_existing or took_the_role_over:
# Invalidate the previous epoch's readiness stamp FIRST:
# it attests a push that is about to stop being current.
# Without this, readers pass their gate on a stale stamp
# and execute while this writer's push is mid-flight or
# has failed — the stamp must always attest the *current*
# lease holder's completed push.
self._clear_state_ready()
# A resumed sandbox can hold the only copy of something
# the agent wrote there — the previous teardown's pull may
# have failed, and the failure is only visible in a log.
# Recover before pushing, because the push is what would
# bury it. Raises when the sandbox state cannot be
# verified: the force-push below overwrites every remote
# file the host also has, so pushing over *unverified*
# remote state would silently destroy the only copy.
self._recover_remote_state()
self._push_host_state()
except BaseException:
# Give the role up rather than holding it across failures:
# with the stamp invalidated, readers are blocked, so a
# lease held by a session that cannot push would freeze the
# whole scope. Released, any healthy session — including
# this one, via the re-acquire above — can take over. The
# dirty mark deliberately stays: the state really is
# unusable, and whoever completes a push clears it.
self._release_writer_lease()
raise
self.init_session()
def _push_host_state(self) -> None:
"""Force-push the host's ``~/.hermes`` into the sandbox. Fail loud.
Continuing past a failed push would hand the agent a sandbox with none
of its skills, credentials, or cached files and no indication why its
tools behave differently.
"""
with _watch_sync() as outcome: