mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Merge current development into workflow tool fixes
This commit is contained in:
commit
4ba52dd762
35 changed files with 823 additions and 120 deletions
|
|
@ -65,7 +65,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
|
||||
## Hidden coupling (classified out of the strict graph)
|
||||
|
||||
- lazy-only cross-domain pairs: **103**
|
||||
- lazy-only cross-domain pairs: **102**
|
||||
- D01->D08
|
||||
- D01->D10
|
||||
- D01->D11
|
||||
|
|
@ -90,7 +90,6 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
- D05->D06
|
||||
- D05->D07
|
||||
- D05->D14
|
||||
- D05->D20
|
||||
- D06->D03
|
||||
- D06->D05
|
||||
- D06->D08
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── owner_delivery.py ← Owner event delivery: live queue XOR `pending_events` fallback with sticky deferral, preserving narrative order after the first live failure
|
||||
│ ├── deliverables_shell.py ← cp/mv/ln into deliverables with symlink checks
|
||||
│ ├── shell_audit.py ← Post-exec custody audit for process tools
|
||||
│ ├── process_facts.py ← Typed process-fact seam consumed by loop_tool_execution for the same call; the regex harvest stays a read fallback
|
||||
│ ├── process_facts.py ← Per-call selected environment, secret egress masking and typed process/runtime facts consumed by loop_tool_execution; the regex harvest stays a read fallback
|
||||
│ ├── write_shape.py ← Retained interpreter/non-interpreter syntax helpers; process permission is owned by the task/resource and Supervisor contract (§6 Safety and runtime mode)
|
||||
│ ├── extension_dispatch.py ← Extension tool dispatch (contracts preserved; discovery stays in registry.py)
|
||||
│ ├── release_sync.py ← `sync_release_metadata` (version carriers) used by commit-admission preflight; `_preflight_check` uses `check_history_limit`; the carrier-span SSOT (`VERSION_CARRIER_SPANS`, `substitute_carrier_spans`, the `carrier_only_change` predicate) shared by the managed-update resolver and the commit-triad pack cut (§10 invariant 2)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ A forced turn sends the round's exact tool envelope — same schemas, same serve
|
|||
|
||||
#### Headless finalization and workspace patch capture
|
||||
|
||||
A workspace task's completion compares against the captured preflight base — task-local commits stay in the delta, not `git diff HEAD` — and the patch is bound to `task_constraint.base_sha`; a moved HEAD fails closed only for `self_worktree` (a shared tree relies on reverse-patch verification), and an unborn repo diffs against the canonical empty tree. `workspace_patch_capture.py` streams the tracked binary diff plus admitted untracked files under the pure rules of `workspace_patch_rules.py` (5 MiB per untracked file; `untracked_capture_veto_reason` is the one composite the patch and the delegated-run execution snapshot both ask), excluding each vetoed entry with a per-file reason; otherwise eligible oversized or binary untracked outputs ride complete manifest+zip file artifacts, and tracked files whose old or current size exceeds 50 MiB stay in the same file-reference manifest instead of a giant Git patch. Generated output (`dist/`, `build/`) is governed by the project's own `.gitignore`, honoured through `--exclude-standard`, not by a host name rule — git-ignored files are outside the capture universe and are not listed as exclusions, because a project whose deliverable IS its build output must not have it silently dropped; a sensitive-looking untracked credential is excluded per-file and disclosed as `sensitive_blocked`. `workspace_patch.json` is written for EVERY workspace finalization, no-change and failed included, and is the truth source for CLI strict-patch (it distinguishes omitted, no-op and failed); `workspace.patch` exists only for `ready_with_changes`. A forked or empty child drive under `data/state/headless_tasks/<task_id>/data` is execution state: the result copies back to the canonical root, declared artifacts rebase to `data/task_results/artifacts/<task_id>/`, and once the canonical result is terminal a late copy-back cannot overwrite the parent-owned terminal marker or the cost/round/token fields. The startup prune (`headless.prune_headless_task_drives`, after prior-process custody) removes a child drive only when the canonical parent is terminal, artifact finalization is terminal, retention has elapsed, the recorded child path matches the expected directory and no child-ref promotion is pending — everything needed after deletion must cross the canonical handoff before a task is presented as settled. The CLI contract itself stays in §1 CLI / Headless Boundary.
|
||||
A workspace task's completion compares against the captured preflight base — task-local commits stay in the delta, not `git diff HEAD` — and the patch is bound to `task_constraint.base_sha`; a moved HEAD fails closed only for `self_worktree` (a shared tree relies on reverse-patch verification), and an unborn repo diffs against the canonical empty tree. `workspace_patch_capture.py` streams the tracked binary diff plus admitted untracked files under the pure rules of `workspace_patch_rules.py` (5 MiB per untracked file; `untracked_capture_veto_reason` is the one composite the patch and the delegated-run execution snapshot both ask), excluding each vetoed entry with a per-file reason; otherwise eligible oversized or binary untracked outputs ride complete manifest+zip file artifacts, and tracked files whose old or current size exceeds 50 MiB stay in the same file-reference manifest instead of a giant Git patch. Generated output (`dist/`, `build/`) is governed by the project's own `.gitignore`, honoured through `--exclude-standard`, not by a host name rule — git-ignored files are outside the capture universe and are not listed as exclusions, because a project whose deliverable IS its build output must not have it silently dropped; a sensitive-looking untracked credential is excluded per-file and disclosed as `sensitive_blocked`. `workspace_patch.json` is written for EVERY workspace finalization, no-change and failed included, and is the truth source for CLI strict-patch (it distinguishes omitted, no-op and failed); `workspace.patch` exists only for `ready_with_changes`. A forked or empty child drive under `data/state/headless_tasks/<task_id>/data` is execution state: the result copies back to the canonical root, declared artifacts rebase to `data/task_results/artifacts/<task_id>/`, and once the canonical result is terminal a late copy-back cannot overwrite the parent-owned terminal marker or the cost/round/token fields. The startup prune (`headless.prune_headless_task_drives`, after prior-process custody) removes a child drive only when the canonical parent is terminal, artifact finalization is terminal, retention has elapsed, the recorded child path matches the expected directory and no child-ref promotion is pending — everything needed after deletion must cross the canonical handoff before a task is presented as settled. The capture manifest explains its acting/admission/empty-tree/capture base and current branch/upstream observations; these do not establish task authorship or change application-patch bytes. An auxiliary comparison uses explicit `vcs_diff(base=..., head=...)` inputs rather than guessing a target. The CLI contract itself stays in §1 CLI / Headless Boundary.
|
||||
|
||||
#### Owner routing verbs
|
||||
|
||||
|
|
@ -74,13 +74,13 @@ A routing/promote decision turn receives host-built ground truth — each projec
|
|||
|
||||
Outcome classification keeps policy refusal separate from execution failure, so an expected authority boundary never becomes the task's headline failure: `user_files_path_blocked`, `cwd_blocked` and `artifact_output_undeclared` are typed non-failure surfaces, while a declared output that cannot be registered is the genuine `artifact_output_error`. The identifier register (`tool_result._EXACT_IDENTIFIER_CODES`) applies the same rule: a typed refusal is recorded as a refusal, never as `ok`. It covers the whole routing family (`STEER_REJECTED`/`STEER_UNCONFIRMED`, `PROMOTE_REJECTED`/`PROMOTE_UNCONFIRMED`, `ROUTE_REJECTED`/`ROUTE_UNCONFIRMED`, `ROUTING_UNCONFIRMED`, `NEEDS_MANUAL_TARGET`), so a promote or route that scheduled nothing is never read as a SUCCESSFUL call; a `_REJECTED` result carries its `(reason: detail)`, and the refusal stays visible to the agent (`is_error`) without degrading the execution axis — the host refused, the agent did not fail. File discovery distinguishes a confinement refusal (an error), a failed listing (a failure) and a path holding nothing — a MISS (`LIST_FILES_NOT_FOUND`, a warning beside `DATA_NOT_YET_CREATED`) that leaves the rest of the outcome uncoloured, because the recovery scan credits only a later success on the same target. The delegate tools (`delegate_start`, `delegate_wait`, `delegate_cancel`, `delegate_answer`) reach the same register from the producer side: `delegate_shared._fail` writes the `ok: false` + `host_code` envelope BESIDE the domain payload (whose `reason` keeps its own vocabulary), so every substrate refusal (daemon, ownership, containment, cancel, subscription window) is RECORDED as a refusal, never counted as a successful call — mapped to `TOOL_REPORTED_FAILURE` (never degrading), a malformed call to `TOOL_ARG_ERROR` (degrades, feeds reflection), never to a timeout or a generic tool error — and a terminal run in state `failed` or `cancelled` is a SUCCESSFUL observation of an unsuccessful leaf.
|
||||
|
||||
Tool API v2 exposes neutral canonical names directly (`read_file`, `list_files`, `search_code`, `write_file`, `edit_text`, `edit_batch`, `apply_patch`, `run_command`, `run_script`, `verify_and_record`, service tools, `commit_reviewed`, `vcs_*`, `schedule_subagent`, `schedule_followup`, `wait_task`, `wait_tasks`); legacy public names are neither exposed nor translated. The file tools share a path-based public ABI; payload-borne paths (`edit_batch` entries, `apply_patch` targets) miss the dispatch seam that rewrites a `path` ARG, so both ends canonicalize through `tool_access.canonical_repo_relative_path` — one normalization contract keeps a guard from judging `repo/BIBLE.md` while the write lands on `BIBLE.md` — and `_ROOT_ARG_REPO_WRITE_TOOLS` is the single set every repo-write fence keys on. `verify_and_record` (`tools/verify.py`) runs the declared check through the SAME pre-execution guards as the process tools — a post-execution check cannot gate a receipt already written — and appends a host-attested receipt to `<drive_root>/task_results/artifacts/<task_id>/verification_receipts.jsonl` (`expected_match`: substring by default, `exact`, `exact_line`, `json_equals`, `bytes_equal`); it reads only public task info, reports an owner-settings change as a typed note and never auto-reverts it (a revert cannot prove causation), and `delegation_zero_run` writes only `incomplete`/`unknown`, after the custody scan proves no open run.
|
||||
Tool API v2 exposes neutral canonical names directly (`read_file`, `list_files`, `search_code`, `write_file`, `edit_text`, `edit_batch`, `apply_patch`, `run_command`, `run_script`, `verify_and_record`, service tools, `commit_reviewed`, `vcs_*`, `schedule_subagent`, `schedule_followup`, `wait_task`, `wait_tasks`); legacy public names are neither exposed nor translated. `search_code` uses one basename-glob selector, including comma brace alternatives, before ripgrep or the Python fallback reads allowed files; selected-file count and content matches remain separate facts. The file tools share a path-based public ABI; payload-borne paths (`edit_batch` entries, `apply_patch` targets) miss the dispatch seam that rewrites a `path` ARG, so both ends canonicalize through `tool_access.canonical_repo_relative_path` — one normalization contract keeps a guard from judging `repo/BIBLE.md` while the write lands on `BIBLE.md` — and `_ROOT_ARG_REPO_WRITE_TOOLS` is the single set every repo-write fence keys on. `verify_and_record` (`tools/verify.py`) runs the declared check through the SAME pre-execution guards as the process tools — a post-execution check cannot gate a receipt already written — and appends a host-attested receipt to `<drive_root>/task_results/artifacts/<task_id>/verification_receipts.jsonl` (`expected_match`: substring by default, `exact`, `exact_line`, `json_equals`, `bytes_equal`); it reads only public task info, reports an owner-settings change as a typed note and never auto-reverts it (a revert cannot prove causation), and `delegation_zero_run` writes only `incomplete`/`unknown`, after the custody scan proves no open run.
|
||||
|
||||
#### Resource roots and physical file identity
|
||||
|
||||
Filesystem tool output is self-locating: results use canonical `root:path` labels and `run_command`/`run_script` echo the resolved `cwd`. A direct Project room selects one active physical folder for reads, writes, editing, process cwd, VCS and delegation; governance stays at `system_repo`, and a missing folder keeps its selected address with an availability note rather than falling back to Ouroboros source. Plain folders support ordinary file/process work and directory delegation (`delegate_directory.py`); Git-only snapshot and integration paths retain typed failures when their selected target lacks Git. Physical file resolution preserves the requested address: an absolute path inside any selected base normalizes to that base, an outside absolute path is refused before `safe_relpath` can turn it into a similarly named file, and the repo basename-prefix and canonical delegated-artifact read redirects sit on the same resolver guards and handlers use (`ToolContext.repo_path`/`drive_path`). A Docker workspace's mapped backend absolute address (`workspace_executor.map_backend_path`) is accepted only inside `active_workspace`; other roots and unmapped absolute paths keep their confinement.
|
||||
|
||||
`user_files` is the first-class root for user-visible files under the owner's home (the Ouroboros repo and runtime control-plane are rejected); `task_drive` is task-scoped scratch; `artifact_store` is task-scoped under `data/task_results/artifacts/<task_id>/`, where external deliverables written through `user_files` or declared process `outputs` are copied for audit, and a rewritten user-visible file keeps its previous copy under `task_results/artifact_versions/<task_id>/` (§1 tree). Two READ-ONLY orchestrator roots, `subagent_projects` and `deliverables`, grant `read`/`list`/`search` only to orchestrator profiles (parent synthesis); a top-level task still writes the physical Deliverables container through `user_files`. Process admission preserves the original argv and the prepared physical target, and that one resource binding is reused for every other authorized destination; command words, script examples and unknown interpreter effects do not establish write intent — the selected Safety Supervisor receives the full task source (§6 Safety and runtime mode) — and the post-execution shell audit is observational: no automatic replay or rollback, no interpreter or attribution proof. Computed targets remain a disclosed parser limit, not grounds for a new semantic scanner.
|
||||
`user_files` is the first-class root for user-visible files under the owner's home (the Ouroboros repo and runtime control-plane are rejected); `task_drive` is task-scoped scratch; `artifact_store` is task-scoped under `data/task_results/artifacts/<task_id>/`, created lazily by a write or output registration, where external deliverables written through `user_files` or declared process `outputs` are copied for audit, and a rewritten user-visible file keeps its previous copy under `task_results/artifact_versions/<task_id>/` (§1 tree). Two READ-ONLY orchestrator roots, `subagent_projects` and `deliverables`, grant `read`/`list`/`search` only to orchestrator profiles (parent synthesis); a top-level task still writes the physical Deliverables container through `user_files`. Process admission preserves the original argv and the prepared physical target, and that one resource binding is reused for every other authorized destination; command words, script examples and unknown interpreter effects do not establish write intent — the selected Safety Supervisor receives the full task source (§6 Safety and runtime mode) — and the post-execution shell audit is observational: no automatic replay or rollback, no interpreter or attribution proof. Computed targets remain a disclosed parser limit, not grounds for a new semantic scanner.
|
||||
|
||||
#### Credential fence and byte masking
|
||||
|
||||
|
|
@ -165,13 +165,15 @@ Runtime mode is effective Access and a self-modification ladder. Light refuses m
|
|||
|
||||
Process admission preserves the original argv and the resolved cwd. Command words, quoted source examples and unknown interpreter effects are not proof of a forbidden action: `shell_parse.py` and `tools/write_shape.py` supply observed targets and syntax facts, never permission judgments. The Safety Supervisor (`safety.py`) judges each call from the complete retained initial and later owner directives, the task contract, the task constraint and the prepared resource target; the bounded newest-first recent-conversation view (`_SAFETY_CONTEXT_CHAR_BUDGET`, omission marker reserved inside the budget) supplements that source and never replaces it. Coverage is `OUROBOROS_SAFETY_MODE` Full/Light/Off over each tool's `TOOL_POLICY` entry (`skip`, `check`, `check_conditional`; an unknown tool falls to `DEFAULT_POLICY` = `check`): policy-skip tools and established safe conditional subjects make no call in any mode, Light additionally skips conditional checks, Off skips every Supervisor call, and every waved-through check leaves a durable `safety_mode_skip` event. `full → light → off` is a decreasing coverage ladder, so outside Cyber Pro a downward step is owner-only (`POST /api/owner/safety-mode`). A selected assessment runs on the light-model route. No second consent store, appeal pass or repeated execution exists.
|
||||
|
||||
Foreground commands, scripts and run-kind verification accept `env_from_settings` through the same Settings-selection authority as service startup. `tools/process_facts.py` resolves references once after admission and masks diagnostics before result and receipt persistence. Foreground execution keeps its inherited environment; Docker transports target values through inert host aliases, so target PATH or DOCKER_HOST cannot reconfigure the host client. Verification matches raw output before masking. Python selection uses the effective child environment without a version probe; wrapper and non-local backend resolution remain unknown when not established. The existing Node health check runs after gates with the selected PATH.
|
||||
|
||||
#### Safety Supervisor outcomes
|
||||
|
||||
The first line of a Supervisor result is the typed outcome. SAFE passes silently; SUSPICIOUS allows the call with `SAFETY_WARNING`; DANGEROUS, an unrecognised status, a failed check and an answer still unparseable after one repair retry (`safety_parse_retry`, `safety_parse_failed`) block with `SAFETY_VIOLATION`. Outside Cyber that warning/refusal stands; Cyber records a negative or unavailable assessment as advice (`SAFETY_ADVICE`, durable `safety_advisory` event) without manufacturing SAFE. A provider 429 is an infrastructure fact about the Supervisor, not a verdict about the call: after one deadline-capped retry the call is blocked with the typed non-verdict `SAFETY_UNAVAILABLE`, which tells the agent to retry the same call rather than reword it, and a confirmed storm arms a process-local latch (`_SAFETY_STORM_COOLDOWN_SEC`) that answers in-window checks without a provider call, because re-probing from the highest-frequency light-model consumer would amplify the storm it reports; each is a durable `safety_check_rate_limited` event, while a structured insufficient-quota refusal stays PERMANENT and blocks as a verdict. The serialized subject has its own 250,000-character budget (`_SAFETY_SUBJECT_CHAR_BUDGET`, rendered `ensure_ascii=False`): an over-budget subject is refused fail-closed with `SAFETY_SUBJECT_TOO_LARGE_BLOCKED` plus a durable `safety_subject_too_large` event — never truncated, because anything past a cut would run unreviewed. Only the no-backend cases fail open with `SAFETY_WARNING` (no provider key and no local lane; a remote key that does not cover the light model's provider, with no local lane; a local runtime chosen as FALLBACK that fails or is rate-limited), so a misconfigured runtime does not hard-block every unknown tool; an explicitly primary local runtime (`USE_LOCAL_LIGHT`) that fails is a real `SAFETY_VIOLATION`.
|
||||
|
||||
Registry dispatch passes its prepared binding into `start_service`; a direct handler consults the same Supervisor only when no prepared registry binding was supplied. Each admitted process effect executes once; post-execution settings/repository observations annotate the original result and never repeat or roll back the operation.
|
||||
|
||||
Outside Cyber Pro, read-only shell git is allowed everywhere; mutating shell git only when its resolved target lies outside the Ouroboros system repository and runtime data drives (`git_shell_policy.py`; network-disabled tasks still fence network git). Acting `self_worktree` children remain read-only because patch capture requires an unmoved HEAD. `git init`/`git clone` are judged by destination, relative destinations and path-valued retargeting flags included; `resolve_shell_cwd` supplies the same canonical cwd to target checks and execution, and command text is not an independent runtime-file permission classifier. The generic Tool API VCS family (`vcs_status`, `vcs_diff`, `vcs_pull_ff`, `vcs_restore`, `vcs_revert`) defaults to `root=active_workspace` and accepts explicit `root=system_repo`; protected Ouroboros path names constrain generic restore/revert only on the explicit system target, so a project's own `BIBLE.md` or `contracts/` remains ordinary project content, while `preflight_review`, `commit_reviewed`/`vcs_commit_reviewed`, `vcs_rollback` and promotion remain system-repository lifecycles.
|
||||
Outside Cyber Pro, read-only shell git is allowed everywhere; mutating shell git only when its resolved target lies outside the Ouroboros system repository and runtime data drives (`git_shell_policy.py`; network-disabled tasks still fence network git). Acting `self_worktree` children remain read-only because patch capture requires an unmoved HEAD. `git init`/`git clone` are judged by destination, relative destinations and path-valued retargeting flags included; `resolve_shell_cwd` supplies the same canonical cwd to target checks and execution, and command text is not an independent runtime-file permission classifier. The generic Tool API VCS family (`vcs_status`, `vcs_diff`, `vcs_pull_ff`, `vcs_restore`, `vcs_revert`) defaults to `root=active_workspace` and accepts explicit `root=system_repo`; protected Ouroboros path names constrain generic restore/revert only on the explicit system target, so a project's own `BIBLE.md` or `contracts/` remains ordinary project content, while `preflight_review`, `commit_reviewed`/`vcs_commit_reviewed`, `vcs_rollback` and promotion remain system-repository lifecycles. `vcs_diff` without refs retains unstaged/staged behavior; `base` compares a resolved local tree to the worktree or index, while `base` plus `head` compares two local trees. Results name requested refs, exact tree IDs and comparison kind, without fetching or implying a merge-base comparison.
|
||||
|
||||
Outside Cyber Pro, a task contract may declare `resource_policy.protected_artifacts[]` as execute-only black boxes (`protected_artifacts.py`): registry guards allow the declared execution but refuse reads, copies, hashes, static inspection and trace/debug wrappers over those paths. Light-mode cognitive writes are redirected to `update_identity`, `update_scratchpad` or `knowledge_write` instead of raw memory-file edits; a corrected cognitive redirect is advisory, while an ignored user-file root correction remains a blocking deliverable failure.
|
||||
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ and 23 (`delegated_transport`), both critical. The imperatives:
|
|||
- The parent is the SOLE committer of the live body: acting children return a
|
||||
`workspace.patch`, the parent applies through `integrate_subagent_patch` and
|
||||
its own `commit_reviewed`; `external_workspace` verifies and records without
|
||||
re-applying; a genesis project is durable because its directory IS the
|
||||
re-applying. Edit/capture text preserves external Git authority and patch-only `self_worktree`. Capture bases prove no authorship; compare via explicit `vcs_diff`. A genesis project is durable because its directory IS the
|
||||
deliverable (disclosed residual: until it declares a `.gitignore`, small
|
||||
text build output rides the `workspace.patch` record, bounded only by the
|
||||
per-file source-patch boundary and git's binary verdict; there is no total
|
||||
|
|
@ -365,7 +365,7 @@ The imperatives:
|
|||
- `wait_task` and `wait_tasks` also peek the waiting actor's own mailbox (its
|
||||
execution drive, not its budget root) through the existing transport-wait
|
||||
reader: a pending message returns control without acknowledging it or
|
||||
stopping children — the round-top drain delivers and acknowledges it. One
|
||||
stopping children — the round-top drain delivers and acknowledges it. Both waits disclose this early return. One
|
||||
episode may retain only a PROVED empty mailbox (fingerprints compared before
|
||||
and after the full reader); a read failure or torn data is never proof and
|
||||
is never cached; no TTL and no ACK in peek.
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ This chapter owns the boundary for configured MCP servers: the base runtime is a
|
|||
|
||||
The base runtime is an optional CLIENT for trusted HTTP/SSE and local stdio MCP servers — never an MCP server (structure, transport validation, the stdio `command`/`args`/`cwd`/`env`/`env_from_settings` contract and the masking rule: ARCHITECTURE "MCP and browser-facing external tools"; `ouroboros/mcp_client.py`). MCP descriptions and results are untrusted data, not policy: configuration trust must not turn remote prose into policy. Enabled tools join the initial capability envelope and still pass runtime safety and the caller's ordinary capability ceiling; a discovery failure becomes a visible capability omission. The owner chooses MCP references in Settings — a caller's existing MCP tool grant does not authorize new references. Executable schema properties, required fields, enum and default values stay intact. Resources, prompts and MCP server behavior remain separate architecture changes. Enforcement: `tests/test_mcp_client.py`, `tests/test_process_environment.py`.
|
||||
|
||||
`start_service` overlays ordinary literal `env` on the existing minimal host baseline, preserving the permitted host CLI configuration; root tasks can additionally choose `env_from_settings` through their host-resolved process authority, while restricted and Presence tasks receive no new Settings-selection authority (their previous literal env and configured MCP access remain), and skill grants do not authorize an unrelated service. Both service backends and MCP reuse `workspace_executor.resolve_process_env`; referenced Settings fields retain the same secret/ordinary classification, cwd still uses the host-owned resource binding, and local import scrubbing and interpreter defaults remain. Docker forwards values through inert CLI environment aliases and restores the selected names inside the container. Values do not enter host argv or generated shell source. Service diagnostics and finalized log blobs mask referenced secrets (ordinary PORT/PATH/DEBUG values, protocol identity/state and executable schema stay intact); the executor's record-stop owner finalizes local logs through the existing service log owner before forgetting the in-memory selections, an unconfirmed termination retains the existing record for a later cleanup, and live or worker-loss-surviving logs keep the private raw-log contract until successful finalization. Oversized or uncapturable log finalization keeps its explicit omission/error report. No secret values enter the durable process ledger. Enforcement: `tests/test_process_environment.py`, `tests/test_workspace_executor_services.py`.
|
||||
`start_service` overlays ordinary literal `env` on the existing minimal host baseline, preserving the permitted host CLI configuration; root tasks can additionally choose `env_from_settings` through their host-resolved process authority, while restricted and Presence tasks receive no new Settings-selection authority (their previous literal env and configured MCP access remain), and skill grants do not authorize an unrelated service. Commands, scripts and run-kind verification reuse this authority with inherited env. Resolve once after admission for launch/provenance; match raw output before masking receipts. Omission preserves behavior; no new literal-env input or Python probe. Both service backends and MCP reuse `workspace_executor.resolve_process_env`; referenced Settings fields retain the same secret/ordinary classification, cwd still uses the host-owned resource binding, and local import scrubbing and interpreter defaults remain. Docker forwards values through inert CLI environment aliases and restores the selected names inside the container. Values do not enter host argv or generated shell source. Service diagnostics and finalized log blobs mask referenced secrets (ordinary PORT/PATH/DEBUG values, protocol identity/state and executable schema stay intact); the executor's record-stop owner finalizes local logs through the existing service log owner before forgetting the in-memory selections, an unconfirmed termination retains the existing record for a later cleanup, and live or worker-loss-surviving logs keep the private raw-log contract until successful finalization. Oversized or uncapturable log finalization keeps its explicit omission/error report. No secret values enter the durable process ledger. Enforcement: `tests/test_process_environment.py`, `tests/test_workspace_executor_services.py`, `tests/test_workflow_process_environment.py`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
|
||||
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 581-670; UTF-8 SHA-256 `143605a7561d5e93a9c5941929b83ab90e0a90e675257871fd7bd09b0760f999`.
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 581-670; UTF-8 SHA-256 `917b6786e18c299dbf55987239ef72d73cd16d58537e4888f7aabb110b5d7e5e`.
|
||||
|
||||
- entries: **78** (code-ref: 71, repo-dir: 6, repo-path: 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from <population module> import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file.
|
||||
|
||||
- facade modules: **58**; marked re-export bindings: **2272**; cross-domain facade→leaf pairs: **130**
|
||||
- facade modules: **58**; marked re-export bindings: **2276**; cross-domain facade→leaf pairs: **130**
|
||||
|
||||
| facade | domain | bindings | leaves |
|
||||
|---|---|---:|---|
|
||||
|
|
@ -39,7 +39,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `ouroboros/tool_access.py` | D04 | 43 | `ouroboros/contracts/task_constraint.py` (2 ✗D19)<br>`ouroboros/tool_access_paths.py` (10)<br>`ouroboros/tool_access_roots.py` (9)<br>`ouroboros/tool_access_types.py` (15)<br>`ouroboros/tool_access_user_files.py` (5)<br>`ouroboros/tool_capabilities.py` (2) |
|
||||
| `ouroboros/tools/claude_advisory_review.py` | D06 | 50 | `ouroboros/commit_admission.py` (3)<br>`ouroboros/config.py` (1 ✗D12)<br>`ouroboros/deadline_utils.py` (1 ✗D01)<br>`ouroboros/skill_review_status.py` (1 ✗D14)<br>`ouroboros/tools/preflight_review_prompt.py` (7)<br>`ouroboros/tools/preflight_review_run.py` (19)<br>`ouroboros/tools/review_helpers.py` (16)<br>`ouroboros/triad_review.py` (2) |
|
||||
| `ouroboros/tools/control.py` | D08 | 111 | `ouroboros/config.py` (4 ✗D12)<br>`ouroboros/contracts/task_contract.py` (3 ✗D19)<br>`ouroboros/depth_evidence.py` (1 ✗D07)<br>`ouroboros/headless.py` (2 ✗D17)<br>`ouroboros/outcomes.py` (1 ✗D01)<br>`ouroboros/subagent_runtime.py` (3 ✗D07)<br>`ouroboros/subagents.py` (2 ✗D07)<br>`ouroboros/task_results.py` (5 ✗D17)<br>`ouroboros/task_status.py` (2 ✗D17)<br>`ouroboros/tool_capabilities.py` (2 ✗D04)<br>`ouroboros/tools/control_delegation.py` (8 ✗D07)<br>`ouroboros/tools/control_events.py` (9)<br>`ouroboros/tools/control_routing.py` (10)<br>`ouroboros/tools/control_runtime.py` (13)<br>`ouroboros/tools/control_scheduling.py` (18 ✗D07)<br>`ouroboros/tools/control_subagent_spec.py` (6 ✗D07)<br>`ouroboros/tools/control_task_results.py` (13 ✗D07)<br>`ouroboros/tools/registry.py` (4 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core.py` | D05 | 84 | `ouroboros/code_search_rg.py` (4)<br>`ouroboros/contracts/skill_payload_policy.py` (13 ✗D19)<br>`ouroboros/project_facts.py` (1 ✗D15)<br>`ouroboros/tool_access.py` (9 ✗D04)<br>`ouroboros/tools/core_artifacts.py` (17)<br>`ouroboros/tools/core_file_tools.py` (32)<br>`ouroboros/tools/registry.py` (3 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core.py` | D05 | 85 | `ouroboros/code_search_rg.py` (5)<br>`ouroboros/contracts/skill_payload_policy.py` (13 ✗D19)<br>`ouroboros/project_facts.py` (1 ✗D15)<br>`ouroboros/tool_access.py` (9 ✗D04)<br>`ouroboros/tools/core_artifacts.py` (17)<br>`ouroboros/tools/core_file_tools.py` (32)<br>`ouroboros/tools/registry.py` (3 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core_file_tools.py` | D05 | 8 | `ouroboros/credential_shapes.py` (1 ✗D13)<br>`ouroboros/tools/core_secret_paths.py` (7) |
|
||||
| `ouroboros/tools/delegate.py` | D07 | 62 | `ouroboros/delegate_containment.py` (5)<br>`ouroboros/delegate_interactions.py` (8)<br>`ouroboros/delegate_output.py` (14)<br>`ouroboros/delegate_shared.py` (6)<br>`ouroboros/delegate_source_coverage.py` (3)<br>`ouroboros/subagent_runtime.py` (2)<br>`ouroboros/subagent_work_order.py` (1)<br>`ouroboros/tools/delegate_integration.py` (14)<br>`ouroboros/tools/delegate_terminal_evidence.py` (9) |
|
||||
| `ouroboros/tools/delegate_integration.py` | D07 | 7 | `ouroboros/tools/delegate_payload_patch.py` (7) |
|
||||
|
|
@ -50,7 +50,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `ouroboros/tools/review.py` | D06 | 47 | `ouroboros/llm.py` (1 ✗D02)<br>`ouroboros/review_substrate.py` (3)<br>`ouroboros/reviewer_window.py` (2)<br>`ouroboros/tools/review_helpers.py` (21)<br>`ouroboros/tools/review_multi_model.py` (8)<br>`ouroboros/tools/review_response.py` (2)<br>`ouroboros/tools/review_synthesis.py` (1)<br>`ouroboros/triad_review.py` (4)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/review_helpers.py` | D06 | 57 | `ouroboros/tools/release_sync.py` (1 ✗D10)<br>`ouroboros/tools/review_file_pack.py` (26)<br>`ouroboros/tools/review_prompt_text.py` (27)<br>`ouroboros/utils.py` (3 ✗D18) |
|
||||
| `ouroboros/tools/scope_review.py` | D06 | 2 | `ouroboros/review_substrate.py` (2) |
|
||||
| `ouroboros/tools/shell.py` | D05 | 69 | `ouroboros/artifacts.py` (3)<br>`ouroboros/config.py` (2 ✗D12)<br>`ouroboros/deadline_utils.py` (1 ✗D01)<br>`ouroboros/platform_layer.py` (4 ✗D18)<br>`ouroboros/runtime_mode_policy.py` (1 ✗D13)<br>`ouroboros/shell_parse.py` (3 ✗D13)<br>`ouroboros/tool_access.py` (10 ✗D04)<br>`ouroboros/tools/deliverables_shell.py` (1 ✗D13)<br>`ouroboros/tools/process_facts.py` (2 ✗D04)<br>`ouroboros/tools/shell_audit.py` (5)<br>`ouroboros/tools/shell_effects.py` (12)<br>`ouroboros/tools/shell_outputs.py` (10)<br>`ouroboros/tools/shell_process.py` (11)<br>`ouroboros/tools/verify.py` (1)<br>`ouroboros/utils.py` (1 ✗D18)<br>`ouroboros/workspace_executor.py` (2 ✗D17) |
|
||||
| `ouroboros/tools/shell.py` | D05 | 72 | `ouroboros/artifacts.py` (3)<br>`ouroboros/config.py` (2 ✗D12)<br>`ouroboros/deadline_utils.py` (1 ✗D01)<br>`ouroboros/platform_layer.py` (4 ✗D18)<br>`ouroboros/runtime_mode_policy.py` (1 ✗D13)<br>`ouroboros/shell_parse.py` (3 ✗D13)<br>`ouroboros/tool_access.py` (10 ✗D04)<br>`ouroboros/tools/deliverables_shell.py` (1 ✗D13)<br>`ouroboros/tools/process_facts.py` (5 ✗D04)<br>`ouroboros/tools/shell_audit.py` (5)<br>`ouroboros/tools/shell_effects.py` (12)<br>`ouroboros/tools/shell_outputs.py` (10)<br>`ouroboros/tools/shell_process.py` (11)<br>`ouroboros/tools/verify.py` (1)<br>`ouroboros/utils.py` (1 ✗D18)<br>`ouroboros/workspace_executor.py` (2 ✗D17) |
|
||||
| `ouroboros/tools/shell_guards.py` | D13 | 14 | `ouroboros/tools/write_shape.py` (14) |
|
||||
| `ouroboros/tools/shell_process.py` | D05 | 1 | `ouroboros/tools/process_facts.py` (1 ✗D04) |
|
||||
| `ouroboros/tools/subagent_integration.py` | D07 | 13 | `ouroboros/headless.py` (2 ✗D17)<br>`ouroboros/tools/subagent_integration_delegated.py` (11) |
|
||||
|
|
|
|||
|
|
@ -45,6 +45,28 @@ def _search_wall_clock_sec() -> float:
|
|||
return get_search_code_wall_sec()
|
||||
|
||||
|
||||
def matches_include(name: str, pattern: str) -> bool:
|
||||
"""Shared basename fnmatch syntax plus comma brace alternatives.
|
||||
|
||||
Unbalanced braces and braces without commas remain literal. Resource
|
||||
policy is independent and still runs before any content read.
|
||||
"""
|
||||
if not pattern:
|
||||
return True
|
||||
opening = pattern.find("{")
|
||||
while opening >= 0:
|
||||
closing = pattern.find("}", opening + 1)
|
||||
if closing < 0:
|
||||
break
|
||||
nested = pattern.rfind("{", opening, closing)
|
||||
choices = pattern[nested + 1:closing].split(",")
|
||||
if len(choices) > 1:
|
||||
return any(matches_include(name, pattern[:nested] + choice + pattern[closing + 1:])
|
||||
for choice in choices)
|
||||
opening = pattern.find("{", closing + 1)
|
||||
return fnmatch.fnmatch(name, pattern)
|
||||
|
||||
|
||||
def search_skip_reason(path: pathlib.Path) -> str:
|
||||
"""Typed reason a path is not searchable, or ``""`` (D3, capinv-447).
|
||||
|
||||
|
|
@ -109,6 +131,7 @@ class RgSearchResult(NamedTuple):
|
|||
truncated: bool
|
||||
file_capped: bool
|
||||
deadline_hit: bool = False
|
||||
files_selected: int | None = None
|
||||
|
||||
|
||||
def _rg_binary() -> str:
|
||||
|
|
@ -140,8 +163,6 @@ def search_with_rg(
|
|||
base_cmd = [rg, "--json", "--line-number", "--color", "never"]
|
||||
if not regex:
|
||||
base_cmd.append("--fixed-strings")
|
||||
if include:
|
||||
base_cmd.extend(["--glob", include])
|
||||
|
||||
# Build an EXPLICIT, gated file list and hand it to rg. Pre-filtering each
|
||||
# path through ``path_allowed`` (the protected/secret/skippable gate) BEFORE
|
||||
|
|
@ -156,7 +177,8 @@ def search_with_rg(
|
|||
capped = False
|
||||
deadline_hit = False
|
||||
if isinstance(search_targets, list):
|
||||
targets = [p for p in search_targets if path_allowed is None or path_allowed(p)]
|
||||
targets = [p for p in search_targets if matches_include(p.name, include)
|
||||
and (path_allowed is None or path_allowed(p))]
|
||||
elif search_targets.is_dir():
|
||||
try:
|
||||
from ouroboros.code_intelligence import SKIP_DIRS
|
||||
|
|
@ -169,7 +191,7 @@ def search_with_rg(
|
|||
break
|
||||
dirnames[:] = [n for n in sorted(dirnames) if n not in SKIP_DIRS]
|
||||
for fname in sorted(filenames):
|
||||
if include and not fnmatch.fnmatch(fname, include):
|
||||
if not matches_include(fname, include):
|
||||
continue
|
||||
path = pathlib.Path(dirpath) / fname
|
||||
if path_allowed is not None and not path_allowed(path):
|
||||
|
|
@ -181,9 +203,10 @@ def search_with_rg(
|
|||
if capped:
|
||||
break
|
||||
else:
|
||||
targets = [search_targets] if path_allowed is None or path_allowed(search_targets) else []
|
||||
targets = ([search_targets] if matches_include(search_targets.name, include)
|
||||
and (path_allowed is None or path_allowed(search_targets)) else [])
|
||||
if not targets:
|
||||
return RgSearchResult([], False, capped, deadline_hit)
|
||||
return RgSearchResult([], False, capped, deadline_hit, 0)
|
||||
|
||||
# Pack targets into batches bounded by total argv LENGTH (not a fixed count),
|
||||
# so N long paths cannot overflow the OS command-line limit. Always keep at
|
||||
|
|
@ -243,7 +266,7 @@ def search_with_rg(
|
|||
break
|
||||
# Return result-cap, file-scan-cap, and deadline SEPARATELY so the caller can tell
|
||||
# an honest "no matches" from "scan/time stopped before the whole tree was seen".
|
||||
return RgSearchResult(matches, result_truncated, capped, deadline_hit)
|
||||
return RgSearchResult(matches, result_truncated, capped, deadline_hit, len(targets))
|
||||
|
||||
|
||||
def format_dropped_files_note(dropped: dict[str, int] | None) -> str:
|
||||
|
|
@ -286,13 +309,16 @@ def format_search_result(
|
|||
if deadline_hit else ""
|
||||
)
|
||||
dropped_note = format_dropped_files_note(dropped)
|
||||
selection_note = (f" {result.files_selected} file(s) selected by the include mask and resource filters."
|
||||
if result.files_selected is not None else "")
|
||||
rendered = [f"{root_name}:{m.path.relative_to(root_path).as_posix()}:{m.line}: {m.text}" for m in matches]
|
||||
if not rendered:
|
||||
# Surface the file-scan cap, the deadline, AND the filter receipt here: a
|
||||
# capped/timed-out/filtered huge-root search with zero matches must never
|
||||
# look like a clean "no matches" (the misleading case).
|
||||
return f"No matches found for {'regex' if regex else 'literal'} `{query}` in {display_path} (ripgrep).{cap_note}{deadline_note}{dropped_note}"
|
||||
return f"No matches found for {'regex' if regex else 'literal'} `{query}` in {display_path} (ripgrep).{selection_note}{cap_note}{deadline_note}{dropped_note}"
|
||||
header = f"Found {len(rendered)} match{'es' if len(rendered) != 1 else ''} in {display_path} (ripgrep)"
|
||||
header += selection_note
|
||||
if truncated:
|
||||
header += f" — truncated at {max_results} results"
|
||||
if file_capped:
|
||||
|
|
|
|||
|
|
@ -838,7 +838,6 @@ lazy_only = [
|
|||
"D05->D06",
|
||||
"D05->D07",
|
||||
"D05->D14",
|
||||
"D05->D20",
|
||||
"D06->D03",
|
||||
"D06->D05",
|
||||
"D06->D08",
|
||||
|
|
|
|||
|
|
@ -598,7 +598,8 @@ def resolve_process_node(
|
|||
# idempotent bootstrap; whoever runs first performs the one mutation). The
|
||||
# snapshot is the base of any attested child-env PATH prepend.
|
||||
bootstrap_process_path()
|
||||
path_snapshot = str(os.environ.get("PATH", "") or "")
|
||||
from ouroboros.tools.process_facts import process_path_for_cwd, selected_process_environment
|
||||
path_snapshot = str(selected_process_environment().get("PATH", os.environ.get("PATH", "")) or "")
|
||||
|
||||
constraint = normalize_task_constraint(effective_constraint)
|
||||
cwd_text = str(original.get("cwd") or "")
|
||||
|
|
@ -651,12 +652,11 @@ def resolve_process_node(
|
|||
|
||||
surface = _surface_for(ctx, binding, constraint)
|
||||
probe_token = requested if trigger == "runtime" else "node"
|
||||
located = shutil.which(probe_token) or ""
|
||||
located = shutil.which(probe_token, path=process_path_for_cwd(path_snapshot, work_dir)) or ""
|
||||
if located and not os.path.isabs(located):
|
||||
# A relative PATH entry resolves against the WORKER cwd here but against
|
||||
# the command's work_dir at exec time: neither health nor brokenness is
|
||||
# provable from this process, so never substitute on that evidence —
|
||||
# run as written (argv and child env stay byte-identical) (T10).
|
||||
# Search entries are bound to the launch cwd above. If a platform still
|
||||
# cannot establish an absolute candidate, retain the as-written launch
|
||||
# rather than substituting a runtime on unprovable evidence (T10).
|
||||
trace = _node_trace(
|
||||
tool=name,
|
||||
requested_interpreter=requested,
|
||||
|
|
@ -894,7 +894,8 @@ def record_interpreter_resolution(ctx: Any, trace: Optional[InterpreterResolutio
|
|||
log_dir = pathlib.Path(drive_logs())
|
||||
else:
|
||||
log_dir = pathlib.Path(getattr(ctx, "drive_root")) / "logs"
|
||||
append_jsonl(log_dir / "events.jsonl", event)
|
||||
from ouroboros.tools.process_facts import redact_process_data
|
||||
append_jsonl(log_dir / "events.jsonl", redact_process_data(event))
|
||||
except Exception:
|
||||
# Trace persistence must not make an otherwise-valid process call fail.
|
||||
return
|
||||
|
|
|
|||
|
|
@ -454,6 +454,14 @@ def cache_horizon_note(ctx: Any, elapsed_sec: Any) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _wait_early_return_note(early) -> str:
|
||||
if not early:
|
||||
return ""
|
||||
if early.get("reason") == "owner_mailbox_pending":
|
||||
return "The ordinary loop will deliver and acknowledge the unread message. This does not stop the child."
|
||||
return "A child attention beacon interrupted this wait. Inspect early_return; the children keep running."
|
||||
|
||||
|
||||
def _wait_for_task(
|
||||
ctx: ToolContext, task_id: str, timeout_sec: int = 180, known_result_sha256: str = "",
|
||||
) -> str:
|
||||
|
|
@ -478,7 +486,7 @@ def _wait_for_task(
|
|||
early = waited.get("early_return")
|
||||
if early and early.get("reason") == "owner_mailbox_pending":
|
||||
header = "Task wait interrupted by an unread message for this task"
|
||||
extra = "\n\nThe ordinary loop will deliver and acknowledge the message. This does not stop the child."
|
||||
extra = "\n\n" + _wait_early_return_note(early)
|
||||
elif early:
|
||||
header = "Task wait interrupted by a child attention beacon"
|
||||
extra = f"\n\n[CHILD_BEACONS]\n{json.dumps(early, ensure_ascii=False, indent=2)}\n[/CHILD_BEACONS]"
|
||||
|
|
@ -879,6 +887,8 @@ def _wait_for_tasks(
|
|||
"max_timeout_sec": float(_WAIT_TASKS_CLAMP_SEC),
|
||||
"live_task_ids": live_ids,
|
||||
}
|
||||
if note := _wait_early_return_note(waited.get("early_return")):
|
||||
waited["early_return_note"] = note
|
||||
horizon_note = cache_horizon_note(ctx, waited.get("elapsed_sec"))
|
||||
if horizon_note:
|
||||
waited["cache_horizon_note"] = horizon_note
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
from ouroboros.tools.tool_result import ToolResult, _publish_tool_result
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
|
@ -860,7 +859,7 @@ from ouroboros.code_search_rg import ( # noqa: E402
|
|||
MAX_SEARCH_FILES_SCANNED as _MAX_SEARCH_FILES_SCANNED,
|
||||
_search_wall_clock_sec,
|
||||
is_search_skippable as _is_search_skippable, # noqa: F401 — re-exported for tests/call sites
|
||||
search_skip_reason as _search_skip_reason,
|
||||
search_skip_reason as _search_skip_reason, matches_include,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1071,7 +1070,7 @@ def _code_search(ctx: ToolContext, query: str, path: str = ".",
|
|||
for fname in sorted(filenames):
|
||||
fp = pathlib.Path(dirpath) / fname
|
||||
|
||||
if include and not fnmatch.fnmatch(fname, include):
|
||||
if not matches_include(fname, include):
|
||||
continue
|
||||
|
||||
if subagent_readonly and _local_readonly_resource_block(ctx, normalized, fp, root_path, action="SEARCH", secret_check=secret_check):
|
||||
|
|
@ -1330,6 +1329,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
ToolEntry("write_file", {
|
||||
"name": "write_file",
|
||||
"description": (
|
||||
"For canonical output use root=artifact_store (created lazily), e.g. path=report.txt. Do not assume its physical directory already exists. "
|
||||
"Write UTF-8 file(s) to a declared resource root. "
|
||||
"Default root=active_workspace. "
|
||||
"OK messages show root:path. "
|
||||
|
|
@ -1444,7 +1444,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"skill_name": {"type": "string", "description": "Required only for root=skill_payload."},
|
||||
"regex": {"type": "boolean", "default": False, "description": "Treat query as a regular expression"},
|
||||
"max_results": {"type": "integer", "default": 200, "description": "Maximum number of matches to return (max 200)"},
|
||||
"include": {"type": "string", "default": "", "description": "Filter by glob pattern (e.g. '*.py')"},
|
||||
"include": {"type": "string", "default": "", "description": "Basename glob, including brace alternatives (e.g. '*.py', '*.{js,css}'); applies on every backend"},
|
||||
}, "required": ["query"]},
|
||||
}, _code_search),
|
||||
ToolEntry("escalate", {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,16 @@ def _runtime_mode() -> str:
|
|||
return "advanced"
|
||||
|
||||
|
||||
def workspace_edit_note(ctx: ToolContext) -> str:
|
||||
"""Explain capture without inventing broader Git authority."""
|
||||
from ouroboros.contracts.task_constraint import normalize_task_constraint
|
||||
|
||||
constraint = normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
if constraint is not None and constraint.surface == "self_worktree":
|
||||
return "Do not commit this self-worktree; return the captured patch for parent integration."
|
||||
return "The headless runner captures a workspace patch; it is not proof of Git commit or publication."
|
||||
|
||||
|
||||
def _finish_mutation(
|
||||
ctx: ToolContext,
|
||||
changed_paths: List[str],
|
||||
|
|
@ -192,7 +202,7 @@ def _finish_mutation(
|
|||
return capture_note
|
||||
footer = "Files are on disk but NOT committed."
|
||||
if ctx.is_workspace_mode():
|
||||
footer += " Do not commit; the headless runner will emit a patch artifact."
|
||||
footer += " " + workspace_edit_note(ctx)
|
||||
return footer
|
||||
footer = (
|
||||
"Files are on disk but NOT committed. Run commit_reviewed when ready.\n"
|
||||
|
|
|
|||
|
|
@ -1526,9 +1526,11 @@ def get_tools() -> List[ToolEntry]:
|
|||
}, _git_status, is_code_tool=True),
|
||||
ToolEntry("vcs_diff", {
|
||||
"name": "vcs_diff",
|
||||
"description": "git diff for the selected repository (use staged=true to see staged changes after git add).",
|
||||
"description": "Local git diff. Omit refs for unstaged/staged changes. Base compares to worktree or index; base+head compares two trees, not their merge base. No fetch.",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"root": {"type": "string", "enum": ["active_workspace", "system_repo"], "default": "active_workspace", "description": "Omit for the active project workspace; use system_repo for Ouroboros source."},
|
||||
"base": {"type": "string", "default": "", "description": "Optional local base ref, resolved once to an exact tree."},
|
||||
"head": {"type": "string", "default": "", "description": "Optional local second tree; requires base and staged=false."},
|
||||
"staged": {"type": "boolean", "default": False, "description": "If true, show staged changes (--staged)"},
|
||||
"path": {"type": "string", "default": "", "description": "Optional path filter relative to the selected repository"},
|
||||
"stat": {"type": "boolean", "default": False, "description": "If true, show --stat output"},
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ def _repo_write(ctx: ToolContext, path: str = "", content: str = "",
|
|||
# P3: the force bypass is never silent — a forced write of invalid content
|
||||
# still discloses what the guard found in the success message.
|
||||
syntax_bypass_notes: List[str] = []
|
||||
from ouroboros.tools.edit_ops import _syntax_check
|
||||
from ouroboros.tools.edit_ops import _syntax_check, workspace_edit_note
|
||||
|
||||
if mode != "append": # an append chunk is not a full file — the guard would block every chunk
|
||||
for e, binding in zip(write_list, binding_items):
|
||||
|
|
@ -242,7 +242,7 @@ def _repo_write(ctx: ToolContext, path: str = "", content: str = "",
|
|||
elif ctx.is_workspace_mode() and not system_target:
|
||||
result = (
|
||||
f"✅ Written {len(written)} file(s): {summary}\n"
|
||||
"Files are on disk in the active workspace. Do not commit; the headless runner will emit a patch artifact."
|
||||
"Files are on disk in the active workspace. " + workspace_edit_note(ctx)
|
||||
)
|
||||
elif not system_target:
|
||||
result = (
|
||||
|
|
@ -456,7 +456,8 @@ def _str_replace_editor(
|
|||
if capture_note:
|
||||
result += "\n" + capture_note
|
||||
elif data_skill_target is None and ctx.is_workspace_mode() and not system_target:
|
||||
result += "\nDo not commit; the headless runner will emit a patch artifact."
|
||||
from ouroboros.tools.edit_ops import workspace_edit_note
|
||||
result += "\n" + workspace_edit_note(ctx)
|
||||
elif system_target:
|
||||
result += "\nRun commit_reviewed when ready.\n⚠️ Advisory pre-review is now stale — run preflight_review before commit_reviewed."
|
||||
elif data_skill_target is not None:
|
||||
|
|
|
|||
|
|
@ -115,10 +115,23 @@ def _git_diff(
|
|||
max_chars: int = 0,
|
||||
root: str = "system_repo",
|
||||
_resolved_binding: Optional[ResolvedResourceBinding] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
try:
|
||||
binding = _git()._vcs_binding(ctx, _resolved_binding, root=root, path=path or ".")
|
||||
repo_dir = binding.base_path
|
||||
base, head = str(kwargs.get("base") or ""), str(kwargs.get("head") or "")
|
||||
if head and (not base or staged):
|
||||
from ouroboros.tools.tool_result import ToolResult, _publish_tool_result
|
||||
return _publish_tool_result(ctx, ToolResult(status="error", code="TOOL_ARG_ERROR",
|
||||
text="⚠️ TOOL_ARG_ERROR (vcs_diff): head requires base and cannot be combined with staged=true."))
|
||||
comparison = {}
|
||||
revisions = []
|
||||
for name, ref in (("base", base), ("head", head)):
|
||||
if ref:
|
||||
tree = _git().run_cmd(["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{tree}}"], cwd=repo_dir).strip()
|
||||
revisions.append(tree)
|
||||
comparison.update({f"{name}_ref": ref, f"{name}_tree": tree})
|
||||
cmd = ["git", "diff"]
|
||||
if staged:
|
||||
cmd.append("--staged")
|
||||
|
|
@ -126,6 +139,7 @@ def _git_diff(
|
|||
cmd.append("--name-only")
|
||||
elif stat:
|
||||
cmd.append("--stat")
|
||||
cmd.extend(revisions)
|
||||
if relative := _git()._binding_relative_path(binding, path):
|
||||
cmd.extend(["--", _git().safe_relpath(relative)])
|
||||
from ouroboros.protected_artifacts import shell_block_reason as protected_artifact_shell_block_reason
|
||||
|
|
@ -135,7 +149,14 @@ def _git_diff(
|
|||
)
|
||||
if protected_block:
|
||||
return _git()._vcs_result(protected_block, binding)
|
||||
return _git()._vcs_result(_git()._limit_git_output(_git().run_cmd(cmd, cwd=repo_dir), max_chars), binding)
|
||||
text = _git()._vcs_result(_git()._limit_git_output(_git().run_cmd(cmd, cwd=repo_dir), max_chars), binding)
|
||||
if comparison:
|
||||
import json
|
||||
from ouroboros.tools.tool_result import ToolResult, _publish_tool_result
|
||||
comparison["kind"] = "tree_to_tree" if head else "tree_to_index" if staged else "tree_to_worktree"
|
||||
text += "\nComparison: " + json.dumps(comparison, ensure_ascii=False, sort_keys=True)
|
||||
return _publish_tool_result(ctx, ToolResult(status="ok", code="OK", text=text, meta={"comparison": comparison}))
|
||||
return text
|
||||
except Exception as e:
|
||||
return _publish_git_error(
|
||||
ctx,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Typed process facts: the handler→loop seam for host process tools.
|
||||
"""Scoped process configuration and facts at the handler→loop boundary.
|
||||
|
||||
R5 (node-runtime sprint, stream B): the process-tool handler measures its child
|
||||
directly (returncode, POSIX signal name, wall-clock duration, and — via the
|
||||
|
|
@ -9,8 +9,9 @@ for the same tool call and merges them into the typed ``result_meta``.
|
|||
Thread-local, not ctx-scoped, because the tool executor runs each handler in
|
||||
its own worker thread — the slot is therefore naturally per-in-flight-call,
|
||||
and an ABANDONED (outer-timeout) handler thread that finishes late writes only
|
||||
its own thread's slot and can never contaminate a later call's facts. The
|
||||
rendered result text is unchanged by this channel; a record with no typed
|
||||
its own thread's slot and can never contaminate a later call's facts. Selected Settings values stay in the scoped launch environment; diagnostics
|
||||
are redacted before publication. Runtime provenance is also rendered in the
|
||||
result. A record with no typed
|
||||
publication carries no process facts at all — under the typed-result organ
|
||||
(D02) prose is never harvested into typed fields.
|
||||
|
||||
|
|
@ -21,7 +22,12 @@ This lives outside ``tools/shell.py`` deliberately: it is a loop↔handler seam
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict
|
||||
|
|
@ -29,6 +35,158 @@ from typing import Dict
|
|||
from ouroboros.platform_layer import posix_signal_name
|
||||
|
||||
|
||||
def settings_environment_allowed(ctx) -> bool:
|
||||
"""The existing service authority to select new Settings references."""
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.presence_authority import presence_ceiling_from_context
|
||||
from ouroboros.runtime_mode_policy import mode_has_unrestricted_agency
|
||||
from ouroboros.tool_access import active_tool_profile, _TOP_LEVEL_PRINCIPAL_PROFILES
|
||||
|
||||
profile = active_tool_profile(ctx)
|
||||
cyber_actor = profile == "acting_subagent" and mode_has_unrestricted_agency(get_runtime_mode())
|
||||
return (cyber_actor or profile in (_TOP_LEVEL_PRINCIPAL_PROFILES | {"operator_control"})) and presence_ceiling_from_context(ctx) is None
|
||||
|
||||
|
||||
def redact_process_data(value):
|
||||
"""Mask selected values at result/receipt egress, retaining typed host facts."""
|
||||
from ouroboros.secret_masking import redact_known_values
|
||||
|
||||
prepared = getattr(_process_facts_tls, "environment", None)
|
||||
secrets = prepared[1] if prepared is not None else ()
|
||||
if isinstance(value, dict):
|
||||
# Host vocabulary/identity is not a child echo. In particular a one-char
|
||||
# selected secret must not rewrite PASS/status or receipt identity hashes.
|
||||
typed = {"tool", "status", "code", "contract_kind", "expected_match", "ts",
|
||||
"criterion_source", "check_rendering", "root", "source", "kind",
|
||||
"sha256", "content_sha256", "input_sha256", "output_sha256"}
|
||||
return {key: item if key in typed else redact_process_data(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [redact_process_data(item) for item in value]
|
||||
return redact_known_values(value, secrets)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def process_environment_scope(ctx, name, arguments):
|
||||
"""One admitted Settings snapshot shared by resolution and nested launchers."""
|
||||
from ouroboros.config import load_settings, runtime_settings
|
||||
from ouroboros.workspace_executor import resolve_process_env, validate_process_env
|
||||
from ouroboros.tools.tool_result import ToolResult
|
||||
|
||||
prior = getattr(_process_facts_tls, "environment", None)
|
||||
old_runtime = getattr(_process_facts_tls, "runtime_provenance", None)
|
||||
prepared, error = prior, None
|
||||
if prior is None and name in {"run_command", "run_script", "verify_and_record"}:
|
||||
try:
|
||||
refs = validate_process_env(arguments.get("env_from_settings"))
|
||||
if refs and name == "verify_and_record" and arguments.get("contract_kind") not in {"visible_verifier", "explicit_command", "explicit_metric"}:
|
||||
raise ValueError("env_from_settings applies only to run-kind verification")
|
||||
if refs and not settings_environment_allowed(ctx):
|
||||
error = ToolResult(status="blocked", code="ACCESS_BLOCKED",
|
||||
text="⚠️ PROCESS_ENV_REFERENCE_BLOCKED: this task cannot select settings-backed process environment.")
|
||||
else:
|
||||
prepared = resolve_process_env(None, refs,
|
||||
settings=runtime_settings(settings_reader=load_settings) if refs else None)
|
||||
except ValueError as exc:
|
||||
error = ToolResult(status="error", code="TOOL_ARG_ERROR", text=f"⚠️ TOOL_ARG_ERROR: {exc}")
|
||||
_process_facts_tls.environment = prepared
|
||||
if prior is None:
|
||||
_process_facts_tls.runtime_provenance = None
|
||||
try:
|
||||
yield error
|
||||
finally:
|
||||
_process_facts_tls.environment = prior
|
||||
_process_facts_tls.runtime_provenance = old_runtime
|
||||
|
||||
|
||||
def process_environment_tool(handler):
|
||||
"""Also support direct helpers; mask before the registry persists results."""
|
||||
@functools.wraps(handler)
|
||||
def invoke(ctx, *args, **kwargs):
|
||||
from ouroboros.tools.tool_result import ToolResult, _publish_tool_result, _published_tool_result, _replace_tool_result
|
||||
|
||||
name = {"_run_shell": "run_command", "_run_script": "run_script", "_verify_and_record": "verify_and_record"}[handler.__name__]
|
||||
arguments = dict(kwargs)
|
||||
if name == "verify_and_record" and args:
|
||||
arguments.setdefault("contract_kind", args[0])
|
||||
with process_environment_scope(ctx, name, arguments) as error:
|
||||
if error is not None:
|
||||
return _publish_tool_result(ctx, error)
|
||||
try:
|
||||
result = handler(ctx, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
if getattr(_process_facts_tls, "environment", (None, ()))[1]:
|
||||
raise RuntimeError(redact_process_data(str(exc))) from None
|
||||
raise
|
||||
published = _published_tool_result(ctx, None)
|
||||
text = redact_process_data(result)
|
||||
provenance = getattr(_process_facts_tls, "runtime_provenance", None)
|
||||
if provenance and name != "run_script":
|
||||
text += "\n\nRuntime selection: " + json.dumps(provenance, ensure_ascii=False, sort_keys=True)
|
||||
if isinstance(published, ToolResult) and published.text == result:
|
||||
text = _publish_tool_result(ctx, _replace_tool_result(published, text=text,
|
||||
meta_updates=redact_process_data(dict(published.meta))))
|
||||
return text
|
||||
return invoke
|
||||
|
||||
|
||||
def selected_process_environment():
|
||||
prepared = getattr(_process_facts_tls, "environment", None)
|
||||
return prepared[0] if prepared is not None else {}
|
||||
|
||||
|
||||
def process_path_for_cwd(path, cwd):
|
||||
"""Resolve PATH search entries against the child cwd, preserving lexical paths."""
|
||||
return os.pathsep.join(
|
||||
str(pathlib.Path(part) if pathlib.Path(part).is_absolute() else pathlib.Path(cwd) / part)
|
||||
for part in path.split(os.pathsep)
|
||||
)
|
||||
|
||||
|
||||
def record_runtime_selection(ctx, argv, cwd, environment):
|
||||
"""Non-executing provenance over the actual launch environment; never rewrite argv."""
|
||||
from ouroboros.workspace_executor import executor_ref_from_ctx, map_host_path
|
||||
|
||||
trace = getattr(ctx, "_active_interpreter_resolution", None)
|
||||
requested = str(getattr(trace, "requested_interpreter", "") or (argv[0] if argv else ""))
|
||||
name = pathlib.Path(requested).name.lower()
|
||||
python = getattr(trace, "family", "") == "python" or name.startswith("python")
|
||||
wrapper = name in {"env", "sh", "bash", "zsh", "dash", "cmd", "cmd.exe", "powershell", "pwsh"}
|
||||
if not python and not wrapper:
|
||||
return
|
||||
executor = executor_ref_from_ctx(ctx)
|
||||
remote = False
|
||||
if executor is not None and executor.kind != "local":
|
||||
try:
|
||||
map_host_path(executor, pathlib.Path(cwd))
|
||||
remote = True
|
||||
except ValueError:
|
||||
pass
|
||||
selected = ""
|
||||
source = str(getattr(trace, "environment", "") or "explicit")
|
||||
unknown = ""
|
||||
if wrapper:
|
||||
source, unknown = "wrapper", "runtime inside wrapper not inspected"
|
||||
elif remote:
|
||||
source, unknown = "backend_path", "physical backend executable not reported"
|
||||
else:
|
||||
spelling = str(argv[0])
|
||||
if os.path.dirname(spelling):
|
||||
candidate = pathlib.Path(spelling)
|
||||
candidate = candidate if candidate.is_absolute() else pathlib.Path(cwd) / candidate
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
selected = os.path.abspath(candidate) # lexical venv path, not realpath
|
||||
else:
|
||||
path = process_path_for_cwd(environment.get("PATH", os.defpath), cwd)
|
||||
selected = shutil.which(spelling, path=path) or ""
|
||||
source = "PATH" if not trace or not trace.changed else source
|
||||
if not selected:
|
||||
unknown = "executable not established on the launch PATH"
|
||||
_process_facts_tls.runtime_provenance = redact_process_data({
|
||||
"requested": requested, "selected_path": selected or None, "source": source,
|
||||
"unknown_reason": unknown, "version": getattr(trace, "runtime_version", "") or None,
|
||||
})
|
||||
|
||||
|
||||
def signal_name_for_returncode(returncode) -> str:
|
||||
"""POSIX signal name for a NEGATIVE subprocess returncode, '' otherwise.
|
||||
|
||||
|
|
@ -67,6 +225,7 @@ PROCESS_FACT_KEYS = (
|
|||
"signal",
|
||||
"duration_ms",
|
||||
"resolved_runtime",
|
||||
"runtime_provenance",
|
||||
"timed_out",
|
||||
"killed_by_host",
|
||||
"pre_exec_failure",
|
||||
|
|
@ -139,6 +298,9 @@ def publish_process_facts(
|
|||
}
|
||||
if failures:
|
||||
facts["ws_relay_failures"] = failures
|
||||
if provenance := getattr(_process_facts_tls, "runtime_provenance", None):
|
||||
facts["runtime_provenance"] = provenance
|
||||
facts = redact_process_data(facts)
|
||||
_process_facts_tls.facts = facts
|
||||
# Returned so a producer that ALSO discloses these facts elsewhere (the
|
||||
# verify_and_record receipt) copies the published ones instead of deriving
|
||||
|
|
|
|||
|
|
@ -1277,16 +1277,21 @@ class ToolRegistry:
|
|||
)
|
||||
worktree_before = self._worktree_status_snapshot() if entry.mutates_worktree else None
|
||||
settings_before = registry_guard_process._owner_settings_snapshot() if name in _PROCESS_COMMAND_TOOLS else None
|
||||
if interpreter_resolution is None: # node: post-gates (A-F4)
|
||||
from ouroboros.process_interpreters import resolve_node_postgates
|
||||
from ouroboros.tools.process_facts import process_environment_scope
|
||||
|
||||
args, interpreter_resolution = resolve_node_postgates(
|
||||
self._ctx, name, args, runtime_mode=_runtime_mode,
|
||||
effective_constraint=effective_constraint, resolved_binding=resolved_binding,
|
||||
with process_environment_scope(self._ctx, name, args) as environment_error:
|
||||
if environment_error is not None:
|
||||
return environment_error
|
||||
if interpreter_resolution is None: # node: post-gates (A-F4)
|
||||
from ouroboros.process_interpreters import resolve_node_postgates
|
||||
|
||||
args, interpreter_resolution = resolve_node_postgates(
|
||||
self._ctx, name, args, runtime_mode=_runtime_mode,
|
||||
effective_constraint=effective_constraint, resolved_binding=resolved_binding,
|
||||
)
|
||||
early_error, result = self._invoke_builtin_handler(
|
||||
name, entry, args, resolved_binding, interpreter_resolution, worktree_before,
|
||||
)
|
||||
early_error, result = self._invoke_builtin_handler(
|
||||
name, entry, args, resolved_binding, interpreter_resolution, worktree_before,
|
||||
)
|
||||
if name in _PROCESS_COMMAND_TOOLS:
|
||||
# Tripwires run on the TOOL_ERROR path too: two early_error returns
|
||||
# fire AFTER the process already ran (#447 B2).
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from ouroboros.tool_access import (
|
|||
build_resolved_resource_binding,
|
||||
canonical_data_root,
|
||||
shell_cwd_block_message,
|
||||
_TOP_LEVEL_PRINCIPAL_PROFILES,
|
||||
)
|
||||
from ouroboros.utils import append_jsonl, utc_now_iso
|
||||
from ouroboros.workspace_executor import executor_ref_from_ctx
|
||||
|
|
@ -375,17 +374,9 @@ def _start_service(
|
|||
try:
|
||||
refs = validate_process_env(env_from_settings)
|
||||
if refs:
|
||||
# Presence authority is relevant only to selecting new Settings
|
||||
# references; literal env keeps its existing process capability.
|
||||
from ouroboros.presence_authority import presence_ceiling_from_context
|
||||
from ouroboros.tools.process_facts import settings_environment_allowed
|
||||
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import mode_has_unrestricted_agency
|
||||
|
||||
cyber_actor = (active_tool_profile(ctx) == "acting_subagent"
|
||||
and mode_has_unrestricted_agency(get_runtime_mode()))
|
||||
if (not cyber_actor and active_tool_profile(ctx) not in (_TOP_LEVEL_PRINCIPAL_PROFILES | {"operator_control"})
|
||||
or presence_ceiling_from_context(ctx) is not None):
|
||||
if not settings_environment_allowed(ctx):
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="blocked", code="ACCESS_BLOCKED",
|
||||
text="⚠️ SERVICE_ENV_REFERENCE_BLOCKED: this task cannot select settings-backed service environment. A root task can start the service; existing literal environment and configured MCP access remain available.",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ _CONTROL_DIR_BACKUP_MAX_BYTES = 5 * 1024 * 1024
|
|||
# Historical private spellings stay as aliases for call sites and tests.
|
||||
from ouroboros.tools.process_facts import ( # noqa: E402
|
||||
active_resolved_runtime as _active_resolved_runtime,
|
||||
process_environment_tool, record_runtime_selection, selected_process_environment,
|
||||
publish_process_facts as _publish_process_facts, # noqa: F401 — historical private spelling for call sites and tests
|
||||
)
|
||||
from ouroboros.tools.shell_process import ( # noqa: E402
|
||||
|
|
@ -266,6 +267,7 @@ def _literal_argv_notes(cmd: List[str]) -> str:
|
|||
return "".join(notes)
|
||||
|
||||
|
||||
@process_environment_tool
|
||||
def _run_shell(
|
||||
ctx: ToolContext,
|
||||
cmd,
|
||||
|
|
@ -403,16 +405,19 @@ def _run_shell(
|
|||
bootstrap_process_path()
|
||||
# Emergency bundled-node PATH prepend; None on every healthy path (env stays byte-identical).
|
||||
node_resolution = active_node_resolution(ctx)
|
||||
from ouroboros.workspace_executor import overlay_env
|
||||
selected_env = selected_process_environment()
|
||||
run_env = apply_env_path_prepend(overlay_env(_shell_env_for_cwd(ctx, pathlib.Path(work_dir)), selected_env), node_resolution)
|
||||
record_runtime_selection(ctx, cmd, work_dir, run_env)
|
||||
# Two clocks (D2-1): EPOCH feeds the st_mtime audit; MONOTONIC feeds durations.
|
||||
_command_start_epoch = time.time()
|
||||
_command_start_ts = time.monotonic()
|
||||
try:
|
||||
if _executor_can_run_cwd(ctx, pathlib.Path(work_dir)):
|
||||
res = executor_execute(ctx, cmd, pathlib.Path(work_dir), timeout_sec,
|
||||
env_overlay=interpreter_path_overlay(node_resolution))
|
||||
env_overlay=interpreter_path_overlay(node_resolution),
|
||||
**({"target_env": selected_env} if selected_env else {}))
|
||||
else:
|
||||
run_env = apply_env_path_prepend(
|
||||
_shell_env_for_cwd(ctx, pathlib.Path(work_dir)), node_resolution)
|
||||
res = _tracked_subprocess_run(
|
||||
cmd, cwd=str(work_dir),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
|
|
@ -541,6 +546,7 @@ def _run_shell(
|
|||
return f"⚠️ SHELL_ERROR: {e}. root={binding.root}, cwd={work_dir}"
|
||||
|
||||
|
||||
@process_environment_tool
|
||||
def _run_script(
|
||||
ctx: ToolContext,
|
||||
script: str,
|
||||
|
|
@ -616,6 +622,7 @@ def _run_script(
|
|||
result = _run_shell(
|
||||
ctx, argv, cwd=cwd, outputs=outputs, scratch=scratch,
|
||||
_resolved_binding=binding, timeout_sec=timeout_sec, timeout=timeout,
|
||||
env_from_settings=kwargs.get("env_from_settings"),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -682,13 +689,14 @@ def get_tools() -> List[ToolEntry]:
|
|||
),
|
||||
},
|
||||
"cwd": {"type": "string", "default": "", "description": "Omit for active_workspace; use system_repo[/subdir] for Ouroboros or skill_payload[/subdir] with bucket+skill_name for a skill. Existing task_drive, artifact_store, user_files and authorized absolute cwd forms remain available; use cwd instead of the rejected cd builtin."},
|
||||
"env_from_settings": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Explicit environment variable → saved setting key mapping. Uses existing Settings-selection authority; secret values are masked in diagnostics."},
|
||||
"bucket": {"type": "string", "enum": ["external", "clawhub", "ouroboroshub", "user_repo"], "description": "Physical skill location for cwd=skill_payload[/subdir]."},
|
||||
"skill_name": {"type": "string", "description": "Exact skill identity for cwd=skill_payload[/subdir]."},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Generated file paths to copy/register into the task artifact store after success.",
|
||||
"description": "Generated paths copied/registered after success, e.g. outputs=['report.txt']. The managed artifact directory is created lazily; do not move files into an assumed physical store.",
|
||||
},
|
||||
"scratch": {
|
||||
"type": "array",
|
||||
|
|
@ -725,13 +733,14 @@ def get_tools() -> List[ToolEntry]:
|
|||
"interpreter": {"type": "string", "default": "python3", "description": "Installed executable name or path that accepts a script filename, such as python3, node, perl, zsh or lua. Receives the temporary script path followed by args. Use run_command for compiler or launcher subcommands."},
|
||||
"args": {"type": "array", "items": {"type": "string"}, "default": []},
|
||||
"cwd": {"type": "string", "default": "", "description": "Omit for active_workspace; use system_repo[/subdir] for Ouroboros or skill_payload[/subdir] with bucket+skill_name for a skill."},
|
||||
"env_from_settings": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Explicit environment variable → saved setting key mapping. Uses existing Settings-selection authority; secret values are masked in diagnostics."},
|
||||
"bucket": {"type": "string", "enum": ["external", "clawhub", "ouroboroshub", "user_repo"], "description": "Physical skill location for cwd=skill_payload[/subdir]."},
|
||||
"skill_name": {"type": "string", "description": "Exact skill identity for cwd=skill_payload[/subdir]."},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Generated file paths to copy/register into the task artifact store after success.",
|
||||
"description": "Generated paths copied/registered after success, e.g. outputs=['report.txt']. The managed artifact directory is created lazily; do not move files into an assumed physical store.",
|
||||
},
|
||||
"scratch": {
|
||||
"type": "array",
|
||||
|
|
|
|||
|
|
@ -167,9 +167,11 @@ from ouroboros.tools.process_facts import describe_returncode as _describe_retur
|
|||
|
||||
|
||||
def _format_process_output(stdout: str, stderr: str, *, limit: int = 50_000) -> str:
|
||||
"""Render bounded stdout/stderr sections."""
|
||||
stdout_text = str(stdout or "")
|
||||
stderr_text = str(stderr or "")
|
||||
"""Mask diagnostic copies before bounds can split a selected secret."""
|
||||
from ouroboros.tools.process_facts import redact_process_data
|
||||
|
||||
stdout_text = redact_process_data(str(stdout or ""))
|
||||
stderr_text = redact_process_data(str(stderr or ""))
|
||||
parts: List[str] = []
|
||||
if stdout_text.strip():
|
||||
parts.append(f"STDOUT:\n{stdout_text}")
|
||||
|
|
|
|||
|
|
@ -585,9 +585,11 @@ def _format_tool_arg_error(entry: "ToolEntry", *, rejected: tuple[str, ...] = ()
|
|||
# signature-bind refusal cannot name one, and a PRIVATE dispatch carrier is
|
||||
# never echoed back.
|
||||
named = f"unsupported argument(s): {', '.join(rejected)}. " if rejected else ""
|
||||
hint = (" Use cwd=system_repo or cwd=system_repo/subdir, not root."
|
||||
if "root" in rejected and entry.name in {"run_command", "run_script", "start_service"} else "")
|
||||
return (
|
||||
f"⚠️ TOOL_ARG_ERROR ({entry.name}): invalid arguments for {entry.name}. "
|
||||
f"{named}Accepted parameters: {accepted}."
|
||||
f"{named}Accepted parameters: {accepted}.{hint}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from ouroboros.tool_access import (
|
|||
canonical_data_root,
|
||||
)
|
||||
from ouroboros.tools.registry import ToolContext, ToolEntry, active_repo_dir_for
|
||||
from ouroboros.tools.process_facts import process_environment_tool, redact_process_data, record_runtime_selection, selected_process_environment
|
||||
from ouroboros.utils import utc_now_iso
|
||||
|
||||
# Durable receipt evidence is bounded but the truncation is DISCLOSED (BIBLE P1, never
|
||||
|
|
@ -54,7 +55,7 @@ _RECEIPT_OUTPUT_CAP = 20000
|
|||
# call's result_meta, not to the receipt's stored shape), so the receipt keeps
|
||||
# exactly the three keys it has always carried, copied from the one publication
|
||||
# instead of derived a second time.
|
||||
_RECEIPT_PROCESS_KEYS = ("duration_ms", "signal", "resolved_runtime")
|
||||
_RECEIPT_PROCESS_KEYS = ("duration_ms", "signal", "resolved_runtime", "runtime_provenance")
|
||||
_TOOL_OUTPUT_CAP = 4000
|
||||
# The `no_visible_machine_contract` receipt carries the agent's OWN stated proxy and
|
||||
# residual risk — decision-shaping cognitive evidence a reviewer reads, so it goes
|
||||
|
|
@ -63,7 +64,9 @@ _RECEIPT_DECLARED_SUMMARY_CAP = 1000
|
|||
|
||||
|
||||
def _bounded(text: Any, cap: int) -> str:
|
||||
t = str(text or "").strip()
|
||||
# Raw output remains available to expected-match evaluation. Only this
|
||||
# diagnostic copy is masked, before a bound could expose a secret fragment.
|
||||
t = redact_process_data(str(text or "")).strip()
|
||||
if len(t) <= cap:
|
||||
return t
|
||||
return t[:cap] + f"\n…[truncated {len(t) - cap} of {len(t)} chars]"
|
||||
|
|
@ -324,7 +327,7 @@ def _compare_files_bytes_equal(
|
|||
rc = int(rc_raw)
|
||||
except (TypeError, ValueError):
|
||||
return False, f"bytes_equal: executor cmp returned no exit status for {a_raw} vs {b_raw}"
|
||||
out = ((res.stdout or "") + ("\n" + res.stderr if res.stderr else "")).strip()
|
||||
out = redact_process_data((res.stdout or "") + ("\n" + res.stderr if res.stderr else "")).strip()
|
||||
if rc == 0:
|
||||
return True, f"bytes_equal: {a_raw} == {b_raw} (executor cmp)"
|
||||
if rc > 1:
|
||||
|
|
@ -569,6 +572,7 @@ def _record_delegation_zero_run(
|
|||
)
|
||||
|
||||
|
||||
@process_environment_tool
|
||||
def _verify_and_record(
|
||||
ctx: ToolContext,
|
||||
contract_kind: str = "",
|
||||
|
|
@ -731,6 +735,10 @@ def _verify_and_record(
|
|||
# untouched, and the agent-visible result text below is unchanged.
|
||||
_check_started_ts = time.monotonic()
|
||||
_resolved_runtime = _active_resolved_runtime(ctx)
|
||||
from ouroboros.workspace_executor import overlay_env
|
||||
selected_env = selected_process_environment()
|
||||
run_env = apply_env_path_prepend(overlay_env(_shell_env_for_cwd(ctx, pathlib.Path(work_dir)), selected_env), node_resolution)
|
||||
record_runtime_selection(ctx, exec_argv, work_dir, run_env)
|
||||
try:
|
||||
if use_executor:
|
||||
# Route the check through the host-owned executor backend (e.g. docker_exec
|
||||
|
|
@ -740,11 +748,9 @@ def _verify_and_record(
|
|||
res = executor_execute(
|
||||
ctx, exec_argv, pathlib.Path(work_dir), timeout,
|
||||
env_overlay=interpreter_path_overlay(node_resolution),
|
||||
**({"target_env": selected_env} if selected_env else {}),
|
||||
)
|
||||
else:
|
||||
run_env = apply_env_path_prepend(
|
||||
_shell_env_for_cwd(ctx, pathlib.Path(work_dir)), node_resolution,
|
||||
)
|
||||
res = _tracked_subprocess_run(
|
||||
exec_argv, cwd=str(work_dir),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout,
|
||||
|
|
@ -762,7 +768,7 @@ def _verify_and_record(
|
|||
# stamp: a timeout red must be reconcilable by the later green of that argv.
|
||||
receipt.update({"status": "fail", "returncode": None, "matched": False, "check": shlex.join(argv), "check_rendering": CHECK_RENDERING_SHLEX_JOIN, "summary": f"check timed out after {timeout}s"})
|
||||
receipt.update({k: v for k, v in _facts.items() if k in _RECEIPT_PROCESS_KEYS})
|
||||
if not append_verification_receipt(drive_root, task_id, receipt):
|
||||
if not append_verification_receipt(drive_root, task_id, redact_process_data(receipt)):
|
||||
return _receipt_custody_failure(
|
||||
kind, f"check timed out after {timeout}s",
|
||||
)
|
||||
|
|
@ -824,7 +830,7 @@ def _verify_and_record(
|
|||
if _masked:
|
||||
receipt["check_exit_masking"] = True
|
||||
receipt["check_exit_masking_reasons"] = _mask_reasons
|
||||
if not append_verification_receipt(drive_root, task_id, receipt):
|
||||
if not append_verification_receipt(drive_root, task_id, redact_process_data(receipt)):
|
||||
return _receipt_custody_failure(
|
||||
kind, f"verdict={'PASS' if passed else 'FAIL'}, exit={rc}",
|
||||
) + f"\n\n{_bounded(out, _TOOL_OUTPUT_CAP)}"
|
||||
|
|
@ -840,7 +846,7 @@ def _verify_and_record(
|
|||
paths = [str(p) for p in (artifact_paths or []) if str(p or "").strip()]
|
||||
obs_status, detail = _observe_artifacts(ctx, paths)
|
||||
receipt.update({"status": obs_status, "paths": paths[:20], "summary": detail})
|
||||
if not append_verification_receipt(drive_root, task_id, receipt):
|
||||
if not append_verification_receipt(drive_root, task_id, redact_process_data(receipt)):
|
||||
return _receipt_custody_failure(kind, f"{obs_status}: {detail}")
|
||||
# refused_out_of_scope is a POLICY block, not a verification failure — surface it
|
||||
# honestly (not a red FAIL) so a deliverable outside the observable roots doesn't
|
||||
|
|
@ -860,7 +866,7 @@ def _verify_and_record(
|
|||
# best proxy + residual risk is recorded as a receipt and judged by a reviewer.
|
||||
# The agent's own text, not a render of an argv that ran — its own rendering stamp.
|
||||
receipt.update({"status": "declared", "check": str(check or ""), "check_rendering": CHECK_RENDERING_DECLARED_TEXT, "summary": _bounded(expected_s or str(check or ""), _RECEIPT_DECLARED_SUMMARY_CAP)})
|
||||
if not append_verification_receipt(drive_root, task_id, receipt):
|
||||
if not append_verification_receipt(drive_root, task_id, redact_process_data(receipt)):
|
||||
return _receipt_custody_failure(kind, receipt["summary"])
|
||||
return (
|
||||
"verify_and_record [no_visible_machine_contract] DECLARED: no host-checkable contract; "
|
||||
|
|
@ -898,6 +904,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"expected": {"type": "string", "default": "", "description": "Optional expected substring/metric in the check output (explicit_command/explicit_metric)."},
|
||||
"expected_match": {"type": "string", "enum": list(_EXPECTED_MATCH_KINDS), "default": "substring", "description": "How `expected` is matched: substring (default) · exact (whole stripped output equals expected) · exact_line (expected equals one stripped output line) · json_equals (output and expected parse to equal JSON, key-order tolerant) · bytes_equal (after the check runs, artifact_paths=[a, b] are compared BYTE-FOR-BYTE — golden files, migration parity; the receipt records a bounded hexdump of the first divergence). Use a stricter mode when the task gives a worked example / exact output."},
|
||||
"artifact_paths": {"type": "array", "items": {"type": "string"}, "description": "Deliverable paths. For artifact_observation the host confirms they exist (existence/size only, never content) — observable roots are the active workspace plus every resource root the ACTIVE profile can already read (for orchestrating parents that includes subagent_projects and deliverables, so a parent CAN confirm a child's deliverable in the projects tree; child/readonly profiles lack those roots); a path outside these is a non-fatal refused_out_of_scope, not a failure. For run-kind checks (visible_verifier/explicit_command/explicit_metric) the host ALSO probes (after the check) whether each declared path that is RELATIVE to the check's working directory (cwd) still exists and records an advisory artifact_lifecycle flag — catching a check that built then deleted its own deliverable."},
|
||||
"env_from_settings": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Run-kind only: explicit environment variable → saved setting key mapping, with existing Settings-selection authority and secret masking."},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
|
|
|
|||
|
|
@ -233,13 +233,15 @@ def execute(
|
|||
timeout_sec: int,
|
||||
*,
|
||||
env_overlay: "dict[str, str] | None" = None,
|
||||
target_env: "dict[str, str] | None" = None,
|
||||
) -> ExecutorResult:
|
||||
"""Run one foreground command in the configured backend.
|
||||
|
||||
``env_overlay`` (e.g. the interpreter resolver's attested emergency PATH
|
||||
prepend) applies only to the LOCAL backend, which runs on this host; the
|
||||
docker backend deliberately ignores it — host paths and host PATH must not
|
||||
leak into a container environment.
|
||||
leak into a container environment. Explicit target_env is separate and
|
||||
reaches either backend; Docker carries its values only through inert aliases.
|
||||
"""
|
||||
executor = executor_ref_from_ctx(ctx)
|
||||
if executor is None:
|
||||
|
|
@ -251,9 +253,10 @@ def execute(
|
|||
return _execute_local(
|
||||
executor, cmd, cwd_path, timeout_sec,
|
||||
drive_root=_drive_root_from_ctx(ctx),
|
||||
env_overlay=env_overlay,
|
||||
env_overlay=env_overlay, **({"target_env": target_env} if target_env else {}),
|
||||
)
|
||||
return _execute_docker(executor, cmd, backend_cwd, timeout_sec, drive_root=_drive_root_from_ctx(ctx))
|
||||
return _execute_docker(executor, cmd, backend_cwd, timeout_sec, drive_root=_drive_root_from_ctx(ctx),
|
||||
**({"target_env": target_env} if target_env else {}))
|
||||
|
||||
|
||||
def _system_repo_dir() -> str | None:
|
||||
|
|
@ -304,6 +307,7 @@ def _execute_local(
|
|||
*,
|
||||
drive_root: pathlib.Path | None,
|
||||
env_overlay: "dict[str, str] | None" = None,
|
||||
target_env: "dict[str, str] | None" = None,
|
||||
) -> ExecutorResult:
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
|
|
@ -314,7 +318,7 @@ def _execute_local(
|
|||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
errors="replace",
|
||||
env=scrub_repo_from_pythonpath(_env_with_overlay(env_overlay), _system_repo_dir()),
|
||||
env=overlay_env(overlay_env(scrub_repo_from_pythonpath(_env_with_overlay(None), _system_repo_dir()), target_env), env_overlay),
|
||||
**subprocess_new_group_kwargs(),
|
||||
)
|
||||
record_path = _register_process(
|
||||
|
|
@ -352,11 +356,14 @@ def _execute_docker(
|
|||
timeout_sec: int,
|
||||
*,
|
||||
drive_root: pathlib.Path | None,
|
||||
target_env: "dict[str, str] | None" = None,
|
||||
) -> ExecutorResult:
|
||||
if executor.network == "none":
|
||||
_assert_docker_network_none(executor.container_name)
|
||||
pidfile = f"/tmp/ouroboros-exec-{uuid.uuid4().hex}.pid"
|
||||
command = shlex.join(str(part) for part in cmd)
|
||||
prefix = f"OUROBOROS_PROCESS_ENV_{uuid.uuid4().hex}_"
|
||||
aliases = {key: f"{prefix}{index}" for index, key in enumerate(target_env or {})}
|
||||
command = _docker_env_command(shlex.join(str(part) for part in cmd), aliases)
|
||||
exec_payload = shlex.quote(f"exec {command}")
|
||||
quoted_pidfile = shlex.quote(pidfile)
|
||||
wrapper = (
|
||||
|
|
@ -374,6 +381,7 @@ def _execute_docker(
|
|||
docker_cmd = [
|
||||
"docker",
|
||||
"exec",
|
||||
*[part for alias in aliases.values() for part in ("--env", alias)],
|
||||
"--workdir",
|
||||
backend_cwd,
|
||||
executor.container_name,
|
||||
|
|
@ -389,6 +397,7 @@ def _execute_docker(
|
|||
text=True,
|
||||
errors="replace",
|
||||
stdin=subprocess.DEVNULL,
|
||||
**({"env": {**os.environ, **{aliases[key]: value for key, value in target_env.items()}}} if target_env else {}),
|
||||
**subprocess_new_group_kwargs(),
|
||||
)
|
||||
record_path = _register_process(
|
||||
|
|
@ -1194,14 +1203,17 @@ def _executor_service_env() -> dict[str, str]:
|
|||
return scrub_repo_from_pythonpath(service_env(), _system_repo_dir())
|
||||
|
||||
|
||||
def _docker_env_command(command: str, aliases: dict[str, str] | None) -> str:
|
||||
"""Expand selected values only in the target, never in host argv or shell code."""
|
||||
if not aliases:
|
||||
return command
|
||||
unset = shlex.join([part for alias in aliases.values() for part in ("-u", alias)])
|
||||
assignments = " ".join(f'{shlex.quote(key)}="${{{alias}}}"' for key, alias in aliases.items())
|
||||
return f"env {unset} -- {assignments} {command}"
|
||||
|
||||
|
||||
def _docker_service_start_shell(record: _ExecutorService, log_path: str, aliases: dict[str, str] | None = None) -> str:
|
||||
command = shlex.join(record.cmd)
|
||||
if aliases:
|
||||
# Expand values only inside the container; env removes transport aliases
|
||||
# and supports names that are not shell identifiers. No value is quoted into code.
|
||||
unset = shlex.join([part for alias in aliases.values() for part in ("-u", alias)])
|
||||
assignments = " ".join(f'{shlex.quote(key)}="${{{alias}}}"' for key, alias in aliases.items())
|
||||
command = f"env {unset} -- {assignments} {command}"
|
||||
command = _docker_env_command(shlex.join(record.cmd), aliases)
|
||||
exec_payload = shlex.quote(f"exec {command}")
|
||||
quoted_cwd = shlex.quote(record.backend_cwd)
|
||||
quoted_log = shlex.quote(log_path)
|
||||
|
|
|
|||
|
|
@ -278,24 +278,17 @@ def write_workspace_patch_artifacts(
|
|||
# every innocent in-flight sibling; shared-tree integrity is verified by the
|
||||
# reverse-patch check in tools/subagent_integration (verified_shared_workspace),
|
||||
# and base_sha stays the patch BASE so parent-committed work is still captured.
|
||||
if task_base_sha and acting_constraint is not None and acting_constraint.surface == "self_worktree":
|
||||
if (task_base_sha and acting_constraint is not None
|
||||
and acting_constraint.surface == "self_worktree" and current_head != base_head):
|
||||
if not current_head:
|
||||
errors.extend(head_errors)
|
||||
head_error = {
|
||||
"type": "workspace_head_unverified",
|
||||
"message": "workspace HEAD could not be verified at artifact finalization",
|
||||
"expected_head": base_head,
|
||||
"current_head": "",
|
||||
}
|
||||
errors.append(head_error)
|
||||
elif current_head != base_head:
|
||||
head_error = {
|
||||
"type": "workspace_head_changed",
|
||||
"message": "workspace HEAD changed during task execution; patch artifact is invalid",
|
||||
"expected_head": base_head,
|
||||
"current_head": current_head,
|
||||
}
|
||||
errors.append(head_error)
|
||||
errors.append({
|
||||
"type": "workspace_head_changed" if current_head else "workspace_head_unverified",
|
||||
"message": ("workspace HEAD changed during task execution; patch artifact is invalid"
|
||||
if current_head else "workspace HEAD could not be verified at artifact finalization"),
|
||||
"expected_head": base_head,
|
||||
"current_head": current_head or "",
|
||||
})
|
||||
if errors:
|
||||
total_size, digest = 0, ""
|
||||
status = ARTIFACT_STATUS_FAILED
|
||||
|
|
@ -316,6 +309,12 @@ def write_workspace_patch_artifacts(
|
|||
"workspace_root": str(root),
|
||||
"patch_name": "workspace.patch",
|
||||
"manifest_name": "workspace_patch.json",
|
||||
"base_provenance": ("task_constraint" if task_base_sha else "admission_head" if preflight_head
|
||||
else "empty_tree" if base_is_empty_tree else "capture_head"),
|
||||
"base_explanation": "Application patch relative to the recorded base; includes eligible workspace changes and may include branch differences. It does not attribute authorship.",
|
||||
"comparison_note": "No auxiliary comparison target was selected. Use vcs_diff(base=..., head=...) for two trees, or base only for the current worktree.",
|
||||
"current_branch": _git_stdout(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, allow_rc={0, 1}, errors=diagnostics).strip(),
|
||||
"tracking_upstream": _git_stdout(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], root, allow_rc={0, 128}, errors=diagnostics).strip(),
|
||||
"base_ref": base_ref,
|
||||
"base_head": base_head,
|
||||
"base_is_empty_tree": base_is_empty_tree,
|
||||
|
|
|
|||
|
|
@ -574,6 +574,9 @@ def test_an_older_fail_never_outvotes_the_pass_that_accepted_the_task(full_loop,
|
|||
f.reviewer_verdict = "PASS"
|
||||
return {"content": "", "tool_calls": [call("task_acceptance_review", {"claim": second}, "second-review")]}, 0.0
|
||||
if f.model_step == 3:
|
||||
# This scenario rewrites under B's settled PASS, not while B runs.
|
||||
with f.condition:
|
||||
assert f.condition.wait_for(lambda: f.settled_count >= 2, timeout=10)
|
||||
observation = f.ctx._acceptance_observation
|
||||
return {"content": json.dumps({"delivery_control": "replace", "full_answer": third,
|
||||
"acceptance_subject": {"owner_source_sha256": observation["owner_source_sha256"]}})}, 0.0
|
||||
|
|
|
|||
|
|
@ -114,8 +114,9 @@ def test_core_catalog_schema_bytes_and_handler_owners_are_stable():
|
|||
# the chat as an ordinary owner message) and the optional `max_wait_minutes`
|
||||
# bound joins its parameters. Diffing the whole catalog base to head shows
|
||||
# exactly those edits and nothing else.
|
||||
# Workflow scope: describe lazy artifact writes and the shared brace-mask selector.
|
||||
assert hashlib.sha256(schema_bytes).hexdigest() == (
|
||||
"532059cb95c431df39b0a950ced52e05228e88ec3ddd180bc0d2fa9a0cc8f616"
|
||||
"eb0f87df6a8507019363fe4fb9ba0e0d96cadb094e7619cb41b0821cb71c8e43"
|
||||
)
|
||||
assert {
|
||||
entry.name: (entry.handler.__module__, entry.handler.__name__)
|
||||
|
|
|
|||
|
|
@ -107,8 +107,9 @@ def test_git_catalog_schema_bytes_and_handler_owners_are_stable():
|
|||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
# Workflow scope: explicit local base/head comparisons on vcs_diff.
|
||||
assert hashlib.sha256(schema_bytes).hexdigest() == (
|
||||
"729fdf1425126168c7408e431611f70ddd11139fa1c4161628fbcec7a27bf8ec"
|
||||
"73f2e452b574e31135f4992750afcd08ecf3b329216df8203b07b7e5e7e32ef0"
|
||||
)
|
||||
assert {
|
||||
entry.name: (entry.handler.__module__, entry.handler.__name__)
|
||||
|
|
|
|||
|
|
@ -877,12 +877,11 @@ def test_whitespace_padded_head_is_not_classified(tmp_path, monkeypatch, quiet_b
|
|||
|
||||
|
||||
def test_relative_path_which_result_is_a_noop(tmp_path, monkeypatch, quiet_bootstrap):
|
||||
"""T10 pin: a which() hit through a RELATIVE PATH entry is unprovable from
|
||||
the worker process (exec resolves it against the command cwd instead), so
|
||||
the resolver must run as written — never substitute bundled node."""
|
||||
"""T10 pin: if which() still returns a relative candidate after binding
|
||||
PATH to the launch cwd, keep the unprovable launch as written."""
|
||||
bundled = _healthy_stub(tmp_path / "bundle" / "node-standalone" / "bin" / "node")
|
||||
monkeypatch.setattr(resolver, "resolve_bundled_node", lambda: str(bundled))
|
||||
monkeypatch.setattr(resolver.shutil, "which", lambda tok: "bin/node")
|
||||
monkeypatch.setattr(resolver.shutil, "which", lambda tok, path=None: "bin/node")
|
||||
ctx = _context(tmp_path)
|
||||
args = {"cmd": ["node", "app.js"]}
|
||||
resolved, trace = resolve_process_node(ctx, "run_command", args, runtime_mode="advanced")
|
||||
|
|
|
|||
|
|
@ -109,10 +109,16 @@ def test_shell_catalog_schema_bytes_and_handler_owners_are_stable():
|
|||
).encode()
|
||||
# run_script accepts any installed file interpreter; its temporary file
|
||||
# lives in an ignored workspace directory or the existing task drive.
|
||||
# Workflow scope: explicit saved-setting references and lazy-output guidance.
|
||||
assert hashlib.sha256(schema_bytes).hexdigest() == (
|
||||
"2e6ebf9e5d81bc2fb321bd9615d66af2c6cd1e58f7bcc23a1a557353049e99f8"
|
||||
"87606208e795ede931339a7ba106fd0cf795fae314e1bc02cafaba6af8c62660"
|
||||
)
|
||||
original = json.loads(schema_bytes)
|
||||
for schema in original:
|
||||
schema["parameters"]["properties"].pop("env_from_settings")
|
||||
schema["parameters"]["properties"]["outputs"]["description"] = (
|
||||
"Generated file paths to copy/register into the task artifact store after success."
|
||||
)
|
||||
original[1]["description"] = (
|
||||
"Run a short task-scoped temporary script with a declared interpreter. "
|
||||
"Use for multi-line diagnostics or harness helpers; generated script files live under the task drive. "
|
||||
|
|
|
|||
|
|
@ -326,6 +326,9 @@ def _golden() -> dict[str, dict]:
|
|||
# unavailable. Keep the historical corpus intact and assert the new observed
|
||||
# outcome explicitly rather than manufacture old evidence (04-AGENCY S1/S3).
|
||||
CURRENT_PRODUCER_CONTRACTS = {
|
||||
# Saved-setting selection uses the existing process access authority; its new
|
||||
# foreground refusal remains blocked through both text and native ACCESS_BLOCKED.
|
||||
"PROCESS_ENV_REFERENCE_BLOCKED": (True, "blocked"),
|
||||
"SAFETY_ADVICE": (False, "ok"),
|
||||
"LIGHT_MODE_REPO_CHANGED": (False, "ok"),
|
||||
"BROWSER_ACTION_OUTCOME_UNKNOWN": (True, "error"),
|
||||
|
|
|
|||
|
|
@ -147,10 +147,10 @@ def _events(path):
|
|||
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
|
||||
|
||||
|
||||
def _call(client=None, **kwargs):
|
||||
def _call(client=None, *, timeout=1.0, **kwargs):
|
||||
return vision._vision_query_with_timeout(client, prompt="Describe only this image",
|
||||
images=[{"url": "data:image/png;base64,AAAA"}],
|
||||
model=MODEL, timeout=1.0, **kwargs)
|
||||
model=MODEL, timeout=timeout, **kwargs)
|
||||
|
||||
|
||||
def test_private_ipc_publication_never_exposes_an_empty_control_file(tmp_path, monkeypatch):
|
||||
|
|
@ -179,7 +179,10 @@ def test_private_ipc_publication_never_exposes_an_empty_control_file(tmp_path, m
|
|||
def test_real_child_retains_one_generation_and_unknown_cost(child_fixture, mode):
|
||||
state, events, root = child_fixture
|
||||
state["mode"] = mode
|
||||
text, usage = _call()
|
||||
# Lost-create recovery must survive ordinary cold imports on its first
|
||||
# control outage. The other cases keep 20ms to prove that transport bounds
|
||||
# do not become logical inference deadlines.
|
||||
text, usage = _call(timeout=1.0 if mode == "lose_create" else 0.02)
|
||||
assert text == "pixels read exactly once 🐍"
|
||||
assert usage["cost"] is None and usage["cost_final"] is False
|
||||
assert len(usage["ledger_attempt_ids"]) == 1
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ def test_wait_yields_mailbox_without_acknowledging_or_stopping_child(tmp_path, f
|
|||
if batch:
|
||||
decoded = json.loads(result)
|
||||
assert decoded["early_return"]["reason"] == "owner_mailbox_pending"
|
||||
assert "deliver and acknowledge" in decoded["early_return_note"]
|
||||
assert "does not stop the child" in decoded["early_return_note"]
|
||||
assert decoded["all_terminal"] is False
|
||||
assert decoded["tasks"]["child"]["status"] == "running"
|
||||
else:
|
||||
|
|
|
|||
288
tests/test_workflow_process_environment.py
Normal file
288
tests/test_workflow_process_environment.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""The real foreground consumers share one selected environment and its egress."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_process_environment import process_context as _process_context
|
||||
|
||||
process_context = _process_context
|
||||
|
||||
pytestmark = pytest.mark.serial
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["host", "local"])
|
||||
@pytest.mark.parametrize("tool", ["run_command", "run_script", "verify_and_record"])
|
||||
def test_registered_foreground_env_reaches_child_and_redacts_egress(process_context, monkeypatch, backend, tool):
|
||||
from ouroboros import workspace_executor
|
||||
from ouroboros.outcomes import verification_receipts_path
|
||||
from ouroboros.loop_tool_execution import _execute_single_tool
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
if backend == "local":
|
||||
ctx.executor_ref = {"type": "local", "workspace_host_path": str(workspace), "workspace_backend_path": "/workspace"}
|
||||
secret = 'private-test-"quoted"\nsecond-line'
|
||||
expected_hash = hashlib.sha256(secret.encode()).hexdigest()
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret, "OPENAI_BASE_URL": "ordinary-8080"})
|
||||
calls = []
|
||||
resolve = workspace_executor.resolve_process_env
|
||||
|
||||
def observe(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return resolve(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(workspace_executor, "resolve_process_env", observe)
|
||||
program = "import os,hashlib; print(os.environ['TOKEN']); print(os.environ['PORT']); print(hashlib.sha256(os.environ['TOKEN'].encode()).hexdigest())"
|
||||
args = {"cwd": str(workspace), "env_from_settings": {"TOKEN": "CUSTOM_KEY", "PORT": "OPENAI_BASE_URL"}}
|
||||
if tool == "run_script":
|
||||
args.update(script=program, interpreter=sys.executable)
|
||||
elif tool == "verify_and_record":
|
||||
args.update(contract_kind="explicit_command", check=[sys.executable, "-c", program], expected=expected_hash)
|
||||
else:
|
||||
args.update(cmd=[sys.executable, "-c", program])
|
||||
logs = data / "logs"
|
||||
logs.mkdir(exist_ok=True)
|
||||
_execute_single_tool(registry, {"id": "env-proof", "function": {"name": tool, "arguments": json.dumps(args)}}, logs, task_id=ctx.task_id)
|
||||
records = (logs / "tools.jsonl").read_text(encoding="utf-8")
|
||||
assert expected_hash in records and "ordinary-8080" in records
|
||||
assert secret not in records and "private-test-" not in records
|
||||
assert calls == [1], "one frozen Settings selection per public invocation"
|
||||
if tool == "verify_and_record":
|
||||
receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert receipt["status"] == "pass" and receipt["returncode"] == 0
|
||||
assert expected_hash in receipt["summary"]
|
||||
assert "private-test-" not in json.dumps(receipt)
|
||||
assert receipt["runtime_provenance"]["selected_path"] == sys.executable
|
||||
|
||||
|
||||
def test_verify_matches_raw_selected_value_before_receipt_redaction(process_context, monkeypatch):
|
||||
from ouroboros.outcomes import verification_receipts_path
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
secret = "!"
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret})
|
||||
result = registry.execute_result("verify_and_record", {"contract_kind": "explicit_command",
|
||||
"check": [sys.executable, "-c", "import os; print(os.environ['TOKEN'])"],
|
||||
"cwd": str(workspace), "expected": secret, "env_from_settings": {"TOKEN": "CUSTOM_KEY"}})
|
||||
receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert receipt["status"] == "pass" and receipt["matched"] is True
|
||||
assert receipt["expected"] == "***" and secret not in result.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool,padding,suffix", [
|
||||
("run_command", 24982, 60000),
|
||||
("run_command", 60000, 24982),
|
||||
("run_script", 24982, 60000),
|
||||
("run_script", 60000, 24982),
|
||||
("verify_and_record", 3990, 60000),
|
||||
("verify_and_record", 19990, 60000),
|
||||
])
|
||||
@pytest.mark.parametrize("returncode,stream", [(0, "stdout"), (7, "stderr")])
|
||||
def test_selected_secret_is_masked_before_diagnostic_bounds(
|
||||
process_context, monkeypatch, tool, padding, suffix, returncode, stream,
|
||||
):
|
||||
from ouroboros.outcomes import verification_receipts_path
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
secret = "UNIQUE_CONFIDENTIAL_FRAGMENT_0123456789_END"
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret})
|
||||
program = (
|
||||
"import os,sys; "
|
||||
f"print('x'*{padding}+os.environ['TOKEN']+'y'*{suffix}, file=sys.{stream}); "
|
||||
f"sys.exit({returncode})"
|
||||
)
|
||||
args = {"cwd": str(workspace), "env_from_settings": {"TOKEN": "CUSTOM_KEY"}}
|
||||
if tool == "run_script":
|
||||
args.update(script=program, interpreter=sys.executable)
|
||||
elif tool == "verify_and_record":
|
||||
args.update(contract_kind="explicit_command", check=[sys.executable, "-c", program], expected=secret)
|
||||
else:
|
||||
args.update(cmd=[sys.executable, "-c", program])
|
||||
result = registry.execute_result(tool, args)
|
||||
rendered = result.text
|
||||
assert "truncated" in rendered
|
||||
if tool == "verify_and_record":
|
||||
receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert receipt["returncode"] == returncode and receipt["matched"] is True
|
||||
assert receipt["status"] == ("fail" if returncode else "pass")
|
||||
assert len(receipt["summary"]) < 20100
|
||||
assert len(rendered) < 6000
|
||||
rendered += json.dumps(receipt)
|
||||
else:
|
||||
assert result.meta["exit_code"] == returncode
|
||||
assert result.status == ("error" if returncode else "ok")
|
||||
assert len(rendered) < 52000
|
||||
assert "yyyyyyyyyy" in rendered, "the bounded tail remains available"
|
||||
assert "xxxxxxxxxx" in rendered, "ordinary output remains available"
|
||||
assert secret[:10] not in rendered and secret[-10:] not in rendered
|
||||
|
||||
|
||||
@pytest.mark.parametrize("match_mode", ["exact", "exact_line", "json_equals"])
|
||||
@pytest.mark.parametrize("matches", [True, False])
|
||||
def test_verify_secret_masking_preserves_strict_expected_match(
|
||||
process_context, monkeypatch, match_mode, matches,
|
||||
):
|
||||
from ouroboros.outcomes import verification_receipts_path
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
secret = "synthetic-exact-output-secret"
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret})
|
||||
output = "json.dumps(os.environ['TOKEN'])" if match_mode == "json_equals" else "os.environ['TOKEN']"
|
||||
expected = secret if matches else "wrong-value"
|
||||
if match_mode == "json_equals":
|
||||
expected = json.dumps(expected)
|
||||
result = registry.execute_result("verify_and_record", {
|
||||
"contract_kind": "explicit_command", "cwd": str(workspace),
|
||||
"check": [sys.executable, "-c", f"import os,json; print({output})"],
|
||||
"expected": expected, "expected_match": match_mode,
|
||||
"env_from_settings": {"TOKEN": "CUSTOM_KEY"},
|
||||
})
|
||||
receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert receipt["matched"] is matches and receipt["returncode"] == 0
|
||||
assert receipt["status"] == ("pass" if matches else "fail")
|
||||
assert secret not in result.text + json.dumps(receipt)
|
||||
assert "***" in result.text and "***" in receipt["summary"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["nonzero", "spawn", "timeout"])
|
||||
@pytest.mark.parametrize("tool", ["run_command", "run_script", "verify_and_record"])
|
||||
def test_selected_secret_does_not_escape_failed_process(process_context, monkeypatch, failure, tool):
|
||||
from ouroboros.outcomes import verification_receipts_path
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
secret = "synthetic-failed-process-secret"
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret})
|
||||
interpreter = str(workspace / secret) if failure == "spawn" else sys.executable
|
||||
program = "import os,sys,time; print(os.environ['TOKEN'],flush=True); " + ("sys.exit(7)" if failure == "nonzero" else "time.sleep(5)")
|
||||
args = {"cwd": str(workspace), "timeout_sec": 1, "env_from_settings": {"TOKEN": "CUSTOM_KEY"}}
|
||||
if tool == "run_script":
|
||||
args.update(script=program, interpreter=interpreter)
|
||||
elif tool == "verify_and_record":
|
||||
args.update(contract_kind="explicit_command", check=[interpreter, "-c", program])
|
||||
else:
|
||||
args.update(cmd=[interpreter, "-c", program])
|
||||
result = registry.execute_result(tool, args)
|
||||
assert secret not in result.text
|
||||
if tool == "verify_and_record" and failure != "spawn":
|
||||
receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert receipt["status"] == "fail"
|
||||
assert receipt["returncode"] == (7 if failure == "nonzero" else None)
|
||||
assert secret not in json.dumps(receipt)
|
||||
else:
|
||||
assert result.status != "ok"
|
||||
if failure == "nonzero" and tool != "verify_and_record":
|
||||
assert result.meta["exit_code"] == 7 and "***" in result.text
|
||||
|
||||
|
||||
def test_reference_authority_precedes_settings_read_and_spawn(process_context, monkeypatch):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.shell import _run_shell
|
||||
|
||||
_registry, ctx, workspace, _data = process_context
|
||||
ctx.task_constraint = TaskConstraint(mode="acting_subagent", surface="external_workspace", write_root=str(workspace))
|
||||
monkeypatch.setattr("ouroboros.config.runtime_settings", lambda **kwargs: pytest.fail("read forbidden Settings"))
|
||||
result = _run_shell(ctx, [sys.executable, "-c", "raise SystemExit('must not run')"],
|
||||
cwd=str(workspace), env_from_settings={"TOKEN": "CUSTOM_KEY"})
|
||||
assert "PROCESS_ENV_REFERENCE_BLOCKED" in result
|
||||
|
||||
|
||||
def test_non_run_verify_refuses_environment_instead_of_ignoring_it(process_context):
|
||||
registry, _ctx, _workspace, _data = process_context
|
||||
result = registry.execute_result("verify_and_record", {"contract_kind": "artifact_observation",
|
||||
"env_from_settings": {"TOKEN": "CUSTOM_KEY"}, "artifact_paths": []})
|
||||
assert result.code == "TOOL_ARG_ERROR" and "only to run-kind" in result.text
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX shim fixture; Windows environment merging has separate coverage")
|
||||
@pytest.mark.parametrize("runtime", ["python", "node"])
|
||||
def test_runtime_uses_explicit_child_path_not_host_path(process_context, monkeypatch, runtime):
|
||||
registry, _ctx, workspace, _data = process_context
|
||||
binary_dir = workspace / "chosen-bin"
|
||||
binary_dir.mkdir()
|
||||
binary = binary_dir / runtime
|
||||
if runtime == "python":
|
||||
binary.symlink_to(sys.executable)
|
||||
command = [runtime, "-c", "print('chosen-runtime')"]
|
||||
else:
|
||||
binary.write_text("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v22.1.0; else echo chosen-runtime; fi\n", encoding="utf-8")
|
||||
binary.chmod(0o755)
|
||||
command = [runtime, "-e", "unused"]
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"OPENAI_BASE_URL": str(binary_dir)})
|
||||
result = registry.execute_result("run_command", {"cmd": command, "cwd": str(workspace),
|
||||
"env_from_settings": {"PATH": "OPENAI_BASE_URL"}})
|
||||
assert result.status == "ok" and "chosen-runtime" in result.text, result.text
|
||||
if runtime == "python":
|
||||
assert str(binary) in result.text and '"source": "PATH"' in result.text
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX executable shims; native Windows execution is not exercised")
|
||||
@pytest.mark.parametrize("backend", ["host", "local"])
|
||||
@pytest.mark.parametrize("relative_dir", ["relative-node-bin", ""])
|
||||
def test_relative_selected_path_reaches_workspace_node(process_context, monkeypatch, backend, relative_dir):
|
||||
from ouroboros import process_interpreters
|
||||
|
||||
registry, ctx, workspace, data = process_context
|
||||
if backend == "local":
|
||||
ctx.executor_ref = {"type": "local", "workspace_host_path": str(workspace), "workspace_backend_path": "/workspace"}
|
||||
selected_dir = workspace / relative_dir
|
||||
selected_dir.mkdir(exist_ok=True)
|
||||
bundled_dir = data / "bundle"
|
||||
bundled_dir.mkdir()
|
||||
for directory, label in ((selected_dir, "chosen-relative-node"), (bundled_dir, "incorrect-bundled-node")):
|
||||
binary = directory / "node"
|
||||
binary.write_text(
|
||||
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo v22.1.0; else echo ' + label + '; fi\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
binary.chmod(0o755)
|
||||
selected_path = os.pathsep.join((relative_dir, "/usr/bin", "/bin"))
|
||||
monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"OPENAI_BASE_URL": selected_path})
|
||||
monkeypatch.setattr(process_interpreters, "resolve_bundled_node", lambda: str(bundled_dir / "node"))
|
||||
result = registry.execute_result("run_command", {"cmd": ["node", "-e", "unused"], "cwd": str(workspace),
|
||||
"env_from_settings": {"PATH": "OPENAI_BASE_URL"}})
|
||||
assert result.status == "ok" and result.meta["exit_code"] == 0, result.text
|
||||
assert "chosen-relative-node" in result.text and "incorrect-bundled-node" not in result.text
|
||||
events = [json.loads(line) for line in (data / "logs/events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
trace = next(event for event in reversed(events) if event.get("type") == "node_runtime_resolution")
|
||||
assert trace["requested_interpreter"] == trace["resolved_interpreter"] == "node"
|
||||
assert trace["runtime_path"] == str(selected_dir / "node")
|
||||
assert trace["path_snapshot"] == selected_path and not trace["env_path_prepend"]
|
||||
|
||||
|
||||
def test_unknown_wrapper_provenance_never_invents_a_python_path(process_context):
|
||||
registry, _ctx, workspace, _data = process_context
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX shell fixture")
|
||||
result = registry.execute_result("run_command", {"cmd": ["sh", "-c", "printf ready"], "cwd": str(workspace)})
|
||||
assert result.status == "ok" and "runtime inside wrapper not inspected" in result.text
|
||||
assert '"selected_path": null' in result.text
|
||||
|
||||
|
||||
def test_docker_target_env_uses_only_inert_host_aliases(monkeypatch, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
from ouroboros import workspace_executor as executor
|
||||
|
||||
seen = {}
|
||||
class Process:
|
||||
pid, returncode = 123, 0
|
||||
def __init__(self, command, **kwargs):
|
||||
seen.update(command=command, kwargs=kwargs)
|
||||
def communicate(self, **kwargs):
|
||||
return "done", ""
|
||||
monkeypatch.setattr(executor.subprocess, "Popen", Process)
|
||||
monkeypatch.setattr(executor, "_register_process", lambda *a: None)
|
||||
monkeypatch.setattr(executor, "_forget_process", lambda *a: None)
|
||||
ref = SimpleNamespace(network="default", container_name="isolated-fixture", kind="docker_exec", executor_id="fixture")
|
||||
target = {"DOCKER_HOST": "target-only", "PATH": "/target/bin", "TOKEN": "fake-$('literal')\nsecret"}
|
||||
result = executor._execute_docker(ref, ["python3", "-c", "print('done')"], "/workspace", 5, drive_root=tmp_path, target_env=target)
|
||||
assert result.returncode == 0
|
||||
host_env = seen["kwargs"]["env"]
|
||||
assert host_env.get("DOCKER_HOST") == os.environ.get("DOCKER_HOST")
|
||||
assert host_env["PATH"] == os.environ["PATH"]
|
||||
assert all(value not in " ".join(seen["command"]) for value in target.values())
|
||||
aliases = [seen["command"][i + 1] for i, value in enumerate(seen["command"]) if value == "--env"]
|
||||
assert len(aliases) == 3 and {host_env[key] for key in aliases} == set(target.values())
|
||||
137
tests/test_workflow_tool_contracts.py
Normal file
137
tests/test_workflow_tool_contracts.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Owner-facing tool contracts verified through their actual consumers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_vcs_target_binding import _registry, _git
|
||||
from tests.test_process_environment import process_context as _process_context
|
||||
|
||||
process_context = _process_context
|
||||
|
||||
pytestmark = pytest.mark.serial
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fallback", [False, True])
|
||||
def test_brace_search_returns_same_selected_languages(process_context, monkeypatch, fallback):
|
||||
from ouroboros import code_search_rg
|
||||
|
||||
registry, _ctx, workspace, _data = process_context
|
||||
for name in ("one.js", "two.css", "three.py", "space é.js"):
|
||||
(workspace / name).write_text("needle\n", encoding="utf-8")
|
||||
if fallback:
|
||||
monkeypatch.setattr(code_search_rg, "_rg_binary", lambda: "")
|
||||
elif not code_search_rg._rg_binary():
|
||||
pytest.skip("rg unavailable; forced fallback has separate coverage")
|
||||
result = registry.execute_result("search_code", {"query": "needle", "include": "*.{js,css}"})
|
||||
assert result.status == "ok", result.text
|
||||
assert all(name in result.text for name in ("one.js", "two.css", "space é.js"))
|
||||
assert "three.py" not in result.text
|
||||
excluded = registry.execute("search_code", {"query": "needle", "include": "*.css", "path": "one.js"})
|
||||
assert "No matches" in excluded and "0 files searched" in excluded
|
||||
empty = registry.execute("search_code", {"query": "needle", "include": "*.{rs,go}"})
|
||||
assert "0 file" in empty
|
||||
|
||||
|
||||
def test_explicit_rg_list_is_filtered_before_read(tmp_path, monkeypatch):
|
||||
from ouroboros import code_search_rg
|
||||
from tests.test_code_search_rg import _install_fake_rg
|
||||
|
||||
selected, excluded = tmp_path / "allowed.js", tmp_path / "excluded.py"
|
||||
selected.write_text("needle\n", encoding="utf-8")
|
||||
excluded.write_text("needle\n", encoding="utf-8")
|
||||
_install_fake_rg(tmp_path, monkeypatch)
|
||||
read = []
|
||||
result = code_search_rg.search_with_rg([selected, excluded], "needle", regex=False,
|
||||
include="*.{js,css}", path_allowed=lambda path: read.append(path) or True)
|
||||
assert read and set(read) == {selected}
|
||||
assert [match.path for match in result.matches] == [selected]
|
||||
assert result.files_selected == 1
|
||||
|
||||
|
||||
def test_vcs_diff_explicit_trees_index_and_worktree(tmp_path, monkeypatch):
|
||||
registry, _ctx, _system, project = _registry(tmp_path)
|
||||
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
|
||||
base = _git(project, "rev-parse", "HEAD")
|
||||
target = project / "project.txt"
|
||||
target.write_text("committed\n", encoding="utf-8")
|
||||
_git(project, "commit", "-am", "work")
|
||||
head = _git(project, "rev-parse", "HEAD")
|
||||
target.write_text("indexed\n", encoding="utf-8")
|
||||
_git(project, "add", "project.txt")
|
||||
target.write_text("working\n", encoding="utf-8")
|
||||
observed = {}
|
||||
for name, args in {
|
||||
"trees": {"base": base, "head": head},
|
||||
"index": {"base": base, "staged": True},
|
||||
"working": {"base": base},
|
||||
"default": {},
|
||||
}.items():
|
||||
result = registry.execute_result("vcs_diff", args)
|
||||
assert result.status == "ok", result.text
|
||||
observed[name] = result.text
|
||||
if args.get("base"):
|
||||
assert result.meta["comparison"]["base_tree"] == _git(project, "rev-parse", f"{base}^{{tree}}")
|
||||
assert "+committed" in observed["trees"] and "+working" not in observed["trees"]
|
||||
assert "+indexed" in observed["index"] and "+working" not in observed["index"]
|
||||
assert "+working" in observed["working"]
|
||||
assert "-indexed" in observed["default"] and "Comparison:" not in observed["default"]
|
||||
for args in ({"head": head}, {"base": base, "head": head, "staged": True}):
|
||||
assert registry.execute_result("vcs_diff", args).code == "TOOL_ARG_ERROR"
|
||||
assert "GIT_ERROR" in registry.execute("vcs_diff", {"base": "missing-ref"})
|
||||
|
||||
|
||||
def test_patch_provenance_does_not_change_application_bytes(tmp_path):
|
||||
from ouroboros.workspace_patch_capture import write_workspace_patch_artifacts
|
||||
|
||||
registry, _ctx, _system, project = _registry(tmp_path)
|
||||
base = _git(project, "rev-parse", "HEAD")
|
||||
_git(project, "checkout", "-qb", "task-branch")
|
||||
(project / "project.txt").write_text("branch difference\n", encoding="utf-8")
|
||||
_git(project, "commit", "-am", "branch")
|
||||
(project / "project.txt").write_text("corrected work\n", encoding="utf-8")
|
||||
output = tmp_path / "artifacts"
|
||||
_artifacts, manifest = write_workspace_patch_artifacts(project, output, task={
|
||||
"metadata": {"workspace_preflight": {"git": {"head": base}}},
|
||||
"workspace_preflight": {"git": {"head": base}},
|
||||
})
|
||||
patch = (output / "workspace.patch").read_bytes()
|
||||
assert manifest["base_head"] == base and manifest["base_provenance"] == "admission_head"
|
||||
assert manifest["current_branch"] == "task-branch"
|
||||
assert manifest["tracking_upstream"] == ""
|
||||
assert "does not attribute authorship" in manifest["base_explanation"]
|
||||
assert "vcs_diff" in manifest["comparison_note"]
|
||||
assert manifest["sha256"] == hashlib.sha256(patch).hexdigest()
|
||||
expected = subprocess.check_output(["git", "diff", "--binary", "--no-ext-diff", "--no-color", base, "--", "project.txt"], cwd=project)
|
||||
assert patch == expected
|
||||
destination = tmp_path / "apply"
|
||||
subprocess.run(["git", "clone", "-q", str(project), str(destination)], check=True)
|
||||
_git(destination, "checkout", "--detach", base)
|
||||
subprocess.run(["git", "apply", str(output / "workspace.patch")], cwd=destination, check=True)
|
||||
assert (destination / "project.txt").read_text(encoding="utf-8") == "corrected work\n"
|
||||
|
||||
|
||||
def test_external_edit_footer_does_not_forbid_authorized_git(tmp_path, monkeypatch):
|
||||
registry, ctx, _system, workspace = _registry(tmp_path)
|
||||
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
|
||||
written = registry.execute_result("write_file", {"path": "note.txt", "content": "old text\n"})
|
||||
edited = registry.execute_result("edit_text", {"path": "note.txt", "old_str": "old", "new_str": "new"})
|
||||
assert (workspace / "note.txt").read_text(encoding="utf-8") == "new text\n"
|
||||
for result in (written, edited):
|
||||
assert result.status == "ok", result.text
|
||||
assert "Do not commit" not in result.text
|
||||
assert "headless runner captures a workspace patch" in result.text
|
||||
from ouroboros.tools.edit_ops import workspace_edit_note
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
ctx.task_constraint = TaskConstraint(mode="acting_subagent", surface="self_worktree", write_root=str(workspace))
|
||||
assert "Do not commit this self-worktree" in workspace_edit_note(ctx)
|
||||
|
||||
|
||||
def test_cwd_argument_repair_and_lazy_artifact_schema(process_context):
|
||||
registry, _ctx, _workspace, _data = process_context
|
||||
result = registry.execute_result("run_command", {"cmd": ["unused"], "root": "system_repo"})
|
||||
assert result.code == "TOOL_ARG_ERROR" and "Use cwd=system_repo" in result.text
|
||||
description = registry.get_schema_by_name("write_file")["function"]["description"]
|
||||
assert "created lazily" in description and "artifact_store" in description
|
||||
Loading…
Add table
Add a link
Reference in a new issue