Metadata-Version: 2.5
Name: agentcyberrange
Version: 0.2.0
Summary: Async Python SDK for Agent Cyber Range
Author: Agent Cyber Range contributors
License-Expression: MIT
License-File: LICENSE
Keywords: asyncio,cyber-range,evaluation,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx[socks]>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.2; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# Agent Cyber Range SDK

`agentcyberrange` is the Python SDK for running autonomous Agents against the
Agent Cyber Range evaluation platform. It discovers the available Challenges,
keeps a bounded number of Arenas running, waits for capacity, and submits each
Arena when the Agent finishes or reaches its deadline.

Access to the evaluation service requires a Task Token issued by Agent Cyber Range.

Python 3.11 or newer is required.

## Install

Install the latest release from PyPI:

```bash
python -m pip install agentcyberrange
```

For local development from a repository checkout:

```bash
uv sync --extra dev
```

## Quick start

Set the Task Token issued by Agent Cyber Range:

```bash
export AGENTCYBERRANGE_TASK_TOKEN='replace-with-secret'
```

Then provide one asynchronous Agent function:

```python
import asyncio
import os

from agentcyberrange import (
    AgentCyberRangeClient,
    AgentOutputPath,
    ArenaHandle,
    run_agent,
)


async def run_your_agent(arena: ArenaHandle) -> AgentOutputPath | None:
    # Replace this body with your Agent implementation.
    print(arena.task_prompt)
    print(arena.entry_urls)
    agent_output: AgentOutputPath | None = None
    return agent_output

async def main() -> None:
    async with AgentCyberRangeClient(
        base_url=os.environ.get(
            "AGENTCYBERRANGE_BASE_URL", "https://eval.agentcyberrange.io/"
        ),
        task_token=os.environ["AGENTCYBERRANGE_TASK_TOKEN"],
    ) as client:
        challenges = await client.list_challenges()
        # Replace these with one or more specific Challenge IDs if needed.
        challenge_ids = [challenge.challenge_id for challenge in challenges[:5]]
        result = await run_agent(
            run_your_agent,
            max_concurrency=2,
            challenge_ids=challenge_ids,
            client=client,
        )
    print(result.model_dump_json(indent=2))


asyncio.run(main())
```

The Quick Start keeps the main customization points visible: the Agent callback, the
server-provided Prompt, and two concurrent Agents. The SDK returns the available
Challenge list, and the example selects the first five for this run. It leaves
`agent_timeout` unset, so Web Challenges use the 30-minute default and post Challenges use
the 2-hour default. Pass `agent_timeout` directly to apply one exact limit across the
selected Challenges.

Environment variables are reserved for connection configuration
(`AGENTCYBERRANGE_TASK_TOKEN` and, optionally, `AGENTCYBERRANGE_BASE_URL`). Per-run
policy such as concurrency, Agent timeout, model, and permission mode stays explicit in
Python arguments or CLI flags instead of hidden process-global configuration.

When `agent_timeout` is omitted, the SDK uses a 30-minute Web (non-post) default or a
2-hour post default. These are defaults, not maximums; the server-provided Arena lease is
the final resource deadline.

`agent_timeout` is an optional local limit in seconds. `run_agent()` applies an explicit
value instead of the default. For example, `agent_timeout=3600` runs
both Web and post Agents for up to 60 minutes. The effective deadline is the earlier of
this local limit and the Arena's `lease_expires_at`. When the local timeout is reached,
the SDK signals `arena.cancel_event`. Archive callbacks should observe this event, stop
workers, collect files and return their archive. Collection gets up to two minutes for an
explicit local timeout, or 30 seconds for the default timeout, cancellation and lease
deadline. On collection failure the SDK records the error and closes the Arena without
submitting an empty archive. Empty-body Challenges submit immediately. Callbacks must
cooperate with cancellation and own subprocess/container termination: Python cannot
forcibly stop arbitrary coroutine code; a callback that suppresses cancellation may
outlive the scheduler and delay application event-loop shutdown.


`on_event` receives lifecycle and retry events. Capacity waits include the HTTP status,
error code, request ID, `Retry-After`, and attempt number in `SchedulerEvent`, so callers
can report an automatic wait without disabling it. Pass a `RetryPolicy` to `run_agent()`
when a finite `capacity_wait_timeout` is required.

`arena_ready_delay` adds a cancellable settling period after Fleet reports an Arena as
running and before the Agent callback starts. This is useful when an exposed service needs
a few extra seconds to begin accepting connections.

The SDK automatically:

- discovers all currently available Challenges;
- keeps at most `max_concurrency` Work Slots active;
- waits and retries when the control plane has no free capacity;
- refills a Work Slot as soon as an Arena is submitted;
- submits a post Challenge when the Agent returns, raises, times out, or reaches the
  server deadline;
- submits an archive Challenge only with a valid nonempty archive; missing output or
  callback failures close the Arena and produce a failed Task Outcome.

## Run Codex

Install and authenticate the Codex CLI, set the Agent Cyber Range Task Token, then run:

```bash
uv run python examples/codex_cli.py
```

