Skip to content

sandbox

View module diagram

Sandbox configuration, protocols, and implementations for agent isolation.

This package defines tunable agent isolation infrastructure — sandbox types, token permission tiers, isolation configuration, and the protocols that sandbox and framework adapter implementations must satisfy.

Subpackages

core: Enums, config dataclasses, context, AgentSandbox, AgentFrameworkAdapter, and CredentialProvider protocols. implementations: Concrete implementations (NullSandbox, ClaudeCodeAdapter, NullCredentialProvider, FileCredentialProvider, etc.).

AgentFrameworkAdapter

Bases: Protocol

Protocol for building framework-specific agent CLI commands.

Implementations construct the raw CLI invocation string for a specific AI framework (Claude Code, Codex, etc.). The resulting command is then wrapped by an :class:AgentSandbox before execution.

Methods:

Name Description
build_command

Build the CLI command string for launching an agent.

Source code in src/agentrelay/sandbox/core/adapters.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@runtime_checkable
class AgentFrameworkAdapter(Protocol):
    """Protocol for building framework-specific agent CLI commands.

    Implementations construct the raw CLI invocation string for a specific
    AI framework (Claude Code, Codex, etc.). The resulting command is then
    wrapped by an :class:`AgentSandbox` before execution.

    Methods:
        build_command: Build the CLI command string for launching an agent.
    """

    def build_command(self, config: AgentConfig, signal_dir: Path) -> str:
        """Build the framework-specific CLI command string.

        Args:
            config: Agent configuration with framework, model, and settings.
            signal_dir: Path to the signal directory for this task.

        Returns:
            The raw CLI command string ready for sandbox wrapping.
        """
        ...

build_command(config, signal_dir)

Build the framework-specific CLI command string.

Parameters:

Name Type Description Default
config AgentConfig

Agent configuration with framework, model, and settings.

required
signal_dir Path

Path to the signal directory for this task.

required

Returns:

Type Description
str

The raw CLI command string ready for sandbox wrapping.

Source code in src/agentrelay/sandbox/core/adapters.py
32
33
34
35
36
37
38
39
40
41
42
def build_command(self, config: AgentConfig, signal_dir: Path) -> str:
    """Build the framework-specific CLI command string.

    Args:
        config: Agent configuration with framework, model, and settings.
        signal_dir: Path to the signal directory for this task.

    Returns:
        The raw CLI command string ready for sandbox wrapping.
    """
    ...

AnthropicCredential dataclass

Resolved Anthropic credential for agent authentication.

Holds a named credential entry from the credentials YAML anthropic section, with type-specific fields populated.

Attributes:

Name Type Description
name str

Key from the YAML anthropic section (e.g., "dev_api_key", "max_plan").

credential_type CredentialType

Whether this is an API key or OAuth credential.

api_key Optional[str]

The API key string. Set when credential_type is :attr:CredentialType.API_KEY, None otherwise.

oauth_path Optional[Path]

Path to the .credentials.json file. Set when credential_type is :attr:CredentialType.OAUTH, None otherwise.

Source code in src/agentrelay/sandbox/core/config.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True)
class AnthropicCredential:
    """Resolved Anthropic credential for agent authentication.

    Holds a named credential entry from the credentials YAML
    ``anthropic`` section, with type-specific fields populated.

    Attributes:
        name: Key from the YAML ``anthropic`` section (e.g.,
            ``"dev_api_key"``, ``"max_plan"``).
        credential_type: Whether this is an API key or OAuth credential.
        api_key: The API key string.  Set when ``credential_type``
            is :attr:`CredentialType.API_KEY`, ``None`` otherwise.
        oauth_path: Path to the ``.credentials.json`` file.  Set when
            ``credential_type`` is :attr:`CredentialType.OAUTH`,
            ``None`` otherwise.
    """

    name: str
    credential_type: CredentialType
    api_key: Optional[str] = None
    oauth_path: Optional[Path] = None

ContainerRuntime

Bases: str, Enum

Container runtime binary for OCI sandbox execution.

Attributes:

Name Type Description
DOCKER

Docker container runtime.

PODMAN

Podman container runtime.

Source code in src/agentrelay/sandbox/core/config.py
51
52
53
54
55
56
57
58
59
60
class ContainerRuntime(str, Enum):
    """Container runtime binary for OCI sandbox execution.

    Attributes:
        DOCKER: Docker container runtime.
        PODMAN: Podman container runtime.
    """

    DOCKER = "docker"
    PODMAN = "podman"

CredentialType

Bases: str, Enum

Type of Anthropic credential for agent authentication.

Attributes:

Name Type Description
API_KEY

Pay-per-token API key (injected as env var).

OAUTH

Max plan OAuth token file (.credentials.json).

Source code in src/agentrelay/sandbox/core/config.py
63
64
65
66
67
68
69
70
71
72
class CredentialType(str, Enum):
    """Type of Anthropic credential for agent authentication.

    Attributes:
        API_KEY: Pay-per-token API key (injected as env var).
        OAUTH: Max plan OAuth token file (``.credentials.json``).
    """

    API_KEY = "api_key"
    OAUTH = "oauth"

IsolationConfig dataclass

Fully-resolved sandbox configuration for an agent or task.

All fields are required — partial/inherited configs are resolved during YAML parsing before constructing this type.

Attributes:

Name Type Description
sandbox_type SandboxType

Type of sandbox boundary (none or OCI container).

token_tier TokenTier

Permission tier for credential injection.

image Optional[str]

Container image name, or None for default.

runtime Optional[ContainerRuntime]

Container runtime, or None for default (Docker).

Source code in src/agentrelay/sandbox/core/config.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@dataclass(frozen=True)
class IsolationConfig:
    """Fully-resolved sandbox configuration for an agent or task.

    All fields are required — partial/inherited configs are resolved
    during YAML parsing before constructing this type.

    Attributes:
        sandbox_type: Type of sandbox boundary (none or OCI container).
        token_tier: Permission tier for credential injection.
        image: Container image name, or None for default.
        runtime: Container runtime, or None for default (Docker).
    """

    sandbox_type: SandboxType
    token_tier: TokenTier
    image: Optional[str] = None
    runtime: Optional[ContainerRuntime] = None

