agentic-dev-loop 1.0.0a18__tar.gz → 1.0.0a19__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.

Files changed (86) hide show
  1. {agentic_dev_loop-1.0.0a18/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a19}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/isolation.py +15 -14
  4. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/step_workspace.py +1 -1
  5. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/sync_back.py +10 -2
  6. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  7. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary_integration.py +38 -17
  8. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_isolation.py +48 -23
  9. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_step_workspace.py +20 -0
  10. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_sync_back.py +19 -0
  11. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/LICENSE +0 -0
  12. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/README.md +0 -0
  13. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/setup.cfg +0 -0
  14. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/__init__.py +0 -0
  15. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/config.py +0 -0
  17. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/results.py +0 -0
  18. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/verdict.py +0 -0
  19. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/actions.py +0 -0
  21. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/cli.py +0 -0
  23. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/deps.py +0 -0
  24. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  25. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  26. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  27. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  28. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  29. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/graph.py +0 -0
  30. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  31. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/llm.py +0 -0
  32. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/loaders.py +0 -0
  33. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/mounts.py +0 -0
  34. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/outputs.py +0 -0
  35. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/presets.py +0 -0
  36. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/proc.py +0 -0
  37. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/registry.py +0 -0
  38. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  39. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  40. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/secrets.py +0 -0
  41. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/stages.py +0 -0
  42. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/py.typed +0 -0
  43. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__main__.py +0 -0
  45. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/server.py +0 -0
  46. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  47. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  48. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  49. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  50. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  51. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_actions.py +0 -0
  52. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_agents.py +0 -0
  53. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_aidlc.py +0 -0
  54. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_cli.py +0 -0
  55. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_config.py +0 -0
  56. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_deps.py +0 -0
  57. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_dry_run.py +0 -0
  58. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_e2e_fixtures.py +0 -0
  59. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_egress_proxy.py +0 -0
  60. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_graph.py +0 -0
  61. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary.py +0 -0
  62. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_integration.py +0 -0
  63. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_pbt.py +0 -0
  64. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_llm.py +0 -0
  65. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_loaders.py +0 -0
  66. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_module_contract.py +0 -0
  67. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_outputs.py +0 -0
  68. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_outputs_pbt.py +0 -0
  69. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_proc.py +0 -0
  70. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_publish.py +0 -0
  71. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_qa_prompt.py +0 -0
  72. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_registry.py +0 -0
  73. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_repo_ascii.py +0 -0
  74. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_results.py +0 -0
  75. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_sdk_compat.py +0 -0
  76. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_secrets.py +0 -0
  77. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_integration.py +0 -0
  78. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_pipeline.py +0 -0
  79. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_source_resolution.py +0 -0
  80. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_stages.py +0 -0
  81. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_trigger_script.py +0 -0
  82. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_unit5_config.py +0 -0
  83. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_unit5_pbt.py +0 -0
  84. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_unit5_pure.py +0 -0
  85. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_verdict.py +0 -0
  86. {agentic_dev_loop-1.0.0a18 → agentic_dev_loop-1.0.0a19}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a18
3
+ Version: 1.0.0a19
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a18"
7
+ version = "1.0.0a19"
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"
@@ -1271,16 +1271,20 @@ def resolve_image_ref(image: str, environ: Mapping[str, str] | None = None) -> s
1271
1271
 
1272
1272
 
1273
1273
  def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1274
+ state_dir: str | Path,
1274
1275
  runner: CommandRunner | None = None,
