Skip to content

feat(inspect-ai): add host-side Dockerfile and Compose build image support - #340

Open
tholop wants to merge 8 commits into
feat/inspect-capsem-containersfrom
feat/inspect-capsem-host-build
Open

tholop wants to merge 8 commits into
feat/inspect-capsem-containersfrom
feat/inspect-capsem-host-build

Conversation

@tholop

@tholop tholop commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Enables inspect_ai evaluations that specify a Dockerfile, Containerfile, CapsemSandboxConfig(dockerfile=...), CapsemSandboxConfig(build=...), or Compose build: block (stacked on feat/inspect-capsem-containers). Like PR 3, this is Inspect-provider-only: it needs zero capsem-service / guest / SDK changes and reuses 0.7's native image sessions.
  • Enforces os.path.realpath containment on build contexts and Dockerfiles against CAPSEM_INSPECT_ALLOWED_HOST_PATHS, computes deterministic SHA-256 cache keys over build context trees (with parallel build deduplication and uncacheable digest handling), and gates docker build / docker image save on CAPSEM_INSPECT_HOST_BUILD (default 1) and CAPSEM_INSPECT_BUILD_TIMEOUT (default 600s).
  • Verified live on 0.7 (capsem-dev, google/gemini-3.5-flash):
    • inspect_evals/vimgolf_single_turn --limit 2: status=success, 2/2 samples (0 sample errors, accuracy=0.500, 103s), 0 leftover VMs (and fails closed before VM creation when CAPSEM_INSPECT_ALLOWED_HOST_PATHS is unset).
    • inspect_evals/gdm_intercode_ctf --limit 2 (sample_ids=[0, 2], Compose build: ., max_sandboxes=2): status=success, 2/2 samples (0 sample errors, accuracy=0.500, 169s), 0 leftover VMs.
    • inspect_harbor/terminal_bench_2 (1/1 sample, 0 sample errors, 0 leftover VMs).

How It Works

capsem-service can only start an image session from an OCI reference it can pull through its host-side puller, so a locally built image has to be reachable as a registry. This PR keeps everything on the host and feeds the existing pull path:

  1. Build on the host: inspect_capsem.containers.image_build runs docker build (with --platform, --target, args, dockerfile_inline) against the realpath-contained context, then docker image save -o <tmp>/image.tar.
  2. Ingest into a content-addressed cache: oci_registry.ingest_docker_save_tar streams the archive's config and layer blobs in 256 KiB chunks into ~/.cache/capsem/inspect-oci-builds (CAPSEM_INSPECT_BUILD_CACHE_DIR), keyed by SHA-256, and writes an OCI image manifest (nothing is read whole into memory; identical build contexts dedupe on the cache key).
  3. Serve over a loopback OCI v2 registry: a daemon thread serves /v2/<name>/manifests/<digest> and /v2/<name>/blobs/<digest> over HTTPS on 127.0.0.1:5055 (CAPSEM_INSPECT_BUILD_REGISTRY_PORT), using an ephemeral TLS CA + leaf certificate generated once per cache dir under an fcntl.flock so concurrent evals share it.
  4. Create the VM from it: Hypervisor.create(image="127.0.0.1:5055/inspect-capsem/build@sha256:<manifest>", registry=Registry(ca_pem=...)) — capsem-service pulls from the loopback registry with the supplied CA, unpacks in the guest, and the rest of the lifecycle is identical to PR 3's image: path.

Operator prerequisites (documented in web/docs/.../inspect-ai.md): docker and openssl on PATH, and an image source admission rule in ~/.capsem/settings.toml ([images] sources = [{ name = "local_inspect_builds", registries = ["127.0.0.1:5055"] }] + admit = [{ source = "local_inspect_builds", namespaces = ["inspect-capsem"], images = ["build"] }]). The docs also carry the trust caveat: Dockerfile RUN steps execute in the host Docker daemon, outside the micro-VM boundary, so untrusted benchmark Dockerfiles should only be built when that is acceptable, or disabled with CAPSEM_INSPECT_HOST_BUILD=0.

Alternatives Considered / Potential Future Improvements

We kept host-side builds as a separate top-of-stack PR so the mechanism can be revisited without holding up PR 2b / PR 3. Two directions would simplify or improve it; neither is needed for this PR to work:

  • Native OCI archive import in capsem-service (potential improvement): a POST /images/import (or local OCI-layout reference) that ingests a docker image save / OCI layout tarball straight into the host blob cache would remove the loopback HTTPS registry, the ephemeral TLS CA/leaf generation, and the ~/.capsem/settings.toml [images] admission step from inspect-capsem, and would let admission policy see "local build" as a first-class source. Fits naturally with the image/cache service contracts in 0.7: Complete service image/cache and managed ephemeral-session contracts #301; we are happy to draft it if you prefer this shape long-term.
  • In-VM rootless image builder (alternative considered, not pursued): building Dockerfiles inside a dedicated Capsem builder VM (buildkitd / podman) would remove the host Docker daemon requirement and keep RUN steps inside the VM boundary, at the cost of slower cold builds, a builder image to maintain, and a way to hand the result to the host cache (which again wants the import endpoint above). Rejected for now because it needs new runtime pieces, whereas the host-build path ships with 0.7 as is.

tholop and others added 8 commits October 6, 2026 03:17
Adapted from 8a146e3. Preserve the current runtime, pinned-image, qualification and transition models and canonical functional-schema identities while retaining the source-package inheritance seam.
…list

Allow callers to attach optional key/value string labels to VMs at
creation time, inherit or override them on fork, and return them on
every SandboxInfo entry in /vms/list and /vms/{id}/info so external
orchestrators and garbage collectors can identify their own VMs.

- capsem-api / capsem-service / OpenAPI: add optional labels
  (Option<HashMap<String, String>>) to ProvisionRequest, ForkRequest,
  SandboxInfo, VmInstance, SessionCreationParams, and PersistentVmEntry,
  validated server-side via naming::validate_vm_labels (<= 64 entries,
  keys reuse validate_vm_name: 1..=64 ASCII chars starting with
  [A-Za-z0-9] and containing only [A-Za-z0-9_-]; values <= 255 UTF-8
  bytes with no control chars) and normalized via
  naming::non_empty_labels so {} is treated identically to None.