SandboxContext dataclass

Execution context passed to sandbox operations.

Provides the sandbox implementation with the paths, identifiers, and environment variables it needs to set up and wrap agent commands.

Attributes:

Name Type Description
worktree_path Path

Absolute path to the git worktree for this task.

signal_dir Path

Absolute path to the signal directory for this task.

repo_path Path

Absolute path to the main repository.

task_id str

Unique identifier of the task being sandboxed.

graph_name str

Name of the task graph being executed.

attempt_num int

Current attempt number (0-indexed). Used to generate unique container and tmux window names across retries.

env_vars dict[str, str]

Environment variables to inject into the sandbox.

Source code in src/agentrelay/sandbox/core/config.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@dataclass(frozen=True)
class SandboxContext:
    """Execution context passed to sandbox operations.

    Provides the sandbox implementation with the paths, identifiers, and
    environment variables it needs to set up and wrap agent commands.

    Attributes:
        worktree_path: Absolute path to the git worktree for this task.
        signal_dir: Absolute path to the signal directory for this task.
        repo_path: Absolute path to the main repository.
        task_id: Unique identifier of the task being sandboxed.
        graph_name: Name of the task graph being executed.
        attempt_num: Current attempt number (0-indexed). Used to generate
            unique container and tmux window names across retries.
        env_vars: Environment variables to inject into the sandbox.
    """

    worktree_path: Path
    signal_dir: Path
    repo_path: Path
    task_id: str
    graph_name: str
    attempt_num: int = 0
    env_vars: dict[str, str] = field(default_factory=dict)

SandboxType

Bases: str, Enum

Type of sandbox execution boundary.

Attributes:

Name Type Description
NONE

No sandbox — agent runs directly on the host.

OCI

Agent runs inside an OCI container (Docker/Podman).

Source code in src/agentrelay/sandbox/core/config.py
25
26
27
28
29
30
31
32
33
34
class SandboxType(str, Enum):
    """Type of sandbox execution boundary.

    Attributes:
        NONE: No sandbox — agent runs directly on the host.
        OCI: Agent runs inside an OCI container (Docker/Podman).
    """

    NONE = "none"
    OCI = "oci"

TokenTier

Bases: str, Enum

Permission tier for credential injection into sandboxed agents.

Attributes:

Name Type Description
READ_ONLY

Read-only access (e.g., repo clone, PR read).

STANDARD

Standard access (e.g., push branches, create PRs).

ELEVATED

Elevated access (e.g., merge PRs, admin operations).

Source code in src/agentrelay/sandbox/core/config.py
37
38
39
40
41
42
43
44
45
46
47
48
class TokenTier(str, Enum):
    """Permission tier for credential injection into sandboxed agents.

    Attributes:
        READ_ONLY: Read-only access (e.g., repo clone, PR read).
        STANDARD: Standard access (e.g., push branches, create PRs).
        ELEVATED: Elevated access (e.g., merge PRs, admin operations).
    """

    READ_ONLY = "read_only"
    STANDARD = "standard"
    ELEVATED = "elevated"

CredentialProvider

Bases: Protocol

Protocol for resolving a token tier into credential environment variables.

Implementations map a :class:TokenTier to a dictionary of environment variable names and values that should be injected into the agent's execution environment.

Methods:

Name Description
resolve

Resolve a token tier into credential environment variables.

Source code in src/agentrelay/sandbox/core/credentials.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@runtime_checkable
class CredentialProvider(Protocol):
    """Protocol for resolving a token tier into credential environment variables.

    Implementations map a :class:`TokenTier` to a dictionary of environment
    variable names and values that should be injected into the agent's
    execution environment.

    Methods:
        resolve: Resolve a token tier into credential environment variables.
    """

    def resolve(self, tier: TokenTier) -> dict[str, str]:
        """Resolve a token tier into credential environment variables.

        Args:
            tier: Permission tier to resolve credentials for.

        Returns:
            Dictionary of environment variable names to values.
        """
        ...

resolve(tier)

Resolve a token tier into credential environment variables.

Parameters:

Name Type Description Default
tier TokenTier

Permission tier to resolve credentials for.

required

Returns:

Type Description
dict[str, str]

Dictionary of environment variable names to values.

Source code in src/agentrelay/sandbox/core/credentials.py
28
29
30
31
32
33
34
35
36
37
def resolve(self, tier: TokenTier) -> dict[str, str]:
    """Resolve a token tier into credential environment variables.

    Args:
        tier: Permission tier to resolve credentials for.

    Returns:
        Dictionary of environment variable names to values.
    """
    ...

SandboxInfrastructureManager

Bases: Protocol

Protocol for managing graph-level sandbox infrastructure.

Implementations handle the setup and teardown of infrastructure required by sandboxed agents — for example, creating and removing Docker networks for OCI-isolated tasks.

The graph name (needed for network naming) is captured at construction time by the factory, keeping the protocol methods argument-free.

Methods:

Name Description
setup

Provision infrastructure before orchestrator execution.

teardown

Clean up infrastructure after orchestrator execution.

Source code in src/agentrelay/sandbox/core/infrastructure.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@runtime_checkable
class SandboxInfrastructureManager(Protocol):
    """Protocol for managing graph-level sandbox infrastructure.

    Implementations handle the setup and teardown of infrastructure
    required by sandboxed agents — for example, creating and removing
    Docker networks for OCI-isolated tasks.

    The graph name (needed for network naming) is captured at
    construction time by the factory, keeping the protocol methods
    argument-free.

    Methods:
        setup: Provision infrastructure before orchestrator execution.
        teardown: Clean up infrastructure after orchestrator execution.
    """

    def setup(self) -> None:
        """Provision sandbox infrastructure.

        Raises:
            RuntimeError: If required infrastructure cannot be created.
        """
        ...

    def teardown(self) -> None:
        """Clean up sandbox infrastructure.

        Best-effort: implementations should swallow errors when
        resources are already removed.
        """
        ...

