agentic-dev-loop 1.0.0a19__tar.gz → 1.0.0a21__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 (88) hide show
  1. {agentic_dev_loop-1.0.0a19/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a21}/PKG-INFO +2 -1
  2. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/README.md +1 -0
  3. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/config.py +147 -0
  5. agentic_dev_loop-1.0.0a21/src/agentic_dev_loop/core/paths.py +69 -0
  6. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/egress_proxy.py +17 -0
  7. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/isolation.py +76 -23
  8. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/presets.py +3 -14
  9. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/step_workspace.py +25 -12
  10. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/sync_back.py +34 -21
  11. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21/src/agentic_dev_loop.egg-info}/PKG-INFO +2 -1
  12. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  13. agentic_dev_loop-1.0.0a21/tests/test_028_declared_caches.py +241 -0
  14. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_egress_proxy.py +77 -0
  15. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_isolation.py +81 -0
  16. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_repo_ascii.py +4 -0
  17. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_step_workspace.py +46 -0
  18. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_sync_back.py +85 -0
  19. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_pbt.py +19 -0
  20. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/LICENSE +0 -0
  21. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/setup.cfg +0 -0
  22. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/__init__.py +0 -0
  24. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/results.py +0 -0
  25. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/verdict.py +0 -0
  26. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/actions.py +0 -0
  28. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/agents.py +0 -0
  29. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/cli.py +0 -0
  30. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/deps.py +0 -0
  31. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  32. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  33. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  34. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  35. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/llm.py +0 -0
  38. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/loaders.py +0 -0
  39. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/mounts.py +0 -0
  40. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/proc.py +0 -0
  42. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/registry.py +0 -0
  43. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  45. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/secrets.py +0 -0
  46. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/stages.py +0 -0
  47. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/py.typed +0 -0
  48. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/__init__.py +0 -0
  49. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/__main__.py +0 -0
  50. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/server.py +0 -0
  51. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  52. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  53. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  54. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  55. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_actions.py +0 -0
  56. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_agents.py +0 -0
  57. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_aidlc.py +0 -0
  58. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_cli.py +0 -0
  59. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_config.py +0 -0
  60. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_deps.py +0 -0
  61. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_dry_run.py +0 -0
  62. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_e2e_fixtures.py +0 -0
  63. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_graph.py +0 -0
  64. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_hostboundary.py +0 -0
  65. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_hostboundary_integration.py +0 -0
  66. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_isolation_integration.py +0 -0
  67. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_isolation_pbt.py +0 -0
  68. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_llm.py +0 -0
  69. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_loaders.py +0 -0
  70. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_module_contract.py +0 -0
  71. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_outputs.py +0 -0
  72. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_outputs_pbt.py +0 -0
  73. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_proc.py +0 -0
  74. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_publish.py +0 -0
  75. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_qa_prompt.py +0 -0
  76. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_registry.py +0 -0
  77. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_results.py +0 -0
  78. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_sdk_compat.py +0 -0
  79. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_secrets.py +0 -0
  80. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_secrets_integration.py +0 -0
  81. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_secrets_pipeline.py +0 -0
  82. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_source_resolution.py +0 -0
  83. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_stages.py +0 -0
  84. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_trigger_script.py +0 -0
  85. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_config.py +0 -0
  86. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_pure.py +0 -0
  87. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/tests/test_verdict.py +0 -0
  88. {agentic_dev_loop-1.0.0a19 → agentic_dev_loop-1.0.0a21}/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.0a19
3
+ Version: 1.0.0a21
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
@@ -220,6 +220,7 @@ steps:
220
220
  | `extensions_dir` | Where consumer `extensions:` modules resolve - defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
221
221
  | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
222
222
  | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
223
+ | `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
223
224
 
224
225
  ### Secrets (`secrets:`) - refs only, never values
225
226
 
@@ -178,6 +178,7 @@ steps:
178
178
  | `extensions_dir` | Where consumer `extensions:` modules resolve - defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
179
179
  | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
180
180
  | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
181
+ | `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
181
182
 
182
183
  ### Secrets (`secrets:`) - refs only, never values
183
184
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a19"
7
+ version = "1.0.0a21"
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"
@@ -4,12 +4,15 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import re
7
+ import warnings
7
8
  from pathlib import Path