- Fork inheritance: /vms/{id}/fork inherits the source VM's labels when
  ForkRequest.labels is omitted/null, replaces them when a non-empty map
  is provided, and clears them when {} is provided.
- List cache fingerprint: include running and inactive VM labels in
  list_response_fingerprint via append_labels_fingerprint so label
  differences invalidate cached /vms/list responses.
- SDKs (Python, Rust, TypeScript): thread optional labels through
  Hypervisor.create / CreateOptions and VM.fork / ForkOptions and
  regenerate OpenAPI models across all three SDKs.
- CLI & TUI: add repeatable -l / --label KEY=VALUE to capsem create and
  capsem fork, and display labels in the TUI session detail pane.
…nd discovery

Add structured error codes (`ErrorCode::VmNotFound`, `ErrorCode::CreateTimeout`,
`ErrorCode::ExecTimeout`) to `ErrorResponse` in `capsem-api` and `capsem-service`,
returning HTTP 504 (`StatusCode::GATEWAY_TIMEOUT`) with `timeout_secs` on guest
command execution timeouts and replacing the `pub fn AppError` constructor shim
with `AppError::new`.

In `sdk/python`:
- Parse `ErrorResponse` once in `HttpError.__init__` (`response` and `code`) and
  dispatch `VmNotFoundError`, `CreateTimeoutError`, and `ExecTimeoutError`
  (subclassing `CapsemTimeoutError(TimeoutError, CapsemError)`) from `_ERROR_DISPATCH`
  keyed by `models.ErrorCode`, with pickle round-trip support (`__reduce__`/`__setstate__`).
- Add host gateway URL and bearer token discovery (`discover_gateway`,
  `capsem_run_dir`, `Hypervisor.connect`). Note: `capsem_run_dir` is the one
  client-side path helper retained as an exception to relying on the server for
  path rules because `discover_gateway` resolves `~/.capsem/run/gateway.{port,token}`
  on the host before any gateway connection exists; guest file path sanitization
  is delegated entirely to the gateway (`POST/GET /vms/{id}/files/*`).
…ration

Port the inspect-capsem Inspect AI SandboxEnvironment integration onto
the 0.7 Python SDK surface in VM-only mode (container/image execution
deferred to the follow-up container commit):

- Split the implementation across focused modules under
  integrations/inspect-ai/inspect_capsem/ (config.py, _controller.py,
  _exec.py, _files.py, _lifecycle.py, _registry.py, _tools.py,
  _transfer.py, sandbox.py) with every module under 250 lines at 100
  columns.
- Encapsulate private CapsemSandboxEnvironment state inside sandbox.py,
  return SandboxConnection(type="capsem", command="capsem shell <id>"),
  and bound process-owned VM teardown at interpreter exit.
- Derive staged file transfer part sizes from MAX_REQUEST_BODY_BYTES,
  scope VM cleanup to exact managed-by + prefix labels, and clean up
  unnamed VMs on 504 CreateTimeoutError via CreateTimeoutError.vm_id.
- Wire integrations/inspect-ai into capsem-gate, CI scope routing, and
  live VM ironbank acceptance.
Add OCI container execution mode (`execution_mode="container"` / `execution_mode="auto"`, `image=...`, and `compose.yaml` / `docker-compose.yml` config parsing) on top of Capsem VM sandboxes:

- `inspect_capsem/containers/compose.py` + `compose_fields.py`: Compose service parser supporting `image`, `command`/`entrypoint`, `environment`, `working_dir`, `user`, `volumes` (read-write and `:ro` bind mounts with symlink containment), `healthcheck`, `cpus`/`mem_limit`, and `x-default` / `default` service selection, with fail-closed validation on unsupported Compose keys and multi-service topologies.
- `inspect_capsem/containers/runtime.py` + `controller.py`: OCI container staging (`stage_bind_mounts`, `wait_for_healthcheck`, `is_root_user_spec`) and `ContainerController` protocol.
- `inspect_capsem/_config_io.py`, `_exec.py`, `_files.py`, `_lifecycle.py`, `config.py`, `sandbox.py`: thread container execution mode, non-root `user` execution (`su -m` / `setpriv`), and `/workspace` staging through `CapsemSandboxEnvironment`.
- Unit tests (`tests/containers/*`, `tests/test_config.py`, `tests/test_sandbox*.py`) and live OCI container acceptance tests (`tests/live_acceptance.py`).
…pport

Add host-side Docker/OCI image build and loopback HTTPS OCI v2 registry support (`CapsemSandboxConfig(dockerfile=...)`, `CapsemSandboxConfig(build=...)`, string `"Dockerfile"` / `"Containerfile"` configs, and Compose `build:` / `dockerfile:` service blocks):

- `inspect_capsem/containers/image_build.py`: validate and normalize Compose `build:` / `dockerfile:` blocks with `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` containment (`os.path.realpath`), compute deterministic SHA-256 cache keys over build context trees, run `docker build` + `docker image save` with `CAPSEM_INSPECT_BUILD_TIMEOUT` (default 600s) and `CAPSEM_INSPECT_HOST_BUILD` gating, and ingest saved tarballs into the content-addressed blob cache.
- `inspect_capsem/containers/oci_registry.py`: serve cached OCI v2 manifests and blobs over a loopback HTTPS registry (`127.0.0.1:5055` by default, `CAPSEM_INSPECT_BUILD_REGISTRY_PORT`) with `fcntl.flock`-guarded ephemeral TLS CA/leaf cert generation (`ensure_localhost_tls`).
- `inspect_capsem/_controller.py` + `_lifecycle.py`: pass `registry={"ca_pem": ...}` to `Hypervisor.create` and format actionable `~/.capsem/settings.toml` admission guidance on HTTP 403.
- Unit tests (`tests/containers/test_image_build.py`, `tests/containers/test_compose.py`, `tests/test_compose_config.py`) and user documentation (`web/docs/src/content/docs/usage/inspect-ai.md`).
@codecov-commenter

