agentic-dev-loop 1.0.0a36__tar.gz → 1.0.0a37__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.
Potentially problematic release.
This version of agentic-dev-loop might be problematic. Click here for more details.
- {agentic_dev_loop-1.0.0a36/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a37}/PKG-INFO +18 -16
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/README.md +17 -15
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/egress_proxy.py +26 -8
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/isolation.py +115 -21
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37/src/agentic_dev_loop.egg-info}/PKG-INFO +18 -16
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_e2e_fixtures.py +91 -10
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_egress_proxy.py +63 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_isolation.py +93 -34
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/config.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/allowlist.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/forbidden.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_032_stop_when_green.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_033_brief_step.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_035_finalize_nudge.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_agent_error_guide.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a36/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a37}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a37
|
|
4
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
|
|
@@ -328,11 +328,13 @@ steps:
|
|
|
328
328
|
a command like `curl ... -o spec.json` has no writable cwd to write into.
|
|
329
329
|
Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
|
|
330
330
|
networked step that needs to produce files.
|
|
331
|
-
- **`network: true` is
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
- **`network: true` is scoped by the `egress:` allowlist, and egress is
|
|
332
|
+
structurally enforced.** A networked step runs on a per-run internal
|
|
333
|
+
docker network with no route to the host. Its traffic is forced
|
|
334
|
+
through a containerized egress proxy (injected via
|
|
335
|
+
`HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
|
|
336
|
+
on the internal network); a request to an undeclared host is denied
|
|
337
|
+
and logged as an enforcement event. The engine never adds an
|
|
336
338
|
undeclared target. `egress: { class: browser }` is the explicit
|
|
337
339
|
broad-but-logged class (every connection logged, nothing filtered); command
|
|
338
340
|
and agent steps default to a strict allowlist.
|
|
@@ -342,16 +344,16 @@ steps:
|
|
|
342
344
|
to exactly the declared hosts for that stage; the default stays network-off
|
|
343
345
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
344
346
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
345
|
-
- **Host-service reachability (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
host
|
|
352
|
-
host
|
|
353
|
-
|
|
354
|
-
|
|
347
|
+
- **Host-service reachability (planning-docs/037, 038).** The egress
|
|
348
|
+
proxy is a container on the run's internal network with a second
|
|
349
|
+
host-access attachment carrying the `host.docker.internal`
|
|
350
|
+
host-gateway alias, so the name resolves natively inside the proxy
|
|
351
|
+
container and host services (an S3 SDK against a local MinIO, say)
|
|
352
|
+
are reached through the enforced, logged path. Sandbox containers
|
|
353
|
+
have no host route: the internal network carries no default route to
|
|
354
|
+
the host, and sandbox containers get no host-gateway alias.
|
|
355
|
+
`dial_rewrites` on the proxy remains the platform override for hosts
|
|
356
|
+
where the alias does not resolve.
|
|
355
357
|
|
|
356
358
|
### Sync-back conflicts (host edits during a run)
|
|
357
359
|
|
|
@@ -286,11 +286,13 @@ steps:
|
|
|
286
286
|
a command like `curl ... -o spec.json` has no writable cwd to write into.
|
|
287
287
|
Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
|
|
288
288
|
networked step that needs to produce files.
|
|
289
|
-
- **`network: true` is
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
- **`network: true` is scoped by the `egress:` allowlist, and egress is
|
|
290
|
+
structurally enforced.** A networked step runs on a per-run internal
|
|
291
|
+
docker network with no route to the host. Its traffic is forced
|
|
292
|
+
through a containerized egress proxy (injected via
|
|
293
|
+
`HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
|
|
294
|
+
on the internal network); a request to an undeclared host is denied
|
|
295
|
+
and logged as an enforcement event. The engine never adds an
|
|
294
296
|
undeclared target. `egress: { class: browser }` is the explicit
|
|
295
297
|
broad-but-logged class (every connection logged, nothing filtered); command
|
|
296
298
|
and agent steps default to a strict allowlist.
|
|
@@ -300,16 +302,16 @@ steps:
|
|
|
300
302
|
to exactly the declared hosts for that stage; the default stays network-off
|
|
301
303
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
302
304
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
303
|
-
- **Host-service reachability (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
host
|
|
310
|
-
host
|
|
311
|
-
|
|
312
|
-
|
|
305
|
+
- **Host-service reachability (planning-docs/037, 038).** The egress
|
|
306
|
+
proxy is a container on the run's internal network with a second
|
|
307
|
+
host-access attachment carrying the `host.docker.internal`
|
|
308
|
+
host-gateway alias, so the name resolves natively inside the proxy
|
|
309
|
+
container and host services (an S3 SDK against a local MinIO, say)
|
|
310
|
+
are reached through the enforced, logged path. Sandbox containers
|
|
311
|
+
have no host route: the internal network carries no default route to
|
|
312
|
+
the host, and sandbox containers get no host-gateway alias.
|
|
313
|
+
`dial_rewrites` on the proxy remains the platform override for hosts
|
|
314
|
+
where the alias does not resolve.
|
|
313
315
|
|
|
314
316
|
### Sync-back conflicts (host edits during a run)
|
|
315
317
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0a37"
|
|
8
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"
|
{agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/egress_proxy.py
RENAMED
|
@@ -13,6 +13,7 @@ import asyncio
|
|
|
13
13
|
import json
|
|
14
14
|
import sys
|
|
15
15
|
import threading
|
|
16
|
+
import time
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from datetime import datetime, timezone
|
|
18
19
|
from pathlib import Path
|
|
@@ -56,24 +57,40 @@ class EgressProxy:
|
|
|
56
57
|
"""True while the proxy's server thread is running."""
|
|
57
58
|
return self._thread is not None and self._thread.is_alive()
|
|
58
59
|
|
|
59
|
-
def start(self, host: str = "127.0.0.1", port: int = 0
|
|
60
|
+
def start(self, host: str = "127.0.0.1", port: int = 0,
|
|
61
|
+
bind_timeout: float = 5.0) -> int:
|
|
60
62
|
"""Bind the proxy on ``host:port`` (ephemeral when port=0) and serve
|
|
61
|
-
in a background thread. Returns the bound port.
|
|
63
|
+
in a background thread. Returns the bound port.
|
|
64
|
+
|
|
65
|
+
``bind_timeout`` bounds how long the bind may wait for the requested
|
|
66
|
+
address to appear (the container-mode entrypoint binds an
|
|
67
|
+
internal-network IP that is attached after the container starts)."""
|
|
62
68
|
self._loop = asyncio.new_event_loop()
|
|
63
69
|
|
|
64
70
|
def _run() -> None:
|
|
65
71
|
asyncio.set_event_loop(self._loop)
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
deadline = time.monotonic() + bind_timeout
|
|
73
|
+
while True:
|
|
74
|
+
try:
|
|
75
|
+
self._server = self._loop.run_until_complete(
|
|
76
|
+
asyncio.start_server(self._handle, host, port))
|
|
77
|
+
break
|
|
78
|
+
except OSError:
|
|
79
|
+
if time.monotonic() >= deadline:
|
|
80
|
+
raise
|
|
81
|
+
time.sleep(0.1)
|
|
68
82
|
self._port = self._server.sockets[0].getsockname()[1]
|
|
69
83
|
self._loop.run_forever()
|
|
70
84
|
|
|
71
85
|
self._thread = threading.Thread(target=_run, daemon=True)
|
|
72
86
|
self._thread.start()
|
|
73
|
-
deadline = datetime.now().timestamp() +
|
|
87
|
+
deadline = datetime.now().timestamp() + bind_timeout + 1.0
|
|
74
88
|
while self._port is None:
|
|
89
|
+
if not self._thread.is_alive():
|
|
90
|
+
raise RuntimeError("egress proxy failed to bind")
|
|
75
91
|
if datetime.now().timestamp() > deadline:
|
|
76
|
-
raise RuntimeError(
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"egress proxy failed to bind within {bind_timeout}s")
|
|
77
94
|
threading.Event().wait(0.01)
|
|
78
95
|
return self._port
|
|
79
96
|
|
|
@@ -323,8 +340,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
323
340
|
parser.add_argument("--policy", required=True)
|
|
324
341
|
parser.add_argument("--events", required=True)
|
|
325
342
|
parser.add_argument("--dial-rewrites", default="{}")
|
|
326
|
-
parser.add_argument("--host", default="
|
|
343
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
327
344
|
parser.add_argument("--port", type=int, default=0)
|
|
345
|
+
parser.add_argument("--bind-timeout", type=float, default=5.0)
|
|
328
346
|
args = parser.parse_args(argv)
|
|
329
347
|
|
|
330
348
|
proxy = EgressProxy(
|
|
@@ -332,7 +350,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
332
350
|
dial_rewrites=json.loads(args.dial_rewrites),
|
|
333
351
|
event_file=Path(args.events),
|
|
334
352
|
)
|
|
335
|
-
proxy.start(host=args.host, port=args.port)
|
|
353
|
+
proxy.start(host=args.host, port=args.port, bind_timeout=args.bind_timeout)
|
|
336
354
|
threading.Event().wait()
|
|
337
355
|
return 0
|
|
338
356
|
|
{agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -19,6 +19,7 @@ last-error.md) are never mounted - sandboxed code cannot reach them.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import hashlib
|
|
22
|
+
import ipaddress
|
|
22
23
|
import json
|
|
23
24
|
import math
|
|
24
25
|
import os
|
|
@@ -103,6 +104,14 @@ EGRESS_PROXY_PORT = 3128
|
|
|
103
104
|
HOST_ACCESS_NETWORK = "bridge"
|
|
104
105
|
EGRESS_EVENTS_PREFIX = "egress-events"
|
|
105
106
|
|
|
107
|
+
# 039: hardened proxy-container runtime posture and readiness bounds.
|
|
108
|
+
PROXY_USER = "65534:65534"
|
|
109
|
+
PROXY_MEMORY = "64m"
|
|
110
|
+
PROXY_CPUS = 0.5
|
|
111
|
+
PROXY_BIND_TIMEOUT = 15.0
|
|
112
|
+
PROXY_READY_ATTEMPTS = 5
|
|
113
|
+
PROXY_READY_DELAY = 0.2
|
|
114
|
+
|
|
106
115
|
|
|
107
116
|
class SandboxError(RuntimeError):
|
|
108
117
|
"""A loud fail-fast sandbox failure; the message names the problem and
|
|
@@ -301,6 +310,37 @@ def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
|
|
|
301
310
|
return Path(iso_state_dir) / f"{EGRESS_EVENTS_PREFIX}-{run_id}.jsonl"
|
|
302
311
|
|
|
303
312
|
|
|
313
|
+
def first_usable_ip(subnets: str) -> str | None:
|
|
314
|
+
"""First usable non-gateway IPv4 address from one or more CIDR subnets."""
|
|
315
|
+
for token in subnets.split():
|
|
316
|
+
try:
|
|
317
|
+
network = ipaddress.ip_network(token, strict=False)
|
|
318
|
+
except ValueError:
|
|
319
|
+
continue
|
|
320
|
+
if network.version != 4:
|
|
321
|
+
continue
|
|
322
|
+
hosts = list(network.hosts())
|
|
323
|
+
if len(hosts) < 2:
|
|
324
|
+
continue
|
|
325
|
+
return str(hosts[1])
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def proxy_readiness_argv(name: str, host: str,
|
|
331
|
+
port: int = EGRESS_PROXY_PORT) -> list[str]:
|
|
332
|
+
"""The in-container TCP-connect probe for proxy readiness (039, D2)."""
|
|
333
|
+
probe = (
|
|
334
|
+
"import socket,sys;"
|
|
335
|
+
"s=socket.socket();"
|
|
336
|
+
"s.settimeout(2);"
|
|
337
|
+
"s.connect((sys.argv[1], int(sys.argv[2])));"
|
|
338
|
+
"s.close()"
|
|
339
|
+
)
|
|
340
|
+
return ["docker", "exec", name, "python3", "-c", probe, host, str(port)]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
304
344
|
def mount_set_docker_flags(export_root: str, mount_set: MountSet) -> list[str]:
|
|
305
345
|
"""Translate a resolved MountSet into Docker ``-v`` flags over the
|
|
306
346
|
export (ID-4/BR-1). Strict-by-default: an empty mount set mounts nothing
|
|
@@ -730,12 +770,16 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
|
|
|
730
770
|
events_host_path: str,
|
|
731
771
|
egress_proxy_host_path: str,
|
|
732
772
|
egress_policy_host_path: str,
|
|
773
|
+
bind_host: str,
|
|
733
774
|
port: int = EGRESS_PROXY_PORT,
|
|
734
775
|
host_network: str = HOST_ACCESS_NETWORK) -> list[str]:
|
|
735
|
-
"""Exact argv for the containerized egress proxy (038
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
776
|
+
"""Exact argv for the containerized egress proxy (038 D2/D5, 039 D1/D4).
|
|
777
|
+
|
|
778
|
+
Created on the host-access network with a host-gateway mapping (D8), then
|
|
779
|
+
attached to the internal network with the pinned ``bind_host``; the proxy
|
|
780
|
+
binds that internal address only, so the host-access attachment carries no
|
|
781
|
+
listener. Hardened with a read-only rootfs, dropped capabilities, a non-root
|
|
782
|
+
user, and memory/CPU limits. The policy is passed as JSON; the two engine
|
|
739
783
|
modules are bind-mounted read-only so the image does not need the engine
|
|
740
784
|
package installed."""
|
|
741
785
|
payload = [
|
|
@@ -744,6 +788,10 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
|
|
|
744
788
|
"--label", f"agentic-dev-loop.run={run_id}",
|
|
745
789
|
"--network", host_network,
|
|
746
790
|
"--add-host", "host.docker.internal:host-gateway",
|
|
791
|
+
"--read-only",
|
|
792
|
+
"--cap-drop", "ALL",
|
|
793
|
+
"--user", PROXY_USER,
|
|
794
|
+
"--memory", PROXY_MEMORY, "--cpus", str(PROXY_CPUS),
|
|
747
795
|
"-v", f"{egress_proxy_host_path}:/egress/egress_proxy.py:ro",
|
|
748
796
|
"-v", f"{egress_policy_host_path}:/egress/egress_policy.py:ro",
|
|
749
797
|
"-v", f"{events_host_path}:/egress/events.jsonl",
|
|
@@ -753,11 +801,13 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
|
|
|
753
801
|
"--policy", policy_json,
|
|
754
802
|
"--events", "/egress/events.jsonl",
|
|
755
803
|
"--dial-rewrites", dial_rewrites_json,
|
|
756
|
-
"--host",
|
|
804
|
+
"--host", bind_host,
|
|
805
|
+
"--bind-timeout", str(PROXY_BIND_TIMEOUT),
|
|
757
806
|
"--port", str(port),
|
|
758
807
|
]
|
|
759
808
|
return payload
|
|
760
809
|
|
|
810
|
+
|
|
761
811
|
def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
762
812
|
setup_cmd: str,
|
|
763
813
|
sandbox_venv_dir: str | None = None,
|
|
@@ -891,7 +941,8 @@ class DockerBackend(Sandbox):
|
|
|
891
941
|
export_root: str | None = None,
|
|
892
942
|
cache_mounts: list[tuple[str, str, str]] | None = None,
|
|
893
943
|
sync_excludes: list[str] | None = None,
|
|
894
|
-
merge_on_conflict: list[str] | None = None
|
|
944
|
+
merge_on_conflict: list[str] | None = None,
|
|
945
|
+
dial_rewrites: dict[str, str] | None = None):
|
|
895
946
|
self._image = image
|
|
896
947
|
self._workspace = workspace
|
|
897
948
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -927,6 +978,7 @@ class DockerBackend(Sandbox):
|
|
|
927
978
|
# 029: append-style logs union-merged on a concurrent-edit conflict
|
|
928
979
|
# instead of raising (host wins, export-only lines appended).
|
|
929
980
|
self._merge_on_conflict = list(merge_on_conflict or ())
|
|
981
|
+
self._dial_rewrites = dict(dial_rewrites or {})
|
|
930
982
|
self._baseline: dict[str, str] = {}
|
|
931
983
|
# The profile-keyed pool (D-1/BR-10): profile key -> running container.
|
|
932
984
|
self._pool: dict[tuple[str, bool], _ProfileContainer] = {}
|
|
@@ -1154,11 +1206,6 @@ class DockerBackend(Sandbox):
|
|
|
1154
1206
|
if prof is None:
|
|
1155
1207
|
prof = self._start_profile(key, mount_set, policy, request.network)
|
|
1156
1208
|
self._pool[key] = prof
|
|
1157
|
-
if request.network and not prof.started:
|
|
1158
|
-
raise SandboxError(
|
|
1159
|
-
"AE-SBX-013: networked execution requested but no networked "
|
|
1160
|
-
"container is running - declare `network: true` on the step "
|
|
1161
|
-
"(docs/setup.md §3)")
|
|
1162
1209
|
if request.network:
|
|
1163
1210
|
if prof.proxy_name is None:
|
|
1164
1211
|
raise SandboxError(
|
|
@@ -1238,7 +1285,8 @@ class DockerBackend(Sandbox):
|
|
|
1238
1285
|
proxy_url_value: str | None = None
|
|
1239
1286
|
if network and self._export_root is not None:
|
|
1240
1287
|
proxy_name = proxy_container_name(self._run_id, key[0][:16])
|
|
1241
|
-
self._start_proxy_container(proxy_name, policy
|
|
1288
|
+
self._start_proxy_container(proxy_name, policy,
|
|
1289
|
+
self._dial_rewrites)
|
|
1242
1290
|
proxy_url_value = proxy_url(proxy_name)
|
|
1243
1291
|
payload = docker_create_payload(
|
|
1244
1292
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
@@ -1261,37 +1309,81 @@ class DockerBackend(Sandbox):
|
|
|
1261
1309
|
egress_policy=policy, proxy_name=proxy_name,
|
|
1262
1310
|
started=True)
|
|
1263
1311
|
|
|
1264
|
-
def _start_proxy_container(self, name: str, policy: EgressPolicy
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1312
|
+
def _start_proxy_container(self, name: str, policy: EgressPolicy,
|
|
1313
|
+
dial_rewrites: dict[str, str] | None = None) -> None:
|
|
1314
|
+
"""Create one containerized proxy (038 D2/D5, 039 D1-D4): host-access
|
|
1315
|
+
network at create time with a pinned internal IP, then attach the internal
|
|
1316
|
+
network; the proxy binds that address only and is readiness probed from
|
|
1317
|
+
inside the container before the first sandbox exec (D2)."""
|
|
1268
1318
|
self._ensure_run_network()
|
|
1269
1319
|
events_host = self._egress_events_path
|
|
1270
1320
|
events_host.parent.mkdir(parents=True, exist_ok=True)
|
|
1271
1321
|
events_host.touch(exist_ok=True)
|
|
1322
|
+
try:
|
|
1323
|
+
os.chmod(events_host, 0o666)
|
|
1324
|
+
except OSError:
|
|
1325
|
+
pass
|
|
1272
1326
|
policy_json = egress_proxy_module.policy_to_json(policy)
|
|
1327
|
+
dial_rewrites = dict(dial_rewrites or {})
|
|
1328
|
+
dial_rewrites_json = json.dumps(dial_rewrites, sort_keys=True)
|
|
1329
|
+
bind_host = self._proxy_internal_ip()
|
|
1273
1330
|
payload = docker_proxy_payload(
|
|
1274
1331
|
run_id=self._run_id, image=self._image, name=name,
|
|
1275
|
-
policy_json=policy_json, dial_rewrites_json=
|
|
1332
|
+
policy_json=policy_json, dial_rewrites_json=dial_rewrites_json,
|
|
1276
1333
|
events_host_path=str(events_host),
|
|
1277
1334
|
egress_proxy_host_path=str(Path(egress_proxy_module.__file__)),
|
|
1278
1335
|
egress_policy_host_path=str(Path(egress_policy_module.__file__)),
|
|
1279
|
-
port=EGRESS_PROXY_PORT)
|
|
1336
|
+
bind_host=bind_host, port=EGRESS_PROXY_PORT)
|
|
1280
1337
|
res = self._runner.run(payload, timeout=60)
|
|
1281
1338
|
if res.exit_code != 0:
|
|
1282
1339
|
raise SandboxError(
|
|
1283
1340
|
f"AE-SBX-020: egress proxy container run failed: "
|
|
1284
1341
|
f"{res.stderr.strip() or res.stdout.strip()}")
|
|
1285
1342
|
connect = self._runner.run(
|
|
1286
|
-
["docker", "network", "connect",
|
|
1343
|
+
["docker", "network", "connect", "--ip", bind_host,
|
|
1344
|
+
self._run_net, name], timeout=60)
|
|
1287
1345
|
if connect.exit_code != 0:
|
|
1288
1346
|
self._rm_container(name)
|
|
1289
1347
|
raise SandboxError(
|
|
1290
1348
|
f"AE-SBX-020: egress proxy network attach failed: "
|
|
1291
1349
|
f"{connect.stderr.strip() or connect.stdout.strip()}")
|
|
1350
|
+
if not self._wait_proxy_ready(name, bind_host):
|
|
1351
|
+
self._rm_container(name)
|
|
1352
|
+
raise SandboxError(
|
|
1353
|
+
f"AE-SBX-021: egress proxy failed to bind "
|
|
1354
|
+
f"{bind_host}:{EGRESS_PROXY_PORT} after "
|
|
1355
|
+
f"{PROXY_READY_ATTEMPTS} readiness probes")
|
|
1292
1356
|
self._register_container(name)
|
|
1293
1357
|
self._proxy_containers.append(name)
|
|
1294
1358
|
|
|
1359
|
+
def _proxy_internal_ip(self) -> str:
|
|
1360
|
+
"""Return the pinned first-usable IPv4 address on the internal network."""
|
|
1361
|
+
inspect = self._runner.run(
|
|
1362
|
+
["docker", "network", "inspect", "-f",
|
|
1363
|
+
"{{range .IPAM.Config}}{{.Subnet}} {{end}}", self._run_net],
|
|
1364
|
+
timeout=30)
|
|
1365
|
+
if inspect.exit_code != 0:
|
|
1366
|
+
raise SandboxError(
|
|
1367
|
+
f"AE-SBX-021: internal network inspect failed: "
|
|
1368
|
+
f"{inspect.stderr.strip() or inspect.stdout.strip()}")
|
|
1369
|
+
ip = first_usable_ip(inspect.stdout)
|
|
1370
|
+
if ip is None:
|
|
1371
|
+
raise SandboxError(
|
|
1372
|
+
f"AE-SBX-021: internal network has no usable IPv4 subnet: "
|
|
1373
|
+
f"{inspect.stdout.strip()!r}")
|
|
1374
|
+
return ip
|
|
1375
|
+
|
|
1376
|
+
def _wait_proxy_ready(self, name: str, host: str) -> bool:
|
|
1377
|
+
"""Bounded in-container TCP-connect readiness probe (039 D2)."""
|
|
1378
|
+
for _ in range(PROXY_READY_ATTEMPTS):
|
|
1379
|
+
res = self._runner.run(proxy_readiness_argv(name, host),
|
|
1380
|
+
timeout=15)
|
|
1381
|
+
if res.exit_code == 0:
|
|
1382
|
+
return True
|
|
1383
|
+
time.sleep(PROXY_READY_DELAY)
|
|
1384
|
+
return False
|
|
1385
|
+
|
|
1386
|
+
|
|
1295
1387
|
def _proxy_alive(self, name: str) -> bool:
|
|
1296
1388
|
"""Query the daemon for proxy liveness (038 AE-SBX-018)."""
|
|
1297
1389
|
res = self._runner.run(
|
|
@@ -1809,7 +1901,8 @@ def _announce(msg: str) -> None:
|
|
|
1809
1901
|
def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
1810
1902
|
state_dir: str | Path,
|
|
1811
1903
|
runner: CommandRunner | None = None,
|
|
1812
|
-
refresh: bool | None = None
|
|
1904
|
+
refresh: bool | None = None,
|
|
1905
|
+
dial_rewrites: dict[str, str] | None = None) -> Sandbox:
|
|
1813
1906
|
"""Eager startup sequence (Q2): probe → image → materialise export →
|
|
1814
1907
|
backend → ensure_image → create+start → pre-provision every declared setup
|
|
1815
1908
|
(stamp-cached).
|
|
@@ -1866,7 +1959,8 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1866
1959
|
sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
|
|
1867
1960
|
export_root=export_root, cache_mounts=cache_mounts,
|
|
1868
1961
|
sync_excludes=list(cfg.sync_excludes),
|
|
1869
|
-
merge_on_conflict=list(cfg.merge_on_conflict)
|
|
1962
|
+
merge_on_conflict=list(cfg.merge_on_conflict),
|
|
1963
|
+
dial_rewrites=dial_rewrites)
|
|
1870
1964
|
_announce(f"preparing sandbox image {image} (first pull can take a while)")
|
|
1871
1965
|
backend.ensure_image()
|
|
1872
1966
|
_announce("sandbox image ready")
|
{agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a37/src/agentic_dev_loop.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a37
|
|
4
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
|
|
@@ -328,11 +328,13 @@ steps:
|
|
|
328
328
|
a command like `curl ... -o spec.json` has no writable cwd to write into.
|
|
329
329
|
Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
|
|
330
330
|
networked step that needs to produce files.
|
|
331
|
-
- **`network: true` is
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
- **`network: true` is scoped by the `egress:` allowlist, and egress is
|
|
332
|
+
structurally enforced.** A networked step runs on a per-run internal
|
|
333
|
+
docker network with no route to the host. Its traffic is forced
|
|
334
|
+
through a containerized egress proxy (injected via
|
|
335
|
+
`HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
|
|
336
|
+
on the internal network); a request to an undeclared host is denied
|
|
337
|
+
and logged as an enforcement event. The engine never adds an
|
|
336
338
|
undeclared target. `egress: { class: browser }` is the explicit
|
|
337
339
|
broad-but-logged class (every connection logged, nothing filtered); command
|
|
338
340
|
and agent steps default to a strict allowlist.
|
|
@@ -342,16 +344,16 @@ steps:
|
|
|
342
344
|
to exactly the declared hosts for that stage; the default stays network-off
|
|
343
345
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
344
346
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
345
|
-
- **Host-service reachability (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
host
|
|
352
|
-
host
|
|
353
|
-
|
|
354
|
-
|
|
347
|
+
- **Host-service reachability (planning-docs/037, 038).** The egress
|
|
348
|
+
proxy is a container on the run's internal network with a second
|
|
349
|
+
host-access attachment carrying the `host.docker.internal`
|
|
350
|
+
host-gateway alias, so the name resolves natively inside the proxy
|
|
351
|
+
container and host services (an S3 SDK against a local MinIO, say)
|
|
352
|
+
are reached through the enforced, logged path. Sandbox containers
|
|
353
|
+
have no host route: the internal network carries no default route to
|
|
354
|
+
the host, and sandbox containers get no host-gateway alias.
|
|
355
|
+
`dial_rewrites` on the proxy remains the platform override for hosts
|
|
356
|
+
where the alias does not resolve.
|
|
355
357
|
|
|
356
358
|
### Sync-back conflicts (host edits during a run)
|
|
357
359
|
|
|
@@ -13,6 +13,7 @@ default viz port. Kill behavior itself is covered in test_actions.py.
|
|
|
13
13
|
|
|
14
14
|
import shutil
|
|
15
15
|
import subprocess
|
|
16
|
+
import textwrap
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
import pytest
|
|
@@ -74,6 +75,34 @@ def _passing_scripts(fake_agents, **overrides):
|
|
|
74
75
|
fake_agents.set(agent, behaviors)
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
def _materialize_referenced_docs(pipeline_path: Path, workspace: Path) -> None:
|
|
79
|
+
"""Create a stub for every file the copied pipeline references, derived
|
|
80
|
+
from the pipeline config itself (additional_files plus the brief step's
|
|
81
|
+
brief_inputs.include) so future pipeline doc additions can never break
|
|
82
|
+
the fixture again. Replaces the hand-written stubs: a34 fixed the ERD
|
|
83
|
+
case by hand, 4b1cbd6 hand-fixed the 038/CHANGELOG/known-issues trio.
|
|
84
|
+
Literal include entries only: a wildcard cannot be stub-materialized,
|
|
85
|
+
so it raises instead of silently degrading."""
|
|
86
|
+
cfg = load_config(pipeline_path)
|
|
87
|
+
targets: list[Path] = []
|
|
88
|
+
for rel in cfg.additional_files:
|
|
89
|
+
targets.append(pipeline_path.parent / rel)
|
|
90
|
+
for step in cfg.steps:
|
|
91
|
+
if step.brief_inputs is None:
|
|
92
|
+
continue
|
|
93
|
+
for spec in step.brief_inputs.include:
|
|
94
|
+
if any(ch in spec for ch in "*?["):
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"e2e fixture cannot materialize wildcard "
|
|
97
|
+
f"brief_inputs include: {spec!r}")
|
|
98
|
+
targets.append(workspace / spec)
|
|
99
|
+
for target in targets:
|
|
100
|
+
if target.is_file():
|
|
101
|
+
continue
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
target.write_text(f"# {target.name}\n", encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
|
|
77
106
|
@pytest.fixture
|
|
78
107
|
def consumer_repo(tmp_path, monkeypatch, fake_agents, fake_sandbox):
|
|
79
108
|
"""Byte-identical copy of the shipped example as a clean git repo,
|
|
@@ -96,16 +125,13 @@ def consumer_repo(tmp_path, monkeypatch, fake_agents, fake_sandbox):
|
|
|
96
125
|
example_pipeline.write_text(
|
|
97
126
|
example_pipeline.read_text().replace(
|
|
98
127
|
"agents_dir: sdk_agents", "agents_dir: sdk_agents\nstate_dir: .pr", 1))
|
|
99
|
-
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# sibling; the consumer copy must carry it (the real repo does).
|
|
107
|
-
(repo / "aidlc-docs").mkdir(exist_ok=True)
|
|
108
|
-
(repo / "aidlc-docs" / "domain-model-erd.md").write_text("# Domain model\n")
|
|
128
|
+
# Every file the shipped pipeline references (additional_files plus the
|
|
129
|
+
# brief step's brief_inputs.include) must exist in the consumer copy, or
|
|
130
|
+
# the engine fails fast at load. Derive stubs from the copied
|
|
131
|
+
# pipeline.yaml so future doc additions can never break the fixture
|
|
132
|
+
# again (a34 fixed the ERD case by hand; 4b1cbd6 hand-fixed the
|
|
133
|
+
# 038/CHANGELOG/known-issues trio; derivation ends the pattern).
|
|
134
|
+
_materialize_referenced_docs(example_pipeline, repo)
|
|
109
135
|
monkeypatch.chdir(repo)
|
|
110
136
|
subprocess.run(["git", "init", "-q"], check=True)
|
|
111
137
|
subprocess.run(["git", "config", "user.email", "t@t"], check=True)
|
|
@@ -129,6 +155,61 @@ def _log(repo: Path) -> str:
|
|
|
129
155
|
capture_output=True, text=True, check=True).stdout
|
|
130
156
|
|
|
131
157
|
|
|
158
|
+
def _stub_pipeline(tmp_path: Path, additional: list[str],
|
|
159
|
+
include: list[str]) -> tuple[Path, Path]:
|
|
160
|
+
"""Minimal valid pipeline.yaml whose referenced files are all missing,
|
|
161
|
+
for pinning the stub-derivation helper directly."""
|
|
162
|
+
workspace = tmp_path / "repo"
|
|
163
|
+
pdir = workspace / "agentic-dev-loop"
|
|
164
|
+
pdir.mkdir(parents=True)
|
|
165
|
+
pipeline = pdir / "pipeline.yaml"
|
|
166
|
+
pipeline.write_text(textwrap.dedent(f"""
|
|
167
|
+
name: stub-derivation
|
|
168
|
+
llm:
|
|
169
|
+
model: x
|
|
170
|
+
api_key_env: OPENAI_API_KEY
|
|
171
|
+
agents_dir: sdk_agents
|
|
172
|
+
deps_policy: open
|
|
173
|
+
additional_files: {additional}
|
|
174
|
+
steps:
|
|
175
|
+
- name: brief
|
|
176
|
+
agent: brief
|
|
177
|
+
produces: [attempt-brief.md]
|
|
178
|
+
brief_inputs:
|
|
179
|
+
include: {include}
|
|
180
|
+
env_facts: []
|
|
181
|
+
"""))
|
|
182
|
+
return pipeline, workspace
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestMaterializeReferencedDocs:
|
|
186
|
+
"""The consumer_repo fixture derives pipeline-referenced stubs from the
|
|
187
|
+
copied pipeline.yaml (see _materialize_referenced_docs). Pin the
|
|
188
|
+
derivation itself: missing additional_files and brief_inputs.include
|
|
189
|
+
entries get a stub, existing files are never overwritten, and wildcard
|
|
190
|
+
includes raise (a wildcard cannot be stub-materialized, and silent
|
|
191
|
+
fallbacks are forbidden)."""
|
|
192
|
+
|
|
193
|
+
def test_missing_entries_are_stubbed(self, tmp_path):
|
|
194
|
+
pipeline, workspace = _stub_pipeline(
|
|
195
|
+
tmp_path, ["../a.md", "../docs/b.md"], ["rules.md"])
|
|
196
|
+
_materialize_referenced_docs(pipeline, workspace)
|
|
197
|
+
assert (workspace / "a.md").is_file()
|
|
198
|
+
assert (workspace / "docs" / "b.md").is_file()
|
|
199
|
+
assert (workspace / "rules.md").is_file()
|
|
200
|
+
|
|
201
|
+
def test_existing_files_are_not_overwritten(self, tmp_path):
|
|
202
|
+
pipeline, workspace = _stub_pipeline(tmp_path, ["../a.md"], [])
|
|
203
|
+
(workspace / "a.md").write_text("keep\n")
|
|
204
|
+
_materialize_referenced_docs(pipeline, workspace)
|
|
205
|
+
assert (workspace / "a.md").read_text(encoding="utf-8") == "keep\n"
|
|
206
|
+
|
|
207
|
+
def test_wildcard_include_raises(self, tmp_path):
|
|
208
|
+
pipeline, workspace = _stub_pipeline(tmp_path, [], ["*.md"])
|
|
209
|
+
with pytest.raises(RuntimeError, match="wildcard"):
|
|
210
|
+
_materialize_referenced_docs(pipeline, workspace)
|
|
211
|
+
|
|
212
|
+
|
|
132
213
|
class TestHistoricalFixtures:
|
|
133
214
|
def test_run004_decorated_verdict_still_passes(self, consumer_repo,
|
|
134
215
|
fake_agents):
|