agentic-dev-loop 1.0.0a15__tar.gz → 1.0.0a17__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 (75) hide show
  1. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/PKG-INFO +3 -3
  2. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/README.md +2 -2
  3. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/core/config.py +9 -5
  5. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/actions.py +1 -1
  6. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/dryrun.py +2 -1
  7. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/graph.py +16 -7
  8. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/hostboundary.py +1 -1
  9. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/isolation.py +13 -2
  10. agentic_dev_loop-1.0.0a17/src/agentic_dev_loop/engine/seccomp/__init__.py +72 -0
  11. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/stages.py +53 -14
  12. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/PKG-INFO +3 -3
  13. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_actions.py +1 -1
  14. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_agents.py +1 -1
  15. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_config.py +3 -3
  16. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_graph.py +35 -10
  17. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_isolation.py +105 -0
  18. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_qa_prompt.py +8 -0
  19. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_secrets.py +4 -4
  20. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_stages.py +64 -11
  21. agentic_dev_loop-1.0.0a15/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -27
  22. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/LICENSE +0 -0
  23. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/setup.cfg +0 -0
  24. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/__init__.py +0 -0
  25. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/core/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/core/results.py +0 -0
  27. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/core/verdict.py +0 -0
  28. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/agents.py +0 -0
  30. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/cli.py +0 -0
  31. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/deps.py +0 -0
  32. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/llm.py +0 -0
  35. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/loaders.py +0 -0
  36. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/outputs.py +0 -0
  37. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/proc.py +0 -0
  38. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/registry.py +0 -0
  39. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  40. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/engine/secrets.py +0 -0
  41. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/py.typed +0 -0
  42. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/viz/__init__.py +0 -0
  43. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/viz/__main__.py +0 -0
  44. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop/viz/server.py +0 -0
  45. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  46. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  47. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  48. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  49. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  50. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_aidlc.py +0 -0
  51. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_cli.py +0 -0
  52. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_deps.py +0 -0
  53. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_dry_run.py +0 -0
  54. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_e2e_fixtures.py +0 -0
  55. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_hostboundary.py +0 -0
  56. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_hostboundary_integration.py +0 -0
  57. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_isolation_integration.py +0 -0
  58. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_isolation_pbt.py +0 -0
  59. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_llm.py +0 -0
  60. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_loaders.py +0 -0
  61. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_module_contract.py +0 -0
  62. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_outputs.py +0 -0
  63. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_outputs_pbt.py +0 -0
  64. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_proc.py +0 -0
  65. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_publish.py +0 -0
  66. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_registry.py +0 -0
  67. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_repo_ascii.py +0 -0
  68. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_results.py +0 -0
  69. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_sdk_compat.py +0 -0
  70. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_secrets_integration.py +0 -0
  71. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_secrets_pipeline.py +0 -0
  72. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_source_resolution.py +0 -0
  73. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_trigger_script.py +0 -0
  74. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/tests/test_verdict.py +0 -0
  75. {agentic_dev_loop-1.0.0a15 → agentic_dev_loop-1.0.0a17}/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.0a15
3
+ Version: 1.0.0a17
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
@@ -213,7 +213,7 @@ steps:
213
213
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
214
214
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) - see [Per-stage networking](#per-stage-networking). |
215
215
  | `secrets:` (top-level) / step `secrets:` | Declares secret **refs only - never values**; each step opts in to the refs it needs. Resolved host-side and injected into that step's exec env only. See [Secrets](#secrets--refs-only-never-values). |
216
- | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`. It can never disable it. |
216
+ | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`, `seccomp_profile`. It can never disable it. See [browser sandbox](docs/browser-sandbox.md). |
217
217
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` - artifacts and checkpoints live outside the sandbox mount. |
218
218
  | `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. |
219
219
  | `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). |
@@ -231,7 +231,7 @@ secrets: # refs only - never values
231
231
  key: KINDIE_CLIENT_SECRET # provider-specific locator
232
232
  r2-keys:
233
233
  from: env
234
- key: YOSK_R2_KEYS
234
+ key: R2_KEYS
235
235
 
236
236
  steps:
237
237
  - name: qa
@@ -171,7 +171,7 @@ steps:
171
171
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
172
172
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) - see [Per-stage networking](#per-stage-networking). |
173
173
  | `secrets:` (top-level) / step `secrets:` | Declares secret **refs only - never values**; each step opts in to the refs it needs. Resolved host-side and injected into that step's exec env only. See [Secrets](#secrets--refs-only-never-values). |
174
- | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`. It can never disable it. |
174
+ | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`, `seccomp_profile`. It can never disable it. See [browser sandbox](docs/browser-sandbox.md). |
175
175
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` - artifacts and checkpoints live outside the sandbox mount. |
176
176
  | `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. |
177
177
  | `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). |
@@ -189,7 +189,7 @@ secrets: # refs only - never values
189
189
  key: KINDIE_CLIENT_SECRET # provider-specific locator
190
190
  r2-keys:
191
191
  from: env
192
- key: YOSK_R2_KEYS
192
+ key: R2_KEYS
193
193
 
194
194
  steps:
195
195
  - name: qa
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a15"
7
+ version = "1.0.0a17"
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"
@@ -87,11 +87,15 @@ class IsolationConfig(BaseModel):
87
87
  # the workspace - validated at startup, not load (the workspace is unknown
88
88
  # here).
89
89
  sandbox_venv_dir: str | None = None
90
- # 023: optional host path to a Docker seccomp profile JSON. When set, the
91
- # exec container runs with ``--security-opt seccomp=<path>`` in place of
92
- # Docker's default profile. Used to allow Chromium's namespace sandbox
93
- # (userns syscalls) under --cap-drop ALL. The path is validated by Docker
94
- # at container-create time, not at load.
90
+ # 023/025: optional host path to a Docker seccomp profile JSON, or a
91
+ # ``bundled:<name>`` sentinel resolved at load to a packaged profile
92
+ # (e.g. ``bundled:chromium-userns``). When set, the exec container runs
93
+ # with ``--security-opt seccomp=<path>`` in place of Docker's default
94
+ # profile, plus ``--cap-add SYS_CHROOT`` (the profile gates chroot(2) on
95
+ # exactly that capability). Used to allow Chromium's namespace sandbox
96
+ # (userns syscalls) under --cap-drop ALL. An unknown sentinel name fails
97
+ # at load naming the known bundled profiles; a raw path is validated by
98
+ # Docker at container-create time, not at load.
95
99
  seccomp_profile: str | None = None
96
100
 
97
101
  @field_validator("memory")