codecov-commenter commented Oct 7, 2026 •

Copy link
Copy Markdown

❌ 6 Tests Failed:

Tests completed Failed Passed Skipped
6751 6 6745 0
View the top 3 failed test(s) by shortest run time
tests.test_sandbox_exec::test_exit_137_sigkill_vs_actual_timeout
Stack Traces | 0.008s run time
tmp_path = PosixPath('.../pytest-of-runner/pytest-1/test_exit_137_sigkill_vs_actua0')

    async def test_exit_137_sigkill_vs_actual_timeout(tmp_path: Path) -> None:
        """Exit 137 from SIGKILL is not misreported as TimeoutError."""
        controller = LocalFakeCapsemController(tmp_path)
        work_dir = tmp_path / "work"
        work_dir.mkdir()
        env = CapsemSandboxEnvironment(
            vm_id="vm-sigkill",
            controller=cast(Any, controller),
            working_dir=str(work_dir),
        )
        try:
            res = await env.exec(["bash", "-c", "kill -KILL $$"], timeout=30)
            assert res.success is False
>           assert res.returncode == 137
E           AssertionError: assert 127 == 137
E            +  where 127 = ExecResult(success=False, returncode=127, stdout='', stderr='bash: timeout: command not found\n').returncode

tests/test_sandbox_exec.py:200: AssertionError
tests.test_sandbox_exec::test_exec_edge_cases
Stack Traces | 0.015s run time
async def test_exec_edge_cases() -> None:
        ctrl = Scripted()
        env = env_for(ctrl)
        assert (await env.exec([])).success
        ctrl.rules = [("./tool", fail(126))]
        assert not (await env.exec(["./tool"])).success
        ctrl.rules = [("my_script", fail(126, stderr="custom failure"))]
        assert (await env.exec(["bash", "-c", "my_script"])).returncode == 126
        ctrl.rules = [("timeout -k", fail(126, stderr="bash: Permission denied"))]
        with pytest.raises(PermissionError, match="Permission denied"):
            await env.exec(["tool"], timeout=5)
        ctrl.rules = [("timeout -k", CommandResult(0, "x", "", truncated=True))]
        with pytest.raises(OutputLimitExceededError):
            await env.exec(["tool"], timeout=5)
    
        ctrl.rules = []
        ctrl.commands.clear()
        result = await env.exec(["cat"], input="x" * 20000, env={"A": "1"}, user="bob")
        assert result.success
        assert any(".capsem_stdin_" in p for p in ctrl.uploads)
        assert len(ctrl.commands) == 1
        script = ctrl.commands[0]
        assert "su -m bob" in script and "export A=1" in script and "timeout -k" not in script
        assert "rm -f /tmp/.capsem_stdin_" in script
        await env.exec(["echo", "y" * 70000])
        assert any(".capsem_cmd_" in p for p in ctrl.uploads)
        assert len(ctrl.commands) == 2 and "rm -f /tmp/.capsem_cmd_" in ctrl.commands[1]
    
        ctrl.commands.clear()
        env_nonroot = env_for(ctrl, execution_mode="container", user="developer")
        assert (await env_nonroot.exec(["id", "-un"])).success
        assert any(
            not c.startswith("bash -c")
            and "su -m developer" in c
            and 'export USER="$__u" LOGNAME="$__u" HOME="${__h:-/home/$__u}"' in c
            for c in ctrl.commands
        )
        nonroot_script, _ = exec_mod._format_exec_command(
            'printf "%s:%s:%s" "$USER" "$LOGNAME" "$HOME"',
            effective_cwd="/",
            env=None,
            user=str(os.getuid()),
            timeout=None,
        )
        su_idx = nonroot_script.index("-s /bin/bash -c ") + len("-s /bin/bash -c ")
        su_body = shlex.split(nonroot_script[su_idx:])[0]
        sub_env = {**os.environ, "HOME": "/root", "USER": "root", "LOGNAME": "root"}
        sub_out = subprocess.run(
            ["bash", "-c", su_body], env=sub_env, capture_output=True, text=True, check=True
        ).stdout
        pw = pwd.getpwuid(os.getuid())
>       assert sub_out in (
            f"{pw.pw_name}:{pw.pw_name}:{pw.pw_dir}",
            f"{pw.pw_uid}:{pw.pw_uid}:/home/{pw.pw_uid}",
        )
E       AssertionError: assert 'runner:runner:/home/runner' in ('runner:runner:/Users/runner', '501:501:/home/501')

