From bc1b90ccbaaf041183d8acfc8e9f2569794bd2c6 Mon Sep 17 00:00:00 2001 From: Ouroboros Date: Sun, 20 Sep 2026 18:40:05 +0300 Subject: [PATCH] Simplify delegated binding and disclose target drift Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- docs/DELEGATED_ADMISSION.md | 26 ++++---- docs/DOMAIN_MAP.md | 5 +- .../01-high-level-architecture.md | 2 +- docs/architecture/06-agent-core.md | 2 +- ouroboros/delegate_start_instructions.py | 63 +++++-------------- ouroboros/delegate_target_drift.py | 15 +++-- ouroboros/domains.toml | 1 + ouroboros/subagent_worktrees.py | 4 ++ ouroboros/tools/delegate.py | 12 ++-- ouroboros/tools/delegate_integration.py | 44 +++++-------- tests/test_bootstrap_launcher_authority.py | 22 +++++++ tests/test_delegated_drift_regressions.py | 23 +++++-- tests/test_delegated_execution_binding.py | 33 +++++++--- 13 files changed, 132 insertions(+), 120 deletions(-) diff --git a/docs/DELEGATED_ADMISSION.md b/docs/DELEGATED_ADMISSION.md index 0f0d3823b..06759dfa0 100644 --- a/docs/DELEGATED_ADMISSION.md +++ b/docs/DELEGATED_ADMISSION.md @@ -41,20 +41,18 @@ The harness can execute model-generated commands under the operator's OS identity. It is not assumed hostile, but the host cannot review each command before it runs. -When a private snapshot exists, the host appends a canonical execution binding -after the inherited work order: the snapshot is the only writable root and the -stable project root is a read-only identity until explicit integration. The -binding supersedes path fields in the inherited assignment; legacy engines that -expose only the snapshot as `scope.root` receive the same binding using that -root. Full native access does not make that path binding enforceable by itself, -so terminal capture rechecks the authority root against the recorded baseline. -A ready-no-changes result with drift or an unverifiable check is a failed capture -with retained evidence; an explicit reject can still release that empty private -snapshot, while apply remains refused. A ready-with-changes result retains its -private artifact and lets the existing locked apply check decide whether -integration is safe. Authority checks include eligible file inputs and excluded -untracked preimages, so an omitted sensitive file cannot turn a changed target -into a clean result. +When a private snapshot exists, the host appends a separate typed execution +binding after the immutable inherited work order: the snapshot is the writable +root and the stable project root is a read-only identity until explicit +integration. Legacy engines that expose only the snapshot as `scope.root` +receive the same binding using that root. Full native access does not make the +binding enforceable by itself, so terminal capture records authority-tree drift +as diagnostic evidence. A ready-no-changes result remains a normal no-change +capture even when the shared authority tree moved; the evidence names the +changed paths and keeps authorship unknown. A ready-with-changes result retains +its private artifact and the existing locked apply check decides whether +integration is safe. Excluded nested repositories remain outside the snapshot +inventory and are disclosed as an untracked residual. A read-only child requests `mode: ask`, `access: readonly` under Claudexor's ordinary envelope. The host reads effective access back for both shapes; diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md index 04d19fb25..65031b4a8 100644 --- a/docs/DOMAIN_MAP.md +++ b/docs/DOMAIN_MAP.md @@ -14,7 +14,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D04 | Tool execution: registry, access & typed results | 21 | 0 | | D05 | Tool surfaces: files, code, shell, media, external | 28 | 0 | | D06 | Review stack | 67 | 0 | -| D07 | Delegation, subagents & Claudexor | 51 | 0 | +| D07 | Delegation, subagents & Claudexor | 52 | 0 | | D08 | Supervisor: queue, workers, events & runtime control | 46 | 0 | | D09 | Cancellation, owner control & process custody | 13 | 0 | | D10 | Git, update & release machinery | 28 | 0 | @@ -28,7 +28,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D18 | Launcher, packaging, platform & shared substrate | 14 | 0 | | D19 | Frozen contracts (ABI) | 10 | 0 | | D20 | Presence | 10 | 0 | -| **total** | | **555** | **0** | +| **total** | | **556** | **0** | ## Dependency direction matrix (strict, pinned) @@ -419,6 +419,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne - `ouroboros/delegate_start_instructions.py` - `ouroboros/delegate_state_sweep.py` - `ouroboros/delegate_supervision.py` +- `ouroboros/delegate_target_drift.py` - `ouroboros/delegate_terminal.py` - `ouroboros/depth_evidence.py` - `ouroboros/gateways/__init__.py` diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index 0e00a7eb2..ef91fe357 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -472,6 +472,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de │ ├── join_ledger.py ← Child-result absorption: validates lineage + exact hashes; dispositions integrated/irrelevant/deferred; `CHILD_RESULT_STALE`; keeps peek_task/discard_child_result │ ├── delegate.py ← Delegation facade verbs `delegate_start` (with `retry_of`), `delegate_wait`, `delegate_cancel`, `delegate_answer`; the host pre-start rides the same wrapper and the shared `subagent_runtime.exact_start` (§6 Delegated subagents) │ ├── delegate_integration.py ← Delegated-patch integration: `_mutation_authority`, `_provision_snapshot`, retry-binding validation, `_capture_terminal_patch`; the skill-payload cluster (`_payload_mutation_authority`, `_write_payload_patch_artifacts`; reserved paths refuse the WHOLE apply as `blocked_reserved_paths`) and `integrate_payload_patch` (CAS, index-free git apply in NO-REPOSITORY mode under `GIT_CEILING_DIRECTORIES`, typed `INTEGRATE_APPLY_NO_OP`; a successful apply QUEUES the extension reconcile request via `request_extension_reconcile`) (§6 Delegated subagents) + │ ├── delegate_target_drift.py ← Read-only authority-tree drift evidence for delegated capture; records changed paths without attributing them to the child or blocking a normal no-change disposition (§6 Delegated subagents) │ ├── delegate_payload_patch.py, delegate_terminal_evidence.py, subagent_integration_delegated.py ← Delegation leaves: the skill-payload patch pipeline (capture artifacts, guards, the live apply); the terminal story of ONE delegated run as the parent reads it; `integrate_delegated_patch`, the delegated-run disposition seam │ ├── subagent_integration.py ← integrate_subagent_patch (sha256, 3-way --index, protected-path gated, genesis refused), external-workspace audited verdict, `coop_already_in_tree` no-op, compare_subagent_patches │ └── patch_verdict.py ← The ONE verdict writer for both patch pipelines (`_write_verdict`): subjects are minted by the writer (`run_`), never prefix-matched by readers; each decision lands twice — artifact + typed `delegate_run_patch_verdict` custody row @@ -670,4 +671,3 @@ Bundled resources use the CLI / Headless Boundary lookup order rather than assum The generated `docs/inventories/DATA_LAYOUT_INVENTORY.md` probes every entry of this tree: its last literal path segment must be a tracked repo path or directory, or a literal in the runtime sources. A durable file renamed in code while its row here survives therefore turns red, not silent — but this is a basename-and-substring check and proves nothing stronger about an entry. --- - diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index dfd6d7cc4..4a73adb47 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -321,7 +321,7 @@ WHERE a mutating run's changes are destined is the second, separate record — t A payload target gets a standalone private Git snapshot (`subagent_worktrees.provision_payload_snapshot`): the live payload is never initialized as Git, and capture trusts nothing under the child-writable snapshot's `.git`. Disposition (`integrate_payload_patch`) applies a live, index-free `git apply` in no-repository mode under a whole-payload content-hash CAS (drift = typed conflict; identical content = idempotent applied) — `GIT_CEILING_DIRECTORIES` is pinned at the payload's resolved PARENT, because git still searches the ceiling entry itself and an ancestor Git worktree above the runtime data root could otherwise make git skip every hunk at rc=0 — and reserved paths refuse the WHOLE apply as `blocked_reserved_paths` with the candidate preserved. The post-apply outcome set is complete: a live loader hash equal to the recorded RESULT hash is the success; a hash equal to the recorded BASELINE hash with a non-empty touched set is a provable non-mutation that RESOLVES the apply intent (typed `INTEGRATE_APPLY_NO_OP`, the `apply_no_op` arm: no success, no disposition, no reconcile queued, retry lane open); anything else is the ambiguous mismatch, whose intent stays PENDING and whose reconcile marker IS queued because the payload did mutate. A successful apply queues the extension reconcile (`request_extension_reconcile`) and the skill's review goes STALE pending fresh `skill_preflight`/`skill_review`; a run whose ONLY change is a mode flip is already refused at CAPTURE time as `unreviewable_metadata_change`, so a live hash still equal to the baseline means nothing was written. -**A mutating run executes in a PRIVATE EXECUTION SNAPSHOT, never in the shared tree** (metered children keep sharing the tree; their patches integrate through `integrate_subagent_patch` — sha256-bound, 3-way `--index`, protected-path gated, genesis refused, `coop_already_in_tree` a no-op). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`, registered before the start intent); `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The host appends a canonical execution binding after the inherited assignment: the snapshot is the sole writable root, and the authority target is a read-only identity until explicit integration. This binding is required because full native access has no filesystem sandbox and a model can otherwise select an absolute authority path directly. Terminal capture records one durable, read-only authority check before minting `PATCH_CAPTURED`: a ready-no-changes result with proven target drift or an unverifiable check becomes a typed failed capture with the snapshot and evidence retained, never `ready_no_changes` or a private-only provenance claim. An explicit reject may release that empty result, while apply remains refused. A ready-with-changes result retains its private artifact and discloses target drift; integration still runs the existing locked baseline proof, which refuses changed or unverifiable targets. The authority check covers file-input and excluded-untracked preimages as well as Git paths. Skill-payload captures use their existing content-hash CAS rather than a Git target check. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly, and a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint. Worktrees live in the durable registry `state/subagent_worktrees.json` (`subagent_worktrees.py`; `provision_genesis_project` never enters registry or GC); removal is explicit or custody-cross-checked startup GC, fail-closed — an unreadable custody log replays as "no open runs", so the prune skips rather than destroy a child's only copy of its work. The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged. +**A mutating run normally executes in a PRIVATE EXECUTION SNAPSHOT** (metered children keep sharing the tree; their patches integrate through `integrate_subagent_patch` — sha256-bound, 3-way `--index`, protected-path gated, genesis refused, `coop_already_in_tree` a no-op). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`, registered before the start intent); `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The host appends a separate typed execution binding after the immutable inherited work order: the snapshot is the writable root and the authority target is a read-only identity until explicit integration. Directory-copy runs use the engine-created copy and never invent the source folder as its execution root. This binding is required because full native access has no filesystem sandbox and a model can otherwise select an absolute authority path directly. Terminal capture records authority-tree drift as diagnostic evidence; a ready-no-changes result stays a normal no-change capture with authorship unknown, while ready-with-changes retains its private artifact and the existing locked baseline proof decides whether integration is safe. Nested untracked repositories are excluded from the snapshot inventory and disclosed. Skill-payload captures use their existing content-hash CAS rather than a Git target check. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly, and a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint. Worktrees live in the durable registry `state/subagent_worktrees.json` (`subagent_worktrees.py`; `provision_genesis_project` never enters registry or GC); removal is explicit or custody-cross-checked startup GC, fail-closed — an unreadable custody log replays as "no open runs", so the prune skips rather than destroy a child's only copy of its work. The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged. At terminal, `delegate_wait` captures the run's diff against the baseline durably into the task's artifact store; NOTHING reaches the target automatically — the nanny explicitly applies or rejects through `integrate_delegated_patch`. Git and skill captures are whole-result operations: omitted `paths` and an exact empty list select the same captured result, disclosed when explicit; nonempty selectors remain directory-only. Engine-directory empty/subset semantics are unchanged. The staging substrate differs: a GIT workspace target applies under the repo git lock after PROVING no touched path drifted from `baseline_sha` — a plain `git apply` relocates hunks by offset, and the touched-path set is read from `git apply --numstat` in BOTH directions because each direction names only the paths it writes — then applies and STAGES, never commits. A SKILL-PAYLOAD target captures through the payload adapter over a parent-owned trusted index and applies LIVE into the non-Git payload — nothing is staged into any active root and no `.git` or index is created in the payload. The protected-path gate applies only when the target IS the Ouroboros body; a conflict (proven drift) is owned by the still-running nanny, with snapshot and patch persisting until explicit resolution or discard. Mutation rides an apply-intent protocol: a durable `delegate_run_patch_apply_started` row lands before any tree mutation, so on replay a pending intent without a disposition answers typed `INTEGRATE_DELEGATED_APPLY_AMBIGUOUS`, resolved only by explicit `acknowledge_ambiguous=true`, while the provably non-mutating outcomes (a lock error, proven baseline drift, a failed apply, a verified revert, a baseline-equal payload hash) RESOLVE the intent. `patch_verdict.py` is the ONE verdict writer for both pipelines: subjects are minted `run_` by the writer, never prefix-matched by readers, and each decision lands twice — artifact plus typed `delegate_run_patch_verdict` custody row — with a failed artifact write disclosed on the row. `artifacts.delegated_capture_read_target` narrowly rebinds `artifact_store` READS for the owning task's own `delegated_runs/` prefix, and `delegate_shared.orphan_capture_read_target` extends the same read-only, one-directory READ to the terminal-owner ORPHAN the disposition rule authorizes (confirmed by `orphan_disposition_status`), so the actor that may dispose a patch can inspect it without widened write authority. A read-only child stays in Claudexor's default envelope — one transport with one derived difference, not a second pipeline. diff --git a/ouroboros/delegate_start_instructions.py b/ouroboros/delegate_start_instructions.py index 619807e6f..2832c870b 100644 --- a/ouroboros/delegate_start_instructions.py +++ b/ouroboros/delegate_start_instructions.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from hashlib import sha256 @@ -88,8 +87,8 @@ def execution_binding_instruction(execution_root: str, authority_root: str) -> s if not execution: return "" return ( - "\n\nDELEGATED EXECUTION BINDING (canonical host fact; supersedes path fields " - "in the inherited contract for this run): " + "\n\nDELEGATED EXECUTION BINDING (separate typed host fact; the canonical " + "work order remains byte-identical): " f"the sole writable execution root for this run is {execution}. " "Use relative paths or absolute paths under that root for every shell, " "file, and patch operation. The stable authority/project root " @@ -101,51 +100,23 @@ def execution_binding_instruction(execution_root: str, authority_root: str) -> s ) -def _rebind_json_block(text: str, marker: str, execution_root: str, authority_root: str) -> str: - start = text.find(marker) - if start < 0: - return text - json_start = text.find("\n", start) - if json_start < 0: - return text - json_start += 1 - try: - value, used = json.JSONDecoder().raw_decode(text[json_start:]) - except (TypeError, ValueError): - return text - if not isinstance(value, dict): - return text - - def rewrite(node): - if isinstance(node, dict): - for key, child in list(node.items()): - if key in {"workspace_root", "write_root"} and isinstance(child, str) and child: - node[key] = execution_root - else: - rewrite(child) - node.setdefault("authority_target_root", authority_root) - elif isinstance(node, list): - for child in node: - rewrite(child) - - rewrite(value) - rendered = json.dumps(value, ensure_ascii=False, sort_keys=True) - return text[:json_start] + rendered + text[json_start + used:] +def directory_copy_binding_instruction(authority_root: str) -> str: + """Tell a directory-copy child that the engine creates its write root later.""" + authority = str(authority_root or "").strip() or "(unknown)" + return ( + "\n\nDELEGATED DIRECTORY COPY BINDING (separate typed host fact; the canonical " + "work order remains byte-identical): the engine will create a private " + "execution copy for this run. Use only the engine-provided working " + "directory/cwd for writes; the selected authority folder " + f"{authority} is a read-only source reference. Do not write to that " + "authority folder directly." + ) -def bind_execution_assignment(text: str, execution_root: str, authority_root: str) -> str: - """Rewrite child-facing structured path fields before appending the binding.""" - bound = _rebind_json_block(text, "HOST TASK CONTRACT AUTHORITY", execution_root, authority_root) - return _rebind_json_block(bound, "HOST AUTHORITY BINDING", execution_root, authority_root) - - -def apply_execution_binding(instructions: str, prompt: str, compiled: bool, - execution_root: str, authority_root: str) -> tuple[str, str]: - binding = execution_binding_instruction(execution_root, authority_root) - instructions = bind_execution_assignment(instructions, execution_root, authority_root) + binding - if compiled: - prompt = bind_execution_assignment(prompt, execution_root, authority_root) + binding - return instructions, prompt +def apply_execution_binding(instructions: str, execution_root: str, + authority_root: str) -> str: + """Append one typed binding without rewriting canonical work-order bytes.""" + return instructions + execution_binding_instruction(execution_root, authority_root) def append_coordination_context( diff --git a/ouroboros/delegate_target_drift.py b/ouroboros/delegate_target_drift.py index 219a36186..ea417d812 100644 --- a/ouroboros/delegate_target_drift.py +++ b/ouroboros/delegate_target_drift.py @@ -3,6 +3,7 @@ from __future__ import annotations import pathlib +import os import subprocess from typing import Any, Dict, List @@ -20,13 +21,14 @@ def _target_drift_evidence(entry: Any) -> Dict[str, Any]: evidence["error"] = f"authority target is not a Git worktree: {target}" return evidence try: + git_env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0"} def git_error(proc, fallback): detail = proc.stderr or proc.stdout or b"" return (detail.decode("utf-8", "replace") if isinstance(detail, bytes) else str(detail)).strip() or fallback baseline_files = subprocess.run( ["git", "ls-tree", "-r", "-z", "--name-only", baseline], - cwd=str(target), capture_output=True, check=False, + cwd=str(target), capture_output=True, check=False, env=git_env, ) if baseline_files.returncode != 0: evidence["error"] = git_error(baseline_files, f"git ls-tree exited {baseline_files.returncode}") @@ -62,6 +64,8 @@ def _target_drift_evidence(entry: Any) -> Dict[str, Any]: for row in excluded_rows: if not isinstance(row, dict) or not row.get("path"): continue + if row.get("reason") == "nested_repository" and not isinstance(row.get("baseline"), dict): + continue relative, before = str(row["path"]), row.get("baseline") if not isinstance(before, dict): evidence["error"] = f"baseline identity unavailable for excluded path {relative}" @@ -70,7 +74,7 @@ def _target_drift_evidence(entry: Any) -> Dict[str, Any]: evidence["paths"].append(relative) diff = subprocess.run( ["git", "diff", "--name-only", "-z", "--no-renames", baseline, "--"], - cwd=str(target), capture_output=True, check=False, + cwd=str(target), capture_output=True, check=False, env=git_env, ) if diff.returncode != 0: evidence["error"] = git_error(diff, f"git diff exited {diff.returncode}") @@ -82,7 +86,7 @@ def _target_drift_evidence(entry: Any) -> Dict[str, Any]: ) untracked = subprocess.run( ["git", "ls-files", "-z", "--others", "--exclude-standard"], - cwd=str(target), capture_output=True, check=False, + cwd=str(target), capture_output=True, check=False, env=git_env, ) if untracked.returncode != 0: evidence["error"] = (untracked.stderr or b"").decode("utf-8", "replace").strip() or \ @@ -109,7 +113,6 @@ def _target_drift_paths(entry: Any) -> List[str]: def _persist_target_drift(manifest_path: pathlib.Path, manifest: Dict[str, Any], evidence: Dict[str, Any]) -> Dict[str, Any]: - from ouroboros.headless import ARTIFACT_STATUS_READY_NO_CHANGES from ouroboros.utils import atomic_write_json, utc_now_iso updated = dict(manifest) @@ -121,9 +124,5 @@ def _persist_target_drift(manifest_path: pathlib.Path, manifest: Dict[str, Any], } updated["authority_drift_status"] = ( "unknown" if evidence.get("error") else "changed" if evidence.get("paths") else "clean") - if (evidence.get("error") or evidence.get("paths")) \ - and updated.get("status") == ARTIFACT_STATUS_READY_NO_CHANGES: - updated["status"] = "failed" - updated["note"] = "Authority-tree drift was not accepted as a clean no-change capture; snapshot preserved." atomic_write_json(manifest_path, updated, trailing_newline=True) return updated diff --git a/ouroboros/domains.toml b/ouroboros/domains.toml index 6ee40fcb4..2e38adffd 100644 --- a/ouroboros/domains.toml +++ b/ouroboros/domains.toml @@ -146,6 +146,7 @@ D20 = "Presence" "ouroboros/delegate_source_coverage.py" = "D07" "ouroboros/delegate_start_claims.py" = "D07" "ouroboros/delegate_start_instructions.py" = "D07" +"ouroboros/delegate_target_drift.py" = "D07" "ouroboros/delegate_supervision.py" = "D07" "ouroboros/delegate_terminal.py" = "D07" "ouroboros/deliverables_paths.py" = "D17" diff --git a/ouroboros/subagent_worktrees.py b/ouroboros/subagent_worktrees.py index a95afed23..28d567427 100644 --- a/ouroboros/subagent_worktrees.py +++ b/ouroboros/subagent_worktrees.py @@ -545,6 +545,10 @@ def provision_execution_snapshot( file_inputs: List[str] = [] capture_warnings: List[Dict[str, Any]] = [] for rel in (p for p in untracked_raw.split("\0") if p): + candidate = target / rel + if candidate.is_dir() and not candidate.is_symlink(): + excluded.append({"path": rel, "reason": "nested_repository"}) + continue reference: List[str] = [] reason = untracked_capture_veto_reason(target, rel, file_outputs=reference, warnings=capture_warnings) if reference: diff --git a/ouroboros/tools/delegate.py b/ouroboros/tools/delegate.py index 6977f79b2..6468c769c 100644 --- a/ouroboros/tools/delegate.py +++ b/ouroboros/tools/delegate.py @@ -57,6 +57,7 @@ from ouroboros.delegate_start_instructions import ( access_instruction, append_coordination_context, apply_execution_binding, + directory_copy_binding_instruction, ) from ouroboros.subagent_runtime import ( # noqa: F401 - shared primitive re-export delegate_start_entry as _delegate_start_entry, @@ -471,10 +472,11 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] = resource_ref = dict(record_auth.get("resource_ref") or {}) execution_root = (root if directory_options.get("isolation") == "live" else "") if directory_options else delegated_execution_workspace_root(gateway, authority, root) scope_root = target_root if execution_root or directory_options else root - if snapshot is not None or (directory_options and directory_options.get("isolation") == "envelope"): - instructions, text = apply_execution_binding( - instructions, text, bool(actor.get("compiled_work_order")), - execution_root or root, target_root) + if snapshot is not None: + instructions = apply_execution_binding( + instructions, execution_root or root, target_root) + elif directory_options and directory_options.get("isolation") == "envelope": + instructions += directory_copy_binding_instruction(target_root) (project_id, owned_project_id, project_persistent) = resolve_registration( gateway, scope_root, execution_root, getattr(authority, "access", "")) if directory_options: @@ -499,9 +501,7 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] = idempotency_key=key, invocation_id=invocation_id, max_seconds=seconds, request=request_body, project_id=project_id, project_owned=bool(owned_project_id), project_persistent=project_persistent, route=route.route_id, - # Recovered pending invocations retain their original lineage. root_task_id=str(lineage.get("root_task_id") or ""), parent_task_id=str(lineage.get("parent_task_id") or ""), - # Before POST, persist target/baseline and only known execution paths. snapshot_id=snapshot_id, execution_root=(root if snapshot_id or resource_ref.get("strategy") == "direct" else ""), baseline_sha=baseline_sha, target_root=target_root, authority_source=authority_source, resource_ref=resource_ref, diff --git a/ouroboros/tools/delegate_integration.py b/ouroboros/tools/delegate_integration.py index ed06bf144..7c3f70e97 100644 --- a/ouroboros/tools/delegate_integration.py +++ b/ouroboros/tools/delegate_integration.py @@ -465,37 +465,25 @@ def _capture_block(entry: _RunCustody, cap_dir: pathlib.Path, block["target_drift_checked"] = True if drift_paths: block["target_mutated_during_run"] = drift_paths - if status == ARTIFACT_STATUS_READY_WITH_CHANGES: - block["note"] = ( - "NOT APPLIED: the run edited its private execution snapshot only. " - "Authority-tree drift was observed while the result was captured; " - "the existing locked baseline check will decide whether integration " - "is safe. Nothing reaches the shared tree until explicit disposition." - ) + block["note"] = ( + "The private execution snapshot has no captured file changes, but the " + "authority tree changed while the run was open. The author is unknown: " + "the child, a neighbor, or another process may have written there. " + "The drift is diagnostic evidence; it is not attributed to this child." + if status == ARTIFACT_STATUS_READY_NO_CHANGES else + "NOT APPLIED: the run edited its private execution snapshot only. " + "Authority-tree drift was observed while the result was captured; " + "the existing locked baseline check will decide whether integration " + "is safe. Nothing reaches the shared tree until explicit disposition." + ) if drift_error: block["target_drift_unknown"] = drift_error - if status == ARTIFACT_STATUS_READY_WITH_CHANGES: + if status == ARTIFACT_STATUS_READY_NO_CHANGES: block["note"] = ( - "NOT APPLIED: the run edited its private execution snapshot only. " - f"Authority-tree drift could not be verified ({drift_error}); the " - "locked integration check remains fail-closed. Nothing reaches the " - "shared tree until explicit disposition." - ) - if (drift_paths or drift_error) and status == ARTIFACT_STATUS_READY_NO_CHANGES: - block["status"] = "failed" - if drift_error: - block["note"] = ( - "TARGET DRIFT UNKNOWN: the host could not prove that the authority " - f"tree stayed unchanged ({drift_error}). No disposition is authorized; " - "preserve the snapshot and captured material for inspection." - ) - else: - block["note"] = ( - "TARGET MUTATED DURING RUN: the authority tree changed relative to the " - "delegated baseline while this run was open. The host cannot attribute " - "those bytes to the child, so the private capture is not a clean " - "no-changes result and no disposition is authorized. Preserve the " - "snapshot and captured material for inspection." + "The private execution snapshot has no captured file changes, but " + f"authority-tree drift could not be verified ({drift_error}). " + "The author is unknown; preserve this diagnostic fact and use the " + "normal no-change disposition." ) if status not in {ARTIFACT_STATUS_READY_WITH_CHANGES, ARTIFACT_STATUS_READY_NO_CHANGES}: # A failed manifest's own typed note (unreviewable_metadata_change, diff --git a/tests/test_bootstrap_launcher_authority.py b/tests/test_bootstrap_launcher_authority.py index 616f88492..360202517 100644 --- a/tests/test_bootstrap_launcher_authority.py +++ b/tests/test_bootstrap_launcher_authority.py @@ -2,6 +2,7 @@ from __future__ import annotations + class _GitOps: def __init__(self): self.safe_restart_calls = [] @@ -63,3 +64,24 @@ def test_bootstrap_matching_repo_identity_keeps_managed_reset(monkeypatch, tmp_p assert message == "reset" assert git.safe_restart_calls == [{"reason": "bootstrap", "unsynced_policy": "rescue_and_reset"}] assert git.local_sync_calls == [] + + +def test_bootstrap_legacy_launcher_metadata_keeps_managed_reset(monkeypatch, tmp_path): + import server + + repo = tmp_path / "managed" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "ouroboros-managed.json").write_text("{}", encoding="utf-8") + git = _GitOps() + monkeypatch.setattr(server, "REPO_DIR", repo) + monkeypatch.setattr(server, "DATA_DIR", tmp_path / "data") + monkeypatch.setattr(server, "_LAUNCHER_MANAGED", True) + monkeypatch.setattr(server, "_LAUNCHER_MANAGED_REPO_DIR", "") + monkeypatch.setattr(server, "setup_remote_if_configured", lambda *args: None) + monkeypatch.setattr(server, "_has_active_evolution_transaction", lambda: False) + monkeypatch.setattr(server, "_safe_restart_serialized", lambda fn, **kwargs: fn(**kwargs)) + + ok, message = server._bootstrap_supervisor_repo({}, git) + + assert ok is True and message == "reset" + assert git.safe_restart_calls == [{"reason": "bootstrap", "unsynced_policy": "rescue_and_reset"}] diff --git a/tests/test_delegated_drift_regressions.py b/tests/test_delegated_drift_regressions.py index d9f3184d8..168decaa7 100644 --- a/tests/test_delegated_drift_regressions.py +++ b/tests/test_delegated_drift_regressions.py @@ -24,7 +24,7 @@ def test_drift_capture_freezes_evidence_and_safe_reject_releases_empty_snapshot( (target / "neighbor.txt").write_text("owner change\n", encoding="utf-8") capture = _capture_terminal_patch(ctx, entry) - assert capture["status"] == "failed" + assert capture["status"] == "ready_no_changes" assert capture["target_mutated_during_run"] == ["neighbor.txt"] manifest_path = custody.delegated_capture_dir(custody.custody_root(ctx), "t-nanny", "snapDrift") / "workspace_patch.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -32,7 +32,7 @@ def test_drift_capture_freezes_evidence_and_safe_reject_releases_empty_snapshot( (target / "neighbor.txt").unlink() replay = _capture_terminal_patch(ctx, entry) - assert replay["status"] == "failed" + assert replay["status"] == "ready_no_changes" assert replay["target_mutated_during_run"] == ["neighbor.txt"] assert "Rejected delegated run" in _integrate_delegated_patch( ctx, "run-drift", "reject", "preserve for inspection") @@ -51,7 +51,22 @@ def test_excluded_untracked_baseline_drift_is_not_clean(tmp_path, monkeypatch): (target / ".env").write_text("SECRET=changed\n", encoding="utf-8") capture = _capture_terminal_patch(ctx, entry) - assert capture["status"] == "failed" + assert capture["status"] == "ready_no_changes" assert capture["target_mutated_during_run"] == [".env"] - assert entry.patch_captured is False + assert entry.patch_captured is True custody._CUSTODY.clear() + + +def test_nested_untracked_git_repository_does_not_break_snapshot_provision(tmp_path): + target = _seed_target(tmp_path) + nested = target / "vendor" / "inner" + nested.mkdir(parents=True) + import subprocess + subprocess.run(["git", "init", "-q"], cwd=nested, check=True) + (nested / "README").write_text("nested\n", encoding="utf-8") + + handle = provision_execution_snapshot( + target_root=target, task_id="t-nested", snapshot_id="snapNested") + + assert Path(handle.path).exists() + assert any(row.get("reason") == "nested_repository" for row in handle.excluded_untracked) diff --git a/tests/test_delegated_execution_binding.py b/tests/test_delegated_execution_binding.py index 4e0fb7a9c..07ebf996c 100644 --- a/tests/test_delegated_execution_binding.py +++ b/tests/test_delegated_execution_binding.py @@ -6,7 +6,8 @@ import subprocess from types import SimpleNamespace from ouroboros.delegate_start_instructions import ( - apply_execution_binding, execution_binding_instruction, + apply_execution_binding, directory_copy_binding_instruction, + execution_binding_instruction, ) from ouroboros.tools.delegate_integration import ( _capture_block, _target_drift_evidence, _target_drift_paths, @@ -31,20 +32,28 @@ def test_runtime_child_environment_drops_launcher_authority(monkeypatch): from ouroboros.settings_integrity import runtime_environ monkeypatch.setenv("OUROBOROS_MANAGED_BY_LAUNCHER", "1") + monkeypatch.setenv("OUROBOROS_MANAGED_REPO_DIR", "/private/launcher-repo") assert "OUROBOROS_MANAGED_BY_LAUNCHER" not in runtime_environ() + assert "OUROBOROS_MANAGED_REPO_DIR" not in runtime_environ() -def test_compiled_work_order_rebinds_child_facing_write_fields(): +def test_execution_binding_appends_without_rewriting_canonical_work_order(): instructions = ( "HOST TASK CONTRACT AUTHORITY (complete normalized JSON; exact strings are authority):\n" '{"workspace_root":"/authority","task_constraint":{"write_root":"/authority"}}' ) - bound, prompt = apply_execution_binding( - instructions, instructions, True, "/private/snapshot", "/authority") - assert '"workspace_root": "/private/snapshot"' in bound - assert '"write_root": "/private/snapshot"' in bound - assert '"authority_target_root": "/authority"' in bound - assert "/private/snapshot" in prompt + bound = apply_execution_binding(instructions, "/private/snapshot", "/authority") + assert bound.startswith(instructions) + assert '"workspace_root":"/authority"' in bound + assert '"write_root":"/authority"' in bound + assert "sole writable execution root" in bound + + +def test_directory_copy_binding_does_not_invent_source_as_execution_root(): + text = directory_copy_binding_instruction("/authority") + assert "/authority" in text + assert "engine will create a private execution copy" in text + assert "Do not write to that authority folder directly" in text def test_target_drift_is_detected_without_staging_or_rewriting_index(tmp_path): @@ -60,12 +69,15 @@ def test_target_drift_is_detected_without_staging_or_rewriting_index(tmp_path): (target / "tracked.txt").write_text("owner changed\n", encoding="utf-8") (target / "new.txt").write_text("owner file\n", encoding="utf-8") before = _git(target, "status", "--porcelain").stdout + index = target / ".git" / "index" + before_index = index.read_bytes() entry = SimpleNamespace(target_root=str(target), baseline_sha=baseline) changed = _target_drift_paths(entry) assert changed == ["new.txt", "tracked.txt"] assert _git(target, "status", "--porcelain").stdout == before + assert index.read_bytes() == before_index def test_capture_block_does_not_claim_private_only_after_target_drift(tmp_path): @@ -80,10 +92,11 @@ def test_capture_block_does_not_claim_private_only_after_target_drift(tmp_path): {"status": "ready_no_changes", "sha256": "", "diffstat": "0 files"}, ["neighbor.txt"], ) - assert block["status"] == "failed" + assert block["status"] == "ready_no_changes" assert block["target_mutated_during_run"] == ["neighbor.txt"] - assert "TARGET MUTATED DURING RUN" in block["note"] + assert "authority tree changed" in block["note"] assert "private execution snapshot only" not in block["note"] + assert "author is unknown" in block["note"] def test_target_drift_probe_failure_is_unknown_not_clean(tmp_path, monkeypatch):