@@ -1,6 +1,6 @@
1
1
  """Built-in pipeline actions. Node-shaped: (state, params) -> state update.
2
2
 
3
- `commit` policy (ported unchanged from yosk): agents never commit mid-run;
3
+ `commit` policy (ported unchanged from the consumer): agents never commit mid-run;
4
4
  the pipeline commits exactly once, and only when the worktree was clean at
5
5
  run start - a dirty-at-start run is committed by the human, so the
6
6
  auto-commit can never swallow pre-existing unrelated changes (run 004: the
@@ -16,7 +16,7 @@ from agentic_dev_loop.core.config import PipelineConfig, StepConfig, load_config
16
16
  from .hostboundary import check_orchestrator_install, check_tooling_integrity
17
17
  from .loaders import resolve_docs
18
18
  from .isolation import (isolation_state_dir, resolve_effective_profile,
19
- resolve_image_ref)
19
+ resolve_image_ref, resolved_seccomp_profile)
20
20
  from .registry import (Registry, default_registry, load_extensions,
21
21
  validate_action_network)
22
22
  from .secrets import scan_env_files
@@ -134,6 +134,7 @@ def dry_run(cfg_path, args, registry: Registry | None = None) -> int:
134
134
 
135
135
  _validate_agents(cfg, base / cfg.agents_dir)
136
136
  _validate_actions(cfg, registry)
137
+ resolved_seccomp_profile(cfg) # fails loud on an unknown bundled:<name>
137
138
 
138
139
  loader, docs, subject = resolve_docs(cfg, base, registry, args)
139
140
  image, memory, cpus = _resolved_image(cfg)
@@ -27,19 +27,27 @@ from .loaders import resolve_docs
27
27
  from .secrets import resolve_secrets, scan_env_files
28
28
  from .registry import (Registry, default_registry, load_extensions,
29
29
  validate_action_network)
30
- from .stages import (Runtime, build_context, clear_attempt_artifacts,
30
+ from .stages import (Runtime, clear_attempt_artifacts,
31
31
  make_action_node, make_agent_node, make_bump_node,
32
32
  make_command_node, make_router, success_node)
33
33
 
34
34
  CHECKPOINT_DB = "loop-checkpoints.sqlite" # under state_dir, one DB per workspace
35
35
 
36
36
 
37
+ def _merge_artifacts(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
38
+ """Accumulate per-step artifact text across nodes. LangGraph gives
39
+ un-annotated fields last-write-wins, so without this reducer one node's
40
+ entry would silently replace the accumulated dict."""
41
+ return {**a, **b}
42
+
43
+
37
44
  class LoopState(TypedDict):
38
45
  """State threaded through the graph. `notes` accumulates across attempts
39
- (append reducer); every other key is last-write-wins."""
46
+ (append reducer), `artifacts` accumulates across stages (merge reducer);
47
+ every other key is last-write-wins."""
40
48
  subject: str
41
49
  docs: dict[str, str]
42
- context: str # built at attempt start, shared by all stages
50
+ artifacts: Annotated[dict[str, str], _merge_artifacts] # step name -> artifact text
43
51
  attempt: int
44
52
  max_attempts: int
45
53
  notes: Annotated[list[str], operator.add]
@@ -95,7 +103,7 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
95
103
  registry: Registry | None = None) -> tuple[bool, int]:
96
104
  """Load a pipeline YAML, resolve docs via the selected document loader,
97
105
  and run the graph to completion. Returns (success, attempts) - the
98
- public API, yosk's `construct`/`resume_loop` equivalent.
106
+ public API, the consumer's `construct`/`resume_loop` equivalent.
99
107
 
100
108
  `args` carries the parsed CLI namespace: loader flags, --resume,
101
109
  --max-attempts (which overrides the YAML value when set), and --no-viz.
@@ -163,7 +171,7 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
163
171
  config = {"configurable": {"thread_id": thread_id}}
164
172
  db = str(state_dir / CHECKPOINT_DB)
165
173
  if resume:
166
- initial = None # state (docs/subject/context) comes from the checkpoint
174
+ initial = None # state (docs/subject/artifacts) comes from the checkpoint
167
175
  else:
168
176
  _loader, docs, subject = resolve_docs(cfg, base, registry, args)
169
177
  # A fresh run owns state_dir: drop cross-run leftovers (a previous
@@ -173,7 +181,7 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
173
181
  initial: LoopState = {
174
182
  "subject": subject,
175
183
  "docs": docs,
176
- "context": build_context(runtime, docs, subject, []),
184
+ "artifacts": {},
177
185
  "attempt": 1,
178
186
  "max_attempts": max_attempts,
179
187
  "notes": [],
@@ -221,7 +229,8 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
221
229
  f"Failure details: {state_dir}/last-error.md",
222
230
  flush=True)
223
231
  initial = {**values, "failed": False,
224
- "resume_target": target, "failed_step": None}
232
+ "resume_target": target, "failed_step": None,
233
+ "artifacts": values.get("artifacts", {})}
225
234
 
226
235
  if bus is None and not getattr(args, "no_viz", True):
227
236
  start_state = initial if initial is not None \
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
29
29
  from agentic_dev_loop.core.config import PipelineConfig
30
30
 
31
31
  # Tooling directories are workspace-root-relative fixed names (the same layout
32
- # the dogfood pipeline and yosk use); the other tooling paths are config- or
32
+ # the dogfood pipeline and the consumer repo use); the other tooling paths are config- or
33
33
  # pipeline-file-relative.
34
34
  TOOLING_DIR_NAMES = ("aidlc-docs", "tools")
35
35
 
@@ -37,6 +37,7 @@ from typing import Protocol
37
37
 
38
38
  from agentic_dev_loop.core.config import IsolationConfig, PipelineConfig
39
39
  from .proc import needs_shell, split_command
40
+ from .seccomp import resolve_seccomp_profile
40
41
 
41
42
  # ---------------------------------------------------------------------------
42
43
  # Constants (KD-2 / ID-4)
@@ -480,7 +481,9 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
480
481
  CAP_SYS_CHROOT; the profile gates chroot(2) on exactly that capability,
481
482
  so the payload adds it back (the bounding set is otherwise empty under
482
483
  --cap-drop ALL). Without the cap the zygote dies even with the profile
483
- (023 host-side gate 2)."""
484
+ (023 host-side gate 2). The ``--cap-add SYS_CHROOT`` applies to any
485
+ seccomp profile by design: the bundled profile requires it, and custom
486
+ profiles are expected to follow the same cap-gated pattern."""
484
487
  payload = [
485
488
  "docker", "run", "--detach", "--rm", "--name",
486
489
  exec_container_name(run_id, network),
@@ -956,6 +959,14 @@ def isolation_state_dir(cfg: PipelineConfig) -> Path:
956
959
  return Path(iso.state_dir).expanduser()
957
960
 
958
961
 
962
+ def resolved_seccomp_profile(cfg: PipelineConfig) -> str | None:
963
+ """The pipeline's seccomp profile with any ``bundled:<name>`` sentinel
964
+ expanded to a real host path (025). A raw path passes through unchanged
965
+ and is still validated by Docker at container-create time."""
966
+ return resolve_seccomp_profile(
967
+ cfg.isolation.seccomp_profile if cfg.isolation else None)
968
+
969
+
959
970
  def resolve_image_ref(image: str, environ: Mapping[str, str] | None = None) -> str:
960
971
  """AGENTIC_DEV_LOOP_DEV_IMAGE override (tag OR digest) → else the profile