setup()

Provision sandbox infrastructure.

Raises:

Type Description
RuntimeError

If required infrastructure cannot be created.

Source code in src/agentrelay/sandbox/core/infrastructure.py
32
33
34
35
36
37
38
def setup(self) -> None:
    """Provision sandbox infrastructure.

    Raises:
        RuntimeError: If required infrastructure cannot be created.
    """
    ...

teardown()

Clean up sandbox infrastructure.

Best-effort: implementations should swallow errors when resources are already removed.

Source code in src/agentrelay/sandbox/core/infrastructure.py
40
41
42
43
44
45
46
def teardown(self) -> None:
    """Clean up sandbox infrastructure.

    Best-effort: implementations should swallow errors when
    resources are already removed.
    """
    ...

AgentSandbox

Bases: Protocol

Protocol for wrapping agent commands with sandbox isolation.

Implementations control how an agent command is executed — directly on the host (NullSandbox), inside a container (OciSandbox), etc.

Methods:

Name Description
wrap_command

Transform a raw agent command into a sandboxed command.

setup

Perform any pre-launch setup (e.g., create Docker network).

teardown

Clean up sandbox resources after agent completion.

Source code in src/agentrelay/sandbox/core/sandbox.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@runtime_checkable
class AgentSandbox(Protocol):
    """Protocol for wrapping agent commands with sandbox isolation.

    Implementations control how an agent command is executed — directly
    on the host (NullSandbox), inside a container (OciSandbox), etc.

    Methods:
        wrap_command: Transform a raw agent command into a sandboxed command.
        setup: Perform any pre-launch setup (e.g., create Docker network).
        teardown: Clean up sandbox resources after agent completion.
    """

    def wrap_command(self, cmd: str, context: SandboxContext) -> str:
        """Wrap an agent command string with sandbox isolation.

        Args:
            cmd: The raw agent command to execute.
            context: Execution context with paths and environment.

        Returns:
            The wrapped command string ready for execution.
        """
        ...

    def setup(self, context: SandboxContext) -> None:
        """Perform pre-launch sandbox setup.

        Args:
            context: Execution context with paths and environment.
        """
        ...

    def teardown(self, context: SandboxContext) -> None:
        """Clean up sandbox resources after agent completion.

        Args:
            context: Execution context with paths and environment.
        """
        ...

wrap_command(cmd, context)

Wrap an agent command string with sandbox isolation.

Parameters:

Name Type Description Default
cmd str

The raw agent command to execute.

required
context SandboxContext

Execution context with paths and environment.

required

Returns:

Type Description
str

The wrapped command string ready for execution.

Source code in src/agentrelay/sandbox/core/sandbox.py
29
30
31
32
33
34
35
36
37
38
39
def wrap_command(self, cmd: str, context: SandboxContext) -> str:
    """Wrap an agent command string with sandbox isolation.

    Args:
        cmd: The raw agent command to execute.
        context: Execution context with paths and environment.

    Returns:
        The wrapped command string ready for execution.
    """
    ...

setup(context)

Perform pre-launch sandbox setup.

Parameters:

Name Type Description Default
context SandboxContext

Execution context with paths and environment.

required
Source code in src/agentrelay/sandbox/core/sandbox.py
41
42
43
44
45
46
47
def setup(self, context: SandboxContext) -> None:
    """Perform pre-launch sandbox setup.

    Args:
        context: Execution context with paths and environment.
    """
    ...

teardown(context)

Clean up sandbox resources after agent completion.

Parameters:

Name Type Description Default
context SandboxContext

Execution context with paths and environment.

required
Source code in src/agentrelay/sandbox/core/sandbox.py
49
50
51
52
53
54
55
def teardown(self, context: SandboxContext) -> None:
    """Clean up sandbox resources after agent completion.

    Args:
        context: Execution context with paths and environment.
    """
    ...

ClaudeCodeAdapter

Build the CLI command string for launching a Claude Code agent.

Constructs the claude CLI invocation with the signal directory environment variable, optional model flag, and permission skip flag.

Source code in src/agentrelay/sandbox/implementations/claude_code_adapter.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class ClaudeCodeAdapter:
    """Build the CLI command string for launching a Claude Code agent.

    Constructs the ``claude`` CLI invocation with the signal directory
    environment variable, optional model flag, and permission skip flag.
    """

    def build_command(self, config: AgentConfig, signal_dir: Path) -> str:
        """Build the Claude Code CLI command string.

        Args:
            config: Agent configuration. Uses ``config.model`` for the
                ``--model`` flag (omitted when ``None``).
            signal_dir: Path to the signal directory, injected as the
                ``AGENTRELAY_SIGNAL_DIR`` environment variable prefix.

        Returns:
            The raw CLI command string, e.g.::

                AGENTRELAY_SIGNAL_DIR="/path" claude --model X --dangerously-skip-permissions
        """
        model_flag = f" --model {config.model}" if config.model else ""
        return (
            f'AGENTRELAY_SIGNAL_DIR="{signal_dir}"'
            f" claude{model_flag}"
            f" --dangerously-skip-permissions"
        )

build_command(config, signal_dir)

Build the Claude Code CLI command string.

Parameters:

Name Type Description Default
config AgentConfig

Agent configuration. Uses config.model for the --model flag (omitted when None).

required
signal_dir Path

Path to the signal directory, injected as the AGENTRELAY_SIGNAL_DIR environment variable prefix.

required

Returns:

Type Description
str

The raw CLI command string, e.g.::

AGENTRELAY_SIGNAL_DIR="/path" claude --model X --dangerously-skip-permissions