1275
- refresh: bool | None = None,
1276
- state_dir: str | Path | None = None) -> Sandbox:
1276
+ refresh: bool | None = None) -> Sandbox:
1277
1277
  """Eager startup sequence (Q2): probe → image → materialise export →
1278
1278
  backend → ensure_image → create+start → pre-provision every declared setup
1279
1279
  (stamp-cached).
1280
1280
 
1281
1281
  ``state_dir`` (the run state dir) hosts the .git-free export under
1282
- ``exports/<run-id>/``, materialised here (BR-2/BR-3); when omitted the
1283
- legacy workspace model applies (integration tests, programmatic calls)."""
1282
+ ``exports/<run-id>/``, materialised here (BR-2/BR-3). It is required:
1283
+ the runtime always resolves a mount set for steps, and enforcement needs
1284
+ the export (AE-SBX-017), so there is no legacy fallback through this
1285
+ entry point. The legacy workspace model is reachable only through the
1286
+ direct Sandbox API (DockerBackend without an export + requests carrying
1287
+ ``mount_set=None``), which is integration-tests-only."""
1284
1288
  seccomp_profile = resolved_seccomp_profile(cfg)
1285
1289
  probe_docker(runner)
1286
1290
  profile = resolve_effective_profile(cfg.isolation)
@@ -1290,16 +1294,13 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1290
1294
  sandbox_venv_dir = resolve_sandbox_venv_dir(
1291
1295
  cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
1292
1296
 
1293
- export_root: str | None = None
1294
- cache_mounts: list[tuple[str, str, str]] | None = None
1295
- if state_dir is not None:
1296
- export_root = str(Path(state_dir) / "exports" / uuid.uuid4().hex)
1297
- cache_paths = discover_cache_paths(workspace, sandbox_venv_dir)
1298
- ws = StepWorkspace.materialise(
1299
- workspace, cache_paths, sys.platform, export_root,
1300
- state_dir=str(state_dir))
1301
- export_root = str(ws.export_root)
1302
- cache_mounts = ws.mount_points()[1:] # skip the export root itself
1297
+ export_root = str(Path(state_dir) / "exports" / uuid.uuid4().hex)
1298
+ cache_paths = discover_cache_paths(workspace, sandbox_venv_dir)
1299
+ ws = StepWorkspace.materialise(
1300
+ workspace, cache_paths, sys.platform, export_root,
1301
+ state_dir=str(state_dir))
1302
+ export_root = str(ws.export_root)
1303
+ cache_mounts = ws.mount_points()[1:] # skip the export root itself
1303
1304
 
1304
1305
  backend = DockerBackend(
1305
1306
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
@@ -140,7 +140,7 @@ class StepWorkspace:
140
140
  src = repo / rel
141
141
  dst = self._export_root / rel
142
142
  dst.parent.mkdir(parents=True, exist_ok=True)
143
- shutil.copy2(src, dst)
143
+ shutil.copy2(src, dst, follow_symlinks=False)
144
144
  for cache in self._plan.cache_mounts:
145
145
  (self._export_root / cache.relative).mkdir(parents=True,
146
146
  exist_ok=True)
@@ -105,6 +105,12 @@ def plan_sync_back(export_changes: Mapping[str, str],
105
105
 
106
106
 
107
107
  def _digest(path: Path) -> str:
108
+ if path.is_symlink():
109
+ # Track the link target, never the resolved content: the export can
110
+ # legitimately hold dangling symlinks (host-darwin venv entries,
111
+ # scratch links) and resolving them against the host would crash.
112
+ return "link:" + hashlib.sha256(
113
+ os.readlink(path).encode("utf-8")).hexdigest()
108
114
  return hashlib.sha256(path.read_bytes()).hexdigest()
109
115
 
110
116
 
@@ -112,7 +118,9 @@ def _snapshot(root: Path,
112
118
  cache_paths: list[str] | tuple[str, ...] = ()) -> dict[str, str]:
113
119
  """rel-path -> sha256 for every file under root, excluding ``.git``,
114
120
  host-only dirs, and declared caches (BR-3: caches are bind-mounted, not
115
- copied, so their contents are never part of the export)."""
121
+ copied, so their contents are never part of the export). Symlinks are
122
+ tracked by their link target (never resolved against the host), so
123
+ dangling links snapshot fine."""
116
124
  cache = [_parts(c) for c in cache_paths]
117
125
  out: dict[str, str] = {}
118
126
  for dirpath, dirnames, filenames in os.walk(root):
@@ -180,7 +188,7 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
180
188
  src = export_root / rel
181
189
  dst = real_repo / rel
182
190
  dst.parent.mkdir(parents=True, exist_ok=True)
183
- shutil.copy2(src, dst)
191
+ shutil.copy2(src, dst, follow_symlinks=False)
184
192
  for rel in plan.to_delete:
185
193
  (real_repo / rel).unlink(missing_ok=True)