The example uses the default SDK settings and Codex model. It starts a non-interactive,
ephemeral `codex exec` session in a temporary workspace and enables network access inside
the `workspace-write` sandbox. See the
[Codex non-interactive mode documentation](https://learn.chatgpt.com/docs/non-interactive-mode).

The Codex example passes the server-provided `arena.task_prompt` to Codex unchanged. It
removes `AGENTCYBERRANGE_TASK_TOKEN` and `AGENTCYBERRANGE_BASE_URL` from the child
environment, so Codex cannot directly operate the control plane. If Codex creates a
`final_answer/` directory for a non-post Challenge, the callback archives it under
`.agentcyberrange/agent-output/` and returns that archive path to the Scheduler.

When an SDK timeout or Arena deadline signals the callback, the example first stops Codex
and then archives any existing `final_answer/` before the temporary workspace is removed.
The Scheduler submits that archive immediately. If the directory does not exist, the
Scheduler closes the Arena without submission and records missing output. A post Challenge returns
`None` and is submitted with an empty body.

## Low-level client

Use `AgentCyberRangeClient` when you need to manage one Arena manually:

```python
import asyncio
import os

from agentcyberrange import AgentCyberRangeClient


async def main() -> None:
    async with AgentCyberRangeClient(
        base_url=os.getenv(
            "AGENTCYBERRANGE_BASE_URL",
            "https://eval.agentcyberrange.io/",
        ),
        task_token=os.environ["AGENTCYBERRANGE_TASK_TOKEN"],
    ) as client:
        challenges = await client.list_challenges()
        if not challenges:
            raise RuntimeError("Agent Cyber Range has no available Challenges")
        arena = await client.create_arena(challenges[0].challenge_id)
        print(arena.task_prompt)
        print(arena.entry_urls)
        result = await client.submit(arena.arena_id)
        print(result.verdict)


asyncio.run(main())
```

`create_arena()` automatically waits on `capacity_exhausted` and reuses the same
Idempotency-Key across retries. The client honors standard proxy environment variables;
set `trust_env=False` when a localhost backend must bypass them.

`submit()` also accepts a `.tar.gz` or `.zip` path for a non-post Challenge:

```python
result = await client.submit(
    arena.arena_id,
    agent_output="outputs/agent-output.zip",
)
```

The SDK resolves a relative output path against the process working directory when
`submit()` starts, then reads the file once so an ambiguous HTTP result can safely retry
the same archive. The file must be non-empty and no larger than 10 MiB.

With `run_agent()`, return the archive path when a non-post Agent produced one. Returning
`None` is valid only for an empty-body Challenge. Archive Challenges require a nonempty
regular file inside a valid archive. Validation rejects unsafe paths, links, corruption,
more than 4096 entries or more than 100 MiB of expanded data.

```python
from agentcyberrange import AgentOutputPath, ArenaHandle


async def run_your_agent(arena: ArenaHandle) -> AgentOutputPath | None:
    agent_output = await your_agent(arena)
    return agent_output
```

## Examples

- [`examples/quickstart.py`](examples/quickstart.py): minimal custom Agent callback
- [`examples/codex_cli.py`](examples/codex_cli.py): Codex CLI integration
- [`examples/manual_client.py`](examples/manual_client.py): low-level client flow

## Development verification

```bash
uv sync --extra dev
uv run pytest
uv run ruff check .
uv run mypy src
uv build
```

`scripts/live_backend_smoke.py` exercises every low-level client method.
`scripts/live_scheduler_smoke.py` verifies rolling refill and capacity fallback.
`scripts/run_agent_test.py` runs mock or external Agent processes against the deployed
control plane and asserts that no active Arena is left behind.

For a repository-only live Agent test:

```bash
uv run python scripts/run_agent_test.py
```

To replace the mock Agent with another process:

```bash
uv run python scripts/run_agent_test.py \
  --agent-command 'codex exec --ephemeral --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true -'
```

The default base URL is `https://eval.agentcyberrange.io/`. Override it with
`AGENTCYBERRANGE_BASE_URL` when testing another deployment.

## Execution results and migration to 0.2.0

Use `AgentResult(agent_output=path, succeeded=False, stop_reason="abnormal_exit")`
to submit partial findings while preserving the execution failure. Set `collection_error`
when file collection failed. Returning a plain path remains supported, but cannot express
an internal engine failure. A successful worker exit code is not an engine result.

`PublicVerdict.assessment_status` defaults to `complete` for legacy responses. An
`incomplete` assessment retains its provisional score and is excluded from a successful
Execution result; do not compare that score with complete assessments. Objective counts
are optional. This SDK does not automatically resubmit an incomplete assessment.

Set `ChallengeTask(submission_mode="archive")` or `"empty_body"` explicitly; `run_agent`
also accepts this setting for all selected Challenges. When omitted, legacy IDs `post`,
`post-*` and `pb-postexp-*` use empty bodies. Arbitrary occurrences of `post` no longer
select submission behavior. Per-Challenge settings should use `RoundScheduler`.

An invalid archive rejection permits corrected submission through the same ArenaHandle.
Cancelling a caller waiting for a shared submission does not cancel the HTTP operation.
Unknown outcomes remain attached to the original operation; client transport recovery
queries Fleet before retrying. Missing output is no longer replaced with an empty tarball.