Source code in src/agentrelay/sandbox/implementations/claude_code_adapter.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def build_command(self, config: AgentConfig, signal_dir: Path) -> str:
    """Build the Claude Code CLI command string.

    Args:
        config: Agent configuration. Uses ``config.model`` for the
            ``--model`` flag (omitted when ``None``).
        signal_dir: Path to the signal directory, injected as the
            ``AGENTRELAY_SIGNAL_DIR`` environment variable prefix.

    Returns:
        The raw CLI command string, e.g.::

            AGENTRELAY_SIGNAL_DIR="/path" claude --model X --dangerously-skip-permissions
    """
    model_flag = f" --model {config.model}" if config.model else ""
    return (
        f'AGENTRELAY_SIGNAL_DIR="{signal_dir}"'
        f" claude{model_flag}"
        f" --dangerously-skip-permissions"
    )

FileCredentialProvider

Resolve credential environment variables from a YAML file.

The YAML file contains a token_tiers mapping keyed by tier value and an optional anthropic mapping of named Anthropic credentials. On :meth:resolve, the requested tier's entries are returned.

The file is read once at construction time and cached.

Attributes:

Name Type Description
path

Path to the YAML credential file.

Source code in src/agentrelay/sandbox/implementations/file_credentials.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
class FileCredentialProvider:
    """Resolve credential environment variables from a YAML file.

    The YAML file contains a ``token_tiers`` mapping keyed by tier value
    and an optional ``anthropic`` mapping of named Anthropic credentials.
    On :meth:`resolve`, the requested tier's entries are returned.

    The file is read once at construction time and cached.

    Attributes:
        path: Path to the YAML credential file.
    """

    def __init__(self, path: Path) -> None:
        """Load and parse the credential YAML file.

        Args:
            path: Path to the YAML credential file.

        Raises:
            FileNotFoundError: If the file does not exist.
            ValueError: If the file is not valid YAML or has an
                unexpected structure.
        """
        self.path = path
        text = path.read_text()
        data = yaml.safe_load(text)
        if not isinstance(data, dict):
            raise ValueError(
                f"Credential file must be a YAML mapping, got {type(data).__name__}"
            )

        raw_tiers = data.get("token_tiers", {})
        if not isinstance(raw_tiers, dict):
            raise ValueError(
                f"token_tiers must be a mapping, got {type(raw_tiers).__name__}"
            )
        self._tiers: dict[str, dict[str, str]] = {}
        for tier_key, tier_vars in raw_tiers.items():
            if not isinstance(tier_vars, dict):
                raise ValueError(
                    f"token_tiers.{tier_key} must be a mapping, "
                    f"got {type(tier_vars).__name__}"
                )
            self._tiers[str(tier_key)] = {str(k): str(v) for k, v in tier_vars.items()}

        # Parse optional anthropic credentials section.
        raw_anthropic = data.get("anthropic", {})
        if not isinstance(raw_anthropic, dict):
            raise ValueError(
                f"anthropic must be a mapping, got {type(raw_anthropic).__name__}"
            )
        self._anthropic: dict[str, AnthropicCredential] = {}
        for name, entry in raw_anthropic.items():
            if not isinstance(entry, dict):
                raise ValueError(
                    f"anthropic.{name} must be a mapping, "
                    f"got {type(entry).__name__}"
                )
            if "type" not in entry:
                raise ValueError(f"anthropic.{name} must have a 'type' field")
            try:
                ctype = CredentialType(entry["type"])
            except ValueError:
                raise ValueError(
                    f"anthropic.{name}.type must be one of "
                    f"{[t.value for t in CredentialType]}, got {entry['type']!r}"
                )
            if ctype == CredentialType.API_KEY:
                has_key = "key" in entry
                has_key_file = "key_file" in entry
                if has_key and has_key_file:
                    raise ValueError(
                        f"anthropic.{name} has both 'key' and 'key_file'; "
                        "specify exactly one"
                    )
                if has_key:
                    api_key = str(entry["key"])
                elif has_key_file:
                    key_path = Path(str(entry["key_file"])).expanduser()
                    try:
                        api_key = key_path.read_text().strip()
                    except FileNotFoundError:
                        raise ValueError(
                            f"anthropic.{name}.key_file not found: {key_path}"
                        )
                else:
                    raise ValueError(
                        f"anthropic.{name} with type 'api_key' must have "
                        "a 'key' or 'key_file' field"
                    )
                self._anthropic[str(name)] = AnthropicCredential(
                    name=str(name),
                    credential_type=ctype,
                    api_key=api_key,
                )
            elif ctype == CredentialType.OAUTH:
                if "path" not in entry:
                    raise ValueError(
                        f"anthropic.{name} with type 'oauth' must have a 'path' field"
                    )
                self._anthropic[str(name)] = AnthropicCredential(
                    name=str(name),
                    credential_type=ctype,
                    oauth_path=Path(str(entry["path"])).expanduser(),
                )

    def resolve(self, tier: TokenTier) -> dict[str, str]:
        """Resolve credentials for the given token tier.

        Returns the tier-specific credential environment variables.

        Args:
            tier: Permission tier to resolve credentials for.

        Returns:
            Dictionary of environment variable names to values.

        Raises:
            ValueError: If the requested tier is not defined in the
                credential file.
        """
        if tier.value not in self._tiers:
            raise ValueError(
                f"Token tier {tier.value!r} not found in credential file "
                f"{self.path}; available tiers: "
                f"{sorted(self._tiers.keys())}"
            )
        return dict(self._tiers[tier.value])

    def resolve_anthropic(
        self, name: Optional[str] = None
    ) -> Optional[AnthropicCredential]:
        """Resolve a named Anthropic credential.

        Args:
            name: Credential name from the YAML ``anthropic`` section.
                When ``None``, auto-selects if exactly one entry exists.

        Returns:
            The resolved credential, or ``None`` if no ``anthropic``
            section is defined.

        Raises:
            ValueError: If ``name`` is not found, or if multiple entries
                exist and ``name`` is not specified.
        """
        if not self._anthropic:
            return None
        if name is not None:
            if name not in self._anthropic:
                raise ValueError(
                    f"Anthropic credential {name!r} not found in {self.path}; "
                    f"available: {sorted(self._anthropic.keys())}"
                )
            return self._anthropic[name]
        if len(self._anthropic) == 1:
            return next(iter(self._anthropic.values()))
        raise ValueError(
            f"Multiple Anthropic credentials defined in {self.path}; "
            f"specify one with --anthropic-credential: "
            f"{sorted(self._anthropic.keys())}"
        )

    @property
    def anthropic_names(self) -> list[str]:
        """Sorted list of available Anthropic credential names."""
        return sorted(self._anthropic.keys())