186
194
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a18
3
+ Version: 1.0.0a19
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
@@ -5,6 +5,7 @@ never silently skipped in CI."""
5
5
  from __future__ import annotations
6
6
 
7
7
  import shutil
8
+ from pathlib import Path
8
9
 
9
10
  import pytest
10
11
  from openhands.sdk import LLM
@@ -16,6 +17,7 @@ from agentic_dev_loop.engine.isolation import (
16
17
  )
17
18
  from agentic_dev_loop.engine.registry import Registry
18
19
  from agentic_dev_loop.engine.stages import Runtime, make_action_node
20
+ from agentic_dev_loop.engine.step_workspace import StepWorkspace
19
21
 
20
22
  pytestmark = pytest.mark.docker
21
23
 
@@ -44,16 +46,24 @@ def backend(tmp_path):
44
46
  pytest.skip(f"integration image {TEST_IMAGE!r} not present locally")
45
47
  ws = tmp_path / "ws"
46
48
  ws.mkdir()
49
+ # unit-5: every step resolves a mount set, so enforcement needs the
50
+ # materialised .git-free export (AE-SBX-017 otherwise). Wire the export
51
+ # the way startup_sandbox does (mirror test_isolation_integration's
52
+ # _export_backend).
53
+ export = tmp_path / "state" / "exports" / "r1"
54
+ ws_obj = StepWorkspace.materialise(ws, [], "linux", export)
47
55
  profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
48
56
  b = DockerBackend(image=TEST_IMAGE, workspace=str(ws),
49
- iso_state_dir=str(tmp_path / "state"), profile=profile)
57
+ iso_state_dir=str(tmp_path / "state"), profile=profile,
58
+ export_root=str(export),
59
+ cache_mounts=ws_obj.mount_points()[1:])
50
60
  b.ensure_image()
51
61
  b.create_execution_container()
52
- yield b, ws
62
+ yield b, ws, export
53
63
  b.close()
54
64
 
55
65
 
56
- def _runtime(backend, ws, script, tmp_path):
66
+ def _runtime(backend, ws, export, script, tmp_path):
57
67
  reg = Registry()
58
68
  reg.add_sandbox_action("probe", script)
59
69
  cfg = PipelineConfig.model_validate({
@@ -61,32 +71,43 @@ def _runtime(backend, ws, script, tmp_path):
61
71
  "llm": {"model": "openai/test-model", "api_key_env": "K"},
62
72
  "agents_dir": "agents",
63
73
  "lockfiles": ["uv.lock"],
64
- "steps": [{"name": "probe", "action": "probe", "on_pass": "success"}],
74
+ # The workspace is a declared-rw mount (BR-1.2): the consumer can
75
+ # write inside the sandbox, and sync-back (BR-4) carries it to ws.
76
+ "steps": [{"name": "probe", "action": "probe", "on_pass": "success",
77
+ "mounts": [{"path": ".", "mode": "rw"}]}],
65
78
  })
66
79
  runtime = Runtime(cfg=cfg, registry=reg,
67
80
  llm=LLM(model="openai/test-model", api_key="sk-test"),
68
81
  agents_dir=tmp_path / "agents", state_dir=tmp_path / "state",
69
- workspace=ws, sandbox=backend)
82
+ workspace=ws, sandbox=backend, sandbox_cwd=Path(export))
70
83
  return runtime, cfg.steps[0]
71
84
 
72
85
 
73
86
  class TestConsumerActionSentinel:
74
87
  def test_action_code_cannot_create_host_file(self, backend, tmp_path):
75
- b, ws = backend
88
+ b, ws, export = backend
76
89
  sentinel = tmp_path / "host-sentinel.txt"
77
- script = f'python -c "open({str(sentinel)!r}, \\"w\\").write(\\"pwned\\")"'
78
- runtime, step = _runtime(b, ws, script, tmp_path)
79
- node = make_action_node(runtime, step)
80
- node({"subject": "s", "attempt": 1, "step_verdicts": {}})
81
- # The action wrote to a path that exists on the host but is not the
82
- # workspace mount - it landed in the container's own filesystem (or was
83
- # refused by the read-only rootfs), never on the host.
90
+ # The marker is written first: it proves the consumer code really ran
91
+ # inside the sandbox, so this test can never pass vacuously when the
92
+ # action dies pre-exec. Then the escape attempt.
93
+ script = (f'python -c "open(\\"ran-in-sandbox.txt\\", \\"w\\").write(\\"ran\\"); '
94
+ f'open({str(sentinel)!r}, \\"w\\").write(\\"pwned\\")"')
95
+ runtime, step = _runtime(b, ws, export, script, tmp_path)
96
+ make_action_node(runtime, step)({"subject": "s", "attempt": 1,
97
+ "step_verdicts": {}})
98
+ # The escape attempt targets a path that exists on the host but is not
99
+ # the export mount: the container never sees it, so it fails inside
100
+ # the container (or lands in its own filesystem), never on the host.
101
+ assert (ws / "ran-in-sandbox.txt").read_text() == "ran"
84
102
  assert not sentinel.exists()
85
103
 
86
104
  def test_action_can_still_write_inside_workspace(self, backend, tmp_path):
87
- b, ws = backend
105
+ b, ws, export = backend
88
106
  script = 'python -c "open(\\"in-sandbox.txt\\", \\"w\\").write(\\"ok\\")"'
89
- runtime, step = _runtime(b, ws, script, tmp_path)
90
- make_action_node(runtime, step)({"subject": "s", "attempt": 1,
91
- "step_verdicts": {}})
107
+ runtime, step = _runtime(b, ws, export, script, tmp_path)
108
+ update = make_action_node(runtime, step)({"subject": "s", "attempt": 1,
109
+ "step_verdicts": {}})
110
+ # The action genuinely executed (verdict pass) and its declared-rw
111
+ # write synced back to the real workspace (BR-4).
112
+ assert update["step_verdicts"]["probe"] == "pass"
92
113
  assert (ws / "in-sandbox.txt").read_text() == "ok"
@@ -975,6 +975,24 @@ class TestStartupSandbox:
975
975
  "isolation": {"state_dir": str(tmp_path / "iso-state")},
976
976
  })
977
977
 
978
+ def test_state_dir_required(self, tmp_path, monkeypatch):
979
+ """The run state_dir is mandatory: the runtime always resolves a
980
+ mount set for steps, so enforcement needs the materialised export
981
+ (AE-SBX-017). Omitting state_dir fails at the call - never silently
982
+ degrades to the legacy workspace model."""
983
+ from agentic_dev_loop.engine.isolation import startup_sandbox
984
+
985
+ ws = tmp_path / "ws"
986
+ ws.mkdir()
987
+ cfg = self._cfg(tmp_path)
988
+ monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.which",
989
+ lambda *a: "/usr/bin/docker")
990
+ monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
991
+ runner = FakeRunner()
992
+ with pytest.raises(TypeError, match="state_dir"):
993
+ startup_sandbox(cfg, workspace=ws, runner=runner) # type: ignore[call-arg]
994
+ assert runner.calls == []
995
+
978
996
  def test_sequence_and_provision(self, tmp_path, monkeypatch):
979
997
  from agentic_dev_loop.engine import isolation
980
998
  from agentic_dev_loop.engine.isolation import startup_sandbox
@@ -992,7 +1010,7 @@ class TestStartupSandbox:
992
1010
  ok(""), # docker run -d --rm (exec container)
993
1011
  ok(""), # docker run provision
994
1012
  )
995
- sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1013
+ sandbox = startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
996
1014
  try:
997
1015
  kinds = [c[1] for c in runner.calls]
998
1016
  assert kinds == ["version", "info", "image", "run", "run"]
@@ -1006,9 +1024,10 @@ class TestStartupSandbox:
1006
1024
  sandbox.close()
1007
1025
 
1008
1026
  def test_network_declaration_starts_second_container(self, tmp_path, monkeypatch):
1009
- """014: declaring `network: true` on any step makes eager startup
1010
- create the bridge-network container in addition to the default
1011
- network-none one - the default (no declaration) stays single-container."""
1027
+ """014/unit-5: startup creates only the base network-none export
1028
+ container. The bridge-network container for a network-declaring step
1029
+ starts lazily in the profile pool on the first networked request
1030
+ (covered by test_isolation_integration), never eagerly at startup."""
1012
1031
  from agentic_dev_loop.core.config import PipelineConfig
1013
1032
  from agentic_dev_loop.engine.isolation import startup_sandbox
1014
1033
 
@@ -1030,15 +1049,13 @@ class TestStartupSandbox:
1030
1049
  ok("1.54"), # probe version
1031
1050
  ok("linux"), # probe ostype
1032
1051
  ok(""), # image inspect
1033
- ok(""), # docker run -d --rm (network-none exec)
1034
- ok(""), # docker run -d --rm (networked exec)
1052
+ ok(""), # docker run -d --rm (base export container)
1035
1053
  )
1036
- sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1054
+ sandbox = startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1037
1055
  try:
1038
1056
  run_argv = [c for c in runner.calls if c[1] == "run"]
1039
- assert len(run_argv) == 2
1057
+ assert len(run_argv) == 1
1040
1058
  assert "--network" in run_argv[0] and "none" in run_argv[0]
1041
- assert "--network" in run_argv[1] and "bridge" in run_argv[1]
1042
1059
  finally:
1043
1060
  sandbox.close()
1044
1061
 
@@ -1059,20 +1076,24 @@ class TestStartupSandbox:
1059
1076
 
1060
1077
  # Without refresh the stamp is a hit: no provision container runs.
1061
1078
  runner = FakeRunner(ok("1.54"), ok("linux"), ok(""), ok(""))
1062
- sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1079
+ sandbox = startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1063
1080
  sandbox.close()
1064
1081
  assert not [c for c in runner.calls if "python -m venv .venv-sandbox" in c]
1065
1082
 
1066
1083
  # With refresh the stamp is ignored: provision re-runs.
1067
1084
  runner2 = FakeRunner(ok("1.54"), ok("linux"), ok(""), ok(""), ok(""))
1068
- sandbox2 = startup_sandbox(cfg, workspace=ws, runner=runner2, refresh=True)
1085
+ sandbox2 = startup_sandbox(cfg, workspace=ws,
1086
+ state_dir=tmp_path / "run-state",
1087
+ runner=runner2, refresh=True)
1069
1088
  sandbox2.close()
1070
1089
  assert [c for c in runner2.calls if "python -m venv .venv-sandbox" in c]
1071
1090
 
1072
1091
  def test_relocated_venv_dir_flows_into_payloads(self, tmp_path, monkeypatch):
1073
1092
  """022: a set `sandbox_venv_dir` is resolved and threaded into BOTH the