8
9
  from typing import Any
9
10
 
10
11
  import yaml
11
12
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
13
 
14
+ from agentic_dev_loop.core.paths import _has_wildcards, path_excluded, relative_parts
15
+
13
16
  TERMINALS = frozenset({"success", "failure"})
14
17
  KNOWN_SECRET_PROVIDERS = frozenset({"env"})
15
18
  MOUNT_MODES = frozenset({"rw", "ro"})
@@ -51,6 +54,38 @@ def _validate_mount_path(path: str) -> str:
51
54
  return "/".join(parts) if parts else "."
52
55
 
53
56
 
57
+ def _validate_declared_path(path: str, *, allow_wildcards: bool) -> str:
58
+ """Validate one declared ``caches:``/``sync_excludes:`` entry (028).
59
+
60
+ Same repo-relative contract as mount paths: non-empty, no absolute paths,
61
+ no ``..`` traversal, no ``.git`` component, no host-only dir. Wildcard
62
+ entries (``sync_excludes:`` only) skip wildcard segments in the literal
63
+ checks; the literal segments still obey the same rules. The normalised
64
+ forward-slash form is returned.
65
+ """
66
+ if not isinstance(path, str) or not path.strip():
67
+ raise ValueError("declared path must be a non-empty string")
68
+ p = path.strip()
69
+ if p.startswith(("/", "\\")) or (len(p) > 1 and p[1] == ":"):
70
+ raise ValueError(
71
+ f"declared path {path!r} must be repo-relative (no absolute paths)")
72
+ parts = [seg for seg in p.replace("\\", "/").split("/")
73
+ if seg not in ("", ".")]
74
+ for seg in parts:
75
+ if allow_wildcards and any(ch in seg for ch in "*?["):
76
+ continue
77
+ if seg == "..":
78
+ raise ValueError(
79
+ f"declared path {path!r} escapes the repo (no '..' traversal)")
80
+ if seg == ".git":
81
+ raise ValueError(
82
+ f"declared path {path!r} must never name '.git'")
83
+ if seg in HOST_ONLY_MOUNT_DIRS:
84
+ raise ValueError(
85
+ f"declared path {path!r} names the host-only dir {seg!r}")
86
+ return "/".join(parts) if parts else "."
87
+
88
+
54
89
  def parse_egress_target(spec: str) -> tuple[str, int | None]:
55
90
  """Parse ``host``, ``host:port``, or ``scheme://host[:port]`` into
56
91
  ``(host, port)``. Malformed entries that can never match fail loud
@@ -404,6 +439,13 @@ class PipelineConfig(BaseModel):
404
439
  # missing-dependency fail-fast.
405
440
  deps_policy: str = "locked"
406
441
  lockfiles: list[str] = []
442
+ # 028: pipeline-declared toolchain caches (rw bind-mounts from the real
443
+ # workspace) and run-wide sync-back excludes (scratch semantics: skipped
444
+ # from both the export copy and sync-back). Both default to [] - absent
445
+ # means "no toolchain caches beyond the engine's own sandbox venv" and
446
+ # "no sync-back excludes".
447
+ caches: list[str] = []
448
+ sync_excludes: list[str] = []
407
449
 
408
450
  @field_validator("deps_policy")
409
451
  @classmethod
@@ -418,6 +460,21 @@ class PipelineConfig(BaseModel):
418
460
  def _coerce_lockfiles(cls, v: Any) -> Any:
419
461
  return [v] if isinstance(v, str) else v
420
462
 
463
+ @field_validator("caches", "sync_excludes", mode="before")
464
+ @classmethod
465
+ def _coerce_declared_paths(cls, v: Any) -> Any:
466
+ return [v] if isinstance(v, str) else v
467
+
468
+ @field_validator("caches")
469
+ @classmethod
470
+ def _valid_caches(cls, v: list[str]) -> list[str]:
471
+ return [_validate_declared_path(c, allow_wildcards=False) for c in v]
472
+
473
+ @field_validator("sync_excludes")
474
+ @classmethod
475
+ def _valid_sync_excludes(cls, v: list[str]) -> list[str]:
476
+ return [_validate_declared_path(c, allow_wildcards=True) for c in v]
477
+
421
478
  @field_validator("secrets", mode="before")
422
479
  @classmethod
423
480
  def _reject_value_shaped_refs(cls, v: Any) -> Any:
@@ -464,6 +521,96 @@ class PipelineConfig(BaseModel):
464
521
  f"the host-only state_dir {self.state_dir!r}")
465
522
  return self
466
523
 
524
+ @model_validator(mode="after")
525
+ def _caches_avoid_state_dir(self) -> "PipelineConfig":
526
+ """028: a declared cache may never name (or sit under) the host-only
527
+ run-state dir - it would bind-mount the engine's own state into the
528
+ sandbox as a rw cache."""
529
+ sd = self.state_dir.strip().rstrip("/\\")
530
+ if not sd or sd in ("/", "."):
531
+ return self
532
+ sd_parts = relative_parts(sd)
533
+ if not sd_parts:
534
+ return self
535
+ for cache in self.caches:
536
+ cp = relative_parts(cache)
537
+ if cp[:len(sd_parts)] == sd_parts:
538
+ raise ValueError(
539
+ f"cache path {cache!r} names the host-only state_dir "
540
+ f"{self.state_dir!r}")
541
+ return self
542
+
543
+ @model_validator(mode="after")
544
+ def _mounts_avoid_caches(self) -> "PipelineConfig":
545
+ """028/BR-1.3: a step mount equal to or under a declared cache is
546
+ rejected - the rw cache submount would silently defeat a declared
547
+ ``ro`` mount there. A whole-tree ``.`` mount stays fine (the cache
548
+ submount layers on top)."""
549
+ cache_parts = [relative_parts(c) for c in self.caches if relative_parts(c)]
550
+ if not cache_parts:
551
+ return self
552
+ for step in self.steps:
553
+ for m in step.mounts:
554
+ mp = relative_parts(m.path)
555
+ for cp in cache_parts:
556
+ if mp[:len(cp)] == cp:
557
+ raise ValueError(
558
+ f"step {step.name!r}: mount path {m.path!r} names "
559
+ f"declared cache {('/'.join(cp))!r}")
560
+ return self
561
+
562
+ @model_validator(mode="after")
563
+ def _excludes_avoid_caches(self) -> "PipelineConfig":
564
+ """028: a literal sync-exclude equal to or under a declared cache is
565
+ redundant (caches already behave as scratch) and fails loud. Wildcard
566
+ excludes are skipped - their overlap is fuzzy, not a contradiction."""
567
+ cache_parts = [relative_parts(c) for c in self.caches if relative_parts(c)]
568
+ if not cache_parts:
569
+ return self
570
+ for exclude in self.sync_excludes:
571
+ if _has_wildcards(exclude):
572
+ continue
573
+ ep = relative_parts(exclude)
574
+ for cp in cache_parts:
575
+ if ep[:len(cp)] == cp:
576
+ raise ValueError(
577
+ f"sync_excludes entry {exclude!r} is redundant: it is "
578
+ f"already excluded as declared cache "
579
+ f"{('/'.join(cp))!r}")
580
+ return self
581
+
582
+ @model_validator(mode="after")
583
+ def _sandbox_venv_not_declared_cache(self) -> "PipelineConfig":
584
+ """028: the sandbox venv is engine infrastructure, never author data.
585
+ Declaring ``.venv-sandbox`` in ``caches:`` while the 022 relocation
586
+ (``isolation.sandbox_venv_dir``) is set is a contradiction."""
587
+ if self.isolation is None or self.isolation.sandbox_venv_dir is None:
588
+ return self
589
+ for cache in self.caches:
590
+ if relative_parts(cache) == (".venv-sandbox",):
591
+ raise ValueError(
592
+ "declaring '.venv-sandbox' in 'caches:' contradicts "
593
+ "'isolation.sandbox_venv_dir' - the sandbox venv is "
594
+ "relocated outside the workspace (022)")
595
+ return self
596
+
597
+ @model_validator(mode="after")
598
+ def _warn_shadowed_artifacts(self) -> "PipelineConfig":
599
+ """028: an artifact/produces path matching a sync-exclude would never
600
+ sync back. Non-fatal warning (wildcard overlap is fuzzy, so a hard
601
+ failure would false-positive)."""
602
+ if not self.sync_excludes:
603
+ return self
604
+ for step in self.steps:
605
+ for artifact in list(step.produces) + (
606
+ [step.artifact] if step.artifact is not None else []):
607
+ if path_excluded(artifact, self.sync_excludes):
608
+ warnings.warn(
609
+ f"step {step.name!r}: {artifact!r} matches a "
610
+ "sync_excludes entry and will never sync back",
611
+ stacklevel=2)
612
+ return self
613
+
467
614
  @model_validator(mode="after")
468
615
  def _check_graph(self) -> "PipelineConfig":
469
616
  names = [s.name for s in self.steps]
@@ -0,0 +1,69 @@
1
+ """Repo-relative path helpers shared by config validation and sync-back.
2
+
3
+ One place for the declared-path exclusion vocabulary (028): literal entries
4
+ exclude by component prefix (the dir and everything under it); wildcard
5
+ entries match by ``fnmatch.fnmatchcase`` on the full posix relpath. Both the
6
+ ``caches:``/``sync_excludes:`` validators (core) and the export/sync-back
7
+ planners (engine) consume these, so the semantics cannot drift.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+
14
+
15
+ def relative_parts(path: str) -> tuple[str, ...]:
16
+ """Normalise a repo-relative path into its non-empty, non-``.`` segments."""
17
+ return tuple(seg for seg in path.replace("\\", "/").split("/")
18
+ if seg not in ("", "."))
19
+
20
+
21
+ def _has_wildcards(spec: str) -> bool:
22
+ return any(ch in spec for ch in "*?[")
23
+
24
+
25
+ def path_excluded(rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
26
+ """True when a repo-relative posix path is excluded by a declared
27
+ ``sync_excludes:`` spec (028).
28
+
29
+ Literal entries exclude by component prefix; wildcard entries by
30
+ ``fnmatch.fnmatchcase`` on the full posix relpath. ``*`` spans path
31
+ separators (fnmatch string semantics), so ``**/bin/**`` matches a literal
32
+ ``/bin/`` segment at any depth and ``*.dll`` matches at any depth.
33
+ """
34
+ parts = relative_parts(rel)
35
+ for spec in excludes:
36
+ if _has_wildcards(spec):
37
+ if fnmatch.fnmatchcase(rel, spec):
38
+ return True
39
+ else:
40
+ spec_parts = relative_parts(spec)
41
+ if not spec_parts:
42
+ # ``.`` (or a blank entry) excludes the whole tree.
43
+ return True
44
+ if parts[:len(spec_parts)] == spec_parts:
45
+ return True
46
+ return False
47
+
48
+
49
+ def dir_excluded(dir_rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
50
+ """True when a directory (as a posix relpath) must be pruned from a walk.
51
+
52
+ Literal excludes prune their subtree. Wildcard excludes prune only when
53
+ the directory itself is the matched target - checked with a trailing
54
+ separator so ``**/bin/**`` prunes ``src/proj/bin`` but ``*.dll`` never
55
+ prunes a directory.
56
+ """
57
+ parts = relative_parts(dir_rel)
58
+ for spec in excludes:
59
+ if _has_wildcards(spec):
60
+ if fnmatch.fnmatchcase(dir_rel.rstrip("/") + "/", spec):
61
+ return True
62
+ else:
63
+ spec_parts = relative_parts(spec)
64
+ if not spec_parts:
65
+ # ``.`` (or a blank entry) excludes the whole tree.
66
+ return True
67
+ if parts[:len(spec_parts)] == spec_parts:
68
+ return True
69
+ return False
@@ -131,6 +131,13 @@ class EgressProxy:
131
131
  _write(writer, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
132
132
  return
133
133
  self._record(host, True)
134
+ # Drain the rest of the CONNECT request (the header block after the
135
+ # request line). Clients send the whole request in one write, so the
136
+ # headers are already buffered here; relaying them to the upstream as
137
+ # if they were the first tunnel bytes corrupts the TLS handshake (the
138
+ # upstream answers with a decode alert - the restore step's SSL
139
+ # TLSV1_ALERT_DECODE_ERROR).
140
+ await _drain_headers(reader)
134
141
  conn = await _open(host, port)
135
142
  if conn is None:
136
143
  _write(writer, b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
@@ -222,6 +229,16 @@ async def _forward_headers(reader, upstream, host: str) -> None:
222
229
  await upstream.drain()
223
230
 
224
231
 
232
+ async def _drain_headers(reader) -> None:
233
+ """Consume a request's header block (the lines after the request line,
234
+ terminated by a blank line). The CONNECT path must not forward these to
235
+ the upstream tunnel - only the post-200 bytes belong there."""
236
+ while True:
237
+ line = await reader.readline()
238
+ if not line or line in (b"\r\n", b"\n"):
239
+ break
240
+
241
+
225
242
  async def _relay_both(client_reader, client_writer, upstream_reader,
226
243
  upstream_writer) -> None:
227
244
  await asyncio.gather(
@@ -40,7 +40,6 @@ from agentic_dev_loop.core.config import IsolationConfig, PipelineConfig
40
40
  from .egress_policy import EgressPolicy
41
41
  from .egress_proxy import EgressProxy
42
42
  from .mounts import Mount, MountSet
43
- from .presets import CACHE_DIRS
44
43
  from .proc import needs_shell, split_command
45
44
  from .seccomp import resolve_seccomp_profile
46
45
  from .step_workspace import StepWorkspace
@@ -230,21 +229,6 @@ class ContainerProfile:
230
229
  return container_profile_key(self.mount_set, self.egress_policy)
231
230
 
232
231
 
233
- def discover_cache_paths(workspace: str | Path,
234
- sandbox_venv_dir: str | None = None) -> list[str]:
235
- """The in-workspace cache dirs to bind-mount into the export (BR-3/F2).
236
-
237
- Only dirs that actually exist are caches - an absent cache dir is simply
238
- not a cache (the toolchain is unused), so materialisation never fails on
239
- a repo that does not use, say, node. The relocated sandbox venv (022) is
240
- never a cache: it bind-mounts from outside the workspace instead."""
241
- ws = Path(workspace)
242
- paths = [d for d in CACHE_DIRS if (ws / d).is_dir()]
243
- if sandbox_venv_dir is not None:
244
- paths = [d for d in paths if d != SANDBOX_VENV_DIR]
245
- return paths
246
-
247
-
248
232
  @dataclass
249
233
  class _ProfileContainer:
250
234
  """One pooled execution container (D-1/BR-10): its resolved profile plus
@@ -523,6 +507,53 @@ def remove_sandbox_venv(workspace: str | Path, *,
523
507
  f"- remove it manually and re-run (docs/setup.md §5)") from e
524
508
 
525
509
 
510
+ def repair_venv_shebangs(venv_dir: str | Path, *,
511
+ state_dir: str | Path,
512
+ export_root: str | Path) -> None:
513
+ """Repair stale export-path shebangs in a sandbox venv (028).
514
+
515
+ Each run materialises a fresh export under ``state_dir/exports/<uuid>/``,
516
+ so a venv built by an earlier run carries console-script shebangs that bake
517
+ that (now dead) export path. Rewrite every regular file under
518
+ ``<venv_dir>/bin/`` whose first line starts with ``#!`` and points at a
519
+ dead ``state_dir/exports/`` path to the current run's venv python
520
+ (``{export_root}/.venv-sandbox/bin/python``). Symlinks and files without a
521
+ shebang are left alone; idempotent; host-side, before any exec.
522
+ """
523
+ bin_dir = Path(venv_dir) / "bin"
524
+ if not bin_dir.is_dir():
525
+ return
526
+ target = str(Path(export_root) / SANDBOX_VENV_DIR / "bin" / "python")
527
+ stale_prefix = str(Path(state_dir) / "exports").rstrip("/\\") + "/"
528
+ for entry in sorted(bin_dir.iterdir()):
529
+ if entry.is_symlink() or not entry.is_file():
530
+ continue
531
+ try:
532
+ with entry.open("rb") as fh:
533
+ first = fh.readline(256)
534
+ except OSError:
535
+ continue
536
+ if not first.startswith(b"#!"):
537
+ continue
538
+ line = first.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
539
+ interpreter = line[2:].strip()
540
+ if not interpreter or interpreter == target:
541
+ continue
542
+ if not interpreter.startswith(stale_prefix):
543
+ continue
544
+ _rewrite_shebang(entry, target)
545
+
546
+
547
+ def _rewrite_shebang(path: Path, target: str) -> None:
548
+ """Replace only the first line of a regular file, preserving the rest."""
549
+ data = path.read_bytes()
550
+ nl = data.find(b"\n")
551
+ if nl == -1:
552
+ return
553
+ body = data[nl + 1:]
554
+ path.write_bytes(f"#!{target}\n".encode("utf-8") + body)
555
+
556
+
526
557
  def host_user_flag() -> str | None:
527
558
  """The ``docker --user`` value binding the sandbox to the host user.
528
559
 
@@ -759,7 +790,8 @@ class DockerBackend(Sandbox):
759
790
  network: bool = False, sandbox_venv_dir: str | None = None,
760
791
  seccomp_profile: str | None = None,
761
792
  export_root: str | None = None,
762
- cache_mounts: list[tuple[str, str, str]] | None = None):
793
+ cache_mounts: list[tuple[str, str, str]] | None = None,
794
+ sync_excludes: list[str] | None = None):
763
795
  self._image = image
764
796
  self._workspace = workspace
765
797
  self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
@@ -783,6 +815,9 @@ class DockerBackend(Sandbox):
783
815
  for _source, target, _mode in self._cache_mounts:
784
816
  self._cache_paths.append(
785
817
  os.path.relpath(target, export_root).replace("\\", "/"))
818
+ # 028: run-wide sync-back excludes (scratch paths - skipped from both
819
+ # the export copy and every snapshot).
820
+ self._sync_excludes = list(sync_excludes or ())
786
821
  self._baseline: dict[str, str] = {}
787
822
  # The profile-keyed pool (D-1/BR-10): profile key -> running container.
788
823
  self._pool: dict[tuple[str, bool], _ProfileContainer] = {}
@@ -889,7 +924,8 @@ class DockerBackend(Sandbox):
889
924
  self._started = True
890
925
  if not self._baseline:
891
926
  self._baseline = _snapshot(Path(self._export_root),
892
- self._cache_paths)
927
+ self._cache_paths,
928
+ self._sync_excludes)
893
929
 
894
930
  def provision(self, setup_cmd: str, stamp_key: str, stamp_dir: str,
895
931
  force: bool) -> None:
@@ -1043,14 +1079,15 @@ class DockerBackend(Sandbox):
1043
1079
  report: SyncBackReport = sync_back(
1044
1080
  self._export_root, self._workspace, list(mount_set.rw_paths()),
1045
1081
  baseline=self._baseline, step_id=None,
1046
- cache_paths=self._cache_paths)
1082
+ cache_paths=self._cache_paths, excludes=self._sync_excludes)
1047
1083
  if report.conflicts:
1048
1084
  raise SandboxError(
1049
1085
  f"AE-SBX-016: sync-back conflict in "
1050
1086
  f"{', '.join(report.conflicts)} - the real repo changed "
1051
1087
  f"underneath; resolve before retrying (BR-4.4)")
1052
1088
  self._baseline = _snapshot(Path(self._export_root),
1053
- self._cache_paths)
1089
+ self._cache_paths,
1090
+ self._sync_excludes)
1054
1091
  append_audit_record(self._iso_state_dir, {
1055
1092
  "timestamp": datetime.now(timezone.utc).isoformat(),
1056
1093
  "event": "sync_back",
@@ -1295,18 +1332,34 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1295
1332
  cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
1296
1333
 
1297
1334
  export_root = str(Path(state_dir) / "exports" / uuid.uuid4().hex)
1298
- cache_paths = discover_cache_paths(workspace, sandbox_venv_dir)
1335
+ # 028: cache paths are pipeline-author data (``caches:``), plus the engine's
1336
+ # own sandbox venv when it is not relocated (016/022 behaviour - the venv
1337
+ # is a cache only once it exists). Declared-but-missing caches are
1338
+ # auto-created host-side (uid-owned, empty) before export planning, so the
1339
+ # missing-cache guard in plan_step_workspace stays a race guard only.
1340
+ cache_paths = list(cfg.caches)
1341
+ if sandbox_venv_dir is None and (workspace / SANDBOX_VENV_DIR).is_dir():
1342
+ if SANDBOX_VENV_DIR not in cache_paths:
1343
+ cache_paths.append(SANDBOX_VENV_DIR)
1344
+ for cache in cfg.caches:
1345
+ (workspace / cache).mkdir(parents=True, exist_ok=True)
1299
1346
  ws = StepWorkspace.materialise(
1300
1347
  workspace, cache_paths, sys.platform, export_root,
1301
- state_dir=str(state_dir))
1348
+ state_dir=str(state_dir), excludes=list(cfg.sync_excludes))
1302
1349
  export_root = str(ws.export_root)
1303
1350
  cache_mounts = ws.mount_points()[1:] # skip the export root itself
1351
+ # Repair console-script shebangs that bake a previous run's dead export
1352
+ # path (028): a venv built by export A must execute its ``bin/*`` scripts
1353
+ # when mounted into export B.
1354
+ venv_dir = Path(sandbox_venv_dir) if sandbox_venv_dir else workspace / SANDBOX_VENV_DIR
1355
+ repair_venv_shebangs(venv_dir, state_dir=state_dir, export_root=export_root)
1304
1356
 
1305
1357
  backend = DockerBackend(
1306
1358
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
1307
1359
  profile=profile, runner=runner, network=needs_network,
1308
1360
  sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
1309
- export_root=export_root, cache_mounts=cache_mounts)
1361
+ export_root=export_root, cache_mounts=cache_mounts,
1362
+ sync_excludes=list(cfg.sync_excludes))
1310
1363
  backend.ensure_image()
1311
1364
  backend.create_execution_container()
1312
1365
  if refresh is None:
@@ -2,26 +2,15 @@
2
2
 
3
3
  Preset data, never mechanism (D-2): one ``package-registries`` preset serves
4
4
  every toolchain, so the restore step stays toolchain-agnostic. Adding a
5
- toolchain adds a target here plus a cache declaration - no per-toolchain
6
- egress code exists.
5
+ toolchain adds a target here plus a ``caches:`` entry in the pipeline - no
6
+ per-toolchain egress code exists. Toolchain cache dirs are pipeline author
7
+ data (``caches:``), never engine preset data (028).
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
11
12
  from agentic_dev_loop.core.config import KNOWN_EGRESS_PRESETS
12
13
 
13
- # The per-toolchain cache dirs (BR-9.3, BR-3.1): in-workspace paths that are
14
- # bind-mounted rw into the export rather than copied. Cache declarations are
15
- # preset data (D-2) - adding a toolchain adds a path here, never mechanism.
16
- CACHE_DIRS: tuple[str, ...] = (
17
- ".venv-sandbox",
18
- "node_modules",
19
- "qa/node_modules",
20
- "qa/.browsers",
21
- ".nuget-packages",
22
- "target",
23
- )
24
-
25
14
 
26
15
  # The package registries every toolchain's locked restore touches (BR-9.1):
27
16
  # pypi, npm, NuGet, cargo, plus the VCS/source-install hosts shared by all.
@@ -14,6 +14,8 @@ import shutil
14
14
  from dataclasses import dataclass
15
15
  from pathlib import Path
16
16
 
17
+ from agentic_dev_loop.core.paths import dir_excluded, path_excluded
18
+
17
19
  from .sync_back import SyncBackReport, _snapshot, sync_back
18
20
 
19
21
  # Host-only dirs never copied into the export (022). ``.git`` is excluded by
@@ -48,16 +50,19 @@ def _relative_parts(path: str) -> tuple[str, ...]:
48
50
 
49
51
  def plan_step_workspace(repo: str | Path, cache_paths: list[str],
50
52
  platform: str, export_root: str | Path,
51
- state_dir: str | None = None) -> StepWorkspacePlan:
53
+ state_dir: str | None = None,
54
+ excludes: list[str] | None = None) -> StepWorkspacePlan:
52
55
  """Plan the export (BR-2/BR-3): copy every workspace file except
53
- ``.git``/host-only dirs/cache paths/``state_dir``; bind-mount each
54
- declared cache path rw. A missing declared cache path is a loud failure
55
- (BR-3.2, pillar 5). Pure over platform facts - deterministic given the
56
- repo state."""
56
+ ``.git``/host-only dirs/cache paths/``state_dir``/sync-excludes; bind-mount
57
+ each declared cache path rw. A missing declared cache path is a loud
58
+ failure (BR-3.2, pillar 5) - kept as a race guard; startup pre-creates the
59
+ dirs host-side (028). Excluded paths are scratch: skipped at copy time with
60
+ no mount. Pure over platform facts - deterministic given the repo state."""
57
61
  repo = Path(repo)
58
62
  export_root = Path(export_root)
59
63
  cache_rels = [_relative_parts(c) for c in cache_paths]
60
64
  state_dir_rel = _relative_parts(state_dir) if state_dir else None
65
+ excludes = list(excludes or ())
61
66
 
62
67
  for cache in cache_paths:
63
68
  if not (repo / cache).exists():
@@ -73,11 +78,14 @@ def plan_step_workspace(repo: str | Path, cache_paths: list[str],
73
78
  d for d in dirnames
74
79
  if d not in _EXCLUDED_DIRS
75
80
  and not _excluded(rel_dir_parts + (d,), cache_rels, state_dir_rel)
81
+ and not dir_excluded((rel_dir / d).as_posix(), excludes)
76
82
  ]
77
83
  for name in filenames:
78
84
  parts = rel_dir_parts + (name,)
79
85
  if _excluded(parts, cache_rels, state_dir_rel):
80
86
  continue
87
+ if path_excluded("/".join(parts), excludes):
88
+ continue
81
89
  copy_paths.append("/".join(parts))
82
90
 
83
91
  cache_mounts = tuple(
@@ -122,16 +130,19 @@ class StepWorkspace:
122
130
  @classmethod
123
131
  def materialise(cls, repo: str | Path, cache_paths: list[str],
124
132
  platform: str, export_root: str | Path,
125
- state_dir: str | None = None) -> "StepWorkspace":
133
+ state_dir: str | None = None,
134
+ excludes: list[str] | None = None) -> "StepWorkspace":
126
135
  """Copy the workspace into the export, excluding ``.git``/host-only