tests/test_sandbox_exec.py:114: AssertionError
tests.test_sandbox_exec::test_self_check_with_local_fake_controller
Stack Traces | 0.139s run time
tmp_path = PosixPath('.../pytest-of-runner/pytest-1/test_self_check_with_local_fak0')
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10ba05da0>

    async def test_self_check_with_local_fake_controller(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """sample_init passes self_check with LocalFakeCapsemController."""
        controller = LocalFakeCapsemController(tmp_path, skip_bake=False)
        monkeypatch.setattr(sb, "SdkCapsemController", lambda: controller)
    
        guest_work = tmp_path / "guest_work"
        guest_work.mkdir()
        envs = await CapsemSandboxEnvironment.sample_init(
            task_name="self_check_no_host_ws",
            config=CapsemSandboxConfig(working_dir=str(guest_work)),
            metadata={},
        )
        env = envs["default"]
        assert isinstance(env, CapsemSandboxEnvironment)
        try:
            skip = {
                "test_read_and_write_large_file_binary",
                "test_exec_input_large",
                "test_read_file_limit",
                "test_exec_as_user",
>           } | _host_timeout_skips()
                ^^^^^^^^^^^^^^^^^^^^^

tests/test_sandbox_exec.py:271: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/helpers.py:251: in _host_timeout_skips
    version = subprocess.run(
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:555: in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1039: in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Popen: returncode: 255 args: ['timeout', '--version']>
args = ['timeout', '--version'], executable = b'timeout', preexec_fn = None
close_fds = True, pass_fds = (), cwd = None, env = None, startupinfo = None
creationflags = 0, shell = False, p2cread = -1, p2cwrite = -1, c2pread = 17
c2pwrite = 18, errread = 19, errwrite = 20, restore_signals = True, gid = None
gids = None, uid = None, umask = -1, start_new_session = False
process_group = -1

    def _execute_child(self, args, executable, preexec_fn, close_fds,
                       pass_fds, cwd, env,
                       startupinfo, creationflags, shell,
                       p2cread, p2cwrite,
                       c2pread, c2pwrite,
                       errread, errwrite,
                       restore_signals,
                       gid, gids, uid, umask,
                       start_new_session, process_group):
        """Execute program (POSIX version)"""
    
        if isinstance(args, (str, bytes)):
            args = [args]
        elif isinstance(args, os.PathLike):
            if shell:
                raise TypeError('path-like args is not allowed when '
                                'shell is true')
            args = [args]
        else:
            args = list(args)
    
        if shell:
            # On Android the default shell is at '....../system/bin/sh'.
            unix_shell = ('....../system/bin/sh' if
                      hasattr(sys, 'getandroidapilevel') else '/bin/sh')
            args = [unix_shell, "-c"] + args
            if executable:
                args[0] = executable
    
        if executable is None:
            executable = args[0]
    
        sys.audit("subprocess.Popen", executable, args, cwd, env)
    
        if (_USE_POSIX_SPAWN
                and os.path.dirname(executable)
                and preexec_fn is None
                and (not close_fds or _HAVE_POSIX_SPAWN_CLOSEFROM)
                and not pass_fds
                and cwd is None
                and (p2cread == -1 or p2cread > 2)
                and (c2pwrite == -1 or c2pwrite > 2)
                and (errwrite == -1 or errwrite > 2)
                and not start_new_session
                and process_group == -1
                and gid is None
                and gids is None
                and uid is None
                and umask < 0):
            self._posix_spawn(args, executable, env, restore_signals, close_fds,
                              p2cread, p2cwrite,
                              c2pread, c2pwrite,
                              errread, errwrite)
            return
    
        orig_executable = executable
    
        # For transferring possible exec failure from child to parent.
        # Data format: "exception name:hex errno:description"
        # Pickle is not used; it is complex and involves memory allocation.
        errpipe_read, errpipe_write = os.pipe()
        # errpipe_write must not be in the standard io 0, 1, or 2 fd range.
        low_fds_to_close = []
        while errpipe_write < 3:
            low_fds_to_close.append(errpipe_write)
            errpipe_write = os.dup(errpipe_write)
        for low_fd in low_fds_to_close:
            os.close(low_fd)
        try:
            try:
                # We must avoid complex work that could involve
                # malloc or free in the child process to avoid
                # potential deadlocks, thus we do all this here.
                # and pass it to fork_exec()
    
                if env is not None:
                    env_list = []
                    for k, v in env.items():
                        k = os.fsencode(k)
                        if b'=' in k:
                            raise ValueError("illegal environment variable name")
                        env_list.append(k + b'=' + os.fsencode(v))
                else:
                    env_list = None  # Use execv instead of execve.
                executable = os.fsencode(executable)
                if os.path.dirname(executable):
                    executable_list = (executable,)
                else:
                    # This matches the behavior of os._execvpe().
                    executable_list = tuple(
                        os.path.join(os.fsencode(dir), executable)
                        for dir in os.get_exec_path(env))
                fds_to_keep = set(pass_fds)
                fds_to_keep.add(errpipe_write)
                self.pid = _fork_exec(
                        args, executable_list,
                        close_fds, tuple(sorted(map(int, fds_to_keep))),
                        cwd, env_list,
                        p2cread, p2cwrite, c2pread, c2pwrite,
                        errread, errwrite,
                        errpipe_read, errpipe_write,
                        restore_signals, start_new_session,
                        process_group, gid, gids, uid, umask,
                        preexec_fn)
                self._child_created = True
            finally:
                # be sure the FD is closed no matter what
                os.close(errpipe_write)
    
            self._close_pipe_fds(p2cread, p2cwrite,
                                 c2pread, c2pwrite,
                                 errread, errwrite)
    
            # Wait for exec to fail or succeed; possibly raising an
            # exception (limited in size)
            errpipe_data = bytearray()
            while True:
                part = os.read(errpipe_read, 50000)
                errpipe_data += part
                if not part or len(errpipe_data) > 50000:
                    break
        finally:
            # be sure the FD is closed no matter what
            os.close(errpipe_read)
    
        if errpipe_data:
            try:
                pid, sts = os.waitpid(self.pid, 0)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
                else:
                    self.returncode = sys.maxsize
            except ChildProcessError:
                pass
    
            try:
                exception_name, hex_errno, err_msg = (
                        errpipe_data.split(b':', 2))
                # The encoding here should match the encoding
                # written in by the subprocess implementations
                # like _posixsubprocess
                err_msg = err_msg.decode()
            except ValueError:
                exception_name = b'SubprocessError'
                hex_errno = b'0'
                err_msg = 'Bad exception data from child: {!r}'.format(
                              bytes(errpipe_data))
            child_exception_type = getattr(
                    builtins, exception_name.decode('ascii'),
                    SubprocessError)
            if issubclass(child_exception_type, OSError) and hex_errno:
                errno_num = int(hex_errno, 16)
                if err_msg == "noexec:chdir":
                    err_msg = ""
                    # The error must be from chdir(cwd).
                    err_filename = cwd
                elif err_msg == "noexec":
                    err_msg = ""
                    err_filename = None
                else:
                    err_filename = orig_executable
                if errno_num != 0:
                    err_msg = os.strerror(errno_num)
                if err_filename is not None:
>                   raise child_exception_type(errno_num, err_msg, err_filename)
E                   FileNotFoundError: [Errno 2] No such file or directory: 'timeout'

.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1990: FileNotFoundError
tests.test_sandbox_container::test_container_mode_passes_self_check_with_local_fake_controller
Stack Traces | 0.196s run time
tmp_path = PosixPath('.../pytest-of-runner/pytest-1/test_container_mode_passes_sel0')
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10b90d6a0>

    async def test_container_mode_passes_self_check_with_local_fake_controller(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`execution_mode='container'` passes Inspect `self_check` via `LocalFakeCapsemController`."""
        controller = LocalFakeCapsemController(tmp_path, skip_bake=False)
        monkeypatch.setattr(sb_mod, "SdkCapsemController", lambda: controller)
        guest_work = tmp_path / "container_work"
        guest_work.mkdir()
        cfg = CapsemSandboxConfig(
            execution_mode="container", image="ubuntu:24.04", working_dir=str(guest_work)
        )
        envs = await CapsemSandboxEnvironment.sample_init(
            task_name="self_check_container_mode", config=cfg, metadata={}
        )
        env = envs["default"]
        assert isinstance(env, CapsemSandboxEnvironment)
        assert env.execution_mode == "container"
        try:
            skip = {
                "test_read_and_write_large_file_binary",
                "test_exec_input_large",
                "test_read_file_limit",
                "test_exec_as_user",
>           } | _host_timeout_skips()
                ^^^^^^^^^^^^^^^^^^^^^

tests/test_sandbox_container.py:135: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/helpers.py:251: in _host_timeout_skips
    version = subprocess.run(
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:555: in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1039: in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Popen: returncode: 255 args: ['timeout', '--version']>
args = ['timeout', '--version'], executable = b'timeout', preexec_fn = None
close_fds = True, pass_fds = (), cwd = None, env = None, startupinfo = None
creationflags = 0, shell = False, p2cread = -1, p2cwrite = -1, c2pread = 17
c2pwrite = 18, errread = 19, errwrite = 20, restore_signals = True, gid = None
gids = None, uid = None, umask = -1, start_new_session = False
process_group = -1

    def _execute_child(self, args, executable, preexec_fn, close_fds,
                       pass_fds, cwd, env,
                       startupinfo, creationflags, shell,
                       p2cread, p2cwrite,
                       c2pread, c2pwrite,
                       errread, errwrite,
                       restore_signals,
                       gid, gids, uid, umask,
                       start_new_session, process_group):
        """Execute program (POSIX version)"""
    
        if isinstance(args, (str, bytes)):
            args = [args]
        elif isinstance(args, os.PathLike):
            if shell:
                raise TypeError('path-like args is not allowed when '
                                'shell is true')
            args = [args]
        else:
            args = list(args)
    
        if shell:
            # On Android the default shell is at '....../system/bin/sh'.
            unix_shell = ('....../system/bin/sh' if
                      hasattr(sys, 'getandroidapilevel') else '/bin/sh')
            args = [unix_shell, "-c"] + args
            if executable:
                args[0] = executable
    
        if executable is None:
            executable = args[0]
    
        sys.audit("subprocess.Popen", executable, args, cwd, env)
    
        if (_USE_POSIX_SPAWN
                and os.path.dirname(executable)
                and preexec_fn is None
                and (not close_fds or _HAVE_POSIX_SPAWN_CLOSEFROM)
                and not pass_fds
                and cwd is None
                and (p2cread == -1 or p2cread > 2)
                and (c2pwrite == -1 or c2pwrite > 2)
                and (errwrite == -1 or errwrite > 2)
                and not start_new_session
                and process_group == -1
                and gid is None
                and gids is None
                and uid is None
                and umask < 0):
            self._posix_spawn(args, executable, env, restore_signals, close_fds,
                              p2cread, p2cwrite,
                              c2pread, c2pwrite,
                              errread, errwrite)
            return
    
        orig_executable = executable
    
        # For transferring possible exec failure from child to parent.
        # Data format: "exception name:hex errno:description"
        # Pickle is not used; it is complex and involves memory allocation.
        errpipe_read, errpipe_write = os.pipe()
        # errpipe_write must not be in the standard io 0, 1, or 2 fd range.
        low_fds_to_close = []
        while errpipe_write < 3:
            low_fds_to_close.append(errpipe_write)
            errpipe_write = os.dup(errpipe_write)
        for low_fd in low_fds_to_close:
            os.close(low_fd)
        try:
            try:
                # We must avoid complex work that could involve
                # malloc or free in the child process to avoid
                # potential deadlocks, thus we do all this here.
                # and pass it to fork_exec()
    
                if env is not None:
                    env_list = []
                    for k, v in env.items():
                        k = os.fsencode(k)
                        if b'=' in k:
                            raise ValueError("illegal environment variable name")
                        env_list.append(k + b'=' + os.fsencode(v))
                else:
                    env_list = None  # Use execv instead of execve.
                executable = os.fsencode(executable)
                if os.path.dirname(executable):
                    executable_list = (executable,)
                else:
                    # This matches the behavior of os._execvpe().
                    executable_list = tuple(
                        os.path.join(os.fsencode(dir), executable)
                        for dir in os.get_exec_path(env))
                fds_to_keep = set(pass_fds)
                fds_to_keep.add(errpipe_write)
                self.pid = _fork_exec(
                        args, executable_list,
                        close_fds, tuple(sorted(map(int, fds_to_keep))),
                        cwd, env_list,
                        p2cread, p2cwrite, c2pread, c2pwrite,
                        errread, errwrite,
                        errpipe_read, errpipe_write,
                        restore_signals, start_new_session,
                        process_group, gid, gids, uid, umask,
                        preexec_fn)
                self._child_created = True
            finally:
                # be sure the FD is closed no matter what
                os.close(errpipe_write)
    
            self._close_pipe_fds(p2cread, p2cwrite,
                                 c2pread, c2pwrite,
                                 errread, errwrite)
    
            # Wait for exec to fail or succeed; possibly raising an
            # exception (limited in size)
            errpipe_data = bytearray()
            while True:
                part = os.read(errpipe_read, 50000)
                errpipe_data += part
                if not part or len(errpipe_data) > 50000:
                    break
        finally:
            # be sure the FD is closed no matter what
            os.close(errpipe_read)
    
        if errpipe_data:
            try:
                pid, sts = os.waitpid(self.pid, 0)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
                else:
                    self.returncode = sys.maxsize
            except ChildProcessError:
                pass
    
            try:
                exception_name, hex_errno, err_msg = (
                        errpipe_data.split(b':', 2))
                # The encoding here should match the encoding
                # written in by the subprocess implementations
                # like _posixsubprocess
                err_msg = err_msg.decode()
            except ValueError:
                exception_name = b'SubprocessError'
                hex_errno = b'0'
                err_msg = 'Bad exception data from child: {!r}'.format(
                              bytes(errpipe_data))
            child_exception_type = getattr(
                    builtins, exception_name.decode('ascii'),
                    SubprocessError)
            if issubclass(child_exception_type, OSError) and hex_errno:
                errno_num = int(hex_errno, 16)
                if err_msg == "noexec:chdir":
                    err_msg = ""
                    # The error must be from chdir(cwd).
                    err_filename = cwd
                elif err_msg == "noexec":
                    err_msg = ""
                    err_filename = None
                else:
                    err_filename = orig_executable
                if errno_num != 0:
                    err_msg = os.strerror(errno_num)
                if err_filename is not None:
>                   raise child_exception_type(errno_num, err_msg, err_filename)
E                   FileNotFoundError: [Errno 2] No such file or directory: 'timeout'

.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1990: FileNotFoundError
tests.test_transfer::test_sdk_controller_file_transfer_self_check
Stack Traces | 0.259s run time
tmp_path = PosixPath('.../pytest-of-runner/pytest-1/test_sdk_controller_file_trans0')
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10ba066d0>

    async def test_sdk_controller_file_transfer_self_check(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Writes and reads go through Files API parts, not base64 exec chunks."""
        stage_root = tmp_path / "root"
        stage_root.mkdir()
        monkeypatch.setattr(xfer_mod, "_XFER_STAGE_DIR", str(stage_root))
        monkeypatch.setattr(xfer_mod, "_XFER_PART_BYTES", 1024)
        local_vm = _LocalSdkVM(stage_root)
    
        async def _create(**_: Any) -> _LocalSdkVM:
            return local_vm
    
        async def _close() -> None:
            return None
    
        hv = SimpleNamespace(vm=lambda **_: local_vm, create=_create, close=_close)
        ctrl = SdkCapsemController(hypervisor=hv)
        work = tmp_path / "work"
        work.mkdir()
        try:
            vid = await ctrl.start_vm(template="code", cpu_count=1, ram_gb=1)
            data = os.urandom(5000)
            await ctrl.upload_to_vm(vid, str(work / "sub" / "blob.bin"), data)
            assert (work / "sub" / "blob.bin").read_bytes() == data
            assert local_vm.writes == [1024, 1024, 1024, 1024, 904]
            assert await ctrl.download_from_vm(vid, str(work / "sub" / "blob.bin")) == data
            await ctrl.upload_to_vm(vid, str(work / "empty.bin"), b"")
            assert await ctrl.download_from_vm(vid, str(work / "empty.bin")) == b""
            assert list(stage_root.iterdir()) == []
            colon_path = stage_root / "a:b.txt"
            await ctrl.upload_to_vm(vid, str(colon_path), b"colon-ok")
            assert colon_path.read_bytes() == b"colon-ok"
            assert await ctrl.download_from_vm(vid, str(colon_path)) == b"colon-ok"
            colon_path.unlink()
            assert list(stage_root.iterdir()) == []
    
            env = CapsemSandboxEnvironment(vm_id=vid, controller=ctrl, working_dir=str(work))
            before = local_vm.exec_count
            big = os.urandom(200_000)
            await env.write_file("big.bin", big)
            assert await env.read_file("big.bin", text=False) == big
            assert local_vm.exec_count - before < 12
            skip_tests = {
                "test_read_and_write_large_file_binary",
                "test_exec_input_large",
                "test_exec_as_user",
>           } | _host_timeout_skips()
                ^^^^^^^^^^^^^^^^^^^^^

tests/test_transfer.py:288: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/helpers.py:251: in _host_timeout_skips
    version = subprocess.run(
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:555: in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1039: in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Popen: returncode: 255 args: ['timeout', '--version']>
args = ['timeout', '--version'], executable = b'timeout', preexec_fn = None
close_fds = True, pass_fds = (), cwd = None, env = None, startupinfo = None
creationflags = 0, shell = False, p2cread = -1, p2cwrite = -1, c2pread = 17
c2pwrite = 18, errread = 19, errwrite = 20, restore_signals = True, gid = None
gids = None, uid = None, umask = -1, start_new_session = False
process_group = -1

    def _execute_child(self, args, executable, preexec_fn, close_fds,
                       pass_fds, cwd, env,
                       startupinfo, creationflags, shell,
                       p2cread, p2cwrite,
                       c2pread, c2pwrite,
                       errread, errwrite,
                       restore_signals,
                       gid, gids, uid, umask,
                       start_new_session, process_group):
        """Execute program (POSIX version)"""
    
        if isinstance(args, (str, bytes)):
            args = [args]
        elif isinstance(args, os.PathLike):
            if shell:
                raise TypeError('path-like args is not allowed when '
                                'shell is true')
            args = [args]
        else:
            args = list(args)
    
        if shell:
            # On Android the default shell is at '....../system/bin/sh'.
            unix_shell = ('....../system/bin/sh' if
                      hasattr(sys, 'getandroidapilevel') else '/bin/sh')
            args = [unix_shell, "-c"] + args
            if executable:
                args[0] = executable
    
        if executable is None:
            executable = args[0]
    
        sys.audit("subprocess.Popen", executable, args, cwd, env)
    
        if (_USE_POSIX_SPAWN
                and os.path.dirname(executable)
                and preexec_fn is None
                and (not close_fds or _HAVE_POSIX_SPAWN_CLOSEFROM)
                and not pass_fds
                and cwd is None
                and (p2cread == -1 or p2cread > 2)
                and (c2pwrite == -1 or c2pwrite > 2)
                and (errwrite == -1 or errwrite > 2)
                and not start_new_session
                and process_group == -1
                and gid is None
                and gids is None
                and uid is None
                and umask < 0):
            self._posix_spawn(args, executable, env, restore_signals, close_fds,
                              p2cread, p2cwrite,
                              c2pread, c2pwrite,
                              errread, errwrite)
            return
    
        orig_executable = executable
    
        # For transferring possible exec failure from child to parent.
        # Data format: "exception name:hex errno:description"
        # Pickle is not used; it is complex and involves memory allocation.
        errpipe_read, errpipe_write = os.pipe()
        # errpipe_write must not be in the standard io 0, 1, or 2 fd range.
        low_fds_to_close = []
        while errpipe_write < 3:
            low_fds_to_close.append(errpipe_write)
            errpipe_write = os.dup(errpipe_write)
        for low_fd in low_fds_to_close:
            os.close(low_fd)
        try:
            try:
                # We must avoid complex work that could involve
                # malloc or free in the child process to avoid
                # potential deadlocks, thus we do all this here.
                # and pass it to fork_exec()
    
                if env is not None:
                    env_list = []
                    for k, v in env.items():
                        k = os.fsencode(k)
                        if b'=' in k:
                            raise ValueError("illegal environment variable name")
                        env_list.append(k + b'=' + os.fsencode(v))
                else:
                    env_list = None  # Use execv instead of execve.
                executable = os.fsencode(executable)
                if os.path.dirname(executable):
                    executable_list = (executable,)
                else:
                    # This matches the behavior of os._execvpe().
                    executable_list = tuple(
                        os.path.join(os.fsencode(dir), executable)
                        for dir in os.get_exec_path(env))
                fds_to_keep = set(pass_fds)
                fds_to_keep.add(errpipe_write)
                self.pid = _fork_exec(
                        args, executable_list,
                        close_fds, tuple(sorted(map(int, fds_to_keep))),
                        cwd, env_list,
                        p2cread, p2cwrite, c2pread, c2pwrite,
                        errread, errwrite,
                        errpipe_read, errpipe_write,
                        restore_signals, start_new_session,
                        process_group, gid, gids, uid, umask,
                        preexec_fn)
                self._child_created = True
            finally:
                # be sure the FD is closed no matter what
                os.close(errpipe_write)
    
            self._close_pipe_fds(p2cread, p2cwrite,
                                 c2pread, c2pwrite,
                                 errread, errwrite)
    
            # Wait for exec to fail or succeed; possibly raising an
            # exception (limited in size)
            errpipe_data = bytearray()
            while True:
                part = os.read(errpipe_read, 50000)
                errpipe_data += part
                if not part or len(errpipe_data) > 50000:
                    break
        finally:
            # be sure the FD is closed no matter what
            os.close(errpipe_read)
    
        if errpipe_data:
            try:
                pid, sts = os.waitpid(self.pid, 0)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
                else:
                    self.returncode = sys.maxsize
            except ChildProcessError:
                pass
    
            try:
                exception_name, hex_errno, err_msg = (
                        errpipe_data.split(b':', 2))
                # The encoding here should match the encoding
                # written in by the subprocess implementations
                # like _posixsubprocess
                err_msg = err_msg.decode()
            except ValueError:
                exception_name = b'SubprocessError'
                hex_errno = b'0'
                err_msg = 'Bad exception data from child: {!r}'.format(
                              bytes(errpipe_data))
            child_exception_type = getattr(
                    builtins, exception_name.decode('ascii'),
                    SubprocessError)
            if issubclass(child_exception_type, OSError) and hex_errno:
                errno_num = int(hex_errno, 16)
                if err_msg == "noexec:chdir":
                    err_msg = ""
                    # The error must be from chdir(cwd).
                    err_filename = cwd
                elif err_msg == "noexec":
                    err_msg = ""
                    err_filename = None
                else:
                    err_filename = orig_executable
                if errno_num != 0:
                    err_msg = os.strerror(errno_num)
                if err_filename is not None:
>                   raise child_exception_type(errno_num, err_msg, err_filename)
E                   FileNotFoundError: [Errno 2] No such file or directory: 'timeout'

.../homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/subprocess.py:1990: FileNotFoundError
tests.containers.test_image_build::test_oci_registry_tls_ingest_and_https_serving
Stack Traces | 0.272s run time
tmp_path = PosixPath('.../pytest-of-runner/pytest-1/test_oci_registry_tls_ingest_a0')
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10b7acf50>

    def test_oci_registry_tls_ingest_and_https_serving(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.delenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", raising=False)
        monkeypatch.delenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", raising=False)
        assert reg_mod.default_cache_dir().name == "inspect-oci-builds"
        assert reg_mod.configured_registry_port() == reg_mod.DEFAULT_REGISTRY_PORT
    
        monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", str(tmp_path / "cache with space"))
        monkeypatch.setenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", "0")
        assert reg_mod.default_cache_dir() == (tmp_path / "cache with space").resolve()
        assert reg_mod.configured_registry_port() == 0
    
        real_which = reg_mod.shutil.which
        monkeypatch.setattr(reg_mod.shutil, "which", lambda _: None)
        with pytest.raises(RuntimeError, match="openssl is required"):
            reg_mod.ensure_localhost_tls(tmp_path / "no_openssl")
        monkeypatch.setattr(reg_mod.shutil, "which", real_which)
    
        cert_p, key_p, pem = reg_mod.ensure_localhost_tls(reg_mod.default_cache_dir())
        assert "BEGIN CERTIFICATE" in pem and cert_p.is_file() and key_p.is_file()
        assert reg_mod.ensure_localhost_tls(reg_mod.default_cache_dir())[2] == pem
    
        tar1, tar_flat, tar_legacy = (
            tmp_path / "oci1.tar",
            tmp_path / "flat.tar",
            tmp_path / "leg.tar",
        )
        m_hex = _make_oci_tar(tar1, nested_index=True)
        _make_oci_tar(tar_flat)
        _make_oci_tar(tar_legacy, legacy_only=True)
        assert reg_mod.ingest_docker_save_tar(tar1) == m_hex
        assert reg_mod.ingest_docker_save_tar(tar1) == m_hex
        assert reg_mod.ingest_docker_save_tar(tar_flat) == m_hex
        assert len(reg_mod.ingest_docker_save_tar(tar_legacy)) == 64
    
        blobs_dir = reg_mod.default_cache_dir() / "blobs" / "sha256"
        assert reg_mod._resolve_tar_member(f"blobs/sha256/{m_hex}", {}, blobs_dir)[0] == m_hex
    
        empty_tar = tmp_path / "empty.tar"
        with tarfile.open(empty_tar, "w"):
            pass
        with pytest.raises(RuntimeError, match="Unsupported docker save archive"):
            reg_mod.ingest_docker_save_tar(empty_tar)
        with pytest.raises(RuntimeError, match="Missing referenced archive member"):
            reg_mod._resolve_tar_member("missing.tar", {}, tmp_path)
    
        port, ca_pem = reg_mod.ensure_registry_server(port=0)
        assert port > 0 and ca_pem == pem and reg_mod.ensure_registry_server(port=0) == (port, ca_pem)
>       assert reg_mod._probe_existing_registry(port, ca_pem) is True
E       AssertionError: assert False is True
E        +  where False = <function _probe_existing_registry at 0x10b556400>(51142, '-----BEGIN CERTIFICATE-----\nMIIDOjCCAiKgAwIBAgIUdR3lDXdXtZH3jvHU1NYgX+cGWpAwDQYJKoZIhvcNAQEL\nBQAwHDEaMBgGA1UEAwwRaW...v\nMw8tfkEBHraqy0MWVbo1uyJJVh7MvT8R4lBjKQRu9jGbXscJV03jNMovO33gxs/Y\n8pkvawsXvArNbEZR1U8=\n-----END CERTIFICATE-----\n')
E        +    where <function _probe_existing_registry at 0x10b556400> = reg_mod._probe_existing_registry

tests/containers/test_image_build.py:117: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

ebursztein added a commit that referenced this pull request Oct 7, 2026
Link assigned issue #342 and source PRs #338, #339 and #340. Keep completed parser components available while removing Inspect VM/workload and Dockerfile/Compose phases from this agent sprint. Shared 0.7 includes all carried code and latest main; complete integration and runtime/package qualification remain Pierre’s work.
tholop added a commit that referenced this pull request Oct 8, 2026
Add OCI container execution mode (`execution_mode="container"` / `execution_mode="auto"`, `image=...`, and `compose.yaml` / `docker-compose.yml` config parsing) on top of Capsem VM sandboxes:

- `inspect_capsem/containers/compose.py` + `compose_fields.py` + `compose_inputs.py` + `compose_interpolation.py` + `compose_service.py` + `compose_values.py`: single bounded Compose parser built on `ComposeInputs`, `ComposeLimits` / `_BoundedSafeLoader`, and `InterpolationBudget` with redacted diagnostics, default-deny host environment interpolation (`SAMPLE_METADATA_*` allowlist with `.env` spoofing rejection), support for `image`, `command`/`entrypoint`, `environment`, `working_dir`, `user`, `volumes` (read-write and `:ro` bind mounts with symlink and project-root containment), `healthcheck`, `cpus`/`mem_limit`, and fail-closed validation on unsupported service keys, network isolation overrides, and multi-service topologies.
- `inspect_capsem/containers/runtime.py` + `controller.py`: OCI container staging (`prepare_oci_workload_container`, `_stage_oci_bind_volumes`) and `ContainerController` protocol.
- `inspect_capsem/_compose.py`, `_controller.py`, `_exec.py`, `_files.py`, `_lifecycle.py`, `config.py`, `sandbox.py`: thread container execution mode, non-root `user` execution (`su -m` / `setpriv`), `/workspace` staging, and `SdkCapsemController` `registry_ca_pem` / `Registry(ca_pem=...)` plumbing (moved from PR #340 into PR #339 so #339's hermetic loopback TLS OCI workload fixture authenticates without #340 while `CapsemSandboxConfig` continues to reject `registry_ca_pem` in untrusted task configs).
- Unit tests (`tests/containers/*`, `tests/test_config.py`, `tests/test_sandbox*.py`) and hermetic loopback TLS OCI workload acceptance (`tests/oci_workload_fixture.py`, `tests/live_acceptance.py`).

Proves #310 / #311 / #342 acceptance criteria:
- [x] Compose config coercion (`compose.yaml` / `docker-compose.yml`) and `CapsemSandboxConfig(image=...)` select `execution_mode="container"` and route `exec` to `ExecTarget.WORKLOAD`.
- [x] Hermetic OCI workload acceptance (`tests/oci_workload_fixture.py` + `tests/live_acceptance.py`) builds a digest-pinned OCI image from the guest initrd busybox, serves it over loopback TLS with a per-run CA passed via `SdkCapsemController(registry_ca_pem=...)`, admits its digest in `settings.toml`, runs `sample_init`, `exec` (`ExecTarget.WORKLOAD`), `write_file`/`read_file` (text and binary), and `eval_async` with `SandboxEnvironmentSpec("capsem", ...)` without pulling from Docker Hub, and verifies `history(layer=EXEC)` + `session.db` `exec_events` (`target="workload"`) and zero leaked VMs after `sample_cleanup`.
@tholop
tholop force-pushed the feat/inspect-capsem-containers branch from 99a088b to 3cb33cd Compare October 8, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants