Skip to content

feat: add sandbox runtime spec contract and explicit lifecycle API - #1007

Merged
AlexLiu190625 merged 7 commits into
xorbitsai:mainfrom
AlexLiu190625:fix/sandbox-runtime-spec-lifecycle-api
Jul 27, 2026
Merged

feat: add sandbox runtime spec contract and explicit lifecycle API#1007
AlexLiu190625 merged 7 commits into
xorbitsai:mainfrom
AlexLiu190625:fix/sandbox-runtime-spec-lifecycle-api

Conversation

@AlexLiu190625

Copy link
Copy Markdown
Collaborator

Summary

Adds the desired-state contract and explicit lifecycle API that sandbox reconciliation needs, as a purely additive foundation. No production caller uses the new API yet; existing sandbox behavior is unchanged.

Context: same-CA external tasks fail intermittently with Sandbox already exists with different runtime configuration (xorbitsai/xagent-saas#296). The root cause is that desired sandbox configuration and actually-running configuration have no shared, verifiable representation: provider cache hits skip validation, restarts adopt existing containers without checking, and stored metadata can mask actual container state. This PR introduces that representation; a follow-up PR moves SandboxManager onto it.

What's added

Types (xagent/sandbox/base.py)

  • ResolvedSandboxRuntimeSpec: canonical desired runtime configuration. Structural equality is authoritative; fingerprint() hashes the same structure. from_parts() applies the same defaults and path normalization the backend applies, so desired and actual state are produced by one normalizer.
  • ObservedRuntimeFacts / SandboxInspection: observation-side types with identity equality only and no fingerprint — observed state is never comparable as if it were desired state. Facts carry raw backend units (NanoCpus, memory bytes) so live edits such as docker update --cpus 0.5 stay observable.
  • SandboxMountIntent: lexical mount classification (covered / covering / disjoint) that normalizes and never raises; consumption policy belongs to the caller.
  • spec_matches_inspection() with a three-state verdict (MATCH / MISMATCH / UNVERIFIED). Containers without a spec label are UNVERIFIED, not MISMATCH: a mismatch verdict is advisory, and destruction decisions additionally require the manager's ref-count safety contract.
  • SandboxContractError hierarchy (deliberately not RuntimeError subclasses, so generic runtime-error recovery paths cannot swallow contract violations) and a supports_runtime_spec() capability probe.

Docker backend (xagent/sandbox/docker_sandbox.py)

  • inspect(): side-effect-free snapshot built from actual docker inspect data; takes no locks.
  • create(): existence check, snapshot resolution, static volume/port conflict validation, container built from the canonical spec, spec fingerprint + contract-version labels stamped at creation, publish-before-verify (a container whose observed state does not match its own label is removed and the call raises), then the store row. Docker name-conflict races normalize to SandboxAlreadyExistsError.
  • start_existing() / stop_existing(): idempotent, guarded by the per-name lock plus the sandbox's exclusive-access drain, converging the store row unconditionally.
  • Per-name locking moved to a waiter-counted, identity-checked registry (_named_lock), fixing a pre-existing race where delete() recycled a lock while other tasks held references to it, without leaking entries in the unbounded per-task name space.
  • _SandboxControl construction consolidated behind _get_live_control for lock-held paths.

Boxlite backend: supports_runtime_spec() returns False; the lifecycle methods keep the base default (SandboxReconcileUnsupportedError). The Boxlite runtime cannot prove mounts/env/network on reattach, so reconciliation is Docker-only.

Behavior compatibility

Legacy paths (get_or_create, list_sandboxes, delete, create_snapshot, cleanup, warmup, _merge_info) keep their semantics. Three deliberate, bounded exceptions:

  1. per-name lock registry swap (fixes the lock-identity race described above; same acquire/release semantics),
  2. delete()'s control-map removal is now identity-checked,
  3. _create_container gained an optional extra_labels parameter (legacy call site passes nothing; the label set without it is unchanged).

Testing

  • tests/sandbox/: 137 passed, 12 skipped (Boxlite-runtime skips), sequentially and with -n 4 --dist=loadscope, against a real Docker daemon. Baseline before this change: 18 passed, 12 skipped.
  • New suites: spec normalization/fingerprint sensitivity, mount-intent classification, matcher verdict matrix, lock-registry race and cancellation pins, lifecycle API integration (including create-from-snapshot, publish-verification failure compensation, and conflict pre-checks), and bridge seams pinning how legacy-created containers (UNVERIFIED) and spec-created containers (MATCH) are told apart.
  • tests/web/ -k sandbox: 157 passed.
  • pre-commit (ruff check/format, mypy, isort, codespell): clean on all changed files.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a spec-based reconciliation lifecycle for sandboxes, implementing explicit lifecycle methods (inspect, create, start_existing, stop_existing) in DockerSandboxService along with robust concurrency locking via _named_lock. It also adds comprehensive test suites to verify these behaviors. Feedback on the changes highlights a critical issue where task cancellation during container startup can bypass the compensating container removal and leak orphan containers. Additionally, the review points out that port verification will fail when dynamic port allocation (host port 0) is used, and recommends validating host port conflicts to prevent silent overwrites.

Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py Outdated
…ated cancellation

Also reject conflicting non-zero host ports in _check_no_conflicting_ports, which previously only checked for guest-port collisions.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a spec-based reconciliation lifecycle for sandboxes, adding dataclasses like ResolvedSandboxRuntimeSpec and SandboxInspection, and implementing explicit lifecycle methods (inspect, create, start_existing, stop_existing) in the Docker backend. The feedback focuses on path normalization and cross-platform compatibility. Specifically, the reviewer recommends normalizing volume mount paths and working directories extracted from Docker inspect data to prevent false-positive mismatches, and using os.path.normpath instead of posixpath.normpath for host-side paths to ensure proper behavior on Windows hosts.

Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/base.py
Comment thread src/xagent/sandbox/docker_sandbox.py Outdated
Comment thread src/xagent/sandbox/base.py
…ackend

posixpath.normpath is not a canonical form: POSIX reserves a leading "//"
for implementation-defined interpretation, so normpath keeps exactly two
leading slashes while Docker collapses them in the values it reports back
(Mounts.Destination, Config.WorkingDir). A desired spec built from a
"//"-prefixed absolute path (reachable from an absolute XAGENT_UPLOADS_DIR
or SANDBOX_VOLUMES entry, e.g. "${ROOT}/data" with ROOT=/) therefore never
byte-matched what the backend echoed: create()'s publish-before-verify step
reported a volumes/working_dir mismatch for a container that was exactly
what had been requested, removed it, and raised
SandboxRuntimeConflictError on every attempt. The same gap let "//x" and
"/x" pass the pre-create conflict checks as distinct mount points when the
backend would collapse them onto one.

Route every desired-state path through one canonical_sandbox_path() owner
in sandbox/base.py (resolved spec volumes and working_dir, mount intent
root and extras, and the Docker volume conflict check), which normalizes in
the POSIX domain the sandbox path domain actually uses on both sides of a
mount and collapses the leading slash run. Observed facts stay
backend-native, as ObservedRuntimeFacts documents.

Verified against Docker 29.4.0: with a "//"-prefixed host source, guest
target and working_dir, create() + inspect() now reports zero publish
mismatches, while a non-canonical request the backend actually rewrites
(trailing slash, which Docker Desktop answers with a /host_mnt-prefixed
Source) is still caught and refused.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a new spec-based reconciliation lifecycle API for sandboxes, including canonical desired state representation (ResolvedSandboxRuntimeSpec), observed state facts (ObservedRuntimeFacts), and a matcher (spec_matches_inspection). It implements this lifecycle contract for the Docker backend with robust locking, validation, and verification steps, while keeping it disabled for the Boxlite backend. A critical issue was identified in the publish verification step of create() where comparing ports directly will fail when ephemeral ports (host port 0) are requested, as Docker assigns a concrete non-zero port at runtime. A suggestion was provided to map ports by guest port and allow a desired host port of 0 to match any allocated host port.

Comment thread src/xagent/sandbox/docker_sandbox.py
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a spec-based reconciliation lifecycle for sandboxes, adding new data structures, exceptions, and helper functions to define and verify sandbox configurations. It implements these lifecycle methods (inspect, create, start_existing, stop_existing) for the Docker backend while leaving them unsupported for Boxlite. The review feedback highlights critical issues in the publish-verification step of container creation: comparing ephemeral host ports (0) directly will cause false mismatches, and observed volume paths and working directories should be canonicalized to prevent false positives due to minor lexical differences.

Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
_check_no_conflicting_ports already rejects both sides of a port mapping:
the guest-side collision that _create_container's guest-keyed dict would
silently drop, and the host-side collision Docker only reports when the
container starts. The volume checker covered only its host-keyed
direction, so two different host sources on the same guest path reached
the daemon and came back as a raw APIError 400 "Duplicate mount point"
(verified on Docker 29.4.0) instead of the typed pre-create error.

Add the symmetric guest-side check, canonicalizing through the same
canonical_sandbox_path owner so paths the backend collapses onto one
mount point share a key. Nothing that Docker accepts today becomes
rejected: a shared guest path with differing sources cannot be created
at all, and a shared guest path with a differing mode was already
rejected by the host-side direction.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a spec-based reconciliation lifecycle for sandboxes, implementing new explicit lifecycle methods (inspect, create, start_existing, stop_existing) in DockerSandboxService backed by a robust per-name locking mechanism and publish-before-verify validation. It also adds comprehensive unit and integration tests. The review feedback suggests improving the robustness of publish verification by normalizing observed volume paths and working directories with canonical_sandbox_path to avoid false mismatches, and raising SandboxContractError instead of SandboxRuntimeConflictError when verification fails, along with updating the corresponding test assertions.

Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread tests/sandbox/test_docker_lifecycle_api.py
The docstring only described the pre-create volume-conflict check, but
the same typed error also covers pre-create port conflicts and
create()'s publish-before-verify mismatch against the observed
backend state. Broaden it to the actual desired-state-conflict
invariant instead of narrowing to one call site.

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

This PR introduces a desired-state/observed-state contract for sandbox runtimes: ResolvedSandboxRuntimeSpec (canonicalized, structurally comparable, with a stable fingerprint()), ObservedRuntimeFacts/SandboxInspection (backend-native units, identity-only equality), and a three-state matcher spec_matches_inspection() returning MATCH/MISMATCH/UNVERIFIED. On the Docker side it adds an explicit lifecycle API (inspect/create/start_existing/stop_existing) with publish-before-verify semantics, fingerprint-label attestation, and a reworked waiter-counted per-name lock registry.

The motivation is sound and the root cause is correctly identified: today there is no shared, verifiable representation of "what was asked for" vs. "what is actually running", which is why same-CA external tasks intermittently fail with "Sandbox already exists with different runtime configuration". The change is purely additive — verified repo-wide that nothing outside src/xagent/sandbox/ and tests/sandbox/ calls any of the new surface. This is explicitly step 1 of a multi-PR plan, with the family state machine, generation-aware attach, and SandboxManager integration deferred.


Approach verdict: ACCEPTABLE WITH RESERVATIONS

The desired/observed split, the three-state verdict (rather than a boolean), and label-based attestation are the right abstractions — this is a root-cause fix, not a symptom patch, and the docstrings show unusually careful thinking about the semantics. The reservations are about scope sequencing and about two load-bearing mechanisms whose failure mode is worse than the bug being fixed.

D1 — The API's own stated usage contract has no supported implementation. create()'s and inspect()'s docstrings both tell callers that get-or-create semantics require calling inspect() first "under their own critical section" — but the only per-name mutex, _named_lock, is private (src/xagent/sandbox/docker_sandbox.py:989). There is currently no public way for any caller to span inspect→create atomically. The future reconciler will have to build its own lock registry, which leads directly into D2.

D2 — The new lock registry is a step-for-step duplicate of an existing one. _NamedLockEntry/_named_lock/_drop_named_lock_if_unused (src/xagent/sandbox/docker_sandbox.py:591-596, :989-1042) is structurally identical to _LifecycleLockEntry/_lifecycle_locked/_drop_lifecycle_lock_if_unused in src/xagent/web/sandbox_manager.py:376-414 — same entry dataclass, same waiter counting, same rollback on acquire failure, same identity-checked eviction. The docstring acknowledges the mirroring rather than extracting a shared helper. Given D1, a third copy is the likely outcome. Consider extracting this into one shared utility now, while there are only two copies.

D3 — Publish-before-verify makes an availability/safety tradeoff implicitly. _find_publish_mismatches (src/xagent/sandbox/docker_sandbox.py:452-492) is byte-equality against daemon-echoed values, and any disagreement destroys the just-created container. This module advertises Podman/compatible-runtime support, where mount-source realpath resolution and SELinux mode suffixes are routine. A single unanticipated daemon-side normalization means "no sandbox can ever be created on this deployment" — a strictly worse outage than the intermittent conflict this PR fixes. This should be an explicit policy, not an emergent property of exact-match logic: hard-fail on security-relevant fields (volumes, ports, network_isolated), log-and-degrade on cosmetic ones (working_dir).

D4 — The lifecycle API is reference-count-blind. stop_existing() has no notion of how many actors hold the sandbox, even though spec_matches_inspection's own docstring states that destructive actions "must go through the reference-count check" — and SandboxLeaseSpec, the type that would carry that concept, is defined here and used by nothing. The linked production bug is specifically about multi-actor sandbox sharing, so this is on-target rather than tangential.

D5 — Some surface is shipping well ahead of the consumer that would define its requirements. SandboxMountIntent, SandboxLeaseSpec, SandboxRecoveryRequiredError, and canonical_sandbox_path (as an exported concept) have zero callers outside base.py and tests. These are exactly the pieces most likely to be reshaped once the family state machine PR is designed — see the Simplification section.

Minor design notes: SandboxInspection.state is typed Literal["running","stopped"] while _build_inspection collapses created/exited/dead/restarting into "stopped", yet inspect()'s docstring says a never-started container "must be treated specially" — a distinction the type cannot express without reaching into raw_status. And the single-process/single-owner assumption underpinning _named_lock, _get_live_control, and create()'s check-then-create sequence is never stated, despite otherwise exhaustive docstrings.


Confirmed findings

Major

See inline comments for findings 1, 2, and 4 (docker_sandbox.py:1341, :1444, :1367).

Test coverage gap (major): _find_publish_mismatches has 7 comparison branches (image, volumes, ports, working_dir, network_isolated, cpus, memory); only 1 (volumes) is tested for a mismatch — the other 6 have zero mismatch-triggering coverage, and per D3 a false positive in any of them destroys the just-created container. _build_inspection (docker_sandbox.py:281-345) has no dedicated unit test at all — its image_digest, runtime_networks, labels, network_isolated fields have zero direct assertions, and the state mapping is only exercised for running/exited, never created/dead/restarting. No test exists for either finding 1 (remove-also-fails) or finding 2 (snapshot label inheritance).

Minor

See inline comments for findings 3 (severity-adjusted down from major), 5, 6, 7, 8, 9, 10 (docker_sandbox.py:1044, :1388, base.py:284, base.py:487, docker_sandbox.py:480, :354, :1350).

Additional minor items not mapped to a single line: two divergent derivations of network_isolated (docker_sandbox.py:340 vs :251-254 — the :340 derivation is verified correct against moby source; unify on it). The lock-recycle race fixed here for Docker is left unfixed in Boxlite (boxlite_sandbox.py:475-487). SandboxService.create/SandboxService.inspect are collision-prone names on a public ABC (the test file already has to import inspect as std_inspect). Several docstrings encode caller obligations nothing enforces or tests (spec_matches_inspection's consumer contract, inspect()'s critical-section requirement, SandboxMountIntent's realpath-resolution requirement). canonical_sandbox_path is not exported from src/xagent/sandbox/__init__.py despite being the normalization SandboxMountIntent's contract tells callers to align with. Docstring gaps: create() doesn't mention it's reference-count-blind; start_existing() doesn't state it performs no spec verification before adopting a container; the single-process/single-owner assumption is never stated.


Test coverage

Positive first: the new tests are genuinely non-tautological. The fingerprint field-sensitivity matrix, the full MATCH/MISMATCH/UNVERIFIED verdict matrix (including detecting a newer contract version), the lock mutual-exclusion/release-and-requeue test, and the repeated-cancellation-holds-lock regression test were each independently confirmed to actually pin the properties they claim.

Gaps that matter are covered above (publish-mismatch branch coverage, _build_inspection, findings 1/2). Minor gaps: no real-daemon test creates a network_isolated=True sandbox (pure coverage gap — moby's NetworkDisabled round-trips correctly, not a suspected bug). The fingerprint field-sensitivity matrix omits template_type and snapshot_id. test_docker_lock_infra.py:299 asserts source.count("_SandboxControl(") == 2 over raw file text, which breaks on reformatting. test_docker_lock_infra.py:187's "double cancel" test calls cancel() twice before the task ever runs, so the second call is a no-op — it doesn't test what its docstring claims, and the equivalent scenario is already properly covered at test_docker_lifecycle_api.py:272. No test for the finding-4 concurrency scenario, the finding-5 _controls growth, or _named_lock releasing when the body raises a non-cancellation exception.


Prior review findings (gemini-code-assist) — resolution status

All prior findings were addressed before this pass; each resolution was independently re-verified against the current code.

  1. Cancellation during container.start leaking an orphan container — FIXED. _await_shielded now shields the compensating remove, loops until the task truly completes even under repeated cancellation, and re-raises the original CancelledError afterward so cancellation semantics are preserved. Pinned by test_docker_lifecycle_api.py:259-341, confirmed passing.

  2. Ephemeral host port (0) comparison always fails — WAIVED; the bot's claim was factually wrong and the author's rebuttal is correct. Verified: _build_inspection reads facts.ports exclusively from HostConfig.PortBindings; NetworkSettings.Ports is never read anywhere in the repo. Docker preserves the requested HostPort: "0" in PortBindings for the container's lifetime — the concrete allocation only appears in NetworkSettings.Ports. A desired 0 is echoed back as 0 and the comparison never false-positives. No action needed.

  3. Missing host-port-collision check — FIXED. _check_no_conflicting_ports (docker_sandbox.py:406-449) now checks both directions, with a dedicated passing test. Commit-hygiene note only: the fix landed in 6735babaa (titled about lock handling) rather than the more plausibly-named 9ff4bb4d (titled about volume conflicts).

  4. Path-normalization false-positive mismatches — FIXED, at the correct layer. The bot's suggestion to normalize the observed side was rightly rejected: ObservedRuntimeFacts deliberately stays backend-native by contract, and normalizing it would mask genuine backend rewrites (verified: no normalization call inside _build_inspection). The real bug was on the desired side — posixpath.normpath preserves a leading // while Docker collapses it, so a //-prefixed path (reachable via XAGENT_UPLOADS_DIR/SANDBOX_VOLUMES misconfiguration) never byte-matched. Fixed by canonical_sandbox_path() (base.py:202-228), verified applied only to desired-side call sites and covered by a parametrized regression test.

  5. Windows os.path.normpath vs posixpath.normpath — WAIVED; sound judgment, independently verified. All seven supporting claims check out (Docker-only linux/amd64+arm64 image, no Windows CI job, no os.name/sys.platform branches in the module, Boxlite uninstallable on Windows per pyproject.toml platform markers, the XAGENT_SANDBOX_HOST_STORAGE_ROOT overlay, and get_sandbox_volumes's docstring stating sources are already Docker-host paths). The crux is correct: these strings are the Docker daemon's host paths, not the local process's, so applying local-OS rules would be wrong even under Docker Desktop on Windows. This reasoning is now stated in canonical_sandbox_path's docstring.

  6. SandboxRuntimeConflictError reused for publish-verification mismatch — WAIVED; sound judgment, independently verified. The docstring was actually expanded to cover both the pre-create-conflict and publish-verify-mismatch cases (base.py:46-62). Repo-wide grep confirms zero call sites branch on SandboxRuntimeConflictError as distinct from SandboxContractError; all consumption is via pytest.raises/import. A new subclass would be speculative surface today and remains a backward-compatible option later.

Legacy-compatibility claims verified accurate. The PR's claimed three bounded exceptions to legacy behavior (lock registry swap, delete()'s identity-checked control removal, _create_container's optional extra_labels) all hold; _merge_info, list_sandboxes, create_snapshot's body, and the cleanup/warmup paths are confirmed untouched.


Simplification opportunities

  • src/xagent/sandbox/base.py L397: yagni SandboxLeaseSpec has zero production callers; only its own test constructs it. Drop it and reintroduce with the PR that actually needs lease/concurrency tracking.
  • src/xagent/sandbox/base.py L465: yagni SandboxMountIntent and its covered_extras/covering_extras/disjoint_extras classification have zero production callers. Drop the class and its _is_root_or_descendant helper (L456), reintroducing alongside the mount-reconciliation caller.
  • src/xagent/sandbox/docker_sandbox.py L1028: shrink _drop_named_lock_if_unused splits a two-line waiter-count eviction into its own method for one usage pattern. Inline it into _named_lock's except/finally. (Separate from, and smaller than, D2's cross-file duplication.)
  • src/xagent/sandbox/docker_sandbox.py L1044: stdlib _await_shielded's hand-rolled shield-and-retry loop duplicates what asyncio.shield already provides. Replace with task = asyncio.ensure_future(coro); while True: try: return await asyncio.shield(task) except asyncio.CancelledError: if task.done(): raise — but preserve the "always re-raise the original cancellation" and "hold the lock until truly done" properties that test_docker_lifecycle_api.py:259 depends on.

net: roughly -250 lines possible. This is approximate — the two yagni removals also mean deleting tests/sandbox/test_mount_intent.py in full (120 lines) and the lease-related portion of tests/sandbox/test_runtime_spec.py.


Test run

pytest tests/sandbox/ in a clean worktree at this branch: 165 collected, 113 passed, 52 skipped, 0 failures, 0 errors. All 52 skips are Requires reachable Docker daemon, which was unavailable in this review environment — CI must confirm the Docker-gated half.


Recommendation

The design is right and the majority of this PR is high quality, so this is not a rework request. But three things should land before merge:

  1. Finding 1 (unguarded compensating remove) — a few lines, mirroring the pattern already used 20 lines above it. Two of its three consequences are live today.
  2. Finding 2 (snapshot label inheritance) — this one silently breaks the PR's own core guarantee, in the direction that matters most (false MATCH on an unverified container). Strip spec labels on commit, or explicitly clear them in get_or_create()'s legacy path, plus a regression test.
  3. The publish-verification coverage gap — a parametrized test over all 7 _find_publish_mismatches branches, plus tests for findings 1 and 2. Publish-before-verify is the mechanism whose false positives destroy containers (D3); shipping it with 1-of-7 branches covered is the single largest risk in the PR.

Findings 3, 4, and the remaining minor items do not need to block: finding 3 is bounded by docker-py's default timeout, and finding 4 is unreachable until start_existing/stop_existing gain a production caller — though it should be tracked explicitly and resolved before that caller lands.

D2 (the duplicated lock registry) and D3 (making the publish-verify failure policy explicit per-field) are strongly recommended for this PR while there are still only two copies and no consumer depending on current behavior. D1 and D4 are legitimately follow-up work, but D1 should at least be acknowledged in the PR description — shipping an API whose docstrings prescribe a critical section that no public primitive can provide is worth flagging for the next author.

Comment thread src/xagent/sandbox/docker_sandbox.py Outdated
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/base.py
Comment thread src/xagent/sandbox/base.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Docker copies a container's labels into the image produced by commit(), and
merges an image's labels into any container created from it for every key the
create request does not set. create(A, spec_A) -> create_snapshot(A) -> a
container built from that snapshot therefore inherited spec_A's fingerprint
and presented it as its own attestation: MISMATCH where UNVERIFIED was owed,
or a false MATCH when the specs agreed, on a container that never went
through the verified create() path. Verified on Docker 29.4.0.

Fix it at the label mechanism rather than at the snapshot: _create_container
now always writes both attestation keys for every container either lifecycle
path creates, blank when there is nothing to attest, so no base image can
supply them. Docker cannot remove an inherited label, only overwrite it, so
_build_inspection reads a blank label as absent -- reading it as a real value
would turn UNVERIFIED into MISMATCH and rebuild adoptable containers.

Also:
- Guard create()'s compensating remove on the publish-mismatch path. A
  failing remove() no longer replaces SandboxRuntimeConflictError with a raw
  docker error; the leaked container is logged for operator action.
- Reject unrealizable ResolvedSandboxRuntimeSpec values at construction
  (cpus >= 1, memory >= 128, mirroring SandboxConfig) instead of failing
  pydantic validation later inside create().
- Reject empty and relative paths in SandboxMountIntent. Its
  covered/covering/disjoint split is lexical, so "" normalized to "." and
  then read as disjoint from every absolute path -- the direction that grants
  a separate mount instead of folding it into an approved root.
- Resolve the container before the control object in start_existing/
  stop_existing, so probing an absent name no longer installs a _controls
  entry that nothing evicts.
- Extract the per-key lock registry into sandbox/keyed_lock.py so the
  primitive has one owner and one set of tests. SandboxManager's own
  lifecycle lock is left as is; it is keyed by lifecycle key rather than
  container name and can adopt the shared registry separately.
- Drop SandboxLeaseSpec, which had no callers.
- Export canonical_sandbox_path, the normalization SandboxMountIntent's
  contract tells callers to align with.
- State the contracts the docstrings had left implicit: publish verification
  is all-or-nothing because the fingerprint it stamps is immutable and covers
  every field; create()/start_existing()/stop_existing() are
  reference-count-blind; start_existing() adopts a container without
  verifying its spec; neither is mutually exclusive with Sandbox.stop(); the
  whole service assumes a single owning process; SandboxInspection.state is a
  two-value reduction and facts.raw_status carries the rest.

Tests: all seven publish-mismatch branches with negative controls, direct
_build_inspection coverage including the state mapping and blank-label
normalization, a real-daemon snapshot-attestation regression test, a
remove-also-fails publish mismatch, network_isolated=True against a real
daemon, control-entry leak bounds, spec bounds, template_type/snapshot_id
fingerprint sensitivity plus a guard against uncovered new fields, and the
keyed lock registry's identity/eviction/cancellation invariants. Replaces the
_SandboxControl construction-site check with an AST walk instead of a raw
substring count, and corrects the repeated-cancel test's claim to the
invariant it actually reaches.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

Thanks — this was a thorough pass and several items were real. Fixes are in e9ef5b3; inline threads have individual replies. Below is the disposition for everything in the review body, since those items have no threads to reply in.

pytest tests/sandbox/ in this worktree at e9ef5b3: 237 tests, 0 failures, 0 errors, 12 skipped (all 12 Requires boxlite runtime). Docker was reachable here, so the Docker-gated half ran rather than skipping — including the new real-daemon attestation and network-isolation tests. pre-commit run --files clean across ruff/ruff-format/mypy/isort/codespell.

Design reservations

D1 — no public primitive spans inspect→create. Answered rather than changed: the consumer holds its own critical section at the layer that owns the identity. SandboxManager wraps the whole reconcile route — inspect(), the verdict, and create() — in its per-lifecycle-key lock, which is a coarser key than a container name (a lifecycle family, spanning primary and worker names). _named_lock cannot serve that purpose even if it were public, because it is keyed on the wrong thing. What was genuinely missing was the contract text, so _named_lock's docstring now states that it does not span a caller's inspect→create sequence and that the caller must own that window, and create()'s docstring says the same from the other side.

D2 — duplicated lock registry. Extracted. The entry dataclass, waiter counting, acquire-failure rollback and identity-checked eviction now live in src/xagent/sandbox/keyed_lock.py as KeyedLockRegistry, and DockerSandboxService delegates to it. Placed in the sandbox package deliberately: it is the lower of the two layers, web already imports from xagent.sandbox, and the reverse direction must never exist.

SandboxManager._lifecycle_locked is left as is. It is the older of the two and is keyed by lifecycle key rather than container name, so this is an extract-for-the-new-caller rather than a refactor of the existing one — the shared primitive now exists, which is what prevents the third copy you were worried about, and the manager can adopt it in a change scoped to that file. test_keyed_lock.py covers the primitive directly (identity across release-and-requeue, retention while a waiter is queued, identity-checked eviction, release on a raising body, cancelled-waiter rollback, sole-waiter cancellation), which the previous through-one-caller tests did not.

D3 — publish-verify failure policy. Agreed it should be an explicit policy; adopted that, but not the per-field tiering, because the tiering is not implementable where you suggested and would create a worse failure than the one it avoids.

create() stamps LABEL_SPEC_FINGERPRINT over the entire desired spec before the container starts — it must, because Docker cannot add or change a label on an existing container (probed: both an empty labels value and commit(changes=["LABEL k="]) land on the empty string; there is no removal at all). spec_matches_inspection then trusts that one label for every field except cpus/memory. So publishing a container whose working_dir was accepted-with-a-warning leaves a label positively attesting a spec the container does not implement, and every later reconcile reads MATCH. That is the same failure class as the snapshot-inheritance bug, and a lying attestation is worse than a refused create.

Which means the tiering has to live upstream of the label — fields whose exact value cannot be guaranteed get constrained where the desired spec is built. Two such constraints already exist and are what keep exact equality from being brittle: canonical_sandbox_path spells desired paths the way the backend echoes them, and get_sandbox_volumes() clamps volume mode to exactly ro/rw, which is what makes the SELinux scenario unreachable (detail on that thread). All of this is now written into _find_publish_mismatches' docstring so it reads as policy rather than as a side effect of !=.

On the Podman framing: the repo's only mentions are _create_docker_client's docstring and the is_docker_available error hint, both about pointing DOCKER_HOST at a compatible socket. There is no Podman CI and no runtime-specific handling — a connectivity note, not a support claim. Residual risk on non-Docker runtimes is real but unquantified; the honest mitigation is probe evidence before claiming support, not pre-emptively weakening the attestation.

D4 — reference-count blindness. Correct layering, so the capability stays where it is, but the contract text was wrong by omission and is fixed. Reference counting needs to know about lifecycle families, attach, and idle eviction — all manager concepts; SandboxManager re-reads its _activity ref-count immediately before every destructive step and rejects the new caller instead of tearing down a sandbox in use. A service-level lease would be a second, weaker authority over the same decision. create(), start_existing() and stop_existing() now state that they are reference-count-blind and that a sharing caller owns the counting. SandboxLeaseSpec is deleted (see D5).

D5 — surface ahead of its consumer. Split, because the four items are not in the same position:

  • SandboxLeaseSpec — genuinely unused, in this PR and in the stacked consumer. Deleted, along with its tests.
  • SandboxMountIntent — has real consumers: web/services/workspace_binding.py constructs it and folds mount roots through its covered/covering classification, and web/sandbox_manager.py threads it through the reconcile route. Kept, and the empty-mount_root bug you found is fixed rather than sidestepped.
  • SandboxRecoveryRequiredError — raised by the manager's mismatch handling when a sandbox will not stop. Kept.
  • canonical_sandbox_path — consumed by the manager's conflict checking, and now exported from __init__.py as you noted it should be.

Minor design notes. SandboxInspection.state: the two-value reduction is deliberate (the reconcile decision only branches on running vs not), and the distinction is reachable — facts.raw_status carries the backend's status string unreduced. The docstring now says so, naming created/exited/dead/restarting and pointing at raw_status; TestBuildInspectionStateMapping covers all of them plus paused and empty, and asserts raw_status survives. Single-process/single-owner assumption: now stated at the top of the lifecycle section — the lock and control registries are in-process with no cross-process counterpart, and the guard is deployment topology.

Confirmed findings

Test coverage gap (major) — all closed.

  • All seven _find_publish_mismatches branches, each with a negative control, plus multi-field reporting, None raw units not passing as equal, and order-insensitivity for volumes/ports (TestFindPublishMismatchesEveryBranch).
  • _build_inspection now has direct unit coverage: image_ref vs image_digest, raw cpu/memory units, env parsing including a malformed entry, volume mode from the RW flag with non-bind mounts skipped, ports from PortBindings, runtime_networks, labels, network_isolated present/absent, unnormalized working_dir, and empty attrs.
  • Finding 1: test_conflict_error_survives_a_failing_compensating_remove.
  • Finding 2: two real-daemon tests, both confirmed to fail without the fix.

Additional minor items.

  • Two network_isolated derivations. Confirmed the divergence, and confirmed :340 (Config.NetworkDisabled) is the new one — the NetworkSettings.Networks == {} or NetworkMode == "none" derivation is pre-existing legacy in _parse_container_config, untouched by this PR. Not unifying here: that function feeds SandboxInfo for list_sandboxes()/_merge_info, whose consumers are the legacy paths, so changing it alters legacy output for no gain to this contract. The new derivation is covered both ways by tests. Follow-up.
  • Boxlite lock-recycle race. Real, pre-existing, and a different scheme (a global _locks_lock around a dict, popped in delete()). Boxlite reports supports_runtime_spec() == False, so it does not participate in this contract at all. Follow-up rather than a drive-by fix in a backend this PR does not otherwise touch.
  • SandboxService.create/inspect naming. Fair — the test file does need import inspect as std_inspect. Not renaming now: both names are already consumed by the stacked PR, and a public-ABC rename should be one deliberate change rather than a rider here. Follow-up.
  • Docstrings encoding unenforced caller obligations. Partly addressed by the D1/D4 text above. The remainder is inherent: SandboxMountIntent's realpath requirement cannot be checked without touching the filesystem, which the type is explicitly not allowed to do. What was checkable is now checked — the absolute-path precondition is enforced instead of merely documented.
  • canonical_sandbox_path not exported. Fixed.
  • Three docstring gaps. All three added: create() is reference-count-blind, start_existing() performs no spec verification before adopting a container, and the single-process assumption.

Test coverage

  • network_isolated=True against a real daemon: added, with a non-isolated negative control, asserting the observation tracks the request and the verdict is MATCH.
  • Fingerprint matrix template_type/snapshot_id: added. Also added a guard asserting the spec's field set equals the covered set, so a future field cannot arrive without fingerprint coverage.
  • test_docker_lock_infra.py:299: replaced the raw substring count with an AST walk asserting _SandboxControl is constructed only inside _get_control and _get_live_control. Reformatting-proof, and it no longer counts mentions in comments.
  • test_docker_lock_infra.py:187 "double cancel": correct, the second cancel() was a no-op and the docstring overclaimed. The scenario as described is structurally unreachable — the rollback in the except BaseException branch contains no await, so nothing can interleave inside it. Renamed to what it does verify (repeated cancel of a queued waiter does not double-decrement) and rewrote the comment to state that, pointing at the _await_shielded test for the genuinely-concurrent case.
  • _controls growth (finding 5): covered, including 25 distinct unbounded-namespace probes.
  • _named_lock releasing when the body raises a non-cancellation exception: covered in test_keyed_lock.py.
  • Finding-4 concurrency scenario: not covered — see that thread; the fix is deferred and a test asserting the current (unsafe) interleaving would pin the wrong invariant.

Simplification

  • SandboxLeaseSpec, SandboxMountIntent — see D5.
  • _drop_named_lock_if_unused inlining and the _await_shielded rewrite: declining both. They are the same trade — fewer lines for zero behavior change in the lowest-level concurrency primitive in the PR, with the stacked consumer already built on it. The asyncio.shield rewrite in particular has to preserve "always re-raise the original cancellation" and "hold the lock until the operation truly completes", which is what the current loop exists to do and what test_docker_lifecycle_api.py:259 pins; the proposed form is not obviously equivalent under repeated cancellation. _drop_named_lock_if_unused did move — it is now KeyedLockRegistry._drop_if_unused — but as part of the D2 extraction, still a named method, because the identity check is the subtle part and deserves the docstring.

Follow-ups I am not opening from here

Explicit Docker client timeout for lifecycle operations (finding 3, with the exec_run interaction); shared mutex between Sandbox.stop() and start_existing/stop_existing (finding 4); _create_container from volumes= dict to mounts=[] so legal multi-guest-path mounts stop being rejected (finding 9); unify the legacy network_isolated derivation on Config.NetworkDisabled; Boxlite lock-recycle race; SandboxService.create/inspect naming on the public ABC; and the residual from finding 1 — a container leaked by a failed compensating remove keeps a valid-looking attestation, and Docker cannot strip it.

@AlexLiu190625
AlexLiu190625 requested a review from rogercloud July 27, 2026 09:42

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Update summary

One new commit since the last review, e9ef5b3e ("fix: stop snapshot images from laundering spec attestation", 10 files, ~1400 insertions), addresses essentially every finding from the previous pass:

  • Fixes the two confirmed major bugs: the unguarded compensating remove() on publish-verification failure, and the snapshot-attestation-inheritance bug (via a label-mechanism fix: _create_container now unconditionally writes both attestation label keys — blank when there's nothing to attest — so no base/snapshot image can supply an inherited fingerprint through Docker's label-merge).
  • Fixes 4 of the 6 confirmed minor bugs (_controls leak, unrealizable spec construction, empty/relative SandboxMountIntent paths) and closes all 3 test-coverage gaps (all 7 _find_publish_mismatches branches, direct _build_inspection unit tests, remove-also-fails + snapshot-inheritance regression tests).
  • Extracts the per-name lock registry into a new shared module, src/xagent/sandbox/keyed_lock.py.
  • Adds docstrings stating several previously-implicit contracts (publish-verify is all-or-nothing, the lifecycle methods are reference-count-blind, no mutual exclusion with Sandbox.stop(), single-owning-process assumption).
  • Drops SandboxLeaseSpec (zero callers) per the prior simplification suggestion; exports canonical_sandbox_path.

All of the above was independently re-verified against the current code (not taken on the commit message's word) — see the checklist below.


Prior findings — verification status

1. Unguarded compensating remove() on publish-verification failure — FIXED. docker_sandbox.py:1401-1418 now wraps the remove in try/except Exception, logs the sandbox name/mismatched fields/underlying error on removal failure, and unconditionally raises SandboxRuntimeConflictError afterward. except Exception correctly excludes CancelledError. New test: test_conflict_error_survives_a_failing_compensating_remove (test_docker_lifecycle_api.py:409-450) makes remove() itself raise and confirms the typed error still surfaces.

2. Snapshot images inherit spec fingerprint labels — FIXED. _create_container now unconditionally seeds both LABEL_SPEC_FINGERPRINT/LABEL_SPEC_VERSION as blank for every container it builds (both create() and the legacy get_or_create() path), and _attestation_label reads a blank label as absent. Since Docker only merges an image's label into a new container for keys the create request does not set, and the create request now always sets both keys, an inherited (non-blank) label from a base/snapshot image can never survive. Two new real-daemon regression tests in test_bridge_seams.py (TestSnapshotDoesNotLaunderAttestation) directly exercise create(A) -> create_snapshot(A) -> get_or_create(B, template=snapshot) and assert B is UNVERIFIED, covering both the MISMATCH-should-be-UNVERIFIED and false-MATCH-should-be-UNVERIFIED directions. No gaps found.

3. _await_shielded unbounded-hang concern — unchanged, WAIVED (already severity-adjusted to minor last round). The author declined to add an explicit client timeout, reasoning that docker.from_env()'s per-request timeout doesn't bound the connection's whole lifetime differently than already assumed. This doesn't change last round's conclusion: docker-py's 60s default still bounds the realistic wedged-daemon case; not blocking.

4. start_existing/stop_existing vs. DockerSandbox.stop() mutex asymmetry — DOCUMENTED, still open/latent. Verified: stop()'s body is byte-identical to before; only new docstrings on start_existing/stop_existing (docker_sandbox.py:1460-1463, 1487-1488) now explicitly state "Mutual exclusion is per container name and is not shared with Sandbox.stop()... A caller that can issue both concurrently for one name must serialize them itself." This is an honest acknowledgment, not a code fix. Repo-wide grep confirms start_existing/stop_existing still have zero production callers, so this remains non-exploitable today. Please actually close this (e.g. have stop() acquire the same keyed lock) before either method gains a real caller — a documented race is still a race once something starts calling these methods.

5. _controls leak on nonexistent-name probes — FIXED. Both methods now resolve the container and raise SandboxNotFoundError before _get_live_control is ever reached. 4 new regression tests (TestLifecycleDoesNotLeakControlEntries, including a 25-distinct-unknown-names bound check) all pass.

6. from_parts() accepting unrealizable spec values — FIXED. ResolvedSandboxRuntimeSpec.__post_init__ now enforces cpus >= 1 / memory >= 128, matching SandboxConfig's own bounds exactly, raising ValueError at construction. Tests pass.

7. SandboxMountIntent(mount_root="") silently misclassifying mounts — FIXED. __post_init__ now rejects empty/relative mount_root/extra_mounts via _require_absolute_mount_path, raising ValueError rather than silently normalizing to a wrong answer. Tests pass.

Side note on the reply to this finding: the claim that SandboxMountIntent "has real consumers in the stacked reconcile PR" (web/services/workspace_binding.py, a reconcile route in web/sandbox_manager.py) does not hold for the current repository — neither that file nor any such route exists anywhere in this repo (checked locally across all branches and via GitHub-wide code search). That's a reasonable justification for keeping the type around given stated future plans, but it doesn't change that SandboxMountIntent has zero callers in this PR's own diff today. Not a blocker — just noting the claim isn't independently verifiable yet, and the type's behavior is correct regardless of who ends up calling it.

8. Volume mode byte-comparison with no constraint — WAIVED, and the reasoning is actually stronger than stated. get_sandbox_volumes() (src/xagent/config.py:1580-1583) clamps mode to "ro"/"rw" before it can reach SandboxConfig, confirmed as the sole production path constructing sandbox volume config. More importantly, the observed side of the comparison isn't a raw string from Docker at all — _build_inspection derives it purely from the RW boolean flag (docker_sandbox.py:318), so the failure mode this finding described (an SELinux mode suffix like "rw,z" reaching the comparison) structurally cannot occur on either side. Not a blocker. Minor caveat carried forward: SandboxConfig.volumes still has no type-level constraint enforcing this, so it's a convention rather than a guarantee — low priority.

9. Volume conflict check rejecting a legal Docker mount configuration — still open, correctly logged as deferred (not fixed, not disputed). Confirmed unchanged: _create_container still builds a volumes={host: {...}} dict keyed on host path, and the conflict check still rejects reusing one host path at two guest paths for exactly that reason. The author's characterization ("load-bearing for the current implementation, deferring rather than disputing") matches the code. Fine to leave open; worth a tracking note for whenever docker.types.Mount(...) migration happens.

10. Store row persists raw config while the fingerprint attests the canonicalized form — disagree with WAIVED, recommend keeping this open. The author's rebuttal ("already symmetric, canonicalization is owned by from_parts") is true only for the one comparison that exists in this PR today (spec_matches_inspection, currently zero production callers) — it structurally compares a freshly-from_parts()-normalized desired against the label, so that specific path is fine. But nothing in the code enforces that a future reconciler renormalizes the stored raw config via from_parts() before diffing it against anything else; spec_matches_inspection's own docstring explicitly documents the exact fallback this finding warned about ("fall back to a full desired-state comparison against the record" on UNVERIFIED). Since that reconciler lives in a separate, not-yet-reviewable PR, this can't be confirmed safe yet. Recommend either persisting the canonicalized to_backend_config() output instead of the raw caller input, or explicitly noting in the store-row docstring that any future comparison against it MUST re-normalize first. Not a blocker for this PR (no live bug today), but please don't close this thread as resolved — re-open it when the reconciler PR is reviewed if it doesn't renormalize.

17, 18, 20 (test coverage gaps) — all FIXED and independently verified branch-by-branch. All 7 _find_publish_mismatches branches (image, volumes, ports, working_dir, network_isolated, cpus, memory) now have genuine mismatch-triggering tests plus negative controls (TestFindPublishMismatchesEveryBranch). _build_inspection now has direct unit tests via a fake container stub covering image_digest, runtime_networks, labels, network_isolated, and the full raw_status state-mapping (created/exited/dead/restarting/paused/empty). Both the remove-also-fails and snapshot-attestation-inheritance scenarios (findings 1 and 2) now have dedicated regression tests, confirmed to actually exercise the buggy scenario rather than a weaker proxy.


D1-D5 design concerns — updated status

  • D2 (duplicated lock registry) — partially addressed. The new src/xagent/sandbox/keyed_lock.py extracts a shared KeyedLockRegistry, and docker_sandbox.py's _named_lock is now a thin alias over it (confirmed: docker_sandbox.py:54,1050-1057). However, src/xagent/web/sandbox_manager.py's own _LifecycleLockEntry/_lifecycle_locked (sandbox_manager.py:71,333,377) was not migrated — confirmed unchanged, still a separate implementation. The commit message states this is intentional ("SandboxManager's own lifecycle lock is left as is; it can adopt the shared registry separately"). That's a reasonable incremental step — extracting one copy into a reusable, independently-tested primitive (tests/sandbox/test_keyed_lock.py, 247 lines) is real progress even if the second copy isn't migrated yet — but D2 isn't fully closed. Please track the sandbox_manager.py migration as explicit follow-up work rather than letting it get forgotten now that the new copy looks "done."
  • D1, D3, D4 — addressed via documentation, not code changes. New docstrings now explicitly state the previously-implicit contracts: publish-verify is all-or-nothing (D3), the lifecycle methods are reference-count-blind (D4), and there's no mutual exclusion between the new methods and Sandbox.stop() (ties to finding 4/D1). This is a real improvement — an implicit trap is now an explicit, discoverable one — but none of D1/D3/D4 were structurally closed. That's acceptable for this PR's stated scope (a foundational, purely-additive layer with zero production callers), provided these are genuinely tracked for the reconciler PR that will actually call this API, rather than assumed away because they're now documented.
  • D5 (surface shipped ahead of its consumer) — partially addressed. SandboxLeaseSpec (zero callers) was dropped entirely, per the simplification suggestion. SandboxMountIntent was kept (author disputes it's dead code, citing a not-yet-existing stacked PR — see finding 7 note above) but its bug was fixed, so it's at least now correctly-behaved zero-caller code rather than buggy zero-caller code.

Simplification opportunities — updated

  • _drop_named_lock_if_unused (previously flagged as an unnecessarily separate method) no longer exists as a standalone unit — absorbed into the keyed_lock.py extraction. Resolved as part of the D2 refactor.
  • _await_shielded's hand-rolled shield-and-retry loop (previously flagged as a stdlib simplification opportunity) is unchanged. Still a valid, low-priority suggestion; not revisited this round.
  • SandboxLeaseSpec (previously flagged yagni) — dropped entirely, as recommended.
  • SandboxMountIntent (previously flagged yagni) — kept, per the author's stated future-consumer plans (not yet verifiable in this repo); its bug is now fixed, so no residual simplification finding here beyond the original suggestion, which the author has explicitly declined for stated reasons.

No new simplification findings from this round's changes — keyed_lock.py is a reasonably tight, single-purpose 103-line module with its own dedicated 247-line test file.


Test run

pytest tests/sandbox/ in the refreshed worktree at the new head: 237 collected, 181 passed, 56 skipped, 0 failures, 0 errors. All skips are Docker/Boxlite-daemon-gated (unavailable in this review environment); CI must confirm those, including the two new real-daemon snapshot-attestation regression tests, which are the most important ones to see pass given they pin the fix for the more serious of the two prior major bugs.


Recommendation

Both previously-confirmed major bugs are fixed with real, non-trivial regression tests, and all test-coverage gaps that mattered most (the publish-verification mechanism the PR's whole safety story depends on) are closed. The remaining open items — finding 4 (documented but not code-fixed, still latent), finding 9 (acknowledged, deferred), and finding 10 (disputed, recommend keeping open) — are all either genuinely non-exploitable today (zero production callers of the affected methods) or low-severity conventions rather than active bugs. None of them block this specific PR's merge given its stated scope as a purely-additive, zero-caller foundation.

This PR looks ready to merge, conditional on: (a) CI confirming the Docker-gated tests (especially the two new snapshot-attestation regression tests) actually pass against a real daemon, and (b) findings 4 and 10 being tracked as explicit follow-up items — ideally referenced in the follow-up PR(s) that will give this API its first real caller — rather than being silently forgotten once this PR merges.

Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
Comment thread src/xagent/sandbox/docker_sandbox.py
…l rows

DockerSandbox.stop() took only the sandbox's own exclusive_access barrier,
which drains in-flight operation() work and tracks no exclusive holder.
Neither container.stop() nor container.start() registers as an operation(),
so a stop could interleave with start_existing/stop_existing/delete/
create_snapshot for the same container name. The handle now receives the
owning service's KeyedLockRegistry and takes the same per-name entry before
its exclusive_access barrier, in the same order every service lifecycle
method uses, so the two guards cannot deadlock against each other. The
registry is injected rather than the service, so the handle keeps no reverse
dependency on DockerSandboxService.

The lock is not reentrant, so no lock-holding path may route through the
handle: stop_existing() keeps stopping its container through the raw
Container.stop API, the same rule that keeps create()'s compensating cleanup
on raw container.remove().

create() now persists the canonical desired state produced by
to_backend_config() instead of the caller's raw template/config. The row and
the fingerprint label were otherwise two different spellings of one spec,
leaving every future reader obliged to re-normalize the row through
from_parts before comparing it -- an obligation nothing enforces. The
conversion drops no information: SandboxTemplate is exactly
(type, image, snapshot_id) and SandboxConfig exactly the seven fields the
spec carries. The legacy get_or_create() path writes no attestation label
and keeps recording the request verbatim.
@AlexLiu190625

AlexLiu190625 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Three things the inline threads don't cover.

#1014 lives on a fork, which is why you couldn't find it. You were right that the consumer claim isn't independently verifiable from this repository — the branch is AlexLiu190625/xagent:fix/sandbox-manager-spec-reconcile, opened as #1014. Its diff contains src/xagent/web/services/workspace_binding.py and the reconcile route in sandbox_manager.py, where SandboxMountIntent, SandboxRecoveryRequiredError, canonical_sandbox_path, ObservedRuntimeFacts and SpecVerdict all have callers. Your point stands for this PR's own diff, which calls none of them.

D1, D2 and D4 land in #1014. That includes the sandbox_manager.py lock migration: the file is +1043/−127 there, so migrating it here would only create a conflict. Same for routing destructive actions through the reference-count check (D4) and owning the inspect→act critical section (D1). D3 belongs there too, in the sense that tiering can only be decided by whoever decides whether to attest a spec at all — the verification step itself cannot degrade a field while leaving a label that positively attests it.

The Docker-gated tests already ran in CI on the commit you reviewed. tests/sandbox is covered by Pytest Fast Deepdoc (core); on e9ef5b3e that job reported 5834 passed / 36 skipped with zero Requires reachable Docker daemon skips, so both snapshot-attestation regressions in test_bridge_seams.py executed against a real daemon. The remaining 12 sandbox skips are boxlite-only.

@AlexLiu190625
AlexLiu190625 requested a review from rogercloud July 27, 2026 13:59

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Update summary

One new, narrowly-scoped commit since the last review, 811a2f2f ("fix: share one lifecycle mutex with sandbox stop and persist canonical rows", 4 files, ~372 insertions). It closes the two remaining open items from the last round:

  1. Finding 4 (start_existing/stop_existing not mutually exclusive with DockerSandbox.stop()) — previously only documented, now actually fixed.
  2. Finding 10 (store row persisting raw config while the fingerprint attests the canonical form) — previously disputed, now fixed at the value level.

Both were independently re-verified against the code (not the commit message), along with a targeted discovery sweep for any regression this change might have introduced.


Verification

Finding 4 — FIXED, cleanly. DockerSandbox now receives the owning service's KeyedLockRegistry (injected as a plain collaborator, not the service itself — confirmed no reverse dependency on DockerSandboxService anywhere in the class). stop() now acquires the same per-name lock entry before its exclusive_access barrier, in the same lock-then-barrier order every other lifecycle method (create, start_existing, stop_existing, delete, create_snapshot) already used — so the two guards can't deadlock against each other. Verified all 4 DockerSandbox(...) construction sites pass the shared registry (no partial wiring), and verified — independently, via two different search angles — that no existing caller holds the per-name lock while calling .stop() on a handle (the lock is correctly non-reentrant, and the commit's own reasoning that lock-holding paths route through the raw Container.stop/Container.remove API instead of the handle holds up). New regression test TestHandleStopSharesTheServiceLifecycleLock::test_stop_and_stop_existing_cannot_overlap_for_one_name directly falsifies the previously-latent race (asserts waiters==2, serialized execution) — this would fail against the pre-fix code.

Finding 10 — FIXED, at the value level. create() now persists to_backend_config()'s canonical output into the store row instead of the caller's raw template/config; the conversion is lossless (SandboxTemplate/SandboxConfig map field-for-field onto the spec). SandboxInfo's schema is unchanged — this is a change in what value gets written, not a new field. Confirmed no existing consumer is affected: web/sandbox_manager.py never calls the new create() path, only the untouched legacy get_or_create() (which deliberately keeps recording the caller's raw request verbatim — pinned by a new test). Worth flagging one thing from the verification, for the record rather than as a new finding: the author's previous rebuttal on this thread ("already symmetric, no risk") had cited a specific function, SandboxManager._spec_from_stored_info, as proof a future reader would renormalize before comparing — that function does not exist anywhere in this repository; it's apparently in a separate, not-yet-merged branch (referenced as PR #1014). The rebuttal at the time was based on code that isn't here, not just an untested assumption. Not an issue with the current fix (which is solid, with a genuine before/after regression test using a deliberately non-canonical path), just worth noting for calibration on future replies that cite specific external symbols — please confirm such symbols actually exist in this repo before relying on them in a review response, or say explicitly "this exists in stacked PR #N" so it can be flagged as unverifiable-here rather than treated as a settled fact.

Discovery sweep — no new issues found. No new coupling introduced by the registry injection; lock/barrier ordering is consistent everywhere; no external code constructs DockerSandbox directly so the new required constructor param didn't break anything; both new test files are genuine regression tests that would fail if their respective fixes were reverted, not superficial checks. One minor, explicitly non-blocking observation: stop() can now block behind a slow/stuck create()/delete()/etc. for the same name (the lock has no timeout), but this is an existing, already-accepted trade-off shared by every other lifecycle method — stop() is just joining it, not introducing a new one.

Test run: pytest tests/sandbox/ at the new head: 186 passed, 56 skipped (Docker/boxlite-daemon-gated, unavailable in this review environment), 0 failures, 0 errors, confirmed by three independent runs across the verification agents.


Recommendation

Every finding from this review is now resolved: the two confirmed major bugs (publish-verify remove guard, snapshot label laundering) and both previously-open minor items (stop() mutex, store-row canonicalization) are fixed with genuine regression tests; all test-coverage gaps are closed; the remaining waived items (findings 3, 8, 9) hold up under independent scrutiny and are appropriately low-priority or explicitly deferred with a stated reason.

This PR is ready to merge, conditional only on CI confirming the Docker-gated tests pass against a real daemon (in particular the two real-daemon snapshot-attestation tests and the new lock-sharing regression test, none of which could run in this Docker-unavailable review environment).

@AlexLiu190625
AlexLiu190625 added this pull request to the merge queue Jul 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 27, 2026
@AlexLiu190625
AlexLiu190625 added this pull request to the merge queue Jul 27, 2026
Merged via the queue into xorbitsai:main with commit 9c98544 Jul 27, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants