agentic-dev-loop 1.0.0a2__tar.gz → 1.0.0a4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/PKG-INFO +31 -6
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/README.md +28 -4
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/pyproject.toml +7 -2
- agentic_dev_loop-1.0.0a4/src/agentic_dev_loop/__init__.py +1 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/agents.py +7 -4
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/config.py +6 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/dryrun.py +2 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/isolation.py +73 -23
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/stages.py +7 -4
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/PKG-INFO +31 -6
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/requires.txt +1 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_agents.py +17 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_config.py +33 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_dry_run.py +11 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_isolation.py +130 -5
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_isolation_integration.py +54 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_isolation_pbt.py +24 -2
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_stages.py +10 -0
- agentic_dev_loop-1.0.0a2/src/agentic_dev_loop/__init__.py +0 -1
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/actions.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/cli.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/graph.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/llm.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/proc.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/registry.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/results.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/viz.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/tests/test_viz.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.0.0a4
|
|
4
|
+
Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
|
|
5
5
|
Author: Luke Wilson
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/lukedawilson/agentic-dev-loop
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: openhands-sdk~=1.17.0
|
|
29
29
|
Requires-Dist: openhands-tools~=1.17.0
|
|
30
|
+
Requires-Dist: agent-client-protocol<1.0.0
|
|
30
31
|
Requires-Dist: langgraph>=1.2
|
|
31
32
|
Requires-Dist: langgraph-checkpoint-sqlite>=3.0
|
|
32
33
|
Requires-Dist: pydantic>=2.0
|
|
@@ -68,7 +69,7 @@ flowchart LR
|
|
|
68
69
|
| Feature | What it gives you |
|
|
69
70
|
| --- | --- |
|
|
70
71
|
| **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
|
|
71
|
-
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
72
|
+
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
72
73
|
| **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
|
|
73
74
|
| **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
|
|
74
75
|
| **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
|
|
@@ -89,8 +90,7 @@ LLM timeout (agent runs are long).
|
|
|
89
90
|
| LLM API key | The env var named by your pipeline's `api_key_env` (e.g. `OPENAI_API_KEY`) must be set — a missing key fails fast, naming it. |
|
|
90
91
|
|
|
91
92
|
```bash
|
|
92
|
-
pip install agentic-dev-loop==1.0.
|
|
93
|
-
# or: pip install --pre agentic-dev-loop
|
|
93
|
+
pip install agentic-dev-loop==1.0.0a3 # pin the current alpha release explicitly
|
|
94
94
|
agentic-dev-loop --version
|
|
95
95
|
```
|
|
96
96
|
|
|
@@ -191,10 +191,35 @@ steps:
|
|
|
191
191
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
|
|
192
192
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
193
193
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
194
|
-
| `env:` / `setup:`
|
|
194
|
+
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
|
|
195
195
|
| `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
|
|
196
196
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
|
|
197
197
|
|
|
198
|
+
### Per-stage networking
|
|
199
|
+
|
|
200
|
+
The sandbox is network-off by default. A step that legitimately needs egress —
|
|
201
|
+
a QA step that curls a service it booted, an agent that must hit an API or
|
|
202
|
+
fetch package metadata mid-conversation — declares it explicitly:
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
steps:
|
|
206
|
+
- name: fetch-docs
|
|
207
|
+
command: curl -fsSL https://example.com/spec.json -o spec.json
|
|
208
|
+
network: true # egress for this step only
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **Default off.** Every step runs with `--network none` unless it declares
|
|
212
|
+
`network: true`. A networked step runs in a second, otherwise-identical
|
|
213
|
+
sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
|
|
214
|
+
on the Docker bridge network — the default network-none container is always
|
|
215
|
+
created too, so no infrastructure work happens mid-run.
|
|
216
|
+
- **Command and agent steps** may declare it. Action steps are host-side and
|
|
217
|
+
never take it (a load-time error).
|
|
218
|
+
- **Author opt-in is the control.** A networked step gives its agent raw
|
|
219
|
+
egress for that stage; the default stays network-off everywhere. Reserve it
|
|
220
|
+
for stages that need it (QA, integration). The residual risk is documented
|
|
221
|
+
in [docs/setup.md](docs/setup.md).
|
|
222
|
+
|
|
198
223
|
## Development environment
|
|
199
224
|
|
|
200
225
|
```bash
|
|
@@ -30,7 +30,7 @@ flowchart LR
|
|
|
30
30
|
| Feature | What it gives you |
|
|
31
31
|
| --- | --- |
|
|
32
32
|
| **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
|
|
33
|
-
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
33
|
+
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
34
34
|
| **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
|
|
35
35
|
| **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
|
|
36
36
|
| **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
|
|
@@ -51,8 +51,7 @@ LLM timeout (agent runs are long).
|
|
|
51
51
|
| LLM API key | The env var named by your pipeline's `api_key_env` (e.g. `OPENAI_API_KEY`) must be set — a missing key fails fast, naming it. |
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
pip install agentic-dev-loop==1.0.
|
|
55
|
-
# or: pip install --pre agentic-dev-loop
|
|
54
|
+
pip install agentic-dev-loop==1.0.0a3 # pin the current alpha release explicitly
|
|
56
55
|
agentic-dev-loop --version
|
|
57
56
|
```
|
|
58
57
|
|
|
@@ -153,10 +152,35 @@ steps:
|
|
|
153
152
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
|
|
154
153
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
155
154
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
156
|
-
| `env:` / `setup:`
|
|
155
|
+
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
|
|
157
156
|
| `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
|
|
158
157
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
|
|
159
158
|
|
|
159
|
+
### Per-stage networking
|
|
160
|
+
|
|
161
|
+
The sandbox is network-off by default. A step that legitimately needs egress —
|
|
162
|
+
a QA step that curls a service it booted, an agent that must hit an API or
|
|
163
|
+
fetch package metadata mid-conversation — declares it explicitly:
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
steps:
|
|
167
|
+
- name: fetch-docs
|
|
168
|
+
command: curl -fsSL https://example.com/spec.json -o spec.json
|
|
169
|
+
network: true # egress for this step only
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **Default off.** Every step runs with `--network none` unless it declares
|
|
173
|
+
`network: true`. A networked step runs in a second, otherwise-identical
|
|
174
|
+
sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
|
|
175
|
+
on the Docker bridge network — the default network-none container is always
|
|
176
|
+
created too, so no infrastructure work happens mid-run.
|
|
177
|
+
- **Command and agent steps** may declare it. Action steps are host-side and
|
|
178
|
+
never take it (a load-time error).
|
|
179
|
+
- **Author opt-in is the control.** A networked step gives its agent raw
|
|
180
|
+
egress for that stage; the default stays network-off everywhere. Reserve it
|
|
181
|
+
for stages that need it (QA, integration). The residual risk is documented
|
|
182
|
+
in [docs/setup.md](docs/setup.md).
|
|
183
|
+
|
|
160
184
|
## Development environment
|
|
161
185
|
|
|
162
186
|
```bash
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "1.0.0a4"
|
|
8
|
+
description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = "Apache-2.0"
|
|
@@ -34,6 +34,11 @@ classifiers = [
|
|
|
34
34
|
dependencies = [
|
|
35
35
|
"openhands-sdk ~=1.17.0",
|
|
36
36
|
"openhands-tools ~=1.17.0",
|
|
37
|
+
# openhands-sdk declares agent-client-protocol>=0.8.1 with NO upper bound;
|
|
38
|
+
# the 1.0.0rc pre-releases drop acp.helpers (breaks sdk 1.17.0 imports).
|
|
39
|
+
# Without this cap, --pre installs resolve to the broken rc. See AGENTS.md
|
|
40
|
+
# error-handling rules + COMPAT-SHIMS.md for the SDK-compat pattern.
|
|
41
|
+
"agent-client-protocol<1.0.0",
|
|
37
42
|
"langgraph>=1.2",
|
|
38
43
|
"langgraph-checkpoint-sqlite>=3.0",
|
|
39
44
|
"pydantic>=2.0",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."""
|
|
@@ -64,12 +64,14 @@ class SandboxTerminalExecutor(ToolExecutor):
|
|
|
64
64
|
|
|
65
65
|
def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
|
|
66
66
|
env: dict[str, str] | None = None,
|
|
67
|
-
pipeline: str | None = None, step: str | None = None
|
|
67
|
+
pipeline: str | None = None, step: str | None = None,
|
|
68
|
+
network: bool = False):
|
|
68
69
|
self._sandbox = sandbox
|
|
69
70
|
self._workspace = workspace
|
|
70
71
|
self._env = env or {}
|
|
71
72
|
self._pipeline = pipeline
|
|
72
73
|
self._step = step
|
|
74
|
+
self._network = network
|
|
73
75
|
|
|
74
76
|
def __call__(self, action, conversation=None):
|
|
75
77
|
from openhands.tools.terminal import TerminalObservation
|
|
@@ -90,7 +92,7 @@ class SandboxTerminalExecutor(ToolExecutor):
|
|
|
90
92
|
request = SandboxExecutionRequest(
|
|
91
93
|
argv=command_argv(action.command), cwd=self._workspace,
|
|
92
94
|
env=self._env, timeout=action.timeout,
|
|
93
|
-
pipeline=self._pipeline, step=self._step)
|
|
95
|
+
pipeline=self._pipeline, step=self._step, network=self._network)
|
|
94
96
|
result = self._sandbox.run(request)
|
|
95
97
|
output = result.stdout + result.stderr
|
|
96
98
|
return TerminalObservation.from_text(
|
|
@@ -107,7 +109,8 @@ class SandboxTerminalExecutor(ToolExecutor):
|
|
|
107
109
|
|
|
108
110
|
def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
|
|
109
111
|
workspace: str = "", pipeline: str | None = None,
|
|
110
|
-
step: str | None = None
|
|
112
|
+
step: str | None = None,
|
|
113
|
+
network: bool = False) -> None:
|
|
111
114
|
"""Per-run re-registration of the `terminal` name (KD-4): a callable
|
|
112
115
|
factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
|
|
113
116
|
Pipelines are sequential, so re-registering per agent step is safe."""
|
|
@@ -120,6 +123,6 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
|
|
|
120
123
|
conv_state,
|
|
121
124
|
executor=SandboxTerminalExecutor(
|
|
122
125
|
sandbox, workspace=workspace, env=resolved_env,
|
|
123
|
-
pipeline=pipeline, step=step))
|
|
126
|
+
pipeline=pipeline, step=step, network=network))
|
|
124
127
|
|
|
125
128
|
register_tool("terminal", factory)
|
|
@@ -85,6 +85,7 @@ class StepConfig(BaseModel):
|
|
|
85
85
|
timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
|
|
86
86
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
87
87
|
setup: str | None = None # BR-13: command steps only
|
|
88
|
+
network: bool = False # per-stage egress (command + agent steps; action steps reject)
|
|
88
89
|
on_pass: RouteTarget | None = None
|
|
89
90
|
on_fail: RouteTarget | None = None
|
|
90
91
|
|
|
@@ -142,6 +143,11 @@ class StepConfig(BaseModel):
|
|
|
142
143
|
raise ValueError(
|
|
143
144
|
f"step {self.name!r}: 'setup' is only allowed on 'command' steps"
|
|
144
145
|
)
|
|
146
|
+
if self.network and self.action is not None:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"step {self.name!r}: 'network' is only allowed on 'agent' or "
|
|
149
|
+
"'command' steps"
|
|
150
|
+
)
|
|
145
151
|
return self
|
|
146
152
|
|
|
147
153
|
|
|
@@ -85,6 +85,7 @@ class SandboxExecutionRequest:
|
|
|
85
85
|
cwd: str # absolute container-side path (== host mount path)
|
|
86
86
|
env: dict[str, str] # resolved sandbox env (already scrubbed)
|
|
87
87
|
timeout: float | None = None # seconds; None = no limit
|
|
88
|
+
network: bool = False # route to the bridge-network exec container when True
|
|
88
89
|
# Host-side audit metadata (BR-11) — never sent into the container.
|
|
89
90
|
pipeline: str | None = None
|
|
90
91
|
step: str | None = None
|
|
@@ -92,6 +93,9 @@ class SandboxExecutionRequest:
|
|
|
92
93
|
def __post_init__(self) -> None:
|
|
93
94
|
if not self.argv:
|
|
94
95
|
raise ValueError("SandboxExecutionRequest.argv must be non-empty")
|
|
96
|
+
# Coerce Path-like cwd to str so the invariant "cwd is always a str"
|
|
97
|
+
# holds at the boundary (audit digest etc. serialize it).
|
|
98
|
+
object.__setattr__(self, "cwd", os.fspath(self.cwd))
|
|
95
99
|
if not (Path(self.cwd).is_absolute()
|
|
96
100
|
or PurePosixPath(self.cwd).is_absolute()):
|
|
97
101
|
raise ValueError(f"SandboxExecutionRequest.cwd must be absolute: {self.cwd!r}")
|
|
@@ -280,17 +284,27 @@ def host_user_flag() -> str | None:
|
|
|
280
284
|
return None
|
|
281
285
|
|
|
282
286
|
|
|
287
|
+
def exec_container_name(run_id: str, network: bool = False) -> str:
|
|
288
|
+
"""The execution container name for a network mode. Networked requests
|
|
289
|
+
land in a second, bridge-network container; the default is the always-on
|
|
290
|
+
network-none container (014 per-stage networking)."""
|
|
291
|
+
return f"{run_id}-exec-net" if network else f"{run_id}-exec"
|
|
292
|
+
|
|
293
|
+
|
|
283
294
|
def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
284
|
-
workspace: str) -> list[str]:
|
|
295
|
+
workspace: str, network: bool = False) -> list[str]:
|
|
285
296
|
"""Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
|
|
286
|
-
Single mount {workspace} (Q1);
|
|
287
|
-
|
|
297
|
+
Single mount {workspace} (Q1); ``--network none`` (bridge when ``network``
|
|
298
|
+
is true — 014 per-stage networking), --read-only, --cap-drop ALL; --user
|
|
299
|
+
host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
|
|
288
300
|
HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs). ``--rm`` lets the
|
|
289
301
|
daemon auto-remove the container the instant it exits, however it exits
|
|
290
302
|
(D1) — a SIGKILLed CLI never reaches close()."""
|
|
291
303
|
payload = [
|
|
292
|
-
"docker", "run", "--detach", "--rm", "--name",
|
|
293
|
-
|
|
304
|
+
"docker", "run", "--detach", "--rm", "--name",
|
|
305
|
+
exec_container_name(run_id, network),
|
|
306
|
+
"--network", "bridge" if network else "none",
|
|
307
|
+
"--read-only", "--cap-drop", "ALL",
|
|
294
308
|
"--memory", profile.memory, "--cpus", str(profile.cpus),
|
|
295
309
|
"--tmpfs", "/tmp", "--tmpfs", "/root",
|
|
296
310
|
]
|
|
@@ -410,20 +424,27 @@ def _request_digest(argv: list[str], cwd: str, env: dict[str, str]) -> str:
|
|
|
410
424
|
|
|
411
425
|
|
|
412
426
|
class DockerBackend(Sandbox):
|
|
413
|
-
"""Docker-based sandbox:
|
|
414
|
-
|
|
415
|
-
|
|
427
|
+
"""Docker-based sandbox: a persistent network-none execution container per
|
|
428
|
+
run, plus — when any step declares ``network: true`` — a second
|
|
429
|
+
bridge-network container (eagerly created + started, Q2). ``docker exec``
|
|
430
|
+
per request routes to the container matching its network mode; both are
|
|
431
|
+
destroyed on close(). The injected ``runner`` callable is the unit-test
|
|
432
|
+
seam."""
|
|
416
433
|
|
|
417
434
|
def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
|
|
418
|
-
profile: EffectiveProfile, runner: CommandRunner | None = None
|
|
435
|
+
profile: EffectiveProfile, runner: CommandRunner | None = None,
|
|
436
|
+
network: bool = False):
|
|
419
437
|
self._image = image
|
|
420
438
|
self._workspace = workspace
|
|
421
439
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
422
440
|
self._profile = profile
|
|
423
441
|
self._runner = runner or _default_runner
|
|
424
442
|
self._run_id = uuid.uuid4().hex
|
|
425
|
-
self._container =
|
|
443
|
+
self._container = exec_container_name(self._run_id, network=False)
|
|
444
|
+
self._network_container = exec_container_name(self._run_id, network=True)
|
|
445
|
+
self._network_enabled = network
|
|
426
446
|
self._started = False
|
|
447
|
+
self._network_started = False
|
|
427
448
|
|
|
428
449
|
# -- infrastructure (startup) -------------------------------------------
|
|
429
450
|
|
|
@@ -456,16 +477,32 @@ class DockerBackend(Sandbox):
|
|
|
456
477
|
def create_execution_container(self) -> None:
|
|
457
478
|
"""Eager create + start via ``docker run -d --rm`` (Q2, D1) — never
|
|
458
479
|
lazy; ``run -d`` is create + start in one call, so there is no
|
|
459
|
-
separate ``docker start``.
|
|
480
|
+
separate ``docker start``. When any step declares ``network: true`` a
|
|
481
|
+
second, otherwise-identical bridge-network container is started so no
|
|
482
|
+
infrastructure work happens mid-run (014 per-stage networking)."""
|
|
460
483
|
payload = docker_create_payload(
|
|
461
484
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
462
|
-
workspace=self._workspace)
|
|
485
|
+
workspace=self._workspace, network=False)
|
|
463
486
|
res = self._runner.run(payload, timeout=60)
|
|
464
487
|
if res.exit_code != 0:
|
|
465
488
|
raise SandboxError(
|
|
466
489
|
f"AE-SBX-010: container run failed: "
|
|
467
490
|
f"{res.stderr.strip() or res.stdout.strip()}")
|
|
468
491
|
self._started = True
|
|
492
|
+
if not self._network_enabled:
|
|
493
|
+
return
|
|
494
|
+
net_payload = docker_create_payload(
|
|
495
|
+
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
496
|
+
workspace=self._workspace, network=True)
|
|
497
|
+
net_res = self._runner.run(net_payload, timeout=60)
|
|
498
|
+
if net_res.exit_code != 0:
|
|
499
|
+
# reclaim the already-started network-none container — never leak it.
|
|
500
|
+
self._runner.run(["docker", "rm", "-f", self._container], timeout=60)
|
|
501
|
+
self._started = False
|
|
502
|
+
raise SandboxError(
|
|
503
|
+
f"AE-SBX-013: networked container run failed: "
|
|
504
|
+
f"{net_res.stderr.strip() or net_res.stdout.strip()}")
|
|
505
|
+
self._network_started = True
|
|
469
506
|
|
|
470
507
|
def provision(self, setup_cmd: str, stamp_key: str, stamp_dir: str,
|
|
471
508
|
force: bool) -> None:
|
|
@@ -488,11 +525,17 @@ class DockerBackend(Sandbox):
|
|
|
488
525
|
|
|
489
526
|
def run(self, request: SandboxExecutionRequest) -> SandboxExecutionResult:
|
|
490
527
|
_guard_git(request, self._profile.git_mutations_blocked)
|
|
528
|
+
container = self._network_container if request.network else self._container
|
|
529
|
+
if request.network and not self._network_started:
|
|
530
|
+
raise SandboxError(
|
|
531
|
+
"AE-SBX-013: networked execution requested but no networked "
|
|
532
|
+
"container is running — declare `network: true` on the step "
|
|
533
|
+
"(docs/setup.md §3)")
|
|
491
534
|
start = time.monotonic()
|
|
492
535
|
exec_argv = ["docker", "exec"]
|
|
493
536
|
for key, value in request.env.items():
|
|
494
537
|
exec_argv += ["-e", f"{key}={value}"]
|
|
495
|
-
exec_argv += ["-w", self._workspace,
|
|
538
|
+
exec_argv += ["-w", self._workspace, container]
|
|
496
539
|
runner_timeout = None
|
|
497
540
|
if request.timeout is not None:
|
|
498
541
|
exec_argv += ["/usr/bin/timeout", str(math.ceil(request.timeout))]
|
|
@@ -504,7 +547,7 @@ class DockerBackend(Sandbox):
|
|
|
504
547
|
result = SandboxExecutionResult(
|
|
505
548
|
exit_code=res.exit_code, stdout=res.stdout, stderr=res.stderr,
|
|
506
549
|
duration_seconds=duration, backend="docker", timed_out=timed_out)
|
|
507
|
-
self._audit(request, result)
|
|
550
|
+
self._audit(request, result, container)
|
|
508
551
|
if res.exit_code is None:
|
|
509
552
|
raise SandboxError(
|
|
510
553
|
f"AE-SBX-012: Docker daemon lost mid-run: "
|
|
@@ -516,12 +559,18 @@ class DockerBackend(Sandbox):
|
|
|
516
559
|
daemon may already have auto-removed the container by the time close()
|
|
517
560
|
runs, so a nonzero stop/rm exit code ("no such container") is treated
|
|
518
561
|
as success, never raised (D2)."""
|
|
519
|
-
if
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
562
|
+
if self._started:
|
|
563
|
+
self._runner.run(["docker", "stop", "-t", "5", self._container],
|
|
564
|
+
timeout=60)
|
|
565
|
+
# rm -f returns rc != 0 when --rm already removed it — ignore, never raise.
|
|
566
|
+
self._runner.run(["docker", "rm", "-f", self._container], timeout=60)
|
|
567
|
+
self._started = False
|
|
568
|
+
if self._network_started:
|
|
569
|
+
self._runner.run(["docker", "stop", "-t", "5", self._network_container],
|
|
570
|
+
timeout=60)
|
|
571
|
+
self._runner.run(["docker", "rm", "-f", self._network_container],
|
|
572
|
+
timeout=60)
|
|
573
|
+
self._network_started = False
|
|
525
574
|
|
|
526
575
|
# -- internal -----------------------------------------------------------
|
|
527
576
|
|
|
@@ -538,14 +587,14 @@ class DockerBackend(Sandbox):
|
|
|
538
587
|
"failed; refusing to continue")
|
|
539
588
|
|
|
540
589
|
def _audit(self, request: SandboxExecutionRequest,
|
|
541
|
-
result: SandboxExecutionResult) -> None:
|
|
590
|
+
result: SandboxExecutionResult, container: str) -> None:
|
|
542
591
|
append_audit_record(self._iso_state_dir, {
|
|
543
592
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
544
593
|
"pipeline": request.pipeline or "",
|
|
545
594
|
"step": request.step or "",
|
|
546
595
|
"request_digest": _request_digest(request.argv, request.cwd, request.env),
|
|
547
596
|
"backend": "docker",
|
|
548
|
-
"container_id":
|
|
597
|
+
"container_id": container,
|
|
549
598
|
"exit_code": result.exit_code,
|
|
550
599
|
"timed_out": result.timed_out,
|
|
551
600
|
"duration_seconds": result.duration_seconds,
|
|
@@ -710,9 +759,10 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
710
759
|
profile = resolve_effective_profile(cfg.isolation)
|
|
711
760
|
image = resolve_image_ref(profile.image)
|
|
712
761
|
iso_state_dir = isolation_state_dir(cfg)
|
|
762
|
+
needs_network = any(step.network for step in cfg.steps)
|
|
713
763
|
backend = DockerBackend(
|
|
714
764
|
image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
|
|
715
|
-
profile=profile, runner=runner)
|
|
765
|
+
profile=profile, runner=runner, network=needs_network)
|
|
716
766
|
backend.ensure_image()
|
|
717
767
|
backend.create_execution_container()
|
|
718
768
|
if refresh is None:
|
|
@@ -110,7 +110,8 @@ def run_agent(agent_name: str, message: str, runtime: Runtime,
|
|
|
110
110
|
env=step.env if step is not None else None,
|
|
111
111
|
workspace=str(runtime.workspace),
|
|
112
112
|
pipeline=runtime.cfg.name,
|
|
113
|
-
step=step.name if step is not None else None
|
|
113
|
+
step=step.name if step is not None else None,
|
|
114
|
+
network=step.network if step is not None else False)
|
|
114
115
|
agent = load_agent(runtime.agents_dir, agent_name, runtime.llm)
|
|
115
116
|
conv = Conversation(agent=agent, workspace=str(runtime.workspace),
|
|
116
117
|
max_iteration_per_run=max_iterations)
|
|
@@ -151,7 +152,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
|
|
|
151
152
|
env: dict[str, str] | None = None,
|
|
152
153
|
sandbox: Sandbox | None = None,
|
|
153
154
|
pipeline: str | None = None,
|
|
154
|
-
step: str | None = None
|
|
155
|
+
step: str | None = None,
|
|
156
|
+
network: bool = False) -> tuple[int | None, str, bool]:
|
|
155
157
|
"""Run a command step in the workspace, capturing combined stdout/stderr.
|
|
156
158
|
|
|
157
159
|
With a ``Sandbox`` the command routes through the isolation boundary: git
|
|
@@ -166,7 +168,7 @@ def _run_command(command: str, workspace: Path, timeout: float,
|
|
|
166
168
|
if sandbox is not None:
|
|
167
169
|
request = SandboxExecutionRequest(
|
|
168
170
|
argv=command_argv(command), cwd=str(workspace), env=env or {},
|
|
169
|
-
timeout=timeout, pipeline=pipeline, step=step)
|
|
171
|
+
timeout=timeout, pipeline=pipeline, step=step, network=network)
|
|
170
172
|
result = sandbox.run(request)
|
|
171
173
|
return result.exit_code, result.stdout + result.stderr, result.timed_out
|
|
172
174
|
if needs_shell(command):
|
|
@@ -346,7 +348,8 @@ def make_command_node(runtime: Runtime, step: StepConfig):
|
|
|
346
348
|
try:
|
|
347
349
|
exit_code, output, timed_out = _run_command(
|
|
348
350
|
step.command, runtime.workspace, timeout, env,
|
|
349
|
-
sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name
|
|
351
|
+
sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name,
|
|
352
|
+
network=step.network)
|
|
350
353
|
except Exception:
|
|
351
354
|
print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
|
|
352
355
|
return {"failed": True, "failed_step": step.name}
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/PKG-INFO
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.0.0a4
|
|
4
|
+
Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
|
|
5
5
|
Author: Luke Wilson
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/lukedawilson/agentic-dev-loop
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: openhands-sdk~=1.17.0
|
|
29
29
|
Requires-Dist: openhands-tools~=1.17.0
|
|
30
|
+
Requires-Dist: agent-client-protocol<1.0.0
|
|
30
31
|
Requires-Dist: langgraph>=1.2
|
|
31
32
|
Requires-Dist: langgraph-checkpoint-sqlite>=3.0
|
|
32
33
|
Requires-Dist: pydantic>=2.0
|
|
@@ -68,7 +69,7 @@ flowchart LR
|
|
|
68
69
|
| Feature | What it gives you |
|
|
69
70
|
| --- | --- |
|
|
70
71
|
| **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
|
|
71
|
-
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
72
|
+
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
|
|
72
73
|
| **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
|
|
73
74
|
| **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
|
|
74
75
|
| **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
|
|
@@ -89,8 +90,7 @@ LLM timeout (agent runs are long).
|
|
|
89
90
|
| LLM API key | The env var named by your pipeline's `api_key_env` (e.g. `OPENAI_API_KEY`) must be set — a missing key fails fast, naming it. |
|
|
90
91
|
|
|
91
92
|
```bash
|
|
92
|
-
pip install agentic-dev-loop==1.0.
|
|
93
|
-
# or: pip install --pre agentic-dev-loop
|
|
93
|
+
pip install agentic-dev-loop==1.0.0a3 # pin the current alpha release explicitly
|
|
94
94
|
agentic-dev-loop --version
|
|
95
95
|
```
|
|
96
96
|
|
|
@@ -191,10 +191,35 @@ steps:
|
|
|
191
191
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
|
|
192
192
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
193
193
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
194
|
-
| `env:` / `setup:`
|
|
194
|
+
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
|
|
195
195
|
| `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
|
|
196
196
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
|
|
197
197
|
|
|
198
|
+
### Per-stage networking
|
|
199
|
+
|
|
200
|
+
The sandbox is network-off by default. A step that legitimately needs egress —
|
|
201
|
+
a QA step that curls a service it booted, an agent that must hit an API or
|
|
202
|
+
fetch package metadata mid-conversation — declares it explicitly:
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
steps:
|
|
206
|
+
- name: fetch-docs
|
|
207
|
+
command: curl -fsSL https://example.com/spec.json -o spec.json
|
|
208
|
+
network: true # egress for this step only
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **Default off.** Every step runs with `--network none` unless it declares
|
|
212
|
+
`network: true`. A networked step runs in a second, otherwise-identical
|
|
213
|
+
sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
|
|
214
|
+
on the Docker bridge network — the default network-none container is always
|
|
215
|
+
created too, so no infrastructure work happens mid-run.
|
|
216
|
+
- **Command and agent steps** may declare it. Action steps are host-side and
|
|
217
|
+
never take it (a load-time error).
|
|
218
|
+
- **Author opt-in is the control.** A networked step gives its agent raw
|
|
219
|
+
egress for that stage; the default stays network-off everywhere. Reserve it
|
|
220
|
+
for stages that need it (QA, integration). The residual risk is documented
|
|
221
|
+
in [docs/setup.md](docs/setup.md).
|
|
222
|
+
|
|
198
223
|
## Development environment
|
|
199
224
|
|
|
200
225
|
```bash
|
|
@@ -144,6 +144,23 @@ class TestSandboxTerminalExecutor:
|
|
|
144
144
|
obs = ex(TerminalAction(command="sleep 5", timeout=1), None)
|
|
145
145
|
assert obs.timeout is True
|
|
146
146
|
|
|
147
|
+
def test_network_reaches_request(self, fake_sandbox, tmp_path):
|
|
148
|
+
from openhands.tools.terminal import TerminalAction
|
|
149
|
+
|
|
150
|
+
from agentic_dev_loop.agents import SandboxTerminalExecutor
|
|
151
|
+
ex = SandboxTerminalExecutor(fake_sandbox, workspace=str(tmp_path),
|
|
152
|
+
env={}, network=True)
|
|
153
|
+
ex(TerminalAction(command="curl https://example.com"), None)
|
|
154
|
+
assert fake_sandbox.requests[0].network is True
|
|
155
|
+
|
|
156
|
+
def test_network_defaults_off(self, fake_sandbox, tmp_path):
|
|
157
|
+
from openhands.tools.terminal import TerminalAction
|
|
158
|
+
|
|
159
|
+
from agentic_dev_loop.agents import SandboxTerminalExecutor
|
|
160
|
+
ex = SandboxTerminalExecutor(fake_sandbox, workspace=str(tmp_path), env={})
|
|
161
|
+
ex(TerminalAction(command="echo hi"), None)
|
|
162
|
+
assert fake_sandbox.requests[0].network is False
|
|
163
|
+
|
|
147
164
|
|
|
148
165
|
class TestRegisterSandboxTerminal:
|
|
149
166
|
def test_resolves_to_sandbox_executor(self, fake_sandbox, tmp_path):
|
|
@@ -460,3 +460,36 @@ class TestStepEnvAndSetup:
|
|
|
460
460
|
step = _load(tmp_path, data).steps[0]
|
|
461
461
|
rt = type(step).model_validate(step.model_dump())
|
|
462
462
|
assert rt.env == {"A": "x", "B": None}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class TestStepNetwork:
|
|
466
|
+
def test_network_defaults_false(self, tmp_path):
|
|
467
|
+
step = _load(tmp_path, _base()).steps[0]
|
|
468
|
+
assert step.network is False
|
|
469
|
+
|
|
470
|
+
def test_network_on_command_step(self, tmp_path):
|
|
471
|
+
data = _base()
|
|
472
|
+
data["steps"] = [{"name": "fetch", "command": "curl https://example.com",
|
|
473
|
+
"network": True, "on_pass": "success"}]
|
|
474
|
+
assert _load(tmp_path, data).steps[0].network is True
|
|
475
|
+
|
|
476
|
+
def test_network_on_agent_step(self, tmp_path):
|
|
477
|
+
data = _base()
|
|
478
|
+
data["steps"] = [{"name": "qa", "agent": "qa", "network": True,
|
|
479
|
+
"on_pass": "success"}]
|
|
480
|
+
assert _load(tmp_path, data).steps[0].network is True
|
|
481
|
+
|
|
482
|
+
def test_network_on_action_step_rejected(self, tmp_path):
|
|
483
|
+
data = _base()
|
|
484
|
+
data["steps"] = [{"name": "commit", "action": "commit", "network": True,
|
|
485
|
+
"on_pass": "success"}]
|
|
486
|
+
with pytest.raises(ValidationError,
|
|
487
|
+
match="'network' is only allowed on 'agent' or "
|
|
488
|
+
"'command' steps"):
|
|
489
|
+
_load(tmp_path, data)
|
|
490
|
+
|
|
491
|
+
def test_network_false_on_action_step_allowed(self, tmp_path):
|
|
492
|
+
data = _base()
|
|
493
|
+
data["steps"] = [{"name": "commit", "action": "commit", "network": False,
|
|
494
|
+
"on_pass": "success"}]
|
|
495
|
+
assert _load(tmp_path, data).steps[0].network is False
|
|
@@ -100,6 +100,17 @@ class TestDryRun:
|
|
|
100
100
|
"--no-viz", "--resume", "some-thread-id",
|
|
101
101
|
"--refresh", "--max-attempts", "5"]) == 0
|
|
102
102
|
|
|
103
|
+
def test_network_step_surfaced_in_summary(self, repo, capsys):
|
|
104
|
+
(repo / "pipeline.yaml").write_text(
|
|
105
|
+
DRY_RUN_YAML.replace(
|
|
106
|
+
" command: .venv-sandbox/bin/python -m pytest tests/ -q\n",
|
|
107
|
+
" command: .venv-sandbox/bin/python -m pytest tests/ -q\n"
|
|
108
|
+
" network: true\n"))
|
|
109
|
+
assert main(["pipeline.yaml", "--dry-run", "--plan", "plan.md"]) == 0
|
|
110
|
+
out = capsys.readouterr().out
|
|
111
|
+
assert "test [command]" in out
|
|
112
|
+
assert "network=True" in out
|
|
113
|
+
|
|
103
114
|
|
|
104
115
|
class TestDryRunHelp:
|
|
105
116
|
def test_help_documents_flag(self, repo, capsys):
|
|
@@ -19,9 +19,9 @@ from agentic_dev_loop.isolation import (
|
|
|
19
19
|
DockerBackend, EffectiveProfile, ProbeResult, SandboxError,
|
|
20
20
|
SandboxExecutionRequest, SandboxExecutionResult, append_audit_record,
|
|
21
21
|
clean_stamps, docker_create_payload, docker_provision_payload,
|
|
22
|
-
is_git_mutation, probe_docker, read_stamp,
|
|
23
|
-
resolve_image_ref, resolve_sandbox_env,
|
|
24
|
-
translate_mount_path, write_stamp,
|
|
22
|
+
exec_container_name, is_git_mutation, probe_docker, read_stamp,
|
|
23
|
+
resolve_effective_profile, resolve_image_ref, resolve_sandbox_env,
|
|
24
|
+
select_backend, setup_stamp_key, translate_mount_path, write_stamp,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
@@ -311,6 +311,30 @@ class TestPayloads:
|
|
|
311
311
|
assert "--user" not in create
|
|
312
312
|
assert "--user" not in provision
|
|
313
313
|
|
|
314
|
+
def test_networked_create_payload(self, tmp_path):
|
|
315
|
+
"""014: `network: true` swaps the network mode AND the container name,
|
|
316
|
+
but nothing else — the networked container is otherwise identical
|
|
317
|
+
(read-only, cap-dropped, single mount)."""
|
|
318
|
+
ws = str(tmp_path)
|
|
319
|
+
p = docker_create_payload(run_id="r1", image="img",
|
|
320
|
+
profile=self._profile(), workspace=ws,
|
|
321
|
+
network=True)
|
|
322
|
+
assert "--network" in p and "bridge" in p
|
|
323
|
+
assert "none" not in p
|
|
324
|
+
assert "--name" in p and f"{p[p.index('--name') + 1]}" == "r1-exec-net"
|
|
325
|
+
assert "--read-only" in p
|
|
326
|
+
assert "--cap-drop" in p and "ALL" in p
|
|
327
|
+
mounts = [p[i + 1] for i, a in enumerate(p) if a == "-v"]
|
|
328
|
+
assert mounts == [f"{ws}:{ws}"]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class TestExecContainerName:
|
|
332
|
+
def test_default_is_network_none(self):
|
|
333
|
+
assert exec_container_name("abc") == "abc-exec"
|
|
334
|
+
|
|
335
|
+
def test_networked_suffix(self):
|
|
336
|
+
assert exec_container_name("abc", network=True) == "abc-exec-net"
|
|
337
|
+
|
|
314
338
|
|
|
315
339
|
class TestSelectBackend:
|
|
316
340
|
def test_totality(self):
|
|
@@ -374,13 +398,14 @@ class TestResolveImageRef:
|
|
|
374
398
|
# ---------------------------------------------------------------------------
|
|
375
399
|
|
|
376
400
|
|
|
377
|
-
def _backend(tmp_path, runner, *, image="img@sha256:abc", git_blocked=True
|
|
401
|
+
def _backend(tmp_path, runner, *, image="img@sha256:abc", git_blocked=True,
|
|
402
|
+
network=False):
|
|
378
403
|
return DockerBackend(
|
|
379
404
|
image=image, workspace=str(tmp_path / "ws"),
|
|
380
405
|
iso_state_dir=str(tmp_path / "state"),
|
|
381
406
|
profile=EffectiveProfile(True, True, True, True, git_blocked,
|
|
382
407
|
image, "4g", 2.0),
|
|
383
|
-
runner=runner)
|
|
408
|
+
runner=runner, network=network)
|
|
384
409
|
|
|
385
410
|
|
|
386
411
|
class TestEnsureImage:
|
|
@@ -432,6 +457,30 @@ class TestCreateExecutionContainer:
|
|
|
432
457
|
assert "--network" in runner.calls[0] and "none" in runner.calls[0]
|
|
433
458
|
assert len(runner.calls) == 1
|
|
434
459
|
|
|
460
|
+
def test_networked_second_container_eagerly_created(self, tmp_path):
|
|
461
|
+
"""014: when any step declares network, startup creates BOTH the
|
|
462
|
+
network-none container and a bridge-network container — zero infra
|
|
463
|
+
work is deferred to the run phase."""
|
|
464
|
+
runner = FakeRunner(ok("cid-none"), ok("cid-net"))
|
|
465
|
+
backend = _backend(tmp_path, runner, network=True)
|
|
466
|
+
backend.create_execution_container()
|
|
467
|
+
assert len(runner.calls) == 2
|
|
468
|
+
none_argv, net_argv = runner.calls
|
|
469
|
+
assert "--network" in none_argv and "none" in none_argv
|
|
470
|
+
assert none_argv[none_argv.index("--name") + 1].endswith("-exec")
|
|
471
|
+
assert "--network" in net_argv and "bridge" in net_argv
|
|
472
|
+
assert net_argv[net_argv.index("--name") + 1].endswith("-exec-net")
|
|
473
|
+
|
|
474
|
+
def test_networked_container_failure_is_loud_and_reclaims_main(self, tmp_path):
|
|
475
|
+
runner = FakeRunner(ok("cid-none"), fail(125, "network bridge unavailable"))
|
|
476
|
+
backend = _backend(tmp_path, runner, network=True)
|
|
477
|
+
with pytest.raises(SandboxError, match="AE-SBX-013"):
|
|
478
|
+
backend.create_execution_container()
|
|
479
|
+
assert backend._started is False
|
|
480
|
+
# the already-started network-none container is reclaimed, never leaked
|
|
481
|
+
rm = [c for c in runner.calls if c[1] == "rm"]
|
|
482
|
+
assert rm and backend._container in rm[-1]
|
|
483
|
+
|
|
435
484
|
|
|
436
485
|
class TestRun:
|
|
437
486
|
def test_exec_argv_assembly(self, tmp_path):
|
|
@@ -457,6 +506,34 @@ class TestRun:
|
|
|
457
506
|
assert backend._container in runner.calls[-1]
|
|
458
507
|
assert "/usr/bin/timeout" not in runner.calls[-1]
|
|
459
508
|
|
|
509
|
+
def test_networked_request_routes_to_network_container(self, tmp_path):
|
|
510
|
+
"""014: an exec routes to the bridge-network container iff the request
|
|
511
|
+
declares network — the default stays network-none."""
|
|
512
|
+
runner = FakeRunner(ok(""), ok(""))
|
|
513
|
+
backend = _backend(tmp_path, runner, network=True)
|
|
514
|
+
backend._container = "c1"
|
|
515
|
+
backend._network_container = "c1-net"
|
|
516
|
+
backend._started = True
|
|
517
|
+
backend._network_started = True
|
|
518
|
+
req = SandboxExecutionRequest(argv=["curl", "https://example.com"],
|
|
519
|
+
cwd=str(tmp_path / "ws"), env={},
|
|
520
|
+
network=True)
|
|
521
|
+
backend.run(req)
|
|
522
|
+
assert "c1-net" in runner.calls[-1]
|
|
523
|
+
assert "c1" not in runner.calls[-1]
|
|
524
|
+
|
|
525
|
+
def test_networked_request_without_network_container_is_loud(self, tmp_path):
|
|
526
|
+
runner = FakeRunner()
|
|
527
|
+
backend = _backend(tmp_path, runner, network=True)
|
|
528
|
+
backend._container = "c1"
|
|
529
|
+
backend._started = True # network container never started
|
|
530
|
+
req = SandboxExecutionRequest(argv=["curl", "https://example.com"],
|
|
531
|
+
cwd=str(tmp_path / "ws"), env={},
|
|
532
|
+
network=True)
|
|
533
|
+
with pytest.raises(SandboxError, match="AE-SBX-013"):
|
|
534
|
+
backend.run(req)
|
|
535
|
+
assert not runner.calls # never reached docker exec
|
|
536
|
+
|
|
460
537
|
def test_exit_124_is_timeout(self, tmp_path):
|
|
461
538
|
runner = FakeRunner(CmdResult(124, "out", ""))
|
|
462
539
|
backend = _backend(tmp_path, runner)
|
|
@@ -545,6 +622,18 @@ class TestClose:
|
|
|
545
622
|
backend.close() # must not raise
|
|
546
623
|
assert not backend._started
|
|
547
624
|
|
|
625
|
+
def test_closes_network_container_too(self, tmp_path):
|
|
626
|
+
runner = FakeRunner(ok(""), ok(""), ok(""), ok(""))
|
|
627
|
+
backend = _backend(tmp_path, runner, network=True)
|
|
628
|
+
backend._container = "c1"
|
|
629
|
+
backend._network_container = "c1-net"
|
|
630
|
+
backend._started = True
|
|
631
|
+
backend._network_started = True
|
|
632
|
+
backend.close()
|
|
633
|
+
stopped = [c[-1] for c in runner.calls if c[1] == "stop"]
|
|
634
|
+
assert stopped == ["c1", "c1-net"]
|
|
635
|
+
assert not backend._started and not backend._network_started
|
|
636
|
+
|
|
548
637
|
|
|
549
638
|
# ---------------------------------------------------------------------------
|
|
550
639
|
# Provision + stamps + audit (real temp dirs)
|
|
@@ -649,6 +738,42 @@ class TestStartupSandbox:
|
|
|
649
738
|
finally:
|
|
650
739
|
sandbox.close()
|
|
651
740
|
|
|
741
|
+
def test_network_declaration_starts_second_container(self, tmp_path, monkeypatch):
|
|
742
|
+
"""014: declaring `network: true` on any step makes eager startup
|
|
743
|
+
create the bridge-network container in addition to the default
|
|
744
|
+
network-none one — the default (no declaration) stays single-container."""
|
|
745
|
+
from agentic_dev_loop.config import PipelineConfig
|
|
746
|
+
from agentic_dev_loop.isolation import startup_sandbox
|
|
747
|
+
|
|
748
|
+
ws = tmp_path / "ws"
|
|
749
|
+
ws.mkdir()
|
|
750
|
+
cfg = PipelineConfig.model_validate({
|
|
751
|
+
"name": "p", "llm": {"model": "m", "api_key_env": "K"},
|
|
752
|
+
"agents_dir": "agents",
|
|
753
|
+
"steps": [{"name": "dev", "agent": "dev", "on_pass": "test"},
|
|
754
|
+
{"name": "test", "command": "make", "network": True,
|
|
755
|
+
"on_pass": "success"}],
|
|
756
|
+
"isolation": {"state_dir": str(tmp_path / "iso-state")},
|
|
757
|
+
})
|
|
758
|
+
monkeypatch.setattr("agentic_dev_loop.isolation.shutil.which",
|
|
759
|
+
lambda *a: "/usr/bin/docker")
|
|
760
|
+
monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
|
|
761
|
+
runner = FakeRunner(
|
|
762
|
+
ok("1.54"), # probe version
|
|
763
|
+
ok("linux"), # probe ostype
|
|
764
|
+
ok(""), # image inspect
|
|
765
|
+
ok(""), # docker run -d --rm (network-none exec)
|
|
766
|
+
ok(""), # docker run -d --rm (networked exec)
|
|
767
|
+
)
|
|
768
|
+
sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
|
|
769
|
+
try:
|
|
770
|
+
run_argv = [c for c in runner.calls if c[1] == "run"]
|
|
771
|
+
assert len(run_argv) == 2
|
|
772
|
+
assert "--network" in run_argv[0] and "none" in run_argv[0]
|
|
773
|
+
assert "--network" in run_argv[1] and "bridge" in run_argv[1]
|
|
774
|
+
finally:
|
|
775
|
+
sandbox.close()
|
|
776
|
+
|
|
652
777
|
def test_refresh_forces_provision(self, tmp_path, monkeypatch):
|
|
653
778
|
from agentic_dev_loop.isolation import (setup_stamp_key, startup_sandbox,
|
|
654
779
|
write_stamp)
|
|
@@ -110,6 +110,60 @@ class TestGitGuardE2E:
|
|
|
110
110
|
assert not isinstance(result, SandboxError)
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
class TestNetworkedContainerE2E:
|
|
114
|
+
"""014: `network: true` steps run in a second, bridge-network container.
|
|
115
|
+
The distinguishing observable is the interface set — network-none has only
|
|
116
|
+
loopback; the bridge container additionally gets a routable eth0. This is
|
|
117
|
+
deterministic (no external egress required)."""
|
|
118
|
+
|
|
119
|
+
@pytest.fixture
|
|
120
|
+
def networked_backend(self, tmp_path):
|
|
121
|
+
reason = _docker_available()
|
|
122
|
+
if reason is not None:
|
|
123
|
+
pytest.skip(reason)
|
|
124
|
+
inspect = SubprocessRunner().run(
|
|
125
|
+
["docker", "image", "inspect", TEST_IMAGE, "--format", "{{.Id}}"],
|
|
126
|
+
timeout=30)
|
|
127
|
+
if inspect.exit_code != 0:
|
|
128
|
+
pytest.skip(f"integration image {TEST_IMAGE!r} not present locally")
|
|
129
|
+
ws = tmp_path / "ws"
|
|
130
|
+
ws.mkdir()
|
|
131
|
+
profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
|
|
132
|
+
b = DockerBackend(image=TEST_IMAGE, workspace=str(ws),
|
|
133
|
+
iso_state_dir=str(tmp_path / "state"), profile=profile,
|
|
134
|
+
network=True)
|
|
135
|
+
b.ensure_image()
|
|
136
|
+
b.create_execution_container()
|
|
137
|
+
yield b, ws
|
|
138
|
+
b.close()
|
|
139
|
+
|
|
140
|
+
def test_networked_container_has_bridge_interface(self, networked_backend):
|
|
141
|
+
b, ws = networked_backend
|
|
142
|
+
req = SandboxExecutionRequest(
|
|
143
|
+
argv=["python", "-c",
|
|
144
|
+
"import socket; print(' '.join(sorted(n for _, n in socket.if_nameindex())))"],
|
|
145
|
+
cwd=ws,
|
|
146
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"},
|
|
147
|
+
network=True)
|
|
148
|
+
result = b.run(req)
|
|
149
|
+
assert result.exit_code == 0, result.stderr
|
|
150
|
+
names = result.stdout.split()
|
|
151
|
+
assert "lo" in names
|
|
152
|
+
assert any(name != "lo" for name in names)
|
|
153
|
+
|
|
154
|
+
def test_default_container_still_network_none(self, networked_backend):
|
|
155
|
+
b, ws = networked_backend
|
|
156
|
+
req = SandboxExecutionRequest(
|
|
157
|
+
argv=["python", "-c",
|
|
158
|
+
"print(len(open('/proc/net/route').read().strip().splitlines()[1:]))"],
|
|
159
|
+
cwd=ws,
|
|
160
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"},
|
|
161
|
+
network=False)
|
|
162
|
+
result = b.run(req)
|
|
163
|
+
assert result.exit_code == 0, result.stderr
|
|
164
|
+
assert result.stdout.strip() == "0" # no routes at all — no egress
|
|
165
|
+
|
|
166
|
+
|
|
113
167
|
class TestProvisionE2E:
|
|
114
168
|
def test_provision_stamp_skip_refresh(self, backend, tmp_path):
|
|
115
169
|
b, ws = backend
|
|
@@ -12,8 +12,9 @@ from hypothesis import assume, given, strategies as st
|
|
|
12
12
|
|
|
13
13
|
from agentic_dev_loop.isolation import (
|
|
14
14
|
EffectiveProfile, ProbeResult, SandboxExecutionRequest, SandboxExecutionResult,
|
|
15
|
-
docker_create_payload,
|
|
16
|
-
resolve_sandbox_env, select_backend,
|
|
15
|
+
docker_create_payload, exec_container_name, is_git_mutation,
|
|
16
|
+
resolve_effective_profile, resolve_sandbox_env, select_backend,
|
|
17
|
+
setup_stamp_key, translate_mount_path,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
profiles = st.builds(
|
|
@@ -69,6 +70,27 @@ class TestResolveSandboxEnvScrub:
|
|
|
69
70
|
assert set(env) == {"PATH", "HOME", "LANG", "LC_ALL", "TERM"}
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
class TestNetworkModePayload:
|
|
74
|
+
@given(profile=profiles, workspace=st.text(min_size=1), run_id=st.text(min_size=1))
|
|
75
|
+
def test_default_is_network_none(self, profile, workspace, run_id):
|
|
76
|
+
p = docker_create_payload(run_id=run_id, image="img", profile=profile,
|
|
77
|
+
workspace=workspace, network=False)
|
|
78
|
+
assert "--network" in p and "none" in p
|
|
79
|
+
assert "--name" in p and p[p.index("--name") + 1] == exec_container_name(run_id)
|
|
80
|
+
|
|
81
|
+
@given(profile=profiles, workspace=st.text(min_size=1), run_id=st.text(min_size=1))
|
|
82
|
+
def test_network_true_swaps_mode_and_name(self, profile, workspace, run_id):
|
|
83
|
+
p = docker_create_payload(run_id=run_id, image="img", profile=profile,
|
|
84
|
+
workspace=workspace, network=True)
|
|
85
|
+
assert "--network" in p and "bridge" in p
|
|
86
|
+
assert "--name" in p and p[p.index("--name") + 1] == exec_container_name(
|
|
87
|
+
run_id, network=True)
|
|
88
|
+
# the rest of the hardening invariants still hold for the networked container
|
|
89
|
+
assert "--read-only" in p and "--cap-drop" in p and "ALL" in p
|
|
90
|
+
mounts = [p[i + 1] for i, a in enumerate(p) if a == "-v"]
|
|
91
|
+
assert mounts == [f"{workspace}:{workspace}"]
|
|
92
|
+
|
|
93
|
+
|
|
72
94
|
class TestSetupStampKey:
|
|
73
95
|
@given(cmd=st.text(), lockfiles=st.dictionaries(st.text(min_size=1), st.text()))
|
|
74
96
|
def test_stable(self, cmd, lockfiles):
|
|
@@ -511,6 +511,16 @@ class TestSandboxedCommandNode:
|
|
|
511
511
|
assert fake_sandbox.requests[0].env["MY_VAR"] == "declared"
|
|
512
512
|
assert fake_sandbox.requests[0].env["PATH"].startswith("/usr/local/bin")
|
|
513
513
|
|
|
514
|
+
def test_network_defaults_off(self, runtime, fake_sandbox):
|
|
515
|
+
node = self._node(runtime, "echo routed")
|
|
516
|
+
node(make_state())
|
|
517
|
+
assert fake_sandbox.requests[0].network is False
|
|
518
|
+
|
|
519
|
+
def test_network_true_reaches_request(self, runtime, fake_sandbox):
|
|
520
|
+
node = self._node(runtime, "curl https://example.com", network=True)
|
|
521
|
+
node(make_state())
|
|
522
|
+
assert fake_sandbox.requests[0].network is True
|
|
523
|
+
|
|
514
524
|
def test_git_mutation_hard_fails_not_retried(self, runtime, fake_sandbox):
|
|
515
525
|
cfg = make_cfg([
|
|
516
526
|
{"name": "dev", "agent": "dev", "on_pass": "test"},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Opinionated LangGraph engine for OpenHands agent pipelines."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/aidlc.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a2 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|