anthropic_names property

Sorted list of available Anthropic credential names.

__init__(path)

Load and parse the credential YAML file.

Parameters:

Name Type Description Default
path Path

Path to the YAML credential file.

required

Raises:

Type Description
FileNotFoundError

If the file does not exist.

ValueError

If the file is not valid YAML or has an unexpected structure.

Source code in src/agentrelay/sandbox/implementations/file_credentials.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def __init__(self, path: Path) -> None:
    """Load and parse the credential YAML file.

    Args:
        path: Path to the YAML credential file.

    Raises:
        FileNotFoundError: If the file does not exist.
        ValueError: If the file is not valid YAML or has an
            unexpected structure.
    """
    self.path = path
    text = path.read_text()
    data = yaml.safe_load(text)
    if not isinstance(data, dict):
        raise ValueError(
            f"Credential file must be a YAML mapping, got {type(data).__name__}"
        )

    raw_tiers = data.get("token_tiers", {})
    if not isinstance(raw_tiers, dict):
        raise ValueError(
            f"token_tiers must be a mapping, got {type(raw_tiers).__name__}"
        )
    self._tiers: dict[str, dict[str, str]] = {}
    for tier_key, tier_vars in raw_tiers.items():
        if not isinstance(tier_vars, dict):
            raise ValueError(
                f"token_tiers.{tier_key} must be a mapping, "
                f"got {type(tier_vars).__name__}"
            )
        self._tiers[str(tier_key)] = {str(k): str(v) for k, v in tier_vars.items()}

    # Parse optional anthropic credentials section.
    raw_anthropic = data.get("anthropic", {})
    if not isinstance(raw_anthropic, dict):
        raise ValueError(
            f"anthropic must be a mapping, got {type(raw_anthropic).__name__}"
        )
    self._anthropic: dict[str, AnthropicCredential] = {}
    for name, entry in raw_anthropic.items():
        if not isinstance(entry, dict):
            raise ValueError(
                f"anthropic.{name} must be a mapping, "
                f"got {type(entry).__name__}"
            )
        if "type" not in entry:
            raise ValueError(f"anthropic.{name} must have a 'type' field")
        try:
            ctype = CredentialType(entry["type"])
        except ValueError:
            raise ValueError(
                f"anthropic.{name}.type must be one of "
                f"{[t.value for t in CredentialType]}, got {entry['type']!r}"
            )
        if ctype == CredentialType.API_KEY:
            has_key = "key" in entry
            has_key_file = "key_file" in entry
            if has_key and has_key_file:
                raise ValueError(
                    f"anthropic.{name} has both 'key' and 'key_file'; "
                    "specify exactly one"
                )
            if has_key:
                api_key = str(entry["key"])
            elif has_key_file:
                key_path = Path(str(entry["key_file"])).expanduser()
                try:
                    api_key = key_path.read_text().strip()
                except FileNotFoundError:
                    raise ValueError(
                        f"anthropic.{name}.key_file not found: {key_path}"
                    )
            else:
                raise ValueError(
                    f"anthropic.{name} with type 'api_key' must have "
                    "a 'key' or 'key_file' field"
                )
            self._anthropic[str(name)] = AnthropicCredential(
                name=str(name),
                credential_type=ctype,
                api_key=api_key,
            )
        elif ctype == CredentialType.OAUTH:
            if "path" not in entry:
                raise ValueError(
                    f"anthropic.{name} with type 'oauth' must have a 'path' field"
                )
            self._anthropic[str(name)] = AnthropicCredential(
                name=str(name),
                credential_type=ctype,
                oauth_path=Path(str(entry["path"])).expanduser(),
            )

resolve(tier)

Resolve credentials for the given token tier.

Returns the tier-specific credential environment variables.

Parameters:

Name Type Description Default
tier TokenTier

Permission tier to resolve credentials for.

required

Returns:

Type Description
dict[str, str]

Dictionary of environment variable names to values.

Raises:

Type Description
ValueError

If the requested tier is not defined in the credential file.

Source code in src/agentrelay/sandbox/implementations/file_credentials.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def resolve(self, tier: TokenTier) -> dict[str, str]:
    """Resolve credentials for the given token tier.

    Returns the tier-specific credential environment variables.

    Args:
        tier: Permission tier to resolve credentials for.

    Returns:
        Dictionary of environment variable names to values.

    Raises:
        ValueError: If the requested tier is not defined in the
            credential file.
    """
    if tier.value not in self._tiers:
        raise ValueError(
            f"Token tier {tier.value!r} not found in credential file "
            f"{self.path}; available tiers: "
            f"{sorted(self._tiers.keys())}"
        )
    return dict(self._tiers[tier.value])

resolve_anthropic(name=None)

Resolve a named Anthropic credential.

Parameters:

Name Type Description Default
name Optional[str]

Credential name from the YAML anthropic section. When None, auto-selects if exactly one entry exists.

None

Returns:

Type Description
Optional[AnthropicCredential]

The resolved credential, or None if no anthropic

Optional[AnthropicCredential]

section is defined.

Raises:

Type Description
ValueError

If name is not found, or if multiple entries exist and name is not specified.