961
972
  image (digest-pinned) → if empty, fail fast AE-SBX-005."""
@@ -976,6 +987,7 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
976
987
  refresh: bool | None = None) -> Sandbox:
977
988
  """Eager startup sequence (Q2): probe → image → backend → ensure_image →
978
989
  create+start → pre-provision every declared setup (stamp-cached)."""
990
+ seccomp_profile = resolved_seccomp_profile(cfg)
979
991
  probe_docker(runner)
980
992
  profile = resolve_effective_profile(cfg.isolation)
981
993
  image = resolve_image_ref(profile.image)
@@ -983,7 +995,6 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
983
995
  needs_network = any(step.network for step in cfg.steps)
984
996
  sandbox_venv_dir = resolve_sandbox_venv_dir(
985
997
  cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
986
- seccomp_profile = cfg.isolation.seccomp_profile if cfg.isolation else None
987
998
  backend = DockerBackend(
988
999
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
989
1000
  profile=profile, runner=runner, network=needs_network,
@@ -0,0 +1,72 @@
1
+ """Bundled Docker seccomp profiles (023).
2
+
3
+ Docker's default seccomp profile blocks the user-namespace syscalls Chromium's
4
+ namespace sandbox needs, which makes ``chromium.launch(chromiumSandbox=True)``
5
+ die at the zygote under ``--cap-drop ALL``. These profiles are Docker's default
6
+ profile with an allowlist for those userns syscalls; pass one as
7
+ ``isolation.seccomp_profile`` to swap it in. The ``bundled:<name>`` sentinel
8
+ (025) resolves a profile by name so consumers declare intent instead of
9
+ rediscovering the packaged path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib.resources import as_file, files
15
+
16
+ CHROMIUM_USERNS_NAME = "chromium-userns"
17
+ CHROMIUM_USERNS_PROFILE = "chromium-userns.json"
18
+
19
+ # ``bundled:<name>`` sentinel suffix -> packaged JSON filename. This is the
20
+ # single registry new bundled profiles slot into (025).
21
+ BUNDLED_PROFILES = {
22
+ CHROMIUM_USERNS_NAME: CHROMIUM_USERNS_PROFILE,
23
+ }
24
+
25
+
26
+ def _profile_path(filename: str) -> str:
27
+ """Host filesystem path to a packaged seccomp profile JSON."""
28
+ traversable = files("agentic_dev_loop.engine.seccomp").joinpath(filename)
29
+ with as_file(traversable) as path:
30
+ return str(path)
31
+
32
+
33
+ def bundled_profile_path(name: str) -> str:
34
+ """Host filesystem path to the named bundled seccomp profile.
35
+
36
+ Raises ``ValueError`` listing every known ``bundled:<name>`` when ``name``
37
+ is unknown, so a bad sentinel fails loud at load instead of at
38
+ container-create time.
39
+ """
40
+ filename = BUNDLED_PROFILES.get(name)
41
+ if filename is None:
42
+ known = ", ".join(f"bundled:{n}" for n in sorted(BUNDLED_PROFILES))
43
+ raise ValueError(
44
+ f"unknown bundled seccomp profile {name!r} (known: {known})")
45
+ return _profile_path(filename)
46
+
47
+
48
+ def chromium_userns_profile_path() -> str:
49
+ """Host filesystem path to the bundled Chromium userns seccomp profile.
50
+
51
+ Docker's ``--security-opt seccomp=<path>`` takes a host path, so the JSON
52
+ must resolve to a real file. Wheels unpack to disk, so ``as_file`` returns
53
+ the installed path without copying.
54
+ """
55
+ return bundled_profile_path(CHROMIUM_USERNS_NAME)
56
+
57
+
58
+ def resolve_seccomp_profile(value: str | None) -> str | None:
59
+ """Expand a ``bundled:<name>`` sentinel to its real profile path (025).
60
+
61
+ A raw host path passes through unchanged (Docker still validates it at
62
+ container-create time, unchanged from 023). An unknown or empty bundled
63
+ name fails loud with every known ``bundled:<name>``.
64
+ """
65
+ if value is None or not value.startswith("bundled:"):
66
+ return value
67
+ name = value[len("bundled:"):]
68
+ if not name:
69
+ known = ", ".join(f"bundled:{n}" for n in sorted(BUNDLED_PROFILES))
70
+ raise ValueError(
71
+ f"seccomp_profile 'bundled:' is missing a name (known: {known})")
72
+ return bundled_profile_path(name)
@@ -1,6 +1,6 @@
1
1
  """Stage nodes, routers, and the run_agent port.
2
2
 
3
- The yosk loop's hardcoded stages become per-step closures built from the
3
+ The consumer loop's hardcoded stages become per-step closures built from the
4
4
  pipeline config; the fixed policies stay here, not in config:
5
5
 
6
6
  - per-attempt hygiene: every declared artifact/produces file + last-error.md
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
49
49
  DEFAULT_MAX_ITERATIONS = 500
50
50
  DEFAULT_COMMAND_TIMEOUT = 600.0 # seconds - a pytest-class suite budget
51
51
  LAST_ERROR = "last-error.md"
52
+ ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
52
53
 
53
54
 
54
55
  @dataclass
@@ -68,7 +69,7 @@ class Runtime:
68
69
  def prompt_dir(runtime: Runtime) -> str:
69
70
  """state_dir as agents must address it. Agents resolve prompt paths
70
71
  against their workspace, so a bare `state_dir.name` only reaches the real
71
- state_dir when the pipeline file sits at the workspace root - yosk runs
72
+ state_dir when the pipeline file sits at the workspace root - the consumer runs
72
73
  agentic-dev-loop/pipeline.yaml from the repo root, where `.pr` and
73
74
  `agentic-dev-loop/.pr` diverge. Workspace-relative when under the workspace,
74
75
  the absolute path when outside it."""
@@ -196,15 +197,41 @@ def _run_command(command: str, workspace: Path, timeout: float,
196
197
  return None, out.decode("utf-8", "replace"), True
197
198
 
198
199
 
200
+ def _artifact_file(runtime: Runtime, step_name: str) -> str | None:
201
+ """The declared artifact/produces filename a step writes, keyed by step
202
+ name - the artifacts dict only carries step name -> captured text."""
203
+ for step in runtime.cfg.steps:
204
+ if step.name == step_name:
205
+ if step.artifact is not None:
206
+ return step.artifact
207
+ if step.produces:
208
+ return step.produces[0]
209
+ return None
210
+ return None
211
+
212
+
199
213
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
200
- notes: list[str] | None = None) -> str:
214
+ notes: list[str] | None = None,
215
+ artifacts: dict[str, str] | None = None) -> str:
201
216
  """Assemble the attempt's shared message: subject, doc bundle, the
