From 714902d2adccf097a205e3e5adc983eccc164ad6 Mon Sep 17 00:00:00 2001 From: Lingrui Mei Date: Thu, 23 Jul 2026 03:11:31 +0800 Subject: [PATCH] provider: add CAPE SandboxProvider backend (assumed CLI contract) Adds agentix-provider-cape: a SandboxProvider that maps one Agentix sandbox onto one long-lived request inside a dedicated CAPE session (CAPE is a lease-based GPU pool whose sessions survive across commands with a persistent workspace). The agent sandbox *is* a CAPE session: persistent workspace, on-demand GPUs. Design: - CapeProviderConfig is fully defaulted (zero-arg construction for the plugin registry); credentials come from a token file or env var, read per operation, with a 0o077 file-mode gate and redaction of the token from any subprocess output that can reach exception text. - _CapeCli is the single class holding the assumed run/status/logs/ cancel verb surface. The contract was reverse-documented from a sibling project's adapter of the same CLI and has never been checked against a real cape binary; the README carries the verification checklist for when the real CLI arrives. - Endpoint discovery is serial-session friendly: the boot script prints an AGENTIX_ENDPOINT marker to stdout before exec'ing the bundle bootstrap, and discovery polls cape logs on the one server request (no concurrent in-session commands), then health-probes with a raw TCP client. Transient status/logs failures are tolerated up to a configurable consecutive limit. - Lifecycle discipline: the initial submit is shielded so cancellation can still harvest the request id and cancel it; subprocess timeout and cancellation paths kill and reap with bounded waits; delete() retains bookkeeping until a cancel is confirmed (retryable) and never raises; per-sandbox runtime ports come from a configurable range. - Deliberately out of scope: BundleDeployer / bundle transport to CAPE nodes (SandboxConfig.bundle is an opaque node-visible path), and any recovery of in-process bookkeeping after a crash (documented, with a session-key naming convention as the server-side cleanup handle). 24 plugin tests drive a fake cape executable (argv recorded as JSONL, fault injection via env vars) plus a loopback /health server: run-face assertions, multi-sandbox classification, discovery retry/timeout, health timeout, token hygiene, delete retry semantics, and real sh -c execution of the boot script round-tripped through the endpoint parser. Co-authored-by: Claude Fable 5 --- ROADMAP.md | 7 +- docs/providers.mdx | 34 + plugins/providers/cape/README.md | 149 +++ .../providers/cape/agentix/provider/cape.py | 847 ++++++++++++++++++ plugins/providers/cape/pyproject.toml | 37 + .../cape/tests/test_cape_provider.py | 670 ++++++++++++++ pyproject.toml | 2 + uv.lock | 12 + 8 files changed, 1756 insertions(+), 2 deletions(-) create mode 100644 plugins/providers/cape/README.md create mode 100644 plugins/providers/cape/agentix/provider/cape.py create mode 100644 plugins/providers/cape/pyproject.toml create mode 100644 plugins/providers/cape/tests/test_cape_provider.py diff --git a/ROADMAP.md b/ROADMAP.md index b170c43..3ea9d01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,8 +109,11 @@ with Agentix HEAD while the design is still moving quickly. [`-daytona`](plugins/providers/daytona) / [`-e2b`](plugins/providers/e2b) / [`-apptainer`](plugins/providers/apptainer) / - [`-uv`](plugins/providers/uv) — sandbox backends (the uv backend - materializes the runtime from a local uv venv, with no container). + [`-uv`](plugins/providers/uv) / + [`-cape`](plugins/providers/cape) — sandbox backends (the uv backend + materializes the runtime from a local uv venv, with no container; the + cape backend drives a lease-based GPU pool through an assumed CLI + contract pending verification against the real `cape` CLI). - [`agentix-runner`](plugins/runner) — `run_rollouts(...)` batch orchestration. - [`agentix-dataset-swe`](plugins/datasets/swebench) — SWE-bench task diff --git a/docs/providers.mdx b/docs/providers.mdx index ce0c9a6..fff62c4 100644 --- a/docs/providers.mdx +++ b/docs/providers.mdx @@ -141,6 +141,39 @@ selects the container runtime, network, GPU args, and so on. Backend-neutral settings (`image`, `bundle`, `env`, `resource`) belong in `SandboxConfig`, not the backend. +## CAPE + +The `cape` backend targets a lease-based GPU capacity pool driven by a +submitter-side `cape` CLI. One sandbox maps onto exactly one long-lived +CAPE request inside a per-sandbox session: the request bind-mounts the +bundle's `/nix` tree read-only, prints an `AGENTIX_ENDPOINT` marker to +stdout, and execs `/nix/runtime/bootstrap.sh`; the provider discovers +the endpoint by polling `cape status` / `cape logs` for that marker and +health-checks it. No second command is ever submitted into the session. + +```python +from agentix import SandboxConfig +from agentix.provider.cape import CapeProvider, CapeProviderConfig + +provider = CapeProvider( + CapeProviderConfig( + controller_url="https://cape-controller.example:8443", + pool="coding-agent-gpu", + token_file="~/.config/cape/token", + ) +) +``` + +For this backend `SandboxConfig.bundle` is an opaque node-visible path +to an already-extracted bundle tree — there is no `agentix deploy cape` +yet, because bundle transport to CAPE nodes is still an open question. + +**Contract status:** the CAPE CLI surface this backend emits is an +*assumed* contract, reverse-documented from a sibling project's adapter +and only ever exercised against fakes and emulators. Verify it against +the real `cape` CLI before production use — see the checklist in +[`plugins/providers/cape/README.md`](https://github.com/Agentix-Project/Agentix/tree/master/plugins/providers/cape). + ## Configuration | Variable | Used by | Purpose | @@ -148,6 +181,7 @@ in `SandboxConfig`, not the backend. | `AGENTIX_BIND_PORT` | runtime server | Sandbox-side bind port, default `8000` | | `DAYTONA_API_KEY` | `daytona` backend | API authentication | | `E2B_API_KEY` / `E2B_TEMPLATE_ID` | `e2b` backend | API authentication and template selection | +| `CAPE_CONTROLLER_URL` / `CAPE_TOKEN` / `CAPE_TOKEN_FILE` / `CAPE_BINARY` | `cape` backend | Controller endpoint, authentication, and CLI selection | The `daytona` and `e2b` backends are placeholders today: they validate configuration but their lifecycle methods raise `NotImplementedError` diff --git a/plugins/providers/cape/README.md b/plugins/providers/cape/README.md new file mode 100644 index 0000000..46dceb9 --- /dev/null +++ b/plugins/providers/cape/README.md @@ -0,0 +1,149 @@ +# agentix-provider-cape + +CAPE provider backend for +[Agentix](https://github.com/Agentix-Project/Agentix). + +CAPE is a lease-based GPU capacity pool: a submitter-side `cape` CLI +sends workload requests to a controller, which schedules them onto pool +nodes with strong per-request isolation. Requests sharing a +`--session-key` land in the same warm session (RUNNING_COMMAND ↔ +CACHED_IDLE — a *serial* model, one command at a time per session). +This backend maps one Agentix sandbox onto exactly one long-lived CAPE +request inside a per-sandbox session: the request bind-mounts the +bundle's `/nix` tree read-only, prints an `AGENTIX_ENDPOINT +` marker line to stdout, and execs `/nix/runtime/bootstrap.sh`. +The provider discovers the endpoint by polling `cape status` and +`cape logs` for that marker (it never submits a second command into the +session, so it does not depend on same-session concurrency or on +cross-request workspace persistence), then health-checks `GET /health` +on the discovered endpoint. + +## Contract status — ASSUMED CLI, not verified + +**The CAPE CLI verb surface implemented here +(`cape run/status/logs/cancel`, their flags, the request-id stdout +shape, and the `cape status` JSON schema) is an *assumed* contract.** +It was reverse-documented from a sibling project's adapter of the +same CLI, whose authors record that the official CAPE CLI was never +obtained: the contract has only ever been exercised +against fakes and local emulators. Treat every invocation in +`agentix/provider/cape.py` (`_CapeCli` is the single class that knows +the CLI) as a hypothesis to confirm. + +Verification checklist for when the real CLI arrives: + +- [ ] Run `cape --help` (and per-verb `--help`) and diff the verb set + against `run` / `status` / `logs` / `cancel`. +- [ ] Compare the `cape run` argv table (`--controller-url`, `--token`, + `--pool`, `--user`, `--workspace`, `--image`, `--gpus`, + `--cpu-cores`, `--memory-gb`, `--gpu-mode`, `--isolation-policy`, + `--runtime-adapter`, `--max-duration-seconds`, `--cwd`, + `--session-key`, `--bind`, `--env`, `-- `) flag by + flag, including the "stdout is exactly one `req-...` line" parse + rule. +- [ ] Compare the `cape status` JSON schema: the `state` field, the + terminal-state set (COMPLETED / FAILED / CANCELLED / EXPIRED / + LOST / INFEASIBLE), and the `request_id` echo this provider + cross-checks. +- [ ] Confirm `cape logs` can return the stdout of a still-RUNNING + request — endpoint discovery depends on it. This assumption is + *new in this provider* (the reference adapter only called logs on + terminal requests). +- [ ] Confirm the session model is serial (RUNNING_COMMAND ↔ + CACHED_IDLE). This provider deliberately submits only one request + per session, so it works either way — but tooling built on top + must not assume same-session concurrency. +- [ ] Confirm how the token is passed — `--token` on argv is visible in + process listings; prefer an env var or token-file option if the + real CLI supports one. +- [ ] Confirm how a workload's node/port can be discovered — the + stdout-marker dance here exists only because the assumed contract + has no native endpoint query. + +## Install + +```bash +pip install agentix-provider-cape +``` + +Set `CAPE_CONTROLLER_URL` and a token (`CAPE_TOKEN_FILE` pointing at a +`chmod 600` file, or the `CAPE_TOKEN` env var). `CAPE_BINARY` overrides +which `cape` binary is invoked. The token is re-read on every operation, +so rotation needs no restart, and its literal value is redacted from +any error output. + +## Use + +```python +from agentix import SandboxConfig +from agentix.provider.cape import CapeProvider, CapeProviderConfig + +provider = CapeProvider( + CapeProviderConfig( + controller_url="https://cape-controller.example:8443", + pool="coding-agent-gpu", + token_file="~/.config/cape/token", + ) +) +config = SandboxConfig( + image="docker://nvcr.io/org/sandbox-runtime@sha256:...", + bundle="/mnt/shared/agentix-bundles/sha256-.../", # node-visible path + resource={"gpu": 1}, +) + +async with provider.session(config, call_deadline=1800) as sandbox: + result = await sandbox.remote(run, input="hello") +``` + +Backend-specific notes: + +* **`SandboxConfig.bundle` is an opaque node-visible path** to an + already-extracted bundle tree (its `nix/`... contents are bind-mounted + read-only at `/nix`). How the bundle tree gets onto CAPE nodes — + shared filesystem, prior upload, a CAPE-side template — is an **open + question**; this iteration deliberately ships no `BundleDeployer` / + `agentix deploy cape` until bundle transport is decided. +* **`url_template`** controls how the runtime URL is built from the + discovered `host`/`port`. The default `http://{host}:{port}` assumes + the submitter can route to pool nodes directly; behind an SSH tunnel, + set `url_template="http://127.0.0.1:{port}"` and forward the port + yourself. +* Health probing uses a raw TCP socket with a minimal HTTP request — + never a proxy-aware HTTP client — so corp-proxy env vars cannot + poison loopback/tunnel probes. +* `providers().get("cape")` resolves after `uv sync` / `pip install`. + +## Operational notes and known limitations + +* **Residual submit window.** `create()` shields the initial `cape run` + so an external cancellation still harvests the request id and cancels + the request. But if the submitter process is killed (or the CLI dies) + in the instant after the controller accepted the request and before + its id was printed, that request cannot be cancelled from this side — + it runs until `--max-duration-seconds`. Session keys are always + prefixed `agentix-cape-...`, so server-side/admin tooling can find and + reclaim orphaned Agentix sessions by that naming convention. +* **Bookkeeping is in-process only.** The `sandbox_id → request_id` map + lives in the provider instance; if the submitter process crashes + after `create()`, a fresh provider cannot see or delete the old + sandbox (`delete()` of an unknown id is a silent no-op). Recovery is + server-side cleanup by the `agentix-cape-...` session-key convention, + or waiting out `--max-duration-seconds`. The assumed CLI has no + list/query-by-session verb to rebuild the map from. +* **Delete retries instead of leaking.** `delete()` drops bookkeeping + only after the controller confirms the cancel (`cape cancel` rc=0); a + failed cancel logs a warning and keeps the record so calling + `delete()` again retries it. +* **Runtime ports.** Each live sandbox of one provider instance gets a + distinct `AGENTIX_BIND_PORT` from + `[runtime_port_base, runtime_port_base + runtime_port_span)`, so + sandboxes of the same provider can never answer each other's health + probes (same node or same SSH tunnel). Port collisions with *other* + submitters or users on the same node cannot be reserved from this + side — that is a known limitation of the assumed contract (no + controller-side port brokering). + +## License + +MIT — see the repository root +[LICENSE](https://github.com/Agentix-Project/Agentix/blob/master/LICENSE). diff --git a/plugins/providers/cape/agentix/provider/cape.py b/plugins/providers/cape/agentix/provider/cape.py new file mode 100644 index 0000000..f3e7740 --- /dev/null +++ b/plugins/providers/cape/agentix/provider/cape.py @@ -0,0 +1,847 @@ +"""CAPE provider: sandbox CRUD via the `cape` CLI on a lease-based GPU pool. + +CAPE is a lease-based GPU capacity pool: a submitter-side `cape` CLI +sends workload requests to a controller, which schedules them onto +pool nodes with strong per-request isolation. Requests that share a +`--session-key` land in the same warm session — the session alternates +between RUNNING_COMMAND and CACHED_IDLE (a *serial* model: one command +at a time per session), so a session's writable state survives across +commands until the session is reclaimed. + +Mapping to Agentix: one sandbox is exactly one long-lived CAPE request +inside a per-sandbox session (`agentix-cape-...` session keys). The +provider never submits a second request into the same session, so it +does not depend on same-session concurrency or on cross-request +workspace persistence. + + - `create()` submits a runtime request whose workload prints an + `AGENTIX_ENDPOINT ` marker line to stdout (first IP of + `hostname -i`, plus the assigned bind port) and then execs the + bundle's `/nix/runtime/bootstrap.sh`. The provider polls + `cape status` (to detect early death) and `cape logs` (to find the + marker), then health-checks `GET /health` on the discovered + endpoint with a raw TCP probe (never an env-proxy-aware HTTP + client, which would hang behind a corp proxy or SSH tunnel). + - `delete()` cancels the runtime request; bookkeeping is dropped only + after the controller confirms the cancel, so a failed cancel can be + retried with another `delete()`. + - `config.bundle` for this backend is an OPAQUE node-visible path to + an already-extracted bundle tree; it is bind-mounted read-only at + `/nix`. How the bundle tree gets onto CAPE nodes (shared FS, prior + upload) is deliberately out of scope — there is no `BundleDeployer` + in this iteration. + +ASSUMED CLI CONTRACT — the `cape run/status/logs/cancel` verb surface +implemented in `_CapeCli` was reverse-documented from a sibling +project's adapter of the same CLI and has only ever been +exercised against fakes and emulators; the official CAPE CLI has never +been obtained. Endpoint discovery additionally assumes `cape logs` can +return the stdout of a still-RUNNING request. Verify every verb, flag, +and the `cape status` JSON schema against the real CLI before +production use (see the provider README's "Contract status" checklist). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import math +import os +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from agentix.provider.base import ( + Sandbox, + SandboxConfig, + SandboxId, + SandboxInfo, + SandboxProvider, + SandboxResource, +) +from agentix.runtime import BIND_PORT_ENV, BUNDLE_NIX_ROOT, BUNDLE_RUNTIME_ENTRYPOINT + +logger = logging.getLogger("agentix.provider.cape") + +_REQUEST_ID_RE = re.compile(r"req-[A-Za-z0-9][A-Za-z0-9_.-]*") +"""Shape of the request id `cape run` prints on stdout (assumed contract).""" + +_TERMINAL_STATES = frozenset({"COMPLETED", "FAILED", "CANCELLED", "EXPIRED", "LOST", "INFEASIBLE"}) +"""Terminal request states in the `cape status` JSON (assumed contract).""" + +_REASON_UNSAFE_RE = re.compile(r"[^A-Za-z0-9_.-]") +"""Characters stripped from `cape cancel --reason` strings.""" + +_ENDPOINT_MARKER = "AGENTIX_ENDPOINT" +"""Prefix of the stdout marker line the boot script prints before exec.""" + +_MEMORY_RE = re.compile(r"(\d+)\s*([kmgt])?i?b?", re.IGNORECASE) +_MEMORY_UNIT_BYTES = {"k": 1 << 10, "m": 1 << 20, "g": 1 << 30, "t": 1 << 40} + +_REAP_TIMEOUT_SECONDS = 5.0 +"""Bounded wait for a killed `cape` subprocess to be reaped.""" + + +class CapeProviderConfig(BaseModel): + """Submitter-side settings for the CAPE backend. + + Every field is optional or defaulted so `CapeProvider()` constructs + with zero arguments — the plugin registry instantiates providers + with `cls()`. Anything unset falls back to environment variables at + first use. + """ + + binary: str | None = Field( + default=None, + description="`cape` CLI to invoke. Resolution order: this field, then the " + "`CAPE_BINARY` env var, then bare `cape` on PATH. A value containing a " + "path separator must point at an existing executable file.", + ) + controller_url: str | None = Field( + default=None, + description="CAPE controller URL. Falls back to `CAPE_CONTROLLER_URL`; " + "required at first use.", + ) + token_file: str | None = Field( + default=None, + description="File holding the CAPE token (read per operation, so rotation " + "works). Falls back to `CAPE_TOKEN_FILE`. The file must not be " + "group/other accessible (mode & 0o077 == 0).", + ) + token_env: str = Field( + default="CAPE_TOKEN", + description="Env var consulted for the token when no token file is configured.", + ) + pool: str | None = Field(default=None, description="Optional CAPE pool name.") + user: str | None = Field(default=None, description="Optional CAPE user identity.") + workspace_root: str = Field( + default="/workspace", + description="Node-side base directory; each sandbox uses " + "`/` as its session workspace and cwd.", + ) + cpu_cores: int | None = Field( + default=None, + description="Default `--cpu-cores` when `SandboxConfig.resource.cpu` is unset.", + ) + memory_gb: int | None = Field( + default=None, + description="Default `--memory-gb` when `SandboxConfig.resource.memory` is unset.", + ) + max_duration_seconds: int = Field( + default=14400, + description="`--max-duration-seconds` for the long-lived runtime request.", + ) + isolation_policy: str = Field(default="default", description="`--isolation-policy` value.") + runtime_adapter: str | None = Field( + default=None, + description="Optional `--runtime-adapter` (e.g. `apptainer`).", + ) + extra_binds: list[str] = Field( + default_factory=list, + description="Raw `src:dst[:ro|rw]` bind specs passed through in addition to " + "the bundle's `/nix` bind.", + ) + runtime_port_base: int = Field( + default=8710, + description="First in-sandbox runtime port (`AGENTIX_BIND_PORT`). Each live " + "sandbox of this provider gets a distinct port from " + "`[runtime_port_base, runtime_port_base + runtime_port_span)` so " + "same-node / same-tunnel sandboxes cannot answer each other's probes. " + "Collisions with other submitters on the same node cannot be reserved " + "from this side — see the README's known limitations.", + ) + runtime_port_span: int = Field( + default=200, + description="Size of the per-provider runtime port range; bounds the number " + "of concurrently live sandboxes per provider instance.", + ) + url_template: str = Field( + default="http://{host}:{port}", + description="How to build `runtime_url` from the discovered endpoint. Users " + "reaching CAPE nodes through an SSH tunnel can set e.g. " + "`http://127.0.0.1:{port}`.", + ) + client_timeout_seconds: float = Field( + default=60.0, + description="Per-`cape`-subprocess timeout.", + ) + create_timeout_seconds: float = Field( + default=600.0, + description="Total budget for endpoint discovery plus the health probe " + "(lease queuing can be slow).", + ) + poll_interval_seconds: float = Field( + default=2.0, + description="Sleep between endpoint-discovery attempts.", + ) + transient_failure_limit: int = Field( + default=5, + description="Consecutive `cape status`/`cape logs` failures tolerated during " + "endpoint discovery before the create is failed (a single controller " + "blip must not cancel a lease that already queued onto a GPU).", + ) + + +def _resolve_binary(config: CapeProviderConfig) -> str: + """Resolve the `cape` binary: config field > `CAPE_BINARY` env > PATH. + + Synchronous file IO — call via `asyncio.to_thread` from async code. + """ + value = config.binary or os.environ.get("CAPE_BINARY") or "cape" + has_separator = os.sep in value or (os.altsep is not None and os.altsep in value) + if has_separator: + path = Path(value).expanduser() + if not path.is_file() or not os.access(path, os.X_OK): + raise RuntimeError( + f"cape binary {value!r} (from CapeProviderConfig.binary or CAPE_BINARY) " + f"must be an existing executable file" + ) + return str(path) + return value + + +def _resolve_controller_url(config: CapeProviderConfig) -> str: + url = config.controller_url or os.environ.get("CAPE_CONTROLLER_URL") + if not url: + raise RuntimeError( + "CAPE controller URL is not configured: set CapeProviderConfig.controller_url " + "or the CAPE_CONTROLLER_URL environment variable" + ) + return url + + +def _read_token(config: CapeProviderConfig) -> str: + """Read the CAPE token, fresh on every operation (supports rotation). + + The token file (config or `CAPE_TOKEN_FILE`) wins over the token env + var. Empty and whitespace-containing tokens are rejected with an + error naming the offending source. The literal token value must + never reach logs or exception text — see `_redact`. + + Synchronous file IO — call via `asyncio.to_thread` from async code + (the token file often lives on NFS, which must not block the loop). + """ + token_file = config.token_file or os.environ.get("CAPE_TOKEN_FILE") + if token_file: + path = Path(token_file).expanduser() + try: + mode = path.stat().st_mode + except OSError as exc: + raise RuntimeError(f"CAPE token file {path} is not readable: {exc}") from exc + if mode & 0o077 != 0: + raise RuntimeError( + f"CAPE token file {path} must not be group/other accessible; run `chmod 600` on it" + ) + token = path.read_text(encoding="utf-8").strip() + source = f"CAPE token file {path}" + else: + token = os.environ.get(config.token_env, "") + source = f"CAPE token env var {config.token_env}" + if not token: + raise RuntimeError(f"{source} is empty; provide a CAPE token") + if any(ch.isspace() for ch in token): + raise RuntimeError(f"{source} contains whitespace; refusing to use it") + return token + + +def _redact(text: str, token: str) -> str: + """Replace the literal token value with `` in `text`.""" + if token and token in text: + return text.replace(token, "") + return text + + +def _consume_task_result(task: asyncio.Future[Any]) -> None: + """Done-callback that retrieves a detached task's exception so it is + never reported as `Task exception was never retrieved`.""" + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.debug("detached cape task failed: %r", exc) + + +def _cpu_cores(resource: SandboxResource | None, config: CapeProviderConfig) -> int | None: + if resource is not None and resource.cpu is not None: + # CAPE cores are whole integers; round a fractional request up so + # the granted capacity always covers what was asked for. + return max(1, math.ceil(resource.cpu)) + return config.cpu_cores + + +def _memory_gb(resource: SandboxResource | None, config: CapeProviderConfig) -> int | None: + if resource is None or resource.memory is None: + return config.memory_gb + memory = resource.memory + if isinstance(memory, int): + num_bytes = memory + else: + match = _MEMORY_RE.fullmatch(memory.strip()) + if match is None: + raise RuntimeError( + f"cannot map memory {memory!r} to --memory-gb; use container CLI " + f"unit syntax, e.g. `16g`" + ) + unit = (match.group(2) or "").lower() + num_bytes = int(match.group(1)) * (_MEMORY_UNIT_BYTES[unit] if unit else 1) + return max(1, math.ceil(num_bytes / (1 << 30))) + + +def _boot_script() -> str: + """Workload for the long-lived runtime request. + + Prints the `AGENTIX_ENDPOINT ` marker to stdout (first + IP of `hostname -i`, plus the assigned bind port) and execs the + bundle's bootstrap entry point. The marker is read back host-side + via `cape logs` during endpoint discovery — nothing is written to + the workspace, so discovery does not depend on cross-request + workspace persistence or on a second same-session command. + """ + return ( + f'printf "{_ENDPOINT_MARKER} %s %s\\n" "$(hostname -i | cut -d" " -f1)" ' + f'"${{{BIND_PORT_ENV}}}" && ' + f"exec {BUNDLE_RUNTIME_ENTRYPOINT}" + ) + + +def _parse_endpoint(text: str) -> tuple[str, int] | None: + """Find the `AGENTIX_ENDPOINT ` marker line in workload stdout. + + The explicit marker prefix keeps unrelated two-token output (banner + lines like `GPU 0`) from being misread as an endpoint. + """ + for line in text.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[0] == _ENDPOINT_MARKER and parts[2].isdigit(): + return parts[1], int(parts[2]) + return None + + +class _CapeCli: + """The ONE place that knows the assumed `cape` CLI verb surface. + + Every method's contract (verbs, flags, stdout/JSON shapes, terminal + states) is an assumption reverse-documented from a sibling + project's adapter — it has never been checked against a real + `cape` binary. + Keep all CLI knowledge in this class so a contract correction after + real-CLI verification is a single-class change. + """ + + def __init__(self, config: CapeProviderConfig) -> None: + self._config = config + + def _common(self, token: str) -> list[str]: + return ["--controller-url", _resolve_controller_url(self._config), "--token", token] + + async def _exec(self, argv: Sequence[str], *, token: str, verb: str) -> tuple[int, str, str]: + """Run one `cape` subprocess; stdout/stderr come back token-redacted. + + The subprocess is never abandoned: local timeout and external + cancellation both kill it and reap it within a bounded window + (a leaked `cape run` could otherwise still submit work). + """ + binary = await asyncio.to_thread(_resolve_binary, self._config) + try: + proc = await asyncio.create_subprocess_exec( + binary, + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"cape binary {binary!r} not found; set CapeProviderConfig.binary or " + f"CAPE_BINARY, or install `cape` on PATH" + ) from exc + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=self._config.client_timeout_seconds + ) + except asyncio.CancelledError: + proc.kill() + # Bounded reap via `proc.wait()` (not `communicate()`): a killed + # child cannot block `wait()` on a full pipe, and a grandchild + # holding the pipe write-end open cannot stall us past the bound. + with contextlib.suppress(Exception): + await asyncio.wait_for(proc.wait(), timeout=_REAP_TIMEOUT_SECONDS) + raise + except TimeoutError: + proc.kill() + with contextlib.suppress(Exception): + await asyncio.wait_for(proc.wait(), timeout=_REAP_TIMEOUT_SECONDS) + raise RuntimeError( + f"cape {verb} timed out after {self._config.client_timeout_seconds}s" + ) from None + return ( + proc.returncode or 0, + _redact(stdout.decode(errors="replace"), token), + _redact(stderr.decode(errors="replace"), token), + ) + + async def run( + self, + *, + session_key: str, + workspace: str, + cwd: str, + image: str, + gpus: int, + workload: Sequence[str], + binds: Sequence[str] = (), + env: Mapping[str, str] | None = None, + cpu_cores: int | None = None, + memory_gb: int | None = None, + ) -> str: + """ASSUMED CLI contract — verify against the real `cape` CLI before production use. + + Submits one workload request and returns its request id: + + cape run --controller-url U --token T [--pool P] [--user USR] + --workspace WS --image IMG --gpus N [--cpu-cores N] + [--memory-gb N] --gpu-mode whole --isolation-policy X + [--runtime-adapter Y] --max-duration-seconds T --cwd CWD + --session-key KEY [--bind src:dst[:ro|rw]]... [--env K=V]... + -- + + stdout, after stripping blank lines, must be exactly one line + matching `req-[A-Za-z0-9][A-Za-z0-9_.-]*`. + """ + token = await asyncio.to_thread(_read_token, self._config) + cfg = self._config + argv: list[str] = ["run", *self._common(token)] + if cfg.pool: + argv += ["--pool", cfg.pool] + if cfg.user: + argv += ["--user", cfg.user] + argv += ["--workspace", workspace, "--image", image, "--gpus", str(int(gpus))] + if cpu_cores is not None: + argv += ["--cpu-cores", str(int(cpu_cores))] + if memory_gb is not None: + argv += ["--memory-gb", str(int(memory_gb))] + argv += ["--gpu-mode", "whole", "--isolation-policy", cfg.isolation_policy] + if cfg.runtime_adapter: + argv += ["--runtime-adapter", cfg.runtime_adapter] + argv += ["--max-duration-seconds", str(int(cfg.max_duration_seconds))] + argv += ["--cwd", cwd, "--session-key", session_key] + for bind in binds: + argv += ["--bind", bind] + for key, value in (env or {}).items(): + argv += ["--env", f"{key}={value}"] + argv += ["--", *workload] + rc, stdout, stderr = await self._exec(argv, token=token, verb="run") + if rc != 0: + raise RuntimeError(f"cape run failed (rc={rc}): {stderr}") + lines = [line.strip() for line in stdout.splitlines() if line.strip()] + if len(lines) != 1 or not _REQUEST_ID_RE.fullmatch(lines[0]): + raise RuntimeError( + f"cape run did not print exactly one request id " + f"(stdout={stdout!r}, stderr={stderr})" + ) + return lines[0] + + async def status(self, request_id: str) -> dict[str, object]: + """ASSUMED CLI contract — verify against the real `cape` CLI before production use. + + `cape status --controller-url U --token T` prints one JSON + object with at least a `state` field; terminal states are + COMPLETED / FAILED / CANCELLED / EXPIRED / LOST / INFEASIBLE. A + `request_id` field, when present and non-null, must echo the + queried id — a mismatch means the CLI answered for a different + request and is treated as an infrastructure error, not trusted. + """ + token = await asyncio.to_thread(_read_token, self._config) + rc, stdout, stderr = await self._exec( + ["status", request_id, *self._common(token)], token=token, verb="status" + ) + if rc != 0: + raise RuntimeError(f"cape status {request_id} failed (rc={rc}): {stderr}") + try: + payload = json.loads(stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"cape status {request_id} printed non-JSON output: {stdout!r}" + ) from exc + if not isinstance(payload, dict): + raise RuntimeError( + f"cape status {request_id} printed JSON of type " + f"{type(payload).__name__}, expected an object" + ) + echoed = payload.get("request_id") + if echoed is not None and echoed != request_id: + raise RuntimeError( + f"cape status {request_id} returned status for a different request " + f"({echoed!r}); refusing to trust it" + ) + return payload + + async def cancel(self, request_id: str, reason: str) -> bool: + """ASSUMED CLI contract — verify against the real `cape` CLI before production use. + + `cape cancel --controller-url U --token T --reason R`. + Returns True only when the CLI confirmed the cancel (rc=0); + ordinary failures are swallowed (best-effort) after a redacted + warning. External cancellation is NOT swallowed: the in-flight + cancel RPC gets a bounded shielded window to reach the + controller, then CancelledError is re-raised so callers such as + `asyncio.timeout` / TaskGroup teardown still observe it. + """ + safe_reason = _REASON_UNSAFE_RE.sub("_", reason) + task = asyncio.ensure_future(self._cancel_once(request_id, safe_reason)) + task.add_done_callback(_consume_task_result) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + with contextlib.suppress(BaseException): + await asyncio.wait_for( + asyncio.shield(task), + timeout=self._config.client_timeout_seconds + _REAP_TIMEOUT_SECONDS, + ) + raise + except Exception: + logger.warning( + "cape cancel %s (reason=%s) failed; ignoring", + request_id, + safe_reason, + exc_info=True, + ) + return False + + async def _cancel_once(self, request_id: str, reason: str) -> bool: + token = await asyncio.to_thread(_read_token, self._config) + rc, _stdout, stderr = await self._exec( + ["cancel", request_id, *self._common(token), "--reason", reason], + token=token, + verb="cancel", + ) + if rc != 0: + logger.warning( + "cape cancel %s (reason=%s) exited rc=%d: %s", + request_id, + reason, + rc, + stderr.strip(), + ) + return False + return True + + async def logs(self, request_id: str, *, check: bool = False) -> tuple[str, str]: + """ASSUMED CLI contract — verify against the real `cape` CLI before production use. + + `cape logs --controller-url U --token T` prints the + workload's stdout/stderr. ADDITIONAL ASSUMPTION introduced by + this provider: `cape logs` can return the stdout of a + still-RUNNING request — endpoint discovery depends on it, and + the reference adapter only ever called logs on terminal + requests. Verify this explicitly against the real CLI. + + With `check=False` (default) this is best-effort — failures + collapse to `("", "")` (error-message enrichment only). With + `check=True` a failed invocation raises RuntimeError so endpoint + discovery can tell a broken verb apart from a marker that simply + has not been printed yet. + """ + try: + token = await asyncio.to_thread(_read_token, self._config) + rc, stdout, stderr = await self._exec( + ["logs", request_id, *self._common(token)], token=token, verb="logs" + ) + except Exception: + if check: + raise + return "", "" + if rc != 0: + if check: + raise RuntimeError(f"cape logs {request_id} failed (rc={rc}): {stderr}") + return "", "" + return stdout, stderr + + +@dataclass +class _CapeSandboxRecord: + """Bookkeeping for one live sandbox.""" + + request_id: str + session_key: str + workspace: str + runtime_url: str + runtime_port: int + + +class CapeProvider(SandboxProvider): + """Sandbox CRUD via the `cape` CLI (assumed contract; see module docstring).""" + + def __init__(self, config: CapeProviderConfig | None = None) -> None: + self.config = config or CapeProviderConfig() + self._cli = _CapeCli(self.config) + self._sandboxes: dict[SandboxId, _CapeSandboxRecord] = {} + # Runtime ports handed out to live/in-flight sandboxes, so two + # sandboxes of this provider can never share an AGENTIX_BIND_PORT + # (same-node or same-SSH-tunnel siblings would otherwise answer + # each other's health probes). Released on delete / failed create. + self._inflight_ports: set[int] = set() + + def _allocate_port(self) -> int: + base = self.config.runtime_port_base + for port in range(base, base + self.config.runtime_port_span): + if port not in self._inflight_ports: + self._inflight_ports.add(port) + return port + raise RuntimeError( + f"no free runtime port in [{base}, {base + self.config.runtime_port_span}); " + f"raise runtime_port_span or delete finished sandboxes" + ) + + async def create(self, config: SandboxConfig) -> Sandbox: + sandbox_id = SandboxId(f"cape-{uuid4().hex[:12]}") + session_key = f"agentix-{sandbox_id}" + workspace = f"{self.config.workspace_root.rstrip('/')}/{sandbox_id}" + port = self._allocate_port() + + binds = [f"{config.bundle}:{BUNDLE_NIX_ROOT}:ro", *self.config.extra_binds] + env = {BIND_PORT_ENV: str(port), **(config.env or {})} + resource = config.resource + gpus = resource.gpu if resource is not None and resource.gpu is not None else 0 + + # The submit runs shielded: if we are cancelled mid-`cape run`, the + # CLI still finishes inside its own timeout, the request id is + # harvested within a bounded window, and the request is best-effort + # cancelled. The residual window where the CLI dies after the + # controller accepted the request cannot be closed from this side — + # see the README's operational notes (session keys are greppable: + # `agentix-cape-...`). + run_task = asyncio.ensure_future( + self._cli.run( + session_key=session_key, + workspace=workspace, + cwd=workspace, + image=config.image, + gpus=gpus, + workload=["sh", "-c", _boot_script()], + binds=binds, + env=env, + cpu_cores=_cpu_cores(resource, self.config), + memory_gb=_memory_gb(resource, self.config), + ) + ) + run_task.add_done_callback(_consume_task_result) + try: + server_req = await asyncio.shield(run_task) + except asyncio.CancelledError: + self._inflight_ports.discard(port) + harvested: str | None = None + with contextlib.suppress(BaseException): + harvested = await asyncio.wait_for( + asyncio.shield(run_task), + timeout=self.config.client_timeout_seconds + _REAP_TIMEOUT_SECONDS, + ) + if harvested is not None: + await self._cli.cancel(harvested, "agentix_create_cancelled") + raise + except BaseException: + self._inflight_ports.discard(port) + raise + logger.info("CAPE runtime request %s submitted for sandbox %s", server_req, sandbox_id) + + # The runtime request has started: from here every failure or + # cancellation must best-effort cancel it, drop bookkeeping, and + # re-raise — `session()` cannot clean up a sandbox it never got. + try: + loop = asyncio.get_running_loop() + deadline = loop.time() + self.config.create_timeout_seconds + host, marker_port = await self._discover_endpoint(server_req, deadline=deadline) + runtime_url = self.config.url_template.format(host=host, port=marker_port) + await self._wait_healthy(server_req, runtime_url, deadline) + except BaseException: + self._sandboxes.pop(sandbox_id, None) + self._inflight_ports.discard(port) + await self._cli.cancel(server_req, "agentix_create_failed") + raise + + self._sandboxes[sandbox_id] = _CapeSandboxRecord( + request_id=server_req, + session_key=session_key, + workspace=workspace, + runtime_url=runtime_url, + runtime_port=port, + ) + logger.info("Created sandbox %s at %s (request %s)", sandbox_id, runtime_url, server_req) + # No RuntimeClient is instantiated here — `session()` stamps + # `call_deadline` on the returned handle post-create. + return Sandbox(sandbox_id=sandbox_id, runtime_url=runtime_url, status="running") + + async def _discover_endpoint(self, server_req: str, *, deadline: float) -> tuple[str, int]: + """Find the node/port the runtime bound, from the workload's stdout. + + Polls `cape status` (a terminal state means the runtime died — + fail fast with its logs) and `cape logs` (looking for the + `AGENTIX_ENDPOINT` marker). Transient status/logs failures are + tolerated up to `transient_failure_limit` consecutive times so a + single controller blip cannot kill a create whose lease already + queued onto a GPU. No second request is ever submitted into the + session — the assumed session model is serial (RUNNING_COMMAND ↔ + CACHED_IDLE), so a same-session probe could queue forever behind + the never-ending runtime command. + """ + loop = asyncio.get_running_loop() + limit = self.config.transient_failure_limit + failures = 0 + while True: + try: + status = await self._cli.status(server_req) + except RuntimeError as exc: + failures += 1 + if failures >= limit: + raise RuntimeError( + f"cape status {server_req} failed {failures} consecutive times " + f"during endpoint discovery: {exc}" + ) from exc + if loop.time() >= deadline: + raise TimeoutError( + f"CAPE runtime request {server_req} did not publish its endpoint " + f"within {self.config.create_timeout_seconds}s" + ) from exc + await asyncio.sleep(self.config.poll_interval_seconds) + continue + state = str(status.get("state", "")) + if state in _TERMINAL_STATES: + stdout, stderr = await self._cli.logs(server_req) + raise RuntimeError( + f"CAPE runtime request {server_req} reached terminal state {state} " + f"before publishing its endpoint.\n" + f"--- workload stdout ---\n{stdout}\n" + f"--- workload stderr ---\n{stderr}" + ) + try: + stdout, _stderr = await self._cli.logs(server_req, check=True) + except (RuntimeError, OSError) as exc: + failures += 1 + if failures >= limit: + raise RuntimeError( + f"cape logs {server_req} failed {failures} consecutive times " + f"during endpoint discovery: {exc}" + ) from exc + if loop.time() >= deadline: + raise TimeoutError( + f"CAPE runtime request {server_req} did not publish its endpoint " + f"within {self.config.create_timeout_seconds}s" + ) from exc + await asyncio.sleep(self.config.poll_interval_seconds) + continue + failures = 0 + endpoint = _parse_endpoint(stdout) + if endpoint is not None: + return endpoint + if loop.time() >= deadline: + raise TimeoutError( + f"CAPE runtime request {server_req} did not publish its endpoint " + f"within {self.config.create_timeout_seconds}s" + ) + await asyncio.sleep(self.config.poll_interval_seconds) + + async def _wait_healthy(self, server_req: str, runtime_url: str, deadline: float) -> None: + """Probe `GET /health` until 200 or the create budget is exhausted. + + Raw TCP + a minimal hand-written HTTP request, never an HTTP + client library: proxy env vars (`http_proxy`, ...) would leak + into loopback/tunnel probes on corp-proxy hosts and hang them. + Every fifth round the server request's state is re-checked so a + runtime that printed its marker and then crashed fails fast with + its logs instead of probing a dead endpoint for the whole budget. + """ + parts = urlsplit(runtime_url) + host = parts.hostname or "127.0.0.1" + port = parts.port + if port is None: + raise RuntimeError( + f"runtime URL {runtime_url!r} has no explicit port; check url_template" + ) + loop = asyncio.get_running_loop() + rounds = 0 + while loop.time() < deadline: + if rounds and rounds % 5 == 0: + state: str | None = None + try: + status = await self._cli.status(server_req) + state = str(status.get("state", "")) + except RuntimeError: + state = None # transient status blip; keep probing + if state in _TERMINAL_STATES: + stdout, stderr = await self._cli.logs(server_req) + raise RuntimeError( + f"CAPE runtime request {server_req} reached terminal state {state} " + f"while waiting for {runtime_url}/health.\n" + f"--- workload stdout ---\n{stdout}\n" + f"--- workload stderr ---\n{stderr}" + ) + rounds += 1 + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=2 + ) + except (TimeoutError, OSError): + await asyncio.sleep(0.5) + continue + try: + writer.write(f"GET /health HTTP/1.0\r\nHost: {host}\r\n\r\n".encode()) + await writer.drain() + status_line = await asyncio.wait_for(reader.readline(), timeout=2) + if status_line.startswith(b"HTTP/1.") and b" 200 " in status_line: + return + except (TimeoutError, OSError): + pass + finally: + writer.close() + with contextlib.suppress(OSError): + await writer.wait_closed() + await asyncio.sleep(0.5) + raise TimeoutError(f"Runtime server not alive at {runtime_url}") + + async def get(self, sandbox_id: SandboxId) -> SandboxInfo: + record = self._sandboxes.get(sandbox_id) + if record is None: + raise KeyError(f"Sandbox not found: {sandbox_id}") + status = await self._cli.status(record.request_id) + state = str(status.get("state", "")) + return SandboxInfo( + sandbox_id=sandbox_id, + runtime_url=record.runtime_url, + status="exited" if state in _TERMINAL_STATES else "running", + ) + + async def delete(self, sandbox_id: SandboxId) -> None: + """Cancel the sandbox's runtime request. + + Unknown ids are a silent no-op (`session()` calls this on the + user's exception path, so a raise here would mask the original + error). Bookkeeping is dropped only after the controller + confirms the cancel — a rejected/failed cancel keeps the record + (with a warning) so a later `delete()` can retry instead of + silently leaking the GPU lease. + """ + record = self._sandboxes.get(sandbox_id) + if record is None: + return + cancelled = await self._cli.cancel(record.request_id, "agentix_delete") + if not cancelled: + logger.warning( + "delete(%s): cape cancel for request %s was not confirmed; " + "keeping bookkeeping so delete() can be retried", + sandbox_id, + record.request_id, + ) + return + self._sandboxes.pop(sandbox_id, None) + self._inflight_ports.discard(record.runtime_port) + logger.info("Deleted sandbox %s (request %s)", sandbox_id, record.request_id) + + +__all__ = ["CapeProvider", "CapeProviderConfig"] diff --git a/plugins/providers/cape/pyproject.toml b/plugins/providers/cape/pyproject.toml new file mode 100644 index 0000000..9d91f34 --- /dev/null +++ b/plugins/providers/cape/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agentix-provider-cape" +version = "0.1.0" +description = "CAPE lease-based GPU pool provider backend for Agentix (registers the `cape` backend; assumed CLI contract)" +requires-python = ">=3.11" +dependencies = [ + # Protocol + dataclasses (`SandboxProvider`, `Sandbox`, `SandboxConfig`, + # `SandboxInfo`, `SandboxId`) all live in core agentix. The backend + # drives the submitter-side `cape` CLI via asyncio subprocesses and + # health-checks the runtime with raw asyncio sockets, so no httpx — + # it would honor environment proxy vars and hang loopback/tunnel + # probes on hosts behind a corp proxy. + "agentixx", +] + +# `agentixx` is the monorepo workspace root — used editable, never from +# PyPI. Core edits are live here with no release step. +[tool.uv.sources] +agentixx = { workspace = true } + +# `uv sync` makes `providers().get("cape")` work with zero framework +# changes — the registry walks this entry-point group. +[project.entry-points."agentix.provider"] +cape = "agentix.provider.cape:CapeProvider" + +[tool.hatch.build.targets.wheel] +# One file at `agentix/provider/cape.py`. The `agentix` and +# `agentix/provider` dirs carry no __init__.py here — those belong to +# core agentix; this wheel installs a sibling into the same namespace. +packages = ["agentix"] + +# ruff / pyright / pytest config is centralized in the workspace-root +# pyproject.toml — this member doesn't repeat it. diff --git a/plugins/providers/cape/tests/test_cape_provider.py b/plugins/providers/cape/tests/test_cape_provider.py new file mode 100644 index 0000000..5271b96 --- /dev/null +++ b/plugins/providers/cape/tests/test_cape_provider.py @@ -0,0 +1,670 @@ +"""Unit tests for `CapeProvider` using a fake `cape` binary. + +Follows the apptainer provider test pattern: a fake executable staged +in `tmp_path` records every invocation as one JSON line, and a real +local HTTP server answers `GET /health` so the provider's raw-TCP +probe succeeds. The point is to lock in the *assumed* CAPE CLI surface +the provider emits (verbs, flags, workload shape) — no network beyond +127.0.0.1, no real `cape`. + +The fake models the assumed contract with a small file state machine: + + * `run` classifies each request by its workload — a workload that + execs the bundle bootstrap is a *server* request (stays RUNNING + until cancelled; overridable via `FAKE_CAPE_SERVER_STATE`); any + other workload completes immediately. Multiple sandboxes therefore + work in one test. + * `logs` prints noisy banner lines plus the `AGENTIX_ENDPOINT` marker + (port from `FAKE_CAPE_PORT`). `FAKE_CAPE_LOGS_EMPTY=N` withholds + the marker for the first N calls (exercising the discovery retry + loop); `FAKE_CAPE_LOGS_FAILURES=N` / `FAKE_CAPE_STATUS_FAILURES=N` + make the first N invocations of that verb exit non-zero + (exercising the transient-failure tolerance). + * `cancel` records a per-request cancelled marker; + `FAKE_CAPE_CANCEL_RC` forces a failing exit code (exercising the + delete-retry path). +""" + +from __future__ import annotations + +import asyncio +import http.server +import json +import os +import subprocess +import sys +import threading +from pathlib import Path +from typing import Any + +import pytest +from agentix.provider.cape import ( + CapeProvider, + CapeProviderConfig, + _boot_script, + _cpu_cores, + _memory_gb, + _parse_endpoint, +) + +from agentix.provider.base import SandboxConfig, SandboxId, SandboxProvider, SandboxResource + +_FAKE_CAPE_BODY = ''' +"""Recording fake `cape` CLI driven by FAKE_CAPE_* env vars.""" +import json +import os +import sys + +LOG = os.environ["FAKE_CAPE_LOG"] +STATE = os.environ["FAKE_CAPE_STATE"] + + +def log(argv): + with open(LOG, "a", encoding="utf-8") as f: + f.write(json.dumps({"argv": argv}) + "\\n") + + +def bump(name): + """Increment and return a per-state-dir invocation counter.""" + path = os.path.join(STATE, name) + n = 0 + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + n = int(f.read().strip() or "0") + n += 1 + with open(path, "w", encoding="utf-8") as f: + f.write(str(n)) + return n + + +def req_kind(req): + path = os.path.join(STATE, "kind-" + req) + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + return f.read().strip() + return "control" + + +def main(): + argv = sys.argv[1:] + log(argv) + verb = argv[0] if argv else "" + if verb == "run": + stderr = os.environ.get("FAKE_CAPE_RUN_STDERR") + if stderr: + sys.stderr.write(stderr + "\\n") + sys.exit(1) + n = bump("run") + req = "req-fake-%d" % n + sep = argv.index("--") + workload = " ".join(argv[sep + 1 :]) + kind = "server" if "bootstrap.sh" in workload else "control" + with open(os.path.join(STATE, "kind-" + req), "w", encoding="utf-8") as f: + f.write(kind) + print(req) + elif verb == "status": + req = argv[1] + fails = int(os.environ.get("FAKE_CAPE_STATUS_FAILURES", "0")) + if bump("status") <= fails: + sys.stderr.write("fake cape: transient controller error\\n") + sys.exit(1) + if os.path.exists(os.path.join(STATE, "cancelled-" + req)): + print(json.dumps({"state": "CANCELLED", "exit_code": 124, "request_id": req})) + elif req_kind(req) == "server": + state = os.environ.get("FAKE_CAPE_SERVER_STATE", "RUNNING") + print(json.dumps({"state": state, "request_id": req})) + else: + print(json.dumps({"state": "COMPLETED", "exit_code": 0, "request_id": req})) + elif verb == "logs": + req = argv[1] + n = bump("logs") + lf = int(os.environ.get("FAKE_CAPE_LOGS_FAILURES", "0")) + if n <= lf: + sys.stderr.write("fake cape: transient log fetch error\\n") + sys.exit(1) + print("booting runtime...") + print("GPU 0") + le = int(os.environ.get("FAKE_CAPE_LOGS_EMPTY", "0")) + if n - lf > le: + print("AGENTIX_ENDPOINT 127.0.0.1 %s" % os.environ["FAKE_CAPE_PORT"]) + elif verb == "cancel": + req = argv[1] + rc = int(os.environ.get("FAKE_CAPE_CANCEL_RC", "0")) + if rc: + sys.stderr.write("fake cape: cancel rejected\\n") + sys.exit(rc) + with open(os.path.join(STATE, "cancelled-" + req), "w", encoding="utf-8"): + pass + else: + sys.stderr.write("fake cape: unsupported verb %r\\n" % verb) + sys.exit(2) + + +if __name__ == "__main__": + main() +''' + + +class _HealthHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API) + if self.path == "/health": + body = b'{"status":"ok"}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args: object, **kwargs: object) -> None: + pass + + +@pytest.fixture +def health_port(): + """A real loopback HTTP server answering 200 on `/health`.""" + server = http.server.HTTPServer(("127.0.0.1", 0), _HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def cape_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, health_port: int) -> dict[str, Any]: + state = tmp_path / "state" + state.mkdir() + log = tmp_path / "cape.log.jsonl" + fake = tmp_path / "fake-bin" / "cape" + fake.parent.mkdir() + fake.write_text(f"#!{sys.executable}\n{_FAKE_CAPE_BODY}") + fake.chmod(0o755) + monkeypatch.setenv("FAKE_CAPE_LOG", str(log)) + monkeypatch.setenv("FAKE_CAPE_STATE", str(state)) + monkeypatch.setenv("FAKE_CAPE_PORT", str(health_port)) + monkeypatch.setenv("CAPE_TOKEN", "unit-test-token") + for var in ( + "CAPE_TOKEN_FILE", + "CAPE_BINARY", + "CAPE_CONTROLLER_URL", + "FAKE_CAPE_SERVER_STATE", + "FAKE_CAPE_RUN_STDERR", + "FAKE_CAPE_CANCEL_RC", + "FAKE_CAPE_LOGS_EMPTY", + "FAKE_CAPE_LOGS_FAILURES", + "FAKE_CAPE_STATUS_FAILURES", + ): + monkeypatch.delenv(var, raising=False) + return {"binary": fake, "log": log, "state": state, "port": health_port} + + +def _provider(cape_env: dict[str, Any], **overrides: Any) -> CapeProvider: + settings: dict[str, Any] = { + "binary": str(cape_env["binary"]), + "controller_url": "http://cape-controller.test:9000", + "poll_interval_seconds": 0.05, + "create_timeout_seconds": 30.0, + "client_timeout_seconds": 30.0, + } + settings.update(overrides) + return CapeProvider(CapeProviderConfig(**settings)) + + +def _sandbox_config(**overrides: Any) -> SandboxConfig: + defaults: dict[str, Any] = { + "image": "docker://task-image:1", + "bundle": "/mnt/shared/bundles/sha256-abc", + } + defaults.update(overrides) + return SandboxConfig(**defaults) + + +def _log_entries(cape_env: dict[str, Any], verb: str | None = None) -> list[dict[str, Any]]: + log: Path = cape_env["log"] + if not log.exists(): + return [] + entries = [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + if verb is not None: + entries = [e for e in entries if e["argv"] and e["argv"][0] == verb] + return entries + + +def _flag(argv: list[str], name: str) -> str: + return argv[argv.index(name) + 1] + + +def _flags(argv: list[str], name: str) -> list[str]: + return [argv[i + 1] for i, tok in enumerate(argv) if tok == name] + + +# ── create / run face ───────────────────────────────────────────────────── + + +async def test_create_returns_sandbox_and_emits_assumed_run_face(cape_env: dict[str, Any]) -> None: + provider = _provider(cape_env) + config = _sandbox_config(env={"HF_HOME": "/tmp/hf"}, resource=SandboxResource(gpu=2)) + sandbox = await provider.create(config) + try: + assert sandbox.status == "running" + assert sandbox.runtime_url == f"http://127.0.0.1:{cape_env['port']}" + + runs = _log_entries(cape_env, verb="run") + # One sandbox = one request; discovery must not submit a second + # same-session command (the assumed session model is serial). + assert len(runs) == 1 + argv = runs[0]["argv"] + assert _flag(argv, "--controller-url") == "http://cape-controller.test:9000" + assert _flag(argv, "--token") == "unit-test-token" + assert _flag(argv, "--session-key") == f"agentix-{sandbox.sandbox_id}" + assert _flag(argv, "--workspace") == f"/workspace/{sandbox.sandbox_id}" + assert _flag(argv, "--cwd") == f"/workspace/{sandbox.sandbox_id}" + assert _flag(argv, "--image") == "docker://task-image:1" + assert _flag(argv, "--gpus") == "2" + assert _flag(argv, "--gpu-mode") == "whole" + assert _flag(argv, "--isolation-policy") == "default" + assert _flag(argv, "--max-duration-seconds") == "14400" + assert _flags(argv, "--bind") == ["/mnt/shared/bundles/sha256-abc:/nix:ro"] + env_args = _flags(argv, "--env") + assert "AGENTIX_BIND_PORT=8710" in env_args + assert "HF_HOME=/tmp/hf" in env_args + # Optional flags absent from a default config. + for absent in ("--pool", "--user", "--cpu-cores", "--memory-gb", "--runtime-adapter"): + assert absent not in argv + # Workload sits after the `--` separator: print the endpoint + # marker to stdout, then exec the bundle entry point. + workload = argv[argv.index("--") + 1 :] + assert workload[:2] == ["sh", "-c"] + assert "AGENTIX_ENDPOINT" in workload[2] + assert workload[2].endswith("exec /nix/runtime/bootstrap.sh") + assert "mkdir" not in workload[2] + assert ".agentix-endpoint" not in workload[2] + # Discovery consumed status + logs of the server request only. + assert _log_entries(cape_env, verb="logs") + finally: + await provider.delete(sandbox.sandbox_id) + + +async def test_optional_flags_emitted_when_configured(cape_env: dict[str, Any]) -> None: + provider = _provider( + cape_env, + pool="pool-a", + user="alice", + runtime_adapter="apptainer", + extra_binds=["/data:/data:rw"], + isolation_policy="strict", + ) + config = _sandbox_config(resource=SandboxResource(cpu=2.5, memory="16g", gpu=1)) + sandbox = await provider.create(config) + try: + argv = _log_entries(cape_env, verb="run")[0]["argv"] + assert _flag(argv, "--pool") == "pool-a" + assert _flag(argv, "--user") == "alice" + assert _flag(argv, "--cpu-cores") == "3" # ceil(2.5) + assert _flag(argv, "--memory-gb") == "16" + assert _flag(argv, "--gpus") == "1" + assert _flag(argv, "--runtime-adapter") == "apptainer" + assert _flag(argv, "--isolation-policy") == "strict" + assert _flags(argv, "--bind") == [ + "/mnt/shared/bundles/sha256-abc:/nix:ro", + "/data:/data:rw", + ] + finally: + await provider.delete(sandbox.sandbox_id) + + +async def test_config_resource_defaults_used_when_resource_unset(cape_env: dict[str, Any]) -> None: + provider = _provider(cape_env, cpu_cores=8, memory_gb=32) + sandbox = await provider.create(_sandbox_config()) + try: + argv = _log_entries(cape_env, verb="run")[0]["argv"] + assert _flag(argv, "--cpu-cores") == "8" + assert _flag(argv, "--memory-gb") == "32" + assert _flag(argv, "--gpus") == "0" + finally: + await provider.delete(sandbox.sandbox_id) + + +# ── multi-sandbox ───────────────────────────────────────────────────────── + + +async def test_two_sandboxes_get_distinct_sessions_ports_and_cancels( + cape_env: dict[str, Any], +) -> None: + provider = _provider(cape_env) + sb1 = await provider.create(_sandbox_config()) + sb2 = await provider.create(_sandbox_config()) + assert sb1.sandbox_id != sb2.sandbox_id + assert len(provider._sandboxes) == 2 + + runs = _log_entries(cape_env, verb="run") + assert len(runs) == 2 + keys = {_flag(r["argv"], "--session-key") for r in runs} + assert keys == {f"agentix-{sb1.sandbox_id}", f"agentix-{sb2.sandbox_id}"} + ports = { + e for r in runs for e in _flags(r["argv"], "--env") if e.startswith("AGENTIX_BIND_PORT=") + } + assert ports == {"AGENTIX_BIND_PORT=8710", "AGENTIX_BIND_PORT=8711"} + + # Deleting sandbox 1 cancels only its own request. + await provider.delete(sb1.sandbox_id) + cancels = _log_entries(cape_env, verb="cancel") + assert [c["argv"][1] for c in cancels] == ["req-fake-1"] + info = await provider.get(sb2.sandbox_id) + assert info.status == "running" + + await provider.delete(sb2.sandbox_id) + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +# ── delete ──────────────────────────────────────────────────────────────── + + +async def test_delete_cancels_server_request_and_is_idempotent(cape_env: dict[str, Any]) -> None: + provider = _provider(cape_env) + sandbox = await provider.create(_sandbox_config()) + await provider.delete(sandbox.sandbox_id) + + cancels = _log_entries(cape_env, verb="cancel") + assert cancels, "no cape cancel recorded" + assert cancels[-1]["argv"][1] == "req-fake-1" + assert _flag(cancels[-1]["argv"], "--reason") == "agentix_delete" + assert provider._inflight_ports == set() + + # Second delete of the same (now unknown) id is a no-op: no raise, + # no extra cancel. + await provider.delete(sandbox.sandbox_id) + assert len(_log_entries(cape_env, verb="cancel")) == len(cancels) + with pytest.raises(KeyError): + await provider.get(sandbox.sandbox_id) + + +async def test_delete_keeps_bookkeeping_when_cancel_fails_then_retries( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + provider = _provider(cape_env) + sandbox = await provider.create(_sandbox_config()) + + monkeypatch.setenv("FAKE_CAPE_CANCEL_RC", "1") + await provider.delete(sandbox.sandbox_id) # must not raise + # Cancel was not confirmed: the record (and its port) stay so a + # later delete() can retry instead of silently leaking the lease. + assert sandbox.sandbox_id in provider._sandboxes + info = await provider.get(sandbox.sandbox_id) + assert info.status == "running" + + monkeypatch.delenv("FAKE_CAPE_CANCEL_RC") + await provider.delete(sandbox.sandbox_id) + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +# ── create failure / cancellation paths ─────────────────────────────────── + + +async def test_create_failure_cancels_request_and_clears_bookkeeping( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAKE_CAPE_SERVER_STATE", "FAILED") + provider = _provider(cape_env) + with pytest.raises(RuntimeError, match="FAILED"): + await provider.create(_sandbox_config()) + + cancels = _log_entries(cape_env, verb="cancel") + assert cancels, "failed create must attempt a cancel" + assert cancels[-1]["argv"][1] == "req-fake-1" + assert _flag(cancels[-1]["argv"], "--reason") == "agentix_create_failed" + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +async def test_discovery_retries_until_marker_appears( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + # Marker withheld for the first 2 logs calls, plus 2 transient status + # failures: create must retry through both and still succeed. + monkeypatch.setenv("FAKE_CAPE_LOGS_EMPTY", "2") + monkeypatch.setenv("FAKE_CAPE_STATUS_FAILURES", "2") + provider = _provider(cape_env) + sandbox = await provider.create(_sandbox_config()) + try: + assert len(_log_entries(cape_env, verb="logs")) >= 3 + finally: + await provider.delete(sandbox.sandbox_id) + + +async def test_discovery_fails_after_consecutive_transient_failures( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAKE_CAPE_STATUS_FAILURES", "100000") + provider = _provider(cape_env, transient_failure_limit=3) + with pytest.raises(RuntimeError, match="3 consecutive"): + await provider.create(_sandbox_config()) + cancels = _log_entries(cape_env, verb="cancel") + assert cancels and _flag(cancels[-1]["argv"], "--reason") == "agentix_create_failed" + assert provider._sandboxes == {} + + +async def test_discovery_times_out_when_marker_never_appears( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAKE_CAPE_LOGS_EMPTY", "100000") + provider = _provider(cape_env, create_timeout_seconds=1.0) + with pytest.raises(TimeoutError, match="did not publish its endpoint"): + await provider.create(_sandbox_config()) + cancels = _log_entries(cape_env, verb="cancel") + assert cancels and _flag(cancels[-1]["argv"], "--reason") == "agentix_create_failed" + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +async def test_health_timeout_fails_create_and_cancels( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + # Point the marker at a port nobody listens on: discovery succeeds, + # the health probe burns the remaining budget, create fails + cancels. + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + dead_port = s.getsockname()[1] + monkeypatch.setenv("FAKE_CAPE_PORT", str(dead_port)) + provider = _provider(cape_env, create_timeout_seconds=3.0) + with pytest.raises(TimeoutError, match="not alive"): + await provider.create(_sandbox_config()) + cancels = _log_entries(cape_env, verb="cancel") + assert cancels and _flag(cancels[-1]["argv"], "--reason") == "agentix_create_failed" + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +async def test_cancelled_create_cancels_submitted_request( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + # Keep discovery spinning so the cancellation lands mid-create. + monkeypatch.setenv("FAKE_CAPE_LOGS_EMPTY", "100000") + provider = _provider(cape_env) + task = asyncio.ensure_future(provider.create(_sandbox_config())) + while not _log_entries(cape_env, verb="logs"): + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + cancels = _log_entries(cape_env, verb="cancel") + assert cancels, "cancelled create must attempt to cancel the submitted request" + assert _flag(cancels[-1]["argv"], "--reason") in { + "agentix_create_failed", + "agentix_create_cancelled", + } + assert provider._sandboxes == {} + assert provider._inflight_ports == set() + + +# ── get ─────────────────────────────────────────────────────────────────── + + +async def test_get_unknown_raises_and_known_reports_running(cape_env: dict[str, Any]) -> None: + provider = _provider(cape_env) + with pytest.raises(KeyError, match="Sandbox not found"): + await provider.get(SandboxId("cape-does-not-exist")) + + sandbox = await provider.create(_sandbox_config()) + try: + info = await provider.get(sandbox.sandbox_id) + assert info.status == "running" + assert info.runtime_url == sandbox.runtime_url + assert info.sandbox_id == sandbox.sandbox_id + finally: + await provider.delete(sandbox.sandbox_id) + + +async def test_get_maps_terminal_state_to_exited( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + provider = _provider(cape_env) + sandbox = await provider.create(_sandbox_config()) + try: + monkeypatch.setenv("FAKE_CAPE_SERVER_STATE", "COMPLETED") + info = await provider.get(sandbox.sandbox_id) + assert info.status == "exited" + finally: + monkeypatch.delenv("FAKE_CAPE_SERVER_STATE", raising=False) + await provider.delete(sandbox.sandbox_id) + + +# ── token hygiene ───────────────────────────────────────────────────────── + + +async def test_token_is_redacted_from_exception_text( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAKE_CAPE_RUN_STDERR", "authentication failed for token unit-test-token") + provider = _provider(cape_env) + with pytest.raises(RuntimeError) as excinfo: + await provider.create(_sandbox_config()) + text = str(excinfo.value) + assert "unit-test-token" not in text + assert "" in text + + +async def test_whitespace_containing_token_is_rejected( + cape_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CAPE_TOKEN", "bad token") + provider = _provider(cape_env) + with pytest.raises(RuntimeError, match="CAPE_TOKEN.*whitespace"): + await provider.create(_sandbox_config()) + assert _log_entries(cape_env) == [] # rejected before any CLI call + + +async def test_token_file_rejected_when_group_or_other_accessible( + cape_env: dict[str, Any], tmp_path: Path +) -> None: + token_file = tmp_path / "cape-token" + token_file.write_text("file-token-abc\n") + token_file.chmod(0o644) + provider = _provider(cape_env, token_file=str(token_file)) + with pytest.raises(RuntimeError, match="group/other"): + await provider.create(_sandbox_config()) + assert _log_entries(cape_env) == [] + + +async def test_token_file_wins_over_env_and_expands_tilde( + cape_env: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + home.mkdir() + token_file = home / "cape-token" + token_file.write_text("file-token-abc\n") + token_file.chmod(0o600) + monkeypatch.setenv("HOME", str(home)) + # CAPE_TOKEN stays set from the fixture; the file must win. + provider = _provider(cape_env, token_file="~/cape-token") + sandbox = await provider.create(_sandbox_config()) + try: + argv = _log_entries(cape_env, verb="run")[0]["argv"] + assert _flag(argv, "--token") == "file-token-abc" + finally: + await provider.delete(sandbox.sandbox_id) + + +async def test_empty_token_file_is_rejected(cape_env: dict[str, Any], tmp_path: Path) -> None: + token_file = tmp_path / "cape-token" + token_file.write_text("\n") + token_file.chmod(0o600) + provider = _provider(cape_env, token_file=str(token_file)) + with pytest.raises(RuntimeError, match="empty"): + await provider.create(_sandbox_config()) + assert _log_entries(cape_env) == [] + + +# ── pure helpers ────────────────────────────────────────────────────────── + + +def test_cpu_cores_mapping() -> None: + cfg = CapeProviderConfig() + assert _cpu_cores(SandboxResource(cpu=0.5), cfg) == 1 + assert _cpu_cores(SandboxResource(cpu=2.5), cfg) == 3 + assert _cpu_cores(SandboxResource(cpu=4), cfg) == 4 + assert _cpu_cores(None, cfg) is None + assert _cpu_cores(None, CapeProviderConfig(cpu_cores=8)) == 8 + + +def test_memory_gb_mapping() -> None: + cfg = CapeProviderConfig() + assert _memory_gb(SandboxResource(memory="16g"), cfg) == 16 + assert _memory_gb(SandboxResource(memory="512m"), cfg) == 1 # rounds up to whole GiB + assert _memory_gb(SandboxResource(memory=1 << 30), cfg) == 1 # int = bytes + assert _memory_gb(SandboxResource(memory=(1 << 30) + 1), cfg) == 2 + assert _memory_gb(None, cfg) is None + assert _memory_gb(None, CapeProviderConfig(memory_gb=32)) == 32 + with pytest.raises(RuntimeError, match="cannot map memory"): + _memory_gb(SandboxResource(memory="sixteen gigs"), cfg) + + +def test_parse_endpoint_requires_marker_prefix() -> None: + marker = "booting runtime...\nGPU 0\nAGENTIX_ENDPOINT 10.0.0.5 8710\n" + assert _parse_endpoint(marker) == ("10.0.0.5", 8710) + # Two-token noise ("GPU 0") must never be misread as an endpoint. + assert _parse_endpoint("GPU 0\n") is None + assert _parse_endpoint("") is None + assert _parse_endpoint("AGENTIX_ENDPOINT host notaport\n") is None + assert _parse_endpoint("AGENTIX_ENDPOINT 10.0.0.5\n") is None + + +def test_boot_script_marker_roundtrip(tmp_path: Path) -> None: + """Run the generated boot script under a real `sh` (with a stubbed + multi-IP `hostname`) and feed its stdout to `_parse_endpoint` — + the producer/consumer pair must agree.""" + stub = tmp_path / "stub-bin" + stub.mkdir() + hostname = stub / "hostname" + hostname.write_text("#!/bin/sh\necho '10.0.0.5 172.17.0.1'\n") + hostname.chmod(0o755) + env = { + "PATH": f"{stub}:{os.environ.get('PATH', '/usr/bin:/bin')}", + "AGENTIX_BIND_PORT": "9999", + } + # `exec /nix/runtime/bootstrap.sh` fails in the test environment — + # the marker must already be on stdout by then. + proc = subprocess.run(["sh", "-c", _boot_script()], env=env, capture_output=True, text=True) + assert _parse_endpoint(proc.stdout) == ("10.0.0.5", 9999) + + +# ── registry contract ───────────────────────────────────────────────────── + + +def test_zero_arg_constructor_for_plugin_registry() -> None: + # The plugin registry instantiates providers with `cls()` — every + # config field must be optional or defaulted. + provider = CapeProvider() + assert isinstance(provider, SandboxProvider) + assert provider.config.workspace_root == "/workspace" + assert provider.config.url_template == "http://{host}:{port}" + assert provider.config.runtime_port_base == 8710 diff --git a/pyproject.toml b/pyproject.toml index a0e80b9..ada8e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ include = [ "plugins/agents/qwen-code/src", "plugins/datasets/swebench/src", "plugins/providers/apptainer/agentix", + "plugins/providers/cape/agentix", "plugins/providers/docker/agentix", "plugins/providers/daytona/agentix", "plugins/providers/e2b/agentix", @@ -142,6 +143,7 @@ extraPaths = [ "plugins/agents/qwen-code/src", "plugins/datasets/swebench/src", "plugins/providers/apptainer", + "plugins/providers/cape", "plugins/providers/docker", "plugins/providers/daytona", "plugins/providers/e2b", diff --git a/uv.lock b/uv.lock index 047b584..ec314fa 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "agentix-bridge", "agentix-dataset-swe", "agentix-provider-apptainer", + "agentix-provider-cape", "agentix-provider-daytona", "agentix-provider-docker", "agentix-provider-e2b", @@ -134,6 +135,17 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "agentixx", editable = "." }] +[[package]] +name = "agentix-provider-cape" +version = "0.1.0" +source = { editable = "plugins/providers/cape" } +dependencies = [ + { name = "agentixx" }, +] + +[package.metadata] +requires-dist = [{ name = "agentixx", editable = "." }] + [[package]] name = "agentix-provider-daytona" version = "0.1.2"