127
- dirs/caches/``state_dir``; pre-create the cache mount targets so the
128
- rw bind-mounts have uid-owned destinations (the AE-SBX-009 class)."""
136
+ dirs/caches/``state_dir``/sync-excludes; pre-create the cache mount
137
+ targets so the rw bind-mounts have uid-owned destinations (the
138
+ AE-SBX-009 class)."""
129
139
  plan = plan_step_workspace(repo, cache_paths, platform, export_root,
130
- state_dir=state_dir)
140
+ state_dir=state_dir, excludes=excludes)
131
141
  ws = cls(plan, real_repo=repo)
132
142
  ws._copy(repo)
133
143
  ws._baseline = _snapshot(
134
- ws._export_root, cache_paths=[c.relative for c in plan.cache_mounts])
144
+ ws._export_root, cache_paths=[c.relative for c in plan.cache_mounts],
145
+ excludes=list(excludes or ()))
135
146
  return ws
136
147
 
137
148
  def _copy(self, repo: Path) -> None:
@@ -154,10 +165,12 @@ class StepWorkspace:
154
165
  points.append((cache.source, f"{root}/{cache.relative}", "rw"))
155
166
  return points
156
167
 
157
- def sync_back(self, declared_rw: list[str]) -> SyncBackReport:
168
+ def sync_back(self, declared_rw: list[str],
169
+ excludes: list[str] | None = None) -> SyncBackReport:
158
170
  """Git-aware diff-copy of declared-rw changes back to the real repo
159
171
  (BR-4). Delegates to ``sync_back``; conflicts are loud."""
160
172
  return sync_back(
161
173
  self._export_root, self._real_repo, declared_rw,
162
174
  baseline=getattr(self, "_baseline", None),
163
- cache_paths=[c.relative for c in self._plan.cache_mounts])
175
+ cache_paths=[c.relative for c in self._plan.cache_mounts],
176
+ excludes=list(excludes or ()))