202
217
  canonical dependency-policy paragraph (021), the expected-output-paths
203
218
  section generated from step defs (a prompt merely naming files lets agents
204
- skip them), and previous-attempt notes."""
219
+ skip them), accumulated stage artifacts (027), and previous-attempt
220
+ notes."""
205
221
  parts = [f"You are working on {subject}.\n"]
206
222
  for name, content in docs.items():
207
223
  parts.append(f"## {name}\n\n{content}")
224
+ if artifacts:
225
+ parts.append("## Stage artifacts")
226
+ for step_name, content in artifacts.items():
227
+ capped = content[:ARTIFACT_CONTEXT_CAP]
228
+ if len(content) > ARTIFACT_CONTEXT_CAP:
229
+ filename = _artifact_file(runtime, step_name)
230
+ path = (f"{prompt_dir(runtime)}/{filename}" if filename
231
+ else prompt_dir(runtime))
232
+ capped += (f"\n\n[... content truncated - full text at "
233
+ f"`{path}`]")
234
+ parts.append(f"### {step_name}\n\n{capped}")
208
235
  parts.append("## Dependency policy\n\n" + policy_injection(runtime.cfg))
209
236
  lines = []
210
237
  for step in runtime.cfg.steps:
@@ -240,7 +267,7 @@ def clear_attempt_artifacts(runtime: Runtime) -> None:
240
267
 
241
268
  def _unclear_target(runtime: Runtime, step: StepConfig) -> str:
242
269
  """Where the unclear-verdict retry loops to: on_fail's goto when that is
243
- a step, else the entry step (yosk always looped back to dev)."""
270
+ a step, else the entry step (the consumer always looped back to dev)."""
244
271
  target = route_target(step.on_fail)
245
272
  if target is not None and target not in TERMINALS:
246
273
  return target
@@ -295,8 +322,11 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
295
322
  attempt = state["attempt"]
296
323
  subject = state["subject"]
297
324
  max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
325
+ message = build_context(runtime, state["docs"], state["subject"],
326
+ state["notes"],
327
+ artifacts=state.get("artifacts", {}))
298
328
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
299
- result = run_agent(step.agent, state["context"], runtime,
329
+ result = run_agent(step.agent, message, runtime,
300
330
  max_iterations=max_iterations, step=step)
301
331
  if not result.ok:
302
332
  print(f"[{subject}] {step.name} agent failed on attempt {attempt}.", flush=True)
@@ -330,7 +360,10 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
330
360
  if verdicts_map is None:
331
361
  merged = {**state["step_verdicts"], step.name: "pass"} if captures else \
332
362
  state["step_verdicts"]
333
- return {"step_verdicts": merged, "failed": False}
363
+ update = {"step_verdicts": merged, "failed": False}
364
+ if captures and result.response is not None:
365
+ update["artifacts"] = {step.name: result.response}
366
+ return update
334
367
 
335
368
  parsed = Verdict.from_text(result.response or "", verdicts_map)
336
369
  merged = {**state["step_verdicts"], step.name: None}
@@ -340,16 +373,24 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
340
373
  if verdict == "pass":
341
374
  print(f"[{subject}] {step.name} VERDICT: PASS on attempt {attempt}.",
342
375
  flush=True)
343
- return {"step_verdicts": merged, "failed": False}
376
+ update = {"step_verdicts": merged, "failed": False}
377
+ if captures and result.response is not None:
378
+ update["artifacts"] = {step.name: result.response}
379
+ return update
344
380
  if verdict == "unverifiable":
345
381
  print(f"[{subject}] {step.name} qa verdict: UNVERIFIABLE "
346
382
  f"(moves on).", flush=True)
347
- return {"step_verdicts": merged, "failed": False}
383
+ update = {"step_verdicts": merged, "failed": False}
384
+ if captures and result.response is not None:
385
+ update["artifacts"] = {step.name: result.response}
386
+ return update
348
387
  print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
349
388
  flush=True)
350
389
  update = {"step_verdicts": merged,
351
390
  "notes": [f"## {step.name} output (attempt {attempt})\n\n"
352
391
  f"{result.response}"]}
392
+ if captures and result.response is not None:
393
+ update["artifacts"] = {step.name: result.response}
353
394
  if isinstance(step.on_fail, Route) and step.on_fail.retry:
354
395
  update["retry_target"] = step.on_fail.goto
355
396
  return update
@@ -548,16 +589,14 @@ def make_router(runtime: Runtime, step: StepConfig):
548
589
 
549
590
  def make_bump_node(runtime: Runtime):
550
591
  """Close the retry cycle: consume one attempt, clear per-attempt
551
- artifacts, and rebuild the context with the accumulated notes - the
552
- attempt-start duties yosk's dev node owned, generalized to any target."""
592
+ artifacts, and reset verdicts/failed for the next attempt. The retried
593
+ agent's message is rebuilt per run by make_agent_node from the
594
+ accumulated notes and artifacts (027)."""
553
595
 
554
596
  def node(state: dict) -> dict:
555
597
  clear_attempt_artifacts(runtime)
556
- context = build_context(runtime, state["docs"], state["subject"],
557
- state["notes"])
558
598
  return {"attempt": state["attempt"] + 1,
559
599
  "step_verdicts": {},
560
- "context": context,
561
600
  "failed": False}
562
601
 
563
602
  return node
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a15
3
+ Version: 1.0.0a17
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
@@ -213,7 +213,7 @@ steps:
213
213
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
214
214
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) - see [Per-stage networking](#per-stage-networking). |
215
215
  | `secrets:` (top-level) / step `secrets:` | Declares secret **refs only - never values**; each step opts in to the refs it needs. Resolved host-side and injected into that step's exec env only. See [Secrets](#secrets--refs-only-never-values). |
216
- | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`. It can never disable it. |
216
+ | `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`, `seccomp_profile`. It can never disable it. See [browser sandbox](docs/browser-sandbox.md). |
217
217
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` - artifacts and checkpoints live outside the sandbox mount. |
218
218
  | `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. |
219
219
  | `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). |
@@ -231,7 +231,7 @@ secrets: # refs only - never values
231
231
  key: KINDIE_CLIENT_SECRET # provider-specific locator
232
232
  r2-keys:
233
233
  from: env
234
- key: YOSK_R2_KEYS
234
+ key: R2_KEYS
235
235
 
236
236
  steps:
237
237
  - name: qa
@@ -1,4 +1,4 @@
1
- """Action tests - ported from yosk's TestKillListenersOnPort /
1
+ """Action tests - ported from the consumer's TestKillListenersOnPort /
2
2
  TestGitWorktreeClean / TestCommitNode, retargeted at the node-shaped
3
3
  `(state, params) -> dict` API. Real processes and real git repos throughout:
4
4
  git and sockets are local utilities, not network boundaries."""