Source code in src/agentrelay/sandbox/implementations/file_credentials.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def resolve_anthropic(
    self, name: Optional[str] = None
) -> Optional[AnthropicCredential]:
    """Resolve a named Anthropic credential.

    Args:
        name: Credential name from the YAML ``anthropic`` section.
            When ``None``, auto-selects if exactly one entry exists.

    Returns:
        The resolved credential, or ``None`` if no ``anthropic``
        section is defined.

    Raises:
        ValueError: If ``name`` is not found, or if multiple entries
            exist and ``name`` is not specified.
    """
    if not self._anthropic:
        return None
    if name is not None:
        if name not in self._anthropic:
            raise ValueError(
                f"Anthropic credential {name!r} not found in {self.path}; "
                f"available: {sorted(self._anthropic.keys())}"
            )
        return self._anthropic[name]
    if len(self._anthropic) == 1:
        return next(iter(self._anthropic.values()))
    raise ValueError(
        f"Multiple Anthropic credentials defined in {self.path}; "
        f"specify one with --anthropic-credential: "
        f"{sorted(self._anthropic.keys())}"
    )

NullCredentialProvider

Credential provider that returns empty credentials for any tier.

Always returns an empty dictionary regardless of the requested token tier. This is the default credential provider for SandboxType.NONE.

Source code in src/agentrelay/sandbox/implementations/null_credentials.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class NullCredentialProvider:
    """Credential provider that returns empty credentials for any tier.

    Always returns an empty dictionary regardless of the requested token
    tier. This is the default credential provider for ``SandboxType.NONE``.
    """

    def resolve(self, tier: TokenTier) -> dict[str, str]:
        """Return an empty credential dictionary.

        Args:
            tier: Permission tier (unused).

        Returns:
            An empty dictionary.
        """
        return {}

resolve(tier)

Return an empty credential dictionary.

Parameters:

Name Type Description Default
tier TokenTier

Permission tier (unused).

required

Returns:

Type Description
dict[str, str]

An empty dictionary.

Source code in src/agentrelay/sandbox/implementations/null_credentials.py
22
23
24
25
26
27
28
29
30
31
def resolve(self, tier: TokenTier) -> dict[str, str]:
    """Return an empty credential dictionary.

    Args:
        tier: Permission tier (unused).

    Returns:
        An empty dictionary.
    """
    return {}

NullSandboxInfrastructureManager

No-op infrastructure manager for graphs without OCI tasks.

Both :meth:setup and :meth:teardown are no-ops.

Source code in src/agentrelay/sandbox/implementations/null_infrastructure.py
11
12
13
14
15
16
17
18
19
20
21
class NullSandboxInfrastructureManager:
    """No-op infrastructure manager for graphs without OCI tasks.

    Both :meth:`setup` and :meth:`teardown` are no-ops.
    """

    def setup(self) -> None:
        """No-op setup."""

    def teardown(self) -> None:
        """No-op teardown."""

setup()

No-op setup.

Source code in src/agentrelay/sandbox/implementations/null_infrastructure.py
17
18
def setup(self) -> None:
    """No-op setup."""

teardown()

No-op teardown.

Source code in src/agentrelay/sandbox/implementations/null_infrastructure.py
20
21
def teardown(self) -> None:
    """No-op teardown."""

NullSandbox

Pass-through sandbox that applies no isolation.

All methods are no-ops: wrap_command returns the command unchanged, and setup/teardown do nothing. This is the default sandbox for SandboxType.NONE.

Source code in src/agentrelay/sandbox/implementations/null_sandbox.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class NullSandbox:
    """Pass-through sandbox that applies no isolation.

    All methods are no-ops: ``wrap_command`` returns the command unchanged,
    and ``setup``/``teardown`` do nothing. This is the default sandbox for
    ``SandboxType.NONE``.
    """

    def wrap_command(self, cmd: str, context: SandboxContext) -> str:
        """Return the command unchanged.

        Args:
            cmd: The raw agent command.
            context: Execution context (unused).

        Returns:
            The original command string, unmodified.
        """
        return cmd

    def setup(self, context: SandboxContext) -> None:
        """No-op setup.

        Args:
            context: Execution context (unused).
        """

    def teardown(self, context: SandboxContext) -> None:
        """No-op teardown.

        Args:
            context: Execution context (unused).
        """

wrap_command(cmd, context)

Return the command unchanged.

Parameters:

Name Type Description Default
cmd str

The raw agent command.

required
context SandboxContext

Execution context (unused).

required

Returns:

Type Description
str

The original command string, unmodified.

Source code in src/agentrelay/sandbox/implementations/null_sandbox.py
22
23
24
25
26
27
28
29
30
31
32
def wrap_command(self, cmd: str, context: SandboxContext) -> str:
    """Return the command unchanged.

    Args:
        cmd: The raw agent command.
        context: Execution context (unused).

    Returns:
        The original command string, unmodified.
    """
    return cmd

setup(context)

No-op setup.

Parameters:

Name Type Description Default
context SandboxContext

Execution context (unused).

required
Source code in src/agentrelay/sandbox/implementations/null_sandbox.py
34
35
36
37
38
39
def setup(self, context: SandboxContext) -> None:
    """No-op setup.

    Args:
        context: Execution context (unused).
    """

teardown(context)

No-op teardown.

Parameters:

Name Type Description Default
context SandboxContext

Execution context (unused).

required
Source code in src/agentrelay/sandbox/implementations/null_sandbox.py
41
42
43
44
45
46
def teardown(self, context: SandboxContext) -> None:
    """No-op teardown.

    Args:
        context: Execution context (unused).
    """

OciSandboxInfrastructureManager

Docker network lifecycle manager for OCI-sandboxed graphs.

Creates a Docker network named agentrelay-<graph_name> on setup() and removes it on teardown().

Parameters:

Name Type Description Default
graph_name str

Name of the graph (used for network naming).

required
Source code in src/agentrelay/sandbox/implementations/oci_infrastructure.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class OciSandboxInfrastructureManager:
    """Docker network lifecycle manager for OCI-sandboxed graphs.

    Creates a Docker network named ``agentrelay-<graph_name>`` on
    ``setup()`` and removes it on ``teardown()``.

    Args:
        graph_name: Name of the graph (used for network naming).
    """

    def __init__(self, graph_name: str) -> None:
        self._network_name = f"agentrelay-{graph_name}"

    def setup(self) -> None:
        """Create the Docker network if it does not already exist.

        Raises:
            RuntimeError: If Docker is not available.
        """
        if not docker_ops.is_available():
            raise RuntimeError(
                "Docker is required for OCI sandbox but is not available"
            )
        if not docker_ops.network_exists(self._network_name):
            docker_ops.network_create(self._network_name)

    def teardown(self) -> None:
        """Remove the Docker network (best-effort)."""
        try:
            docker_ops.network_remove(self._network_name)
        except Exception:
            pass  # Best-effort cleanup