1074
- exec and provision payloads; the workspace stays free of `.venv-sandbox`
1075
- noise (the outside dir is mounted at the unchanged container path)."""
1093
+ exec and provision payloads. The exec (base export) container maps it
1094
+ into the export at `.venv-sandbox`; the provision container maps it
1095
+ into the real workspace path. The workspace stays free of
1096
+ `.venv-sandbox` noise either way."""
1076
1097
  from agentic_dev_loop.engine.isolation import startup_sandbox
1077
1098
 
1078
1099
  ws = tmp_path / "ws"
@@ -1090,14 +1111,18 @@ class TestStartupSandbox:
1090
1111
  ok(""), # docker run -d --rm (exec container)
1091
1112
  ok(""), # docker run provision
1092
1113
  )
1093
- sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1114
+ sandbox = startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1094
1115
  try:
1095
- target = f"{venv_dir}:{ws}/.venv-sandbox"
1096
- for argv in runner.calls:
1097
- if argv[:2] == ["docker", "run"]:
1098
- assert target in argv
1099
- assert argv[argv.index(target) - 1] == "-v"
1100
- assert f"{ws}/.venv:ro" in argv
1116
+ root = sandbox.export_root
1117
+ exec_argv = [c for c in runner.calls
1118
+ if c[:2] == ["docker", "run"] and "--detach" in c][0]
1119
+ prov_argv = [c for c in runner.calls
1120
+ if c[:2] == ["docker", "run"] and "--detach" not in c][0]
1121
+ exec_target = f"{venv_dir}:{root}/.venv-sandbox"
1122
+ prov_target = f"{venv_dir}:{ws}/.venv-sandbox"
1123
+ assert exec_argv[exec_argv.index(exec_target) - 1] == "-v"
1124
+ assert prov_argv[prov_argv.index(prov_target) - 1] == "-v"
1125
+ assert f"{ws}/.venv:ro" in prov_argv
1101
1126
  finally:
1102
1127
  sandbox.close()
1103
1128
 
@@ -1121,7 +1146,7 @@ class TestStartupSandbox:
1121
1146
  ok(""), # image inspect
1122
1147
  ok(""), # docker run -d --rm (exec container)
1123
1148
  )
1124
- sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1149
+ sandbox = startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1125
1150
  try:
1126
1151
  run_argv = [c for c in runner.calls if c[:2] == ["docker", "run"]][0]
1127
1152
  resolved = chromium_userns_profile_path()
@@ -1146,7 +1171,7 @@ class TestStartupSandbox:
1146
1171
  monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
1147
1172
  runner = FakeRunner()
1148
1173
  with pytest.raises(ValueError, match="bundled:chromium-userns"):
1149
- startup_sandbox(cfg, workspace=ws, runner=runner)
1174
+ startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1150
1175
  assert runner.calls == []
1151
1176
 
1152
1177
  def test_in_workspace_sandbox_venv_dir_fails_loud(self, tmp_path, monkeypatch):
@@ -1161,7 +1186,7 @@ class TestStartupSandbox:
1161
1186
  monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
1162
1187
  runner = FakeRunner(ok("1.54"), ok("linux"))
1163
1188
  with pytest.raises(SandboxError, match="AE-SBX-015"):
1164
- startup_sandbox(cfg, workspace=ws, runner=runner)
1189
+ startup_sandbox(cfg, workspace=ws, state_dir=tmp_path / "run-state", runner=runner)
1165
1190
 
1166
1191
 
1167
1192
  class TestBundledSeccompProfile:
@@ -32,6 +32,26 @@ def test_materialise_copies_except_git_and_host_only(tmp_path):
32
32
  assert not (export / ".venv").exists()
33
33
 
34
34
 
35
+ def test_materialise_preserves_symlinks_without_resolving(tmp_path):
36
+ repo = _repo(tmp_path)
37
+ (repo / "links").mkdir()
38
+ (repo / "links" / "dangling").symlink_to("nowhere")
39
+ (repo / "links" / "valid").symlink_to("../src/a.py")
40
+ export = tmp_path / "state" / "exports" / "run1"
41
+ # Completing materialise also proves the baseline snapshot survives the
42
+ # dangling link (it runs _snapshot on the export after the copy).
43
+ StepWorkspace.materialise(repo, [], "linux", export)
44
+ dangling = export / "links" / "dangling"
45
+ valid = export / "links" / "valid"
46
+ # Symlinks copy as symlinks, never resolved against the host: the
47
+ # dangling one stays dangling, the valid one keeps its relative target.
48
+ assert dangling.is_symlink()
49
+ assert os.readlink(dangling) == "nowhere"
50
+ assert not dangling.exists()
51
+ assert valid.is_symlink()
52
+ assert os.readlink(valid) == "../src/a.py"
53
+
54
+
35
55
  def test_missing_cache_is_loud(tmp_path):
36
56
  repo = _repo(tmp_path)
37
57
  export = tmp_path / "state" / "exports" / "run1"
@@ -2,6 +2,7 @@
2
2
  only, .git never touched, conflicts loud."""
3
3
 
4
4
  import hashlib
5
+ import os
5
6
 
6
7
  import pytest
7
8
 
@@ -100,6 +101,24 @@ def test_whole_tree_dot_syncs_back(tmp_path):
100
101
  assert (ws / "docs" / "new.md").read_text() == "new"
101
102
 
102
103
 
104
+ def test_symlink_syncs_as_symlink_never_resolved(tmp_path):
105
+ """A symlink the sandbox creates in a declared-rw path syncs back as a
106
+ symlink, including a dangling one (whose target only makes sense inside
107
+ the export). Resolving it against the host would crash or corrupt."""
108
+ ws = _workspace(tmp_path)
109
+ exp = _export(tmp_path, ws)
110
+ (exp / "links").mkdir()
111
+ (exp / "links" / "to-src").symlink_to("../src/a.py")
112
+ (exp / "links" / "dangling").symlink_to("no-such-target")
113
+ report = sync_back(exp, ws, declared_rw=["."])
114
+ assert report.conflicts == ()
115
+ assert set(report.synced_paths) == {"links/to-src", "links/dangling"}
116
+ assert (ws / "links" / "to-src").is_symlink()
117
+ assert os.readlink(ws / "links" / "to-src") == "../src/a.py"
118
+ assert (ws / "links" / "dangling").is_symlink()
119
+ assert os.readlink(ws / "links" / "dangling") == "no-such-target"
120
+
121
+
103
122
  def test_cache_not_deleted(tmp_path):
104
123
  """BR-3/F2: a bind-mounted cache is absent from the export but present in
105
124
  the real repo - a ``.``-rw sync-back must never classify it as a deletion