@@ -1,4 +1,4 @@
1
- """load_agent tests - ported from yosk's TestLoadAgent, retargeted at the
1
+ """load_agent tests - ported from the consumer's TestLoadAgent, retargeted at the
2
2
  library signature which also builds the Agent via the SDK factory. Real
3
3
  agent files on disk (fail-fast file policy); real LLM/Agent SDK objects
4
4
  (constructed locally, no network)."""
@@ -6,14 +6,14 @@ from pydantic import ValidationError
6
6
 
7
7
  from agentic_dev_loop.core.config import Route, default_state_dir, load_config
8
8
 
9
- # The plan's yosk-equivalent reference pipeline (009, YAML schema section),
9
+ # The plan's reference pipeline (consumer-equivalent) (009, YAML schema section),
10
10
  # verbatim except for two substitutions: 012 replaced the agent step named
11
11
  # `test` (artifact ci-fix.md, PASS/FAIL) with the mechanical command step
12
12
  # named `test` - same position, same on_fail wiring; and 009 dropped the
13
13
  # port_sweep step (its sweep moved into engine startup, no longer a pipeline
14
14
  # step). This is the canonical pin: the documented schema must load.
15
15
  FULL_YAML = """\
16
- name: yosk-construction
16
+ name: reference-construction
17
17
 
18
18
  extensions:
19
19
  - agentic_dev_loop.engine.extensions.aidlc:AidlcExtension
@@ -86,7 +86,7 @@ def test_full_schema_example_round_trips(tmp_path):
86
86
  p.write_text(FULL_YAML)
87
87
  cfg = load_config(p)
88
88
 
89
- assert cfg.name == "yosk-construction"
89
+ assert cfg.name == "reference-construction"
90
90
  assert cfg.extensions == ["agentic_dev_loop.engine.extensions.aidlc:AidlcExtension"]
91
91
  assert cfg.additional_files == ["coding-standards-ddd.yaml"]
92
92
  assert cfg.state_dir == ".pr"
@@ -1,6 +1,6 @@
1
1
  """Graph builder + run_pipeline behavioral tests.
2
2
 
3
- The yosk-equivalent YAML below must reproduce the hardcoded loop's behavior:
3
+ The consumer-equivalent YAML below must reproduce the hardcoded loop's behavior:
4
4
  dev → check → review → qa → commit, retries to dev, failure on
5
5
  exhaustion/exception. run_agent is stubbed (the SDK network boundary);
6
6
  everything else - files, git, checkpoints - is real."""
@@ -21,8 +21,8 @@ from agentic_dev_loop.engine.registry import Registry, default_registry, load_ex
21
21
  from agentic_dev_loop.engine.stages import Runtime
22
22
  from agentic_dev_loop.viz import VizBus
23
23
 
24
- YOSK_YAML = """\
25
- name: yosk-construction
24
+ REFERENCE_YAML = """\
25
+ name: reference-construction
26
26
  extensions:
27
27
  - agentic_dev_loop.engine.extensions.aidlc:AidlcExtension
28
28
  additional_files:
@@ -101,10 +101,10 @@ def git(repo: Path, *args: str) -> str:
101
101
 
102
102
  @pytest.fixture
103
103
  def repo(tmp_path, monkeypatch, fake_sandbox):
104
- """A real git repo with the yosk-equivalent pipeline committed clean."""
104
+ """A real git repo with the consumer-equivalent pipeline committed clean."""
105
105
  repo = tmp_path / "repo"
106
106
  repo.mkdir()
107
- (repo / "pipeline.yaml").write_text(YOSK_YAML)
107
+ (repo / "pipeline.yaml").write_text(REFERENCE_YAML)
108
108
  (repo / "coding-standards-ddd.yaml").write_text("STANDARDS")
109
109
  (repo / "plan.md").write_text("THE PLAN")
110
110
  git(repo, "init")
@@ -131,7 +131,7 @@ def script_all_pass(fake_agents):
131
131
 
132
132
 
133
133
  class TestTopology:
134
- def test_yosk_pipeline_node_set(self, repo):
134
+ def test_reference_pipeline_node_set(self, repo):
135
135
  cfg = load_config(Path("pipeline.yaml"))
136
136
  registry = default_registry()
137
137
  load_extensions(cfg.extensions, registry)
@@ -143,7 +143,7 @@ class TestTopology:
143
143
  "bump", "success"} <= nodes
144
144
 
145
145
  def test_unknown_action_fails_at_build(self, repo):