setup()

Create the Docker network if it does not already exist.

Raises:

Type Description
RuntimeError

If Docker is not available.

Source code in src/agentrelay/sandbox/implementations/oci_infrastructure.py
27
28
29
30
31
32
33
34
35
36
37
38
def setup(self) -> None:
    """Create the Docker network if it does not already exist.

    Raises:
        RuntimeError: If Docker is not available.
    """
    if not docker_ops.is_available():
        raise RuntimeError(
            "Docker is required for OCI sandbox but is not available"
        )
    if not docker_ops.network_exists(self._network_name):
        docker_ops.network_create(self._network_name)

teardown()

Remove the Docker network (best-effort).

Source code in src/agentrelay/sandbox/implementations/oci_infrastructure.py
40
41
42
43
44
45
def teardown(self) -> None:
    """Remove the Docker network (best-effort)."""
    try:
        docker_ops.network_remove(self._network_name)
    except Exception:
        pass  # Best-effort cleanup

OciSandbox

OCI container sandbox — wraps agent commands in docker run.

Mounts the git worktree, signal directory, and main .git/ directory into the container at their original absolute paths so that internal git references resolve without modification.

Attributes:

Name Type Description
_image

Container image to use.

_runtime

Container runtime binary name ("docker" or "podman").

_anthropic_credential

Resolved Anthropic credential for agent authentication, or None for no Anthropic auth.

Source code in src/agentrelay/sandbox/implementations/oci_sandbox.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class OciSandbox:
    """OCI container sandbox — wraps agent commands in ``docker run``.

    Mounts the git worktree, signal directory, and main ``.git/``
    directory into the container at their original absolute paths so
    that internal git references resolve without modification.

    Attributes:
        _image: Container image to use.
        _runtime: Container runtime binary name (``"docker"`` or
            ``"podman"``).
        _anthropic_credential: Resolved Anthropic credential for
            agent authentication, or ``None`` for no Anthropic auth.
    """

    def __init__(
        self,
        image: str | None = None,
        runtime: ContainerRuntime | None = None,
        anthropic_credential: AnthropicCredential | None = None,
    ) -> None:
        self._image = image or _DEFAULT_IMAGE
        self._runtime = (runtime or ContainerRuntime.DOCKER).value
        self._anthropic_credential = anthropic_credential

    def setup(self, context: SandboxContext) -> None:
        """Validate runtime and network are available.

        Args:
            context: Execution context with graph name for network naming.

        Raises:
            RuntimeError: If the container runtime is not available or
                the Docker network does not exist.
        """
        if not docker_ops.is_available(self._runtime):
            raise RuntimeError(f"Container runtime '{self._runtime}' is not available")
        network = f"agentrelay-{context.graph_name}"
        if not docker_ops.network_exists(network, self._runtime):
            raise RuntimeError(
                f"Docker network '{network}' does not exist. "
                "Network must be created by run_graph before task execution."
            )

    def wrap_command(self, cmd: str, context: SandboxContext) -> str:
        """Wrap an agent command in ``docker run`` with bind mounts and env vars.

        Mounts:
            - worktree_path → worktree_path (read-write)
            - signal_dir → signal_dir (read-write)
            - main .git/ dir → same path (read-write, needed for commits)

        Args:
            cmd: The raw agent command to execute inside the container.
            context: Execution context with paths and environment.

        Returns:
            The full ``docker run ...`` command string.

        Raises:
            ValueError: If ``ANTHROPIC_API_KEY`` is found in
                ``context.env_vars`` (should come from
                :class:`AnthropicCredential`, not env vars).
        """
        if "ANTHROPIC_API_KEY" in context.env_vars:
            raise ValueError(
                "ANTHROPIC_API_KEY must not be in SandboxContext.env_vars; "
                "use the 'anthropic' section in the credentials YAML instead"
            )

        git_dir = git_ops.worktree_git_dir(context.worktree_path)
        workflow_dir = str(context.repo_path / ".workflow" / context.graph_name)
        volumes: list[tuple[str, str] | tuple[str, str, str]] = [
            (str(context.worktree_path), str(context.worktree_path)),
            (str(context.signal_dir), str(context.signal_dir)),
            (str(git_dir), str(git_dir)),
            # Read-only: graph YAML + peer signal directories.
            # Agent's own signal_dir (read-write, above) overlays for its subtree.
            (workflow_dir, workflow_dir, "ro"),
        ]

        # Anthropic credential injection — type-specific handling.
        extra_env: dict[str, str] = {}
        if self._anthropic_credential is not None:
            cred = self._anthropic_credential
            if cred.credential_type == CredentialType.API_KEY:
                assert cred.api_key is not None
                extra_env["_ANTHROPIC_API_KEY"] = cred.api_key
            elif cred.credential_type == CredentialType.OAUTH:
                assert cred.oauth_path is not None
                volumes.append(
                    (str(cred.oauth_path), "/tmp/.claude-credentials.json", "ro")
                )

        env_vars = {
            **context.env_vars,
            **extra_env,
            "IS_AI_AGENT": "true",
            "TERM": os.environ.get("TERM", "xterm-256color"),
            "DISABLE_AUTOUPDATER": "1",
            "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
        }
        # Startup chain: generate settings.json, seed folder trust, then agent.
        # Both scripts are baked into the framework image.
        full_cmd = f"claude-setup-credentials && claude-trust-workdir && {cmd}"
        return docker_ops.build_run_command(
            container_name=f"agentrelay-{context.graph_name}-{context.task_id}-{context.attempt_num}",
            image=self._image,
            cmd=full_cmd,
            volumes=volumes,
            env_vars=env_vars,
            labels={
                "agentrelay.graph": context.graph_name,
                "agentrelay.task": context.task_id,
            },
            network=f"agentrelay-{context.graph_name}",
            workdir=str(context.worktree_path),
            runtime=self._runtime,
        )

    def teardown(self, context: SandboxContext) -> None:
        """Stop and remove the container, swallowing errors if already gone.

        Args:
            context: Execution context with task ID for container naming.
        """
        name = (
            f"agentrelay-{context.graph_name}-{context.task_id}-{context.attempt_num}"
        )
        try:
            docker_ops.stop(name, self._runtime)
        except subprocess.CalledProcessError:
            pass
        try:
            docker_ops.rm(name, self._runtime)
        except subprocess.CalledProcessError:
            pass