146
- (repo / "bad.yaml").write_text(YOSK_YAML.replace(
146
+ (repo / "bad.yaml").write_text(REFERENCE_YAML.replace(
147
147
  "action: commit", "action: no_such_action"))
148
148
  cfg = load_config(Path("bad.yaml"))
149
149
  runtime = Runtime(cfg=cfg, registry=default_registry(),
@@ -178,6 +178,31 @@ class TestRunPipeline:
178
178
  second_dev = fake_agents.messages_for("dev")[1]
179
179
  assert "broken build" in second_dev # fail note reached the next attempt
180
180
 
181
+ def test_downstream_agent_sees_prior_artifact(self, repo, fake_agents):
182
+ script_all_pass(fake_agents)
183
+ ok, attempts = run_pipeline("pipeline.yaml", args())
184
+ assert (ok, attempts) == (True, 1)
185
+ (dev_msg,) = fake_agents.messages_for("dev")
186
+ (check_msg,) = fake_agents.messages_for("check")
187
+ assert "## Stage artifacts" not in dev_msg # runs before anything accumulates
188
+ assert "## Stage artifacts" in check_msg
189
+ assert "built it" in check_msg # dev's captured response reached check
190
+
191
+ def test_retry_injects_review_artifact_notes_and_prior_dev(self, repo,
192
+ fake_agents):
193
+ script_all_pass(fake_agents)
194
+ fake_agents.set("review", [
195
+ ("write", "review-findings.md", "VERDICT: NEEDS CHANGES\nfix the thing"),
196
+ ("write", "review-findings.md", "VERDICT: APPROVED"),
197
+ ])
198
+ ok, attempts = run_pipeline("pipeline.yaml", args())
199
+ assert (ok, attempts) == (True, 2)
200
+ second_dev = fake_agents.messages_for("dev")[1]
201
+ assert "## Stage artifacts" in second_dev
202
+ assert "built it" in second_dev # dev's own attempt-1 artifact
203
+ assert "fix the thing" in second_dev # review artifact + fail note
204
+ assert "## Previous attempt feedback" in second_dev
205
+
181
206
  def test_exhaustion_fails(self, repo, fake_agents):
182
207
  script_all_pass(fake_agents)
183
208
  fake_agents.set("check", [("write", "ci-fix.md", "VERDICT: FAIL")])
@@ -248,7 +273,7 @@ class TestRunPipeline:
248
273
  # is not a tooling modification - the loader's missing-file guard is
249
274
  # what catches it.
250
275
  (repo / "pipeline.yaml").write_text(
251
- YOSK_YAML.replace("coding-standards-ddd.yaml",
276
+ REFERENCE_YAML.replace("coding-standards-ddd.yaml",
252
277
  "no-such-shared-file.yaml"))
253
278
  git(repo, "add", "-A")
254
279
  git(repo, "commit", "-m", "reference missing additional file")
@@ -257,7 +282,7 @@ class TestRunPipeline:
257
282
  assert fake_agents.calls == []
258
283
 
259
284
  def test_empty_loader_docs_fail_fast(self, repo, fake_agents):
260
- (repo / "empty.yaml").write_text(YOSK_YAML.replace(
285
+ (repo / "empty.yaml").write_text(REFERENCE_YAML.replace(
261
286
  "agentic_dev_loop.engine.extensions.aidlc:AidlcExtension",
262
287
  "sample_ext:EmptyLoaderExtension"))
263
288
  git(repo, "add", "-A")
@@ -267,7 +292,7 @@ class TestRunPipeline:
267
292
  assert fake_agents.calls == []
268
293
 
269
294
 
270
- LOCKED_YAML = YOSK_YAML.replace(
295
+ LOCKED_YAML = REFERENCE_YAML.replace(
271
296
  "deps_policy: open\n",
272
297
  "deps_policy: locked\nlockfiles: [uv.lock]\n",
273
298
  1)
@@ -1097,6 +1097,54 @@ class TestStartupSandbox:
1097
1097
  finally:
1098
1098
  sandbox.close()
1099
1099
 
1100
+ def test_bundled_seccomp_sentinel_resolved_and_plumbed(self, tmp_path, monkeypatch):
1101
+ """025: a ``bundled:chromium-userns`` sentinel is expanded to the real
1102
+ packaged path before the exec payload is built, so the payload sees a
1103
+ plain path and emits the profile plus the cap-gated SYS_CHROOT add."""
1104
+ from agentic_dev_loop.engine.isolation import startup_sandbox
1105
+ from agentic_dev_loop.engine.seccomp import chromium_userns_profile_path
1106
+
1107
+ ws = tmp_path / "ws"
1108
+ ws.mkdir()
1109
+ cfg = self._cfg(tmp_path, with_setup=False)
1110
+ cfg.isolation.seccomp_profile = "bundled:chromium-userns"
1111
+ monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.which",
1112
+ lambda *a: "/usr/bin/docker")
1113
+ monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
1114
+ runner = FakeRunner(
1115
+ ok("1.54"), # probe version
1116
+ ok("linux"), # probe ostype
1117
+ ok(""), # image inspect
1118
+ ok(""), # docker run -d --rm (exec container)
1119
+ )
1120
+ sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
1121
+ try:
1122
+ run_argv = [c for c in runner.calls if c[:2] == ["docker", "run"]][0]
1123
+ resolved = chromium_userns_profile_path()
1124
+ assert "--security-opt" in run_argv
1125
+ assert run_argv[run_argv.index("--security-opt") + 1] == f"seccomp={resolved}"
1126
+ assert "--cap-add" in run_argv
1127
+ assert run_argv[run_argv.index("--cap-add") + 1] == "SYS_CHROOT"
1128
+ finally:
1129
+ sandbox.close()
1130
+
1131
+ def test_unknown_bundled_seccomp_name_fails_before_docker_probe(self, tmp_path, monkeypatch):
1132
+ """025: an unknown bundled:<name> fails loud at startup, before any
1133
+ Docker probe runs."""
1134
+ from agentic_dev_loop.engine.isolation import startup_sandbox
1135
+
1136
+ ws = tmp_path / "ws"
1137
+ ws.mkdir()
1138
+ cfg = self._cfg(tmp_path, with_setup=False)
1139
+ cfg.isolation.seccomp_profile = "bundled:unknown-name"
1140
+ monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.which",
1141
+ lambda *a: "/usr/bin/docker")
1142
+ monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
1143
+ runner = FakeRunner()
1144
+ with pytest.raises(ValueError, match="bundled:chromium-userns"):
1145
+ startup_sandbox(cfg, workspace=ws, runner=runner)
1146
+ assert runner.calls == []
1147
+
1100
1148
  def test_in_workspace_sandbox_venv_dir_fails_loud(self, tmp_path, monkeypatch):
1101
1149
  from agentic_dev_loop.engine.isolation import startup_sandbox
1102
1150
 
@@ -1155,3 +1203,60 @@ class TestBundledSeccompProfile:
1155
1203
  path = Path(chromium_userns_profile_path())
1156
1204
  assert path.is_file()
1157
1205
  assert json.loads(path.read_text(encoding="utf-8"))["defaultAction"] == "SCMP_ACT_ERRNO"
1206
+
1207
+
1208
+ class TestResolveSeccompProfile:
1209
+ """025: the ``bundled:<name>`` sentinel resolves to a packaged profile
1210
+ path; raw host paths pass through unchanged; unknown names fail loud with
1211
+ every known bundled name."""
1212
+
1213
+ def test_bundled_sentinel_resolves_to_real_path(self):
1214
+ from agentic_dev_loop.engine.seccomp import (chromium_userns_profile_path,
1215
+ resolve_seccomp_profile)
1216
+
1217
+ resolved = resolve_seccomp_profile("bundled:chromium-userns")
1218
+ assert resolved == chromium_userns_profile_path()
1219
+ assert Path(resolved).is_file()
1220
+
1221
+ def test_unknown_bundled_name_lists_known(self):
1222
+ from agentic_dev_loop.engine.seccomp import resolve_seccomp_profile
1223
+
1224
+ with pytest.raises(ValueError, match="bundled:chromium-userns"):
1225
+ resolve_seccomp_profile("bundled:unknown-name")
1226
+
1227
+ def test_empty_bundled_name_lists_known(self):
1228
+ from agentic_dev_loop.engine.seccomp import resolve_seccomp_profile
1229
+
1230
+ with pytest.raises(ValueError, match="bundled:chromium-userns"):
1231
+ resolve_seccomp_profile("bundled:")
1232
+
1233
+ def test_none_passes_through(self):
1234
+ from agentic_dev_loop.engine.seccomp import resolve_seccomp_profile
1235
+
1236
+ assert resolve_seccomp_profile(None) is None
1237
+
1238
+ def test_raw_path_passes_through(self):
1239
+ from agentic_dev_loop.engine.seccomp import resolve_seccomp_profile
1240
+
1241
+ assert resolve_seccomp_profile(
1242
+ "/host/chromium-userns.json") == "/host/chromium-userns.json"
1243
+
1244
+ def test_bundled_profile_path_unknown_name(self):
1245
+ from agentic_dev_loop.engine.seccomp import bundled_profile_path
1246
+
1247
+ with pytest.raises(ValueError, match="bundled:chromium-userns"):
1248
+ bundled_profile_path("nope")
1249
+
1250
+ def test_sentinel_resolves_into_payload(self):
1251
+ """025: the resolved sentinel path reaches ``docker_create_payload``
1252
+ unchanged, producing the profile security-opt and the cap-add."""
1253
+ from agentic_dev_loop.engine.seccomp import resolve_seccomp_profile
1254
+
1255
+ resolved = resolve_seccomp_profile("bundled:chromium-userns")
1256
+ payload = docker_create_payload(
1257
+ run_id="r1", image="img",
1258
+ profile=EffectiveProfile(True, True, True, True, True,
1259
+ "img", "4g", 2.0),
1260
+ workspace="/ws", seccomp_profile=resolved)
1261
+ assert payload[payload.index("--security-opt") + 1] == f"seccomp={resolved}"
1262
+ assert payload[payload.index("--cap-add") + 1] == "SYS_CHROOT"
@@ -27,3 +27,11 @@ def test_unverifiable_verdict_rules_present():
27
27
 
28
28
  def test_inconclusive_budget_rule_retained():
29
29
  assert "INCONCLUSIVE" in _text()
30
+
31
+
32
+ def test_probe_plan_rules_present():
33
+ text = _text()
34
+ assert "**Variant matrix.**" in text
35
+ assert "**Outcome probes.**" in text
36
+ assert "**Concurrency probes.**" in text
37
+ assert "**Error messages are assertions.**" in text
@@ -38,7 +38,7 @@ def _cfg_with_secrets(tmp_path) -> PipelineConfig:
38
38
  data = _base()
39
39
  data["secrets"] = {
40
40
  "kinde-client-secret": {"from": "env", "key": "KINDIE_CLIENT_SECRET"},
41
- "r2-keys": {"from": "env", "key": "YOSK_R2_KEYS"},
41
+ "r2-keys": {"from": "env", "key": "R2_KEYS"},
42
42
  }
43
43
  data["steps"] = [
44
44
  {"name": "qa", "agent": "qa", "secrets": ["kinde-client-secret"],
@@ -127,7 +127,7 @@ class TestProviderResolution:
127
127
  def test_resolve_secrets_returns_ref_keyed_values(self, tmp_path):
128
128
  cfg = _cfg_with_secrets(tmp_path)
129
129
  resolved = resolve_secrets(cfg, {"KINDIE_CLIENT_SECRET": "kinde-secret",
130
- "YOSK_R2_KEYS": "r2-keys"})
130
+ "R2_KEYS": "r2-keys"})
131
131
  assert resolved == {"kinde-client-secret": "kinde-secret",
132
132
  "r2-keys": "r2-keys"}
133
133
 
@@ -137,14 +137,14 @@ class TestProviderResolution:
137
137
  resolve_secrets(cfg, {"KINDIE_CLIENT_SECRET": "kinde-secret"})
138
138
  msg = str(e.value)
139
139
  assert "r2-keys" in msg
140
- assert "YOSK_R2_KEYS" in msg
140
+ assert "R2_KEYS" in msg
141
141
  assert "env" in msg
142
142
  assert "['<none>']" in msg # r2-keys is declared but unused by any step
143
143
 
144
144
  def test_resolve_secrets_missing_used_ref_names_step(self, tmp_path):
145
145
  cfg = _cfg_with_secrets(tmp_path)
146
146
  with pytest.raises(SecretResolutionError) as e:
147
- resolve_secrets(cfg, {"YOSK_R2_KEYS": "r2-keys"})
147
+ resolve_secrets(cfg, {"R2_KEYS": "r2-keys"})
148
148
  assert "kinde-client-secret" in str(e.value)
149
149
  assert "['qa']" in str(e.value)
150
150
 
@@ -1,6 +1,6 @@
1
1
  """Stage nodes, routers, context assembly, and the run_agent port.
2
2
 
3
- The yosk hardcoded stages (dev/test/review/qa + bump) become per-step node
3
+ The consumer hardcoded stages (dev/test/review/qa + bump) become per-step node
4
4
  and router closures built from the pipeline config; the fixed policies
5
5
  (hygiene, verdict-first truncation, unclear-verdict retry, notes) stay here."""
6
6
 
@@ -17,8 +17,9 @@ from openhands.sdk.conversation.state import ConversationExecutionStatus
17
17
  from agentic_dev_loop.core.config import PipelineConfig
18
18
  from agentic_dev_loop.engine.registry import Registry, default_registry
19
19
  from agentic_dev_loop.engine.stages import (
20
- Runtime, _run_command, build_context, make_action_node, make_agent_node,
21
- make_bump_node, make_command_node, make_router, run_agent,
20
+ ARTIFACT_CONTEXT_CAP, Runtime, _run_command, build_context,
21
+ make_action_node, make_agent_node, make_bump_node, make_command_node,
22
+ make_router, run_agent,
22
23
  )
23
24
 
24
25
 
@@ -53,7 +54,7 @@ def runtime(tmp_path, fake_sandbox):
53
54
 
54
55
  def make_state(**over):
55
56
  state = {"subject": "plan plan.md", "docs": {"plan.md": "THE PLAN"},
56
- "context": "CTX", "attempt": 1, "max_attempts": 3, "notes": [],
57
+ "artifacts": {}, "attempt": 1, "max_attempts": 3, "notes": [],
57
58
  "failed": False, "outcome": None, "commit_allowed": False,
58
59
  "step_verdicts": {}, "retry_target": None, "thread_id": "t1"}
59
60
  state.update(over)
@@ -78,7 +79,7 @@ class TestBuildContext:
78
79
  assert "## Previous attempt feedback\n\nnote one\n\nnote two" in ctx
79
80
 
80
81
  def test_output_paths_workspace_relative_for_subdirectory_pipeline(self, tmp_path):
81
- """yosk layout: the pipeline YAML lives in a subdirectory while the
82
+ """consumer layout: the pipeline YAML lives in a subdirectory while the
82
83
  workspace is the repo root - the prompt path must reach the real
83
84
  state_dir; state_dir.name alone sends agents to the wrong directory
84
85
  (U18 burned 3 attempts on an unfindable ci-fix.md)."""
@@ -114,6 +115,27 @@ class TestBuildContext:
114
115
  assert "dev agent writes:" in ctx
115
116
  assert "test agent writes:" not in ctx
116
117
 
118
+ def test_artifacts_render_one_subsection_per_artifact_in_order(self, runtime):
119
+ ctx = build_context(runtime, {}, "s", [],
120
+ artifacts={"dev": "summary text",
121
+ "review": "findings text"})
122
+ assert "## Stage artifacts" in ctx
123
+ assert "### dev\n\nsummary text" in ctx
124
+ assert "### review\n\nfindings text" in ctx
125
+ assert ctx.index("### dev") < ctx.index("### review") # dict order
126
+
127
+ def test_artifact_content_capped_with_run_state_path_marker(self, runtime):
128
+ big = "x" * (ARTIFACT_CONTEXT_CAP + 100)
129
+ ctx = build_context(runtime, {}, "s", [], artifacts={"dev": big})
130
+ assert "x" * ARTIFACT_CONTEXT_CAP in ctx
131
+ assert "x" * (ARTIFACT_CONTEXT_CAP + 100) not in ctx
132
+ assert ("[... content truncated - full text at "
133
+ "`.pr/implementation-summary.md`]") in ctx
134
+
135
+ def test_no_artifacts_renders_no_section(self, runtime):
136
+ ctx = build_context(runtime, {}, "s", [])
137
+ assert "## Stage artifacts" not in ctx
138
+
117
139
 
118
140
  class TestDepsPolicyContext:
119
141
  def test_locked_policy_injected(self, runtime):
@@ -182,6 +204,40 @@ class TestAgentNode:
182
204
  (note,) = update["notes"]
183
205
  assert "broken build" in note and "attempt 2" in note
184
206
 
207
+ def test_no_verdicts_branch_captures_artifact(self, runtime, fake_agents):
208
+ fake_agents.set("dev", [("write", "implementation-summary.md", "MARKER-DEV")])
209
+ update = make_agent_node(runtime, runtime.cfg.steps[0])(make_state())
210
+ assert update["artifacts"] == {"dev": "MARKER-DEV"}
211
+
212
+ def test_pass_verdict_captures_artifact(self, runtime, fake_agents):
213
+ fake_agents.set("check", [("write", "ci-fix.md", "MARKER-CHECK\nVERDICT: PASS\n")])
214
+ update = make_agent_node(runtime, runtime.cfg.steps[1])(make_state())
215
+ assert update["artifacts"] == {"check": "MARKER-CHECK\nVERDICT: PASS\n"}
216
+
217
+ def test_fail_verdict_captures_artifact(self, runtime, fake_agents):
218
+ fake_agents.set("check", [("write", "ci-fix.md", "MARKER-FAIL\nVERDICT: FAIL\n")])
219
+ update = make_agent_node(runtime, runtime.cfg.steps[1])(make_state())
220
+ assert update["artifacts"] == {"check": "MARKER-FAIL\nVERDICT: FAIL\n"}
221
+
222
+ def test_unverifiable_verdict_captures_artifact(self, runtime, fake_agents):
223
+ step = make_cfg([
224
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
225
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
226
+ "unverifiable": "UNVERIFIABLE"},
227
+ "on_pass": "success"}]).steps[0]
228
+ fake_agents.set("qa", [("write", "qa-report.md", "MARKER-UNVER\nVERDICT: UNVERIFIABLE\n")])
229
+ update = make_agent_node(runtime, step)(make_state())
230
+ assert update["artifacts"] == {"qa": "MARKER-UNVER\nVERDICT: UNVERIFIABLE\n"}
231
+
232
+ def test_capture_error_branch_returns_no_artifacts(self, runtime, fake_agents):
233
+ update = make_agent_node(runtime, runtime.cfg.steps[1])(make_state())
234
+ assert "artifacts" not in update
235
+
236
+ def test_unclear_branch_returns_no_artifacts(self, runtime, fake_agents):
237
+ fake_agents.set("check", [("write", "ci-fix.md", "undecided")])
238
+ update = make_agent_node(runtime, runtime.cfg.steps[1])(make_state())
239
+ assert "artifacts" not in update
240
+
185
241
  def test_verdicts_merge_with_prior_steps(self, runtime, fake_agents):
186
242
  fake_agents.set("check", [("write", "ci-fix.md", "VERDICT: PASS")])
187
243
  state = make_state(step_verdicts={"dev": "pass"})
@@ -528,14 +584,11 @@ class TestBumpNode:
528
584
  def test_consumes_attempt_resets_verdicts_rebuilds_cleans(self, runtime):
529
585
  for stale in ("ci-fix.md", "implementation-summary.md", "last-error.md"):
530
586
  (runtime.state_dir / stale).write_text("stale")
531
- state = make_state(attempt=1, notes=["N1"],
587
+ state = make_state(attempt=1, notes=["N1"], artifacts={"dev": "D1"},
532
588
  step_verdicts={"check": "fail"})
533
589
  update = make_bump_node(runtime)(state)
534
- assert update["attempt"] == 2
535
- assert update["step_verdicts"] == {}
536
- assert update["failed"] is False
537
- assert "## Previous attempt feedback\n\nN1" in update["context"]
538
- assert "## plan.md\n\nTHE PLAN" in update["context"]
590
+ assert update == {"attempt": 2, "step_verdicts": {}, "failed": False}
591
+ assert "context" not in update # message is rebuilt per run (027)
539
592
  for stale in ("ci-fix.md", "implementation-summary.md", "last-error.md"):
540
593
  assert not (runtime.state_dir / stale).exists()
541
594
 
@@ -1,27 +0,0 @@
1
- """Bundled Docker seccomp profiles (023).
2
-
3
- Docker's default seccomp profile blocks the user-namespace syscalls Chromium's
4
- namespace sandbox needs, which makes ``chromium.launch(chromiumSandbox=True)``
5
- die at the zygote under ``--cap-drop ALL``. These profiles are Docker's default
6
- profile with an allowlist for those userns syscalls; pass one as
7
- ``isolation.seccomp_profile`` to swap it in.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from importlib.resources import as_file, files
13
-
14
- CHROMIUM_USERNS_PROFILE = "chromium-userns.json"
15
-
16
-
17
- def chromium_userns_profile_path() -> str:
18
- """Host filesystem path to the bundled Chromium userns seccomp profile.
19
-
20
- Docker's ``--security-opt seccomp=<path>`` takes a host path, so the JSON
21
- must resolve to a real file. Wheels unpack to disk, so ``as_file`` returns
22
- the installed path without copying.
23
- """
24
- traversable = files("agentic_dev_loop.engine.seccomp").joinpath(
25
- CHROMIUM_USERNS_PROFILE)
26
- with as_file(traversable) as path:
27
- return str(path)