setup(context)

Validate runtime and network are available.

Parameters:

Name Type Description Default
context SandboxContext

Execution context with graph name for network naming.

required

Raises:

Type Description
RuntimeError

If the container runtime is not available or the Docker network does not exist.

Source code in src/agentrelay/sandbox/implementations/oci_sandbox.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def setup(self, context: SandboxContext) -> None:
    """Validate runtime and network are available.

    Args:
        context: Execution context with graph name for network naming.

    Raises:
        RuntimeError: If the container runtime is not available or
            the Docker network does not exist.
    """
    if not docker_ops.is_available(self._runtime):
        raise RuntimeError(f"Container runtime '{self._runtime}' is not available")
    network = f"agentrelay-{context.graph_name}"
    if not docker_ops.network_exists(network, self._runtime):
        raise RuntimeError(
            f"Docker network '{network}' does not exist. "
            "Network must be created by run_graph before task execution."
        )

wrap_command(cmd, context)

Wrap an agent command in docker run with bind mounts and env vars.

Mounts
  • worktree_path → worktree_path (read-write)
  • signal_dir → signal_dir (read-write)
  • main .git/ dir → same path (read-write, needed for commits)

Parameters:

Name Type Description Default
cmd str

The raw agent command to execute inside the container.

required
context SandboxContext

Execution context with paths and environment.

required

Returns:

Type Description
str

The full docker run ... command string.

Raises:

Type Description
ValueError

If ANTHROPIC_API_KEY is found in context.env_vars (should come from :class:AnthropicCredential, not env vars).

Source code in src/agentrelay/sandbox/implementations/oci_sandbox.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def wrap_command(self, cmd: str, context: SandboxContext) -> str:
    """Wrap an agent command in ``docker run`` with bind mounts and env vars.

    Mounts:
        - worktree_path → worktree_path (read-write)
        - signal_dir → signal_dir (read-write)
        - main .git/ dir → same path (read-write, needed for commits)

    Args:
        cmd: The raw agent command to execute inside the container.
        context: Execution context with paths and environment.

    Returns:
        The full ``docker run ...`` command string.

    Raises:
        ValueError: If ``ANTHROPIC_API_KEY`` is found in
            ``context.env_vars`` (should come from
            :class:`AnthropicCredential`, not env vars).
    """
    if "ANTHROPIC_API_KEY" in context.env_vars:
        raise ValueError(
            "ANTHROPIC_API_KEY must not be in SandboxContext.env_vars; "
            "use the 'anthropic' section in the credentials YAML instead"
        )

    git_dir = git_ops.worktree_git_dir(context.worktree_path)
    workflow_dir = str(context.repo_path / ".workflow" / context.graph_name)
    volumes: list[tuple[str, str] | tuple[str, str, str]] = [
        (str(context.worktree_path), str(context.worktree_path)),
        (str(context.signal_dir), str(context.signal_dir)),
        (str(git_dir), str(git_dir)),
        # Read-only: graph YAML + peer signal directories.
        # Agent's own signal_dir (read-write, above) overlays for its subtree.
        (workflow_dir, workflow_dir, "ro"),
    ]

    # Anthropic credential injection — type-specific handling.
    extra_env: dict[str, str] = {}
    if self._anthropic_credential is not None:
        cred = self._anthropic_credential
        if cred.credential_type == CredentialType.API_KEY:
            assert cred.api_key is not None
            extra_env["_ANTHROPIC_API_KEY"] = cred.api_key
        elif cred.credential_type == CredentialType.OAUTH:
            assert cred.oauth_path is not None
            volumes.append(
                (str(cred.oauth_path), "/tmp/.claude-credentials.json", "ro")
            )

    env_vars = {
        **context.env_vars,
        **extra_env,
        "IS_AI_AGENT": "true",
        "TERM": os.environ.get("TERM", "xterm-256color"),
        "DISABLE_AUTOUPDATER": "1",
        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
    }
    # Startup chain: generate settings.json, seed folder trust, then agent.
    # Both scripts are baked into the framework image.
    full_cmd = f"claude-setup-credentials && claude-trust-workdir && {cmd}"
    return docker_ops.build_run_command(
        container_name=f"agentrelay-{context.graph_name}-{context.task_id}-{context.attempt_num}",
        image=self._image,
        cmd=full_cmd,
        volumes=volumes,
        env_vars=env_vars,
        labels={
            "agentrelay.graph": context.graph_name,
            "agentrelay.task": context.task_id,
        },
        network=f"agentrelay-{context.graph_name}",
        workdir=str(context.worktree_path),
        runtime=self._runtime,
    )

teardown(context)

Stop and remove the container, swallowing errors if already gone.

Parameters:

Name Type Description Default
context SandboxContext

Execution context with task ID for container naming.

required
Source code in src/agentrelay/sandbox/implementations/oci_sandbox.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def teardown(self, context: SandboxContext) -> None:
    """Stop and remove the container, swallowing errors if already gone.

    Args:
        context: Execution context with task ID for container naming.
    """
    name = (
        f"agentrelay-{context.graph_name}-{context.task_id}-{context.attempt_num}"
    )
    try:
        docker_ops.stop(name, self._runtime)
    except subprocess.CalledProcessError:
        pass
    try:
        docker_ops.rm(name, self._runtime)
    except subprocess.CalledProcessError:
        pass