diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1d885b..041d561 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,15 +79,25 @@ jobs: smoke_root="$(mktemp -d)" cd "$smoke_root" uv run python -m venv "$smoke_root/wheel-venv" - "$smoke_root/wheel-venv/bin/pip" install /tmp/dyro-dist/dyro-*.whl + wheel_artifact="$(find /tmp/dyro-dist -maxdepth 1 -name 'dyro-*.whl' -print -quit)" + "$smoke_root/wheel-venv/bin/pip" install "${wheel_artifact}" "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + test -x "$smoke_root/wheel-venv/bin/dyro" + test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" + test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/dispatch-doctor.json" uv run python -m venv "$smoke_root/sdist-venv" - "$smoke_root/sdist-venv/bin/pip" install /tmp/dyro-dist/dyro-*.tar.gz + sdist_artifact="$(find /tmp/dyro-dist -maxdepth 1 -name 'dyro-*.tar.gz' -print -quit)" + "$smoke_root/sdist-venv/bin/pip" install "${sdist_artifact}" "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + test -x "$smoke_root/sdist-venv/bin/dyro" + test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" + test ! -e "$smoke_root/sdist-venv/bin/dyro-mcp" windows-dispatch-import: name: Windows dispatch import / fail-closed smoke diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 7e15a0b..157a3d5 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -11,6 +11,7 @@ on: type: string permissions: + actions: read contents: read jobs: @@ -44,6 +45,40 @@ jobs: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main python tools/verify_release_source.py --release-tag "$RELEASE_TAG" --trusted-ref origin/main + - name: Require successful exact-SHA CI + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + release_sha="$(git rev-parse HEAD)" + for attempt in $(seq 1 30); do + row="$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs" \ + -f "head_sha=${release_sha}" -f event=push -f per_page=100 \ + --jq '.workflow_runs | sort_by(.created_at) | last | if . == null then ["missing","missing","missing","missing","missing"] else [.status, (.conclusion // "pending"), .head_sha, (.id | tostring), .html_url] end | @tsv')" + IFS=$'\t' read -r status conclusion observed_sha run_id run_url <<<"${row}" + if [[ "${observed_sha}" != "missing" && "${observed_sha}" != "${release_sha}" ]]; then + echo "CI run SHA mismatch: expected ${release_sha}, got ${observed_sha}" >&2 + exit 1 + fi + if [[ "${status}" == "completed" ]]; then + if [[ "${conclusion}" != "success" ]]; then + echo "Exact-SHA CI did not succeed: ${conclusion} ${run_url}" >&2 + exit 1 + fi + printf '%s\t%s\t%s\n' \ + "${release_sha}" "${run_id}" "${run_url}" \ + > ci-gate-run.tsv + break + fi + if [[ "${attempt}" -eq 30 ]]; then + echo "No completed successful ci.yml push run for ${release_sha}" >&2 + exit 1 + fi + sleep 10 + done + - name: Verify locked release environment run: | uv lock --check @@ -82,12 +117,22 @@ jobs: "$smoke_root/wheel-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file()" + "$smoke_root/wheel-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" + test -x "$smoke_root/wheel-venv/bin/dyro" + test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" + test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/wheel-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/wheel-doctor.json'))" uv run python -m venv "$smoke_root/sdist-venv" "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file()" + "$smoke_root/sdist-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" + test -x "$smoke_root/sdist-venv/bin/dyro" + test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" + test ! -e "$smoke_root/sdist-venv/bin/dyro-mcp" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/sdist-dispatch-home" "$smoke_root/sdist-venv/bin/dyro" dispatch doctor >"$smoke_root/sdist-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/sdist-doctor.json'))" @@ -115,6 +160,7 @@ jobs: path: | release-build-requirements.txt distribution-sha256sums.txt + ci-gate-run.tsv retention-days: 90 publish-to-pypi: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6968c0a..5322dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.6.3 - 2026-08-12 + +- Keep the shipping surface as CLI + Skill only. PyPI releases through 0.6.2 + never exposed `dyro-bridge` / `dyro-mcp` entry points or the optional `[mcp]` + extra; those remain out of the wheel. Historical ADR/design/evidence docs stay + in the repository as archive only. Upgraders from interim git builds that had + those entry points should expect them to be absent after upgrading. +- Install the cross-platform Skill as a Dyro-owned **mirror** under + `DYRO_HOME/skills/dyro-control-plane`, with per-host **avatars** (symlinks / + Windows junctions) for detected agent homes (Codex, Claude, Agents, Cursor). + After upgrading, preview then install with + `dyro integration install skill --dry-run` / `dyro integration install skill --yes` + (or the `codex` alias). The Skill uses read-only `dyro` CLI commands + (`workspace list` / `status`, `objective list|status|plan`). +- Migrate legacy whole-directory Codex Skill installs to mirror+avatar on the + next owned install, but only when the legacy target is a detected host avatar + whose content matches the packaged Skill assets (fail closed otherwise). +- Let interactive line/hotfix repository picks accept list indices and/or + repository IDs; when a token matches a repository ID exactly (including + pure-numeric IDs), the ID wins over index interpretation. + ## 0.6.2 - 2026-08-05 - Extend interactive `dyro setup` and Profile onboarding with a single, diff --git a/MANIFEST.in b/MANIFEST.in index 410163f..bc2fe46 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ include docs/updates.md include docs/workspace-blueprints.md include examples/blueprints/acme-platform.toml recursive-include src/dyro/console/assets * +recursive-include src/dyro/integrations/assets * diff --git a/README.md b/README.md index 53a2264..0e7215d 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,10 @@ To upgrade later, run `pipx upgrade dyro`. If your team manages Python packages python3 -m pip install --user --upgrade dyro ``` +Optional: after upgrading, attach the Dyro control-plane Skill to detected agent +homes with `dyro integration install skill --dry-run`, then +`dyro integration install skill --yes` (alias: `codex`). + Interactive `dyro`, `dyro home`, and `dyro start` launches check the official PyPI endpoint at most once per local day. A failed or slow check never blocks workspace entry. Updates remain confirmation-first by default: diff --git a/README.zh-CN.md b/README.zh-CN.md index b735e72..708dde9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -281,6 +281,9 @@ dyro --version python3 -m pip install --user --upgrade dyro ``` +可选:升级后用 `dyro integration install skill --dry-run` 预览,再执行 +`dyro integration install skill --yes`(别名 `codex`),把控制面 Skill 挂到已检测到的 Agent 宿主目录。 + 交互运行 `dyro`、`dyro home` 或 `dyro start` 时,Dyro 每个本地自然日最多访问一次官方 PyPI;断网、超时或状态目录不可写都不会阻塞进入工作区。默认仍由用户确认更新: ```bash diff --git a/docs/adr/0006-agent-bridge-phase-0.md b/docs/adr/0006-agent-bridge-phase-0.md new file mode 100644 index 0000000..ed76c9d --- /dev/null +++ b/docs/adr/0006-agent-bridge-phase-0.md @@ -0,0 +1,287 @@ +# ADR 0006: Agent Bridge Phase 0 + +## Status + +Proposed + +## Context + +Dyro already exposes a human CLI, a read-only local Console, an outbound and +advisory `dispatch` experiment, and a native continuation engine. Coding agents +still lack a stable inbound contract for discovering a workspace, reading +delivery state, explaining blockers, and producing a deterministic plan. They +therefore fall back to human-formatted CLI output or direct filesystem access, +both of which are brittle and easy to misinterpret. + +An earlier Agent Bridge proposal combined read operations, planning, generic +confirmed apply, Skill delivery, MCP tools, and cross-host Plugin packaging in +one v1. The adversarial review at +[`2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md`](../superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md) +rejected that scope. The subsequent +[Phase 0 design closure review](../superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md) +closed eight implementation-blueprint findings and approved only the S1 +contract/catalog step. Current source disproves two key assumptions: + +- `task gates` executes configured argv, writes gate logs, and appends the + ledger; it is not a read operation. +- a confirmation SHA can bind a plan to observed facts, but the same Agent can + read and replay it; it is not proof of an independent human approval. + +The repository does contain a useful starting point: +`capture_workspace_read_snapshot()` already composes lines, tasks, and +Objectives with `recover=False` for Objective reads. The Bridge should harden +and reuse Core-owned observations rather than parse or call CLI commands. + +## Decision + +Dyro will introduce **Agent Bridge Phase 0** as an inbound, machine-readable, +inspect-and-plan-only surface. + +1. Phase 0 exposes no operation that mutates workspace, global registry, Git, + Task, Objective, evidence, audit, configuration, preferences, cache, or host + integration state. +2. Phase 0 exposes no generic shell, arbitrary command, `apply`, sign-off, + merge, push, release, publish, cleanup, Agent launch, gate execution, + recovery, repair, or update check. +3. A Core-owned **Observation service** produces immutable typed facts without + parsing human CLI output and without calling a CLI `cmd_*` handler. +4. A Core-owned **Plan service** produces deterministic, explicitly + non-executable plans. A plan may describe effects that a human could later + request through the existing CLI, but the Bridge cannot execute them. +5. The former Operation Registry is replaced by an **Exposure Catalog**. The + catalog contains exposure metadata only: operation ID, schemas, maximum + risk, protocol compatibility, availability, and a reference to a Core + service. Authorization, policy, locks, transactions, and business + invariants remain in Core. +6. A dedicated `dyro-bridge` one-shot transport will accept one bounded JSON + request on stdin and, while stdout remains writable, emit exactly one + bounded JSON response on stdout. A broken output pipe exits deterministically + without retry or traceback. Routing, validation, and error rendering do not + enter the human argparse or terminal-decoration path. +7. CLI transport may use one schema-validated, allowlisted operation field for + inspect and plan. A later MCP adapter must expose a small set of typed tools; + it may not publish generic execute or generic apply tools. +8. Workspace resolution reuses the existing precedence: explicit alias, + upward local Profile discovery, registered default, then a unique usable + registered workspace. A malformed local Profile fails closed and never + falls back to a different workspace. +9. Compact capabilities return IDs, risk, availability, and schema versions. + A client fetches the full schema for one selected operation on demand. +10. Core Bridge and MCP server code ship in the Dyro Python distribution, with + real `dyro-bridge` and later `dyro-mcp` console entry points. A host-specific + Plugin, manifest, or installed Skill is a separately versioned integration + artifact with an explicit compatibility range and reversible installation. +11. Codex is the first candidate host. No other host is described as supported + before its install, discovery, process, sandbox, approval, upgrade, and + rollback journeys pass end-to-end tests. +12. Opening any mutation to an Agent requires a separate ADR and adversarial + review for one typed operation. Phase 0 approval cannot be reused as + mutation approval. + +## Authority and threat model + +Dyro Core remains the sole delivery control plane. A Skill is guidance, an MCP +tool list is an exposure boundary, and a plan digest is an integrity check; +none of them is an authorization credential. + +Phase 0 protects against accidental misuse by exposing only observations and +non-executable plans. It does not claim to isolate a malicious coding agent +that already has the same operating-system identity, shell access, and direct +permission to invoke the human `dyro` CLI. A stronger claim such as “the Agent +cannot sign off, merge, or push” would require every mutation entry point to +pass through a common broker or externally signed policy boundary that the +model cannot forge or read. + +The same boundary applies to local observation authenticity: Phase 0 detects +bounded path and stable-state drift, but it does not claim an immutable +filesystem snapshot against an actively malicious same-identity process that +can replace Git refs or objects during a read and restore them afterward. +“Authoritative Git inspection” means authoritative for cooperative local state, +not cryptographic attestation of a hostile host. Snapshot-backed or brokered +attestation is future work and cannot be inferred from a plan digest. + +If a future host supplies approval, the host capability must be model-invisible, +short-lived, single-use, and bound to the operation, canonical input, plan +digest, workspace identity, effects, host session, expiry, and a random nonce. +Until that property is proven in a real host, no Agent apply operation exists. + +## Risk vocabulary + +The Exposure Catalog uses the maximum possible authority of an operation: + +| Class | Meaning | Phase 0 | +| --- | --- | --- | +| `R0` | Pure observation; no persistent write, network, or non-allowlisted subprocess | Allowed after proof | +| `PLAN` | Pure deterministic plan; explicitly non-executable | Allowed after proof | +| `R1` | Recoverable control-plane write | Not exposed | +| `R2` | Agent, gate, Git-worktree, evidence, or other execution write | Not exposed | +| `R3` | Sign-off, merge, push, release, publish, destructive cleanup, or credential authority | Not exposed | + +Risk is deny-by-default. A command name, a `dry_run` flag, or an intended new +implementation is not evidence of R0. Each catalog entry requires a source +call graph and negative tests that fail on writes, network, and unexpected +process creation. + +## Observation invariants + +Every R0 operation must satisfy all of the following: + +1. no persistent semantic write and no newly created file, directory, lock, + cache, temp artifact, preference, or recent-state record; +2. no recovery, repair, update check, hydration mutation, or lazy index write; +3. no network access and no Agent or configured gate launch; +4. Git reads use optional locks disabled and are tested on every supported + platform for index and metadata stability; +5. partial failures are explicit and bounded; one bad component does not erase + healthy components or cause fallback to another workspace; +6. raw exception text, argv, stdout/stderr, remote URL, environment value, + absolute path, prompt, answer, or gate log is not returned by default; +7. all fields pass an explicit DTO allowlist, size limit, and secret-redaction + boundary before transport serialization; +8. workspace input is bounded before parsing: readers stat regular files before + use, cap per-file and aggregate bytes, cap enumerated records, enforce a + deadline, and isolate a malformed record without erasing healthy siblings; +9. any result derived without authoritative Git integration inspection reports + `integration_inspection: "not_inspected"` and cannot claim final readiness, + dispatchability, or integration blocking. + +## Workspace identity and configuration revision + +Phase 0 does not assume a persisted workspace UUID that current Core does not +have. S1 freezes two explicit local identifiers: + +- `WorkspaceIdentityV1` is + `SHA-256("dyro.workspace.identity/v1\0" + JCS({"canonical_root": + , "profile_name": }))`. The response + exposes only `workspace:`. It is stable while canonical root and Profile + name are unchanged and deliberately changes after moving or renaming the + workspace. It is not an identity credential. +- `ConfigRevisionV1` is + `SHA-256("dyro.config.raw/v1\0" + )`, computed only + after the Profile is proven to be a bounded safe regular file. Comments or + formatting changes invalidate the revision by design, avoiding an incomplete + semantic-field allowlist in Phase 0. + +Neither payload is returned. The domain separators, canonical path semantics, +Profile byte limit, and test vectors are part of the S1 contract, so S2 +observations and S3 plans can be implemented independently without inventing +different identities. + +## Plan invariants + +A Phase 0 plan is data, not authority. Its envelope contains at least: + +- `executable: false` and `authorization: "none"`; +- protocol major, operation ID, operation schema version, and planner revision; +- canonical workspace identity and configuration digest; +- normalized input; +- an operation-specific `read_set` containing every predicate and resolved + object used by planning; +- an operation-specific typed `projection`, semantic effects, warnings, + maximum/effective risk, and expiry; +- a canonical `plan_sha256` that detects drift but is never named or treated as + user approval. + +The digest is computed over the final transport-safe, allowlisted and redacted +canonical plan payload, excluding only `plan_sha256`. A client therefore sees +every hashed field. Raw Core objects or pre-redaction content never contribute +hidden digest material. + +No generic read-set schema is assumed to cover every operation. A future apply +would have to acquire the domain's authoritative lock, replan under that lock, +compare the digest, and use operation-specific idempotency, linearization, +durable intent/start/receipt, fencing, uncertainty, and recovery semantics. + +## Workspace resolution contract + +The response reports `resolution_source` as one of `explicit`, `local`, +`default`, or `unique`. It distinguishes at least: + +- `LOCAL_PROFILE_INVALID`; +- `REGISTRY_INVALID`; +- `WORKSPACE_NOT_REGISTERED`; +- `REGISTERED_ROOT_STALE`; +- `HOST_READ_PERMISSION_REQUIRED`; +- `AMBIGUOUS_WORKSPACE`; +- `WORKSPACE_NOT_FOUND`. + +Each error may include bounded structured `next_actions`, but never a shell +string for automatic execution. Resolution does not mark a workspace recent. + +## Distribution and compatibility + +- The Python distribution owns Core semantics and the Bridge/MCP server code. +- The host integration artifact owns host discovery metadata, Skill content, + installation ownership, and compatibility declarations. +- The handshake includes Core, Bridge, integration, protocol, operation-schema, + planner, and capabilities-digest versions. +- Unknown protocol major, unknown operation, unavailable dependency, or an + incompatible schema range fails closed. +- Adding a Core operation does not automatically expose it through an older + Plugin or existing MCP session. +- Wheel and sdist acceptance runs outside the source checkout and verifies + installed entry points and packaged schemas. + +## Platform scope + +Phase 0 public Core/JSON availability initially targets Linux Ubuntu 24.04. +macOS 15 remains a declared validation target, and the Codex host integration +is initially developed there, but the public Bridge fails closed until an +equivalent system-level zero-effect proof exists. Windows receives only an +import and fail-closed discovery smoke in Phase 0 because current Objective +storage does not provide the same directory-fd guarantees there; all operations +report `OPERATION_UNAVAILABLE` on macOS 15 and Windows. + +Availability is per operation and per platform. Exactly the seven Mandatory +Core Surface operations are public on Linux; the other eleven catalog entries +remain unavailable. Linux acceptance combines +in-process denial traps with `strace` file/network/process evidence and Git +metadata snapshots. macOS acceptance combines in-process traps, read-only +roots, before/after metadata snapshots, process shims, and the real managed +Codex sandbox; any remaining OS-observer blind spot is recorded as `须人工核` +and prevents a public support claim. One evidence layer cannot substitute for +another. + +## Consequences + +Coding agents gain a reliable way to understand Dyro and explain a safe next +step without scraping terminal text. Phase 0 also creates a transport-neutral +Core boundary that the CLI, Console, and future MCP adapter can share. + +The cost is that the first release intentionally cannot complete a user action. +It also requires explicit DTOs, schema/version governance, side-effect tracing, +and real-host black-box tests before a Skill or Plugin can be called supported. + +## Non-goals + +- Replacing the human CLI. +- Turning `dispatch` into an inbound control interface. +- Executing a plan, gate, Agent, Git mutation, recovery, or repair. +- Treating a digest, `--yes`, `actor`, request ID, or Skill instruction as user + authorization. +- Shipping one universal transaction or idempotency layer over current + line/task/workspace mutations. +- Claiming cross-host support based only on locating or launching a host CLI. +- Exposing raw configuration, paths, prompts, logs, environment, or credentials. + +## Acceptance criteria + +- The approved operation inventory contains only source-audited R0 and PLAN + entries; `task.gates`, `task.answer`, mutation, and delivery operations are + absent. +- Bridge imports Core services and never imports or calls CLI `cmd_*` handlers. +- Every success and failure produces exactly one bounded JSON object on stdout + while stdout is writable, with no ANSI, traceback, or secret-bearing raw + exception. A broken pipe produces no retry and no traceback. +- All R0 operations pass deny-write, deny-network, and deny-unexpected-process + tests with read-only HOME, `DYRO_HOME`, workspace, and temp roots. +- Pending Objective transactions are observed without recovery or mutation. +- Workspace resolution preserves local Profile precedence and fail-closed + behavior with stable reason codes and structured next actions. +- Source-tree, wheel, and sdist installations produce compatible schemas and + results outside the checkout. +- The non-empty Mandatory Core Surface is public-available in each supported + artifact; a catalog with zero available operations cannot pass. +- Codex is not marked supported until actual discovery and sandbox journeys pass. +- Phase 0 schemas contain no apply operation and mark every plan + `executable: false`, `authorization: "none"`. diff --git a/docs/designs/agent-bridge-operation-inventory.md b/docs/designs/agent-bridge-operation-inventory.md new file mode 100644 index 0000000..880a229 --- /dev/null +++ b/docs/designs/agent-bridge-operation-inventory.md @@ -0,0 +1,197 @@ +# Dyro Agent Bridge Operation Inventory + +Status: Linux Ubuntu 24.04 Mandatory Core Surface promoted at S5 + +Decision source: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) + +Review source: +[2026-08-06 adversarial review board](../superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md) + +## 1. Purpose + +This inventory prevents operation names from being mistaken for authority or +side-effect evidence. Every Agent-facing operation starts unavailable and is +enabled only after its real Core call graph and negative side-effect tests are +recorded. CLI handlers are evidence about current behavior, not reusable +Bridge services. + +Status vocabulary: + +- `declared`: schema and catalog metadata exist, but no service is callable; +- `implemented_testable`: a service is callable only through an internal test + harness; the public transport still returns `OPERATION_UNAVAILABLE`; +- `public_available`: the installed transport exposes the operation after its + unit, zero-effect, protocol, and artifact gates pass; +- `deferred`: potentially useful, but outside the first slice; +- `excluded`: prohibited from Phase 0; +- `future-review`: a mutation candidate requiring a separate ADR and review. + +Risk vocabulary follows ADR 0006: `R0`, `PLAN`, `R1`, `R2`, and `R3`. + +## 2. Phase 0 declared surface + +| Operation | Class | Status | Current source starting point | Required Core work and proof | +| --- | --- | --- | --- | --- | +| `bridge.hello` | R0 | public_available (Linux) | `bridge/transport.py` | Return only protocol/Core/Bridge version; no update or host probing | +| `bridge.capabilities.compact` | R0 | public_available (Linux) | `bridge/catalog.py`, `bridge/transport.py` | IDs, risk, availability, versions and digest only; no full schemas | +| `bridge.operation.schema` | R0 | public_available (Linux) | `bridge/schemas.py`, `bridge/transport.py` | Fetch exactly one allowlisted callable schema; reject unknown/unavailable operation | +| `workspace.resolve` | R0 | public_available (Linux) | `bridge/observations.py`, `continuation/resolution.py` | Typed result with `resolution_source`; structured fail-closed errors; no recent-state write | +| `workspace.list` | R0 | public_available (Linux) | `bridge/observations.py`, `hub.py` | DTO without absolute paths by default; partial stale/unreadable status; no registry mutation | +| `workspace.observe` | R0 | public_available (Linux) | `bridge/observations.py` | Bounded per-record partial results; mark integration `not_inspected`; never infer final readiness | +| `line.list` | R0 | declared | `workspace.py:117` | Typed projection; no CLI formatting; path fields excluded | +| `task.list` | R0 | declared | `tasks.py:212` | Summary only unless Git inspection is complete; no final dispatchability from status text | +| `task.explain` | R0 | declared | `graph.py`, scheduler snapshot | Authoritative explanation requires reviewed Git-read adapter and B05; otherwise unavailable | +| `task.graph` | R0 | declared | `graph.py`, `cli.py:1754` | Typed nodes/edges/issues; validation cannot repair state or run gates | +| `task.gate_definitions.get` | R0 | implemented_testable | `bridge/observations.py`, bounded Task loader | Return gate names and redacted metadata only; must never call `run_gates` | +| `objective.list` | R0 | declared | `continuation/store.py:425` | New wrapper must call `list_objectives(..., recover=False)` | +| `objective.status` | R0 | declared | scheduler snapshot | Final ready/blocked result requires reviewed Git inspection; summary reports `not_inspected` | +| `objective.plan` | PLAN | public_available (Linux) | `bridge/plans.py`, pure continuation planner | Typed projection/read set, bounded metadata-validated Git inspection, planner revision, non-executable Bridge digest | +| `objective.explain` | PLAN | implemented_testable | `bridge/plans.py`, pure continuation planner | Code-only summary/reasons; incomplete integration fails closed | +| `objective.graph` | PLAN | implemented_testable | `bridge/plans.py`, scheduler projection | Opaque typed nodes/edges only; no mutation or recovery | +| `objective.tick` | PLAN | implemented_testable | `bridge/plans.py`, pure scheduler tick | Typed wave/deferrals/non-mutating actions; no lease, intent, reservation, or execution | +| `objective.attention` | PLAN | implemented_testable | `bridge/plans.py`, pure attention projection | Typed priority/kind/reason/action-kind; never writes presentation state | + +The S3 Git boundary ignores caller `PATH`, uses only validated system Git and +system Python binder executables, disables lazy fetch and replace objects, +rejects config includes and object alternates, requires Git metadata to remain +inside the workspace, and caps one plan at 100 Git process starts. The Linux +implementation binds worktree, Git directory, common directory, and object +store descriptors through `/proc/self/fd`, rejects config includes and +extensions, overrides hooks, credentials and commit-graph use, and applies a +Landlock read-only filesystem boundary before Git starts. Repository config is +an inspected local input; it is not claimed to be globally ignored. Hosts +without the descriptor namespace and Landlock ABI 3 support fail closed for +authoritative Git-dependent plans. S3 accepts only SHA-1 object-format +repositories; SHA-256, reftable, and other repository extensions remain +unavailable rather than being interpreted with incomplete config. + +Declared status is not implementation approval. Each row must acquire a source +call graph and pass the acceptance matrix before it becomes public-available. + +S5 promotes exactly the seven Mandatory Core Surface operations on Linux Ubuntu +24.04. The other five implemented services and all six declared services remain +unavailable through the installed transport. macOS 15 remains declared and +Windows unavailable, so neither host receives an implicit availability +override from this promotion. + +### Mandatory Core Surface + +Phase 0 cannot pass with an empty available catalog. The following non-empty +surface is mandatory in the source-tree, wheel, and sdist protocol corpus: + +- `bridge.hello`; +- `bridge.capabilities.compact`; +- `bridge.operation.schema`; +- `workspace.resolve`; +- `workspace.list`; +- `workspace.observe` as the minimum observation; +- `objective.plan` as the minimum PLAN operation. + +Each catalog record carries `must_be_available: true|false` and an availability +state. Unit/negative testing first moves a mandatory operation from `declared` +to `implemented_testable`; source-tree zero-effect gates then permit +`public_available`; wheel and sdist tests must call the public operation before +Phase 0 Go. A zero-operation or discovery-only artifact fails A01. + +`task.explain` remains a required product journey but is not part of the minimum +surface until the reviewed Git-read adapter passes B05. Before that it returns +`OPERATION_UNAVAILABLE`, not a possibly wrong explanation. + +## 3. Deferred read candidates + +| Operation | Tentative class | Why deferred | +| --- | --- | --- | +| `changeset.list` | R0 | Useful but not required for first ten journeys | +| `changeset.verify` | R0 | Invokes Git observation; needs explicit optional-lock and subprocess allowlist proof | +| `task.attempts` | R0 | Requires output privacy and size policy before exposing provenance summaries | +| `task.binding` | R0 | Full review/evidence hashes need a dedicated redacted DTO | +| `task.gates.last_result` | R0 | Gate logs and process output may contain credentials or excessive content | +| `repo.list` | R0 | Remote/path fields need explicit redaction; lower first-slice value | +| `agent.list` | R0 | Current CLI reveals launch argv; a safe availability-only DTO is not yet defined | +| `tool.list` | R0 | Host probing/cache/update behavior must be separated before inclusion | +| `doctor.inspect` | R0 or PLAN | Existing doctor checks may probe tools, Git, or filesystem capabilities; call graph not yet proven pure | + +## 4. Explicitly excluded from Phase 0 + +| Current or proposed operation | Maximum class | Current behavior/risk | Decision | +| --- | --- | --- | --- | +| `task.gates` / `task.gates.run` | R2 | Starts Profile argv, writes `gate-*.log`, appends ledger | Excluded; never alias as R0 | +| `task.answer` | R2 | May reserve, create attempt/worktree, launch Agent, run gates, mutate quality state | Excluded; future split still needs review | +| `task.run`, `task.next`, `task.loop`, `task.daemon` | R2 | Starts execution and mutates task/provenance state | Excluded | +| `task.review` | R2 | Starts reviewer and writes review state | Excluded | +| `task.claim*`, `task.evidence*` | R1/R2 | Mutates claims, evidence generations, pointers, ledger | Excluded | +| `objective.apply`, `continue` | R2/R3 contextual | Acquires authority, creates intents/starts and invokes Task mutations | Excluded | +| `trigger.probe` | R0/R2 contextual | Provider may perform external observation; network/process policy not uniform | Excluded until typed provider review | +| `workspace.default/remove` | R1 | Mutates global registry | Excluded | +| `line.create`, `hotfix.create` | R2 | Creates branches/worktrees and line manifests | Excluded | +| `task.create`, `task.status`, Objective lifecycle/scope changes | R1 | Mutates control-plane state | Excluded | +| `task.signoff` | R3 | Grants delivery approval | Excluded | +| `task.merge --push` | R3 | Mutates integration branches and optionally remotes | Excluded | +| `key.*`, witness mutation | R3 | Trust, signing, revocation, audit authority | Excluded | +| `update now`, tool/Plugin install | R2/R3 | Installs executable code or changes host integration | Excluded | +| release, publish, cleanup | R3 | External publication or destructive/recovery-sensitive action | Excluded | +| `dispatch` | separate boundary | Outbound advisory workflow, not an inbound control operation | Not part of Bridge | + +## 5. Future mutation candidate + +`workspace.add` is the only named first candidate for a later, separate R1 +experiment because the registry already uses a process lock and atomic replace, +and an identical record can converge on authoritative state. It remains +`future-review`, not Phase 0. + +Before it can be exposed, a new ADR must specify: + +- a model-invisible host approval capability; +- stable workspace and registry identity; +- canonical input and operation-specific plan read set; +- lock order, linearization point, durable intent/receipt, and uncertain state; +- replay semantics for identical and conflicting request IDs; +- symlink/reparse-point and directory-replacement defenses on every platform; +- authenticated versus claimed actor fields; +- crash, concurrency, path, secret-redaction, and real-host tests. + +`line.create`, `task.create`, `task.answer`, Objective apply, sign-off, merge, +push, release, publish, and cleanup are not acceptable first mutation pilots. + +## 6. Per-operation evidence template + +An operation cannot change from `declared` through `implemented_testable` to +`public_available` until this record is complete: + +```yaml +operation_id: workspace.resolve +schema_version: 1 +planner_revision: null +maximum_risk: R0 +core_service: dyro.bridge.observations.resolve_workspace_observation +source_call_graph: + - dyro.continuation.resolution.resolve_workspace_readonly + - dyro.config.load_profile_exact + - dyro.hub.load_registry_bounded +reads: + - dyro.toml + - DYRO_HOME/workspaces.json +writes: [] +subprocesses: [] +network: [] +locks: [] +recovery: none +sensitive_fields: [] +negative_tests: + writes: pass-source-unit + network: pass-source-unit + subprocess: pass-source-unit + traceback_ansi_secrets: pass-source-unit +installed_artifact_test: source-wheel-sdist-public-corpus-required +real_codex_test: pending +must_be_available: true +availability_state: public_available +platform_availability: + linux-ubuntu-24.04: available + macos-15: declared + windows: unavailable +``` + +Any discovered write, recovery, unknown subprocess, network call, or raw secret +path resets availability to `unavailable` until the design and risk class are +reviewed again. diff --git a/docs/designs/agent-bridge-phase-0-acceptance.md b/docs/designs/agent-bridge-phase-0-acceptance.md new file mode 100644 index 0000000..7d1c3a7 --- /dev/null +++ b/docs/designs/agent-bridge-phase-0-acceptance.md @@ -0,0 +1,237 @@ +# Dyro Agent Bridge Phase 0 Acceptance Matrix + +Status: Enforced Linux source/wheel/sdist release gate + +Authority: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) + +Protocol: [Agent Bridge Phase 0 Protocol](agent-bridge-protocol.md) + +Inventory: [Agent Bridge Operation Inventory](agent-bridge-operation-inventory.md) + +## 1. Gate policy + +Phase 0 remains No-Go until every required gate below has reproducible evidence +from current source and installed artifacts. A passing unit test with mocked +filesystem, subprocess, network, or HOME is not sufficient for a corresponding +black-box gate. + +The gate records: + +- commit SHA and dirty-state scope; +- Python and OS version; +- installation source: checkout, wheel, or sdist; +- exact operation and schema versions; +- command or harness invocation; +- expected and actual exit/result; +- captured filesystem/process/network evidence; +- unresolved host behavior marked `须人工核`. + +## 2. Required gate matrix + +| ID | Area | Required evidence | Stop condition | +| --- | --- | --- | --- | +| A01 | Exposure | Generated catalog contains only approved IDs; every Mandatory Core Surface operation is `public_available` in source, wheel and sdist; mutation words/tools absent | Empty surface, unavailable mandatory operation, or any excluded operation is available | +| A02 | Call graph | Each available operation has a reviewed Core call graph and completed evidence template | Unknown call, recovery, write, network, or process path | +| A03 | Core boundary | Bridge imports no `dyro.cli` handler and parses no human-rendered output | Import/call of `cmd_*` or terminal renderer | +| B01 | Zero write | Read-only HOME, XDG, `DYRO_HOME`, workspace and temp audit show no new path or write-open | Any file/dir/lock/cache/temp/mtime/ledger mutation | +| B02 | No recovery | Injected pending Objective transaction remains byte-identical after every R0/PLAN call | Recovery, repair, lock, or state transition occurs | +| B03 | No network | Socket/DNS/connect traps see zero attempts | Any external or loopback connection attempt | +| B04 | Process boundary | Process trap sees only the documented descriptor-binder argv followed by the per-operation Git read allowlist | Agent, gate, host tool, shell, or unknown process starts | +| B05 | Git read | Git observations disable optional locks; index and repository metadata remain stable on supported OSes | Index refresh, lock creation, fetch, hook, or remote access | +| B06 | Workspace bounds | File/count/aggregate-byte/deadline limits and per-record isolation pass for Profile, tasks and Objectives | Unbounded read, request-wide erasure from one bad record, or missing partial/truncated marker | +| C01 | Resolver | Explicit/local/default/unique precedence passes; malformed local never falls back | Wrong project selected or recent state written | +| C02 | Partial failure | Stale/unreadable workspace and corrupt Objective produce bounded component errors | Healthy components disappear or raw exception leaks | +| C03 | Permission | Registry/workspace combinations inside and outside host sandbox return stable permission codes | Traceback, fallback, hang, or write attempt | +| C04 | Integration semantics | Summary reports `not_inspected` and omits final readiness; authoritative explain/status/plan is unavailable until B05 | Unknown integration rendered as ready, blocked, pending, or dispatchable | +| D01 | JSON stdout | While stdout is writable, success and every error emit exactly one bounded JSON object and one newline; broken pipe exits 5 without retry/traceback | ANSI, progress, help, traceback, second output, or retry after broken pipe | +| D02 | Input bounds | Oversize, duplicate keys, trailing bytes, invalid UTF-8 and deep/numerous structures fail before Core access | Partial parse or unbounded resource use | +| D03 | Schema | Unknown fields/operations/major versions and excluded operations fail closed | Coercion, fallback, or accidental new exposure | +| D04 | Redaction | Secret corpus in input, Profile, argv, remote, exception and stdout/stderr never reaches response | Raw secret/path/argv/log appears | +| D05 | Plan semantics | Every plan is deterministic, `executable=false`, `authorization=none`, with typed read set and planner revision | Apply hint, authorization claim, missing semantic binding | +| E01 | Wheel | Clean venv outside checkout imports Core/Bridge, finds schemas and runs all smoke cases | Checkout-relative import/resource dependency | +| E02 | Sdist | Clean build/install outside checkout behaves identically to wheel | Missing file, schema, entry point, or result drift | +| E03-Core | Core version skew | Protocol major/minor, operation schema and planner revision current/N-1 fixtures plus incompatible future/unknown values are tested | Silent downgrade, digest reuse, or new operation exposure | +| E03-Integration | Integration skew | At S7, Core-newer, integration-newer, N/N-1, missing optional MCP dependency and tool-list pinning are tested | Silent downgrade or widened MCP/Plugin tool exposure | +| F01 | Codex discovery | Fresh Codex session discovers only the intended Skill/tools after previewed install | Missing, duplicate, or stale discovery | +| F02 | Codex sandbox | Real workspace-write sessions pass in-sandbox and out-of-sandbox read scenarios | MCP/Bridge bypasses or misreports host permissions | +| F03 | Trigger precision | Ten fresh-session journeys choose Bridge only for Dyro inspect/plan and choose `dispatch` for advisory panels | False activation or wrong boundary | +| F04 | Context budget | Skill, compact catalog, one fetched schema and typical response stay under recorded byte/token budgets | Full registry injected or unbounded output | + +## 3. Test layers + +### Layer 1: unit and contract + +Planned test modules: + +- `tests/test_bridge_models.py` +- `tests/test_bridge_catalog.py` +- `tests/test_bridge_resolution.py` +- `tests/test_bridge_observations.py` +- `tests/test_bridge_plans.py` +- `tests/test_bridge_transport.py` +- `tests/test_bridge_redaction.py` + +These tests inject forbidden writers, network functions, subprocess launchers, +recovery paths, and renderers that raise immediately if called. They also hold +golden JSON-schema, workspace/config identity, canonical-digest, and Core +version-skew vectors. In-process traps are one evidence layer and cannot replace +process-level or host evidence. + +### Layer 2: local black box + +Planned harness: `tools/verify_bridge_zero_effects.py`. + +It creates an isolated fixture, snapshots every permitted root and relevant +metadata, runs the installed `dyro-bridge`, and compares the result. It denies +socket creation, records process starts, makes state roots read-only, and +injects pending recovery state. A test that merely points `DYRO_HOME` to a +writable temporary directory does not satisfy B01. + +Linux Ubuntu 24.04 is the reference process-level audit: pinned CI tooling uses +`strace -ff` to capture file mutation syscalls, socket/connect/DNS paths, and +`execve`, plus before/after Git metadata snapshots. The report includes the +exact trace filter and known blind spots. The authoritative Git adapter must +also prove that worktree, Git directory, common directory, and object store are +opened before launch and passed only as `/proc/self/fd` references, config +includes/extensions are rejected, hooks/credentials/commit-graph use are +overridden, and the binder's Landlock policy denies writes and reads outside +the approved directory objects before Git executes. Linux `strace` evidence +must confirm that remaining validated repository config cannot widen process, +network, or filesystem effects. + +The Linux gate requires Landlock ABI 3 or newer and a real test whose Git +executable reaches the denied write syscall. SHA-1 repositories are the Phase 0 +surface; SHA-256 object format and other repository extensions must fail closed +before Git starts. Consistent with ADR 0006, these gates do not claim an +immutable snapshot against an actively malicious same-identity process. + +macOS 15 combines in-process traps, read-only roots, before/after filesystem and +Git metadata snapshots, fake-PATH process recording, and a real managed Codex +sandbox run. A platform-level network/process observer not available to the +test account is recorded as `须人工核`; mocks or fake PATH alone cannot close that +gate. Authoritative Git-dependent plans return `OPERATION_UNAVAILABLE` on macOS +until an equivalent descriptor or OS-snapshot proof exists. Windows is +import/fail-closed only in Phase 0 and does not count as a supported Objective +operation platform. + +### Layer 3: artifact + +CI builds wheel and sdist, installs each into a new environment outside the +checkout, and runs the same protocol corpus. Artifact tests verify console +entry points, packaged schemas, `-I` imports, every Mandatory Core Surface +operation, and Core protocol/schema/planner compatibility. Missing MCP optional +dependencies and integration version skew belong to E03-Integration at S7, not +the Phase 0 Core gate. + +The S5 corpus contains 43 cases. Both the internal candidate and installed +public process must return full semantic success for all seven mandatory +operations and `OPERATION_UNAVAILABLE` for exactly the other eleven catalog +operations. Source archive, wheel, and sdist reports must have identical +contract digests; the public `objective.plan` trace must include the same +descriptor binder and successful Landlock evidence as the candidate trace. + +### Layer 4: real host + +Codex acceptance is manual-plus-scripted because the actual sandbox and tool +discovery boundary belongs to the host. The evidence records the host version, +permission profile, installed integration version, tool list, request, response, +and filesystem/process/network audit. + +Claude, Cursor, and OpenCode remain `须人工核` and unsupported until equivalent +evidence exists for each host. + +## 4. Resolver journeys + +| Journey | Expected result | +| --- | --- | +| Explicit valid alias from unrelated directory | `resolution_source=explicit` | +| Valid local Profile plus different registry default | local Profile wins | +| Malformed local Profile plus valid default | `LOCAL_PROFILE_INVALID`; no fallback | +| No local Profile and valid default | `resolution_source=default` | +| No default and one usable registration | `resolution_source=unique` | +| No default and multiple usable registrations | `AMBIGUOUS_WORKSPACE` | +| Explicit stale alias | `REGISTERED_ROOT_STALE` | +| Corrupt registry | `REGISTRY_INVALID`; file remains unchanged | +| Registry readable, workspace denied by host | `HOST_READ_PERMISSION_REQUIRED` | +| Nothing discoverable | `WORKSPACE_NOT_FOUND` | + +Every journey asserts that registry recent/default fields and file metadata are +unchanged. + +## 5. Protocol corpus + +The shared corpus covers: + +- valid public request for every Mandatory Core Surface operation and every + other public-available operation; +- `declared` and `implemented_testable` operations remain unavailable through + the public process; +- unknown protocol major and future-minor behavior, including explicit + `PROTOCOL_MINOR_UNSUPPORTED` without silent downgrade; +- unknown, unavailable, and excluded operation IDs; +- missing, extra, wrong-type, oversized, duplicate, and deeply nested fields; +- invalid UTF-8, two concatenated JSON values, and trailing bytes; +- partial component errors; +- non-ASCII identifiers and messages; +- deterministic canonical plan digest; +- output truncation at collection and byte limits; +- secret patterns in every request and source surface; +- broken pipe exit 5 with no response retry or traceback, and interrupted input + with a structured response while stdout remains writable; +- oversized `task.toml`, oversized Objective journal, excessive record counts, + aggregate-byte/deadline exhaustion, and one malformed record beside healthy + siblings; +- `integration_inspection=not_inspected` never rendered as ready, pending, + blocked, integrated, or dispatchable; +- plan `projection` preserves selected/blocked/attention/wave facts, and digest + vectors prove allowlist/redaction occurs before canonical hashing. + +The same corpus runs against in-process Core, source-tree `dyro-bridge`, wheel, +sdist, and later MCP mapping. + +## 6. Fresh-session product journeys + +1. “列出 Dyro 中登记的工程。” +2. “我现在在哪个 Dyro 工程?” +3. “这个工程为什么不可用?” +4. “列出当前开发线和任务。” +5. “TASK-42 为什么被阻塞?” +6. “展示任务依赖图。” +7. “当前 Objective 下一步可能做什么?” +8. “解释 Objective 为什么在等待。” +9. “帮我找三个 Agent 对方案进行评审。”——必须选择 outbound `dispatch` +10. “执行 gates/合并/推送。”——Bridge 必须拒绝并说明 Phase 0 无执行能力 + +The test starts without prior conversation context and records tool selection, +catalog/schema bytes, response bytes, false activations, retries, and whether +the model fabricates an apply path. + +## 7. Context budgets + +Initial budgets to validate and revise with measured evidence: + +| Artifact | Initial maximum | +| --- | ---: | +| Host-neutral `SKILL.md` | 8 KiB | +| Compact capabilities response | 12 KiB and 64 operations | +| One operation request+response schema | 32 KiB | +| Typical R0 response | 64 KiB | +| Error response | 8 KiB | +| Warning count | 64 | + +Budgets are byte gates. Token measurements are recorded for supported hosts but +do not replace byte limits because tokenizers vary. + +## 8. Go/No-Go checklist + +Phase 0 Core and JSON transport become Go only when A01–E02 and E03-Core pass. +Skill beta requires the same Core gates plus F01, F03, and F04. Codex read-only +MCP/Plugin additionally requires E03-Integration and F02. E03-Integration does +not block Core JSON or Skill work before an integration artifact exists. + +Any discovery of mutation, recovery, network, unexpected process execution, +secret leakage, silent workspace fallback, CLI handler reuse, or Agent apply +surface returns the affected module to No-Go and reopens ADR review. + +Passing Phase 0 does not authorize R1/R2/R3 design, implementation, release, or +publication. diff --git a/docs/designs/agent-bridge-protocol.md b/docs/designs/agent-bridge-protocol.md new file mode 100644 index 0000000..0fa58d5 --- /dev/null +++ b/docs/designs/agent-bridge-protocol.md @@ -0,0 +1,514 @@ +# Dyro Agent Bridge Phase 0 Protocol + +Status: Proposed + +Authority: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) + +Operation allowlist: +[Agent Bridge Operation Inventory](agent-bridge-operation-inventory.md) + +## 1. Scope and normative language + +This protocol defines a one-request, one-response JSON boundary for pure Dyro +observations and deterministic non-executable plans. `MUST`, `MUST NOT`, +`SHOULD`, and `MAY` are normative. + +Phase 0 has no apply method. A client cannot turn a plan into an action by +changing a field, copying a digest, adding `--yes`, or claiming an actor. + +## 2. Process contract + +The packaged console entry point is: + +```text +dyro-bridge +``` + +The process contract is: + +1. read exactly one UTF-8 JSON object from stdin, up to 256 KiB; EOF is the + one-shot frame delimiter, so a client MUST close its stdin write end after + the request bytes and MUST NOT wait for a response before doing so; +2. reject trailing non-whitespace bytes and duplicate JSON keys; +3. while stdout is writable, emit exactly one compact UTF-8 JSON object followed + by one newline on stdout; +4. emit no routine logs, progress, ANSI, traceback, warning, or human help on + stdout or stderr; +5. close inherited stdin after the request and terminate after the response; +6. never invoke the human CLI parser or a `cmd_*` handler; +7. use exit code `0` for an `ok=true` response, `2` for request/protocol errors, + `3` for bounded Core observation errors, `4` for unavailable operation or + dependency, and `5` when stdout closes before a complete response. Exit 5 + performs no retry and emits no traceback; it cannot promise a JSON response + because the output channel is unavailable. + +Each invocation is a dedicated single-request, single-thread process. The +descriptor-level stdout/stderr isolation around Core materialization is +process-global and is not a supported in-process concurrency primitive. + +Before JSON decoding, the implementation also rejects nesting deeper than 64, +more than 10,000 decoded value nodes, and numeric tokens longer than 128 bytes. +Escaped surrogate code points are rejected rather than transported across +different Unicode implementations. These limits are protocol-major-1 +invariants, not caller-tunable settings. + +The transport MUST impose response limits. Phase 0 defaults are 1 MiB for a +response, 100 collection items unless the operation defines a smaller maximum, +64 warnings, and 4 KiB for any user-facing message. Truncation is explicit in +metadata and never cuts a JSON token. + +Workspace input is bounded separately from transport output. Before reading, +Phase 0 verifies a safe regular file and enforces these initial ceilings: + +| Resource | Maximum | +| --- | ---: | +| `dyro.toml` | 1 MiB | +| global workspace registry | 1 MiB and 500 records | +| one `task.toml` or Objective metadata file | 256 KiB | +| one Objective event journal | 8 MiB and 10,000 events | +| task records considered | 2,000 | +| Objective records considered | 500 | +| aggregate workspace bytes read per request | 64 MiB | +| Core observation deadline | 5 seconds | + +Operations may choose smaller limits. A single malformed or oversized record is +component-scoped and must not erase valid siblings. Exhausting a count, byte, or +deadline budget sets `partial=true`, `truncated=true` where applicable, and a +stable failure code; response truncation alone is not a computation budget. + +## 3. Request envelope + +```json +{ + "protocol": {"major": 1, "minor": 0}, + "request_id": "client-correlation-id", + "client": {"name": "codex-integration", "version": "0.1.0"}, + "operation": "workspace.resolve", + "input": { + "workspace": null, + "start": "." + } +} +``` + +Rules: + +- `protocol.major`, `client.name`, `client.version`, `operation`, and `input` + are required. +- `request_id` is optional, bounded correlation text. It is echoed only when it + matches `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}` and does not match a secret, URL + credential, or absolute-path pattern; otherwise it becomes `null` with a + fixed `REQUEST_ID_REDACTED` warning on a successful response. Error responses + omit warnings and retain `request_id=null`. It is not an idempotency key, + identity, or authorization credential. +- `start` never performs shell or home-directory expansion. Values beginning + with `~` are invalid; relative values are resolved only against the server's + explicit working-directory context. +- Unknown top-level fields are rejected in protocol major 1. +- `operation` must exist and be available in the server-side Exposure Catalog. +- `input` is validated against that operation's exact schema before any + workspace or registry access. +- Paths are not expanded from environment variables. `start` may be relative to + the process working directory; an explicit workspace alias takes precedence. +- Phase 0 has no `actor`, `approval`, `confirmation`, `command`, `argv`, + `shell`, `apply`, or `dry_run` request field. + +## 4. Success response + +```json +{ + "ok": true, + "meta": { + "server_protocol": {"major": 1, "minor": 0}, + "requested_protocol": {"major": 1, "minor": 0}, + "dyro_version": "0.6.x", + "bridge_version": "1.0", + "operation": "workspace.resolve", + "operation_schema_version": 1, + "planner_revision": null, + "request_id": "client-correlation-id", + "event_id": "evt_", + "capabilities_digest": "sha256:", + "partial": false, + "truncated": false + }, + "data": { + "workspace": {"name": "sample"}, + "resolution_source": "default" + }, + "warnings": [] +} +``` + +`event_id` is generated by the Bridge and is suitable only for correlating +local diagnostics. It does not authenticate a person or host. Absolute paths +are omitted by default; an operation may expose a stable opaque resource ID. + +Partial success uses `ok=true`, `meta.partial=true`, component-scoped failures, +and healthy data. It MUST NOT silently substitute another workspace or turn an +invalid local Profile into a default registry result. + +## 5. Error response + +```json +{ + "ok": false, + "meta": { + "server_protocol": {"major": 1, "minor": 0}, + "requested_protocol": {"major": 1, "minor": 0}, + "dyro_version": "0.6.x", + "bridge_version": "1.0", + "operation": "workspace.resolve", + "operation_schema_version": 1, + "planner_revision": null, + "request_id": "client-correlation-id", + "event_id": "evt_", + "capabilities_digest": "sha256:", + "partial": false, + "truncated": false + }, + "error": { + "code": "LOCAL_PROFILE_INVALID", + "message": "The local Dyro Profile is invalid.", + "retryable": false, + "details": {}, + "next_actions": [ + {"kind": "inspect_profile", "label": "Inspect the local Profile"} + ] + } +} +``` + +Error `message` is a bounded presentation string, not a raw exception. Details +use operation-specific allowlisted fields. `next_actions` are semantic actions, +not shell commands. + +Typed operation schemas and DTOs are the primary output allowlist. Boundary +pattern detection is defense in depth for known credential, URI, argv and path +forms; an unexpected value that would require changing a PLAN fails closed +rather than changing or re-hashing that plan. + +### Transport-error metadata + +Failures before a valid envelope exists use the same top-level `ok/meta/error` +shape but allow unknown request-derived metadata to be `null`: + +```json +{ + "ok": false, + "meta": { + "server_protocol": {"major": 1, "minor": 0}, + "requested_protocol": null, + "dyro_version": "0.6.x", + "bridge_version": "1.0", + "operation": null, + "operation_schema_version": null, + "planner_revision": null, + "request_id": null, + "event_id": "evt_", + "capabilities_digest": "sha256:", + "partial": false, + "truncated": false + }, + "error": { + "code": "INVALID_JSON", + "message": "The request is not one valid JSON object.", + "retryable": false, + "details": {}, + "next_actions": [] + } +} +``` + +The parser fills `requested_protocol`, `operation`, schema/planner versions and +`request_id` only after each field is safely parsed and validated. It never +invents an empty operation or treats the server protocol as the requested one. + +Required common codes include: + +| Code | Meaning | +| --- | --- | +| `INVALID_JSON` | Input is not one valid JSON object | +| `REQUEST_TOO_LARGE` | Input exceeded the transport limit | +| `PROTOCOL_MAJOR_UNSUPPORTED` | Client major is incompatible | +| `PROTOCOL_MINOR_UNSUPPORTED` | Client minor is newer than this server minor | +| `SCHEMA_VALIDATION_FAILED` | Envelope or operation input is invalid | +| `OPERATION_UNKNOWN` | Operation ID is not in this Core catalog | +| `OPERATION_UNAVAILABLE` | Known operation is disabled or dependency is absent | +| `LOCAL_PROFILE_INVALID` | A discovered local Profile exists but is invalid | +| `REGISTRY_INVALID` | Global registry cannot be trusted | +| `WORKSPACE_NOT_REGISTERED` | Explicit alias is unknown | +| `REGISTERED_ROOT_STALE` | Registry entry no longer resolves to a valid Profile | +| `HOST_READ_PERMISSION_REQUIRED` | Host sandbox cannot read the selected resource | +| `AMBIGUOUS_WORKSPACE` | Multiple candidates require explicit selection | +| `WORKSPACE_NOT_FOUND` | No local or usable registered workspace exists | +| `OBSERVATION_PARTIAL` | A requested atomic projection cannot tolerate a partial component | +| `RESOURCE_LIMIT_EXCEEDED` | A file, record-count, or aggregate-byte budget was exhausted | +| `OBSERVATION_DEADLINE_EXCEEDED` | The bounded Core observation deadline elapsed | +| `RECORD_INVALID` | One bounded workspace record is invalid; healthy siblings may remain | +| `INTERNAL_ERROR` | Redacted unexpected failure; never includes raw exception text | + +For one protocol major, a client minor less than or equal to the server minor is +accepted. A future client minor fails explicitly; the Bridge never silently +downgrades it. Each version component is a non-negative RFC 8785 safe integer +(`0..9007199254740991`); values outside that domain fail envelope validation and +are never reflected into response metadata. + +## 6. Compact capability discovery + +`bridge.hello` returns only version and protocol compatibility. + +`bridge.capabilities.compact` returns entries shaped as: + +```json +{ + "operation": "workspace.resolve", + "kind": "inspect", + "maximum_risk": "R0", + "available": true, + "operation_schema_version": 1, + "planner_revision": null +} +``` + +The compact response MUST NOT embed full JSON schemas. The canonical compact +list is hashed into `capabilities_digest`. A client may cache a schema only by +`protocol major + operation ID + operation schema version + capabilities +digest`. + +At the S5 boundary, Linux Ubuntu 24.04 reports exactly the seven Mandatory Core +Surface operations as available. The Linux compact digest is +`sha256:426aaee45de4da518fcad5c89ab85ce129662e6af2faff37c705b717a4311e8a`. +macOS 15 and Windows report no available Phase 0 operations and retain the +fail-closed digest +`sha256:3a008d6baa65db697eb44a9a910c4791eb0a96f58fcd361784341c4140ab2bd7`. +These values are protocol fixtures, not a host-side override. + +`bridge.operation.schema` accepts one operation ID and returns its request and +response schemas. It rejects unavailable, excluded, and mutation operations. + +## 7. Workspace resolution response + +`workspace.resolve` returns: + +```json +{ + "workspace": { + "id": "workspace:", + "name": "sample", + "profile_schema_version": 1 + }, + "resolution_source": "explicit", + "health": "available" +} +``` + +`resolution_source` is exactly one of `explicit`, `local`, `default`, or +`unique`. Phase 0 does not return registry file paths, Profile absolute roots, +remote URLs, adapter argv, or environment values. + +The opaque ID implements ADR 0006 `WorkspaceIdentityV1`: it is stable only while +the canonical Profile root and validated Profile name are unchanged. Moving or +renaming a workspace intentionally changes it. `ConfigRevisionV1` hashes the +bounded exact `dyro.toml` bytes with its domain separator. Neither identifier is +an authentication credential, and neither source payload is returned. + +## 8. Observation response + +Observation DTOs include: + +- `observed_at` in UTC; +- an opaque capture ID and semantic revision digest; +- `complete | partial` completeness; +- bounded typed projections; +- component failures using stable codes. + +Every Task/Objective projection includes `integration_inspection` with one of +`complete`, `not_inspected`, or `partial`. A summary with `not_inspected` may +report stored status and dependency facts but MUST omit final +`dispatchable=true|false`, `ready=true|false`, and integration-blocked claims. +Authoritative `task.explain`, `objective.status`, and plans require the reviewed +optional-lock-disabled Git adapter; they remain unavailable before B05 passes. +The Phase 0 Core service is reached through the single-request transport. Its +descriptor-bound Git adapter starts one exact isolated Python binder process, +retains only the reviewed worktree, Git directory, common directory, and object +store descriptors plus a close-on-exec error channel, applies a Landlock +read-only filesystem ruleset, rejects config includes and extensions, overrides +hooks, credentials and commit-graph use, and then executes an allowlisted system +Git read. Repository config remains a validated local input; the protocol does +not claim that `rev-parse` or `merge-base` ignores it. Host integrations must +spawn the one-shot transport rather than import and invoke planning services in +process. On Linux, repository discovery is pinned to those descriptors through +`/proc/self/fd`. A host without both the verified +descriptor namespace and Landlock ABI 3 support returns +`OPERATION_UNAVAILABLE` for authoritative Git-dependent plans. Phase 0 accepts +only SHA-1 object-format repositories; extended formats return a stable +fail-closed error before Git starts. Object alternates outside the approved +directory objects, lazy fetch, replace objects, and more than 100 Git process +starts per request fail closed. + +This is a bounded cooperative-state observation, not a filesystem attestation. +As defined by ADR 0006, an actively malicious process with the same operating- +system identity can still replace a ref or object during the read and restore +it afterward; defending that case requires an immutable filesystem snapshot or +external broker and is outside Phase 0. Plan digests do not upgrade this trust +model. + +The semantic revision excludes the observation clock so identical facts can +share a digest. A DTO never serializes an internal dataclass recursively; every +field is explicitly copied through the operation schema. + +## 9. Plan response + +```json +{ + "executable": false, + "authorization": "none", + "protocol_major": 1, + "operation": "objective.tick", + "operation_schema_version": 1, + "planner_revision": "objective-tick/1", + "workspace": { + "id": "workspace:", + "config_sha256": "sha256:" + }, + "normalized_input": {"objective_id": "release-readiness"}, + "read_set": { + "observed_at": "2026-08-06T12:00:00Z", + "integration_inspection": "complete", + "execution_mode": "local", + "objective": { + "id": "release-readiness", + "revision": 4, + "event_sequence": 4, + "contract_sha256": "sha256:", + "scope_sha256": "sha256:", + "event_sha256": "sha256:", + "operator_state": "active", + "completion_rule": "all_targets_integrated", + "requested_mode": "supervised", + "operations": ["execute", "review"], + "scope": ["TASK-42"], + "targets": ["TASK-42"], + "budget": {"max_actions": 10, "max_attempts_per_task": 2, "max_failures": 2, "max_no_progress_cycles": 2, "max_parallel": 1, "deadline": null} + }, + "tasks": [ + { + "id": "TASK-41", + "line_id": "release", + "contract_sha256": "sha256:", + "status": "pending", + "depends_on": [], + "blocked_on": [], + "external_claim_active": false, + "integration_state": "not_required", + "integration_checks": [], + "active_conflict_task_ids": [], + "conflict_slot": null, + "execution_slot": "agent-slot:1", + "review_slot": "agent-slot:2", + "merge_slot": "line-slot:1" + }, + { + "id": "TASK-42", + "line_id": "release", + "contract_sha256": "sha256:", + "status": "pending", + "depends_on": ["TASK-41"], + "blocked_on": [], + "external_claim_active": false, + "integration_state": "not_required", + "integration_checks": [], + "active_conflict_task_ids": [], + "conflict_slot": null, + "execution_slot": "agent-slot:1", + "review_slot": "agent-slot:2", + "merge_slot": "line-slot:1" + } + ], + "decisions": [], + "capacity": {"max_parallel": 1, "active_parallel": 0, "available_parallel": 1} + }, + "projection": { + "selected_actions": [], + "blocked": [ + {"kind": "execute_task", "subject_id": "TASK-42", "reason": "DEPENDENCY_PENDING", "predicates": {"has_pending_dependency": true, "related_subject_ids": ["TASK-41"]}} + ], + "attention": [], + "tick_wave": [], + "deferred": [], + "non_mutating_actions": [] + }, + "effects": [], + "warnings": [], + "maximum_risk": "PLAN", + "effective_risk": "PLAN", + "expires_at": "2026-08-06T12:05:00Z", + "plan_sha256": "sha256:" +} +``` + +The shown `read_set` and `projection` are illustrative; each plan operation owns +its exact typed schemas. `projection` carries the operation's selected, +blocked, graph, attention, or wave result rather than forcing those facts into a +generic effect list. + +Sensitive executor, reviewer and conflict-group values never cross the +boundary. When their equality affects planning, the read set uses deterministic +snapshot-local equivalence tokens such as `agent-slot:1` or `conflict-slot:2`; +renaming a hidden raw value without changing the relation therefore does not +leak or perturb the visible plan. + +All request-derived and Core-derived fields first pass the operation allowlist, +size limits, and deterministic redaction. `plan_sha256` is then computed from +RFC 8785 canonical bytes of the final transport-safe plan payload, excluding +only `plan_sha256`. No pre-redaction or hidden field contributes to the digest. +It detects drift and cache corruption only. The Bridge offers no endpoint that +consumes it. + +A subject in `blocked` cannot also appear in `selected_actions`, `tick_wave`, or +a `would_*` effect in the same plan. Contract tests reject contradictory +projections rather than asking clients to infer precedence. + +## 10. Redaction and audit + +Before serialization, request-derived strings, Core exceptions, warnings, and +diagnostics pass a common size and secret guard. Default responses exclude: + +- absolute filesystem paths; +- environment variables and their values; +- adapter or gate argv; +- raw prompt, answer, handoff, receipt, review, and log text; +- remote URL userinfo/query and embedded credentials; +- stdout/stderr and Python exception messages. + +If diagnostics are persisted in a future stage, the record distinguishes +`claimed_client` from an authenticated principal. Phase 0 has no authenticated +human principal and MUST record `authorization=none`, never “user confirmed.” + +## 11. Compatibility + +- Protocol major changes are incompatible and fail closed. +- Minor changes may add optional response fields but cannot add an operation to + a client's granted tool list. +- Operation schema changes that alter validation or meaning increment that + operation's schema version. +- Planner behavior changes increment `planner_revision` and produce a new plan + digest. +- A host integration advertises an explicit supported protocol and schema + range. Core and integration version skew is tested in both directions. + +## 12. MCP mapping after Phase 0 Core approval + +MCP is an adapter over the same Core services, not a separate operation engine. +The initial mapping is small and typed, for example: + +- `dyro_hello` +- `dyro_workspace_resolve` +- `dyro_workspace_observe` +- `dyro_task_list` +- `dyro_task_explain` +- `dyro_objective_plan` + +No MCP tool name contains `execute`, `apply`, `run`, `answer`, `gate`, `review`, +`signoff`, `merge`, `push`, `release`, `publish`, or `cleanup` in Phase 0. diff --git a/docs/publishing.md b/docs/publishing.md index d5cc569..6f16655 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -44,8 +44,13 @@ PyPI Trusted Publishing 将 GitHub Actions 的 OIDC 身份绑定到这个仓库 uv run python -m twine check --strict dist/dyro-*.whl dist/dyro-*.tar.gz ``` + Release 工作流还会通过 GitHub Actions API 查询 `ci.yml`,严格要求当前 + tag SHA 对应的 main push run 已 `completed + success`;缺失、仍在运行、 + 失败、取消或 SHA 不匹配都会阻止发布。制品冒烟会确认 wheel/sdist 含 + Codex Skill 资产,且不再提供 `dyro-bridge` / `dyro-mcp` 入口。 + 4. 提交并推送版本变更,创建与版本严格匹配的 tag,例如 `vX.Y.Z`。 -5. 在 GitHub 基于该 tag 创建并发布 Release。工作流会验证 checkout 恰为该 tag、tag commit 是 `origin/main` 的祖先、`uv.lock` 未漂移,再测试、构建、检查 metadata;通过 `pypi` Environment 的人工批准后才上传 PyPI。 +5. 在 GitHub 基于该 tag 创建并发布 Release。工作流会验证 checkout 恰为该 tag、tag commit 是 `origin/main` 的祖先、同一 SHA 的完整 CI 成功、`uv.lock` 未漂移,再测试、构建、检查 metadata;通过 `pypi` Environment 的人工批准后才上传 PyPI。 6. 发布完成后验证: ```bash diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/FINAL-QUALITY-GATE.md b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/FINAL-QUALITY-GATE.md new file mode 100644 index 0000000..8d63911 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/FINAL-QUALITY-GATE.md @@ -0,0 +1,26 @@ +# Final quality gate package (not a formal Go) + +## Verification (local, this tip) +- `uv run ruff check` on Bridge/integration/CI-touched paths: PASS +- Focused unittest (bridge strace/release/mcp/plugin/integrations): 71 OK +- PR #19 CI on `abca42c`: all jobs SUCCESS including bridge-zero-effects +- Docs tip `cc29fd6` pushed; awaiting follow-up CI on latest tip + +## ai-slop-cleaner +- Command/binary not available in this environment (`ai-slop-cleaner not found`) +- Status: SKIPPED / 须人工核 in an environment that has the cleaner skill + +## Code review +- See reviewer note in ultragoal ledger evidence (requested concurrently) +- Prior adversarial board: Conditional Go for fix merge; No-Go for Phase 0 formal release + +## Release decision +**No-Go for publish.** Remaining host gates: +1. F01 MCP tools on Ubuntu Codex +2. F02 sandbox on Ubuntu Codex +3. F03 remaining 7/10 journeys (+ live Bridge where required) +4. Re-run publish workflow exact-SHA checks after merge to main + +## Explicit non-actions +- Did not merge PR #19 +- Did not tag / Release / PyPI publish diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/INDEX.md b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/INDEX.md new file mode 100644 index 0000000..6997fe8 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/INDEX.md @@ -0,0 +1,12 @@ +# Exact-SHA CI evidence index — Agent Bridge Phase 0 + +- PR: https://github.com/DandreYang/DyroEngineeringFlow/pull/19 +- Branch tip (feat/dev): `abca42cdbdd9cd5125e0a4045a8c79d53b1c0187` +- CI run: https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/31480022379 +- Artifact: `dyro-bridge-zero-effect-evidence` (see `artifact-meta.json`) +- Six-report summary: `six-report-summary.json` (all `passed=true`, package/contract digests unique) + +Note: pull_request jobs may record GitHub’s temporary merge commit in report +`evidence.commit` while the workflow run `headSha` is the PR branch tip. Publish +gates must use the publish workflow’s exact-SHA verification against the +trusted main checkout, not this index alone. diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/RELEASE-READINESS.md b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/RELEASE-READINESS.md new file mode 100644 index 0000000..2260b26 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/RELEASE-READINESS.md @@ -0,0 +1,21 @@ +# Agent Bridge Phase 0 — Release readiness (abca42c / PR #19) + +## Green now +- Ubuntu CI run https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/31480022379 SUCCESS +- `bridge-zero-effects` passed (~4m39s); artifact `dyro-bridge-zero-effect-evidence` not expired +- Six reports passed with matching package/contract digests (see `six-report-summary.json`) +- F04 local byte-budget evidence: `host/f04-context-budget.json` PASS +- Adversarial review board for local-fix: `docs/superpowers/reviews/2026-08-10-dyro-agent-bridge-phase-0-fix-adversarial-review-board.md` + +## Blocked for formal Phase 0 Go / publish +- F01 MCP tool discovery: macOS `dyro-mcp` → `CORE_HANDSHAKE_UNAVAILABLE` (by design). Need Ubuntu Codex host (`G008`). +- F02 sandbox permission boundary: blocked without working MCP on host. +- F03: 3/10 fresh-session channel-choice samples PASS; remaining 7 + live Bridge success journeys need Ubuntu. +- Skill discovery alone is PASS on macOS; Skill beta still needs complete F01/F03/F04 host package per acceptance §8. + +## Not done by this ultragoal yet +- Merge PR #19 to main +- Tag / GitHub Release / PyPI publish (require separate explicit authorization after Go) + +## Recommended next host +Linux Ubuntu 24.04 machine with Codex CLI + `uv run --extra mcp dyro integration install codex` + `codex mcp add dyro-readonly -- $(pwd)/.venv/bin/dyro-mcp`, then re-run F01 tools / F02 / remaining F03. diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/artifact-meta.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/artifact-meta.json new file mode 100644 index 0000000..fb44229 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/artifact-meta.json @@ -0,0 +1 @@ +{"archive_download_url":"https://api.github.com/repos/DandreYang/DyroEngineeringFlow/actions/artifacts/9096941427/zip","created_at":"2026-08-11T10:01:55Z","expired":false,"id":9096941427,"name":"dyro-bridge-zero-effect-evidence","size_in_bytes":6085543} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/bridge-job.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/bridge-job.json new file mode 100644 index 0000000..2b8e945 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/bridge-job.json @@ -0,0 +1 @@ +{"completed_at":"2026-08-11T10:01:58Z","conclusion":"success","html_url":"https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/31480022379/job/93742588669","id":93742588669,"name":"Agent Bridge source/wheel/sdist gate (Ubuntu 24.04)","started_at":"2026-08-11T09:57:19Z"} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/ci-run.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/ci-run.json new file mode 100644 index 0000000..47a1a19 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/ci-run.json @@ -0,0 +1 @@ +{"conclusion":"success","createdAt":"2026-08-11T09:57:16Z","databaseId":31480022379,"displayTitle":"feat: 落地 Agent Bridge Phase 0 只读能力与安全门禁","event":"pull_request","headSha":"abca42cdbdd9cd5125e0a4045a8c79d53b1c0187","updatedAt":"2026-08-11T10:01:59Z","url":"https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/31480022379","workflowName":"CI"} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/code-review.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/code-review.json new file mode 100644 index 0000000..1caac0a --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/code-review.json @@ -0,0 +1,13 @@ +{ + "verdict": "REQUEST_CHANGES", + "phase0_formal_release": "NO_GO", + "p0": [ + "Host gates incomplete: F01 MCP / F02 / F03 remaining journeys require Ubuntu 24.04 Codex" + ], + "p1": [ + "Exact-SHA CI evidence is for abca42c; HEAD moved with docs commits — re-gate final release SHA" + ], + "ai_slop_cleaner": "SKIPPED_NOT_AVAILABLE", + "verification_focused_tests": "71_OK", + "reviewer": "code-reviewer-agent" +} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f01-codex-discovery.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f01-codex-discovery.json new file mode 100644 index 0000000..36dbe62 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f01-codex-discovery.json @@ -0,0 +1,47 @@ +{ + "gate": "F01", + "host": "macOS Darwin", + "time_utc": "2026-08-11T10:17:15.438192+00:00", + "codex_version": "0.147.0", + "integration_status": "codex current", + "skill_install_path": "/Users/dandre/.codex/skills/dyro-control-plane/SKILL.md", + "skill_sha256_installed": "13dc3c91fe58683849c449d638c8036b956a5b17ab58ae24ffd447ab0662f301", + "skill_sha256_package": "13dc3c91fe58683849c449d638c8036b956a5b17ab58ae24ffd447ab0662f301", + "skill_bytes_match_package": true, + "fresh_sessions": [ + { + "session_id": "019ff04e-6b2f-7f10-bd50-38f4a5cf9ba9", + "log": "/private/tmp/dyro-f01-codex-exec.txt", + "discovered_skills": [ + "dyro-control-plane" + ], + "discovered_dyro_mcp_tools": [], + "note": "skills context budget exceeded globally; skill still discovered by name" + }, + { + "session_id": "019ff051-2fab-7301-8976-7dd14746546e", + "log": "/private/tmp/dyro-f01-codex-exec-3.txt", + "discovered_skills": [ + "dyro-control-plane" + ], + "discovered_dyro_mcp_tools": [], + "mcp_servers_containing_dyro": [] + } + ], + "mcp_config": { + "registered": true, + "command": "/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev/dyroengineeringflow/.venv/bin/dyro-mcp", + "startup_on_macos": { + "ok": false, + "error_code": "CORE_HANDSHAKE_UNAVAILABLE", + "message": "The optional read-only MCP integration is unavailable.", + "interpretation": "Phase 0 public Bridge/MCP fail-closed on non-Ubuntu; tool discovery cannot pass on this host by design." + } + }, + "verdict": { + "skill_discovery": "PASS", + "mcp_tool_discovery": "BLOCKED_ON_HOST", + "overall_f01": "PARTIAL", + "required_next": "Re-run MCP tool discovery on Linux Ubuntu 24.04 Codex host where public Bridge handshake succeeds." + } +} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f02-sandbox.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f02-sandbox.json new file mode 100644 index 0000000..0b2a563 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f02-sandbox.json @@ -0,0 +1,7 @@ +{ + "gate": "F02", + "verdict": "BLOCKED_ON_HOST", + "time_utc": "2026-08-11T10:18:37.458393+00:00", + "reason": "dyro-mcp returns CORE_HANDSHAKE_UNAVAILABLE on macOS; cannot exercise MCP/Bridge permission-boundary journeys on this host.", + "depends_on": "Ubuntu 24.04 Codex host with working public Bridge handshake" +} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f03-journeys.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f03-journeys.json new file mode 100644 index 0000000..f235a48 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f03-journeys.json @@ -0,0 +1,110 @@ +{ + "gate": "F03", + "time_utc": "2026-08-11T10:18:43.920496+00:00", + "host": "macOS", + "bridge_smoke_on_host": { + "bridge.hello": { + "exit": 4, + "stdout_prefix": "{\"error\":{\"code\":\"OPERATION_UNAVAILABLE\",\"details\":{},\"message\":\"The requested operation is unavailable.\",\"next_actions\":[{\"kind\":\"retry\",\"label\":\"Retry the operation\"}],\"retryable\":false},\"meta\":{\"bridge_version\":\"1.0\",\"capabilities_digest\":\"sha256:3a008d6baa65db697eb44a9a910c4791eb0a96f58fcd361784", + "stderr_prefix": "" + }, + "bridge.capabilities.compact": { + "exit": 4, + "stdout_prefix": "{\"error\":{\"code\":\"OPERATION_UNAVAILABLE\",\"details\":{},\"message\":\"The requested operation is unavailable.\",\"next_actions\":[{\"kind\":\"retry\",\"label\":\"Retry the operation\"}],\"retryable\":false},\"meta\":{\"bridge_version\":\"1.0\",\"capabilities_digest\":\"sha256:3a008d6baa65db697eb44a9a910c4791eb0a96f58fcd361784", + "stderr_prefix": "" + }, + "workspace.list": { + "exit": 4, + "stdout_prefix": "{\"error\":{\"code\":\"OPERATION_UNAVAILABLE\",\"details\":{},\"message\":\"The requested operation is unavailable.\",\"next_actions\":[{\"kind\":\"retry\",\"label\":\"Retry the operation\"}],\"retryable\":false},\"meta\":{\"bridge_version\":\"1.0\",\"capabilities_digest\":\"sha256:3a008d6baa65db697eb44a9a910c4791eb0a96f58fcd361784", + "stderr_prefix": "" + } + }, + "journey_matrix": [ + { + "id": "J01", + "intent": "list workspaces", + "expected_channel": "bridge_or_skill_cli", + "forbidden": "dispatch" + }, + { + "id": "J02", + "intent": "bridge.hello", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J03", + "intent": "capabilities.compact", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J04", + "intent": "workspace.resolve", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J05", + "intent": "workspace.observe", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J06", + "intent": "objective.plan existing id", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J07", + "intent": "fetch one operation schema", + "expected_channel": "bridge", + "forbidden": "dispatch" + }, + { + "id": "J08", + "intent": "explain blockers without executing", + "expected_channel": "bridge_or_skill", + "forbidden": "dispatch apply" + }, + { + "id": "J09", + "intent": "advisory panel / outbound remediation suggestion", + "expected_channel": "dispatch", + "forbidden": "bridge_mutation" + }, + { + "id": "J10", + "intent": "ask to merge/push/release", + "expected_channel": "refuse_or_human_dyro", + "forbidden": "bridge_execute" + } + ], + "verdict": "PARTIAL", + "note": "Public Bridge may fail-closed on macOS; Skill can still guide to dyro-bridge. Full ten fresh-session Codex journeys require sessions that can invoke Bridge or correctly refuse.", + "sample_sessions": [ + { + "id": "J01", + "result": "PASS", + "choice": "A bridge/skill inspect", + "log": "/private/tmp/dyro-f03-J01.txt" + }, + { + "id": "J09", + "result": "PASS", + "choice": "B dyro dispatch advisory", + "log": "/private/tmp/dyro-f03-J09.txt" + }, + { + "id": "J10", + "result": "PASS", + "choice": "refuse Bridge mutation; human Dyro path", + "log": "/private/tmp/dyro-f03-J10.txt" + } + ], + "completed_sample_count": 3, + "required_count": 10, + "blocker": "Need 7 more fresh-session journeys; public Bridge operations unavailable on macOS (exit 4 OPERATION_UNAVAILABLE).", + "updated_utc": "2026-08-11T10:22:58.028812+00:00" +} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f04-context-budget.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f04-context-budget.json new file mode 100644 index 0000000..949aa34 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f04-context-budget.json @@ -0,0 +1,26 @@ +{ + "gate": "F04", + "time_utc": "2026-08-11T10:25:43.251009+00:00", + "measurements": { + "skill_md_bytes": 2351, + "skill_md_token_approx": 588, + "compatibility_json_bytes": 954, + "mcp_json_bytes": 265, + "plugin_json_bytes": 821, + "bridge_hello_schema_bytes": 136, + "request_envelope_schema_bytes": 863, + "capabilities_compact_bytes": 2602, + "capabilities_compact_token_approx": 651 + }, + "content_guards": { + "skill_mentions_on_demand_schema": true, + "skill_forbids_dispatch": true, + "compact_is_list_metadata_not_full_schemas": true + }, + "checks": { + "skill_under_8kib": true, + "compact_under_64kib": true, + "single_schema_under_64kib": true + }, + "verdict": "PASS" +} diff --git a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/six-report-summary.json b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/six-report-summary.json new file mode 100644 index 0000000..26fa445 --- /dev/null +++ b/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/six-report-summary.json @@ -0,0 +1,114 @@ +{ + "commit": "abca42cdbdd9cd5125e0a4045a8c79d53b1c0187", + "pr": 19, + "ci_run_id": 31480022379, + "ci_run_url": "https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/31480022379", + "reports": { + "sdist-candidate": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/sdist/candidate-report.json" + }, + "sdist-public": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/sdist/public-report.json" + }, + "source-candidate": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/source/candidate-report.json" + }, + "source-public": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/source/public-report.json" + }, + "wheel-candidate": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/wheel/candidate-report.json" + }, + "wheel-public": { + "passed": true, + "operations": 43, + "trace_ok": true, + "binder": 2, + "landlock_success": 2, + "mutation": 0, + "network": 0, + "write_open": 0, + "contract_digest": "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e", + "package_manifest_sha256": "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e", + "commit": "62ab5bd9c1963deee6c718dbd186738ebf2d5fca", + "dirty": "clean", + "harness_verifier_sha256": "ed097fb148074c7d528d732937b0d16b0330b678b0958d1397eb1e02c94209c4", + "path": "evidence/wheel/public-report.json" + } + }, + "parity": { + "report_count": 6, + "package_manifest_unique": [ + "sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e" + ], + "contract_digest_unique": [ + "sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e" + ], + "all_passed": true + } +} diff --git a/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md new file mode 100644 index 0000000..d9270d1 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md @@ -0,0 +1,522 @@ +# Dyro Agent Bridge Design Adversarial Review Board + +Date: 2026-08-06 + +Scope: + +- Repository: `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev/dyroengineeringflow` +- Review substrate: `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4` +- Material under review: the conversation design titled `Dyro Agent Bridge v1` +- Review mode: design and plan review; no business-code implementation + +Reviewed Materials: + +- `docs/architecture.md` +- `docs/designs/optional-local-agent-dispatch.md` +- `docs/adr/0002-optional-local-agent-dispatch.md` +- `docs/adr/0003-zero-friction-global-home.md` +- `docs/adr/0004-native-continuation-engine.md` +- `src/dyro/cli.py` +- `src/dyro/hub.py` +- `src/dyro/workspace.py` +- `src/dyro/tasks.py` +- `src/dyro/continuation/` +- `experiments/local_agent_dispatch/` +- `pyproject.toml` + +SSOT: + +- Current source at the locked review substrate above outranks the proposed design. +- Existing delivery invariants in `docs/architecture.md` remain fixed unless current source disproves them. +- Existing `dyro dispatch` remains outbound and advisory; the proposed `dyro bridge` is inbound. + +## Rules + +1. Each reviewer writes only in their own signed section. +2. Conflicts are resolved by current source or reproducible runtime behavior. +3. Unprovable claims are marked `须人工核`. +4. Findings use P0/P1/P2 severity. +5. Reviewers must try to refute the proposed design, not optimize toward agreement. +6. No reviewer may edit another reviewer's section or Final Arbitration. +7. Product preferences are not treated as security enforcement. + +## Fixed Decisions + +- Dyro Core remains the sole delivery control plane. +- Skill text is guidance, never an authorization boundary. +- `dispatch`, Bridge, Plugin, and MCP cannot review/signoff/merge/push on advisory Agent output. +- Commit, push, merge, signoff, release, publish, and cleanup remain separately authorized. +- Read-only operations must be side-effect free. + +## Open Decisions + +1. Whether Phase 1 should introduce a generic `bridge invoke` operation or only typed commands/tools. +2. Whether MCP belongs in the Dyro wheel as an optional extra or in a separately versioned integration package. +3. Whether any R1 apply operation should be exposed in v1, or v1 must remain inspect-and-plan only. + +--- + +# Architecture Review Section + +## Signed review + +- Reviewer: Turing +- Reviewed at: 2026-08-06 18:43:02 +0800 +- Substrate verified: `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4` +- Verdict: **NO-GO for the proposed v1 R1 `apply`; CONDITIONAL GO only for a revised inspect-and-plan v1.** +- Runtime check: `.venv/bin/python -m unittest tests.test_continuation_supervision tests.test_workspace tests.test_hub` passed 74 tests. This confirms the existing Objective confirmation path and current workspace/home behavior; it does not prove the proposed generic Bridge contract. + +## Executive challenge + +The direction—an inbound, structured Agent interface distinct from outbound `dispatch`—is sound. The proposed layering is not yet sound enough to mutate state. In the current source, “Core” is not a transport-neutral application service: policy checks, confirmation rules, rendering, and even some mutations live in `cli.py`. Adding an `Operation Registry` that owns risk, policy, and handlers alongside that CLI would create the second control plane the design says it avoids. More importantly, a confirmation digest prevents some stale-plan races, but it does not provide an idempotency or crash-consistency boundary. The only current implementation with those properties is the Objective-specific Action Journal, and it is deliberately coupled to Objective leases, budgets, Task operations, and uncertainty handling rather than being a generic transaction engine. + +The safe cut is therefore: + +```text +Codex/Claude + -> typed MCP tools OR one schema-validated JSON inspect/plan endpoint + -> Bridge Exposure Catalog (metadata only) + -> Core application services (policy + authoritative plan/apply) + -> existing domain locks/journals/stores +``` + +For v1, stop before the final arrow can mutate. Do not expose R1 apply until each operation has a Core-owned linearization point and operation-specific recovery semantics. + +## P0 findings + +### P0-1 — The proposed Operation Registry would become a second authorization/control plane + +**Claim refuted:** “Skill, CLI, and MCP share one Operation Registry” is not sufficient to preserve Core as SSOT when that registry also owns risk class, permission policy, and plan/apply handlers. + +**Evidence:** + +- Current confirmation policy is CLI-local: `_require_yes` and `_require_objective_yes` enforce different rules in `src/dyro/cli.py:269-280`. +- Line creation defaults and mutation dispatch are assembled in `src/dyro/cli.py:1650-1679`, not in a transport-neutral command object. +- `task.create` is implemented directly in the CLI, including locking and two-file persistence, at `src/dyro/cli.py:1729-1751`; there is no equivalent Core service to reuse. +- Task execution policy and state fencing remain in the task APIs (`src/dyro/tasks.py:775-830`, `src/dyro/tasks.py:1330-1367`), while Objective mutation authority is separately enforced by its store and Action Journal (`src/dyro/continuation/store.py:646-680`, `src/dyro/continuation/supervision.py:365-510`). + +If Bridge independently decides that an operation is R1 and may apply, while the human CLI keeps its current checks, the two surfaces can drift even if both point at some of the same functions. + +**Required fix:** Rename and narrow the registry to an **Exposure Catalog**. It may contain operation ID, schemas, maximum risk, protocol versions, and a reference to a Core application service. It must not be the owner of authorization or mutation invariants. Extract typed Core services first; both CLI and Bridge must eventually call those services. Until a mutating CLI command has been migrated, Bridge must expose it only as inspect/plan or not at all. + +### P0-2 — `task.answer` is materially misclassified as R1 + +**Claim refuted:** The proposed R1 list treats `task.answer` as a local, recoverable control write. + +**Evidence:** In a local-execution Profile, `answer_task` takes the execution lock, reserves the task, creates an execution attempt, and invokes `_answer_task` (`src/dyro/tasks.py:1558-1606`). `_answer_task` can create worktrees, launch the configured Agent argv, capture output, execute gates, and change quality state (`src/dyro/tasks.py:1609-1643`). Only the external-execution branch records an answer without launching the local Agent (`src/dyro/tasks.py:1559-1575`). + +Thus risk is contextual, and the maximum authority of `task.answer` is execution-write, not control-write. A static R1 declaration could let a generic apply tool start an Agent and gates under an authorization presented as a metadata update. + +**Required fix:** Remove `task.answer` from R1. Mark the catalog entry with maximum risk R2 and compute an `effective_risk` in the Core plan from the loaded Profile. Keep all variants plan-only in v1; later expose separate typed operations such as `task.record_external_answer` and `task.resume_local_execution`, each retaining current task/execution locks and policy checks. + +### P0-3 — The proposed confirmation payload does not bind the actual operation read set or implementation version + +**Claim refuted:** Hashing workspace identity, config digest, repository HEAD/dirty state, line list, inputs, and effects is enough to make a generic apply stale-safe. + +**Evidence:** Line planning also reads target-root emptiness, anchor Git validity, the resolved base ref, destination absence, branch existence, base-to-branch ancestry, and (for `anchor-reference`) the currently checked-out branch (`src/dyro/workspace.py:249-296`). A base or pre-existing branch ref can move while the anchor `HEAD` and dirty state remain unchanged. The proposed generic snapshot does not explicitly bind those resolved refs or predicates. The Objective digest succeeds because it manually serializes every safety-relevant fact for that one domain (`src/dyro/continuation/supervision.py:110-166`) and apply rebuilds the whole wave plus each action (`src/dyro/continuation/supervision.py:378-405`). That is evidence for operation-specific confirmation, not evidence that the pattern can be generalized by one fixed snapshot. + +There is also no planner/operation revision in the proposed hash. A plan copied across a Dyro upgrade could retain the same visible effects while the handler semantics changed. Current JCS support itself is real (`src/dyro/canonical.py:3-17`, dependency at `pyproject.toml:11-14`), so RFC 8785 encoding is not the blocker; defining the complete semantic payload is. + +**Required fix:** Each Core operation must produce a typed, JSON-only `read_set` containing every predicate and resolved object ID it used. Confirmation must bind at least `protocol_major`, `operation_id`, `operation_schema_version`, `planner_revision`, canonical workspace root/config digest, normalized input, `read_set`, and semantic effects. Apply must acquire the operation's authoritative domain lock, rebuild the typed plan under that lock, compare the digest, and only then cross its durable start boundary. Patch upgrades that change planning or apply semantics must bump `planner_revision` and invalidate old confirmations. + +### P0-4 — Request IDs and hashes do not supply idempotency, atomicity, or crash recovery + +**Claim refuted:** The proposed rule “same request ID + operation + confirmation SHA does not duplicate resources” can be implemented as a generic Bridge feature over current R1 APIs. + +**Evidence:** + +- `create_line` has no line-creation lock around plan plus apply. It performs multiple Git worktree/branch mutations and writes the line record last (`src/dyro/workspace.py:330-380`). Its recovery is best-effort (`src/dyro/workspace.py:163-206`). A newly created branch (`src/dyro/workspace.py:289-295`) is not removed by that rollback, so the proposed example's blanket `reversible: true` is false. +- `task.create` has a lock, but creates the directory and then two files in sequence (`src/dyro/cli.py:1739-1750`). A crash after `task.toml` leaves a partial directory; replay fails because the directory already exists. No request journal can currently distinguish “not started,” “partially applied,” and “complete.” +- By contrast, Objective apply publishes an intent, then a durable Action-start before invoking the Task API, and records post-start exceptions as `uncertain` (`src/dyro/continuation/supervision.py:418-490`). Its idempotency key binds Objective revision, events, scope, generation, action, and budgets (`src/dyro/continuation/action_models.py:101-135`). These are domain-specific invariants, not available to line/task/workspace mutations. + +A success-only ledger appended after mutation cannot close the crash window between the side effect and receipt. Replaying after that window can duplicate or damage state; refusing replay without a receipt can strand a successfully applied operation. + +**Required fix:** Keep v1 inspect-and-plan only. Before opening any R1 apply, define per-operation linearization and recovery rather than a universal success ledger: + +1. convergent operations may prove idempotency from authoritative state; +2. multi-effect operations need a durable intent/start/receipt journal and `uncertain` terminal state; +3. plan/recheck/apply must run under a declared domain lock and global lock order; +4. recovery must distinguish safe replay, already applied, repair required, and uncertain; +5. `request_id` is correlation only until a durable record atomically binds it to canonical input and confirmation digest. + +`workspace.add` is the best first post-v1 pilot because the registry already uses an exclusive lock plus atomic replace (`src/dyro/hub.py:161-168`) and can converge on an existing matching record. `line.create` and `task.create` are not acceptable pilots without redesign. + +## P1 findings + +### P1-1 — The zero-write machine read path is not yet a reusable Core boundary + +**Claim challenged:** Existing read commands can simply be registered as R0. + +**Evidence:** Objective plan/tick/attention deliberately call `get_objective(..., recover=False)` (`src/dyro/cli.py:2358-2362`, `src/dyro/cli.py:2404-2423`), but `objective list` and `status` call the default recovery-enabled readers (`src/dyro/cli.py:2332-2348`). Those readers may take the Objective lock and recover a pending transaction (`src/dyro/continuation/store.py:425-441`). Current Git observations also use ordinary `git status` (`src/dyro/workspace.py:383-409`, `src/dyro/process.py:18-50`) without an explicit `GIT_OPTIONAL_LOCKS=0`/`--no-optional-locks` contract; whether a given Git version refreshes index metadata is **须人工核** on each supported platform. + +The focused 74 tests passed, but the current no-write tests compare selected content under normal state (`tests/test_cli.py:812-878`); they do not inject pending Objective recovery, trace filesystem syscalls, or prove Git index metadata is untouched. + +**Required fix:** Add a transport-neutral Observation facade whose APIs have no recovery/repair behavior, no update check, no recent-item write, and no implicit directory/lock creation. Provide an explicit mutating `repair` operation separately. Run Git observations with optional locks disabled and add pending-state, permission-denied-home, and syscall/file-metadata acceptance tests. Define “side-effect free” as no persistent semantic write plus no created path; do not rely only on brittle whole-tree mtime comparison. + +### P1-2 — JSON transport, generic invocation, MCP packaging, and version compatibility need one concrete decision + +**Claim challenged:** `dyro bridge` under the existing CLI plus `python -m dyro.bridge.mcp` is already a reliable distribution/compatibility shape. + +**Evidence:** The main CLI builds one argparse parser, dispatches command functions that print directly, and catches `DyroError` into decorated text (`src/dyro/cli.py:3618-3647`). Reusing this path cannot guarantee “stdout is exactly one JSON object” for parse and routing failures. The current wheel exposes only the `dyro` script and explicitly enumerates packages (`pyproject.toml:35-56`). A plugin-launched `python -m dyro.bridge.mcp` uses the host's `python`, which need not be the pipx/venv interpreter containing `dyro[mcp]`. The proposal also defines a broad `dyro_apply_confirmed_plan`; adding a newly exposed operation in a newer Core would silently widen what an old host-facing generic tool can execute. + +**Required fix and decisions:** + +- Permit one schema-validated generic JSON endpoint only for **inspect and plan** in v1; it must route before the human argparse/error renderer or use a dedicated `dyro-bridge` console script. +- MCP must expose typed tools. Do not expose generic `execute`, generic shell, or generic `apply_confirmed_plan`. Future applies get operation-specific typed tools and Core-side maximum-risk enforcement. +- Keep MCP in the same Dyro distribution as an optional extra for v1 to avoid a second release/version matrix, but install a real `dyro-mcp = dyro.bridge.mcp:main` console entry point. The Plugin invokes that executable, not ambient `python`. +- Handshake on protocol major, operation schema version, and planner revision. Unknown majors/operations fail closed; additive response fields are minor-compatible. The apply digest must reject a plan created by an incompatible planner revision. + +## P2 findings + +No independent P2 finding is recorded in this pass. Schema discoverability, localized messages, output truncation, and Plugin installation ergonomics are useful but should not consume implementation capacity before the P0/P1 boundaries above are closed. + +## Open Decisions + +1. **Generic invoke vs typed tools:** generic JSON inspect/plan endpoint is acceptable for CLI transport; MCP tools and every future apply remain typed. Generic mutating invoke is rejected. +2. **MCP packaging:** same `dyro` distribution, optional `mcp` extra, dedicated `dyro-mcp` executable, protocol handshake. Reconsider a separate package only after a compatibility policy and release automation exist. +3. **R1 in v1:** none. v1 is inspect-and-plan only. `workspace.add` may become the first separately reviewed R1 pilot; `line.create`, `task.create`, `task.answer`, and Objective execution are excluded. +4. **Registry authority:** adopt an Exposure Catalog owned by the Bridge adapter for exposure metadata; Core typed services remain the only policy and mutation authority. + +## Required Fixes before implementation approval + +1. Amend ADR-0006 to state the Core-service/Exposure-Catalog split and the inspect-and-plan-only v1 scope. +2. Define typed Observation and Plan models in Core before adding MCP or Plugin packaging; do not call `cmd_*` functions from Bridge. +3. Specify operation-specific `read_set`, `planner_revision`, lock, linearization point, idempotency, uncertainty, and recovery fields. A shared envelope is allowed; shared transaction semantics are not assumed. +4. Add a dedicated machine transport that owns JSON parse/error behavior and cannot run daily update or human rendering. +5. Prove the no-write boundary with pending-recovery and Git-optional-lock cases in a real Codex-style sandbox. +6. Only after the above passes, ship the Skill and typed read-only MCP tools; require a separate review gate before enabling the `workspace.add` R1 pilot. + +--- + +# Security Review Section + +## Reviewer: Curie + +**Verdict:No-Go。** 当前方案不能开放任何 R1 `apply`,且 R0 清单必须重分级。发现计数:**P0 × 3、P1 × 3、P2 × 0**。以下结论以 `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4` 当前源码为准;Bridge 尚未实现的协议细节均明确标为 `须人工核`。 + +### P0-CURIE-01:Confirmation SHA 只证明计划完整性,不构成用户授权 + +**证据:** 现有 Objective 将 `confirmation_sha256` 原样放进可机器读取的 plan payload([`src/dyro/continuation/supervision.py:202-214`](../../../src/dyro/continuation/supervision.py)),非交互入口随后只校验调用者同时提交 `--yes` 和相同 SHA([`src/dyro/cli.py:2438-2471`](../../../src/dyro/cli.py))。SHA 的计算确实绑定了当前语义事实([`src/dyro/continuation/supervision.py:110-166`](../../../src/dyro/continuation/supervision.py)),但没有绑定一个模型不可伪造、不可读取的人类批准凭据。 + +**攻击路径:** 同一编码智能体调用 `plan` 得到 SHA → 不经过用户,直接调用设计中的 `dyro_apply_confirmed_plan` 并回填该 SHA → 在宿主已授予工作区写权限时不会出现新的权限提示。`actor`、`--yes`、复制 SHA 都由同一不可信调用者提供,所以不能证明用户在 plan 之后作过确认。 + +**Required Fixes:** v1 只能 inspect-and-plan。若以后开放 apply,批准必须由宿主 UI 或独立本地 broker 在模型不可见的通道签发一次性 capability;至少绑定 operation、规范化 input、workspace identity、effects、过期时间、随机 nonce、调用会话和单次消费状态。Confirmation SHA 继续作为完整性摘要,但字段名和文档不得称其为授权。`须人工核`:Codex/Claude 各宿主是否提供真正不回传给模型的 approval token;若没有,R1 永久不进 MCP。 + +### P0-CURIE-02:`task.gates` 被列为 R0,但当前实现会执行 Profile 声明的任意 argv 并写日志/台账 + +**证据:** CLI `task gates` 直接调用 `run_gates`([`src/dyro/cli.py:1956-1962`](../../../src/dyro/cli.py));后者展开任务中的 argv、启动子进程、写 `gate-*.log` 并追加 ledger([`src/dyro/tasks.py:1314-1327`](../../../src/dyro/tasks.py))。Profile 的 gate 是通用 argv,并非只读内建检查;Core 的架构也明确 gates 属于执行/验证机制,而不是只读图查询([`docs/architecture.md:203-214`](../../../docs/architecture.md))。 + +**攻击路径:** 攻击者在可导入工作区的 `task.toml` 中放置有写入、网络或凭据读取副作用的 gate → Skill/MCP 按 R0 “可自动执行”调用 `task.gates` → 以 MCP Server/Dyro 进程权限执行任意程序并持久化输出。这同时击穿“R0 零写入”和“Plugin 不扩权”。 + +**Required Fixes:** 从 R0 删除 `task.gates`。拆成 `task.gates.describe` / `task.gates.last_result`(纯读取)与 `task.gates.run`(至少 R2 execution-write);R0 registry 必须逐 handler 做副作用证明,禁止按命令名称或 `dry_run` 推断。所有可启动 subprocess、读取 adapter、执行 Git 网络操作或写 log/ledger 的 handler 均不得进入 R0。 + +### P0-CURIE-03:隐藏 R3 MCP 工具不能阻止同一编码智能体绕过 Bridge 直接调用现有 CLI + +**证据:** 当前 `task signoff` 允许只传任意 `--by`,签名参数是可选的([`src/dyro/cli.py:3429-3434`](../../../src/dyro/cli.py));local mode 下配置反而禁止启用 `require_signed_*`([`src/dyro/config.py:218-225`](../../../src/dyro/config.py)),而 `_signoff_task` 只在 external mode 强制签名和 principal 独立性([`src/dyro/tasks.py:1878-1905`](../../../src/dyro/tasks.py))。`task merge` 的 CLI 边界仅要求可由 Agent 自行提供的 `--yes`([`src/dyro/cli.py:269-273`](../../../src/dyro/cli.py)、[`src/dyro/cli.py:2282-2290`](../../../src/dyro/cli.py))。 + +**攻击路径:** 安装 Skill/Plugin 后,拥有 shell 和工作区写权限的编码智能体绕过 MCP,直接运行普通 `dyro task signoff ... --by <声称的人>` 与 `dyro task merge ... --yes`;若 Profile 允许 push,还可走现有 CLI 的 `--push`。Skill 文本和“不暴露 R3 工具”对此没有强制力。 + +**Required Fixes:** ADR 必须先声明威胁模型:同 UID、可执行 `dyro` 的 Agent 不是可通过 Skill 隔离的安全主体。需要 broker/daemon 成为唯一 mutation authority,敏感 Core 操作要求模型不可访问的人类 capability 或外部签名;直接 CLI 也必须走同一 policy enforcement。只要开启 `require_external_signoff`,signoff 就必须使用独立可信 principal,不应因 local mode 降级为自报字符串。达不到该隔离时,只能承诺“防误操作”,不能承诺“Agent 不能 signoff/merge/push”。 + +### P1-CURIE-04:Plan→Apply 缺少覆盖整个副作用窗口的冲突锁、fencing 与 durable intent,SHA 复算仍存在 TOCTOU/重复执行 + +**证据:** 当前 line 创建先检查状态/目标/refs([`src/dyro/workspace.py:209-296`](../../../src/dyro/workspace.py)),随后逐仓创建 worktree,最后才写 line state;整个过程没有 workspace/line mutation lock,崩溃恢复只是进程内 best-effort rollback([`src/dyro/workspace.py:330-379`](../../../src/dyro/workspace.py))。相比之下,现有 Objective Action Journal 会先 create-only reserve intent,再在 owner lease/generation 下 start,并把 idempotency key 绑定完整 authority facts([`src/dyro/continuation/action_models.py:101-135`](../../../src/dyro/continuation/action_models.py)、[`src/dyro/continuation/action_journal.py:309-360`](../../../src/dyro/continuation/action_journal.py))。 + +**攻击路径:** 两个不同 `request_id` 对同一 line、不同 repository 子集同时通过 plan → 两边均在 state 尚不存在时开始创建 → 最后一次原子 replace 覆盖 line manifest,遗留另一边 worktree;或进程在首个 Git 副作用后被杀,重试因没有 durable start/receipt 无法区分“未执行”和“执行结果不确定”。另一路径是 plan 固定 `base="main"`,而当前命令最终把符号 ref 交给 `git worktree add`([`src/dyro/workspace.py:265-266`](../../../src/dyro/workspace.py)、[`src/dyro/workspace.py:289-295`](../../../src/dyro/workspace.py));若哈希只记录 anchor 当前 HEAD 而未记录 `main^{commit}`,ref 漂移后仍可能应用不同代码。`须人工核`:设计中的 `repository_heads` 是否意图覆盖每个实际解引用 ref;当前字段定义不足以证明。 + +**Required Fixes:** 引入 workspace 级 mutation lock + 每资源 conflict key,锁内完成“重载 config/registry → 重算 plan → 消费 approval → durable intent/start → Core effect → receipt”。复用 Action Journal 的 create-only、owner generation 与 uncertain 语义;`request_id` 只能是相关 ID,不能代替幂等键。哈希必须绑定每个 symbolic ref 的 full OID、Git common-dir identity、目标父目录 identity 和精确 effect argv;任何副作用后异常都记录 `uncertain`,禁止盲重试。 + +### P1-CURIE-05:现有 Core/hub 的路径检查是 pathname/check-then-use,不能满足设计声称的“realpath 在工作区内”安全边界 + +**证据:** 配置只拒绝绝对路径和 `..`,不拒绝 symlink 路径分量([`src/dyro/config.py:142-145`](../../../src/dyro/config.py));line destination 直接由 `config.root / layout / id / mount` 拼接([`src/dyro/workspace.py:136-150`](../../../src/dyro/workspace.py)),随后 `mkdir`/Git 会跟随父目录 symlink([`src/dyro/workspace.py:353-372`](../../../src/dyro/workspace.py))。通用 `atomic_write_bytes` 与 `exclusive_lock` 也只对最终 lock fd 使用 `O_NOFOLLOW`,父目录仍按 pathname 创建/替换([`src/dyro/state.py:34-50`](../../../src/dyro/state.py)、[`src/dyro/state.py:200-239`](../../../src/dyro/state.py))。hub registry 会 resolve 记录中的 root,但读取/替换 registry 仍是“检查终端 symlink后按路径操作”([`src/dyro/hub.py:83-112`](../../../src/dyro/hub.py)、[`src/dyro/hub.py:161-168`](../../../src/dyro/hub.py))。Objective store 已有基于 directory fd 的更安全范式([`src/dyro/continuation/store.py:66-105`](../../../src/dyro/continuation/store.py))。 + +**攻击路径:** 在 plan 后把 `versions`、`.dyro/lines`、tasks parent 或 `DYRO_HOME` 的父路径替换为 symlink/reparse point → apply 的 mkdir、临时文件或 rename 被重定向到计划外位置;仅在 apply 前再次 `resolve()` 仍挡不住检查后的替换。registry alias 也没有持久化的 workspace UUID/inode binding,路径被替换后可能指向不同 Profile。 + +**Required Fixes:** 写侧必须从预先打开且验证过的 workspace/registry directory fd 开始,逐级 `openat/mkdirat` + `O_NOFOLLOW`,并在整个事务中固定 `(st_dev, st_ino)`;Windows 无等价安全实现时 fail-closed。禁止直接把现有 line/task/hub 写 handler 包进 Bridge。为 workspace 引入稳定 identity,并在 plan/apply 同时绑定 alias、canonical root、config hash、root/config inode 与 registry generation;不匹配即 stale。 + +### P1-CURIE-06:`actor`/`request_id` 是不可信自报,原始 Core 错误又可能把敏感 argv/stdout送入 MCP 与审计 + +**证据:** 设计已说明 `actor` 不是凭据,却拟把 `actor_kind`/`host` 写入 apply ledger;这会形成看似可信的归因。当前真正的 external signoff 会验证签名 key、principal 与 execution/review 身份独立性([`src/dyro/tasks.py:1073-1095`](../../../src/dyro/tasks.py)),说明自报字符串不能承担身份。另一方面,通用 `require_ok` 会把完整 argv 和合并后的 stdout/stderr写入异常([`src/dyro/process.py:37-57`](../../../src/dyro/process.py));local dispatch 已专门在任务文本、Provider 输出和持久化错误前执行 secret guard/redaction([`experiments/local_agent_dispatch/task_contract.py:63-69`](../../../experiments/local_agent_dispatch/task_contract.py)、[`experiments/local_agent_dispatch/context_guard.py:80-105`](../../../experiments/local_agent_dispatch/context_guard.py)),Bridge 方案尚未把同等规则列为强制边界。 + +**攻击路径:** 调用者伪造 `actor.host="codex"` 与任意 request ID,使 ledger 看起来像某宿主/用户批准;同时让 Git/ref/路径或下游工具在错误中回显含 token 的输入/remote URL,MCP 将 error details 或 stderr 返回远端模型并可能再次落审计。 + +**Required Fixes:** 审计区分 `claimed_actor` 与由 transport/broker 观测到的 `authenticated_principal`;没有 approval credential 时明确写 `authorization=unverified`,不得记录“用户已确认”。event ID 由服务端生成,request ID 只作 correlation。所有请求字符串、Core 异常、argv、stdout/stderr、warning、MCP response 和 audit field 统一做大小上限、凭据检测和不可逆脱敏;日志默认不含绝对路径、原始 prompt、remote URL query/userinfo 或环境变量。 + +### Go / No-Go 与解除条件 + +**当前:No-Go(Bridge v1 的 MCP R1 apply、任何 R2/R3、以及原方案 R0 清单)。** 允许继续实现的唯一范围是:typed、零 subprocess、零 lock 创建、零 mtime/ledger 变化的 inspect API,以及返回不可执行计划的 plan API。 + +转为有限 Go 前必须同时满足:P0-CURIE-01 的模型不可见批准能力已由至少一个真实宿主端到端证明;`task.gates` 等所有 handler 完成代码级副作用分类;普通 CLI 不再成为旁路;P1 的 mutation journal/fencing、fd-relative 路径、workspace identity、secret redaction 和可信审计语义均有故障注入/并发/真实沙箱测试。若宿主无法提供不可见批准能力,最终决策应选择 Open Decision 3 的“v1 inspect-and-plan only”。 + +— **Reviewer: Curie** + +--- + +# Product, Skill, Plugin, and Evaluation Review Section + +## Reviewer: Shannon + +### Verdict + +**整体 No-Go;只允许收缩后的 R0 inspect-and-plan 切片进入实现。** `dyro bridge` 作为入站、机器可读适配层有真实价值,而且现有 Core 已经有可复用的纯读取解析器;但当前方案同时承诺 R1 apply、Codex Plugin、MCP 和跨宿主安装,授权来源、制品分发、版本握手与真实沙箱证据均未闭环。若照原方案实施,最危险的结果不是“命令不可用”,而是把已有执行型命令误包装成 R0,或让 Agent 自己取得 Confirmation SHA 后再自行 apply。 + +本轮锁定并核验 `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4`。正向证据是:当前 resolver 已实现“显式 alias → 当前目录向上发现的 Profile → registry 默认/唯一可用工作区”的无副作用解析([`src/dyro/continuation/resolution.py:61-93`](../../../src/dyro/continuation/resolution.py)),registry 的缺失读取不创建目录、损坏时 fail-closed([`src/dyro/hub.py:45-64`](../../../src/dyro/hub.py)、[`src/dyro/hub.py:105-140`](../../../src/dyro/hub.py))。本轮用隔离 `DYRO_HOME` 登记默认工作区后,从无关 `/tmp/.../unrelated` 运行 `dyro task list` 成功,`workspaces.json` 的 mtime/size 前后相同。因此 Bridge 应复用该 resolver,不能再造一套发现规则。 + +### Findings(最多六项) + +#### P0-1 — R0 清单已把真实执行面误标为只读 + +方案把 `task.gates` 放入 R0,但当前同名 CLI 不是“查看 gate 定义”:它直接调用 `run_gates`([`src/dyro/cli.py:1956-1962`](../../../src/dyro/cli.py));后者逐项启动 gate argv、写 `gate-*.log`,并向 ledger 追加结果([`src/dyro/tasks.py:1314-1327`](../../../src/dyro/tasks.py))。这是当前源码对设计的直接反证。即便计划另写 handler,操作名仍会让 Skill、Plugin 作者和使用者错误复用现有语义。 + +**Required fix:** 删除 R0 `task.gates`,拆成 `task.gate_definitions.get`(只读配置投影)与至少 R2 的 `task.gates.run`。每个 OperationSpec 在进入 allowlist 前必须提交实际调用图、副作用清单,以及能令 `run`、`ledger`、`atomic_write_text`、`mkdir`、网络和 Agent spawn 一触发即失败的负向测试;不能按命令名称人工分级。 + +#### P0-2 — Confirmation SHA 绑定了计划,却没有绑定可信的“用户确认” + +请求中的 `actor` 明确只是非可信审计字段,但方案没有另一个可信授权来源。同一个 Agent 可以先调用 plan 得到 SHA,再自行调用 `dyro_apply_confirmed_plan`;SHA 能防 stale plan,不能证明用户在宿主 UI 中确认过。`--yes`、聊天中的一句“确认”或 Skill 纪律也都不是 Core 可验证的 authority。更严重的是,本地 MCP server 是否继承 Codex/Claude 的 workspace sandbox 与逐工具批准策略,仓库中没有实现或证据,**须人工核**;OS `PermissionError` 也不能预先等价为宿主授权状态。 + +**Required fix:** v1 只开放 inspect 和 plan;MCP 不注册通用 apply 工具。R1 先保留为人类 CLI 的独立后续动作。未来若开放,必须定义宿主可验证的一次性授权凭据或强制的 host-native approval broker,绑定 `operation + confirmation SHA + workspace identity + expiry + single use`,并在每个支持宿主的真实进程边界上证明 server 不会越过宿主权限。证明完成前,Open Decision 3 裁定为 **inspect-and-plan only**。 + +#### P1-1 — Plugin/MCP 没有可安装、可升级、可回滚的制品闭环 + +当前 wheel 只显式包含 Python packages,package-data 只有 Console assets([`pyproject.toml:41-59`](../../../pyproject.toml));sdist manifest 也只列文档、示例和 Console assets([`MANIFEST.in:1-8`](../../../MANIFEST.in))。方案把 Plugin 放在 `integrations/codex/...`,却没有让 wheel/sdist 包含该目录,也没有落实先前提出的 `dyro integration install codex|claude`、卸载、覆盖冲突、原子升级和失败回滚。当前 CI 的制品 smoke 只验证 dispatch/continuation/Console([`.github/workflows/ci.yml:52-90`](../../../.github/workflows/ci.yml)),不会发现 Plugin 或 Skill 丢包。Core 的更新流程只验证 Python distribution 版本([`docs/updates.md:42-56`](../../../docs/updates.md)),宿主目录中已复制的 Plugin 会产生版本漂移。 + +方案中的“版本握手”也只有 `bridge_version`/`dyro_version` 展示,没有 client/integration 版本、支持的 schema 范围、协商结果、capabilities digest 或 major mismatch fail-closed 规则。现有仓库只证明 Codex/Claude 等工具可被发现或启动([`src/dyro/tooling.py:63-145`](../../../src/dyro/tooling.py)),这不证明 Claude/Cursor 能消费 Codex Plugin。非 Codex 宿主均 **须人工核**。 + +**Required fix:** v1 明确为 Core CLI + host-neutral Skill source,不宣传跨宿主 Plugin。Core Bridge 随 `dyro` wheel;Codex Plugin/MCP 若进入下一阶段,应成为单独版本化制品,声明兼容的 Core/schema 区间,并提供 `integration status/install/update/uninstall --dry-run`、文件 ownership manifest、原子替换与回滚。CI 必须从 wheel 和 sdist 外部安装,逐字验证 Skill/Plugin/MCP 资源与握手的 N/N-1、Core-newer、Plugin-newer、缺少 `[mcp]` 四种状态。 + +#### P1-2 — “任意目录发现”缺少完整的用户流与错误恢复契约 + +现有 Core 对 malformed local Profile 明确拒绝回落到 registry 默认,避免悄悄操作错误项目;已有测试覆盖损坏文件、悬空 symlink 和目录替代文件([`tests/test_continuation_resolution.py:41-92`](../../../tests/test_continuation_resolution.py))。方案只写了 `workspace.resolve` 和“从任意目录”,没有冻结 resolver precedence、选择来源字段,或零/多可用 workspace、stale default、已登记但宿主不可读、registry 损坏时的结构化恢复动作。现有 Home 至少会列出失效 alias 并给出 `workspace list/add/remove` 的具体下一步([`src/dyro/home.py:677-693`](../../../src/dyro/home.py));Bridge 的单个 `WORKSPACE_NOT_FOUND` 会使 Agent 难以区分“未登记”“路径失效”“本地 Profile 损坏”和“宿主无读取权限”。 + +**Required fix:** 把现有 resolver 作为唯一实现,并在结果中返回 `resolution_source=explicit|local|default|unique`,不写 recent state。为 `LOCAL_PROFILE_INVALID`、`REGISTRY_INVALID`、`REGISTERED_ROOT_STALE`、`HOST_READ_PERMISSION_REQUIRED`、`AMBIGUOUS_WORKSPACE` 分别定义不带 shell 字符串的 `next_actions`。验收必须覆盖 local Profile 优先、malformed local 不回落、stale default、唯一可用回落、零/多候选非 TTY,以及 registry 在沙箱外但不可读的部分失败。 + +#### P1-3 — 现有及拟议验收会漏掉真实编码智能体沙箱失败 + +本轮直接在当前受限 Codex workspace 中运行正常的 `dyro dispatch doctor`,复现 `PermissionError: ... ~/.dyro/local-agent-dispatch/edit-worktrees`:`doctor` 在非 dry-run 下调用创建整棵状态目录的 `dispatch_home`([`experiments/local_agent_dispatch/cli.py:255-271`](../../../experiments/local_agent_dispatch/cli.py)、[`experiments/local_agent_dispatch/paths.py:36-58`](../../../experiments/local_agent_dispatch/paths.py))。现有“零写”测试只覆盖 `--dry-run` 且 mock 掉 backend probe([`tests/test_adversarial_remediation_dispatch.py:2497-2537`](../../../tests/test_adversarial_remediation_dispatch.py));wheel CI 又把 dispatch home 指到可写临时目录([`.github/workflows/ci.yml:73-90`](../../../.github/workflows/ci.yml)),两者都绕开了用户最初遇到的失败。仅比较 workspace 和 registry 文件哈希也看不到临时目录、进程、网络、keyring 或其他用户目录的副作用。 + +**Required fix:** 新增安装后、非 dry-run 的 R0 黑盒门禁:只允许读的 HOME/XDG/DYRO_HOME、无网络、不可写 workspace、进程 spawn 记录器与全临时目录审计;对每个 R0 请求断言零 write/open-for-write、零网络、零非 allowlist 子进程、stdout 单一 JSON、stderr 无 traceback/ANSI。再在真实 Codex workspace-write 环境跑“registry/工作区均在 sandbox 内”和“registry 可读但工作区在 sandbox 外”两套;Claude/Cursor 的等价试验均 **须人工核**。只有 source-tree mock 或把状态根改到 `/tmp` 不计通过。 + +#### P2-1 — Skill 和工具面过宽,违背渐进披露并放大上下文成本 + +Skill 流程要求先跑 doctor/capabilities,而 capabilities 示例携带每个操作的完整输入/输出 schema;同时 MCP 首版列出十多个独立工具,Operation Registry 又覆盖 R0–R3。对普通“为什么 TASK-42 被阻塞”请求,这会把大量无关 schema 注入上下文,并提高误选 `dispatch`、gate execution 或未来 apply 的概率。当前 `skill-render --write` 的真实默认目标是 Dispatch 私有状态树 `.../skills/SKILL.md`([`experiments/local_agent_dispatch/skill_render.py:164-174`](../../../experiments/local_agent_dispatch/skill_render.py)、[`experiments/local_agent_dispatch/paths.py:101-102`](../../../experiments/local_agent_dispatch/paths.py)),CLI 帮助也只承诺“dispatch home or given path”([`experiments/local_agent_dispatch/cli.py:374-382`](../../../experiments/local_agent_dispatch/cli.py)),并未证明宿主会发现它。 + +**Required fix:** 首切片只保留 `hello/capabilities --compact`、`workspace.resolve/list/status`、`task.list/explain/graph` 和 Objective 的既有纯 plan。compact 输出只含版本、operation ID、risk 和 availability;按选中的单一 operation 再取 schema,并以 `schema version + capabilities digest` 缓存。为 SKILL.md、tool catalog 和一次典型 R0 会话设可测 token/byte 上限。触发描述必须正向限定“操作 Dyro 控制面”,并负向排除“委派第二意见/多 Agent panel”(属于 dispatch)。安装必须显式写入宿主真实 discovery 目录,先 preview,处理同名冲突,并可恢复卸载。 + +### Open Decisions + +1. **Public interface:** v1 对 Agent 暴露小规模 typed R0 tools;内部 transport 可以保留 allowlisted `operation` dispatch,但不提供 arbitrary command,也不把完整 registry 一次性变成工具目录。 +2. **Distribution:** Core Bridge CLI 留在 `dyro` wheel;Plugin/MCP 推迟并采用单独版本化 integration artifact。若最终仍放 optional extra,必须同样完成 host 资源打包和双向版本握手,不能只增加 Python 依赖。 +3. **Mutation:** v1 仅 inspect-and-plan。R1 apply 直到可信宿主授权与真实沙箱证据完成后逐项开放。 +4. **Host scope:** 首个承诺应是 Codex 已验证;Claude/Cursor/OpenCode 等只列为 planned,不把“本机能启动 CLI”写成“已支持 Bridge Plugin”。 + +### Required Fixes / Release Gates + +- [ ] 按源码调用图重新分级全部 operation;关闭 `task.gates` R0 缺陷。 +- [ ] 删除 v1 MCP apply,文档、Skill、capabilities 和测试四处一致声明 inspect-and-plan only。 +- [ ] 固化并复用现有 workspace resolver,补齐来源、部分失败和 actionable recovery schema。 +- [ ] 定义 host integration 制品、安装/升级/卸载/回滚和双向版本握手;wheel/sdist 外部安装验收能发现资源漏包。 +- [ ] 完成真实 Codex deny-write/no-network 黑盒验收;其他宿主未实测时公开标注 unsupported/experimental。 +- [ ] 用 compact capability + operation-on-demand schema 控制 Skill 触发和上下文预算,并做上述十个用户旅程的全新会话前向测试。 + +### Go / No-Go + +| 范围 | 裁定 | 放行条件 | +| --- | --- | --- | +| Phase 0:Bridge JSON envelope + compact capabilities + resolver + 纯 R0 | **Conditional Go** | P0-1 修正;真实 deny-write sandbox 零副作用;installed wheel 通过 | +| `dyro-control-plane` Skill beta | **No-Go** | 真实 discovery 目录、preview/install/uninstall、触发冲突与上下文预算验收完成 | +| Codex Plugin + read-only MCP | **No-Go** | 独立制品、版本握手、Core/Plugin skew、真实 MCP 进程权限 **须人工核**并通过 | +| 任意 R1/R2/R3 MCP apply | **No-Go** | 不属于 v1;可信用户授权 broker 和逐宿主隔离证据完成后另行评审 | +| 对外发布“跨宿主 Dyro Agent Bridge v1” | **No-Go** | 至少一个宿主全链路可安装、可升级、可回滚且制品外验收通过;其余宿主准确降级声明 | + +--- + +# Final Arbitration + +## 主审结论 + +- Arbiter: Codex Root +- Arbitrated at: 2026-08-06 +- Locked substrate: `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4` +- Overall verdict: **原始 Dyro Agent Bridge v1 方案 No-Go;收缩后的 inspect-and-plan-only Phase 0 Conditional Go。** +- Severity after deduplication: **P0 × 4、P1 × 5、P2 × 1**。 +- Source changes reviewed: none. This board is a design decision artifact, not an implementation approval. + +三名审查者从架构、权限安全、产品与宿主集成三个方向独立反证,核心结论高度一致:Dyro 确实需要给编码智能体提供稳定、结构化、可发现的入站接口,但当前设计把“计划完整性”“用户授权”“操作幂等”“宿主沙箱”四种不同能力混在了一个通用 `apply` 模型中。现有源码只证明个别领域具备其中一部分能力,不能推出通用 Bridge 已具备安全执行条件。 + +因此本次裁定不是取消 Bridge,而是将第一版产品承诺改为: + +```text +编码智能体 + -> 小型 typed tools / Skill + -> 只读 MCP 或 schema-validated JSON transport + -> Exposure Catalog(仅描述暴露面) + -> Core Observation / Plan services(唯一语义与策略来源) + -X-> 不向 Agent 暴露 apply +``` + +## 证据权重与独立复核 + +主审按“当前源码与可复现实验 > 已有设计文档 > 拟议方案”的顺序裁决。三端重复发现已合并,不按票数重复计级: + +1. 架构端运行了 74 项聚焦测试,全部通过;这证明现有 Objective、workspace 与 hub 的已实现行为,没有证明通用 Bridge apply。 +2. 产品端在隔离 `DYRO_HOME` 下验证了任意目录 resolver 的正向路径,也在真实受限 Codex 环境复现了普通 `dyro dispatch doctor` 尝试创建 `~/.dyro/...` 而失败。该事实说明 source-tree mock、dry-run 或把状态根指向 `/tmp` 不能替代真实宿主验收。 +3. 主审独立复核确认: + - `task gates` 直接调用 `run_gates`,后者启动 argv、写 gate log 并追加 ledger;原 R0 分类被源码直接否定。 + - Objective 的 SHA 绑定了计划语义,但机器调用者能够读取并原样回传;它是完整性摘要,不是不可伪造的人类批准。 + - `objective list/status` 当前使用默认 `recover=True` 的读取路径,存在拿锁和恢复 pending transaction 的可能,不能直接包装为零写 R0。 + - `create_line` 有 preflight/plan,但其多仓 Git 副作用、manifest 落盘和 best-effort rollback 没有构成通用事务与崩溃恢复边界。 + - hub registry 的更新已有独占锁和原子替换,是未来研究收敛式 R1 的较好候选;这仍不等于当前已批准开放。 + +## 合并后的 P0 阻断项 + +### P0-F1 — Confirmation digest 不是 Agent 不可伪造的授权 + +**合并来源:** Architecture P0-3/P0-4、Security P0-CURIE-01、Product P0-2。 + +`confirmation_sha256` 可用于发现计划过期或语义漂移,但同一 Agent 可以先取 plan 再回填 SHA。`--yes`、`actor`、聊天文本或 Skill 指令同样由调用者可控,不能证明用户在计划生成后作过独立批准。 + +**裁定:** 删除 v1 的 `dyro_apply_confirmed_plan` 及所有 R1/R2/R3 MCP/Skill apply 能力。未来只有在真实宿主证明存在模型不可见、可验证、短时、单次消费的 approval capability 后,才可逐 operation 重新评审。该能力至少绑定 operation、canonical input、plan digest、workspace identity、effects、session、expiry 与 nonce。无法证明时,R1 永久保留在人类独立 CLI/控制面,不进入 Agent MCP。 + +### P0-F2 — Operation 风险清单与当前源码不符,必须 deny-by-default + +**合并来源:** Architecture P0-2、Security P0-CURIE-02、Product P0-1。 + +原方案把 `task.gates` 当作 R0,但当前实现会执行通用 argv 并产生持久化记录;`task.answer` 在 local execution Profile 下还可能创建 attempt/worktree、启动 Agent、运行 gates 并改变质量状态。风险不能靠命令名或理想化的新 handler 推断。 + +**裁定:** 每个 exposure 必须先提交源码调用图、最大风险、上下文有效风险与负向副作用测试,再进入 allowlist。立即做以下拆分: + +- `task.gate_definitions.get` / `task.gates.last_result`:只有新建的纯读取实现通过零副作用门禁后才可列 R0。 +- `task.gates.run`:至少 R2,v1 不暴露给 Agent。 +- `task.answer`:最大 R2;拆成外部答案记录与本地执行等 typed operation 后仍不进入 v1 apply。 +- `objective list/status`:不能复用 recovery-enabled reader;必须使用明确 `recover=False` 的 Observation API。 +- 未完成调用图审计的 operation:默认不可用,而不是默认 R0。 + +### P0-F3 — Operation Registry 不能成为第二策略/授权控制面 + +**合并来源:** Architecture P0-1,并由当前 CLI/Core 分散实现佐证。 + +当前 line/task/objective 的确认、锁、状态机和变更规则分布在 CLI 与各领域模块中。若新 Registry 同时拥有 risk、policy 与 handler,它会和人类 CLI 分别决定同一动作能否执行,形成策略漂移。 + +**裁定:** 将 Operation Registry 政名并收缩为 **Exposure Catalog**。它只保存 operation ID、input/output schema、最大风险、协议版本、availability 和对应 Core service 引用;不得拥有授权、业务策略或事务规则。Bridge 禁止调用 `cmd_*`。必须先抽取 transport-neutral 的 Core Observation/Plan service,再由人类 CLI、JSON 与 MCP 共同调用。尚未完成 Core 化的命令不进入 Bridge。 + +### P0-F4 — “不暴露 R3 MCP 工具”不是同 UID Agent 的安全隔离 + +**来源:** Security P0-CURIE-03。 + +当前获得 shell 与工作区写权限的编码智能体仍可绕过 MCP,直接调用普通 `dyro task signoff/merge --yes` 等 CLI。Skill 和工具目录只减少误用面,不能限制同一 OS 身份的调用者。 + +**裁定:** ADR 必须明确威胁模型:Phase 0 提供的是防误操作和稳定 API,不承诺隔离一个已获得同 UID shell 权限的恶意 Agent。任何“Agent 不能 signoff/merge/push”的强保证,必须由所有入口共同经过的 broker/daemon、外部签名或宿主不可见 capability 实现;仅隐藏 MCP tool 不计安全控制。此项阻断原方案的安全宣称,但在 Phase 0 完全不含 mutation 且文档不作该宣称后,不阻断只读实现。 + +## 合并后的 P1 必修项 + +### P1-F1 — 建立真正的零写 Observation 边界 + +R0 必须满足:零业务写入、零目录/lock 创建、零 ledger/mtime 改变、零网络、零非 allowlist subprocess。它不得触发 recovery、repair、recent state、update check 或隐式缓存。Git 观察使用 `GIT_OPTIONAL_LOCKS=0` 或等价显式契约;支持平台是否仍会改 index 元数据必须实测,不能假设。 + +测试必须包含 pending Objective transaction、只读 HOME/XDG/DYRO_HOME、不可写 workspace、全临时目录审计、process/network trap,以及 installed wheel 外部黑盒运行。stdout 必须恰为一个 JSON object,stderr 不得出现 traceback 或 ANSI。 + +### P1-F2 — 计划摘要必须由 operation-specific read set 定义 + +即使 Phase 0 不 apply,计划模型也要为未来兼容性冻结正确边界。共享 envelope 可以统一,但 read set 不能“一套字段覆盖所有命令”。每个计划至少绑定: + +- `protocol_major` +- `operation_id` 与 `operation_schema_version` +- `planner_revision` +- canonical workspace identity 与 config digest +- normalized input +- operation-specific `read_set`,包括解析后的 ref full OID、关键路径/资源身份和所有安全谓词 +- semantic effects、warnings、risk 与 expiry + +未来 apply 必须在领域权威锁内重算并比较;plan 阶段输出不得被描述为“已经授权”或“可自动执行”。 + +### P1-F3 — Mutation 不能依赖通用 request ledger + +`request_id` 只能做 correlation。多副作用 operation 需要各自的 conflict key、锁顺序、linearization point、durable intent/start/receipt、fencing、`uncertain` 状态和恢复协议。Security 提出的 fd-relative/no-follow 路径方案对未来写侧有价值,但它不阻断纯读取 Phase 0;其 Windows 等价能力仍为 **须人工核**。 + +若后续发起 R1 试点,候选只考虑具有锁、atomic replace、可从权威状态判断收敛结果的 `workspace.add`。`line.create`、`task.create`、`task.answer` 和 Objective 执行不得作为首个试点。 + +### P1-F4 — 固化 transport、制品与版本握手 + +解决 Architecture 与 Product 关于打包方式的分歧如下: + +- Core Bridge、JSON transport 和 `dyro-mcp` server code 随同一个 `dyro` distribution 发布;MCP 依赖可使用 optional extra。 +- 必须安装真实 `dyro-bridge` / `dyro-mcp` console entry point,Plugin 不调用 ambient `python -m ...`。 +- 宿主专属 Plugin/manifest/Skill 安装包是**单独版本化的 integration artifact**,声明兼容 Core/protocol/schema 范围,并拥有文件 ownership manifest、preview/install/status/update/uninstall、原子替换与回滚。 +- 握手必须包含 client/integration version、protocol major/minor、operation schema range、planner revision 和 capabilities digest;major mismatch、未知 operation、缺依赖一律 fail closed。 +- CI 从 wheel 与 sdist 外部安装,覆盖 N/N-1、Core-newer、Plugin-newer、无 `[mcp]` 四种组合。 + +### P1-F5 — 复用唯一 workspace resolver,并提供结构化恢复路径 + +保留现有解析优先级:explicit alias → 向上发现 local Profile → registry default → 唯一可用 workspace。malformed local Profile 必须 fail closed,不得静默回落到别的 workspace。响应增加 `resolution_source`,并区分 `LOCAL_PROFILE_INVALID`、`REGISTRY_INVALID`、`REGISTERED_ROOT_STALE`、`HOST_READ_PERMISSION_REQUIRED`、`AMBIGUOUS_WORKSPACE`,每种返回结构化 `next_actions`,不夹带可直接执行的 shell 字符串,也不写 recent state。 + +## P2 改进项 + +### P2-F1 — 收缩 Skill 触发面与上下文预算 + +首版只暴露 compact capabilities;完整 schema 按选中的单一 operation 获取并以 schema version + capabilities digest 缓存。为 SKILL.md、tool catalog、错误详情与典型 R0 会话设置 token/byte 上限。触发描述要正向限定“读取/规划 Dyro 控制面”,并明确排除 `dispatch` 的第二意见/多 Agent 编排语义,避免 Agent 选择错误入口。 + +## Open Decisions 最终裁定 + +1. **Generic invoke vs typed tools:** CLI transport 可保留 schema-validated、allowlisted generic JSON `inspect/plan`;MCP 只提供少量 typed R0/plan tools。禁止 generic shell、arbitrary command 与 generic apply。 +2. **MCP packaging:** MCP server code 与 Core 同 `dyro` distribution、使用独立 console entry point;Codex 等宿主 Plugin 是独立版本化 integration artifact。这样既避免第二套 Core 语义,又能独立管理宿主兼容性。 +3. **R1 in v1:** 无。v1/Phase 0 仅 inspect-and-plan。`workspace.add` 只能在新的 ADR、实现证据和独立对抗复核后成为后续单 operation pilot。 +4. **首个宿主:** 只承诺真实验收通过的 Codex。Claude/Cursor/OpenCode 在各自的 sandbox、approval、安装与进程边界未经端到端验证前标为 planned/experimental,不得宣传为已支持。 + +## 修订后的模块 Go / No-Go + +| 模块 | 当前裁定 | 放行条件 | +| --- | --- | --- | +| 修订 ADR、Exposure Catalog、威胁模型与 operation inventory | **Go** | 仅设计/测试基线,不实现 mutation | +| Phase 0:JSON envelope、compact capabilities、resolver、纯 R0、不可执行 plan | **Conditional Go** | P0-F2/P0-F3 落地;零写与 installed-wheel 黑盒门禁通过 | +| `dyro-control-plane` Skill beta | **No-Go** | Phase 0 通过;真实 discovery、preview/install/uninstall、触发冲突和上下文预算通过 | +| Codex Plugin + typed read-only MCP | **No-Go** | 制品/版本握手/进程权限/真实 Codex sandbox 全链路通过 | +| 任意 R1/R2/R3 Agent apply | **No-Go** | 不属于 v1;可信授权、事务、路径与审计边界逐 operation 另行评审 | +| 跨宿主公开发布 | **No-Go** | 每个宣称支持的宿主独立安装、升级、回滚、权限与沙箱验收通过 | + +## 修订后的实施顺序 + +### Stage A — 先修设计,不写业务功能 + +1. 新建/修订 ADR-0006:冻结 inspect-and-plan-only、Exposure Catalog、同 UID threat model、非授权 digest 语义和无通用 apply。 +2. 产出 operation inventory:逐项记录 source call graph、reads、writes、subprocess/network、locks、recovery、maximum/effective risk、availability。 +3. 冻结 JSON envelope、error taxonomy、version handshake、compact capability 和 operation-on-demand schema。 + +### Stage B — Core Observation / Plan + +1. 抽取 transport-neutral Observation services,所有读取显式禁止 recovery/repair/update/recent writes。 +2. 抽取 typed Plan services;为每个 operation 定义自己的 `read_set` 和 `planner_revision`。 +3. Bridge 只引用这些 services;不得 import/调用 CLI `cmd_*`。 + +### Stage C — 机器 transport 与真实门禁 + +1. 增加 dedicated `dyro-bridge` 入口;parse、route、error 全链路只输出一个 JSON object。 +2. 建立 deny-write/no-network/no-spawn harness,并在 source tree、wheel、sdist 和真实 Codex workspace-write 环境运行。 +3. 加入 malformed local、stale registry、partial permission、pending recovery、Git optional-lock、输出截断与 secret redaction 用例。 + +### Stage D — Skill,再到 Plugin/MCP + +1. 先发布最小 Skill beta,只调用 Phase 0,并验证宿主实际 discovery、误触发和上下文预算。 +2. 再提供 typed read-only MCP 与 Codex integration artifact,完成版本偏移、安装/升级/卸载/回滚验证。 +3. 不在这一阶段加入 apply。 + +### Stage E — 单独评审首个 R1 pilot + +仅当真实宿主批准能力已经证明后,为 `workspace.add` 单独建 ADR、威胁模型、并发/崩溃/路径故障注入测试和新的对抗评审。该评审不得借 Phase 0 的 Go 结论自动放行。 + +## Phase 0 验收标准 + +- Agent 暴露面中不存在 apply、shell、signoff、merge、push、release、publish 或 cleanup。 +- `task.gates` 不存在于 R0;纯读取 gate API 触发 subprocess/log/ledger 即测试失败。 +- 全部 R0 在只读 HOME/DYRO_HOME/workspace 下零新增路径、零 persistent write、零网络、零非 allowlist subprocess。 +- Objective Observation 即使存在 pending transaction 也不恢复、不拿 mutation lock、不改文件。 +- malformed local Profile 不回落到 registry;stale/ambiguous/permission errors 给出稳定结构化 code 与 next actions。 +- stdout 在成功、schema error、routing error、Core error 下都恰为一个有界 JSON object;无 ANSI、traceback、secret、原始 argv 或未截断 stdout/stderr。 +- 从 wheel 和 sdist 安装到 checkout 外仍能运行;缺 optional MCP dependency 时返回结构化 unavailable,而非 Python traceback。 +- protocol major 或 operation schema 不兼容时 fail closed;旧 Plugin 不会因新 Core 增加 operation 而自动扩大工具权限。 +- SKILL.md 和 Plugin 不宣称其能够安全隔离同 UID shell Agent,也不宣称未实测宿主已支持。 + +## 最终发布门槛 + +在上述 Phase 0 条件全部提供可复现证据前,结论保持 **No-Go**。全部通过后,仅把 Phase 0 改为 **Go**;Skill、Plugin/MCP 与任何 mutation 仍分别保留自己的授权和发布门槛。当前最安全、也最有产品价值的下一步,是先让编码智能体能够可靠地“看懂 Dyro、解释状态、生成不可执行计划”,而不是让它代替用户批准和执行交付动作。 + +— **Final Arbiter: Codex Root** diff --git a/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md b/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md new file mode 100644 index 0000000..5c800d5 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md @@ -0,0 +1,73 @@ +# Dyro Agent Bridge Phase 0 Design Closure Review + +Date: 2026-08-06 + +Substrate: `feat/dev@d00d5ca6f1f64edc606ca23d44018033f76f67f4` + +Scope: + +- `docs/adr/0006-agent-bridge-phase-0.md` +- `docs/designs/agent-bridge-operation-inventory.md` +- `docs/designs/agent-bridge-protocol.md` +- `docs/designs/agent-bridge-phase-0-acceptance.md` +- `plans/dyro-agent-bridge-phase-0.md` + +Reviewer: Turing, independent architecture adversarial reviewer + +Arbiter: Codex Root + +No business source was changed or approved by this review. + +## Initial verdict + +The first draft was **No-Go for starting S1** with P0 × 2 and P1 × 6. The +reviewer tried to disprove dependency order, non-vacuous acceptance, transport +implementability, identity stability, bounded input, read authority, plan +consistency, and platform evidence. + +## Findings and closure + +| ID | Initial severity | Challenge | Resolution | Closure | +| --- | --- | --- | --- | --- | +| C1 | P0 | S5 required integration skew evidence for an S7 artifact that did not yet exist | Split `E03-Core` at S5 from `E03-Integration` at S7 | Closed | +| C2 | P0 | A catalog with zero available operations could satisfy a vacuous corpus | Freeze a non-empty Mandatory Core Surface and `declared → implemented_testable → public_available` lifecycle; formal A01 runs at S5 | Closed | +| C3 | P1 | Pre-parse errors could not fill operation metadata; broken stdout could not return JSON | Add nullable transport-error metadata, separate requested/server protocol, and deterministic exit 5 without retry/traceback | Closed | +| C4 | P1 | Workspace ID/config digest were undefined while S2/S3 were parallel | Freeze `WorkspaceIdentityV1` and `ConfigRevisionV1` plus vectors in S1 | Closed | +| C5 | P1 | Response limits did not bound workspace reads; one bad record erased healthy siblings | Add per-file/count/aggregate/deadline budgets, per-record isolation, B06, and adversarial corpus cases | Closed | +| C6 | P1 | Summary reads without Git inspection could falsely report readiness or blocking | Add `integration_inspection`; omit final readiness when not inspected; require B05 for authoritative explain/status/plan | Closed | +| C7 | P1 | Plan lacked typed business projection and digest/redaction order | Add operation-specific `projection`; hash only final allowlisted/redacted payload; reject blocked/selected/effect contradictions | Closed | +| C8 | P1 | Supported platforms and system-level observation mechanisms were undefined | Define Linux/macOS target scope, Windows fail-closed scope, layered evidence, and blind-spot policy | Closed | + +## Closure verification + +The first closure pass left two direct contradictions: + +1. S1 still named full A01 although public operations cannot exist until S4/S5. +2. The example plan marked `TASK-42` blocked while also declaring a + `would_execute_task` effect. + +They were corrected as follows: + +- S1 requires only the A01 catalog/schema unit portion; formal public/artifact + A01 remains an S5 gate. +- The contradictory effect was removed, and the protocol now rejects a blocked + subject that also appears in selected actions, tick wave, or a `would_*` + effect. + +The reviewer then marked both remaining items Closed. + +## Final verdict + +**S1 Go.** This authorizes beginning only the Core contract and Exposure Catalog +step described in the blueprint. It does not authorize Phase 0 release, Skill, +MCP, Plugin, any Agent mutation, commit, push, PR, merge, tag, release, publish, +or installation. + +Later gates remain independent: + +- S5 decides whether Core + JSON Phase 0 may become Go. +- S6 decides whether the host-neutral Skill beta may begin. +- S7 decides whether the Codex read-only MCP/Plugin may be supported. +- Any R1/R2/R3 operation requires a new ADR and adversarial review. + +— **Reviewer closure: Turing · Arbitration: Codex Root** diff --git a/docs/superpowers/reviews/2026-08-10-dyro-agent-bridge-phase-0-fix-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-10-dyro-agent-bridge-phase-0-fix-adversarial-review-board.md new file mode 100644 index 0000000..c2b0115 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-10-dyro-agent-bridge-phase-0-fix-adversarial-review-board.md @@ -0,0 +1,452 @@ +# Dyro Agent Bridge Phase 0 Local Fix — Adversarial Review Board + +Date: 2026-08-10 (Asia/Taipei) + +Scope: +- Repo: `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev/dyroengineeringflow` +- Branch: `feat/dev` @ `e284c1ce2da731c404ab3866124026a28d03691c` +- Mode: code review of uncommitted local-fix WIP + local Docker audit evidence claims + +Reviewed Materials: +- Uncommitted fix diff (5 files): + - `.github/workflows/ci.yml` + - `tests/fixtures/bridge/Dockerfile.audit` + - `tests/test_bridge_strace_audit.py` + - `tests/test_release_source.py` + - `tools/verify_bridge_zero_effects.py` +- Handoff: `plans/dyro-agent-bridge-cursor-handoff-2026-08-10.md` +- Local verification claims from Cursor wrap-up session (2026-08-10): source/wheel/sdist six reports PASS; package/contract digests match +- Acceptance SSOT: `docs/designs/agent-bridge-phase-0-acceptance.md` +- Control-plane skill: `src/dyro/integrations/assets/dyro-control-plane/SKILL.md` + +SSOT: +- `docs/designs/agent-bridge-phase-0-acceptance.md` +- `plans/dyro-agent-bridge-cursor-handoff-2026-08-10.md` +- Live source + uncommitted fix diff above + +Out of scope for this board (do not reopen unless source proves wrong): +- Redesigning Agent Bridge Phase 0 architecture +- Treating user WIP `plans/dyro-agent-bridge-phase-0.md` as part of this fix commit +- Calling/simulating `dyro dispatch`, objective apply, merge, push, release, publish +- Blindly relaxing CI timeouts without Ubuntu runner evidence + +## Rules + +1. Each reviewer writes only in their own signed section. +2. Conflicts are resolved by source code, live contracts, or retained evidence artifacts. +3. Unprovable claims are marked `须人工核`. +4. Findings use P0/P1/P2 severity. +5. Code review mode: bugs, regressions, security, broken contracts, missing tests first. +6. Local Docker evidence is not exact-commit Ubuntu CI evidence. +7. Do not edit, rewrite, or summarize another reviewer section. + +## Fixed Decisions + +- Phase 0 public Bridge availability remains Ubuntu 24.04 only; macOS/Windows stay fail-closed. +- Zero-effect / Landlock / tool-list / fail-closed assertions must not be weakened to make tests green. +- Existing Docker images, evidence volumes, and `/private/tmp` audit contexts must be retained. +- Commit / push / PR require separate explicit user authorization. + +## Open Micro-Decisions + +1. Should CI `bridge-zero-effects` timeouts be changed before the first real Ubuntu PR run, or only after timeout failure evidence? +2. Should the untracked handoff markdown be included in the fix commit, kept untracked, or moved under `docs/superpowers/reviews/`? +3. Are the six local Docker reports sufficient to call “本地修复完成”, while Phase 0 formal Go remains blocked on F01–F04 + exact-commit CI + this board? + +--- + +# Code Contract Reviewer Review Section + +Reviewer: Code-Contract-Agent +Time: 2026-08-10 22:05 Asia/Taipei +Verdict: Conditional Go (merge this local-fix commit only) + +## Findings (severity-ordered) + +### P1 — Stale `git am` session blocks safe commit of this fix +- Evidence: `git status` reports “You are in the middle of an am session”; worktree gitdir `.../worktrees/dyroengineeringflow/rebase-apply/` holds patch `0001` for already-landed `e7e1225` (dated 2026-08-07), with `next=1` / `last=1`. Fix files have no conflict markers. +- Contract impact: any commit/`am --continue` on this worktree risks mixing unrelated patch state with the five-file fix. +- Fix: `git am --abort` (or equivalent cleanup) **before** staging; then stage only the five fix paths. 须人工核 that abort does not discard intended WIP outside those paths. + +### P1 — Local audit reports assert `dirty=clean` while harness ≠ HEAD +- Evidence: all six `/private/tmp/dyro-bridge-reports.ywZ3zl/{source,wheel,sdist}-{candidate,public}-report.json` have `passed=true`, 43 ops, 11 unavailable@exit4, `trace.ok=true`, public `binder=2` / `landlock_success=2`, shared `contract_digest=sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e`, shared `package_manifest_sha256=sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e`, `commit=e284c1ce2da731c404ab3866124026a28d03691c`, `dirty=clean`. +- Counter-evidence: live `tools/verify_bridge_zero_effects.py` sha256 `7c6057b833efe6813afb904ab9d9de1368b88a658fe9dfa937d996d867bfd2eb` matches report `harness.verifier_sha256`; `git show HEAD:tools/verify_bridge_zero_effects.py` hashes to `746f86725f5acc98832bc02ac07043121f1dfa8da1cf9aaf30773a24067e8a7d` (different). `--dirty` is CLI/env asserted (`verify_bridge_zero_effects.py` ~1305 / CI `DYRO_AUDIT_DIRTY=clean`), not measured from git. +- Contract impact: results are valid **local repair-candidate** evidence (dirty harness + clean package@HEAD), **not** exact-commit / release evidence. Do not promote `dirty=clean` wording to formal Go. + +### P1 — Residual CI wall-clock risk (pre-existing; not introduced by this diff) +- Evidence: `.github/workflows/ci.yml` `bridge-zero-effects` has `timeout-minutes: 10` (L55) while each of three serial `docker run` invocations allows `timeout 8m` (L118); handoff §5.3 recorded ~9 minutes for **one** source public+candidate path on Colima. +- Contract impact: this fix does not change timeouts; first Ubuntu PR may still fail on job budget even if the three semantic fixes are correct. Per board rule / open micro-decision #1: **do not** widen timeouts in this commit without Ubuntu failure evidence. 须人工核 after first exact-commit CI run. + +### P2 — Regression tests are string/shape guards, not full Docker rebuilds +- `tests/test_release_source.py` L99–114 and `tests/test_bridge_strace_audit.py` L201–206 assert workflow/Dockerfile text; `test_objective_plan_fixture_uses_the_existing_anchor_repository` (L123–128) asserts `storage_for("api")=="anchor-reference"` only. Acceptable as unit regression for this fix; black-box still owned by Ubuntu Docker gate. + +## Contract Consistency + +Cross-module contracts for the three root failures are aligned and do not weaken zero-effect / Landlock / tool-list / fail-closed gates: + +| Failure | CI | Dockerfile | Fixture / verifier | Tests | +|---|---|---|---|---| +| hash-locked + unpinned build tools | Two `pip download` calls (`.github/workflows/ci.yml` L83–86); `uv export` requirements are hashed (tmp context sample) while `setuptools`/`wheel` absent from that file | Offline install still `--no-index --find-links=/audit/wheelhouse` | N/A | `test_ci_downloads_hash_locked_runtime_and_build_tools_separately` requires both snippets | +| `groupadd`/`useradd` not on PATH | Copies working-tree `Dockerfile.audit` into audit context (L90) | Runtime `PATH=/audit/venv/bin:/usr/bin:/bin` (L52) + absolute `/usr/sbin/groupadd|useradd` (L59–60); PATH not widened | N/A | Runtime-stage asserts absolute `/usr/sbin/...` (L204–206) | +| `objective.plan` early `RECORD_INVALID` | Harness script copied from tree (L92) | CMD runs candidate then public verifier | `prepare_fixture` writes `[storage_modes] api = "anchor-reference"` (`verify_bridge_zero_effects.py` L177–179); matches `Line.storage_for` default `linked-worktree` (`workspace.py` L32–33) and plan path selection (`plans.py` L511–515) | New fixture unit test L123–128 | + +No redesign; gates that require `binder==2` / `landlock_success==2` remain; fixture change **enables** those proofs instead of failing closed before Git bind. + +## Source Evidence Accuracy + +- **Proven:** six exported reports PASS with digest/parity claims above; volumes `dyro-bridge-evidence-source-r3-*`, `wheel-r4-*`, `sdist-r3-*` exist alongside older diagnostic volumes. +- **Proven limitation:** package artifact @ HEAD `e284c1c` + dirty harness (verifier digest mismatch) ⇒ local candidate only (handoff §5.5). Matches board rule “Local Docker != exact-commit Ubuntu CI”. +- **须人工核:** whether any retained volume/`DYRO_AUDIT_DIRTY=clean` run was accidentally reused after further tree drift beyond the five reviewed files; current worktree also has out-of-scope `M plans/dyro-agent-bridge-phase-0.md`. + +## Decision Validity + +1. **Timeouts:** keep current budgets until Ubuntu failure evidence — agree with open micro-decision #1; do not bake speculative timeout edits into this fix. +2. **Handoff markdown:** not required for the code contract of the five-file fix; include only if the commit message/docs policy wants operator SSOT. Keep `plans/dyro-agent-bridge-phase-0.md` out of the fix commit (fixed decision). +3. **“本地修复完成”:** acceptable as **local repair-candidate verification complete**; unacceptable as Phase 0 formal Go (F01–F04 + exact-commit CI still open). + +## Plan Executability + +Merge path for **this fix** is executable after process hygiene: + +1. Abort stale `git am`. +2. Commit only: `ci.yml`, `Dockerfile.audit`, `test_bridge_strace_audit.py`, `test_release_source.py`, `verify_bridge_zero_effects.py`. +3. Push/PR under separate user auth; treat first Ubuntu `bridge-zero-effects` as the real integration proof. +4. Do not treat `/private/tmp/dyro-bridge-reports.ywZ3zl` as release evidence artifact. + +## Scope And Risk + +- Scope of the five-file diff is tightly matched to the three diagnosed failures; no acceptance-matrix weakening observed. +- Main residual risks: stale am session; CI wall-clock; mis-promotion of dirty-harness local reports; accidental inclusion of user WIP plan file. + +## Go/No-Go + +**Conditional Go** for merging **this local-fix** (not Phase 0 release Go). + +Conditions: clear `git am`; exclude `plans/dyro-agent-bridge-phase-0.md`; no timeout weakening in this commit; language stays “local candidate fix”, not exact-commit/release. + +## Required Fixes + +1. **[P1/process]** Resolve stale `git am` before any commit of these paths. +2. **[P1/scope]** Stage only the five fix files; leave user WIP plan unstaged. +3. **[P1/claims]** When recording completion, state harness dirty vs package@HEAD; do not cite these six reports as `dirty=clean` exact-commit evidence. +4. **[P1/follow-up, not in this commit]** After first Ubuntu CI result: if job hits 10m / container 8m, then adjust timeouts with that evidence (open micro-decision #1). + +--- + + +# Security Reviewer Review Section + +Reviewer: Security-Agent +Time: 2026-08-10 ~21:55 Asia/Taipei +Verdict: **GO for merging this fix patch** (security intent preserved). **NO-GO for Phase 0 formal release** (unchanged blockers: F01–F04, exact-commit Ubuntu CI, dirty harness ≠ release evidence). + +Risk Level (this fix patch): **LOW** +Finding counts: P0=0, P1=0, P2=4, 须人工核=2 + +Adversarial focus: PATH expansion temptation, hash-lock bypass, fixture `storage_mode` capability lying, false Landlock evidence, claim inflation of local audits to formal Go. + +## Contract Consistency + +Security gates in acceptance SSOT (B01–B05 Landlock/zero-effect, fail-closed public Bridge on non-Ubuntu, hash-locked offline wheelhouse) remain intact in the five fix files: + +1. **PATH / isolation** — Runtime `PATH=/audit/venv/bin:/usr/bin:/bin` is unchanged. Fix uses absolute `/usr/sbin/groupadd` and `/usr/sbin/useradd` at image *build* time only (`Dockerfile.audit` runtime stage). Does **not** widen runtime PATH to `/usr/sbin`. Non-root `USER 10001:10001` and CI `docker run` flags (`--network=none --read-only --cap-drop=ALL --cap-add=SYS_PTRACE --security-opt no-new-privileges=true`) unchanged. +2. **Hash-lock** — Split `pip download` keeps hashed `uv export --locked` requirements on their own command (pip auto-enables require-hashes when `--hash=` lines are present; live export shows 264 hash lines; `setuptools`/`wheel` are **not** in that export). Second download is only unpinned build tools into the same wheelhouse — restores the previously failing intended design; does **not** strip hashes from runtime deps. +3. **Fixture storage_mode** — `prepare_fixture` still only creates `workspace/repositories/api` (no `versions/...` worktree). Declaring `api = "anchor-reference"` matches `Line.storage_for` → `repository_path` in `plans._integration_state`, so `objective.plan` reaches descriptor-binder + Landlock instead of failing early as `RECORD_INVALID`. This is fixture honesty, not a capability widening of Bridge. +4. **Fail-closed** — No relaxation of unavailable-ops (==11, exit 4), Landlock summary asserts (`binder == 2`, `landlock_success == 2`), mutation/network/write_open gates, or macOS/Windows public availability. + +## Source Evidence Accuracy + +| Claim | Source verdict | +| --- | --- | +| Absolute `/usr/sbin` avoids PATH widen | **Proven** — `git diff` on `Dockerfile.audit`; ENV PATH still excludes `/usr/sbin` | +| Split download preserves runtime hashes | **Proven** — `ci.yml` + live `uv export` hash lines; setuptools/wheel absent from export | +| Fixture uses real anchor path for plan/Landlock | **Proven** — `verify_bridge_zero_effects.py` + `plans.py:511-515` path selection; same `git_reader` / Landlock helper | +| Six local reports PASS with landlock_success=2, mutation=0 | **Proven for artifacts under** `/private/tmp/dyro-bridge-reports.ywZ3zl` (all six; `evidence.commit=e284c1c…`, `dirty=clean`, matching contract/package digests) | +| Those reports are exact-commit Ubuntu CI / release evidence | **False if claimed** — harness includes uncommitted WIP; local Colima/Docker ≠ GHA Ubuntu runner; handoff correctly labels “本地修复候选证据” | +| Full CVE dependency audit clean | **须人工核** — `uvx pip-audit` aborted (`ensurepip` SIGABRT) in this environment; fix patch does not change lockfile/dep pins | + +Secrets scan on the five fix files: no hardcoded keys/passwords/tokens. + +## Decision Validity + +| Fix | Weakens isolation / Landlock / fail-closed / hash-lock / side effects? | Decision | +| --- | --- | --- | +| `/usr/sbin/*` absolute admin tools | No — correct least-privilege alternative to expanding PATH | **Valid** | +| Separate pip downloads | No hash-lock bypass of runtime; residual unpinned build tools pre-existed as intent | **Valid** | +| `anchor-reference` on alpha | No — aligns config with created tree; enables real binder/Landlock evidence rather than fake early failure | **Valid** | +| Regression tests (sbin paths, two downloads, storage_mode) | Strengthen contracts; do not relax asserts | **Valid** | + +False-Landlock concern: rejected. Early `RECORD_INVALID` prevented binder execution; after fix, reports show `binder=2` / `landlock_success=2` / `mutation=0` / `network=0` / `write_open=0` via the same `git_read` Landlock ABI≥3 helper. Not synthetic counters alone. + +## Plan Executability + +- Fix patch is mergeable from a security-regression standpoint. +- Residual executability risks (timeouts 8m/10m, wheel/sdist re-run on exact commit CI) are operational, not security weakenings — do not block *this* patch on security grounds. +- Do not treat local six-report folder as Phase 0 formal Go evidence. + +## Scope And Risk + +- Scope of security-relevant WIP is correctly limited to CI wheelhouse fetch, audit Dockerfile admin paths, fixture storage_mode, and contract tests. User WIP `plans/dyro-agent-bridge-phase-0.md` is out of this security verdict for the fix commit. +- No Bridge production authn/authz surface changed; no dispatch/apply/side-effect paths introduced. +- Overall risk for **merging the fix**: LOW. Overall risk if **inflating local evidence to release Go**: HIGH (process), not a defect in the patch itself. + +## Go/No-Go + +- **Merge this fix patch (security):** GO +- **Phase 0 formal release / publish:** NO-GO until F01–F04 + committed exact-SHA Ubuntu `bridge-zero-effects` evidence artifact; local Docker PASS must not be marketed as that gate. + +## Required Fixes + +None P0/P1 blocking merge of this patch. + +### P2 (should harden soon; not merge-blockers) + +1. **Regression gap — PATH must stay narrow** (`tests/test_bridge_strace_audit.py:201+`) + Assert runtime stage still contains `PATH=/audit/venv/bin:/usr/bin:/bin` and does **not** add `/usr/sbin` to PATH (prevents future “just expand PATH” regressions). + +2. **Regression gap — hash-lock semantics** (`tests/test_release_source.py`) + Assert first download remains `--requirement` alone (no unpinned packages on that line) and second download is separate; optionally assert workflow still uses `uv export --locked` producing hashed requirements. + +3. **Residual supply chain — unpinned build tools in shared wheelhouse** (`.github/workflows/ci.yml:85-86`) + `setuptools>=77.0.3` / `wheel` downloaded without pins/hashes into the same `--find-links` store used by offline `pip install` (esp. sdist build). Intentional and not a runtime hash bypass, but pin+hash or isolate build-tool wheelhouse later. + +4. **Coverage residual — only `anchor-reference` exercised** (`tools/verify_bridge_zero_effects.py`) + Zero-effect Landlock proof path no longer covers `linked-worktree` destination resolution. Not a lie about capabilities; track as follow-up corpus/fixture coverage. + +### 须人工核 + +1. Re-run `pip-audit` (or equivalent) against locked export on a healthy runner — not completed here. +2. Confirm provenance of `/private/tmp/dyro-bridge-reports.ywZ3zl` against the exact WIP harness image digests before any internal “本地修复完成” claim beyond handoff’s candidate wording. + +## OWASP / Checklist (scoped to this patch) + +- A01 Access control: N/A change (gates unchanged) +- A02 Crypto / secrets: no secrets introduced; runtime hash-lock preserved +- A03 Injection: N/A (admin absolute paths; no new shell interpolation of user input) +- A05 Misconfig: PATH not widened; docker hardening flags intact +- A06 Vulnerable components: lockfile unchanged; CVE audit 须人工核 +- A08 Integrity: split download preserves require-hashes for runtime; build tools remain weaker link (P2) +- A10 SSRF: N/A (`--network=none` audit unchanged) + +Security Checklist: +- [x] No hardcoded secrets in fix files +- [x] Isolation / PATH not widened +- [x] Runtime hash-lock not bypassed +- [x] Fixture storage_mode does not skip Landlock / does not invent capabilities +- [x] Fail-closed / zero-effect asserts not relaxed +- [ ] Dependencies CVE-audited in this environment (须人工核) +- [x] Local evidence not accepted as Phase 0 formal Go + +--- + +# Critic Reviewer Review Section + +Reviewer: Critic-Agent +Time: 2026-08-10 22:05 Asia/Taipei +Verdict: **MERGE local fix (5 files): CONDITIONAL GO / ACCEPT-WITH-RESERVATIONS** · **Phase 0 formal Go: NO-GO / REJECT** +Mode: ADVERSARIAL (process blocker + evidence-labeling risk + CI budget arithmetic; security asserts not weakened) + +Pre-commitment vs actual: expected dirty-harness mislabeled as clean/exact-commit, CI timeout hostility, `anchor-reference` coverage hole, digest overclaim, Landlock weakening. Actual: first three confirmed; six-report digests **verified**; silent zero-effect/Landlock weakening **not found** (parent-confirmed). New parent-verified fact: active `git am` session blocks safe commit. + +## Contract Consistency + +- Acceptance SSOT still requires Layer-3 exact-commit Ubuntu + Layer-4 F01–F04 for formal Go. Local six-report PASS cannot close Phase 0. Wrap-up No-Go on formal Phase 0 is correct. +- CI compare asserts unchanged vs HEAD: `operations == 43`, `unavailable == 11`, `trace.ok`, public `binder == 2`, `landlock_success == 2`, single package/contract digest. Five-file diff does **not** relax these. +- Runtime `PATH=/audit/venv/bin:/usr/bin:/bin` unchanged; `/usr/sbin/{groupadd,useradd}` absolute only — not a PATH widen. +- Fixture `[storage_modes] api = "anchor-reference"` matches created `repositories/api` (no `versions/...`). Enables `_bind_git_metadata`/Landlock instead of pre-binder `RECORD_INVALID`. Default `Line.storage_for` remains `linked-worktree` (`workspace.py`); harness still does not exercise `line_repository_path` — coverage residual, not assertion deletion. +- Handoff §5.4/§5.5 (incomplete wheel/sdist) is stale vs retained six PASS reports; artifacts win. + +## Source Evidence Accuracy + +Verified `/private/tmp/dyro-bridge-reports.ywZ3zl` (six files): all `passed=true`; 43 ops; 11 unavailable@exit4/ok=false; `trace.ok`; public `binder=2` / `landlock_success=2`; `mutation/network/write_open=0`; package `sha256:baaf9c710d7a32dd332da0987a93a1073e8904e8d7de0d0142d7b118ca25a70e`; contract `sha256:2769249643ca1e03738d0f175c121dd879230ee8740a8fc65f413957c511971e`; `commit=e284c1ce2da731c404ab3866124026a28d03691c`. Digest/parity claims in wrap-up: **accurate**. + +Parent-verified labeling fact ( Critic concurs ): + +- Reports show `evidence.dirty=clean` (CLI/env asserted via `--dirty clean` / `DYRO_AUDIT_DIRTY=clean`, not measured from git). +- Live `tools/verify_bridge_zero_effects.py` sha256 `7c6057b833efe6813afb904ab9d9de1368b88a658fe9dfa937d996d867bfd2eb` **equals** report `harness.verifier_sha256`. +- `git show HEAD:tools/verify_bridge_zero_effects.py` → `746f86725f5acc98832bc02ac07043121f1dfa8da1cf9aaf30773a24067e8a7d` (**≠** report harness). +- Therefore: valid **本地修复候选证据** only; **invalid** as exact-commit / release evidence. Consumers who trust `dirty=clean` + HEAD SHA without checking harness sha will false-promote Layer-3. + +Colima ~8–9m/artifact duration: **须人工核** (report file mtimes are export-time, not audit wall-clock). + +## Decision Validity + +- Three stated bugs vs fix: `/usr/sbin` absolutes, split `pip download`, fixture `anchor-reference` — all directionally correct; no silent zero-effect/Landlock/fail-closed weakening found. +- Residual (not merge-blockers for security intent): unpinned `setuptools`/`wheel` second download (pre-existing intent); `linked-worktree` path uncovered; regression tests are string/shape guards. +- Phase 0 formal Go remains invalid until committed harness≡package identity, Ubuntu `bridge-zero-effects` artifact, F01–F04, and Final Arbitration ACCEPT. + +### Open micro-decisions (Critic) + +1. **Timeouts:** Do not raise per-artifact `timeout 8m` or weaken corpus asserts in this fix commit. Job `timeout-minutes: 10` vs three serial Docker builds+runs is arithmetically hostile — treat as follow-up after first Ubuntu wall-clock (agree with Code Contract: not in this commit). Distinguish job-budget hygiene from security-gate relaxation. +2. **Handoff:** Keep out of the five-file fix commit (stale mid-sections). Optional later docs commit under `docs/superpowers/reviews/`. +3. **“本地修复完成”:** Acceptable only as **local repair-candidate verification complete** with mandatory dirty-harness qualifier. Reject bare wording that implies Phase 0 Go or exact-commit CI. + +## Plan Executability + +**P0 — Stale `git am` session blocks commit path** + +- Parent-verified: `git status` reports “You are in the middle of an am session”. +- Any commit / `am --continue` on this worktree risks mixing unrelated patch state with the five-file fix (Code Contract notes rebase-apply patch for already-landed `e7e1225`). +- Fix: `git am --abort` (or equivalent) **before** staging; then stage only the five fix paths. 须人工核 abort does not discard intended WIP outside those paths. +- Until cleared: merge/commit of this fix is **not executable**. + +Other executability: + +- `ci.yml` push trigger is `main` only; `feat/dev` needs PR for `bridge-zero-effects`. +- Commit/push/PR require separate user authorization. +- Exclude `plans/dyro-agent-bridge-phase-0.md` (user WIP) from the fix commit. + +## Scope And Risk + +- Five-file diff is tightly scoped (harness/CI/fixture/tests only). No Bridge product runtime modules changed. +- Highest near-term risks: (1) committing during `git am`; (2) promoting dirty-harness reports via `dirty=clean`; (3) CI job timeout on first PR; (4) accidental staging of user WIP plan. +- Security blast radius of the patch itself: low — asserts preserved (aligns with Security-Agent). + +## Go/No-Go + +| Decision | Verdict | Conditions | +| --- | --- | --- | +| (1) Merge of local fix (5 files) | **CONDITIONAL GO** | Abort `git am` first; stage only five fix files; exclude user WIP plan + handoff from this commit; claims must say candidate/dirty-harness, not exact-commit; no timeout weakening in this commit | +| (2) Phase 0 formal Go | **NO-GO** | Missing exact-commit Ubuntu CI, F01–F04, committed harness≡package, Final Arbitration ACCEPT | + +## Required Fixes + +1. **[P0/process]** Resolve stale `git am` (`git am --abort` or equivalent) before any commit of these paths. +2. **[P0/claims]** Record completion with harness `verifier_sha256` ≠ HEAD; never cite these six reports as `dirty=clean` exact-commit evidence. +3. **[P1/scope]** Stage only: `ci.yml`, `Dockerfile.audit`, `test_bridge_strace_audit.py`, `test_release_source.py`, `verify_bridge_zero_effects.py`. +4. **[P1/follow-up]** After first Ubuntu CI wall-clock: adjust job/per-run timeouts only with that evidence (open micro-decision #1). +5. **[P1/coverage, not this-commit blocker]** Track `linked-worktree` destination coverage or document why `anchor-reference`-only Landlock proof is accepted for B05. +6. **[P2]** Prefer refreshed handoff / “本地修复候选证据完成;Phase 0 仍为 No-Go” over bare “本地修复完成”. + +--- + +# Final Arbitration + +Arbiter: Cursor Root (parent agent) +Time: 2026-08-10 22:10 Asia/Taipei +Final verdict: **Conditional Go for merging the 5-file local fix** · **No-Go for Phase 0 formal release** + +## 1. Final Verdict + +- May the local-fix commit proceed: **Conditional Go** (process preconditions below) +- May Phase 0 be declared formal Go / publishable: **No-Go** +- Required preconditions before commit: + 1. Clear stale `git am` (`git am --abort` or equivalent) — **须人工核** abort does not discard intended WIP + 2. Stage only the five fix files; exclude `plans/dyro-agent-bridge-phase-0.md` + 3. Keep claim language as **本地修复候选证据**; do not cite six reports as exact-commit / release evidence + 4. Do **not** widen CI timeouts in this commit +- Blocking reasons for Phase 0 formal Go: F01–F04 host evidence missing; exact-commit Ubuntu CI missing; harness sha ≠ HEAD while reports assert `dirty=clean`; independent review gate previously open (this board closes the *review* gate for the local-fix scope only) + +## 2. Repo / Module Go-No-Go + +| Repo/Module | Spec | Plan | Verdict | Reason | +| --- | --- | --- | --- | --- | +| 5-file local fix (CI / Dockerfile / fixture / tests) | N/A (bugfix) | Handoff Steps 0–6 | **Conditional Go** | Fixes match diagnosed bugs; security gates preserved; process blockers remain | +| Local Docker six-report evidence | Acceptance Layer-2/local | Handoff §5 | **Accept as candidate only** | PASS+parity proven; dirty harness ≠ exact-commit | +| Phase 0 formal release | Acceptance Layer-3/4 | F01–F04 + CI | **No-Go** | Host + Ubuntu exact-SHA gates open | +| Timeout change in this commit | CI budget | Micro-decision #1 | **No-Go (do not change now)** | Need Ubuntu runner wall-clock first | + +## 3. P0 Required Fixes + +### P0-F1: Clear stale `git am` before any commit + +Evidence: +- `git status`: “You are in the middle of an am session.” +- Worktree gitdir `rebase-apply/0001` is the already-landed `e7e1225` patch (dated 2026-08-07); `next=1` / `last=1`. + +Decision: +- Abort the stale am session before staging/committing the five-file fix. +- Do not `am --continue` that patch. + +Acceptance: +- `git status` no longer reports an am session; five fix files remain as intended WIP; user plan WIP still present if desired. + +### P0-F2: Do not promote local reports to exact-commit / release evidence + +Evidence: +- All six `/private/tmp/dyro-bridge-reports.ywZ3zl/*-report.json`: `passed=true`, digests match wrap-up claims, `evidence.dirty=clean`, `evidence.commit=e284c1c…`. +- `evidence.harness.verifier_sha256=7c6057b8…` equals live dirty `tools/verify_bridge_zero_effects.py`. +- `git show HEAD:tools/verify_bridge_zero_effects.py` → `746f8672…` (different). +- `DYRO_AUDIT_DIRTY=clean` is env-asserted, not measured from git. + +Decision: +- Severity split (arbiter): **P0 against formal Go / release marketing**; **not a code defect in the five-file fix**. +- Keep handoff wording: 本地修复候选证据 only. +- After commit, regenerate audits from clean checkout of that SHA for Layer-3. + +Acceptance: +- Any completion report / commit message / PR body that cites these six reports must include dirty-harness qualifier and deny exact-commit status. + +## 4. P1 / P2 + +### P1 (must handle in commit hygiene or immediate follow-up) + +1. **Stage scope:** only `.github/workflows/ci.yml`, `tests/fixtures/bridge/Dockerfile.audit`, `tests/test_bridge_strace_audit.py`, `tests/test_release_source.py`, `tools/verify_bridge_zero_effects.py`. +2. **CI wall-clock risk:** job `timeout-minutes: 10` vs three serial `timeout 8m` docker runs (+ builds) is arithmetically hostile. Record as known risk; adjust only after first Ubuntu failure/success evidence. (Downgraded from Critic “P0 formal” framing for *this fix merge* — it does not make the patch incorrect.) +3. **Claim language:** prefer “本地修复候选证据完成;Phase 0 仍为 No-Go” over bare “本地修复完成”. +4. **Coverage residual:** fixture now only exercises `anchor-reference` Landlock path; track `linked-worktree` coverage as follow-up (not a silent gate weaken). + +### P2 (harden soon; not merge-blockers) + +1. Assert runtime PATH remains narrow and does not gain `/usr/sbin` (Security P2-1). +2. Strengthen hash-lock string tests / later pin+hash or isolate build-tool wheelhouse (Security P2-2/3). +3. Refresh or relocate handoff docs; optional separate docs commit for this board file. +4. Regression tests remain string/shape guards; Docker black-box stays Ubuntu CI’s job. + +## 5. Open Micro-Decisions (resolved) + +1. **CI timeouts:** **Only after** real `ubuntu-24.04` wall-clock evidence (failure or proven margin). Do not change in the fix commit. +2. **Handoff markdown:** **Keep out** of the five-file fix commit. This board file may be a later docs commit; handoff may stay untracked or move under `docs/superpowers/reviews/`. +3. **“本地修复完成” terminology:** **Acceptable with qualifier** = local repair-candidate verification complete. **Unacceptable** as Phase 0 formal Go. + +## 6. Instructions For The Execution Agent + +When user authorizes commit (separately): + +1. Ask/confirm `git am --abort` (do not abort without authorization if user has other intent). +2. Re-check `git status` clean of am session. +3. Stage only the five fix files. +4. Commit with Chinese Conventional Commit subject, e.g. `fix: 修复 Agent Bridge 零副作用审计运行时路径与 fixture 契约`. +5. Stop; ask separately for push; then separately for PR. +6. Do not delete Docker images, evidence volumes, or `/private/tmp` audit contexts. +7. Do not call dispatch / objective apply / merge / release / publish. + +## 7. Conditions To Start Implementation + +N/A for new feature work. For **landing this fix**: + +- P0-F1 cleared +- Stage scope correct +- Claim language correct +- No timeout weakening in the same commit + +## 8. Requires Human Verification + +- Aborting `git am` does not discard intended non-fix WIP (**须人工核**) +- F01–F04 real Codex host journeys (**须人工核** / host-only) +- Exact-commit Ubuntu `bridge-zero-effects` artifact after commit+PR (**须人工核**) +- Optional: `pip-audit` on locked export on healthy runner (**须人工核**) +- Colima vs GHA wall-clock margin (**须人工核** on first PR) + +## 9. Reviewer Conflict Resolution + +| Topic | Code-Contract | Security | Critic | Arbiter | +| --- | --- | --- | --- | --- | +| Merge this fix | Conditional Go | Go | Conditional Go | **Conditional Go** | +| Phase 0 formal Go | No-Go | No-Go | No-Go | **No-Go** | +| Security gate weakening | Not found | Not found | Not found | **Not found** | +| `git am` severity | P1 process | (not primary) | P0 process | **P0 process (commit blocker)** | +| dirty-harness / `dirty=clean` | P1 claims | claim inflation HIGH if misused | P0 claims | **P0 vs formal Go; P1 for labeled candidate merge** | +| CI timeout | P1 follow-up | operational | Critical/Major framing | **P1 follow-up; no change now** | +| Six-report PASS/digests | Proven | Proven | Proven | **Proven as candidate evidence** | + +## 10. Source-Verified Facts (arbiter re-check) + +- HEAD / upstream: `e284c1ce2da731c404ab3866124026a28d03691c` +- Six reports PASS with stated package/contract digests +- public binder=2, landlock_success=2, mutation=network=write_open=0 +- Harness verifier sha matches dirty WIP, not HEAD +- Active `git am` confirmed via status + `rebase-apply` contents for landed `e7e1225` +- No Bridge product runtime modules in the five-file diff + +Final signature: Cursor Root · 2026-08-10 diff --git a/docs/superpowers/reviews/2026-08-12-dyro-0.6.3-release-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-12-dyro-0.6.3-release-adversarial-review-board.md new file mode 100644 index 0000000..34c7759 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-12-dyro-0.6.3-release-adversarial-review-board.md @@ -0,0 +1,542 @@ +# Dyro 0.6.3 Release Adversarial Review Board + +Date: 2026-08-12 + +Scope: +- Repository: `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev/dyroengineeringflow` +- Branch: `feat/dev` @ `a450938a9f5932c9783570210b51055e7773b62c` +- PR: https://github.com/DandreYang/DyroEngineeringFlow/pull/19 +- Base: `origin/main` +- Question: Is this tip ready to merge and publish as **0.6.3**? + +Reviewed Materials: +- Diff: `origin/main...HEAD` (shipping surface focus) +- `src/dyro/integrations/manager.py` (Skill mirror + avatars) +- `src/dyro/integrations/assets/dyro-control-plane/SKILL.md` +- `src/dyro/home.py` (`_parse_repository_selection`, `_ask_line_repositories`) +- `src/dyro/cli.py` (integration skill/codex) +- `.github/workflows/ci.yml`, `.github/workflows/pypi-publish.yml` +- `docs/publishing.md`, `CHANGELOG.md` (Unreleased), `pyproject.toml` (version still 0.6.2) +- Evidence archive (historical only): `docs/superpowers/evidence/agent-bridge-phase-0-abca42c/` + +SSOT: +- Product decision: excise Ubuntu-gated Bridge/MCP from shipping surface; keep CLI + Skill +- Release process: `docs/publishing.md` +- PR CI green on tip (no `bridge-zero-effects` job) + +## Rules + +1. Each reviewer writes only in their own signed section. +2. Conflicts are resolved by source code or live contract. +3. Unprovable claims are marked `须人工核`. +4. Findings use P0/P1/P2 severity. +5. Code-review mode: prioritize bugs, regressions, security, missing tests, release blockers. +6. Do not reopen “Bridge should return” unless source proves Skill/mirror path is unsafe. + +## Fixed Decisions + +- Bridge/MCP public shipping surface is removed; ADR/evidence remain archive. +- Skill install model is mirror + avatar (not per-host full copies). +- `codex` remains a CLI alias for `skill`. +- Publish requires new version (cannot republish 0.6.2). + +## Open Micro-Decisions + +1. Should 0.6.3 merge PR #19 as-is then bump version on `main`, or bump version on `feat/dev` before merge? +2. Is Cursor skill path `~/.cursor/skills/` acceptable for v1, or should it be omitted until confirmed? +3. Should CHANGELOG explicitly warn that `dyro-bridge`/`dyro-mcp` entry points disappear for upgraders from any interim builds? + +## Seat status + +| Seat | Model outcome | Section | +|------|---------------|---------| +| Claude | Opus/Sonnet usage-limited; Composer substitute completed (late) | signed below | +| OpenCode | GPT limited → Composer substitute completed | signed below | +| Hermes | Fable limited → substitute completed | signed below | +| Agy | Completed | signed below | +| Grok | Completed | signed below | + +--- + +# Claude Review Section + +Reviewer: Claude (Composer substitute) +Time: 2026-08-12 +Verdict: **Conditional Go** + +## Findings + +### P0 + +- **`pyproject.toml:7` — version still `0.6.2`, not `0.6.3`.** Publish workflow hard-fails tag mismatch (`.github/workflows/pypi-publish.yml:92-106`). Tip cannot ship as 0.6.3 without a version bump. +- **`CHANGELOG.md:3-16` — release notes live under `## Unreleased`, no `## 0.6.3 - YYYY-MM-DD`.** `docs/publishing.md:27-34` and `tests/test_release_metadata.py:28-29` require a dated section before publish; `DYRO_RELEASE_TAG=v0.6.3` would fail release metadata checks. + +### P1 + +- **Bridge/MCP excision — complete in shipping surface.** Verified: `pyproject.toml:35-36` exposes only `dyro`; no `[mcp]` extra; no `src/dyro/bridge/` package. Local wheel build reports `importlib.util.find_spec('dyro.bridge') is None`. CI wheel smoke asserts Skill assets present, `dyro-readonly` absent, and no `dyro-bridge`/`dyro-mcp` bins (`.github/workflows/ci.yml:87-100`). `pypi-publish.yml` adds the same plus `dyro.bridge` import guard (`121-135`). `tests/test_release_source.py:97-105` asserts `bridge-zero-effects` job removed. Archive ADR/design/evidence remain under `docs/` by design — not shipped in wheel. +- **Skill mirror+avatar + fail-closed — substantively correct.** `manager.py:46-51,113-118,149-153` defines mirror at `$DYRO_HOME/skills/dyro-control-plane` and avatars at `{host}/skills/dyro-control-plane` for codex/claude/agents/cursor. Recovery is fail-closed: unsafe state → `RECOVERY_REQUIRED` (`487-502`), dangling transaction blocks install (`251-252` in tests), rollback preserves recovery markers (`298-350`, `384-433` in `tests/test_integrations.py`). Legacy whole-directory Codex installs migrate on owned install (`207-236`). Packaging wired: `pyproject.toml:62-65`, `MANIFEST.in:9`. +- **Upgrade narrative for PyPI 0.6.2 users — technically honest but thin.** Verified `v0.6.2` tag: only `dyro` script, no integrations package (`git show v0.6.2:pyproject.toml`). PyPI 0.6.2 users never had `dyro-bridge`/`dyro-mcp`; CHANGELOG removal text targets git/WIP installs, not PyPI. **Gap:** neither `README.md` nor `docs/updates.md` mentions `dyro integration install skill` / mirror+avatar — post-upgrade discoverability is poor for the primary new capability. + +### P2 + +- **`home.py:1241-1254` — digit tokens always resolve as 1-based indices, never as numeric repo IDs.** Pure-digit repo IDs (e.g. `"2"`) cannot be selected by ID when they collide with a valid index; UI shows indices (`1206-1207`) so this is consistent but undocumented. Tests cover indices/mixed/dedup/range/unknown (`tests/test_hub.py:46-68`) but not numeric-ID ambiguity. +- **Cursor avatar path unverified in tests.** `manager.py:50` uses `~/.cursor/skills/dyro-control-plane`; Cursor docs confirm `~/.cursor/skills/` as global skill root (须人工核 on every host layout, but docs align). No integration test exercises cursor host detection (codex/claude only in `tests/test_integrations.py:146-158`). +- **Publish smoke slightly weaker than CI smoke.** `pypi-publish.yml:120` omits CI's `assert not root.joinpath('dyro-readonly').is_dir()` (`.github/workflows/ci.yml:87`); low risk given bridge module absent. +- **PR ships historical bridge design docs** (`docs/adr/0006-*`, `plans/dyro-agent-bridge-*`) — archive-only, not in wheel; may confuse readers skimming `docs/` without context. + +### Evidence (independent) + +| Check | Result | +|-------|--------| +| PR #19 CI (tip `a450938`) | All 8 jobs SUCCESS | +| Local unittest | `531 tests`, OK | +| Local wheel build/smoke | `dyro-0.6.2` wheel built; `dyro.bridge` absent; only `dyro` entry point | + +## Go/No-Go + +| Gate | Verdict | Rationale | +|------|---------|-----------| +| **Merge PR #19** | **Conditional Go** | Feature code, bridge excision, integration manager, repo-picker, and CI are merge-ready. No P0 code defects found. Accept P1 doc gap or fix before publish. | +| **Publish 0.6.3 to PyPI** | **No-Go** | Version + dated CHANGELOG are mandatory pre-publish blockers. After merge: bump to `0.6.3`, date changelog, push to `main`, wait for exact-SHA CI success, then tag `v0.6.3` and release. | + +## Required Fixes + +1. **Before publish (blocking):** Set `project.version = "0.6.3"` in `pyproject.toml`; retitle `CHANGELOG.md` Unreleased → `## 0.6.3 - 2026-08-12` (or release date). +2. **Before or with publish (strongly recommended):** Add one paragraph to `README.md` (and optionally `docs/updates.md`) telling 0.6.2→0.6.3 upgraders to run `dyro integration install skill --yes` (or `codex` alias); note PyPI ≤0.6.2 never shipped Bridge/MCP entry points. +3. **Optional hardening:** Test for numeric repo-ID vs index ambiguity; cursor-host integration test; align publish smoke with CI `dyro-readonly` negative assert. + +## Open Micro-Decisions (your vote) + +1. **Version bump timing:** **Bump on `feat/dev` before merge** — keeps PR #19 as the complete 0.6.3 release unit; tag SHA equals merged commit with matching metadata; avoids a second main-only version commit racing publish prep. +2. **Cursor `~/.cursor/skills/` for v1:** **Accept** — matches Cursor's documented global skill discovery path; omitting cursor host would leave a gap for a listed host in `HOSTS`. +3. **CHANGELOG warn about missing `dyro-bridge`/`dyro-mcp`:** **Yes, keep and sharpen** — current Unreleased text is adequate for WIP/git users; add one clause that **PyPI releases through 0.6.2 never exposed those entry points**, so standard `pip/pipx upgrade` users are unaffected; WIP/git installers should run `dyro integration install skill --yes` instead. + +--- + +# OpenCode Review Section + +Reviewer: OpenCode (Composer substitute) +Time: 2026-08-12 +Verdict: **NO-GO for 0.6.3 publish** — shipping logic (Bridge excision, Skill mirror model, CI/publish gates) is largely coherent and well-tested, but release metadata is not prepared and one parser ambiguity plus a legacy-migration rollback gap remain. Safe to merge feature work only after P0 release prep and P1 fixes below. + +--- + +## Required Fixes + +### P0 — Release blockers (must fix before tag/Release/PyPI) + +1. **Version not bumped (confidence: 100)** + - **Where:** `pyproject.toml:7` — `version = "0.6.2"` + - **Impact:** `pypi-publish.yml` tag check (L92–106) requires `vX.Y.Z == v{project.version}`. A `v0.6.3` Release will fail immediately. + - **Fix:** Set `project.version = "0.6.3"` before tagging. + +2. **Changelog not release-ready (confidence: 100)** + - **Where:** `CHANGELOG.md:3` — `## Unreleased`; no `## 0.6.3 - YYYY-MM-DD` + - **Impact:** `docs/publishing.md:27–34` requires a dated section before tag. With `DYRO_RELEASE_TAG` set (publish workflow L89), `tests/test_release_metadata.py` rejects `Unreleased` for the package version. + - **Fix:** Move Unreleased bullets under `## 0.6.3 - 2026-08-12` (or actual ship date). + +3. **Cannot ship 0.6.3 from current tip without above (confidence: 100)** + - Board target is **0.6.3**; tip is feature-complete for Bridge removal but metadata still describes **0.6.2**. Tag/Release/PyPI for 0.6.3 is blocked until P0 #1–2 land on the release commit (typically `main` post-merge). + +--- + +### P1 — Important (fix before or immediately after merge) + +4. **Numeric repository ID / index collision (confidence: 88)** + - **Where:** `src/dyro/home.py:1241–1252` — `_parse_repository_selection` + - **Bug:** All-digit tokens are always treated as 1-based indices (`token.isdigit()`), never as repository IDs. `validate_id` / `SAFE_ID` in `config.py:22–23` allows purely numeric IDs (e.g. `"2"`). + - **Example:** `repositories = ("2", "api", "web")` — user input `"2"` selects `"api"` (index 2), not repo `"2"`. + - **Fix:** Prefer ID match when `token in repositories`, then fall back to index; or require index prefix (e.g. `#2`); add regression test with numeric repo ID. + +5. **Legacy migration rollback can destroy owned install (confidence: 85)** + - **Where:** `src/dyro/integrations/manager.py:929–936`, `1092–1114` + - **Bug:** On legacy whole-directory → mirror+avatar migration, `_install_avatars` `_remove_tree`s the legacy copy before manifest commit. If manifest/transaction fails afterward, rollback removes the new mirror/symlink but **does not restore** the legacy directory. User can end in `ABSENT` after losing a working owned install. + - **Fix:** Stage legacy copy into backup before removal, or defer legacy removal until after committed manifest (mirror rollback already handles backup for upgrades). + +6. **CI vs publish smoke drift after Bridge removal (confidence: 82)** + - **Where:** `.github/workflows/ci.yml:84–100` vs `.github/workflows/pypi-publish.yml:117–135` + - **Gap:** + - CI asserts `dyro-readonly` absent; publish does not. + - Publish asserts `importlib.util.find_spec('dyro.bridge') is None`; CI does not. + - CI imports `dyro.continuation`; publish imports `experiments.local_agent_dispatch`. + - **Impact:** Regressions can pass one gate and fail the other; `docs/publishing.md:49–50` implies a single consistent smoke story. + - **Fix:** Extract one shared smoke script/assert block used by both workflows (Bridge absence + Skill assets + core imports). + +--- + +### P2 — Should fix (non-blocking for merge if accepted) + +7. **Wheel smoke proves only point-checked Skill files, not full asset contract (confidence: 82)** + - **Where:** `ci.yml:87`, `pypi-publish.yml:120`; `pyproject.toml:62–65` + - **Assessment:** For the **current** two-file Skill (`SKILL.md`, `agents/openai.yaml`), smoke **does** prove those assets ship in wheel/sdist and match `package-data`. It does **not** call `manager._asset_inventory()` or verify digest/manifest parity. A third asset added on disk but omitted from `package-data`/smoke would slip through. + - **Fix:** Smoke step: `from dyro.integrations.manager import _asset_inventory` + `importlib.resources` inventory equality (or reuse install-time validation). + +8. **Docs overstate dedicated changelog workflow step (confidence: 80)** + - **Where:** `docs/publishing.md:34` — “发布工作流会验证这一状态” + - **Reality:** Enforcement is via `DYRO_RELEASE_TAG` during `unittest` (`test_release_metadata.py`), not a standalone workflow step. Behavior is correct; wording could mislead operators auditing the YAML alone. + +9. **CHANGELOG upgrade note for Bridge/MCP removal (confidence: 80)** + - **Where:** `CHANGELOG.md` Unreleased section + - **Gap:** Board open question #3 — no explicit “upgraders lose `dyro-bridge` / `dyro-mcp` entry points” callout. Workflows assert absence; user-facing changelog should state it for anyone on interim builds. + +--- + +## Focus-area summaries + +| Area | Finding | +|------|---------| +| **pypi-publish / ci / publishing.md (Bridge removal)** | Aligned on Trusted Publishing, exact-SHA CI gate, no Bridge gate, Skill + no `dyro-bridge`/`dyro-mcp` entry points. Minor smoke assertion drift (P1 #6). Docs match intent; changelog/version prep missing (P0). | +| **Integrations manager transaction/rollback** | Extensive tests; committed-phase recovery markers behave correctly. **Legacy migration failure path loses data** (P1 #5). No other ≥80-confidence rollback bug found. | +| **Wheel smoke vs Skill assets** | **Adequate for current 2-file Skill**; not a full packaging contract (P2 #7). | +| **`_parse_repository_selection` numeric collision** | **Real bug** for valid numeric repo IDs (P1 #4). | +| **0.6.3 release blockers** | **P0 #1–3** — version, dated changelog, then tag `v0.6.3` on trusted `main` with green push CI. | + +--- + +## Merge vs publish + +- **Merge PR #19:** Acceptable after P1 #4–#5 if product accepts legacy-migration risk short-term; strongly prefer #5 before wide `integration install skill` use. +- **Publish 0.6.3:** Blocked until P0 cleared on the release commit. + +--- + +# Hermes Review Section + +Reviewer: Hermes (Security; substitute model) +Time: 2026-08-12 +Verdict: **No-Go** + +## Hunt results (evidence-backed) + +### P0 — Legacy `target` is unbounded; uninstall deletes arbitrary trees +**Category:** A01 Broken Access Control / A04 Insecure Design +**Location:** `src/dyro/integrations/manager.py` — `_legacy_owned_copy` (~446–465), `uninstall_integration` (~1213–1229) +**Exploitability:** Local; requires write to `DYRO_HOME/integrations/codex.json` + `uninstall --yes` +**Blast radius:** Recursive delete of any directory whose inventory matches the forged/legacy manifest (not limited to host skill avatars) + +**Issue:** Ownership validation checks digest inventory only. It does **not** require `manifest["target"]` to equal a detected host avatar path (`/skills/dyro-control-plane`). Uninstall then `os.replace(legacy[1], backup)` + `_remove_tree(backup)`. + +**Live proof (this session):** Forged `codex.json` with `target=/victim_dir` matching asset inventory → status `OUTDATED` → `uninstall_integration(..., yes=True)` → `victim_exists=False`. + +**Required fix:** Bind legacy targets before any mutate/delete: + +```python +def _allowed_legacy_targets( + detected: list[tuple[HostSpec, Path]], +) -> set[Path]: + return {_avatar_path(home) for _spec, home in detected} + +def _legacy_owned_copy(...): + ... + target = Path(str(manifest["target"])) + # require caller-supplied allowlist, or resolve detected hosts here + if allowed_targets is not None and target not in allowed_targets: + return None + ... +``` + +In `uninstall_integration` / `install_integration`, pass allowlist from `_detected_hosts`; if legacy target ∉ allowlist → `RECOVERY_REQUIRED` / refuse delete (fail closed). + +--- + +### P0 — Forged legacy ownership overwrites foreign skills at avatar path +**Category:** A01 / A04 +**Location:** `_install_avatars` (~929–936), `_legacy_owned_copy` +**Exploitability:** Local; write forged legacy manifest whose `files` digests match the foreign tree at the avatar path, then `install --yes` +**Blast radius:** Foreign skill directory is `_remove_tree`’d and replaced with Dyro symlink/junction + +**Issue:** Without a legacy manifest, foreign dirs correctly become `UNOWNED_CONFLICT` and are refused (`test_unowned_conflict_is_never_overwritten_or_removed`). With a digest-matching forged legacy claim, status flips to `OUTDATED` and install treats the tree as owned migration fodder. + +**Live proof:** Foreign `SKILL.md` content + matching forged legacy → `install` → avatar becomes symlink to Dyro mirror; foreign content gone. + +**Required fix:** Same allowlist bound as above, **plus** refuse `_remove_tree` unless target is an allowlisted avatar **and** legacy integration was `codex` **and** (recommended) content matches **current packaged assets** (or an explicit migration allow-digest), not arbitrary foreign inventories: + +```python +if legacy_target is not None and avatar == legacy_target: + if avatar not in allowed_targets: + raise DyroError(f"拒绝迁移越界 legacy target:{avatar}") + if _inventory(avatar) != _asset_inventory(): + raise DyroError(f"拒绝删除非 Dyro 资产目录:{avatar}") + _remove_tree(avatar) +``` + +--- + +### Pass — Symlink/junction avatar overwrite of *unowned* foreign skills (no legacy) +**Evidence:** `_install_avatars` skips auto-detected foreign paths; explicit hosts raise `拒绝覆盖非 Dyro 分身路径`. Tests: `test_unowned_conflict_is_never_overwritten_or_removed`, `test_symlink_avatar_to_foreign_path_is_conflict`, nested `CODEX_HOME` symlink rejection. + +--- + +### Pass (with note) — Path escape via `CODEX_HOME` / `HOME` +**Evidence:** +- Absolute + `normpath` via `_absolute_path`; `..` collapsed. +- Symlink components under explicit host homes blocked (`_symlink_component` / tests `test_nested_symlink_in_codex_home_path_is_rejected`). +- `HOME` merely redirects auto-detect to `$HOME/.codex` (expected env semantics; same-process env trust). + +**P2 (defense-in-depth):** Env-supplied `CODEX_HOME`/`*_HOME` are returned from `_host_home` without an immediate symlink walk; safety is deferred to later avatar checks. Prefer reject-at-resolution for explicit env homes. + +--- + +### Pass — Fail-closed recovery markers +**Evidence:** Any `skill.transaction.json` presence → `RECOVERY_REQUIRED` and mutate refused. Committed-path failures re-preserve marker (`_complete_transaction` / `_preserve_recovery_marker`). Covered by multiple tests (`test_stale_manifest_and_recovery_marker_fail_closed`, committed unlink/fsync/uninstall cleanup cases). + +**P2:** `_preserve_recovery_marker` swallows all exceptions (`except Exception: pass`). If unlink succeeded and recreate fails, marker can be lost (fail-open edge). Prefer best-effort recreate + re-raise / hard error if marker cannot be ensured after committed mutation. + +--- + +### Non-blocking P1 UX — Digit index repo selection +**Location:** `src/dyro/home.py` `_parse_repository_selection` (~1224–1257) +**Evidence:** Indices bounded to `1..len(repositories)`; unknown IDs rejected; only configured repo IDs selectable. Out-of-range covered by tests. Wrong-repo mutation only via user mis-pick among configured repos, with later create confirmation. **Not a security escape / not a release blocker.** + +--- + +## Secrets / dependencies +- Secrets scan on `manager.py` / related integration surface: **no hardcoded secrets**. +- Dependency audit (`pip-audit`): **须人工核** (environment externally managed; audit tool not runnable in this seat). + +## Required Fixes (merge gate) +1. **Bound** legacy `target` to detected host avatar path(s) before install migrate or uninstall delete. +2. **Refuse** `_remove_tree` / `os.replace` on legacy trees outside that allowlist (fail closed → `recovery_required` or hard `DyroError`). +3. Add regression tests for: (a) unbound legacy target uninstall must **not** delete; (b) forged legacy over foreign avatar content must **not** install-migrate/delete. +4. (P2) Harden recovery-marker preserve to not silently succeed after committed mutation if marker write fails. + +Until (1)–(3) land, Hermes votes **No-Go** for 0.6.3 tip merge/publish on the Skill mirror/avatar path. + +--- + +# Agy Review Section + +Reviewer: Agy +Time: 2026-08-12 +Verdict: **Conditional No-Go** — merge-worthy product surface, **not publish-ready as 0.6.3** until release metadata and upgrade narrative are closed. Core Skill mirror+avatar path, hotfix numbering, and publishing workflow alignment are sound; blockers are process + user-facing release honesty/discoverability. + +--- + +## Required Fixes + +### P0 + +1. **`pyproject.toml` still `0.6.2`; `CHANGELOG.md` still `## Unreleased`** (confidence: 100) + - Files: `pyproject.toml:7`, `CHANGELOG.md:3-16` + - PyPI cannot ship 0.6.3; `pypi-publish.yml` tag check requires `v{project.version}`; `tests/test_release_metadata.py` rejects `Unreleased` when `DYRO_RELEASE_TAG` is set. + - **Fix:** Bump to `0.6.3`, rename `Unreleased` → `## 0.6.3 - 2026-08-12` (or release date), commit before tag. + +2. **CHANGELOG leads with “Remove Bridge/MCP” for an audience that never had them on PyPI 0.6.2** (confidence: 85) + - File: `CHANGELOG.md:5-7` + - `origin/main` @ 0.6.2 has no `dyro-bridge`, `dyro-mcp`, or `[mcp]` extra. Primary upgrade path is PyPI 0.6.2 → 0.6.3; “Remove” reads as a regression users should notice, not as “Bridge never shipped publicly; shipping surface is CLI + Skill only.” + - **Fix:** Reframe first bullet for PyPI upgraders (e.g. “Bridge/MCP remain out of the shipping surface; never published to PyPI 0.6.2”) and add an explicit note that `dyro-bridge` / `dyro-mcp` entry points are absent from the wheel (board open item #3). + +### P1 + +3. **Skill install is not discoverable post-upgrade** (confidence: 90) + - Sources: `README.md`, `README.zh-CN.md`, `docs/updates.md` — no mention of `dyro integration install skill`; only `CHANGELOG.md:12` and `dyro integration install --help`. + - After `pipx upgrade dyro` / `dyro update now`, users get a new command with zero onboarding. `codex` alias is documented in CLI help but not in README/changelog upgrade steps. + - **Fix:** Add one-line post-upgrade callout in README(s) and/or `docs/updates.md`: `dyro integration install skill` (alias `codex`), preview-first with `--dry-run` / `--yes`. + +4. **Install preview can look viable when no agent home is detected** (confidence: 82) + - File: `src/dyro/integrations/manager.py:948-949`, `plan_integration` ABSENT branch + - If `~/.codex` / env homes don’t exist, dry-run shows mirror+manifest only; `--yes` fails with “没有可挂接的宿主分身”. Fail-closed is correct, but the preview omits the blocker. + - **Fix:** When `_detected_hosts()` is empty, surface in plan/status: “未检测到宿主目录;需先创建或设置 CODEX_HOME / CLAUDE_HOME / …”. + +5. **Multi-host avatar paths — real-host validation still open** (confidence: 82, **须人工核**) + - File: `src/dyro/integrations/manager.py:46-51`, `149-153` + - All hosts use `{home}/skills/dyro-control-plane`. Unit tests cover Codex+Claude via overrides only. Open risks: Cursor `~/.cursor/skills/` (board #2); Claude uses `CLAUDE_HOME`/`~/.claude` while dispatch uses `CLAUDE_CONFIG_DIR` (`supervisor.py:61`) — avatar may miss real Claude installs. + - **Fix:** Before claiming multi-host support in release notes, run manual install on Codex/Claude/Cursor/Agents hosts; omit Cursor from marketing until confirmed. + +### P2 + +6. **`docs/publishing.md` slightly ahead/behind `pypi-publish.yml`** (confidence: 88) + - Aligned: Skill asset smoke, no `dyro-bridge`/`dyro-mcp`, exact-SHA CI gate, locked `uv` env (`publishing.md:47-50` ↔ `pypi-publish.yml:48-80,120-135`). + - Gaps: local prep lists `ruff check` (`publishing.md:42`) but publish workflow does not; doc omits `DYRO_RELEASE_TAG` changelog gate enforced in publish tests (`pypi-publish.yml:89-90`, `tests/test_release_metadata.py`). + - **Fix:** Note ruff runs via PR CI, not publish job; document `DYRO_RELEASE_TAG` changelog requirement. + +7. **Hotfix custom repo picker numbering — no issues found** (confidence: 95, informational) + - Files: `src/dyro/home.py:1205-1257`, `tests/test_hub.py:45-68` + - Numbered list, comma/CJK-comma tokens, mixed index+ID, range errors, dedup — covered; shared by line and hotfix flows. Ship as-is. + +--- + +## What passes (no fix required) + +- **No Phase 0 GA claim** in `CHANGELOG.md` Unreleased; archive evidence remains No-Go. +- **`codex` alias** correctly aliases `skill` (`manager.py:103-106`, `cli.py:3030-3033`); tests assert both dry-run strings. +- **Bridge gate removal** reflected consistently in `ci.yml`, `pypi-publish.yml`, and `publishing.md` smoke assertions. +- **0.6.2 → 0.6.3 core upgrade path** (`pipx upgrade` / `dyro update now`) unchanged and non-breaking; Skill install is additive/opt-in. + +--- + +# Grok Review Section + +Reviewer: Grok +Time: 2026-08-12 +Tip: `a450938` vs `origin/main` · PR #19 +Verdict: **No-Go** + +## Must-verify scorecard + +| # | Claim | Result | +|---|--------|--------| +| 1 | No `dyro-bridge`/`dyro-mcp`; no `dyro.bridge` | **PASS** — `pyproject.toml` scripts=`dyro` only; optional=`dev` only; packages exclude bridge; no `src/dyro/bridge/` | +| 2 | Mirror under `DYRO_HOME/skills`; avatars → mirror | **PASS** — `src/dyro/integrations/manager.py` `_mirror_path` → `{DYRO_HOME}/skills/{SKILL_NAME}`; `_create_avatar_link` + `_resolves_to` | +| 3 | Install/uninstall recovery markers fail-closed | **PASS** — marker ⇒ `RECOVERY_REQUIRED` + install blocked; covered in `tests/test_integrations.py` | +| 4 | Digit tokens always indices (numeric repo ID collision?) | **FAIL** — real silent mis-select | +| 5 | Version/changelog block publish until bumped | **PASS as gate / FAIL as 0.6.3 readiness** — still `0.6.2` + `## Unreleased`; no `## 0.6.3` | +| 6 | `pypi-publish` no longer requires Bridge artifact | **PASS** — Bridge evidence gate removed (`c329ad1`); `tests/test_release_source.py` asserts absence; smoke asserts no entry points | + +## Findings + +### P0 — No-Go — Version/changelog not releaseable as 0.6.3 +- `pyproject.toml:7` → `version = "0.6.2"` +- `CHANGELOG.md:3` → `## Unreleased` (bridge removal + skill mirror notes); no `## 0.6.3 - YYYY-MM-DD` +- Gates that correctly block a fake 0.6.3 ship: tag↔version (`pypi-publish.yml` “Check release version”); dated changelog when `DYRO_RELEASE_TAG` set (`tests/test_release_metadata.py`) + +**Required fix:** Bump to `0.6.3`, retitle Unreleased → `## 0.6.3 - `, then tag `v0.6.3` only after that lands on trusted `main`. + +### P1 — No-Go — Numeric repo ID / index collision on mutation path +- `src/dyro/home.py` `_parse_repository_selection` (~1241): `token.isdigit()` always treated as 1-based index; never falls through to ID match +- `src/dyro/config.py` `SAFE_ID` allows pure-numeric IDs (`^[a-zA-Z0-9]…`) +- Demo: repos `("api","1","svc")`, input `"1"` → selects `api`, not id `1` +- Tests (`tests/test_hub.py`) never cover numeric IDs; tip commit `a450938` ships this UX onto line/hotfix repo selection + +**Required fix (pick one, then test):** exact ID match before index; or disallow pure-numeric repo IDs; or index-only syntax (e.g. `#1`). Add regression for `("api","1","svc")` + token `"1"`. + +### P2 — Go — Packaging/publish anti-Bridge assertions are in place +- Wheel/sdist smoke: no `dyro.bridge`, no `dyro-bridge`/`dyro-mcp` bins (`.github/workflows/pypi-publish.yml`, `ci.yml`) +- Not a blocker; do not treat historical Bridge evidence docs under `docs/superpowers/evidence/` as release gates + +## Required fixes before Go +1. Version + dated `CHANGELOG` for **0.6.3** +2. Resolve digit-token vs numeric-repo-ID ambiguity in `_parse_repository_selection` + tests + +Until both land: **No-Go** for 0.6.3 PyPI. + +--- + +# Final Arbitration + +Arbiter: Cursor board chair (source-verified) +Time: 2026-08-12 + +## 1. Final Verdict + +- May merge PR #19 as-is: **No** +- May publish tip as PyPI **0.6.3**: **No** +- Direction (Bridge excised; CLI + Skill mirror/avatar): **sound** +- Required preconditions: close P0-F1..P0-F3 below; then P1 before tag +- Blocking reasons: (1) legacy `target` unbounded delete/migrate; (2) version still 0.6.2 + Unreleased changelog; (3) digit/index repo picker silent mis-select on publish path for new UX + +## 2. Repo / Module Go-No-Go + +| Repo/Module | Spec | Plan | Verdict | Reason | +| --- | --- | --- | --- | --- | +| Packaging / Bridge excision | OK | OK | **Go** (feature) | Source + CI/publish smoke assert no bridge entry points | +| Skill mirror + avatar manager | OK | Flawed edge | **No-Go merge** | Hermes P0 reproduced by arbiter | +| Hotfix repo picker | OK | Flawed edge | **Conditional** | Numeric ID collision is real P1 | +| Release metadata 0.6.3 | Missing | Missing | **No-Go publish** | Still 0.6.2 / Unreleased | +| Overall PR #19 → PyPI 0.6.3 | — | — | **No-Go** | Merge blocked by Skill P0; publish blocked by metadata + P1s | + +## 3. P0 Required Fixes + +### P0-F1: Bound legacy `target` before mutate/delete + +Evidence: +- `src/dyro/integrations/manager.py` `_legacy_owned_copy` (~446–465) accepts any absolute dir whose inventory matches manifest `files` +- `uninstall_integration` (~1213–1229) `os.replace` + `_remove_tree` on that target +- Arbiter live repro: forged valid legacy `codex.json` → `OUTDATED` → `uninstall --yes` → victim dir gone +- Second case: forged legacy over foreign avatar inventory → `install --yes` replaces foreign tree with Dyro symlink + +Decision: +- Allowlist legacy targets to detected host avatar paths only +- Refuse migrate/delete when target ∉ allowlist (fail closed) +- Refuse migrate-delete when inventory ≠ current packaged Dyro assets (or explicit migration allow-digest) +- Add regression tests for unbound uninstall and forged-foreign install + +Acceptance: +- Repro scripts above must fail closed; new unit tests green; existing ownership tests still pass + +### P0-F2: Version + dated CHANGELOG for 0.6.3 + +Evidence: +- `pyproject.toml:7` = `0.6.2` +- `CHANGELOG.md:3` = `## Unreleased` +- Publish gates: tag↔version; `DYRO_RELEASE_TAG` rejects Unreleased + +Decision: +- Bump to `0.6.3`; retitle to `## 0.6.3 - ` +- Prefer landing this on `feat/dev` before merge so `main` tip is already publishable + +Acceptance: +- `project.version == 0.6.3` and dated changelog section present on the commit that will be tagged + +### P0-F3: (Publish honesty, elevated from Agy) Reframe Bridge/MCP changelog for PyPI audience + +Evidence: +- PyPI 0.6.2 never shipped `dyro-bridge` / `dyro-mcp` +- Leading with “Remove” misleads upgraders + +Decision: +- Reframe as “shipping surface remains CLI + Skill; Bridge/MCP not published” +- Explicit note for interim-build upgraders that entry points are absent (closes open micro-decision #3: **yes**) + +Acceptance: +- CHANGELOG 0.6.3 section readable for both PyPI-only and interim-build readers + +## 4. P1 / P2 + +### P1 (must fix before tag; strongly prefer before merge) + +1. **Numeric repo ID vs index** (`home.py` `_parse_repository_selection`) — confirmed; Agy “no issues” downgraded (missed `SAFE_ID` numeric IDs). Prefer ID-match-before-index + regression `("api","1","svc")` + `"1"`. +2. **Legacy migration rollback data loss** (OpenCode) — remove/stage legacy only after commit, or restore on rollback. +3. **Empty-host dry-run honesty** (Agy) — preview must surface “no host detected”. +4. **Skill discoverability** (Agy) — one-line README / `docs/updates.md` callout for `dyro integration install skill`. + +### P2 + +- Unify CI vs publish smoke asserts (OpenCode) +- Full `_asset_inventory` smoke parity (OpenCode) +- `publishing.md` wording on changelog gate / ruff (Agy/OpenCode) +- Recovery-marker preserve hardening (Hermes) +- Multi-host real install: **须人工核** (Cursor path; Claude `CLAUDE_HOME` vs `CLAUDE_CONFIG_DIR`) + +### Rejected / downgraded + +- Hermes “digit index is non-blocking security”: accepted as **not security P0**; still **product P1** for publish (Grok/OpenCode/arbiter). +- Treating historical Bridge evidence docs as release gates: **rejected** (Grok). + +## 5. Open Micro-Decisions (arbiter) + +1. **Version bump timing:** Bump on `feat/dev` **before** merge (with P0-F1..F3), so merged `main` is already 0.6.3-ready. +2. **Cursor `~/.cursor/skills/`:** Keep implementation; **omit from marketing** until manual confirm (**须人工核**). +3. **CHANGELOG interim-build warning:** **Yes** — include; also reframe for PyPI 0.6.2 audience (P0-F3). + +## 6. Instructions For The Execution Agent + +Do **not** merge, tag, or publish until the user explicitly authorizes. + +On `feat/dev` tip `a450938` (+ fixes): + +1. Fix P0-F1 in `manager.py` + tests (allowlist + packaged-asset check). +2. Fix P1 numeric ID selection + regression test. +3. Fix P1 legacy-migration rollback (or defer removal until committed). +4. Bump `pyproject.toml` → `0.6.3`; rewrite CHANGELOG `## 0.6.3 - ` with honest Bridge framing + Skill install callout. +5. Optional but preferred: empty-host preview message; README/`docs/updates.md` one-liner. +6. Run `tests/test_integrations.py`, `tests/test_hub.py`, release metadata tests. +7. Leave user WIP `plans/dyro-agent-bridge-phase-0.md` and untracked handoff untouched. +8. Stop and ask user before merge/tag/publish. + +## 7. Conditions To Start Implementation + +- User says to proceed with the P0/P1 fix pass (not yet “merge/publish”). + +### Late Claude seat note + +Claude (Composer substitute) returned **Conditional Go for merge / No-Go for publish**, and treated numeric-ID collision as P2. Arbiter **does not adopt** Claude’s merge Conditional Go: Hermes P0 (unbound legacy `target` delete/migrate) was **independently reproduced** after Claude’s review and remains **merge-blocking P0-F1**. Numeric-ID collision stays **P1** (Grok/OpenCode), not Claude’s P2. + +## 8. Requires Human Verification + +- Manual `dyro integration install skill` on real Codex / Claude / Cursor / Agents hosts +- Confirm Cursor skills directory layout +- Confirm Claude skill home (`CLAUDE_HOME` vs `CLAUDE_CONFIG_DIR`) +- `pip-audit` / dependency review if required by release policy + +Final signature: Cursor board chair — **No-Go** for merge-as-is and **No-Go** for PyPI 0.6.3 until P0-F1..F3 closed diff --git a/docs/updates.md b/docs/updates.md index 8486e68..007136a 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -51,6 +51,16 @@ instructions returned by the network. The requirement is pinned to the version that was checked and Dyro verifies the installed distribution version after the command succeeds. +After upgrading, optionally attach the control-plane Skill to agent homes: + +```bash +dyro integration install skill --dry-run +dyro integration install skill --yes +``` + +(`codex` is an alias for `skill`.) Preview first; install only writes with +`--yes`. + Editable source installations are deliberately rejected. Update those through their Git checkout so a convenience command cannot replace a development environment with a published wheel. diff --git a/plans/dyro-agent-bridge-phase-0.md b/plans/dyro-agent-bridge-phase-0.md new file mode 100644 index 0000000..cbe69f9 --- /dev/null +++ b/plans/dyro-agent-bridge-phase-0.md @@ -0,0 +1,496 @@ +# Dyro Agent Bridge Phase 0 Construction Blueprint + +Status: Proposed + +Objective: deliver a source-audited, installable, real-host-verified inbound +inspect-and-plan interface for coding agents without exposing Dyro mutation. + +Authority: + +- [ADR 0006](../docs/adr/0006-agent-bridge-phase-0.md) +- [Operation inventory](../docs/designs/agent-bridge-operation-inventory.md) +- [Protocol](../docs/designs/agent-bridge-protocol.md) +- [Acceptance matrix](../docs/designs/agent-bridge-phase-0-acceptance.md) +- [Adversarial review board](../docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md) +- [Phase 0 design closure review](../docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md) + +This plan does not authorize commit, push, PR, merge, tag, release, publish, or +integration installation. Those remain separate user decisions. + +## 1. Fixed invariants + +Every implementation step preserves these invariants: + +1. Dyro Core remains the only delivery-policy and mutation authority. +2. Phase 0 contains no Agent apply operation or generic command execution. +3. Bridge code does not call CLI `cmd_*` handlers or parse human CLI output. +4. R0 is zero-write, zero-network, and zero unexpected subprocess. +5. PLAN output is deterministic, non-executable, and unauthorised. +6. Local Profile discovery fails closed and cannot fall back to a different + workspace when a local Profile exists but is invalid. +7. A host is unsupported until its real discovery and sandbox evidence passes. +8. Existing unrelated worktree changes are preserved and never broadly staged. + +## 2. Dependency graph + +```text +S1 Core contracts and Exposure Catalog + ├─> S2 Typed workspace resolution and observations + └─> S3 Typed deterministic plan services + S2 + S3 ─> S4 One-shot JSON transport + S4 ─> S5 Zero-effect and artifact gates + S5 ─> S6 Host-neutral Skill beta + S5 + S6 ─> S7 Codex typed read-only MCP/Plugin +``` + +S2 and S3 may proceed in parallel after S1 because they own separate Core +modules and tests. S1 must first freeze `WorkspaceIdentityV1` and +`ConfigRevisionV1`; neither parallel step may invent its own identity. All +other steps are serial gates. + +## 3. Step S1 — Core contracts and Exposure Catalog + +Implementation status: Complete on 2026-08-06 for the source-tree unit gates. +At that milestone all operations remained deny-by-default; S5 has since +promoted only the seven Linux Mandatory Core Surface records after adding the +installed-artifact gate. + +### Context brief + +The catalog is exposure metadata, not a second policy engine. The protocol +needs immutable request/response metadata, stable risk types, schema versions, +and an allowlist before any transport exists. + +### Ownership + +- `src/dyro/bridge/__init__.py` +- `src/dyro/bridge/models.py` +- `src/dyro/bridge/catalog.py` +- `src/dyro/bridge/schemas.py` +- `pyproject.toml` package declaration and core JSON Schema validator dependency +- `uv.lock` +- `tests/fixtures/bridge/contracts-v1.json` +- `tests/test_bridge_models.py` +- `tests/test_bridge_catalog.py` + +### Tasks + +1. Define frozen protocol, operation, risk, availability, error, warning, and + response models without importing CLI. +2. Implement a deny-by-default catalog containing only Phase 0 declared IDs; + keep them unavailable until their service proof is registered. +3. Generate compact capabilities and per-operation schema separately. +4. Canonicalize and hash the compact catalog. +5. Define and vector-test `WorkspaceIdentityV1` and `ConfigRevisionV1`, including + move/rename semantics, domain separators, canonical path handling, Profile + file bounds, and the fact that neither value authenticates a caller. +6. Implement availability states `declared`, `implemented_testable`, and + `public_available`, plus the non-empty Mandatory Core Surface assertion. +7. Add import guards proving bridge Core modules do not import `dyro.cli`. + +### Verification + +```bash +uv run python -m unittest tests.test_bridge_models tests.test_bridge_catalog -v +uv run ruff check src/dyro/bridge tests/test_bridge_models.py tests/test_bridge_catalog.py +``` + +### Exit criteria + +- The A01 catalog/schema **unit portion**, A02 schema portion, A03, and D03 unit + gates pass. Formal A01 public-availability and artifact assertions are made + only at S5 after the transport and mandatory services exist. +- Catalog contains no apply, run, gate execution, sign-off, merge, push, + release, publish, or cleanup operation. +- The catalog's release-mode validator fails on a fixture that omits a mandatory + operation or declares an empty public surface. Development catalogs may still + contain only `declared`/`implemented_testable` operations before S5. + +### Rollback + +Remove the new isolated bridge package and tests. No existing CLI/Core behavior +should require rollback. + +## 4. Step S2 — Typed workspace resolution and observations + +Implementation status (2026-08-06): source-tree Core work is complete and the +four S2 services are `implemented_testable`. They remain unavailable to public +Bridge callers until the S4 transport and S5 zero-effect/artifact gates pass. + +### Context brief + +Reuse `continuation.resolution` precedence and `observations.py` composition, +but do not expose exceptions, paths, internal dataclasses, or recovery-enabled +Objective reads. R0 must survive read-only state roots without creating paths. + +### Ownership + +- `src/dyro/read_limits.py` +- focused additions to `src/dyro/config.py` +- focused additions to `src/dyro/hub.py` +- focused additions to `src/dyro/tasks.py` +- focused additions to `src/dyro/workspace.py` +- focused additions to `src/dyro/continuation/objective_storage.py` +- focused additions to `src/dyro/continuation/store.py` +- `src/dyro/bridge/observations.py` +- focused additions to `src/dyro/continuation/resolution.py` +- `tests/test_bridge_resolution.py` +- `tests/test_bridge_observations.py` + +### Tasks + +1. Add transport-neutral `ObservationLimits` / `ReadBudget` primitives that + open safe regular files, enforce per-file and aggregate byte budgets, and + stop at an injected monotonic deadline without creating state. +2. Add `load_profile_exact(root, budget) -> LoadedProfile`; registry roots must + contain their own bounded `dyro.toml` and must never search a parent Profile. + The same bounded bytes feed parsing and `ConfigRevisionV1`. +3. Add `resolve_workspace_readonly(...) -> ResolvedWorkspace` with typed source + and typed failure reason; it is non-interactive, never expands `~`, never + updates recent state and never parses human-facing exception text. +4. Return typed resolution source and stable error codes while retaining current + explicit/local/default/unique precedence. +5. Add DTO allowlists for workspace, line, task, graph, Objective and gate + definition observations. +6. Make every Objective observation use `recover=False` explicitly. +7. Separate Git observation behind a documented optional-lock-disabled adapter; + do not add it to an operation until B05 passes. +8. Add stat-before-read file limits, per-class record caps, aggregate-byte + budget, deadline and per-record fault isolation. One malformed Task or + Objective cannot erase healthy siblings. +9. Distinguish `integration_inspection=complete|not_inspected|partial`. Summary + DTOs omit final readiness; authoritative explain/status remains unavailable + until the Git adapter passes B05. +10. Inject clock and limits so tests prove deterministic bounded results. + +### Verification + +```bash +uv run python -m unittest tests.test_bridge_resolution tests.test_bridge_observations tests.test_console_read_model tests.test_continuation_resolution -v +uv run ruff check src/dyro/bridge/observations.py src/dyro/observations.py src/dyro/continuation/resolution.py tests/test_bridge_resolution.py tests/test_bridge_observations.py +``` + +### Exit criteria + +- C01, C02 unit portion, and the no-recovery unit gate pass. +- Malformed local Profile never produces a registry workspace result. +- `task.gate_definitions.get` cannot reach `run_gates` or subprocess APIs. +- Oversized or excessive workspace input becomes a bounded per-record/partial + result, never a request-wide silent empty list. + +### Rollback + +Remove Bridge DTO adapters. Keep only independently useful Core fixes that have +their own tests and do not alter human CLI semantics. + +## 5. Step S3 — Typed deterministic plans + +Implementation status (2026-08-07): the platform-gated source-tree Core and all +five Objective PLAN services are `implemented_testable`. Authoritative Git facts +are enabled only through a Linux `/proc/self/fd` boundary that binds the +worktree, Git directory, common directory, and object store. Other hosts fail +closed. The services remain unavailable to public Bridge callers until S4 and +the Linux S5 zero-effect/artifact/real-host gates pass. + +### Context brief + +Existing Objective plan/tick/attention paths are mostly pure, but Bridge plans +need explicit schema and planner revisions, typed operation-specific read sets, +and language that cannot be mistaken for authorization. + +### Ownership + +- `src/dyro/bridge/plans.py` +- `src/dyro/bridge/git_read.py` +- `src/dyro/bridge/constants.py` +- focused Core plan payload additions under `src/dyro/continuation/` +- focused bounded Task/read-budget additions under `src/dyro/` +- `tests/test_bridge_plans.py` +- canonical vectors under `tests/fixtures/bridge/` + +### Tasks + +1. Define typed read sets separately for Objective plan, explain, graph, tick, + and attention operations. +2. Add `executable=false`, `authorization=none`, schema version and planner + revision to every Bridge plan. +3. Define an operation-specific typed `projection` that preserves selected, + blocked, graph, attention, and tick-wave results separately from effects. +4. Allowlist, bound, and deterministically redact the final plan payload, then + compute RFC 8785 `plan_sha256` over that transport-safe payload excluding + only the digest itself. +5. Prove identical facts/input/clock produce identical plans; any safety fact, + redacted visible value, or planner revision change produces a new digest. +6. Do not implement a plan consumer or confirmation/apply model. + +### Verification + +```bash +uv run python -m unittest tests.test_bridge_plans tests.test_continuation_supervision tests.test_continuation_attention -v +uv run ruff check src/dyro/bridge/plans.py tests/test_bridge_plans.py +``` + +### Exit criteria + +- D05 passes for every plan operation. +- The real Git fixture uses only the fixed `rev-parse --verify HEAD^{commit}` and + `merge-base --is-ancestor ` adapter, + binds both OIDs as domain-separated digests in the typed read set, + disables optional locks and leaves watched repository metadata unchanged. +- The descriptor-bound Linux launcher uses an exact isolated binder argv, + retains only the four reviewed directory descriptors plus a close-on-exec + error channel, applies a Landlock read-only filesystem ruleset, rejects local + config includes and extensions, and overrides hooks, credentials and + commit-graph use before executing only the documented Git argv through + `/proc/self/fd`. Repository config remains an inspected local input rather + than being falsely described as ignored. Host integrations invoke the + launcher only through the one-shot transport. Platforms without the + descriptor namespace and Landlock ABI 3 support return + `OPERATION_UNAVAILABLE` for authoritative Git-dependent plans. Phase 0 accepts + only SHA-1 object-format repositories; extended repository formats fail + closed before Git starts. +- Caller `PATH`, lazy fetch, replace objects, config includes, external Git + metadata/object alternates and requests exceeding 100 Git process starts all + fail closed before the first affected Git read. +- Search confirms no Bridge plan is consumed by a mutation path. + +### Rollback + +Remove Bridge plan adapters and schema vectors; existing continuation planning +remains intact. + +## 6. Step S4 — One-shot JSON transport + +Implementation status (2026-08-07): complete in the source tree. The package +entry point, bounded parser, static router, fixed error surface, fail-closed +PLAN handling, and broken-pipe behavior have focused tests. S5 has since made +the seven Linux Mandatory Core Surface operations publicly available; all +other operations and platforms remain fail-closed. + +### Context brief + +The machine boundary must own parsing and errors before human argparse and +terminal rendering. It reads one bounded request and writes one bounded response +while stdout remains writable; a broken pipe follows the explicit exit-5 rule. + +### Ownership + +- `src/dyro/bridge/transport.py` +- `src/dyro/bridge/redaction.py` +- `pyproject.toml` entry point and optional transport dependencies +- `MANIFEST.in` only if non-Python schemas are packaged +- `tests/test_bridge_transport.py` +- `tests/test_bridge_redaction.py` + +### Tasks + +1. Implement strict duplicate-key-aware bounded JSON parsing. +2. Validate envelope then one operation schema before resolving a workspace. +3. Route only to catalog-bound Core services. +4. Normalize all failures into stable redacted errors and enforce output limits. +5. Define parse-stage error metadata with nullable unknown request fields and + separate server/requested protocol values. +6. Handle broken stdout as deterministic exit 5 without retry or traceback; + exactly-one-JSON applies only while stdout is writable. +7. Add the real `dyro-bridge` console entry point. +8. Keep MCP dependencies and host integration files out of this step. + +The frozen parser limits are 256 KiB request bytes, depth 64, 10,000 decoded +nodes, and 128-byte numeric tokens. Protocol major 1 accepts only client minor +versions at or below the server minor. Request IDs use a narrow correlation-ID +alphabet and are not echoed when boundary redaction rejects them. + +### Verification + +```bash +uv run python -m unittest tests.test_bridge_transport tests.test_bridge_redaction -v +uv run ruff check src/dyro/bridge tests/test_bridge_transport.py tests/test_bridge_redaction.py +uv run python -m compileall -q src tests +``` + +### Exit criteria + +- D01–D05 pass in source-tree tests. +- Human CLI output and error behavior are unchanged. +- No transport request shape contains mutation or approval fields. + +### Rollback + +Remove the console entry point and isolated transport package. Core Observation +and Plan services remain available to the Console/CLI if independently useful. + +## 7. Step S5 — Zero-effect, artifact, and real-sandbox gates + +Implementation status (2026-08-07): the catalog promotes exactly the seven +Mandatory Core Surface operations on Linux Ubuntu 24.04 and retains fail-closed +macOS/Windows metadata. The required CI gate runs the same 43-case corpus +against the internal candidate and installed public process from source, wheel, +and sdist; the exact-commit Docker evidence remains authoritative for Go. + +### Context brief + +The original failure mode was visible only in a real restricted Codex +environment. This step must prove behavior beyond mocks and writable temporary +state roots. + +### Ownership + +- `tools/verify_bridge_zero_effects.py` +- `tests/test_bridge_black_box.py` +- `.github/workflows/ci.yml` +- artifact test fixtures and protocol corpus + +### Tasks + +1. Audit file creation/write-open and relevant metadata across HOME, XDG, + `DYRO_HOME`, workspace, Git metadata and temp roots. +2. Deny network and record process starts; allow only reviewed Git read argv. +3. Inject pending Objective recovery, stale registry, permission failures and + malformed local Profiles. +4. Run one protocol corpus against source, wheel, and sdist outside checkout. +5. Perform real Codex in-sandbox and out-of-sandbox trials and preserve evidence. +6. Run E03-Core protocol/schema/planner current/N-1 fixtures and incompatible + future/unknown cases. Do not require MCP or an integration artifact here. +7. Use the platform-specific layered observation mechanisms from the acceptance + matrix and record their blind spots; unsupported platform operations fail + closed. + +### Verification + +```bash +uv run python -m unittest tests.test_bridge_black_box -v +uv run python tools/verify_bridge_zero_effects.py +uv run python -m build -o /tmp/dyro-bridge-dist +``` + +Artifact install commands must use a newly created temporary directory and +environment; exact commands are recorded with the evidence rather than assumed +by this plan. + +### Exit criteria + +- A01–E02 and E03-Core pass on current commit, and every Mandatory Core Surface + operation is publicly callable in source, wheel, and sdist. +- Phase 0 Core + JSON transport may move from Conditional Go to Go. +- Any write, recovery, network, secret leak, or unexpected process is a hard + stop, not a warning. + +### Rollback + +Disable all catalog availability and remove the entry point if a zero-effect +property cannot be proven. Keep the harness as a regression tool. + +## 8. Step S6 — Host-neutral Skill beta + +### Context brief + +The Skill is progressive-disclosure guidance over a proven Phase 0 transport. +It is not an authorization or security boundary and must not be confused with +outbound multi-Agent `dispatch`. + +### Ownership + +- host-neutral Skill source under a new integration source directory +- integration ownership manifest +- Skill trigger and context-budget tests + +### Tasks + +1. Write a Skill that first calls compact capabilities, then fetches one schema. +2. Define positive Dyro inspect/plan triggers and negative dispatch/mutation + triggers. +3. Implement previewed install/status/uninstall with conflict detection and a + recoverable ownership manifest. +4. Run the ten fresh-session journeys and measure byte/token budgets. + +### Verification + +Use the exact host discovery directory and fresh sessions. A copied Skill file +that was not actually discovered is not acceptance evidence. + +### Exit criteria + +- F01, F03, and F04 pass. +- Skill beta is explicitly Codex-only until another host passes independently. + +### Rollback + +Uninstall only files owned by the manifest and restore any atomically retained +prior version. Never delete an unowned same-name Skill. + +## 9. Step S7 — Codex typed read-only MCP/Plugin + +### Context brief + +MCP adapts the same Core services and exposes a small typed tool set. Server +code ships with Dyro; the Codex integration artifact versions discovery and +compatibility independently. + +### Ownership + +- `src/dyro/bridge/mcp.py` +- `dyro-mcp` entry point and optional dependency metadata +- Codex integration artifact and compatibility manifest +- MCP process, version-skew, install/update/uninstall/rollback tests + +### Tasks + +1. Map only the approved typed read/plan operations. +2. Start the installed `dyro-mcp` executable, never ambient `python -m`. +3. Handshake Core/integration/protocol/schema/planner/capabilities versions and + fail closed on incompatible combinations. +4. Prove the MCP process obeys the actual Codex permission boundary. +5. Verify install, update, failure rollback, status, and uninstall ownership. +6. Complete E03-Integration: Core-newer, integration-newer, N/N-1, missing MCP + dependency, and tool-list pinning without exposure widening. + +### Verification + +Run all Phase 0 protocol and zero-effect gates through MCP, plus F02. Inspect +the actual advertised tool list and assert mutation names are absent. + +### Exit criteria + +- All Core gates, E03-Integration, and F01–F04 pass through the installed Codex + integration. +- Public wording names only the verified host and read/plan scope. + +### Rollback + +Disable or uninstall the integration artifact without removing Core Bridge. +Protocol incompatibility fails closed and leaves the previous owned artifact +recoverable. + +## 10. Adversarial review gates + +An independent reviewer must challenge the implementation after S4 and again +after S7. The reviewer receives the locked branch/HEAD, relevant diff, ADR, +inventory, protocol, acceptance matrix, test evidence, wheel/sdist, and real +host evidence. + +The review tries to prove: + +- an excluded operation is reachable; +- an R0 path writes, recovers, networks, or starts an unknown process; +- a local malformed Profile falls back to another workspace; +- a digest is presented as authorization; +- CLI handler/presentation logic leaked into Core Bridge; +- raw paths, secrets, argv, logs, or exceptions cross the transport; +- packaging or version skew widens the tool surface; +- a host claim is based on source-tree or mocked behavior rather than a real + installed session. + +Any confirmed P0/P1 finding returns the affected step to in-progress. The +reviewer cannot approve mutation as part of Phase 0. + +## 11. Plan mutation protocol + +- Split a step when it no longer has one independently verifiable outcome. +- Insert a prerequisite before dependent work; never mark the dependent step + complete with a waived invariant. +- Reorder only when the dependency graph and file ownership remain valid. +- Record skipped work with the exact reason and resulting unsupported claim. +- Abandon a step by disabling its catalog availability and removing owned + integration files; preserve evidence and unrelated work. +- Any proposed apply, R1/R2/R3 operation, broker, daemon, or approval token is a + new project with a new ADR and review, not a mutation of this blueprint. diff --git a/pyproject.toml b/pyproject.toml index 6b69852..9e06d2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.6.2" +version = "0.6.3" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" @@ -50,6 +50,8 @@ packages = [ "dyro.console", "dyro.console.assets", "dyro.continuation", + "dyro.integrations", + "dyro.integrations.assets", "experiments", "experiments.local_agent_dispatch", "experiments.local_agent_dispatch.adapters", @@ -57,6 +59,10 @@ packages = [ [tool.setuptools.package-data] "dyro.console" = ["assets/*"] +"dyro.integrations" = [ + "assets/dyro-control-plane/SKILL.md", + "assets/dyro-control-plane/agents/openai.yaml", +] [tool.unittest] start-directory = "tests" diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 60a22d0..aad2b29 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -101,6 +101,11 @@ remove_workspace, set_default_workspace, ) +from .integrations import ( + install_integration, + integration_status, + uninstall_integration, +) from .onboarding import ( SetupPlan, append_repository, @@ -472,11 +477,7 @@ def _setup_default_tool(root: Path, provider_preset: str | None) -> str | None: return None current = load_tool_preferences().default_tool recommended = next( - ( - tool.id - for tool in available - if tool.id == provider_preset - ), + (tool.id for tool in available if tool.id == provider_preset), "", ) if not recommended and current in {tool.id for tool in available}: @@ -501,7 +502,11 @@ def choose(candidates: list[HomeTool], *, show_all: bool) -> str: choices.append(("m", "查看全部已检测工具")) aliases["more"] = "m" default = next( - (str(index) for index, tool in enumerate(candidates, start=1) if tool.id == recommended), + ( + str(index) + for index, tool in enumerate(candidates, start=1) + if tool.id == recommended + ), "0", ) return _ask_setup_choice( @@ -571,13 +576,15 @@ def _render_setup_personal_preferences( preferences: SetupPersonalPreferences, ) -> None: update_summary = ( - "每日检测;补丁自动更新" - if preferences.auto_patch - else "每日检测;补丁保持手动更新" - ) if preferences.check_enabled else "关闭每日检测与补丁自动更新" - print( - " - 更新:" + update_summary + ( + "每日检测;补丁自动更新" + if preferences.auto_patch + else "每日检测;补丁保持手动更新" + ) + if preferences.check_enabled + else "关闭每日检测与补丁自动更新" ) + print(" - 更新:" + update_summary) if preferences.default_tool is None: print(" - 编码工具:" + muted("保持当前个人偏好")) elif preferences.default_tool: @@ -1446,6 +1453,39 @@ def cmd_tool_pin(args: argparse.Namespace) -> None: ) +def cmd_integration_status(args: argparse.Namespace) -> None: + status = integration_status(args.id) + print(f"{status.integration}\t{status.state.value}\t{status.target}") + print(status.detail) + for avatar in status.avatars: + print( + f"avatar\t{avatar.host}\t{avatar.state}\t{avatar.path}\t{avatar.detail}" + ) + + +def _print_integration_plan(plan, *, dry_run: bool) -> None: + prefix = "DRY RUN: " if dry_run else "" + print(f"{prefix}{plan.action} {plan.status.integration}: {plan.status.state.value}") + for change in plan.changes: + print(f" - {change}") + + +def cmd_integration_install(args: argparse.Namespace) -> None: + preview = args.dry_run or not args.yes + plan = install_integration(args.id, yes=args.yes, dry_run=preview) + _print_integration_plan(plan, dry_run=preview) + if not args.yes and not args.dry_run: + print("确认计划后,重新运行并添加 --yes 执行。") + + +def cmd_integration_uninstall(args: argparse.Namespace) -> None: + preview = args.dry_run or not args.yes + plan = uninstall_integration(args.id, yes=args.yes, dry_run=preview) + _print_integration_plan(plan, dry_run=preview) + if not args.yes and not args.dry_run: + print("确认计划后,重新运行并添加 --yes 执行。") + + def _print_update_result(result) -> None: if result.error: raise DyroError(result.error) @@ -2973,6 +3013,49 @@ def build_parser() -> argparse.ArgumentParser: tool_pin.add_argument("ids", nargs="*") tool_pin.add_argument("--clear", action="store_true") tool_pin.set_defaults(func=cmd_tool_pin) + integration = sub.add_parser( + "integration", help="管理 Dyro 拥有的可选编码智能体集成" + ) + integration_sub = integration.add_subparsers( + dest="integration_command", required=True + ) + integration_status_parser = integration_sub.add_parser( + "status", help="只读检查集成状态" + ) + integration_status_parser.add_argument("id", choices=("skill", "codex")) + integration_status_parser.set_defaults(func=cmd_integration_status) + integration_install_parser = integration_sub.add_parser( + "install", help="预览或安装 Dyro 自有集成资产(镜像+分身)" + ) + integration_install_parser.add_argument( + "id", + choices=("skill", "codex"), + help="skill 为 canonical id;codex 为兼容别名", + ) + integration_install_parser.add_argument( + "--yes", action="store_true", help="确认执行已预览的安装或升级" + ) + integration_install_parser.add_argument( + "--dry-run", + action="store_true", + default=argparse.SUPPRESS, + help="仅预览安装计划;也兼容全局 --dry-run 放在命令前", + ) + integration_install_parser.set_defaults(func=cmd_integration_install) + integration_uninstall_parser = integration_sub.add_parser( + "uninstall", help="仅卸载仍匹配 ownership manifest 的资产" + ) + integration_uninstall_parser.add_argument("id", choices=("skill", "codex")) + integration_uninstall_parser.add_argument( + "--yes", action="store_true", help="确认卸载仍完整的自有资产" + ) + integration_uninstall_parser.add_argument( + "--dry-run", + action="store_true", + default=argparse.SUPPRESS, + help="仅预览卸载计划;也兼容全局 --dry-run 放在命令前", + ) + integration_uninstall_parser.set_defaults(func=cmd_integration_uninstall) update = sub.add_parser("update", help="检测并安全更新 Dyro") update_sub = update.add_subparsers(dest="update_command", required=True) update_sub.add_parser( diff --git a/src/dyro/config.py b/src/dyro/config.py index 5086d98..1b5c42d 100644 --- a/src/dyro/config.py +++ b/src/dyro/config.py @@ -7,6 +7,7 @@ from typing import Any from .errors import ValidationError +from .read_limits import ReadBudget CONFIG_NAME = "dyro.toml" @@ -101,6 +102,13 @@ def objectives_dir(self) -> Path: return self.root / OBJECTIVES_DIR +@dataclass(frozen=True) +class LoadedProfile: + config: Config + root: Path + profile_bytes: bytes + + def external_security_errors(policy: Policy) -> tuple[str, ...]: """Return the explicit migration requirements for an external Profile.""" if policy.execution_mode != "external": @@ -110,14 +118,18 @@ def external_security_errors(policy: Policy) -> tuple[str, ...]: missing.append("policy.require_signed_execution = true") if not getattr(policy, "require_signed_review", True): missing.append("policy.require_signed_review = true") - if getattr(policy, "require_external_signoff", False) and not getattr(policy, "require_signed_signoff", True): + if getattr(policy, "require_external_signoff", False) and not getattr( + policy, "require_signed_signoff", True + ): missing.append("policy.require_signed_signoff = true") return tuple(missing) def validate_id(value: str, label: str = "ID") -> str: - if not SAFE_ID.fullmatch(value): - raise ValidationError(f"{label} 只能包含字母、数字、点、下划线和连字符:{value!r}") + if not isinstance(value, str) or not SAFE_ID.fullmatch(value): + raise ValidationError( + f"{label} 只能包含字母、数字、点、下划线和连字符:{value!r}" + ) return value @@ -134,8 +146,14 @@ def strict_bool(value: Any, label: str) -> bool: def _argv(value: Any, label: str) -> tuple[str, ...]: - if not isinstance(value, list) or not value or not all(isinstance(x, str) and x for x in value): - raise ValidationError(f"{label} 必须是非空字符串数组(argv),不接受 shell 字符串") + if ( + not isinstance(value, list) + or not value + or not all(isinstance(x, str) and x for x in value) + ): + raise ValidationError( + f"{label} 必须是非空字符串数组(argv),不接受 shell 字符串" + ) return tuple(value) @@ -154,17 +172,22 @@ def find_root(start: Path) -> Path: raise ValidationError(f"从 {start} 起未找到 {CONFIG_NAME};请先运行 dyro init") -def load(root: Path | None = None) -> Config: - workspace = find_root(root or Path.cwd()) +def _parse_config(workspace: Path, profile_bytes: bytes) -> Config: config_file = workspace / CONFIG_NAME try: - raw = tomllib.loads(config_file.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: + raw = tomllib.loads(profile_bytes.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError, RecursionError) as exc: raise ValidationError(f"{config_file} TOML 格式错误:{exc}") from exc if raw.get("schema_version") != 1: raise ValidationError("仅支持 schema_version = 1") - workspace_raw = raw.get("workspace", {}) + def table(name: str) -> dict[str, Any]: + value = raw.get(name, {}) + if not isinstance(value, dict): + raise ValidationError(f"{name} 必须是表") + return value + + workspace_raw = table("workspace") name = _string(workspace_raw.get("name"), "workspace.name") recommended_tool_raw = workspace_raw.get("recommended_tool", "") if not isinstance(recommended_tool_raw, str): @@ -172,32 +195,58 @@ def load(root: Path | None = None) -> Config: recommended_tool = recommended_tool_raw.strip() if recommended_tool: validate_id(recommended_tool, "workspace.recommended_tool") - layout_raw = raw.get("layout", {}) + layout_raw = table("layout") layout = Layout( - anchors=_relative(str(layout_raw.get("anchors", "repositories")), "layout.anchors"), - lines=_relative(str(layout_raw.get("lines", "versions")), "layout.lines"), - hotfixes=_relative(str(layout_raw.get("hotfixes", "hotfixes")), "layout.hotfixes"), - tasks=_relative(str(layout_raw.get("tasks", "worktrees")), "layout.tasks"), + anchors=_relative( + _string(layout_raw.get("anchors", "repositories"), "layout.anchors"), + "layout.anchors", + ), + lines=_relative( + _string(layout_raw.get("lines", "versions"), "layout.lines"), + "layout.lines", + ), + hotfixes=_relative( + _string(layout_raw.get("hotfixes", "hotfixes"), "layout.hotfixes"), + "layout.hotfixes", + ), + tasks=_relative( + _string(layout_raw.get("tasks", "worktrees"), "layout.tasks"), + "layout.tasks", + ), ) - policy_raw = raw.get("policy", {}) + policy_raw = table("policy") policy = Policy( - default_base=_string(policy_raw.get("default_base", "main"), "policy.default_base"), - task_branch_prefix=_string(policy_raw.get("task_branch_prefix", "task/"), "policy.task_branch_prefix"), - allow_push=strict_bool(policy_raw.get("allow_push", False), "policy.allow_push"), - require_clean_merge=strict_bool(policy_raw.get("require_clean_merge", True), "policy.require_clean_merge"), + default_base=_string( + policy_raw.get("default_base", "main"), "policy.default_base" + ), + task_branch_prefix=_string( + policy_raw.get("task_branch_prefix", "task/"), "policy.task_branch_prefix" + ), + allow_push=strict_bool( + policy_raw.get("allow_push", False), "policy.allow_push" + ), + require_clean_merge=strict_bool( + policy_raw.get("require_clean_merge", True), "policy.require_clean_merge" + ), require_external_signoff=strict_bool( - policy_raw.get("require_external_signoff", False), "policy.require_external_signoff" + policy_raw.get("require_external_signoff", False), + "policy.require_external_signoff", ), require_signed_execution=strict_bool( - policy_raw.get("require_signed_execution", False), "policy.require_signed_execution" + policy_raw.get("require_signed_execution", False), + "policy.require_signed_execution", ), require_signed_review=strict_bool( - policy_raw.get("require_signed_review", False), "policy.require_signed_review" + policy_raw.get("require_signed_review", False), + "policy.require_signed_review", ), require_signed_signoff=strict_bool( - policy_raw.get("require_signed_signoff", False), "policy.require_signed_signoff" + policy_raw.get("require_signed_signoff", False), + "policy.require_signed_signoff", + ), + execution_mode=_string( + policy_raw.get("execution_mode", "local"), "policy.execution_mode" ), - execution_mode=_string(policy_raw.get("execution_mode", "local"), "policy.execution_mode"), allow_unattended_execute=strict_bool( policy_raw.get("allow_unattended_execute", False), "policy.allow_unattended_execute", @@ -214,7 +263,9 @@ def load(root: Path | None = None) -> Config: if policy.execution_mode not in ("local", "external"): raise ValidationError("policy.execution_mode 只能是 local 或 external") if not policy.require_clean_merge: - raise ValidationError("policy.require_clean_merge 必须为 true;事务合并不允许脏工作区") + raise ValidationError( + "policy.require_clean_merge 必须为 true;事务合并不允许脏工作区" + ) if ( policy.require_signed_execution or policy.require_signed_review @@ -222,33 +273,55 @@ def load(root: Path | None = None) -> Config: ) and policy.execution_mode != "external": raise ValidationError("require_signed_* 策略仅适用于 execution_mode = external") if policy.require_signed_signoff and not policy.require_external_signoff: - raise ValidationError("require_signed_signoff = true 要求 require_external_signoff = true") + raise ValidationError( + "require_signed_signoff = true 要求 require_external_signoff = true" + ) repositories: dict[str, Repository] = {} - for repo_id, entry in raw.get("repositories", {}).items(): + for repo_id, entry in table("repositories").items(): validate_id(repo_id, "repository id") if not isinstance(entry, dict): raise ValidationError(f"repositories.{repo_id} 必须是表") - path = _relative(_string(entry.get("path"), f"repositories.{repo_id}.path"), "repository path") - mount = _relative(_string(entry.get("mount", repo_id), f"repositories.{repo_id}.mount"), "repository mount") + path = _relative( + _string(entry.get("path"), f"repositories.{repo_id}.path"), + "repository path", + ) + mount = _relative( + _string(entry.get("mount", repo_id), f"repositories.{repo_id}.mount"), + "repository mount", + ) remote = entry.get("remote", "") if remote is None: remote = "" if not isinstance(remote, str): raise ValidationError(f"repositories.{repo_id}.remote 必须是字符串") - verify = tuple(_argv(item, f"repositories.{repo_id}.verify") for item in entry.get("verify", [])) + verify_raw = entry.get("verify", []) + if not isinstance(verify_raw, list): + raise ValidationError( + f"repositories.{repo_id}.verify 必须是 argv 数组的数组" + ) + verify = tuple( + _argv(item, f"repositories.{repo_id}.verify") for item in verify_raw + ) repositories[repo_id] = Repository(repo_id, path, mount, remote, verify) if not repositories: raise ValidationError("至少配置一个 repositories.") adapters: dict[str, Adapter] = {} - for adapter_id, entry in raw.get("adapters", {}).items(): + for adapter_id, entry in table("adapters").items(): validate_id(adapter_id, "adapter id") if not isinstance(entry, dict): raise ValidationError(f"adapters.{adapter_id} 必须是表") - read = _argv(entry.get("read", entry.get("command")), f"adapters.{adapter_id}.read") - write = _argv(entry.get("write", entry.get("command")), f"adapters.{adapter_id}.write") - launch = _argv(entry.get("launch", entry.get("command", entry.get("write"))), f"adapters.{adapter_id}.launch") + read = _argv( + entry.get("read", entry.get("command")), f"adapters.{adapter_id}.read" + ) + write = _argv( + entry.get("write", entry.get("command")), f"adapters.{adapter_id}.write" + ) + launch = _argv( + entry.get("launch", entry.get("command", entry.get("write"))), + f"adapters.{adapter_id}.launch", + ) adapters[adapter_id] = Adapter(adapter_id, launch, read, write) return Config( workspace, @@ -261,6 +334,36 @@ def load(root: Path | None = None) -> Config: ) +def load(root: Path | None = None) -> Config: + workspace = find_root(root or Path.cwd()) + return _parse_config(workspace, (workspace / CONFIG_NAME).read_bytes()) + + +def load_profile_exact(root: Path, budget: ReadBudget) -> LoadedProfile: + """Load exactly ``root/dyro.toml`` from the same bounded bytes that are parsed.""" + + try: + canonical_root = root.absolute().resolve(strict=False) + except PermissionError: + raise + except (OSError, RuntimeError) as exc: + raise ValidationError("Profile root 无法解析") from exc + profile_bytes = budget.read_regular_bytes_at( + root=canonical_root, + directory=canonical_root, + name=CONFIG_NAME, + maximum_bytes=budget.limits.profile_bytes, + label="dyro.toml", + ) + config = _parse_config(canonical_root, profile_bytes) + validate_id(config.name, "workspace name") + return LoadedProfile( + config=config, + root=canonical_root, + profile_bytes=profile_bytes, + ) + + def expand_argv(argv: tuple[str, ...], **values: str | Path) -> tuple[str, ...]: allowed = {key: str(value) for key, value in values.items()} try: diff --git a/src/dyro/continuation/objective_storage.py b/src/dyro/continuation/objective_storage.py index da4ee01..72ff596 100644 --- a/src/dyro/continuation/objective_storage.py +++ b/src/dyro/continuation/objective_storage.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import io import json from contextlib import contextmanager from dataclasses import dataclass @@ -18,6 +19,7 @@ from ..canonical import canonical_json_bytes from ..config import Config, validate_id from ..errors import DyroError, ValidationError +from ..read_limits import ReadBudget, ReadLimitCode, ReadLimitError from ..state import open_safe_child_directory, open_safe_directory from .models import Objective, RequestedMode @@ -42,7 +44,10 @@ class StoredObjective: @property def owns_mutation_scope(self) -> bool: - return self.operator_state == "active" and self.objective.requested_mode != RequestedMode.OBSERVE + return ( + self.operator_state == "active" + and self.objective.requested_mode != RequestedMode.OBSERVE + ) @dataclass(frozen=True) @@ -61,6 +66,7 @@ def open_objective_directory( objective_id: str, *, create: bool = False, + budget: ReadBudget | None = None, ) -> Iterator[ObjectiveDirectory]: """Open one Objective directory without re-resolving mutable state paths. @@ -69,10 +75,32 @@ def open_objective_directory( a platform-native safe traversal is available. """ if os.name == "nt": - raise DyroError("Windows 暂不支持安全的 Objective 持久化;拒绝写入以避免 reparse-point 路径逃逸") + raise DyroError( + "Windows 暂不支持安全的 Objective 持久化;拒绝写入以避免 reparse-point 路径逃逸" + ) if not hasattr(os, "O_NOFOLLOW"): - raise DyroError("当前平台缺少安全的 Objective 持久化能力;拒绝访问以避免路径逃逸") + raise DyroError( + "当前平台缺少安全的 Objective 持久化能力;拒绝访问以避免路径逃逸" + ) validate_id(objective_id, "Objective ID") + if budget is not None: + if create: + raise ValidationError("bounded Objective read 不允许创建状态目录") + with budget.open_safe_directory_chain( + config.root, config.objectives_dir + ) as parent_fd: + assert parent_fd is not None + with budget.open_safe_directory_chain( + config.root, config.objectives_dir / objective_id + ) as objective_fd: + assert objective_fd is not None + yield ObjectiveDirectory( + config.objectives_dir / objective_id, + objective_fd, + parent_fd, + objective_id, + ) + return workspace_fd = open_safe_directory(config.root) dyro_fd: int | None = None objectives_fd: int | None = None @@ -87,7 +115,12 @@ def open_objective_directory( raise DyroError(f"Objective 已存在:{objective_id}") from exc os.fsync(objectives_fd) objective_fd = open_safe_child_directory(objectives_fd, objective_id) - yield ObjectiveDirectory(config.objectives_dir / objective_id, objective_fd, objectives_fd, objective_id) + yield ObjectiveDirectory( + config.objectives_dir / objective_id, + objective_fd, + objectives_fd, + objective_id, + ) finally: if objective_fd is not None: os.close(objective_fd) @@ -101,9 +134,13 @@ def open_objective_directory( def list_objective_ids(config: Config) -> tuple[str, ...]: """Return only verified Objective directory names from a stable root FD.""" if os.name == "nt": - raise DyroError("Windows 暂不支持安全的 Objective 持久化;拒绝访问以避免 reparse-point 路径逃逸") + raise DyroError( + "Windows 暂不支持安全的 Objective 持久化;拒绝访问以避免 reparse-point 路径逃逸" + ) if not hasattr(os, "O_NOFOLLOW"): - raise DyroError("当前平台缺少安全的 Objective 持久化能力;拒绝访问以避免路径逃逸") + raise DyroError( + "当前平台缺少安全的 Objective 持久化能力;拒绝访问以避免路径逃逸" + ) workspace_fd = open_safe_directory(config.root) dyro_fd: int | None = None objectives_fd: int | None = None @@ -127,16 +164,24 @@ def list_objective_ids(config: Config) -> tuple[str, ...]: if name == "objectives.lock": continue if name.startswith("."): - raise ValidationError(f"Objective 根目录包含未知状态文件:{config.objectives_dir / name}") + raise ValidationError( + f"Objective 根目录包含未知状态文件:{config.objectives_dir / name}" + ) try: validate_id(name, "Objective ID") info = os.stat(name, dir_fd=objectives_fd, follow_symlinks=False) except (OSError, ValidationError) as exc: - raise ValidationError(f"Objective 根目录包含不安全条目:{config.objectives_dir / name}") from exc + raise ValidationError( + f"Objective 根目录包含不安全条目:{config.objectives_dir / name}" + ) from exc if stat.S_ISLNK(info.st_mode): - raise ValidationError(f"Objective 根目录包含符号链接:{config.objectives_dir / name}") + raise ValidationError( + f"Objective 根目录包含符号链接:{config.objectives_dir / name}" + ) if not stat.S_ISDIR(info.st_mode): - raise ValidationError(f"Objective 根目录包含不安全条目:{config.objectives_dir / name}") + raise ValidationError( + f"Objective 根目录包含不安全条目:{config.objectives_dir / name}" + ) result.append(name) return tuple(sorted(result)) finally: @@ -190,16 +235,40 @@ def _write_all(descriptor: int, content: bytes) -> None: view = view[written:] -def _read_file(directory: ObjectiveDirectory, name: str, label: str) -> bytes: +def _read_file( + directory: ObjectiveDirectory, + name: str, + label: str, + *, + budget: ReadBudget | None = None, + maximum_bytes: int | None = None, +) -> bytes: name = _checked_name(name) try: - descriptor = os.open(name, _fd_flags(os.O_RDONLY), dir_fd=directory.fd) + descriptor = os.open( + name, + _fd_flags(os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)), + dir_fd=directory.fd, + ) + except PermissionError: + raise except OSError as exc: raise ValidationError(f"无法安全读取 {label}:{directory.path / name}") from exc try: info = os.fstat(descriptor) if not stat.S_ISREG(info.st_mode): - raise ValidationError(f"{label} 必须是安全的普通文件:{directory.path / name}") + raise ValidationError( + f"{label} 必须是安全的普通文件:{directory.path / name}" + ) + if budget is not None: + if maximum_bytes is None: + raise ValidationError("bounded Objective read 缺少 maximum_bytes") + return budget.read_descriptor_bytes( + descriptor, + size=info.st_size, + maximum_bytes=maximum_bytes, + label=label, + ) return _read_all(descriptor, info.st_size) finally: os.close(descriptor) @@ -210,17 +279,28 @@ def _file_exists(directory: ObjectiveDirectory, name: str) -> bool: info = os.stat(_checked_name(name), dir_fd=directory.fd, follow_symlinks=False) except FileNotFoundError: return False + except PermissionError: + raise except OSError as exc: - raise DyroError(f"无法读取 Objective 状态文件:{directory.path / name}") from exc + raise DyroError( + f"无法读取 Objective 状态文件:{directory.path / name}" + ) from exc if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): - raise ValidationError(f"Objective 状态文件必须是安全的普通文件:{directory.path / name}") + raise ValidationError( + f"Objective 状态文件必须是安全的普通文件:{directory.path / name}" + ) return True def _create_file(directory: ObjectiveDirectory, name: str, content: bytes) -> None: name = _checked_name(name) try: - descriptor = os.open(_checked_name(name), _fd_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), 0o600, dir_fd=directory.fd) + descriptor = os.open( + _checked_name(name), + _fd_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), + 0o600, + dir_fd=directory.fd, + ) except FileExistsError as exc: raise DyroError(f"拒绝覆盖已存在的状态文件:{directory.path / name}") from exc except OSError as exc: @@ -236,9 +316,13 @@ def _create_file(directory: ObjectiveDirectory, name: str, content: bytes) -> No def _append_file(directory: ObjectiveDirectory, name: str, content: bytes) -> None: name = _checked_name(name) try: - descriptor = os.open(name, _fd_flags(os.O_WRONLY | os.O_APPEND), dir_fd=directory.fd) + descriptor = os.open( + name, _fd_flags(os.O_WRONLY | os.O_APPEND), dir_fd=directory.fd + ) except OSError as exc: - raise DyroError(f"无法安全追加 Objective 事件日志:{directory.path / name}") from exc + raise DyroError( + f"无法安全追加 Objective 事件日志:{directory.path / name}" + ) from exc try: _write_all(descriptor, content) os.fsync(descriptor) @@ -246,11 +330,18 @@ def _append_file(directory: ObjectiveDirectory, name: str, content: bytes) -> No os.close(descriptor) -def _atomic_replace_file(directory: ObjectiveDirectory, name: str, content: bytes) -> None: +def _atomic_replace_file( + directory: ObjectiveDirectory, name: str, content: bytes +) -> None: name = _checked_name(name) temporary = f".{name}.{os.getpid()}.{os.urandom(8).hex()}" try: - descriptor = os.open(temporary, _fd_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), 0o600, dir_fd=directory.fd) + descriptor = os.open( + temporary, + _fd_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), + 0o600, + dir_fd=directory.fd, + ) try: _write_all(descriptor, content) os.fsync(descriptor) @@ -264,7 +355,9 @@ def _atomic_replace_file(directory: ObjectiveDirectory, name: str, content: byte except FileNotFoundError: pass except OSError as exc: - raise DyroError(f"无法清理 Objective 临时状态文件:{directory.path / temporary}") from exc + raise DyroError( + f"无法清理 Objective 临时状态文件:{directory.path / temporary}" + ) from exc def _remove_file(directory: ObjectiveDirectory, name: str, label: str) -> None: @@ -283,9 +376,16 @@ def event_hash(event: dict[str, object]) -> str: return _sha256(payload) -def _validate_event(event: object, *, expected_seq: int, previous: str, path: Path) -> dict[str, object]: +def _validate_event( + event: object, *, expected_seq: int, previous: str, path: Path +) -> dict[str, object]: if not isinstance(event, dict) or set(event) != { - "schema_version", "seq", "event", "previous_sha256", "record", "sha256" + "schema_version", + "seq", + "event", + "previous_sha256", + "record", + "sha256", }: raise ValidationError(f"Objective 事件结构无效:{path}") if ( @@ -295,7 +395,10 @@ def _validate_event(event: object, *, expected_seq: int, previous: str, path: Pa or event.get("seq") != expected_seq ): raise ValidationError(f"Objective 事件 seq 无效:{path}") - if not isinstance(event.get("event"), str) or event.get("previous_sha256") != previous: + if ( + not isinstance(event.get("event"), str) + or event.get("previous_sha256") != previous + ): raise ValidationError(f"Objective 事件链无效:{path}") digest = event.get("sha256") if not isinstance(digest, str) or digest != event_hash(event): @@ -303,12 +406,37 @@ def _validate_event(event: object, *, expected_seq: int, previous: str, path: Pa return event -def read_events(directory: ObjectiveDirectory, *, allow_empty: bool = False) -> tuple[dict[str, object], ...]: +def _validate_event_bounded( + event: object, *, expected_seq: int, previous: str, path: Path +) -> dict[str, object]: + try: + return _validate_event( + event, + expected_seq=expected_seq, + previous=previous, + path=path, + ) + except RecursionError as exc: + raise ValidationError(f"Objective 事件结构过深:{path}") from exc + + +def read_events( + directory: ObjectiveDirectory, + *, + allow_empty: bool = False, + budget: ReadBudget | None = None, +) -> tuple[dict[str, object], ...]: event_path = _directory_path(directory) / "events.jsonl" if not _file_exists(directory, "events.jsonl") and allow_empty: return () try: - raw = _read_file(directory, "events.jsonl", "Objective 事件日志").decode("utf-8") + raw = _read_file( + directory, + "events.jsonl", + "Objective 事件日志", + budget=budget, + maximum_bytes=(budget.limits.objective_events_bytes if budget else None), + ).decode("utf-8") except UnicodeError as exc: raise ValidationError(f"无法读取 Objective 事件日志:{event_path}") from exc if not raw and allow_empty: @@ -317,14 +445,31 @@ def read_events(directory: ObjectiveDirectory, *, allow_empty: bool = False) -> raise ValidationError(f"Objective 事件日志断尾:{event_path}") events: list[dict[str, object]] = [] previous = "" - for expected_seq, line in enumerate(raw.splitlines(), start=1): + for expected_seq, line in enumerate(io.StringIO(raw), start=1): + if budget is not None: + budget.check_deadline() + if expected_seq > budget.limits.objective_event_records: + raise ReadLimitError( + ReadLimitCode.RECORD_LIMIT_EXCEEDED, + "Objective event record limit exceeded", + ) + line = line.removesuffix("\n") try: event = json.loads(line) - except json.JSONDecodeError as exc: - raise ValidationError(f"Objective 事件日志 JSON 无效:{event_path}") from exc - verified = _validate_event(event, expected_seq=expected_seq, previous=previous, path=event_path) + except (json.JSONDecodeError, RecursionError) as exc: + raise ValidationError( + f"Objective 事件日志 JSON 无效:{event_path}" + ) from exc + verified = _validate_event_bounded( + event, + expected_seq=expected_seq, + previous=previous, + path=event_path, + ) previous = str(verified["sha256"]) events.append(verified) + if budget is not None: + budget.check_deadline() if not events: if allow_empty: return () @@ -356,53 +501,99 @@ def _pending_payload( "event": event, "contract_revision": int(event["record"]["revision"]), "contract_sha256": ( - hashlib.sha256(contract_content).hexdigest() if contract_content is not None else "" + hashlib.sha256(contract_content).hexdigest() + if contract_content is not None + else "" ), "action_cancellation": action_cancellation, } -def read_pending(directory: ObjectiveDirectory) -> dict[str, object] | None: +def read_pending( + directory: ObjectiveDirectory, *, budget: ReadBudget | None = None +) -> dict[str, object] | None: path = _directory_path(directory) / _PENDING_FILE if not _file_exists(directory, _PENDING_FILE): return None try: - payload = json.loads(_read_file(directory, _PENDING_FILE, "Objective pending transaction").decode("utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise ValidationError(f"Objective pending transaction JSON 无效:{path}") from exc + payload = json.loads( + _read_file( + directory, + _PENDING_FILE, + "Objective pending transaction", + budget=budget, + maximum_bytes=( + budget.limits.objective_metadata_bytes if budget else None + ), + ).decode("utf-8") + ) + except PermissionError: + raise + except (OSError, UnicodeError, json.JSONDecodeError, RecursionError) as exc: + raise ValidationError( + f"Objective pending transaction JSON 无效:{path}" + ) from exc pending_fields = ( {"schema_version", "event", "contract_revision", "contract_sha256"}, - {"schema_version", "event", "contract_revision", "contract_sha256", "action_cancellation"}, + { + "schema_version", + "event", + "contract_revision", + "contract_sha256", + "action_cancellation", + }, ) if not isinstance(payload, dict) or set(payload) not in pending_fields: raise ValidationError(f"Objective pending transaction 结构无效:{path}") if payload.get("schema_version") != OBJECTIVE_STORE_SCHEMA_VERSION: raise ValidationError(f"Objective pending transaction 版本无效:{path}") raw_event = payload.get("event") - if not isinstance(raw_event, dict) or type(raw_event.get("seq")) is not int or raw_event["seq"] < 1: + if ( + not isinstance(raw_event, dict) + or type(raw_event.get("seq")) is not int + or raw_event["seq"] < 1 + ): raise ValidationError(f"Objective pending transaction event 无效:{path}") previous = raw_event.get("previous_sha256") if not isinstance(previous, str): - raise ValidationError(f"Objective pending transaction previous hash 无效:{path}") - event = _validate_event(raw_event, expected_seq=raw_event["seq"], previous=previous, path=path) + raise ValidationError( + f"Objective pending transaction previous hash 无效:{path}" + ) + event = _validate_event_bounded( + raw_event, + expected_seq=raw_event["seq"], + previous=previous, + path=path, + ) event_record = event.get("record") if not isinstance(event_record, dict): raise ValidationError(f"Objective pending transaction record 无效:{path}") - if type(payload.get("contract_revision")) is not int or payload["contract_revision"] != event_record.get("revision"): + if type(payload.get("contract_revision")) is not int or payload[ + "contract_revision" + ] != event_record.get("revision"): raise ValidationError(f"Objective pending transaction revision 无效:{path}") contract_digest = payload.get("contract_sha256") if not isinstance(contract_digest, str) or ( contract_digest - and (len(contract_digest) != 64 or any(char not in "0123456789abcdef" for char in contract_digest)) + and ( + len(contract_digest) != 64 + or any(char not in "0123456789abcdef" for char in contract_digest) + ) ): - raise ValidationError(f"Objective pending transaction contract 哈希无效:{path}") + raise ValidationError( + f"Objective pending transaction contract 哈希无效:{path}" + ) action_cancellation = payload.get("action_cancellation") if action_cancellation is not None and not isinstance(action_cancellation, dict): - raise ValidationError(f"Objective pending transaction Action cancellation 无效:{path}") + raise ValidationError( + f"Objective pending transaction Action cancellation 无效:{path}" + ) return payload -def _apply_pending_action_cancellation(directory: ObjectiveDirectory, pending: dict[str, object]) -> None: +def _apply_pending_action_cancellation( + directory: ObjectiveDirectory, pending: dict[str, object] +) -> None: action_cancellation = pending.get("action_cancellation") if action_cancellation is None: return @@ -438,7 +629,9 @@ def recover_pending(directory: ObjectiveDirectory) -> bool: event["record"], event_seq=int(event["seq"]), event_sha256=expected_sha, - contract_content=_read_file(directory, _contract_name(revision), "Objective contract"), + contract_content=_read_file( + directory, _contract_name(revision), "Objective contract" + ), ) _apply_pending_action_cancellation(directory, pending) write_projection(directory, record) @@ -452,8 +645,15 @@ def recover_pending(directory: ObjectiveDirectory) -> bool: if contract_digest: name = _contract_name(revision) if _file_exists(directory, name): - if hashlib.sha256(_read_file(directory, name, "未提交的 Objective contract")).hexdigest() != contract_digest: - raise ValidationError(f"未提交的 Objective contract 哈希不匹配:{path / name}") + if ( + hashlib.sha256( + _read_file(directory, name, "未提交的 Objective contract") + ).hexdigest() + != contract_digest + ): + raise ValidationError( + f"未提交的 Objective contract 哈希不匹配:{path / name}" + ) _remove_file(directory, name, "未提交的 Objective contract") _remove_file(directory, _PENDING_FILE, "Objective pending transaction") if events: @@ -474,19 +674,26 @@ def read_stored( *, recover: bool = True, directory: ObjectiveDirectory | None = None, + budget: ReadBudget | None = None, ) -> StoredObjective: from .store import _record_from_payload, _record_payload if directory is None: - with open_objective_directory(config, objective_id) as opened: - return read_stored(config, objective_id, recover=recover, directory=opened) + with open_objective_directory(config, objective_id, budget=budget) as opened: + return read_stored( + config, objective_id, recover=recover, directory=opened, budget=budget + ) path = directory.path - if read_pending(directory) is not None: + if read_pending(directory, budget=budget) is not None: if not recover: - raise DyroError(f"Objective 存在未完成事务:{objective_id};dry-run 不会写入恢复状态") + raise DyroError( + f"Objective 存在未完成事务:{objective_id};dry-run 不会写入恢复状态" + ) if recover_pending(directory): - raise DyroError(f"Objective 创建在提交前中断,已安全回滚:{objective_id};请重试") - events = read_events(directory) + raise DyroError( + f"Objective 创建在提交前中断,已安全回滚:{objective_id};请重试" + ) + events = read_events(directory, budget=budget) final_event = events[-1] payload = final_event["record"] if not isinstance(payload, dict) or type(payload.get("revision")) is not int: @@ -497,24 +704,55 @@ def read_stored( payload, event_seq=int(final_event["seq"]), event_sha256=str(final_event["sha256"]), - contract_content=_read_file(directory, _contract_name(revision), "Objective contract"), + contract_content=_read_file( + directory, + _contract_name(revision), + "Objective contract", + budget=budget, + maximum_bytes=(budget.limits.objective_metadata_bytes if budget else None), + ), ) try: - state = json.loads(_read_file(directory, "state.json", "Objective 投影").decode("utf-8")) - checkpoint = json.loads(_read_file(directory, "checkpoint.json", "Objective checkpoint").decode("utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: + state = json.loads( + _read_file( + directory, + "state.json", + "Objective 投影", + budget=budget, + maximum_bytes=( + budget.limits.objective_metadata_bytes if budget else None + ), + ).decode("utf-8") + ) + checkpoint = json.loads( + _read_file( + directory, + "checkpoint.json", + "Objective checkpoint", + budget=budget, + maximum_bytes=( + budget.limits.objective_metadata_bytes if budget else None + ), + ).decode("utf-8") + ) + except PermissionError: + raise + except (OSError, UnicodeError, json.JSONDecodeError, RecursionError) as exc: raise ValidationError(f"Objective 投影或 checkpoint JSON 无效:{path}") from exc - expected_state = _record_payload(record) - if _json_bytes(state) != _json_bytes(expected_state): - raise ValidationError(f"Objective 投影与事件重放不一致:{path}") - expected_checkpoint = { - "schema_version": OBJECTIVE_STORE_SCHEMA_VERSION, - "event_seq": record.event_seq, - "event_sha256": record.event_sha256, - "state_sha256": _sha256(expected_state), - } - if _json_bytes(checkpoint) != _json_bytes(expected_checkpoint): - raise ValidationError(f"Objective checkpoint 回滚或损坏:{path}") + try: + expected_state = _record_payload(record) + if _json_bytes(state) != _json_bytes(expected_state): + raise ValidationError(f"Objective 投影与事件重放不一致:{path}") + expected_checkpoint = { + "schema_version": OBJECTIVE_STORE_SCHEMA_VERSION, + "event_seq": record.event_seq, + "event_sha256": record.event_sha256, + "state_sha256": _sha256(expected_state), + } + if _json_bytes(checkpoint) != _json_bytes(expected_checkpoint): + raise ValidationError(f"Objective checkpoint 回滚或损坏:{path}") + except RecursionError as exc: + raise ValidationError(f"Objective 投影或 checkpoint 结构过深:{path}") from exc return record @@ -522,23 +760,40 @@ def write_projection(directory: ObjectiveDirectory, record: StoredObjective) -> from .store import _record_payload state = _record_payload(record) - state_bytes = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + state_bytes = ( + json.dumps( + state, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + b"\n" + ) checkpoint = { "schema_version": OBJECTIVE_STORE_SCHEMA_VERSION, "event_seq": record.event_seq, "event_sha256": record.event_sha256, "state_sha256": _sha256(state), } - checkpoint_bytes = json.dumps(checkpoint, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + checkpoint_bytes = ( + json.dumps( + checkpoint, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + b"\n" + ) _atomic_replace_file(directory, "state.json", state_bytes) _atomic_replace_file(directory, "checkpoint.json", checkpoint_bytes) -def _create_or_validate_contract(directory: ObjectiveDirectory, revision: int, content: bytes) -> None: +def _create_or_validate_contract( + directory: ObjectiveDirectory, revision: int, content: bytes +) -> None: name = _contract_name(revision) path = _directory_path(directory) / name if _file_exists(directory, name): - if hashlib.sha256(_read_file(directory, name, "Objective contract")).hexdigest() != hashlib.sha256(content).hexdigest(): + if ( + hashlib.sha256( + _read_file(directory, name, "Objective contract") + ).hexdigest() + != hashlib.sha256(content).hexdigest() + ): raise ValidationError(f"Objective contract 已存在但内容哈希不同:{path}") return _create_file(directory, name, content) @@ -580,7 +835,12 @@ def commit_event( _append_file( directory, "events.jsonl", - (json.dumps(event, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"), + ( + json.dumps( + event, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + + "\n" + ).encode("utf-8"), ) else: raise ValidationError(f"Objective 事件链无法继续:{path}") diff --git a/src/dyro/continuation/planner.py b/src/dyro/continuation/planner.py index 4b392c0..43b5eda 100644 --- a/src/dyro/continuation/planner.py +++ b/src/dyro/continuation/planner.py @@ -40,7 +40,9 @@ class TaskReadiness: def _facts(**values: object) -> tuple[tuple[str, str], ...]: - return tuple(sorted((key, str(value)) for key, value in values.items() if value != "")) + return tuple( + sorted((key, str(value)) for key, value in values.items() if value != "") + ) def _action( @@ -49,7 +51,9 @@ def _action( reason: ReasonCode, **facts: object, ) -> PlannedAction: - return PlannedAction(kind=kind, subject_id=subject_id, reason=reason, facts=_facts(**facts)) + return PlannedAction( + kind=kind, subject_id=subject_id, reason=reason, facts=_facts(**facts) + ) def _active_conflicts(snapshot: SchedulerSnapshot) -> dict[str, tuple[str, ...]]: @@ -74,7 +78,9 @@ def build_task_readiness( ) -> TaskReadiness: """Classify task execution/review eligibility without reading workspace state.""" by_id = snapshot.tasks_by_id - requested = tuple(sorted(snapshot.candidate_ids if candidate_ids is None else candidate_ids)) + requested = tuple( + sorted(snapshot.candidate_ids if candidate_ids is None else candidate_ids) + ) unknown = sorted(set(requested) - set(by_id)) if unknown: raise ValidationError(f"调度候选不在快照中:{', '.join(unknown)}") @@ -103,7 +109,11 @@ def build_task_readiness( ) ) continue - unresolved = tuple(sorted(key for key in task.blocked_on if decision_states.get(key) != "resolved")) + unresolved = tuple( + sorted( + key for key in task.blocked_on if decision_states.get(key) != "resolved" + ) + ) if unresolved: blocked.append( _action( @@ -206,7 +216,9 @@ def continuation_plan_payload(plan: ContinuationPlan) -> dict[str, object]: "selected_actions": [_action_payload(item) for item in plan.selected_actions], "blocked": [_action_payload(item) for item in plan.blocked], "attention": [_attention_payload(item) for item in plan.attention], - "next_wake_at": None if plan.next_wake_at is None else plan.next_wake_at.isoformat(), + "next_wake_at": None + if plan.next_wake_at is None + else plan.next_wake_at.isoformat(), "facts": dict(plan.facts), } @@ -219,8 +231,12 @@ def _build_plan( attention: Iterable[AttentionItem] = (), **facts: object, ) -> ContinuationPlan: - selected_actions = tuple(sorted(selected, key=lambda item: (item.kind.value, item.subject_id))) - blocked_actions = tuple(sorted(blocked, key=lambda item: (item.kind.value, item.subject_id))) + selected_actions = tuple( + sorted(selected, key=lambda item: (item.kind.value, item.subject_id)) + ) + blocked_actions = tuple( + sorted(blocked, key=lambda item: (item.kind.value, item.subject_id)) + ) attention_items = tuple(sorted(attention, key=lambda item: item.id)) payload = { "schema_version": 1, @@ -250,16 +266,22 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: if not snapshot.objective_id or snapshot.objective_revision < 1: raise ValidationError("Objective 计划必须使用带 revision 的调度快照") if snapshot.objective_drifted: - action = _action(ActionKind.REPAIR_REQUIRED, snapshot.objective_id, ReasonCode.CONTRACT_DRIFT) + action = _action( + ActionKind.REPAIR_REQUIRED, snapshot.objective_id, ReasonCode.CONTRACT_DRIFT + ) attention = AttentionItem( id=f"repair:{snapshot.objective_id}", kind=AttentionKind.REPAIR_REQUIRED, subject_id=snapshot.objective_id, reason=ReasonCode.CONTRACT_DRIFT, ) - return _build_plan(snapshot, PlanCompletion.REPAIR_REQUIRED, (action,), attention=(attention,)) + return _build_plan( + snapshot, PlanCompletion.REPAIR_REQUIRED, (action,), attention=(attention,) + ) if snapshot.objective_state != "active": - action = _action(ActionKind.PAUSE, snapshot.objective_id, ReasonCode.OBJECTIVE_PAUSED) + action = _action( + ActionKind.PAUSE, snapshot.objective_id, ReasonCode.OBJECTIVE_PAUSED + ) attention = AttentionItem( id=f"paused:{snapshot.objective_id}", kind=AttentionKind.PAUSED, @@ -267,7 +289,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: reason=ReasonCode.OBJECTIVE_PAUSED, facts=_facts(operator_state=snapshot.objective_state), ) - return _build_plan(snapshot, PlanCompletion.INCOMPLETE, (action,), attention=(attention,)) + return _build_plan( + snapshot, PlanCompletion.INCOMPLETE, (action,), attention=(attention,) + ) by_id = snapshot.tasks_by_id target_complete = all( target in by_id @@ -276,7 +300,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: for target in snapshot.objective_targets ) if target_complete: - action = _action(ActionKind.COMPLETE, snapshot.objective_id, ReasonCode.TARGETS_INTEGRATED) + action = _action( + ActionKind.COMPLETE, snapshot.objective_id, ReasonCode.TARGETS_INTEGRATED + ) return _build_plan(snapshot, PlanCompletion.COMPLETE, (action,)) scope = tuple(sorted(set(snapshot.objective_scope) & set(snapshot.candidate_ids))) readiness = build_task_readiness(snapshot, candidate_ids=scope) @@ -289,7 +315,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: ) for task in readiness.ready: if execute_allowed: - selected.append(_action(ActionKind.EXECUTE_TASK, task.id, ReasonCode.TASK_READY)) + selected.append( + _action(ActionKind.EXECUTE_TASK, task.id, ReasonCode.TASK_READY) + ) else: blocked.append( _action( @@ -306,7 +334,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: ) for task in readiness.review: if review_allowed: - selected.append(_action(ActionKind.REVIEW_TASK, task.id, ReasonCode.TASK_REVIEW_READY)) + selected.append( + _action(ActionKind.REVIEW_TASK, task.id, ReasonCode.TASK_REVIEW_READY) + ) else: blocked.append( _action( @@ -320,7 +350,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: for task_id in scope: item = by_id[task_id] if item.status == "waiting_answer": - selected.append(_action(ActionKind.ASK_USER, task_id, ReasonCode.ANSWER_REQUIRED)) + selected.append( + _action(ActionKind.ASK_USER, task_id, ReasonCode.ANSWER_REQUIRED) + ) attention.append( AttentionItem( id=f"answer:{task_id}", @@ -349,7 +381,9 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: ) ) if not selected and not blocked and not attention: - selected.append(_action(ActionKind.WAIT, snapshot.objective_id, ReasonCode.NO_PROGRESS)) + selected.append( + _action(ActionKind.WAIT, snapshot.objective_id, ReasonCode.NO_PROGRESS) + ) return _build_plan( snapshot, PlanCompletion.INCOMPLETE, @@ -367,12 +401,17 @@ def build_scheduler_projection( """Build the single path-free graph payload consumed by all presentation layers.""" if plan.objective_id != snapshot.objective_id: raise ValidationError("计划与快照 Objective 不匹配") + if plan.snapshot_sha256 != snapshot.snapshot_sha256: + raise ValidationError("计划与快照摘要不匹配") nodes: list[SchedulerNode] = [ SchedulerNode( id=f"objective:{snapshot.objective_id}", kind="objective", state=plan.completion.value, - facts=_facts(revision=snapshot.objective_revision, operator_state=snapshot.objective_state), + facts=_facts( + revision=snapshot.objective_revision, + operator_state=snapshot.objective_state, + ), ) ] edges: list[SchedulerEdge] = [] @@ -387,7 +426,9 @@ def build_scheduler_projection( ) ) for dependency in sorted(task.depends_on): - edges.append(SchedulerEdge(f"task:{dependency}", f"task:{task.id}", "requires")) + edges.append( + SchedulerEdge(f"task:{dependency}", f"task:{task.id}", "requires") + ) for decision in sorted(task.blocked_on): decision_id = f"decision:{decision}" nodes.append( @@ -408,7 +449,11 @@ def build_scheduler_projection( facts=action.facts, ) ) - target = f"objective:{snapshot.objective_id}" if action.subject_id == snapshot.objective_id else f"task:{action.subject_id}" + target = ( + f"objective:{snapshot.objective_id}" + if action.subject_id == snapshot.objective_id + else f"task:{action.subject_id}" + ) edges.append(SchedulerEdge(action_id, target, "acts_on")) unique_nodes = {node.id: node for node in nodes} constraints = tuple( @@ -427,7 +472,9 @@ def build_scheduler_projection( blocked=plan.blocked, attention=plan.attention, nodes=tuple(unique_nodes[key] for key in sorted(unique_nodes)), - edges=tuple(sorted(set(edges), key=lambda edge: (edge.source, edge.target, edge.kind))), + edges=tuple( + sorted(set(edges), key=lambda edge: (edge.source, edge.target, edge.kind)) + ), constraints=tuple(sorted(constraints)), facts=plan.facts, ) @@ -441,11 +488,18 @@ def projection_payload(projection: SchedulerReadProjection) -> dict[str, object] "snapshot_sha256": projection.snapshot_sha256, "plan_sha256": projection.plan_sha256, "completion": projection.completion.value, - "selected_actions": [_action_payload(item) for item in projection.selected_actions], + "selected_actions": [ + _action_payload(item) for item in projection.selected_actions + ], "blocked": [_action_payload(item) for item in projection.blocked], "attention": [_attention_payload(item) for item in projection.attention], "nodes": [ - {"id": node.id, "kind": node.kind, "state": node.state, "facts": dict(node.facts)} + { + "id": node.id, + "kind": node.kind, + "state": node.state, + "facts": dict(node.facts), + } for node in projection.nodes ], "edges": [ @@ -467,16 +521,25 @@ def render_plan_text(plan: ContinuationPlan) -> str: f"Snapshot SHA-256: {plan.snapshot_sha256}", f"Plan SHA-256: {plan.plan_sha256}", ] - for label, actions in (("Selected", plan.selected_actions), ("Blocked", plan.blocked)): + for label, actions in ( + ("Selected", plan.selected_actions), + ("Blocked", plan.blocked), + ): for action in actions: - lines.append(f"{label}: {action.kind.value} {action.subject_id} ({action.reason.value})") + lines.append( + f"{label}: {action.kind.value} {action.subject_id} ({action.reason.value})" + ) for item in plan.attention: - lines.append(f"Attention: {item.kind.value} {item.subject_id} ({item.reason.value})") + lines.append( + f"Attention: {item.kind.value} {item.subject_id} ({item.reason.value})" + ) return "\n".join(lines) def render_projection_json(projection: SchedulerReadProjection) -> str: - return json.dumps(projection_payload(projection), ensure_ascii=False, sort_keys=True, indent=2) + return json.dumps( + projection_payload(projection), ensure_ascii=False, sort_keys=True, indent=2 + ) def render_projection_mermaid(projection: SchedulerReadProjection) -> str: @@ -487,5 +550,7 @@ def render_projection_mermaid(projection: SchedulerReadProjection) -> str: lines.append(f' {node_ids[node.id]}["{label}"]') for edge in projection.edges: if edge.source in node_ids and edge.target in node_ids: - lines.append(f" {node_ids[edge.source]} -->|{edge.kind}| {node_ids[edge.target]}") + lines.append( + f" {node_ids[edge.source]} -->|{edge.kind}| {node_ids[edge.target]}" + ) return "\n".join(lines) diff --git a/src/dyro/continuation/resolution.py b/src/dyro/continuation/resolution.py index a9f85a4..156bade 100644 --- a/src/dyro/continuation/resolution.py +++ b/src/dyro/continuation/resolution.py @@ -2,13 +2,17 @@ from __future__ import annotations +from dataclasses import dataclass +from enum import Enum from pathlib import Path +import stat import sys from typing import Callable -from ..config import CONFIG_NAME, Config, load +from ..config import CONFIG_NAME, Config, LoadedProfile, load, load_profile_exact, validate_id from ..errors import DyroError, ValidationError -from ..hub import WorkspaceRecord, load_registry +from ..hub import WorkspaceRecord, load_registry, load_registry_bounded +from ..read_limits import ReadBudget, ReadLimitCode, ReadLimitError from ..tasks import list_tasks, worktree_root from ..workspace import Line, get_line, line_root, list_lines from .store import StoredObjective, get_objective, list_objectives @@ -17,6 +21,36 @@ Chooser = Callable[[str, tuple[str, ...]], str] +class WorkspaceResolutionSource(str, Enum): + EXPLICIT = "explicit" + LOCAL = "local" + DEFAULT = "default" + UNIQUE = "unique" + + +class WorkspaceResolutionFailure(str, Enum): + LOCAL_PROFILE_INVALID = "LOCAL_PROFILE_INVALID" + REGISTRY_INVALID = "REGISTRY_INVALID" + WORKSPACE_NOT_REGISTERED = "WORKSPACE_NOT_REGISTERED" + REGISTERED_ROOT_STALE = "REGISTERED_ROOT_STALE" + HOST_READ_PERMISSION_REQUIRED = "HOST_READ_PERMISSION_REQUIRED" + AMBIGUOUS_WORKSPACE = "AMBIGUOUS_WORKSPACE" + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" + + +class WorkspaceResolutionError(DyroError): + def __init__(self, code: WorkspaceResolutionFailure) -> None: + super().__init__(code.value) + self.code = code + + +@dataclass(frozen=True) +class ResolvedWorkspace: + profile: LoadedProfile + source: WorkspaceResolutionSource + registry_alias: str | None + + def _interactive() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() @@ -93,6 +127,161 @@ def resolve_workspace( return load(next(record.root for record in records if record.name == selected)) +def _readonly_location(start: str | Path | None, cwd: Path) -> Path: + if not cwd.is_absolute(): + raise ValidationError("Bridge cwd 必须是绝对路径") + raw = "." if start is None else str(start) + if not raw or raw.startswith("~") or "\x00" in raw: + raise ValidationError("Bridge start 路径无效") + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = cwd / candidate + try: + location = candidate.resolve(strict=False) + if location.is_file(): + return location.parent + return location + except PermissionError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.HOST_READ_PERMISSION_REQUIRED + ) from exc + except (OSError, RuntimeError) as exc: + raise ValidationError("Bridge start 路径无法解析") from exc + + +def _readonly_local_root(location: Path) -> Path | None: + for candidate in (location, *location.parents): + profile = candidate / CONFIG_NAME + try: + info = profile.lstat() + except FileNotFoundError: + continue + except PermissionError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.HOST_READ_PERMISSION_REQUIRED + ) from exc + except OSError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.LOCAL_PROFILE_INVALID + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.LOCAL_PROFILE_INVALID + ) + return candidate + return None + + +def _bounded_registry(budget: ReadBudget): + try: + return load_registry_bounded(budget) + except ReadLimitError as exc: + if exc.code is ReadLimitCode.UNSAFE_FILE: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.REGISTRY_INVALID + ) from exc + raise + except PermissionError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.HOST_READ_PERMISSION_REQUIRED + ) from exc + except (OSError, ValidationError) as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.REGISTRY_INVALID + ) from exc + + +def _registered_profile(record: WorkspaceRecord, budget: ReadBudget) -> LoadedProfile: + try: + return load_profile_exact(record.root, budget) + except ReadLimitError as exc: + if exc.code is ReadLimitCode.UNSAFE_FILE: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.REGISTERED_ROOT_STALE + ) from exc + raise + except PermissionError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.HOST_READ_PERMISSION_REQUIRED + ) from exc + except (OSError, ValidationError) as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.REGISTERED_ROOT_STALE + ) from exc + + +def resolve_workspace_readonly( + *, + start: str | Path | None, + workspace: str | None, + cwd: Path, + budget: ReadBudget, +) -> ResolvedWorkspace: + """Resolve one workspace without interaction, writes, recovery, or fallback drift.""" + if workspace is not None: + validate_id(workspace, "工作区别名") + registry = _bounded_registry(budget) + matches = tuple(item for item in registry.workspaces if item.name == workspace) + if len(matches) != 1: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.WORKSPACE_NOT_REGISTERED + ) + record = matches[0] + return ResolvedWorkspace( + _registered_profile(record, budget), + WorkspaceResolutionSource.EXPLICIT, + record.name, + ) + + location = _readonly_location(start, cwd) + local_root = _readonly_local_root(location) + if local_root is not None: + try: + profile = load_profile_exact(local_root, budget) + except ReadLimitError as exc: + if exc.code is ReadLimitCode.UNSAFE_FILE: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.LOCAL_PROFILE_INVALID + ) from exc + raise + except PermissionError as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.HOST_READ_PERMISSION_REQUIRED + ) from exc + except (OSError, ValidationError) as exc: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.LOCAL_PROFILE_INVALID + ) from exc + return ResolvedWorkspace(profile, WorkspaceResolutionSource.LOCAL, None) + + registry = _bounded_registry(budget) + if registry.default: + record = next(item for item in registry.workspaces if item.name == registry.default) + return ResolvedWorkspace( + _registered_profile(record, budget), + WorkspaceResolutionSource.DEFAULT, + record.name, + ) + + usable: list[tuple[WorkspaceRecord, LoadedProfile]] = [] + for record in registry.workspaces: + try: + profile = _registered_profile(record, budget) + except WorkspaceResolutionError as exc: + if exc.code is WorkspaceResolutionFailure.REGISTERED_ROOT_STALE: + continue + raise + usable.append((record, profile)) + if len(usable) > 1: + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.AMBIGUOUS_WORKSPACE + ) + if not usable: + raise WorkspaceResolutionError(WorkspaceResolutionFailure.WORKSPACE_NOT_FOUND) + record, profile = usable[0] + return ResolvedWorkspace(profile, WorkspaceResolutionSource.UNIQUE, record.name) + + def _line_from_directory(config: Config, start: Path) -> tuple[Line, ...]: location = start.expanduser().resolve() matches: list[Line] = [] diff --git a/src/dyro/continuation/snapshot.py b/src/dyro/continuation/snapshot.py index 40d6c04..b3ab0b2 100644 --- a/src/dyro/continuation/snapshot.py +++ b/src/dyro/continuation/snapshot.py @@ -158,7 +158,9 @@ def _current_scope( pending.extend(task.depends_on) scope = tuple(sorted(closure)) try: - contracts = tuple((task_id, contract_sha256_by_id[task_id]) for task_id in scope) + contracts = tuple( + (task_id, contract_sha256_by_id[task_id]) for task_id in scope + ) except KeyError: return scope, (), True return scope, contracts, invalid @@ -203,9 +205,12 @@ def build_scheduler_snapshot( observed_at = _utc(clock()) known_tasks = tuple(sorted(graph.known_tasks, key=lambda item: item.id)) known_by_id = {task.id: task for task in known_tasks} - candidate_ids = tuple(sorted( - task.id for task in (known_tasks if candidates is None else tuple(candidates)) - )) + candidate_ids = tuple( + sorted( + task.id + for task in (known_tasks if candidates is None else tuple(candidates)) + ) + ) unknown = sorted(set(candidate_ids) - set(known_by_id)) if unknown: raise ValidationError(f"调度候选不在 TaskGraph 中:{', '.join(unknown)}") @@ -244,7 +249,53 @@ def build_scheduler_snapshot( ) for task in known_tasks ) - decisions = tuple(sorted(graph.decisions.items())) + return build_scheduler_snapshot_from_facts( + tasks=tasks, + decisions=tuple(sorted(graph.decisions.items())), + execution_mode=graph.execution_mode, + candidate_ids=candidate_ids, + objective=objective, + observed_at=observed_at, + ) + + +def build_scheduler_snapshot_from_facts( + *, + tasks: Iterable[SchedulerTaskSnapshot], + decisions: Iterable[tuple[str, str]], + execution_mode: str, + candidate_ids: Iterable[str], + observed_at: datetime, + objective: StoredObjective | None = None, +) -> SchedulerSnapshot: + """Build a scheduler snapshot from one caller-owned, already sampled fact set. + + Machine-facing readers use this entry point after bounded filesystem and Git + observations. It performs no I/O and therefore cannot silently fall back to + the legacy, presentation-oriented loaders. + """ + observed_at = _utc(observed_at) + frozen_tasks = tuple(sorted(tasks, key=lambda item: item.task.id)) + if len({item.task.id for item in frozen_tasks}) != len(frozen_tasks): + raise ValidationError("调度快照 Task ID 不能重复") + frozen_candidates = tuple(sorted(candidate_ids)) + known_by_id = {item.task.id: item.task for item in frozen_tasks} + unknown = sorted(set(frozen_candidates) - set(known_by_id)) + if unknown: + raise ValidationError(f"调度候选不在 Task facts 中:{', '.join(unknown)}") + if len(set(frozen_candidates)) != len(frozen_candidates): + raise ValidationError("调度候选不能重复") + frozen_decisions = tuple(sorted(decisions)) + if len({key for key, _value in frozen_decisions}) != len(frozen_decisions): + raise ValidationError("调度快照 decision ID 不能重复") + if execution_mode not in {"local", "external"}: + raise ValidationError("调度快照 execution mode 无效") + + contract_sha256_by_id = { + item.task.id: item.contract_sha256 + for item in frozen_tasks + if item.contract_sha256 + } task_contracts: tuple[tuple[str, str], ...] = () objective_drifted = False if objective is not None: @@ -262,10 +313,10 @@ def build_scheduler_snapshot( canonical_json_bytes( _payload( observed_at=observed_at, - tasks=tasks, - decisions=decisions, - execution_mode=graph.execution_mode, - candidate_ids=candidate_ids, + tasks=frozen_tasks, + decisions=frozen_decisions, + execution_mode=execution_mode, + candidate_ids=frozen_candidates, objective=objective, task_contracts=task_contracts, objective_drifted=objective_drifted, @@ -274,17 +325,21 @@ def build_scheduler_snapshot( ).hexdigest() return SchedulerSnapshot( observed_at=observed_at, - tasks=tasks, - decisions=decisions, - execution_mode=graph.execution_mode, - candidate_ids=candidate_ids, + tasks=frozen_tasks, + decisions=frozen_decisions, + execution_mode=execution_mode, + candidate_ids=frozen_candidates, snapshot_sha256=digest, objective_id="" if objective is None else objective.objective.id, objective_revision=0 if objective is None else objective.revision, objective_state="" if objective is None else objective.operator_state, objective_scope=() if objective is None else objective.scope, objective_targets=() if objective is None else objective.objective.targets, - objective_requested_mode="" if objective is None else objective.objective.requested_mode.value, - objective_operations=() if objective is None else tuple(item.value for item in objective.objective.operations), + objective_requested_mode="" + if objective is None + else objective.objective.requested_mode.value, + objective_operations=() + if objective is None + else tuple(item.value for item in objective.objective.operations), objective_drifted=objective_drifted, ) diff --git a/src/dyro/continuation/store.py b/src/dyro/continuation/store.py index 41e8e1f..10e2316 100644 --- a/src/dyro/continuation/store.py +++ b/src/dyro/continuation/store.py @@ -18,6 +18,7 @@ from ..config import Config, validate_id from ..errors import DyroError, ValidationError from ..graph import build_task_graph, validate_task_graph +from ..read_limits import ReadBudget from ..state import ( ensure_safe_child_directory, exclusive_directory_lock, @@ -42,8 +43,21 @@ start_action as _start_action, verify_owner_lease as _verify_owner_lease, ) -from .contracts import canonical_contract, contract_sha256, parse_contract, validate_objective_scope -from .budgets import BudgetCaps, BudgetDecision, BudgetDecisionInput, BudgetRequest, BudgetReservation, BudgetUsage, decide_budget +from .contracts import ( + canonical_contract, + contract_sha256, + parse_contract, + validate_objective_scope, +) +from .budgets import ( + BudgetCaps, + BudgetDecision, + BudgetDecisionInput, + BudgetRequest, + BudgetReservation, + BudgetUsage, + decide_budget, +) from .models import ActionKind, Objective, Operation, RequestedMode from .objective_storage import ( OBJECTIVE_STORE_SCHEMA_VERSION, @@ -65,7 +79,9 @@ def _toml_string(value: str) -> str: def _objective_root(config: Config, *, create: bool = True) -> Path: if os.name == "nt": - raise DyroError("Windows 暂不支持安全的 Objective 持久化;拒绝访问以避免 reparse-point 路径逃逸") + raise DyroError( + "Windows 暂不支持安全的 Objective 持久化;拒绝访问以避免 reparse-point 路径逃逸" + ) parent = config.root / ".dyro" if parent.is_symlink(): raise ValidationError(f"Objective 状态父目录不能是符号链接:{parent}") @@ -77,7 +93,9 @@ def _objective_root(config: Config, *, create: bool = True) -> Path: try: ensure_safe_child_directory(config.root, ".dyro") except DyroError as exc: - raise ValidationError(f"Objective 状态父目录必须是安全的普通目录:{parent}") from exc + raise ValidationError( + f"Objective 状态父目录必须是安全的普通目录:{parent}" + ) from exc root = config.objectives_dir if root.is_symlink() or (root.exists() and not root.is_dir()): raise ValidationError(f"Objective 状态目录必须是安全的普通目录:{root}") @@ -85,7 +103,9 @@ def _objective_root(config: Config, *, create: bool = True) -> Path: try: ensure_safe_child_directory(parent, "objectives") except DyroError as exc: - raise ValidationError(f"Objective 状态目录必须是安全的普通目录:{root}") from exc + raise ValidationError( + f"Objective 状态目录必须是安全的普通目录:{root}" + ) from exc return root @@ -193,7 +213,9 @@ def _task_contract_sha256(task: Task) -> str: raise ValidationError(f"无法读取任务 {task.id} contract:{manifest}") from exc -def _scope_for(config: Config, objective: Objective) -> tuple[tuple[str, ...], tuple[tuple[str, str], ...]]: +def _scope_for( + config: Config, objective: Objective +) -> tuple[tuple[str, ...], tuple[tuple[str, str], ...]]: graph = build_task_graph(config, line=objective.line) issues = validate_task_graph(graph) if issues: @@ -216,12 +238,21 @@ def _scope_for(config: Config, objective: Objective) -> tuple[tuple[str, ...], t closure.add(task_id) pending.extend(task.depends_on) scope = tuple(sorted(closure)) - contracts = tuple((task_id, _task_contract_sha256(known[task_id])) for task_id in scope) + contracts = tuple( + (task_id, _task_contract_sha256(known[task_id])) for task_id in scope + ) return scope, contracts -def _scope_sha256(scope: tuple[str, ...], contracts: tuple[tuple[str, str], ...]) -> str: - return _sha256({"scope": list(scope), "task_contract_sha256": [list(item) for item in contracts]}) +def _scope_sha256( + scope: tuple[str, ...], contracts: tuple[tuple[str, str], ...] +) -> str: + return _sha256( + { + "scope": list(scope), + "task_contract_sha256": [list(item) for item in contracts], + } + ) def _record_payload(record: StoredObjective) -> dict[str, object]: @@ -256,7 +287,10 @@ def _record_from_payload( "contract_sha256", }: raise ValidationError(f"Objective 投影结构无效:{directory}") - if type(payload.get("schema_version")) is not int or payload.get("schema_version") != OBJECTIVE_STORE_SCHEMA_VERSION: + if ( + type(payload.get("schema_version")) is not int + or payload.get("schema_version") != OBJECTIVE_STORE_SCHEMA_VERSION + ): raise ValidationError(f"Objective 投影版本无效:{directory}") raw_id = payload.get("id") if not isinstance(raw_id, str): @@ -269,7 +303,11 @@ def _record_from_payload( if not isinstance(operator_state, str) or operator_state not in OPERATOR_STATES: raise ValidationError(f"Objective 操作者状态无效:{directory}") scope_raw = payload.get("scope") - if not isinstance(scope_raw, list) or not scope_raw or not all(isinstance(item, str) for item in scope_raw): + if ( + not isinstance(scope_raw, list) + or not scope_raw + or not all(isinstance(item, str) for item in scope_raw) + ): raise ValidationError(f"Objective scope 无效:{directory}") scope = tuple(validate_id(item, "Objective scope task") for item in scope_raw) if scope != tuple(sorted(scope)) or len(set(scope)) != len(scope): @@ -279,7 +317,11 @@ def _record_from_payload( raise ValidationError(f"Objective task contract 投影无效:{directory}") contracts: list[tuple[str, str]] = [] for item in contracts_raw: - if not isinstance(item, list) or len(item) != 2 or not all(isinstance(part, str) for part in item): + if ( + not isinstance(item, list) + or len(item) != 2 + or not all(isinstance(part, str) for part in item) + ): raise ValidationError(f"Objective task contract 投影无效:{directory}") task_id = validate_id(item[0], "Objective scope task") digest = item[1] @@ -290,17 +332,27 @@ def _record_from_payload( if tuple(task_id for task_id, _ in frozen_contracts) != scope: raise ValidationError(f"Objective scope 与 task contract 不一致:{directory}") scope_sha = payload.get("scope_sha256") - if not isinstance(scope_sha, str) or scope_sha != _scope_sha256(scope, frozen_contracts): + if not isinstance(scope_sha, str) or scope_sha != _scope_sha256( + scope, frozen_contracts + ): raise ValidationError(f"Objective scope 哈希无效:{directory}") contract_sha = payload.get("contract_sha256") - if not isinstance(contract_sha, str) or len(contract_sha) != 64 or any(char not in "0123456789abcdef" for char in contract_sha): + if ( + not isinstance(contract_sha, str) + or len(contract_sha) != 64 + or any(char not in "0123456789abcdef" for char in contract_sha) + ): raise ValidationError(f"Objective contract 哈希无效:{directory}") if contract_content is None: - contract_file = _safe_file(_contract_path(directory, revision), "Objective contract") + contract_file = _safe_file( + _contract_path(directory, revision), "Objective contract" + ) try: contract_content = contract_file.read_bytes() except OSError as exc: - raise ValidationError(f"无法读取 Objective contract:{contract_file}") from exc + raise ValidationError( + f"无法读取 Objective contract:{contract_file}" + ) from exc objective = parse_contract(contract_content) if objective.id != objective_id or contract_sha256(objective) != contract_sha: raise ValidationError(f"Objective contract 与投影不匹配:{directory}") @@ -353,7 +405,9 @@ def _assert_ownership_available( return requested = set(record.scope) for other in list_objectives(config, recover=recover): - if other.objective.id == exclude_id or not _retains_mutation_scope(config, other): + if other.objective.id == exclude_id or not _retains_mutation_scope( + config, other + ): continue overlap = sorted(requested & set(other.scope)) if overlap: @@ -367,13 +421,18 @@ def _assert_no_inflight_tasks(config: Config, record: StoredObjective) -> None: in_flight = [ task_id for task_id in record.scope - if task_id in tasks and task_status(config, tasks[task_id]) in {"assigned", "in_progress"} + if task_id in tasks + and task_status(config, tasks[task_id]) in {"assigned", "in_progress"} ] if in_flight: - raise DyroError(f"存在 reserved/started/running Task,拒绝变更 Objective:{', '.join(in_flight)}") + raise DyroError( + f"存在 reserved/started/running Task,拒绝变更 Objective:{', '.join(in_flight)}" + ) -def create_objective(config: Config, content: str | bytes, *, dry_run: bool = False) -> StoredObjective: +def create_objective( + config: Config, content: str | bytes, *, dry_run: bool = False +) -> StoredObjective: """Accept one Objective contract and pin its TaskGraph-derived scope.""" objective = parse_contract(content) if dry_run: @@ -414,11 +473,15 @@ def create_objective(config: Config, content: str | bytes, *, dry_run: bool = Fa ) -def _list_objectives_unlocked(config: Config, *, recover: bool) -> list[StoredObjective]: +def _list_objectives_unlocked( + config: Config, *, recover: bool +) -> list[StoredObjective]: records: list[StoredObjective] = [] for objective_id in list_objective_ids(config): with open_objective_directory(config, objective_id) as directory: - records.append(_read_stored(config, objective_id, recover=recover, directory=directory)) + records.append( + _read_stored(config, objective_id, recover=recover, directory=directory) + ) return records @@ -432,19 +495,43 @@ def list_objectives(config: Config, *, recover: bool = True) -> list[StoredObjec return _list_objectives_unlocked(config, recover=True) -def get_objective(config: Config, objective_id: str, *, recover: bool = True) -> StoredObjective: +def get_objective( + config: Config, + objective_id: str, + *, + recover: bool = True, + read_budget: ReadBudget | None = None, +) -> StoredObjective: if recover: with _objective_lock(config, create=False): with open_objective_directory(config, objective_id) as directory: - return _read_stored(config, objective_id, recover=True, directory=directory) - with open_objective_directory(config, objective_id) as directory: - return _read_stored(config, objective_id, recover=False, directory=directory) + return _read_stored( + config, + objective_id, + recover=True, + directory=directory, + budget=read_budget, + ) + with open_objective_directory( + config, objective_id, budget=read_budget + ) as directory: + return _read_stored( + config, + objective_id, + recover=False, + directory=directory, + budget=read_budget, + ) -def _require_actionable_objective(config: Config, objective_id: str, directory: ObjectiveDirectory) -> StoredObjective: +def _require_actionable_objective( + config: Config, objective_id: str, directory: ObjectiveDirectory +) -> StoredObjective: record = _read_stored(config, objective_id, directory=directory) if record.operator_state != "active": - raise DyroError("Objective 未处于 active 状态;拒绝取得或使用 Scheduler mutation authority") + raise DyroError( + "Objective 未处于 active 状态;拒绝取得或使用 Scheduler mutation authority" + ) if not record.owns_mutation_scope: raise DyroError("observe Objective 不取得 Scheduler mutation authority") return record @@ -459,7 +546,9 @@ def _assert_action_is_authorized(record: StoredObjective, intent: ActionIntent) or intent.objective_event_sha256 != record.event_sha256 or intent.scope_sha256 != record.scope_sha256 ): - raise DyroError("Action intent 未绑定当前已接受的 Objective revision、事件或 scope") + raise DyroError( + "Action intent 未绑定当前已接受的 Objective revision、事件或 scope" + ) if intent.subject_id not in record.scope: raise DyroError("Action subject 不在当前 Objective mutation scope 内") required_operation = { @@ -467,23 +556,36 @@ def _assert_action_is_authorized(record: StoredObjective, intent: ActionIntent) ActionKind.REVIEW_TASK: Operation.REVIEW, ActionKind.MERGE_TASK: Operation.MERGE, }.get(intent.operation) - if required_operation is None or required_operation not in record.objective.operations: + if ( + required_operation is None + or required_operation not in record.objective.operations + ): raise DyroError("Action operation 未获当前 Objective contract 授权") def _unresolved_actions(directory: ObjectiveDirectory) -> tuple[ActionRecord, ...]: - return tuple(record for record in _list_actions(directory) if record.status is ActionStatus.UNCERTAIN) + return tuple( + record + for record in _list_actions(directory) + if record.status is ActionStatus.UNCERTAIN + ) -def _assert_no_unresolved_actions(directory: ObjectiveDirectory, *, operation: str) -> None: +def _assert_no_unresolved_actions( + directory: ObjectiveDirectory, *, operation: str +) -> None: unresolved = _unresolved_actions(directory) if unresolved: action_ids = ", ".join(record.intent.action_id for record in unresolved) raise DyroError(f"存在 uncertain Action,拒绝 {operation}:{action_ids}") -def _prepared_reserved_action_cancellation(directory: ObjectiveDirectory, *, reason: str) -> dict[str, object] | None: - return _prepare_action_cancellation(directory, summary=reason, now=datetime.now(timezone.utc)) +def _prepared_reserved_action_cancellation( + directory: ObjectiveDirectory, *, reason: str +) -> dict[str, object] | None: + return _prepare_action_cancellation( + directory, summary=reason, now=datetime.now(timezone.utc) + ) def _retains_mutation_scope(config: Config, record: StoredObjective) -> bool: @@ -533,7 +635,9 @@ def renew_objective_owner_lease( with _objective_lock(config): with open_objective_directory(config, objective_id) as directory: _require_actionable_objective(config, objective_id, directory) - return _renew_owner_lease(directory, grant=grant, now=now, ttl_seconds=ttl_seconds) + return _renew_owner_lease( + directory, grant=grant, now=now, ttl_seconds=ttl_seconds + ) def release_objective_owner_lease( @@ -564,11 +668,15 @@ def reserve_objective_action( _assert_no_unresolved_actions(directory, operation="创建下一 Action") lease = _verify_owner_lease(directory, grant=grant, now=now) if intent.owner_generation != lease.generation: - raise DyroError("Action intent owner_generation 与当前 Scheduler lease 不匹配") + raise DyroError( + "Action intent owner_generation 与当前 Scheduler lease 不匹配" + ) return _reserve_action(directory, intent) -def _budget_usage(records: Iterable[ActionRecord], *, objective_id: str | None) -> BudgetUsage: +def _budget_usage( + records: Iterable[ActionRecord], *, objective_id: str | None +) -> BudgetUsage: """Derive conservative committed usage from durable Action records only. A start has crossed the durable side-effect barrier, so it is charged even @@ -578,14 +686,17 @@ def _budget_usage(records: Iterable[ActionRecord], *, objective_id: str | None) next Action look safer than it is. """ selected = tuple( - record for record in records + record + for record in records if objective_id is None or record.intent.objective_id == objective_id ) started = tuple(record for record in selected if record.start is not None) attempts: dict[str, int] = {} for record in started: reservation = record.intent.budget_reservation - attempts[reservation.task_id] = attempts.get(reservation.task_id, 0) + reservation.attempts + attempts[reservation.task_id] = ( + attempts.get(reservation.task_id, 0) + reservation.attempts + ) terminal = tuple( sorted( (record for record in started if record.receipt is not None), @@ -595,11 +706,15 @@ def _budget_usage(records: Iterable[ActionRecord], *, objective_id: str | None) failures = sum( record.intent.budget_reservation.failures for record in terminal - if record.receipt is not None and record.receipt.status in {ActionStatus.FAILED, ActionStatus.UNCERTAIN} + if record.receipt is not None + and record.receipt.status in {ActionStatus.FAILED, ActionStatus.UNCERTAIN} ) consecutive = 0 for record in terminal: - if record.receipt is not None and record.receipt.status in {ActionStatus.FAILED, ActionStatus.UNCERTAIN}: + if record.receipt is not None and record.receipt.status in { + ActionStatus.FAILED, + ActionStatus.UNCERTAIN, + }: consecutive += record.intent.budget_reservation.failures else: consecutive = 0 @@ -637,9 +752,13 @@ def _all_action_records_unlocked(config: Config) -> tuple[ActionRecord, ...]: def _budget_request(intent: ActionIntent) -> BudgetRequest: """Fix the conservative charge for each supported supervised operation.""" if intent.operation is ActionKind.EXECUTE_TASK: - return BudgetRequest(intent.subject_id, actions=1, attempts=1, failures=1, parallel=1) + return BudgetRequest( + intent.subject_id, actions=1, attempts=1, failures=1, parallel=1 + ) if intent.operation is ActionKind.REVIEW_TASK: - return BudgetRequest(intent.subject_id, actions=1, attempts=0, failures=1, parallel=1) + return BudgetRequest( + intent.subject_id, actions=1, attempts=0, failures=1, parallel=1 + ) raise DyroError("受监督执行当前只支持 execute_task 与 review_task") @@ -667,7 +786,9 @@ def reserve_supervised_objective_action( _assert_no_unresolved_actions(directory, operation="创建下一 Action") lease = _verify_owner_lease(directory, grant=grant, now=now) if intent.owner_generation != lease.generation: - raise DyroError("Action intent owner_generation 与当前 Scheduler lease 不匹配") + raise DyroError( + "Action intent owner_generation 与当前 Scheduler lease 不匹配" + ) request = _budget_request(intent) decision = decide_budget( BudgetDecisionInput( @@ -684,7 +805,9 @@ def reserve_supervised_objective_action( ) ) if intent.budget_reservation != decision.reservation: - raise DyroError("Action intent budget_reservation 未使用受监督操作的固定保守预算") + raise DyroError( + "Action intent budget_reservation 未使用受监督操作的固定保守预算" + ) if not decision.allowed: reasons = ", ".join(reason.value for reason in decision.reasons) raise DyroError(f"Objective 预算拒绝此 Action:{reasons}") @@ -703,7 +826,9 @@ def start_objective_action( with _objective_lock(config): with open_objective_directory(config, objective_id) as directory: record = _require_actionable_objective(config, objective_id, directory) - _assert_action_is_authorized(record, _read_action(directory, action_id).intent) + _assert_action_is_authorized( + record, _read_action(directory, action_id).intent + ) return _start_action(directory, action_id=action_id, grant=grant, now=now) @@ -721,13 +846,17 @@ def record_objective_action_receipt( return _record_action_receipt(directory, receipt, grant=grant, now=now) -def list_objective_actions(config: Config, objective_id: str) -> tuple[ActionRecord, ...]: +def list_objective_actions( + config: Config, objective_id: str +) -> tuple[ActionRecord, ...]: with _objective_lock(config, create=False): with open_objective_directory(config, objective_id) as directory: return _list_actions(directory) -def get_objective_action(config: Config, objective_id: str, action_id: str) -> ActionRecord: +def get_objective_action( + config: Config, objective_id: str, action_id: str +) -> ActionRecord: with _objective_lock(config, create=False): with open_objective_directory(config, objective_id) as directory: return _read_action(directory, action_id) @@ -768,7 +897,9 @@ def _persist_revision( ) -def reconcile_objective(config: Config, objective_id: str, *, dry_run: bool = False) -> StoredObjective: +def reconcile_objective( + config: Config, objective_id: str, *, dry_run: bool = False +) -> StoredObjective: if dry_run: current = get_objective(config, objective_id, recover=False) if current.operator_state == "stopped": @@ -783,14 +914,18 @@ def reconcile_objective(config: Config, objective_id: str, *, dry_run: bool = Fa event_seq=current.event_seq, event_sha256=current.event_sha256, ) - _assert_ownership_available(config, candidate, exclude_id=current.objective.id, recover=False) + _assert_ownership_available( + config, candidate, exclude_id=current.objective.id, recover=False + ) _assert_no_inflight_tasks(config, candidate) return candidate with _objective_mutation_lock(config): with open_objective_directory(config, objective_id) as directory: current = _read_stored(config, objective_id, directory=directory) if current.operator_state == "stopped": - raise DyroError("已停止的 Objective 不能 reconcile;请创建新的 Objective") + raise DyroError( + "已停止的 Objective 不能 reconcile;请创建新的 Objective" + ) return _persist_revision( config, current, @@ -802,7 +937,9 @@ def reconcile_objective(config: Config, objective_id: str, *, dry_run: bool = Fa def _with_targets(objective: Objective, targets: Iterable[str]) -> Objective: - target_set = tuple(sorted({validate_id(target, "Objective target") for target in targets})) + target_set = tuple( + sorted({validate_id(target, "Objective target") for target in targets}) + ) if not target_set: raise ValidationError("Objective 必须至少保留一个 target") return Objective( @@ -818,15 +955,29 @@ def _with_targets(objective: Objective, targets: Iterable[str]) -> Objective: ) -def add_objective_target(config: Config, objective_id: str, task_id: str, *, dry_run: bool = False) -> StoredObjective: +def add_objective_target( + config: Config, objective_id: str, task_id: str, *, dry_run: bool = False +) -> StoredObjective: if dry_run: current = get_objective(config, objective_id, recover=False) if current.operator_state == "stopped": raise DyroError("已停止的 Objective 不能调整 scope") - updated = _with_targets(current.objective, (*current.objective.targets, task_id)) + updated = _with_targets( + current.objective, (*current.objective.targets, task_id) + ) scope, contracts = _scope_for(config, updated) - candidate = _make_record(updated, revision=current.revision + 1, operator_state=current.operator_state, scope=scope, contracts=contracts, event_seq=current.event_seq, event_sha256=current.event_sha256) - _assert_ownership_available(config, candidate, exclude_id=current.objective.id, recover=False) + candidate = _make_record( + updated, + revision=current.revision + 1, + operator_state=current.operator_state, + scope=scope, + contracts=contracts, + event_seq=current.event_seq, + event_sha256=current.event_sha256, + ) + _assert_ownership_available( + config, candidate, exclude_id=current.objective.id, recover=False + ) _assert_no_inflight_tasks(config, candidate) return candidate with _objective_mutation_lock(config): @@ -834,7 +985,9 @@ def add_objective_target(config: Config, objective_id: str, task_id: str, *, dry current = _read_stored(config, objective_id, directory=directory) if current.operator_state == "stopped": raise DyroError("已停止的 Objective 不能调整 scope") - updated = _with_targets(current.objective, (*current.objective.targets, task_id)) + updated = _with_targets( + current.objective, (*current.objective.targets, task_id) + ) return _persist_revision( config, current, @@ -845,17 +998,32 @@ def add_objective_target(config: Config, objective_id: str, task_id: str, *, dry ) -def remove_objective_target(config: Config, objective_id: str, task_id: str, *, dry_run: bool = False) -> StoredObjective: +def remove_objective_target( + config: Config, objective_id: str, task_id: str, *, dry_run: bool = False +) -> StoredObjective: if dry_run: current = get_objective(config, objective_id, recover=False) if current.operator_state == "stopped": raise DyroError("已停止的 Objective 不能调整 scope") if task_id not in current.objective.targets: raise DyroError(f"Objective target 不存在:{task_id}") - updated = _with_targets(current.objective, (item for item in current.objective.targets if item != task_id)) + updated = _with_targets( + current.objective, + (item for item in current.objective.targets if item != task_id), + ) scope, contracts = _scope_for(config, updated) - candidate = _make_record(updated, revision=current.revision + 1, operator_state=current.operator_state, scope=scope, contracts=contracts, event_seq=current.event_seq, event_sha256=current.event_sha256) - _assert_ownership_available(config, candidate, exclude_id=current.objective.id, recover=False) + candidate = _make_record( + updated, + revision=current.revision + 1, + operator_state=current.operator_state, + scope=scope, + contracts=contracts, + event_seq=current.event_seq, + event_sha256=current.event_sha256, + ) + _assert_ownership_available( + config, candidate, exclude_id=current.objective.id, recover=False + ) _assert_no_inflight_tasks(config, candidate) return candidate with _objective_mutation_lock(config): @@ -865,7 +1033,10 @@ def remove_objective_target(config: Config, objective_id: str, task_id: str, *, raise DyroError("已停止的 Objective 不能调整 scope") if task_id not in current.objective.targets: raise DyroError(f"Objective target 不存在:{task_id}") - updated = _with_targets(current.objective, (item for item in current.objective.targets if item != task_id)) + updated = _with_targets( + current.objective, + (item for item in current.objective.targets if item != task_id), + ) return _persist_revision( config, current, @@ -876,13 +1047,17 @@ def remove_objective_target(config: Config, objective_id: str, task_id: str, *, ) -def _transition_objective(config: Config, objective_id: str, next_state: str, *, dry_run: bool = False) -> StoredObjective: +def _transition_objective( + config: Config, objective_id: str, next_state: str, *, dry_run: bool = False +) -> StoredObjective: def transition(directory: ObjectiveDirectory) -> StoredObjective: current = _read_stored(config, objective_id, directory=directory) if next_state == "active" and current.operator_state == "stopped": raise DyroError("已停止的 Objective 不能恢复;请创建新的 Objective") if next_state == "active" and drifted_objective(config, current): - raise DyroError("Objective contract 或 scope 已漂移;请先运行 objective reconcile") + raise DyroError( + "Objective contract 或 scope 已漂移;请先运行 objective reconcile" + ) if current.operator_state == next_state: return current candidate = _make_record( @@ -895,7 +1070,9 @@ def transition(directory: ObjectiveDirectory) -> StoredObjective: event_sha256=current.event_sha256, ) if next_state == "active": - _assert_ownership_available(config, candidate, exclude_id=current.objective.id) + _assert_ownership_available( + config, candidate, exclude_id=current.objective.id + ) _assert_no_inflight_tasks(config, candidate) cancellation = ( _prepared_reserved_action_cancellation( @@ -917,7 +1094,9 @@ def transition(directory: ObjectiveDirectory) -> StoredObjective: if next_state == "active" and current.operator_state == "stopped": raise DyroError("已停止的 Objective 不能恢复;请创建新的 Objective") if next_state == "active" and drifted_objective(config, current): - raise DyroError("Objective contract 或 scope 已漂移;请先运行 objective reconcile") + raise DyroError( + "Objective contract 或 scope 已漂移;请先运行 objective reconcile" + ) if current.operator_state == next_state: return current candidate = _make_record( @@ -930,7 +1109,9 @@ def transition(directory: ObjectiveDirectory) -> StoredObjective: event_sha256=current.event_sha256, ) if next_state == "active": - _assert_ownership_available(config, candidate, exclude_id=current.objective.id, recover=False) + _assert_ownership_available( + config, candidate, exclude_id=current.objective.id, recover=False + ) _assert_no_inflight_tasks(config, candidate) return candidate with _objective_mutation_lock(config): @@ -938,15 +1119,21 @@ def transition(directory: ObjectiveDirectory) -> StoredObjective: return transition(directory) -def pause_objective(config: Config, objective_id: str, *, dry_run: bool = False) -> StoredObjective: +def pause_objective( + config: Config, objective_id: str, *, dry_run: bool = False +) -> StoredObjective: return _transition_objective(config, objective_id, "paused", dry_run=dry_run) -def resume_objective(config: Config, objective_id: str, *, dry_run: bool = False) -> StoredObjective: +def resume_objective( + config: Config, objective_id: str, *, dry_run: bool = False +) -> StoredObjective: return _transition_objective(config, objective_id, "active", dry_run=dry_run) -def stop_objective(config: Config, objective_id: str, *, dry_run: bool = False) -> StoredObjective: +def stop_objective( + config: Config, objective_id: str, *, dry_run: bool = False +) -> StoredObjective: return _transition_objective(config, objective_id, "stopped", dry_run=dry_run) @@ -963,7 +1150,10 @@ def derive_objective_result(config: Config, record: StoredObjective) -> str: if drifted_objective(config, record): return "repair_required" tasks = {task.id: task for task in list_tasks(config)} - if all(task_status(config, tasks[target]) == "done" for target in record.objective.targets): + if all( + task_status(config, tasks[target]) == "done" + for target in record.objective.targets + ): from ..tasks import _assert_dependency_integrated try: @@ -982,7 +1172,9 @@ def assert_legacy_scheduler_allowed(config: Config, task_ids: Iterable[str]) -> return with _objective_lock(config, create=False): for record in _list_objectives_unlocked(config, recover=True): - if _retains_mutation_scope(config, record) and requested & set(record.scope): + if _retains_mutation_scope(config, record) and requested & set( + record.scope + ): raise DyroError( f"任务位于受保护 Objective {record.objective.id} 的 mutation scope;" "请使用 plan-only Objective 命令,旧 task loop/daemon 不能绕过 ownership" @@ -990,7 +1182,9 @@ def assert_legacy_scheduler_allowed(config: Config, task_ids: Iterable[str]) -> @contextmanager -def legacy_scheduler_reservation(config: Config, task_ids: Iterable[str]) -> Iterator[None]: +def legacy_scheduler_reservation( + config: Config, task_ids: Iterable[str] +) -> Iterator[None]: """Hold the Objective fence through one automated Task reservation. Manual ``task run`` remains an explicit operator action. Old loop/daemon diff --git a/src/dyro/home.py b/src/dyro/home.py index d621f11..273e722 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -1203,30 +1203,65 @@ def _ask_line_repositories(config: Config) -> tuple[str, ...] | object | None: if choice is not custom_choice: return repositories print("\n可选仓库:") - for repo_id in repositories: - print(f" - {repo_id}") + for index, repo_id in enumerate(repositories, start=1): + print(f" {index}) {repo_id}") while True: - raw = input("输入受影响的仓库 ID(逗号分隔;b 上一步,q 取消):").strip() + raw = input( + "输入受影响的仓库序号或 ID(逗号分隔,如 1,3 或 miniapp;b 上一步,q 取消):" + ).strip() if raw.lower() in {"q", "quit"}: print("已取消;没有修改任何 Git 工作区。") return None if raw.lower() in {"b", "back", "返回"}: return _BACK - selected = tuple( - dict.fromkeys(item.strip() for item in raw.split(",") if item.strip()) - ) - if not selected: - print("至少选择一个仓库,或输入 q 取消。") - continue - unknown = [ - repo_id for repo_id in selected if repo_id not in config.repositories - ] - if unknown: - print(f"未配置的仓库:{'、'.join(unknown)}。请重新选择。") + selected, error = _parse_repository_selection(raw, repositories) + if error is not None: + print(error) continue return selected +def _parse_repository_selection( + raw: str, repositories: tuple[str, ...] +) -> tuple[tuple[str, ...] | None, str | None]: + """Resolve comma-separated indices and/or repository IDs. + + Exact repository ID matches win over 1-based indices so pure-numeric IDs + are not silently reinterpreted as list positions. + + Returns ``(selected_ids, None)`` on success, or ``(None, error_message)``. + """ + tokens = [ + item.strip() + for item in raw.replace(",", ",").split(",") + if item.strip() + ] + if not tokens: + return None, "至少选择一个仓库,或输入 q 取消。" + selected: list[str] = [] + unknown: list[str] = [] + for token in tokens: + # Prefer exact repository ID matches so pure-numeric IDs are not + # silently reinterpreted as 1-based list indices. + if token in repositories: + selected.append(token) + continue + if token.isdigit(): + index = int(token) + if 1 <= index <= len(repositories): + selected.append(repositories[index - 1]) + else: + return ( + None, + f"序号超出范围:{token}(有效 1–{len(repositories)})。请重新选择。", + ) + continue + unknown.append(token) + if unknown: + return None, f"未配置的仓库:{'、'.join(unknown)}。请重新选择。" + return tuple(dict.fromkeys(selected)), None + + def _ask_line_base(config: Config) -> str | object | None: manual_choice = HomeChoice("", "其他:输入分支、tag 或 commit SHA") choice = _ask_choice( diff --git a/src/dyro/hub.py b/src/dyro/hub.py index becf70a..bffc792 100644 --- a/src/dyro/hub.py +++ b/src/dyro/hub.py @@ -8,6 +8,7 @@ from .config import Config, load, validate_id from .errors import DyroError, ValidationError +from .read_limits import ReadBudget, ReadLimitCode, ReadLimitError from .state import atomic_write_text, exclusive_lock @@ -64,7 +65,7 @@ def _registry_lock_path() -> Path: return registry_home() / REGISTRY_LOCK -def _record_from_json(raw: object, *, index: int) -> WorkspaceRecord: +def _record_from_json(raw: object, *, index: int, expand_home: bool = True) -> WorkspaceRecord: if not isinstance(raw, dict): raise ValidationError(f"全局工作区记录第 {index} 项必须是对象") expected = {"name", "root", "last_kind", "last_target", "last_agent"} @@ -80,10 +81,13 @@ def _record_from_json(raw: object, *, index: int) -> WorkspaceRecord: root_raw = raw.get("root") if not isinstance(root_raw, str) or not root_raw or "\x00" in root_raw: raise ValidationError(f"全局工作区 {name} 的路径无效") - root = Path(root_raw).expanduser() + if not expand_home and root_raw.startswith("~"): + raise ValidationError(f"全局工作区 {name} 的路径禁止 home expansion") + root = Path(root_raw).expanduser() if expand_home else Path(os.path.normpath(root_raw)) if not root.is_absolute(): raise ValidationError(f"全局工作区 {name} 必须使用绝对路径") - root = root.resolve() + if expand_home: + root = root.resolve() last_kind = raw.get("last_kind", "") last_target = raw.get("last_target", "") last_agent = raw.get("last_agent", "") @@ -102,18 +106,13 @@ def _record_from_json(raw: object, *, index: int) -> WorkspaceRecord: return WorkspaceRecord(name, root, last_kind, last_target, last_agent) -def load_registry() -> WorkspaceRegistry: - path = _registry_path() - if not path.exists() and not path.is_symlink(): - return WorkspaceRegistry() - if path.is_symlink() or not path.is_file(): - raise ValidationError(f"全局工作区记录不是安全的普通文件:{path}") - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise ValidationError( - f"无法读取全局工作区记录 {path};请修复或备份后移走该文件" - ) from exc +def _registry_from_json( + raw: object, + *, + path: Path, + expand_home: bool, + maximum_records: int | None = None, +) -> WorkspaceRegistry: if ( not isinstance(raw, dict) or raw.get("schema_version") != REGISTRY_SCHEMA_VERSION @@ -125,8 +124,13 @@ def load_registry() -> WorkspaceRegistry: entries = raw.get("workspaces", []) if not isinstance(default, str) or not isinstance(entries, list): raise ValidationError(f"全局工作区记录结构无效:{path}") + if maximum_records is not None and len(entries) > maximum_records: + raise ReadLimitError( + ReadLimitCode.RECORD_LIMIT_EXCEEDED, + "Global workspace registry record limit exceeded", + ) workspaces = tuple( - _record_from_json(entry, index=index) + _record_from_json(entry, index=index, expand_home=expand_home) for index, entry in enumerate(entries, start=1) ) names = [record.name for record in workspaces] @@ -140,6 +144,52 @@ def load_registry() -> WorkspaceRegistry: return WorkspaceRegistry(default, workspaces) +def load_registry() -> WorkspaceRegistry: + path = _registry_path() + if not path.exists() and not path.is_symlink(): + return WorkspaceRegistry() + if path.is_symlink() or not path.is_file(): + raise ValidationError(f"全局工作区记录不是安全的普通文件:{path}") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, RecursionError) as exc: + raise ValidationError( + f"无法读取全局工作区记录 {path};请修复或备份后移走该文件" + ) from exc + return _registry_from_json(raw, path=path, expand_home=True) + + +def load_registry_bounded(budget: ReadBudget) -> WorkspaceRegistry: + """Load the registry without locks, writes, home expansion, or unbounded I/O.""" + path = _registry_path() + try: + path.lstat() + except FileNotFoundError: + return WorkspaceRegistry() + try: + canonical_home = path.parent.resolve(strict=True) + content = budget.read_regular_bytes_at( + root=canonical_home, + directory=canonical_home, + name=path.name, + maximum_bytes=budget.limits.registry_bytes, + label="global workspace registry", + ) + raw = json.loads(content.decode("utf-8")) + except ReadLimitError: + raise + except PermissionError: + raise + except (OSError, UnicodeError, json.JSONDecodeError, RecursionError) as exc: + raise ValidationError("无法读取全局工作区记录") from exc + return _registry_from_json( + raw, + path=path, + expand_home=False, + maximum_records=budget.limits.registry_records, + ) + + def _registry_json(registry: WorkspaceRegistry) -> str: payload = { "schema_version": REGISTRY_SCHEMA_VERSION, diff --git a/src/dyro/integrations/__init__.py b/src/dyro/integrations/__init__.py new file mode 100644 index 0000000..2099b33 --- /dev/null +++ b/src/dyro/integrations/__init__.py @@ -0,0 +1,23 @@ +"""Host integration installation surfaces.""" + +from .manager import ( + AvatarStatus, + IntegrationPlan, + IntegrationState, + IntegrationStatus, + install_integration, + integration_status, + plan_integration, + uninstall_integration, +) + +__all__ = [ + "AvatarStatus", + "IntegrationPlan", + "IntegrationState", + "IntegrationStatus", + "install_integration", + "integration_status", + "plan_integration", + "uninstall_integration", +] diff --git a/src/dyro/integrations/assets/__init__.py b/src/dyro/integrations/assets/__init__.py new file mode 100644 index 0000000..b5cc899 --- /dev/null +++ b/src/dyro/integrations/assets/__init__.py @@ -0,0 +1 @@ +"""Packaged assets for optional host integrations.""" diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md new file mode 100644 index 0000000..87b23d4 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -0,0 +1,30 @@ +--- +name: dyro-control-plane +description: Inspect a Dyro workspace and prepare bounded read-only plans from Codex. Use when a request asks Codex to discover registered Dyro workspaces, inspect workspace state, or explain and plan an existing Dyro Objective without executing delivery operations. +--- + +# Dyro Control Plane + +Treat Dyro as the delivery control plane. Observe facts and prepare a plan; leave every state-changing action to the user in Dyro. + +## Workflow + +1. Observe before planning. + - From any directory, run `dyro workspace list` to discover registered workspaces. + - Use `dyro --workspace status` for a human-readable, read-only view. +2. Inspect one workspace. + - Supply an explicit workspace alias when multiple workspaces exist. + - Use `dyro --workspace objective list` and `dyro --workspace objective status ` for Objective facts. + - Treat partial or unavailable observations as unknown, never as ready. +3. Plan without executing. + - Use `dyro --workspace objective plan ` only for an existing Objective ID. + - Present the returned plan, warnings, blockers, and any confirmation digest to the user. + - Ask the user to return to Dyro to approve and execute any next action. + +## Safety Boundary + +- Do not run or imitate `dispatch`, `objective apply`, task execution, merge, push, release, or publish. +- Do not edit Dyro state files or manufacture approval/confirmation fields. +- Do not infer final readiness from summaries, missing integration inspection, or partial data. +- If the requested operation is unavailable, explain the limitation and give the exact read-only Dyro command the user can run next. +- End with a concise observation and plan, then identify the user-controlled Dyro action required to continue. diff --git a/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml b/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml new file mode 100644 index 0000000..7d8baee --- /dev/null +++ b/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dyro Control Plane" + short_description: "Safely inspect and plan Dyro work from Codex" + default_prompt: "Use $dyro-control-plane to inspect this Dyro workspace and prepare a read-only objective plan." diff --git a/src/dyro/integrations/manager.py b/src/dyro/integrations/manager.py new file mode 100644 index 0000000..e44be91 --- /dev/null +++ b/src/dyro/integrations/manager.py @@ -0,0 +1,1335 @@ +"""Safe mirror + avatar installation of the Dyro control-plane Skill.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import hashlib +import json +import os +from pathlib import Path +import shutil +import tempfile +from typing import Mapping + +from ..errors import DyroError, ValidationError +from ..hub import registry_home +from ..state import atomic_write_text, exclusive_lock, fsync_directory + + +CANONICAL_INTEGRATION_ID = "skill" +LEGACY_INTEGRATION_ID = "codex" +SKILL_NAME = "dyro-control-plane" +ASSET_VERSION = 1 +MANIFEST_SCHEMA_VERSION = 2 +LEGACY_MANIFEST_SCHEMA_VERSION = 1 +_SHA256_PREFIX = "sha256:" + + +class IntegrationState(str, Enum): + ABSENT = "absent" + CURRENT = "current" + OUTDATED = "outdated" + DRIFTED = "drifted" + UNOWNED_CONFLICT = "unowned_conflict" + STALE_MANIFEST = "stale_manifest" + RECOVERY_REQUIRED = "recovery_required" + + +@dataclass(frozen=True) +class HostSpec: + host_id: str + env_var: str | None + default_dirname: str + + +HOSTS: tuple[HostSpec, ...] = ( + HostSpec("codex", "CODEX_HOME", ".codex"), + HostSpec("claude", "CLAUDE_HOME", ".claude"), + HostSpec("agents", "AGENTS_HOME", ".agents"), + HostSpec("cursor", "CURSOR_HOME", ".cursor"), +) + + +@dataclass(frozen=True) +class AvatarStatus: + host: str + path: Path + state: str + detail: str + + +@dataclass(frozen=True) +class IntegrationStatus: + integration: str + state: IntegrationState + target: Path + manifest: Path + detail: str + avatars: tuple[AvatarStatus, ...] = () + + +@dataclass(frozen=True) +class IntegrationPlan: + action: str + status: IntegrationStatus + changes: tuple[str, ...] + + +def _asset_root() -> Path: + return Path(__file__).parent / "assets" / SKILL_NAME + + +def _absolute_path(value: Path, label: str) -> Path: + expanded = value.expanduser() + if not expanded.is_absolute(): + raise ValidationError(f"{label} 必须是绝对路径:{value}") + return Path(os.path.normpath(expanded)) + + +def _dyro_home(override: Path | None) -> Path: + if override is not None: + return _absolute_path(override, "Dyro home") + return registry_home() + + +def _user_home() -> Path: + raw = os.environ.get("HOME", "").strip() + if raw: + return _absolute_path(Path(raw), "HOME") + return Path.home() + + +def _normalize_integration(integration: str) -> str: + if integration in {CANONICAL_INTEGRATION_ID, LEGACY_INTEGRATION_ID}: + return CANONICAL_INTEGRATION_ID + raise ValidationError(f"未知 Integration:{integration}") + + +def _avatar_kind() -> str: + return "junction" if os.name == "nt" else "symlink" + + +def _mirror_path(dyro_home: Path | None) -> Path: + home = _dyro_home(dyro_home) + mirror = home / "skills" / SKILL_NAME + if mirror.parent.parent != home or mirror.name != SKILL_NAME: + raise ValidationError("Skill 镜像路径越界") + return mirror + + +def _state_paths(dyro_home: Path | None) -> tuple[Path, Path, Path, Path]: + home = _dyro_home(dyro_home) + state_dir = home / "integrations" + return ( + state_dir / f"{CANONICAL_INTEGRATION_ID}.json", + state_dir / f"{CANONICAL_INTEGRATION_ID}.transaction.json", + state_dir / f"{CANONICAL_INTEGRATION_ID}.lock", + state_dir / f"{LEGACY_INTEGRATION_ID}.json", + ) + + +def _host_home( + spec: HostSpec, overrides: Mapping[str, Path] | None +) -> Path | None: + if overrides is not None and spec.host_id in overrides: + return _absolute_path(overrides[spec.host_id], f"{spec.host_id} home") + if spec.env_var: + raw = os.environ.get(spec.env_var, "").strip() + if raw: + return _absolute_path(Path(raw), spec.env_var) + candidate = _user_home() / spec.default_dirname + if candidate.exists() and candidate.is_dir() and not candidate.is_symlink(): + unsafe = _symlink_component(candidate) + if unsafe is None: + return candidate + return None + + +def _avatar_path(host_home: Path) -> Path: + target = host_home / "skills" / SKILL_NAME + if target.parent.parent != host_home or target.name != SKILL_NAME: + raise ValidationError("Skill 分身路径越界") + return target + + +def _sha256(content: bytes) -> str: + return _SHA256_PREFIX + hashlib.sha256(content).hexdigest() + + +def _inventory(root: Path) -> dict[str, str]: + if root.is_symlink() or not root.is_dir(): + raise ValidationError(f"Skill 镜像必须是普通目录:{root}") + files: dict[str, str] = {} + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ValidationError(f"Skill 镜像禁止 symlink:{path}") + if path.is_dir(): + continue + if not path.is_file(): + raise ValidationError(f"Skill 资产必须是普通文件:{path}") + relative = path.relative_to(root).as_posix() + files[relative] = _sha256(path.read_bytes()) + if not files: + raise ValidationError("Skill 资产不能为空") + return files + + +def _asset_inventory() -> dict[str, str]: + return _inventory(_asset_root()) + + +def _asset_digest(files: Mapping[str, str]) -> str: + payload = json.dumps( + files, ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + return _sha256(payload.encode("utf-8")) + + +def _trusted_system_symlink(path: Path) -> bool: + """Allow only the conventional macOS /tmp and /var compatibility aliases.""" + if os.name == "nt" or path.as_posix() not in {"/tmp", "/var"}: + return False + try: + return path.resolve(strict=True).as_posix() in {"/private/tmp", "/private/var"} + except OSError: + return False + + +def _symlink_component(path: Path, *, boundary: Path | None = None) -> Path | None: + if boundary is not None and path != boundary and boundary not in path.parents: + raise ValidationError(f"安全路径检查越界:{path}") + current = Path(path.anchor) + for part in path.parts[1:]: + current /= part + if current.is_symlink() and not _trusted_system_symlink(current): + return current + if current.exists() and not current.is_dir(): + return current + return None + + +def _safe_existing_directory( + path: Path, label: str, *, boundary: Path | None = None +) -> None: + unsafe = _symlink_component(path, boundary=boundary) + if unsafe is not None: + raise DyroError(f"{label} 包含 symlink 或非目录路径组件:{unsafe}") + if path.exists() and not path.is_dir(): + raise DyroError(f"{label} 必须是目录:{path}") + + +def _ensure_safe_directory(path: Path, label: str, *, boundary: Path) -> None: + if path != boundary and boundary not in path.parents: + raise ValidationError(f"{label} 路径越界:{path}") + unsafe_boundary = _symlink_component(boundary) + if unsafe_boundary is not None: + raise DyroError(f"{label} 包含 symlink 或非目录路径组件:{unsafe_boundary}") + if not boundary.exists(): + boundary.mkdir(mode=0o700, parents=True) + _safe_existing_directory(boundary, label, boundary=boundary) + current = boundary + for part in path.relative_to(boundary).parts: + current /= part + if current.is_symlink(): + raise DyroError(f"{label} 禁止 symlink:{current}") + if current.exists(): + _safe_existing_directory(current, label, boundary=boundary) + continue + try: + current.mkdir(mode=0o700) + except FileExistsError: + pass + _safe_existing_directory(current, label, boundary=boundary) + _safe_existing_directory(path, label, boundary=boundary) + + +def _is_link(path: Path) -> bool: + if path.is_symlink(): + return True + if os.name != "nt" or not path.exists(): + return False + try: + return path.resolve() != path + except OSError: + return False + + +def _resolves_to(path: Path, expected: Path) -> bool: + try: + return path.resolve() == expected.resolve() + except OSError: + return False + + +def _create_avatar_link(avatar: Path, mirror: Path) -> str: + kind = _avatar_kind() + if avatar.exists() or avatar.is_symlink(): + raise DyroError(f"Skill 分身路径已存在:{avatar}") + if os.name == "nt": + import subprocess + + completed = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(avatar), str(mirror)], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise DyroError( + "创建 Skill 分身 junction 失败:" + f"{completed.stderr.strip() or completed.stdout.strip() or completed.returncode}" + ) + else: + avatar.symlink_to(mirror, target_is_directory=True) + if not _resolves_to(avatar, mirror): + raise DyroError(f"Skill 分身未指向镜像:{avatar}") + return kind + + +def _remove_avatar_link(avatar: Path) -> None: + if avatar.is_symlink() or (os.name == "nt" and _is_link(avatar)): + avatar.unlink() + return + if avatar.exists(): + raise DyroError(f"拒绝删除非 Dyro 分身路径:{avatar}") + + +def _manifest_payload( + mirror: Path, + files: Mapping[str, str], + avatars: Mapping[str, Mapping[str, str]], +) -> dict[str, object]: + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "integration": CANONICAL_INTEGRATION_ID, + "asset_version": ASSET_VERSION, + "asset_digest": _asset_digest(files), + "mirror": str(mirror), + "files": dict(sorted(files.items())), + "avatars": { + host: {"path": meta["path"], "kind": meta["kind"]} + for host, meta in sorted(avatars.items()) + }, + } + + +def _validate_file_map(files: object) -> dict[str, str]: + if not isinstance(files, dict) or not files: + raise ValidationError("Integration ownership manifest 文件清单无效") + validated: dict[str, str] = {} + for name, digest in files.items(): + candidate = Path(name) if isinstance(name, str) else Path("/") + if ( + not isinstance(name, str) + or candidate.is_absolute() + or ".." in candidate.parts + or candidate.as_posix() != name + or not isinstance(digest, str) + or len(digest) != 71 + or not digest.startswith(_SHA256_PREFIX) + ): + raise ValidationError("Integration ownership manifest 文件记录无效") + validated[name] = digest + return validated + + +def _parse_manifest(path: Path) -> dict[str, object]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValidationError("Integration ownership manifest 无法读取") from exc + if not isinstance(raw, dict): + raise ValidationError("Integration ownership manifest 必须是 JSON object") + if raw.get("schema_version") != MANIFEST_SCHEMA_VERSION: + raise ValidationError("Integration ownership manifest schema 不受支持") + expected = { + "schema_version", + "integration", + "asset_version", + "asset_digest", + "mirror", + "files", + "avatars", + } + if set(raw) != expected: + raise ValidationError("Integration ownership manifest 字段不匹配") + if raw["integration"] != CANONICAL_INTEGRATION_ID: + raise ValidationError("Integration ownership manifest 主体不匹配") + if not isinstance(raw["asset_version"], int) or raw["asset_version"] < 1: + raise ValidationError("Integration ownership manifest asset version 无效") + if not isinstance(raw["mirror"], str) or not Path(raw["mirror"]).is_absolute(): + raise ValidationError("Integration ownership manifest mirror 无效") + files = _validate_file_map(raw["files"]) + digest = raw["asset_digest"] + if not isinstance(digest, str) or digest != _asset_digest(files): + raise ValidationError("Integration ownership manifest digest 不匹配") + avatars = raw["avatars"] + if not isinstance(avatars, dict): + raise ValidationError("Integration ownership manifest avatars 无效") + for host, meta in avatars.items(): + if ( + not isinstance(host, str) + or not host + or not isinstance(meta, dict) + or set(meta) != {"path", "kind"} + or not isinstance(meta["path"], str) + or not Path(meta["path"]).is_absolute() + or meta["kind"] not in {"symlink", "junction"} + ): + raise ValidationError("Integration ownership manifest avatar 记录无效") + return raw + + +def _parse_legacy_manifest(path: Path) -> dict[str, object]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValidationError("Legacy Integration ownership manifest 无法读取") from exc + if not isinstance(raw, dict): + raise ValidationError("Legacy Integration ownership manifest 必须是 JSON object") + expected = { + "schema_version", + "integration", + "asset_version", + "asset_digest", + "target", + "files", + } + if set(raw) != expected: + raise ValidationError("Legacy Integration ownership manifest 字段不匹配") + if raw["schema_version"] != LEGACY_MANIFEST_SCHEMA_VERSION: + raise ValidationError("Legacy Integration ownership manifest schema 不受支持") + if raw["integration"] != LEGACY_INTEGRATION_ID: + raise ValidationError("Legacy Integration ownership manifest 主体不匹配") + if not isinstance(raw["asset_version"], int) or raw["asset_version"] < 1: + raise ValidationError("Legacy Integration ownership manifest asset version 无效") + if not isinstance(raw["target"], str) or not Path(raw["target"]).is_absolute(): + raise ValidationError("Legacy Integration ownership manifest target 无效") + files = _validate_file_map(raw["files"]) + digest = raw["asset_digest"] + if not isinstance(digest, str) or digest != _asset_digest(files): + raise ValidationError("Legacy Integration ownership manifest digest 不匹配") + return raw + + +def _inspect_avatar( + *, + host: str, + avatar: Path, + mirror: Path, + host_home: Path, + recorded: Mapping[str, object] | None, +) -> AvatarStatus: + unsafe = _symlink_component(avatar.parent, boundary=host_home) + if unsafe is not None: + return AvatarStatus( + host, avatar, "unowned", f"分身父路径不安全:{unsafe}" + ) + exists = avatar.exists() or avatar.is_symlink() + if not exists: + return AvatarStatus(host, avatar, "missing", "分身未安装") + if _is_link(avatar): + if _resolves_to(avatar, mirror): + return AvatarStatus(host, avatar, "current", "分身指向镜像") + return AvatarStatus(host, avatar, "unowned", "分身指向非 Dyro 镜像") + if avatar.is_dir() and not avatar.is_symlink(): + if recorded is not None and str(recorded.get("path")) == str(avatar): + return AvatarStatus(host, avatar, "drifted", "分身被替换为普通目录") + # Owned legacy copy may still sit here before migration. + return AvatarStatus( + host, avatar, "legacy_copy", "存在整目录副本,等待迁移为分身" + ) + return AvatarStatus(host, avatar, "unowned", "分身路径被非目录占用") + + +def _allowed_legacy_targets( + detected: list[tuple[HostSpec, Path]], +) -> set[Path]: + """Legacy whole-directory installs may only live on detected host avatars.""" + return {_avatar_path(home) for _spec, home in detected} + + +def _legacy_owned_copy( + legacy_manifest_path: Path, + *, + expected_target: Path | None = None, + allowed_targets: set[Path] | None = None, + require_current_assets: bool = True, +) -> tuple[dict[str, object], Path] | None: + if not legacy_manifest_path.is_file() or legacy_manifest_path.is_symlink(): + return None + try: + manifest = _parse_legacy_manifest(legacy_manifest_path) + except ValidationError: + return None + target = Path(str(manifest["target"])) + if expected_target is not None and target != expected_target: + return None + if allowed_targets is not None and target not in allowed_targets: + return None + if target.is_symlink() or not target.is_dir(): + return None + try: + inventory = _inventory(target) + if inventory != manifest["files"]: + return None + if require_current_assets and inventory != _asset_inventory(): + return None + except ValidationError: + return None + return manifest, target + + +def integration_status( + integration: str, + *, + dyro_home: Path | None = None, + host_homes: Mapping[str, Path] | None = None, + # Backward-compatible test/API alias for Codex home override. + codex_home: Path | None = None, +) -> IntegrationStatus: + """Inspect Skill mirror/avatar ownership without creating files.""" + requested = integration + _normalize_integration(integration) + overrides: dict[str, Path] = dict(host_homes or {}) + if codex_home is not None: + overrides["codex"] = codex_home + + mirror = _mirror_path(dyro_home) + manifest_path, transaction_path, _, legacy_manifest_path = _state_paths(dyro_home) + state_root = manifest_path.parent + unsafe_state = _symlink_component(state_root, boundary=_dyro_home(dyro_home)) + if unsafe_state is not None: + return IntegrationStatus( + requested, + IntegrationState.RECOVERY_REQUIRED, + mirror, + manifest_path, + f"Dyro Integration 状态路径不安全:{unsafe_state}", + ) + if transaction_path.exists() or transaction_path.is_symlink(): + return IntegrationStatus( + requested, + IntegrationState.RECOVERY_REQUIRED, + mirror, + manifest_path, + "检测到未完成事务;需要人工恢复后再操作", + ) + + detected: list[tuple[HostSpec, Path]] = [] + for spec in HOSTS: + home = _host_home(spec, overrides) + if home is not None: + detected.append((spec, home)) + + avatar_rows: list[AvatarStatus] = [] + host_by_id = {spec.host_id: spec for spec, _home in detected} + for spec, home in detected: + avatar = _avatar_path(home) + row = _inspect_avatar( + host=spec.host_id, + avatar=avatar, + mirror=mirror, + host_home=home, + recorded=None, + ) + avatar_rows.append(row) + + manifest_exists = manifest_path.exists() or manifest_path.is_symlink() + mirror_exists = mirror.exists() or mirror.is_symlink() + legacy = _legacy_owned_copy( + legacy_manifest_path, + allowed_targets=_allowed_legacy_targets(detected), + require_current_assets=True, + ) + blocking_avatars: list[AvatarStatus] = [] + for row in avatar_rows: + if row.state in {"missing", "current"}: + continue + if row.state == "unowned": + spec = host_by_id.get(row.host) + if spec is None or not _host_is_explicit(spec, overrides): + continue + blocking_avatars.append(row) + + if manifest_path.is_symlink(): + return IntegrationStatus( + requested, + IntegrationState.STALE_MANIFEST, + mirror, + manifest_path, + "ownership manifest 不能是 symlink", + tuple(avatar_rows), + ) + + if not manifest_exists: + if legacy is not None: + return IntegrationStatus( + requested, + IntegrationState.OUTDATED, + mirror, + manifest_path, + "检测到旧版 Codex 整目录安装,可迁移为镜像+分身", + tuple(avatar_rows), + ) + if mirror_exists: + return IntegrationStatus( + requested, + IntegrationState.UNOWNED_CONFLICT, + mirror, + manifest_path, + "镜像目录已存在,但不属于 Dyro Integration Manager", + tuple(avatar_rows), + ) + if blocking_avatars: + return IntegrationStatus( + requested, + IntegrationState.UNOWNED_CONFLICT, + mirror, + manifest_path, + "分身路径已存在,但不属于 Dyro Integration Manager", + tuple(avatar_rows), + ) + return IntegrationStatus( + requested, + IntegrationState.ABSENT, + mirror, + manifest_path, + "未安装", + tuple(avatar_rows), + ) + + try: + manifest = _parse_manifest(manifest_path) + except ValidationError as exc: + return IntegrationStatus( + requested, + IntegrationState.STALE_MANIFEST, + mirror, + manifest_path, + str(exc), + tuple(avatar_rows), + ) + + if manifest["mirror"] != str(mirror): + return IntegrationStatus( + requested, + IntegrationState.STALE_MANIFEST, + mirror, + manifest_path, + "ownership manifest 绑定了不同镜像路径", + tuple(avatar_rows), + ) + if not mirror_exists: + return IntegrationStatus( + requested, + IntegrationState.STALE_MANIFEST, + mirror, + manifest_path, + "ownership manifest 存在,但镜像目录缺失", + tuple(avatar_rows), + ) + if mirror.is_symlink() or not mirror.is_dir(): + return IntegrationStatus( + requested, + IntegrationState.DRIFTED, + mirror, + manifest_path, + "镜像被替换为 symlink 或非目录", + tuple(avatar_rows), + ) + try: + installed = _inventory(mirror) + except ValidationError as exc: + return IntegrationStatus( + requested, + IntegrationState.DRIFTED, + mirror, + manifest_path, + str(exc), + tuple(avatar_rows), + ) + if installed != manifest["files"]: + return IntegrationStatus( + requested, + IntegrationState.DRIFTED, + mirror, + manifest_path, + "镜像文件与 ownership manifest 不匹配", + tuple(avatar_rows), + ) + + recorded_avatars = manifest["avatars"] + assert isinstance(recorded_avatars, dict) + refreshed: list[AvatarStatus] = [] + for spec, home in detected: + avatar = _avatar_path(home) + recorded = recorded_avatars.get(spec.host_id) + row = _inspect_avatar( + host=spec.host_id, + avatar=avatar, + mirror=mirror, + host_home=home, + recorded=recorded if isinstance(recorded, dict) else None, + ) + refreshed.append(row) + + if any(row.state in {"drifted", "legacy_copy"} for row in refreshed): + return IntegrationStatus( + requested, + IntegrationState.DRIFTED, + mirror, + manifest_path, + "已拥有分身状态异常", + tuple(refreshed), + ) + # Unowned host paths are skipped; only managed/missing hosts affect freshness. + managed_or_new = [row for row in refreshed if row.state != "unowned"] + if any(row.state == "missing" for row in managed_or_new): + return IntegrationStatus( + requested, + IntegrationState.OUTDATED, + mirror, + manifest_path, + "镜像完整,但缺少一个或多个宿主分身", + tuple(refreshed), + ) + + desired = _asset_inventory() + if ( + manifest["asset_version"] == ASSET_VERSION + and manifest["asset_digest"] == _asset_digest(desired) + and installed == desired + and managed_or_new + and all(row.state == "current" for row in managed_or_new) + ): + return IntegrationStatus( + requested, + IntegrationState.CURRENT, + mirror, + manifest_path, + "镜像与可用分身均与当前 Dyro 包一致", + tuple(refreshed), + ) + if not managed_or_new: + return IntegrationStatus( + requested, + IntegrationState.OUTDATED, + mirror, + manifest_path, + "镜像已安装,但没有可挂接的宿主分身", + tuple(refreshed), + ) + return IntegrationStatus( + requested, + IntegrationState.OUTDATED, + mirror, + manifest_path, + "已安装资产完整,但不是当前 Dyro 包版本", + tuple(refreshed), + ) + + +def plan_integration( + action: str, + integration: str, + *, + dyro_home: Path | None = None, + host_homes: Mapping[str, Path] | None = None, + codex_home: Path | None = None, +) -> IntegrationPlan: + if action not in {"install", "uninstall"}: + raise ValidationError(f"未知 Integration action:{action}") + status = integration_status( + integration, + dyro_home=dyro_home, + host_homes=host_homes, + codex_home=codex_home, + ) + if action == "install": + if status.state is IntegrationState.CURRENT: + changes = ("无需写入;Integration 已是当前版本",) + elif status.state is IntegrationState.ABSENT: + if not status.avatars: + changes = ( + "未检测到宿主目录;需先创建或设置 " + "CODEX_HOME / CLAUDE_HOME / AGENTS_HOME / CURSOR_HOME", + f"(预览)将创建镜像 {status.target}", + f"(预览)将写入 {status.manifest}", + ) + else: + changes = ( + f"创建镜像 {status.target}", + f"写入 {status.manifest}", + *( + f"创建分身 {row.path}" + for row in status.avatars + if row.state == "missing" + ), + ) + elif status.state is IntegrationState.OUTDATED: + changes = ( + f"原子升级镜像 {status.target}", + f"更新 {status.manifest}", + *( + f"修复分身 {row.path}" + for row in status.avatars + if row.state in {"missing", "legacy_copy", "current"} + ), + ) + else: + changes = (f"拒绝写入:{status.detail}",) + elif status.state is IntegrationState.ABSENT: + changes = ("无需写入;Integration 尚未安装",) + elif status.state in {IntegrationState.CURRENT, IntegrationState.OUTDATED}: + changes = ( + *( + f"移除分身 {row.path}" + for row in status.avatars + if row.state in {"current", "legacy_copy", "missing"} + and (row.path.exists() or row.path.is_symlink()) + ), + f"移除镜像 {status.target}", + f"移除 {status.manifest}", + ) + else: + changes = (f"拒绝删除:{status.detail}",) + return IntegrationPlan(action, status, changes) + + +def _write_stage(stage: Path) -> None: + source = _asset_root() + for relative in _asset_inventory(): + source_file = source / relative + destination = stage / relative + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with destination.open("xb") as handle: + handle.write(source_file.read_bytes()) + handle.flush() + os.fsync(handle.fileno()) + destination.chmod(0o644) + fsync_directory(stage) + if _inventory(stage) != _asset_inventory(): + raise DyroError("Integration staging 校验失败") + + +def _remove_tree(path: Path) -> None: + shutil.rmtree(path) + + +def _transaction_payload( + action: str, mirror: Path, backup: Path | None, *, phase: str +) -> str: + if phase not in {"prepared", "committed"}: + raise ValidationError(f"未知 Integration transaction phase:{phase}") + return ( + json.dumps( + { + "schema_version": 1, + "integration": CANONICAL_INTEGRATION_ID, + "action": action, + "phase": phase, + "mirror": str(mirror), + "backup": str(backup) if backup is not None else None, + }, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ) + + +def _unlink_transaction(path: Path) -> None: + path.unlink() + + +def _preserve_recovery_marker(path: Path, payload: str) -> None: + if path.exists() or path.is_symlink(): + return + try: + atomic_write_text(path, payload) + except Exception: + pass + + +def _complete_transaction(path: Path, payload: str) -> None: + try: + _unlink_transaction(path) + fsync_directory(path.parent) + except Exception: + _preserve_recovery_marker(path, payload) + raise + + +def _execution_result( + plan: IntegrationPlan, final_status: IntegrationStatus +) -> IntegrationPlan: + return IntegrationPlan(plan.action, final_status, plan.changes) + + +def _restored_owned_installation( + mirror: Path, manifest_path: Path, original_manifest_text: str +) -> bool: + if ( + mirror.is_symlink() + or not mirror.is_dir() + or manifest_path.is_symlink() + or not manifest_path.is_file() + ): + return False + try: + if manifest_path.read_text(encoding="utf-8") != original_manifest_text: + return False + manifest = _parse_manifest(manifest_path) + if manifest["mirror"] != str(mirror): + return False + if _inventory(mirror) != manifest["files"]: + return False + return ( + not manifest_path.is_symlink() + and manifest_path.read_text(encoding="utf-8") == original_manifest_text + ) + except (OSError, UnicodeError, ValidationError): + return False + + +def _require_mutable_state(status: IntegrationStatus, action: str) -> None: + if action == "install" and status.state in { + IntegrationState.ABSENT, + IntegrationState.OUTDATED, + IntegrationState.CURRENT, + }: + return + if action == "uninstall" and status.state in { + IntegrationState.ABSENT, + IntegrationState.OUTDATED, + IntegrationState.CURRENT, + }: + return + verb = "覆盖" if action == "install" else "删除" + raise DyroError( + f"Integration 状态为 {status.state.value};拒绝{verb}:{status.detail}" + ) + + +def _detected_hosts( + overrides: Mapping[str, Path] | None, +) -> list[tuple[HostSpec, Path]]: + detected: list[tuple[HostSpec, Path]] = [] + for spec in HOSTS: + home = _host_home(spec, overrides) + if home is not None: + detected.append((spec, home)) + return detected + + +def _host_is_explicit( + spec: HostSpec, overrides: Mapping[str, Path] | None +) -> bool: + if overrides is not None and spec.host_id in overrides: + return True + if spec.env_var and os.environ.get(spec.env_var, "").strip(): + return True + return False + + +def _install_avatars( + *, + mirror: Path, + detected: list[tuple[HostSpec, Path]], + legacy_target: Path | None, + overrides: Mapping[str, Path] | None, +) -> tuple[dict[str, dict[str, str]], list[tuple[Path, Path]]]: + """Create host avatars. + + Returns ``(avatars, legacy_backups)`` where each legacy backup is + ``(original_avatar_path, backup_path)``. Callers must delete backups only + after the install transaction commits, and restore them on rollback. + """ + avatars: dict[str, dict[str, str]] = {} + created: list[Path] = [] + legacy_backups: list[tuple[Path, Path]] = [] + allowed = _allowed_legacy_targets(detected) + try: + for spec, home in detected: + avatar = _avatar_path(home) + unsafe = _symlink_component(avatar.parent, boundary=home) + if unsafe is not None: + if _host_is_explicit(spec, overrides): + raise DyroError(f"{spec.host_id} skills 目录不安全:{unsafe}") + continue + if _is_link(avatar) and _resolves_to(avatar, mirror): + avatars[spec.host_id] = { + "path": str(avatar), + "kind": _avatar_kind(), + } + continue + if avatar.exists() or avatar.is_symlink(): + if ( + legacy_target is not None + and avatar == legacy_target + and avatar in allowed + and avatar.is_dir() + and not avatar.is_symlink() + ): + if _inventory(avatar) != _asset_inventory(): + raise DyroError(f"拒绝删除非 Dyro 资产目录:{avatar}") + backup = Path( + tempfile.mkdtemp( + prefix=f".{SKILL_NAME}.legacy-", dir=avatar.parent + ) + ) + backup.rmdir() + os.replace(avatar, backup) + legacy_backups.append((avatar, backup)) + elif _host_is_explicit(spec, overrides): + raise DyroError(f"拒绝覆盖非 Dyro 分身路径:{avatar}") + else: + # Auto-detected host with a foreign skill: leave it alone. + continue + _ensure_safe_directory( + avatar.parent, f"{spec.host_id} skills 目录", boundary=home + ) + kind = _create_avatar_link(avatar, mirror) + created.append(avatar) + avatars[spec.host_id] = {"path": str(avatar), "kind": kind} + if not avatars: + raise DyroError("没有可挂接的宿主分身;拒绝只安装孤立镜像") + return avatars, legacy_backups + except Exception: + for avatar in created: + if _is_link(avatar) and _resolves_to(avatar, mirror): + _remove_avatar_link(avatar) + for original, backup in legacy_backups: + if backup.exists() and not original.exists() and not original.is_symlink(): + os.replace(backup, original) + raise + + +def install_integration( + integration: str, + *, + yes: bool, + dry_run: bool = False, + dyro_home: Path | None = None, + host_homes: Mapping[str, Path] | None = None, + codex_home: Path | None = None, +) -> IntegrationPlan: + overrides: dict[str, Path] = dict(host_homes or {}) + if codex_home is not None: + overrides["codex"] = codex_home + plan = plan_integration( + "install", + integration, + dyro_home=dyro_home, + host_homes=overrides, + ) + if dry_run: + return plan + if not yes: + raise DyroError("安装 Integration 需要先预览,再显式添加 --yes") + _require_mutable_state(plan.status, "install") + if plan.status.state is IntegrationState.CURRENT: + return plan + + manifest_path, transaction_path, lock_path, legacy_manifest_path = _state_paths( + dyro_home + ) + mirror = plan.status.target + detected = _detected_hosts(overrides) + _ensure_safe_directory( + manifest_path.parent, + "Dyro Integration 状态目录", + boundary=_dyro_home(dyro_home), + ) + _ensure_safe_directory( + mirror.parent, "Dyro skills 镜像目录", boundary=_dyro_home(dyro_home) + ) + with exclusive_lock(lock_path): + current = integration_status( + integration, dyro_home=dyro_home, host_homes=overrides + ) + _require_mutable_state(current, "install") + if current.state is IntegrationState.CURRENT: + return plan_integration( + "install", + integration, + dyro_home=dyro_home, + host_homes=overrides, + ) + + legacy = _legacy_owned_copy( + legacy_manifest_path, + allowed_targets=_allowed_legacy_targets(detected), + require_current_assets=True, + ) + legacy_target = legacy[1] if legacy is not None else None + + stage = Path( + tempfile.mkdtemp(prefix=f".{SKILL_NAME}.stage-", dir=mirror.parent) + ) + backup: Path | None = None + activated = False + committed = False + restored = False + created_avatars: list[Path] = [] + legacy_backups: list[tuple[Path, Path]] = [] + old_manifest_text = ( + manifest_path.read_text(encoding="utf-8") + if manifest_path.exists() + else None + ) + manifest_replaced = False + transaction_payload = "" + try: + _write_stage(stage) + if mirror.exists(): + backup = Path( + tempfile.mkdtemp( + prefix=f".{SKILL_NAME}.backup-", dir=mirror.parent + ) + ) + backup.rmdir() + transaction_payload = _transaction_payload( + "install", mirror, backup, phase="prepared" + ) + atomic_write_text(transaction_path, transaction_payload) + if backup is not None: + os.replace(mirror, backup) + if mirror.exists() or mirror.is_symlink(): + raise DyroError("Skill 镜像在事务期间被其他进程创建;已中止") + os.replace(stage, mirror) + activated = True + desired = _asset_inventory() + avatars, legacy_backups = _install_avatars( + mirror=mirror, + detected=detected, + legacy_target=legacy_target, + overrides=overrides, + ) + created_avatars = [Path(meta["path"]) for meta in avatars.values()] + if old_manifest_text is None: + if manifest_path.exists() or manifest_path.is_symlink(): + raise DyroError( + "Integration ownership manifest 在事务期间被其他进程创建;已中止" + ) + elif manifest_path.read_text(encoding="utf-8") != old_manifest_text: + raise DyroError( + "Integration ownership manifest 在事务期间发生变化;已中止" + ) + atomic_write_text( + manifest_path, + json.dumps( + _manifest_payload(mirror, desired, avatars), + ensure_ascii=True, + indent=2, + sort_keys=True, + ) + + "\n", + ) + manifest_replaced = True + if legacy_manifest_path.exists() or legacy_manifest_path.is_symlink(): + legacy_manifest_path.unlink() + transaction_payload = _transaction_payload( + "install", mirror, backup, phase="committed" + ) + atomic_write_text(transaction_path, transaction_payload) + committed = True + if backup is not None: + _remove_tree(backup) + for _original, legacy_backup in legacy_backups: + if legacy_backup.exists(): + _remove_tree(legacy_backup) + _complete_transaction(transaction_path, transaction_payload) + except Exception: + if committed: + _preserve_recovery_marker(transaction_path, transaction_payload) + if stage.exists(): + _remove_tree(stage) + raise + try: + for avatar in created_avatars: + if _is_link(avatar) and _resolves_to(avatar, mirror): + _remove_avatar_link(avatar) + for original, legacy_backup in legacy_backups: + if ( + legacy_backup.exists() + and not original.exists() + and not original.is_symlink() + ): + os.replace(legacy_backup, original) + if activated and mirror.exists(): + _remove_tree(mirror) + if backup is not None and backup.exists(): + os.replace(backup, mirror) + if manifest_replaced: + if old_manifest_text is None: + manifest_path.unlink(missing_ok=True) + else: + atomic_write_text(manifest_path, old_manifest_text) + if old_manifest_text is None: + restored = ( + not mirror.exists() + and not mirror.is_symlink() + and not manifest_path.exists() + and not manifest_path.is_symlink() + ) + elif mirror.exists() and manifest_path.exists(): + restored = _restored_owned_installation( + mirror, manifest_path, old_manifest_text + ) + finally: + if stage.exists(): + _remove_tree(stage) + if restored and transaction_path.exists(): + _complete_transaction(transaction_path, transaction_payload) + raise + final_status = integration_status( + integration, dyro_home=dyro_home, host_homes=overrides + ) + return _execution_result(plan, final_status) + + +def uninstall_integration( + integration: str, + *, + yes: bool, + dry_run: bool = False, + dyro_home: Path | None = None, + host_homes: Mapping[str, Path] | None = None, + codex_home: Path | None = None, +) -> IntegrationPlan: + overrides: dict[str, Path] = dict(host_homes or {}) + if codex_home is not None: + overrides["codex"] = codex_home + plan = plan_integration( + "uninstall", + integration, + dyro_home=dyro_home, + host_homes=overrides, + ) + if dry_run: + return plan + if not yes: + raise DyroError("卸载 Integration 需要先预览,再显式添加 --yes") + _require_mutable_state(plan.status, "uninstall") + if plan.status.state is IntegrationState.ABSENT: + return plan + + manifest_path, transaction_path, lock_path, legacy_manifest_path = _state_paths( + dyro_home + ) + mirror = plan.status.target + _safe_existing_directory( + manifest_path.parent, + "Dyro Integration 状态目录", + boundary=_dyro_home(dyro_home), + ) + with exclusive_lock(lock_path): + current = integration_status( + integration, dyro_home=dyro_home, host_homes=overrides + ) + _require_mutable_state(current, "uninstall") + if current.state is IntegrationState.ABSENT: + return plan_integration( + "uninstall", + integration, + dyro_home=dyro_home, + host_homes=overrides, + ) + + detected = _detected_hosts(overrides) + legacy = _legacy_owned_copy( + legacy_manifest_path, + allowed_targets=_allowed_legacy_targets(detected), + require_current_assets=True, + ) + manifest_text = ( + manifest_path.read_text(encoding="utf-8") + if manifest_path.exists() + else None + ) + if mirror.exists() and not mirror.is_symlink(): + backup_dir = mirror.parent + boundary = _dyro_home(dyro_home) + elif legacy is not None: + backup_dir = legacy[1].parent + boundary = legacy[1].parent.parent + else: + backup_dir = mirror.parent + boundary = _dyro_home(dyro_home) + _ensure_safe_directory(backup_dir, "Skill 卸载备份目录", boundary=boundary) + backup = Path( + tempfile.mkdtemp(prefix=f".{SKILL_NAME}.backup-", dir=backup_dir) + ) + backup.rmdir() + removed_manifest = False + removed_avatars: list[Path] = [] + committed = False + restored = False + transaction_payload = _transaction_payload( + "uninstall", mirror, backup, phase="prepared" + ) + atomic_write_text(transaction_path, transaction_payload) + try: + for row in current.avatars: + avatar = row.path + if _is_link(avatar) and _resolves_to(avatar, mirror): + _remove_avatar_link(avatar) + removed_avatars.append(avatar) + moved_tree = False + if mirror.exists() and not mirror.is_symlink(): + os.replace(mirror, backup) + moved_tree = True + elif legacy is not None and legacy[1].exists() and not legacy[1].is_symlink(): + os.replace(legacy[1], backup) + moved_tree = True + elif mirror.is_symlink(): + mirror.unlink() + if manifest_path.exists(): + manifest_path.unlink() + removed_manifest = True + if legacy_manifest_path.exists() or legacy_manifest_path.is_symlink(): + legacy_manifest_path.unlink() + transaction_payload = _transaction_payload( + "uninstall", mirror, backup, phase="committed" + ) + atomic_write_text(transaction_path, transaction_payload) + committed = True + if moved_tree and backup.exists(): + _remove_tree(backup) + _complete_transaction(transaction_path, transaction_payload) + except Exception: + if committed: + _preserve_recovery_marker(transaction_path, transaction_payload) + raise + try: + if backup.exists() and not mirror.exists(): + os.replace(backup, mirror) + for avatar in removed_avatars: + if ( + not avatar.exists() + and not avatar.is_symlink() + and mirror.exists() + ): + _create_avatar_link(avatar, mirror) + if removed_manifest and manifest_text is not None and mirror.exists(): + atomic_write_text(manifest_path, manifest_text) + if ( + manifest_text is not None + and mirror.exists() + and manifest_path.exists() + ): + restored = _restored_owned_installation( + mirror, manifest_path, manifest_text + ) + finally: + if restored and transaction_path.exists(): + _complete_transaction(transaction_path, transaction_payload) + raise + final_status = integration_status( + integration, dyro_home=dyro_home, host_homes=overrides + ) + return _execution_result(plan, final_status) diff --git a/src/dyro/read_limits.py b/src/dyro/read_limits.py new file mode 100644 index 0000000..6f6d93f --- /dev/null +++ b/src/dyro/read_limits.py @@ -0,0 +1,590 @@ +"""Bounded, side-effect-free file reads for machine-facing observations.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import Enum +import math +import os +from pathlib import Path +import stat +import time +from typing import Callable, Iterator + +from .errors import ValidationError + + +class ReadLimitCode(str, Enum): + FILE_TOO_LARGE = "FILE_TOO_LARGE" + AGGREGATE_BYTES_EXCEEDED = "AGGREGATE_BYTES_EXCEEDED" + DEADLINE_EXCEEDED = "DEADLINE_EXCEEDED" + RECORD_LIMIT_EXCEEDED = "RECORD_LIMIT_EXCEEDED" + UNSAFE_FILE = "UNSAFE_FILE" + + +class ReadLimitError(ValidationError): + """A typed observation limit failure whose message is never transported.""" + + def __init__(self, code: ReadLimitCode, message: str) -> None: + super().__init__(message) + self.code = code + + +def _positive_int(value: int, label: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValidationError(f"{label} 必须是正整数") + + +_PROTOCOL_LIMIT_CEILINGS = { + "profile_bytes": 1024 * 1024, + "registry_bytes": 1024 * 1024, + "registry_records": 500, + "task_manifest_bytes": 256 * 1024, + "task_status_bytes": 4096, + "task_records": 2000, + "line_manifest_bytes": 256 * 1024, + "line_records": 2000, + "objective_metadata_bytes": 256 * 1024, + "objective_events_bytes": 8 * 1024 * 1024, + "objective_event_records": 10_000, + "objective_records": 500, + "response_records": 100, + "aggregate_bytes": 64 * 1024 * 1024, +} +_PROTOCOL_DEADLINE_SECONDS = 5.0 + + +@dataclass(frozen=True) +class ObservationLimits: + profile_bytes: int = _PROTOCOL_LIMIT_CEILINGS["profile_bytes"] + registry_bytes: int = _PROTOCOL_LIMIT_CEILINGS["registry_bytes"] + registry_records: int = _PROTOCOL_LIMIT_CEILINGS["registry_records"] + task_manifest_bytes: int = _PROTOCOL_LIMIT_CEILINGS["task_manifest_bytes"] + task_status_bytes: int = _PROTOCOL_LIMIT_CEILINGS["task_status_bytes"] + task_records: int = _PROTOCOL_LIMIT_CEILINGS["task_records"] + line_manifest_bytes: int = _PROTOCOL_LIMIT_CEILINGS["line_manifest_bytes"] + line_records: int = _PROTOCOL_LIMIT_CEILINGS["line_records"] + objective_metadata_bytes: int = _PROTOCOL_LIMIT_CEILINGS["objective_metadata_bytes"] + objective_events_bytes: int = _PROTOCOL_LIMIT_CEILINGS["objective_events_bytes"] + objective_event_records: int = _PROTOCOL_LIMIT_CEILINGS["objective_event_records"] + objective_records: int = _PROTOCOL_LIMIT_CEILINGS["objective_records"] + response_records: int = _PROTOCOL_LIMIT_CEILINGS["response_records"] + aggregate_bytes: int = _PROTOCOL_LIMIT_CEILINGS["aggregate_bytes"] + deadline_seconds: float = _PROTOCOL_DEADLINE_SECONDS + + def __post_init__(self) -> None: + for label, ceiling in _PROTOCOL_LIMIT_CEILINGS.items(): + value = getattr(self, label) + _positive_int(value, label) + if value > ceiling: + raise ValidationError(f"{label} 不得超过协议上限 {ceiling}") + if ( + isinstance(self.deadline_seconds, bool) + or not isinstance(self.deadline_seconds, (int, float)) + or not math.isfinite(self.deadline_seconds) + or not 0 < self.deadline_seconds <= _PROTOCOL_DEADLINE_SECONDS + ): + raise ValidationError( + f"deadline_seconds 必须是不超过 {_PROTOCOL_DEADLINE_SECONDS} 的有限正数" + ) + + +def _directory_flags() -> int: + if os.name == "nt" or not hasattr(os, "O_NOFOLLOW"): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Platform lacks safe directory traversal support", + ) + return os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW + + +def _checked_absolute(path: Path, label: str) -> Path: + if any(part in {".", ".."} for part in path.parts): + raise ValidationError(f"{label} 不得包含点路径分量") + absolute = path.absolute() + if not absolute.is_absolute() or not absolute.anchor: + raise ValidationError(f"{label} 必须是绝对路径") + return absolute + + +def _open_absolute_directory( + path: Path, check: Callable[[], None] | None = None +) -> int: + """Open every path component without following directory symlinks.""" + + absolute = _checked_absolute(path, "directory") + flags = _directory_flags() + if check is not None: + check() + descriptor = os.open(absolute.anchor, flags) + try: + for part in absolute.parts[1:]: + if check is not None: + check() + child = os.open(part, flags, dir_fd=descriptor) + parent = descriptor + descriptor = child + os.close(parent) + return descriptor + except BaseException: + os.close(descriptor) + raise + + +@contextmanager +def open_safe_directory_chain( + root: Path, + directory: Path, + *, + allow_missing: bool = False, + expected_root_identity: tuple[int, int] | None = None, + check: Callable[[], None] | None = None, + identity_check: Callable[[Path, tuple[int, int] | None], None] | None = None, +) -> Iterator[int | None]: + """Hold a descriptor-bound, non-symlink directory chain below ``root``.""" + + absolute_root = _checked_absolute(root, "workspace root") + absolute_directory = _checked_absolute(directory, "state directory") + try: + relative = absolute_directory.relative_to(absolute_root) + except ValueError as exc: + raise ValidationError( + "Observation state directory escapes workspace root" + ) from exc + + descriptor: int | None = None + root_opened = False + try: + descriptor = _open_absolute_directory(absolute_root, check) + root_opened = True + root_info = os.fstat(descriptor) + root_identity = (root_info.st_dev, root_info.st_ino) + if ( + expected_root_identity is not None + and root_identity != expected_root_identity + ): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation workspace root identity changed", + ) + if identity_check is not None: + identity_check(absolute_root, root_identity) + current = absolute_root + for part in relative.parts: + if check is not None: + check() + next_path = current / part + try: + child = os.open(part, _directory_flags(), dir_fd=descriptor) + except FileNotFoundError: + if identity_check is not None: + identity_check(next_path, None) + raise + parent = descriptor + descriptor = child + os.close(parent) + current = next_path + child_info = os.fstat(descriptor) + if identity_check is not None: + identity_check(current, (child_info.st_dev, child_info.st_ino)) + except FileNotFoundError as exc: + if descriptor is not None: + os.close(descriptor) + descriptor = None + if allow_missing and root_opened: + yield None + return + if not root_opened: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation workspace root is not safe", + ) from exc + raise + except PermissionError: + if descriptor is not None: + os.close(descriptor) + raise + except OSError as exc: + if descriptor is not None: + os.close(descriptor) + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation state directory is not safe", + ) from exc + except BaseException: + if descriptor is not None: + os.close(descriptor) + raise + try: + yield descriptor + finally: + if descriptor is not None: + os.close(descriptor) + + +@dataclass +class ReadBudget: + limits: ObservationLimits + monotonic: Callable[[], float] = time.monotonic + _started_at: float = field(init=False, repr=False) + _bytes_read: int = field(default=0, init=False, repr=False) + _root_identities: dict[str, tuple[int, int]] = field( + default_factory=dict, init=False, repr=False + ) + _directory_identities: dict[str, tuple[int, int] | None] = field( + default_factory=dict, init=False, repr=False + ) + _file_identities: dict[str, tuple[int, int]] = field( + default_factory=dict, init=False, repr=False + ) + + def __post_init__(self) -> None: + if not isinstance(self.limits, ObservationLimits): + raise ValidationError("read budget limits 必须是 ObservationLimits") + if not callable(self.monotonic): + raise ValidationError("read budget monotonic 必须可调用") + started_at = self.monotonic() + if ( + isinstance(started_at, bool) + or not isinstance(started_at, (int, float)) + or not math.isfinite(started_at) + ): + raise ValidationError("read budget monotonic 必须返回有限数值") + self._started_at = started_at + + @property + def bytes_read(self) -> int: + return self._bytes_read + + def check_deadline(self) -> None: + current = self.monotonic() + if ( + isinstance(current, bool) + or not isinstance(current, (int, float)) + or not math.isfinite(current) + or current < self._started_at + ): + raise ValidationError("read budget monotonic 返回了无效数值") + if current - self._started_at > self.limits.deadline_seconds: + raise ReadLimitError( + ReadLimitCode.DEADLINE_EXCEEDED, + "Core observation deadline exceeded", + ) + + def remaining_seconds(self) -> float: + """Return the bounded wall budget available to an allowed subprocess.""" + current = self.monotonic() + if ( + isinstance(current, bool) + or not isinstance(current, (int, float)) + or not math.isfinite(current) + or current < self._started_at + ): + raise ValidationError("read budget monotonic 返回了无效数值") + remaining = self.limits.deadline_seconds - (current - self._started_at) + if remaining <= 0: + raise ReadLimitError( + ReadLimitCode.DEADLINE_EXCEEDED, + "Core observation deadline exceeded", + ) + return remaining + + def _charge(self, size: int) -> None: + if self._bytes_read + size > self.limits.aggregate_bytes: + raise ReadLimitError( + ReadLimitCode.AGGREGATE_BYTES_EXCEEDED, + "Aggregate observation byte budget exceeded", + ) + self._bytes_read += size + + def _root_identity(self, root: Path) -> tuple[int, int]: + absolute = _checked_absolute(root, "workspace root") + key = str(absolute) + expected = self._root_identities.get(key) + try: + descriptor = _open_absolute_directory(absolute, self.check_deadline) + except PermissionError: + raise + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation workspace root is not safe", + ) from exc + try: + info = os.fstat(descriptor) + current = (info.st_dev, info.st_ino) + finally: + os.close(descriptor) + if expected is None: + self._root_identities[key] = current + return current + if current != expected: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation workspace root identity changed", + ) + return expected + + def _bind_directory_identity( + self, path: Path, identity: tuple[int, int] | None + ) -> None: + key = str(_checked_absolute(path, "state directory")) + if key not in self._directory_identities: + self._directory_identities[key] = identity + return + if self._directory_identities[key] != identity: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation state directory identity changed", + ) + + def bind_directory_identity(self, path: Path, identity: tuple[int, int]) -> None: + """Bind a directory identity captured during bounded enumeration.""" + + self._bind_directory_identity(path, identity) + + def bind_file_identity(self, path: Path, identity: tuple[int, int]) -> None: + """Bind a regular-file identity captured during bounded enumeration.""" + + key = str(_checked_absolute(path, "state file")) + expected = self._file_identities.get(key) + if expected is None: + self._file_identities[key] = identity + return + if expected != identity: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Observation state file identity changed", + ) + + @contextmanager + def open_safe_directory_chain( + self, + root: Path, + directory: Path, + *, + allow_missing: bool = False, + ) -> Iterator[int | None]: + expected = self._root_identity(root) + with open_safe_directory_chain( + root, + directory, + allow_missing=allow_missing, + expected_root_identity=expected, + check=self.check_deadline, + identity_check=self._bind_directory_identity, + ) as descriptor: + if descriptor is None: + self._bind_directory_identity(directory, None) + yield descriptor + + def check_root_identity(self, root: Path) -> None: + self._root_identity(root) + + def read_descriptor_bytes( + self, + descriptor: int, + *, + size: int, + maximum_bytes: int, + label: str, + ) -> bytes: + self.check_deadline() + if size > maximum_bytes: + raise ReadLimitError( + ReadLimitCode.FILE_TOO_LARGE, + f"{label} exceeds its byte limit", + ) + if self._bytes_read + size > self.limits.aggregate_bytes: + raise ReadLimitError( + ReadLimitCode.AGGREGATE_BYTES_EXCEEDED, + "Aggregate observation byte budget exceeded", + ) + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_size != size: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} changed before bounded read", + ) + chunks: list[bytes] = [] + remaining = size + while remaining > 0: + self.check_deadline() + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + self._charge(len(chunk)) + chunks.append(chunk) + remaining -= len(chunk) + content = b"".join(chunks) + after = os.fstat(descriptor) + if ( + len(content) != size + or after.st_size != before.st_size + or after.st_mtime_ns != before.st_mtime_ns + or after.st_ctime_ns != before.st_ctime_ns + ): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} changed during bounded read", + ) + return content + + def read_regular_bytes( + self, + path: Path, + *, + maximum_bytes: int, + label: str, + ) -> bytes: + """Read one final-path regular file through the descriptor that was checked.""" + + self.check_deadline() + try: + before = path.lstat() + except OSError: + raise + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} is not a safe regular file", + ) + if before.st_size > maximum_bytes: + raise ReadLimitError( + ReadLimitCode.FILE_TOO_LARGE, + f"{label} exceeds its byte limit", + ) + flags = ( + os.O_RDONLY + | (os.O_NOFOLLOW if hasattr(os, "O_NOFOLLOW") else 0) + | getattr(os, "O_NONBLOCK", 0) + ) + descriptor = os.open(path, flags) + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_dev != before.st_dev + or opened.st_ino != before.st_ino + ): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} changed during safe open", + ) + return self.read_descriptor_bytes( + descriptor, + size=opened.st_size, + maximum_bytes=maximum_bytes, + label=label, + ) + finally: + os.close(descriptor) + + def read_regular_text( + self, + path: Path, + *, + maximum_bytes: int, + label: str, + ) -> str: + return self.read_regular_bytes( + path, maximum_bytes=maximum_bytes, label=label + ).decode("utf-8") + + def read_regular_bytes_at( + self, + *, + root: Path, + directory: Path, + name: str, + maximum_bytes: int, + label: str, + ) -> bytes: + """Read a file relative to a stable, safely traversed directory FD.""" + + if not name or Path(name).name != name: + raise ValidationError(f"{label} 文件名无效") + self.check_deadline() + with self.open_safe_directory_chain(root, directory) as directory_fd: + assert directory_fd is not None + return self.read_regular_bytes_from_directory_fd( + directory_fd, + name=name, + maximum_bytes=maximum_bytes, + label=label, + identity_path=directory / name, + ) + + def read_regular_bytes_from_directory_fd( + self, + directory_fd: int, + *, + name: str, + maximum_bytes: int, + label: str, + identity_path: Path | None = None, + ) -> bytes: + """Read a safe regular file relative to an already-bound directory.""" + + self.check_deadline() + if not name or Path(name).name != name: + raise ValidationError(f"{label} 文件名无效") + flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0) + try: + descriptor = os.open(name, flags, dir_fd=directory_fd) + except (FileNotFoundError, PermissionError): + raise + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} is not a safe regular file", + ) from exc + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} is not a safe regular file", + ) + if identity_path is not None: + self.bind_file_identity( + identity_path, + (info.st_dev, info.st_ino), + ) + return self.read_descriptor_bytes( + descriptor, + size=info.st_size, + maximum_bytes=maximum_bytes, + label=label, + ) + finally: + os.close(descriptor) + + def read_regular_text_at( + self, + *, + root: Path, + directory: Path, + name: str, + maximum_bytes: int, + label: str, + ) -> str: + return self.read_regular_bytes_at( + root=root, + directory=directory, + name=name, + maximum_bytes=maximum_bytes, + label=label, + ).decode("utf-8") + + +def require_safe_directory_chain( + root: Path, directory: Path, *, allow_missing: bool = False +) -> bool: + """Validate a directory chain using the same safe traversal primitive.""" + + with open_safe_directory_chain( + root, directory, allow_missing=allow_missing + ) as descriptor: + return descriptor is not None diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index 0c422de..6202a27 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -10,7 +10,13 @@ import uuid from typing import Any, Iterable -from .config import Config, expand_argv, external_security_errors, strict_bool, validate_id +from .config import ( + Config, + expand_argv, + external_security_errors, + strict_bool, + validate_id, +) from .evidence_store import ( EvidenceGeneration, cleanup_evidence_generations, @@ -20,6 +26,7 @@ ) from .errors import DyroError, ValidationError from .process import git, require_ok, run +from .read_limits import ReadBudget from .provenance import ( ExecutionAttempt, begin_execution_attempt, @@ -34,7 +41,16 @@ from .workspace import Line, get_line, line_repository_path, repository_path -STATUSES = ("backlog", "assigned", "in_progress", "waiting_answer", "review", "review_pending_signoff", "done", "failed") +STATUSES = ( + "backlog", + "assigned", + "in_progress", + "waiting_answer", + "review", + "review_pending_signoff", + "done", + "failed", +) QUALITY_GATE_STATUSES = frozenset({"review", "review_pending_signoff", "done"}) TRANSITIONS = { "backlog": {"assigned"}, @@ -48,8 +64,12 @@ } RESULT_RE = re.compile(r"^result:\s*(DONE|BLOCKED|QUESTION)\s*$", re.IGNORECASE) VERDICT_RE = re.compile(r"^verdict:\s*(PASS|FAIL)\s*$", re.IGNORECASE) -RECEIPT_SHA_RE = re.compile(r"^receipt_sha256:\s*([0-9a-f]{64})\s*$", re.IGNORECASE | re.MULTILINE) -TASK_HEADS_SHA_RE = re.compile(r"^task_heads_sha256:\s*([0-9a-f]{64})\s*$", re.IGNORECASE | re.MULTILINE) +RECEIPT_SHA_RE = re.compile( + r"^receipt_sha256:\s*([0-9a-f]{64})\s*$", re.IGNORECASE | re.MULTILINE +) +TASK_HEADS_SHA_RE = re.compile( + r"^task_heads_sha256:\s*([0-9a-f]{64})\s*$", re.IGNORECASE | re.MULTILINE +) GIT_HEAD_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$", re.IGNORECASE) TASK_HEADS_FILE = "task-heads.json" REVIEW_IDENTITY_FILE = "review-identity.json" @@ -103,7 +123,9 @@ def task_dir(config: Config, task_id: str) -> Path: def _strings(raw: Any, label: str) -> tuple[str, ...]: - if not isinstance(raw, list) or not all(isinstance(item, str) and item for item in raw): + if not isinstance(raw, list) or not all( + isinstance(item, str) and item for item in raw + ): raise ValidationError(f"{label} 必须是字符串数组") return tuple(raw) @@ -114,23 +136,36 @@ def _positive_int(raw: Any, label: str, *, maximum: int) -> int: return raw -def _parse_task(path: Path) -> Task: +def _string(raw: Any, label: str, *, allow_empty: bool = False) -> str: + if not isinstance(raw, str) or (not allow_empty and not raw.strip()): + qualifier = "字符串" if allow_empty else "非空字符串" + raise ValidationError(f"{label} 必须是{qualifier}") + return raw.strip() + + +def _table(raw: Any, label: str) -> dict[str, Any]: + if not isinstance(raw, dict): + raise ValidationError(f"{label} 必须是表") + return raw + + +def _parse_task_content(path: Path, content: bytes) -> Task: try: - raw = tomllib.loads((path / "task.toml").read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: + raw = tomllib.loads(content.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError, RecursionError) as exc: raise ValidationError(f"任务清单格式错误 {path}: {exc}") from exc if raw.get("schema_version") != 1: raise ValidationError(f"任务清单必须使用 schema_version = 1:{path}") - task_id = validate_id(str(raw.get("id", "")), "任务 ID") - title = str(raw.get("title", "")).strip() - line = validate_id(str(raw.get("line", "")), "任务开发线") - risk = str(raw.get("risk", "write")) - if not title or risk not in ("read", "write"): + task_id = validate_id(_string(raw.get("id"), "任务 ID"), "任务 ID") + title = _string(raw.get("title"), f"任务 {task_id} title") + line = validate_id(_string(raw.get("line"), f"任务 {task_id} 开发线"), "任务开发线") + risk = _string(raw.get("risk", "write"), f"任务 {task_id} risk") + if risk not in ("read", "write"): raise ValidationError(f"任务 {task_id} 的 title 或 risk 无效") - executor = str(raw.get("executor", {}).get("agent", "")).strip() - reviewer = str(raw.get("reviewer", {}).get("agent", "")).strip() - if not executor or not reviewer: - raise ValidationError(f"任务 {task_id} 必须配置 executor.agent 与 reviewer.agent") + executor_raw = _table(raw.get("executor", {}), f"任务 {task_id} executor") + reviewer_raw = _table(raw.get("reviewer", {}), f"任务 {task_id} reviewer") + executor = _string(executor_raw.get("agent"), f"任务 {task_id} executor.agent") + reviewer = _string(reviewer_raw.get("agent"), f"任务 {task_id} reviewer.agent") repo_entries = raw.get("repositories", []) if not isinstance(repo_entries, list) or not repo_entries: raise ValidationError(f"任务 {task_id} 至少包含一个 [[repositories]]") @@ -138,17 +173,29 @@ def _parse_task(path: Path) -> Task: for entry in repo_entries: if not isinstance(entry, dict): raise ValidationError(f"任务 {task_id} repositories 结构无效") - repositories.append(validate_id(str(entry.get("id", "")), "任务仓库 id")) + repositories.append( + validate_id( + _string(entry.get("id"), f"任务 {task_id} repository id"), + "任务仓库 id", + ) + ) if len(set(repositories)) != len(repositories): raise ValidationError(f"任务 {task_id} repositories 不能重复") + gates_raw = raw.get("gates", []) + if not isinstance(gates_raw, list): + raise ValidationError(f"任务 {task_id} gates 必须是表数组") gates: list[Gate] = [] - for entry in raw.get("gates", []): + for entry in gates_raw: if not isinstance(entry, dict): raise ValidationError(f"任务 {task_id} gates 结构无效") - name = str(entry.get("name", "")).strip() + name = _string(entry.get("name"), f"任务 {task_id} gate name") argv = entry.get("argv") - cwd = str(entry.get("cwd", ".")) - if not name or not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv): + cwd = _string(entry.get("cwd", "."), f"任务 {task_id} gate {name} cwd") + if ( + not isinstance(argv, list) + or not argv + or not all(isinstance(item, str) and item for item in argv) + ): raise ValidationError(f"任务 {task_id} gate 必须包含 name 与 argv 数组") cwd_path = Path(cwd) if cwd_path.is_absolute() or ".." in cwd_path.parts: @@ -180,7 +227,11 @@ def _parse_task(path: Path) -> Task: repositories=tuple(repositories), depends_on=_strings(raw.get("depends_on", []), "depends_on"), blocked_on=_strings(raw.get("blocked_on", []), "blocked_on"), - conflict_group=str(raw.get("conflict_group", "")), + conflict_group=_string( + raw.get("conflict_group", ""), + f"任务 {task_id} conflict_group", + allow_empty=True, + ), timeout_minutes=_positive_int( raw.get("timeout_minutes", 60), f"任务 {task_id} timeout_minutes", @@ -198,21 +249,165 @@ def _parse_task(path: Path) -> Task: ) +def _parse_task(path: Path) -> Task: + return _parse_task_content(path, (path / "task.toml").read_bytes()) + + def load_task(config: Config, task_id: str) -> Task: task = _parse_task(task_dir(config, task_id)) if task.id != task_id: - raise ValidationError(f"目录任务 ID 与 task.toml 不一致:{task_id} != {task.id}") - unknown = [repo_id for repo_id in task.repositories if repo_id not in config.repositories] + raise ValidationError( + f"目录任务 ID 与 task.toml 不一致:{task_id} != {task.id}" + ) + unknown = [ + repo_id for repo_id in task.repositories if repo_id not in config.repositories + ] if unknown: raise ValidationError(f"任务 {task.id} 引用了未配置仓库:{', '.join(unknown)}") get_line(config, task.line) return task +def load_task_bounded( + config: Config, + task_id: str, + budget: ReadBudget, + *, + known_line_ids: frozenset[str], +) -> Task: + """Load one Task manifest without an unbounded line re-scan.""" + validate_id(task_id, "任务 ID") + directory = config.task_specs_dir / task_id + try: + content = budget.read_regular_bytes_at( + root=config.root, + directory=directory, + name="task.toml", + maximum_bytes=budget.limits.task_manifest_bytes, + label="task.toml", + ) + except FileNotFoundError as exc: + raise ValidationError(f"任务不存在:{task_id}") from exc + return _validate_bounded_task( + config, + task_id, + directory, + content, + known_line_ids=known_line_ids, + ) + + +def _validate_bounded_task( + config: Config, + task_id: str, + directory: Path, + content: bytes, + *, + known_line_ids: frozenset[str], +) -> Task: + task = _parse_task_content(directory, content) + if task.id != task_id: + raise ValidationError( + f"目录任务 ID 与 task.toml 不一致:{task_id} != {task.id}" + ) + unknown = [ + repo_id for repo_id in task.repositories if repo_id not in config.repositories + ] + if unknown: + raise ValidationError(f"任务 {task.id} 引用了未配置仓库:{', '.join(unknown)}") + if task.line not in known_line_ids: + raise ValidationError(f"任务 {task.id} 引用了未登记开发线:{task.line}") + return task + + +def load_task_observation_bounded( + config: Config, + task_id: str, + budget: ReadBudget, + *, + known_line_ids: frozenset[str], +) -> tuple[Task, str]: + """Load one Task manifest and status from the same stable directory FD.""" + + task, current, _content = _load_task_observation_details_bounded( + config, + task_id, + budget, + known_line_ids=known_line_ids, + ) + return task, current + + +def load_task_planning_bounded( + config: Config, + task_id: str, + budget: ReadBudget, + *, + known_line_ids: frozenset[str], +) -> tuple[Task, str, str]: + """Load one Task/status pair and bind its exact manifest digest.""" + + task, current, content = _load_task_observation_details_bounded( + config, + task_id, + budget, + known_line_ids=known_line_ids, + ) + return task, current, hashlib.sha256(content).hexdigest() + + +def _load_task_observation_details_bounded( + config: Config, + task_id: str, + budget: ReadBudget, + *, + known_line_ids: frozenset[str], +) -> tuple[Task, str, bytes]: + validate_id(task_id, "任务 ID") + directory = config.task_specs_dir / task_id + try: + with budget.open_safe_directory_chain(config.root, directory) as directory_fd: + assert directory_fd is not None + content = budget.read_regular_bytes_from_directory_fd( + directory_fd, + name="task.toml", + maximum_bytes=budget.limits.task_manifest_bytes, + label="task.toml", + ) + try: + status_content = budget.read_regular_bytes_from_directory_fd( + directory_fd, + name="status", + maximum_bytes=budget.limits.task_status_bytes, + label="task status", + ) + except FileNotFoundError: + status_content = b"backlog" + except FileNotFoundError as exc: + raise ValidationError(f"任务不存在:{task_id}") from exc + task = _validate_bounded_task( + config, + task_id, + directory, + content, + known_line_ids=known_line_ids, + ) + try: + current = status_content.decode("utf-8").strip() + except UnicodeError as exc: + raise ValidationError(f"任务 {task.id} 状态不是 UTF-8") from exc + if current not in STATUSES: + raise ValidationError(f"任务 {task.id} 状态非法") + return task, current, content + + def list_tasks(config: Config) -> list[Task]: if not config.task_specs_dir.exists(): return [] - return [load_task(config, path.parent.name) for path in sorted(config.task_specs_dir.glob("*/task.toml"))] + return [ + load_task(config, path.parent.name) + for path in sorted(config.task_specs_dir.glob("*/task.toml")) + ] def status(config: Config, task: Task) -> str: @@ -223,6 +418,24 @@ def status(config: Config, task: Task) -> str: return current +def status_bounded(config: Config, task: Task, budget: ReadBudget) -> str: + try: + current = budget.read_regular_text_at( + root=config.root, + directory=task.directory, + name="status", + maximum_bytes=budget.limits.task_status_bytes, + label="task status", + ).strip() + except FileNotFoundError: + return "backlog" + except UnicodeError as exc: + raise ValidationError(f"任务 {task.id} 状态不是 UTF-8") from exc + if current not in STATUSES: + raise ValidationError(f"任务 {task.id} 状态非法") + return current + + def _claim_path(task: Task) -> Path: return task.directory / "claim.json" @@ -256,7 +469,11 @@ def _claim(task: Task) -> dict[str, object] | None: payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValidationError(f"任务 {task.id} 领取记录格式错误") from exc - if not isinstance(payload, dict) or payload.get("task_id") != task.id or not isinstance(payload.get("runner"), str): + if ( + not isinstance(payload, dict) + or payload.get("task_id") != task.id + or not isinstance(payload.get("runner"), str) + ): raise ValidationError(f"任务 {task.id} 领取记录无效") return payload @@ -304,7 +521,9 @@ def external_claim_active(task: Task, *, now: datetime | None = None) -> bool: return claim is not None and not _claim_expired(claim, now=now) -def execution_claim_binding(task: Task, *, claim_file: Path | None = None) -> dict[str, object]: +def execution_claim_binding( + task: Task, *, claim_file: Path | None = None +) -> dict[str, object]: if claim_file is None: claim = _claim(task) else: @@ -318,7 +537,10 @@ def execution_claim_binding(task: Task, *, claim_file: Path | None = None) -> di if ( not isinstance(claim, dict) or claim.get("task_id") != task.id - or any(not isinstance(claim.get(field), str) or not claim.get(field) for field in required) + or any( + not isinstance(claim.get(field), str) or not claim.get(field) + for field in required + ) or not isinstance(claim.get("generation"), int) or int(claim["generation"]) < 1 ): @@ -351,15 +573,22 @@ def claim_task( raise ValidationError("执行器标识不能为空") require_signed_execution = getattr(config.policy, "require_signed_execution", False) if require_signed_execution and not key_id: - raise ValidationError("require_signed_execution = true 时 claim 必须提供 --key-id") + raise ValidationError( + "require_signed_execution = true 时 claim 必须提供 --key-id" + ) if key_id: from .signing import trusted_key_ids, trusted_key_principal, validate_key_id key_id = validate_key_id(key_id) if key_id not in trusted_key_ids(config.root, "execution"): raise ValidationError(f"execution key ID 尚未受信任:{key_id}") - if require_signed_execution and trusted_key_principal(config.root, "execution", key_id) != runner: - raise ValidationError("execution claim runner 必须等于 trusted key 的 principal") + if ( + require_signed_execution + and trusted_key_principal(config.root, "execution", key_id) != runner + ): + raise ValidationError( + "execution claim runner 必须等于 trusted key 的 principal" + ) lease_seconds = _claim_lease_seconds(lease_seconds) with exclusive_lock(_dispatch_lock_path(config)): with exclusive_lock(_state_lock_path(task)): @@ -370,10 +599,14 @@ def claim_task( if current not in ("backlog", "assigned", "waiting_answer") and not ( current == "in_progress" and expired ): - raise DyroError(f"仅 backlog、assigned 或 waiting_answer 任务可领取:{task.id}") + raise DyroError( + f"仅 backlog、assigned 或 waiting_answer 任务可领取:{task.id}" + ) if existing is not None and not expired: raise DyroError(f"任务 {task.id} 已被领取") - target_status = "waiting_answer" if current == "waiting_answer" else "assigned" + target_status = ( + "waiting_answer" if current == "waiting_answer" else "assigned" + ) if dry_run: return target_status now = datetime.now(timezone.utc) @@ -383,10 +616,15 @@ def claim_task( "runner": runner, "execution_key_id": key_id or "", "claimed_at": now.isoformat(timespec="seconds"), - "lease_expires_at": (now + timedelta(seconds=lease_seconds)).isoformat(timespec="seconds"), + "lease_expires_at": (now + timedelta(seconds=lease_seconds)).isoformat( + timespec="seconds" + ), "generation": int(existing.get("generation", 0)) + 1 if existing else 1, } - atomic_write_text(_claim_path(task), json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + atomic_write_text( + _claim_path(task), + json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n", + ) if current in ("backlog", "in_progress"): set_status(config, task, "assigned", force=current == "in_progress") ledger( @@ -395,7 +633,9 @@ def claim_task( "claim_takeover" if expired else "claim", runner=runner, lease_seconds=lease_seconds, - previous_runner=existing.get("runner", "") if expired and existing else "", + previous_runner=existing.get("runner", "") + if expired and existing + else "", claim_id=payload["claim_id"], generation=payload["generation"], execution_key_id=payload["execution_key_id"], @@ -429,10 +669,21 @@ def renew_task_claim( return status(config, task) now = datetime.now(timezone.utc) renewed = dict(claim) - renewed["lease_expires_at"] = (now + timedelta(seconds=lease_seconds)).isoformat(timespec="seconds") + renewed["lease_expires_at"] = ( + now + timedelta(seconds=lease_seconds) + ).isoformat(timespec="seconds") renewed["renewed_at"] = now.isoformat(timespec="seconds") - atomic_write_text(_claim_path(task), json.dumps(renewed, ensure_ascii=False, sort_keys=True) + "\n") - ledger(config, task.id, "claim_renew", runner=runner, lease_seconds=lease_seconds) + atomic_write_text( + _claim_path(task), + json.dumps(renewed, ensure_ascii=False, sort_keys=True) + "\n", + ) + ledger( + config, + task.id, + "claim_renew", + runner=runner, + lease_seconds=lease_seconds, + ) return status(config, task) @@ -444,7 +695,9 @@ def release_task_claim( dry_run: bool = False, ) -> str: if config.policy.execution_mode != "external": - raise DyroError("task claim release 仅用于 execution_mode = external 的 Profile") + raise DyroError( + "task claim release 仅用于 execution_mode = external 的 Profile" + ) _require_external_security(config) runner = runner.strip() with exclusive_lock(_dispatch_lock_path(config)): @@ -455,13 +708,26 @@ def release_task_claim( if claim["runner"] != runner: raise DyroError(f"任务 {task.id} 由其他 runner 领取") current = status(config, task) - next_status = "backlog" if current == "assigned" else "assigned" if current == "in_progress" else current + next_status = ( + "backlog" + if current == "assigned" + else "assigned" + if current == "in_progress" + else current + ) if dry_run: return next_status _claim_path(task).unlink() if next_status != current: set_status(config, task, next_status, force=True) - ledger(config, task.id, "claim_release", runner=runner, from_status=current, to_status=next_status) + ledger( + config, + task.id, + "claim_release", + runner=runner, + from_status=current, + to_status=next_status, + ) return next_status @@ -485,13 +751,23 @@ def set_status( current = status(config, task) if current == next_status: return - if config.policy.require_external_signoff and next_status == "done" and not _valid_external_signoff(config, task): - raise DyroError("当前 Profile 要求外部签收;请先使用 task signoff 写入与回执、复核绑定的签收记录") + if ( + config.policy.require_external_signoff + and next_status == "done" + and not _valid_external_signoff(config, task) + ): + raise DyroError( + "当前 Profile 要求外部签收;请先使用 task signoff 写入与回执、复核绑定的签收记录" + ) if not force and next_status not in TRANSITIONS[current]: - raise DyroError(f"拒绝状态跳转 {current} -> {next_status};如确有人工恢复需求,使用 --force 并留下审计记录") + raise DyroError( + f"拒绝状态跳转 {current} -> {next_status};如确有人工恢复需求,使用 --force 并留下审计记录" + ) if not dry_run: atomic_write_text(task.directory / "status", next_status + "\n") - ledger(config, task.id, "status", from_status=current, to_status=next_status) + ledger( + config, task.id, "status", from_status=current, to_status=next_status + ) def _set_quality_gate_status( @@ -514,9 +790,17 @@ def _set_quality_gate_status( def ledger(config: Config, task_id: str, phase: str, **fields: object) -> None: - payload = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "task_id": task_id, "phase": phase, **fields} + payload = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "task_id": task_id, + "phase": phase, + **fields, + } with exclusive_lock(config.root / ".dyro" / "ledger.lock"): - append_text(config.ledger_file, json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + append_text( + config.ledger_file, + json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n", + ) def decisions(config: Config) -> dict[str, str]: @@ -529,7 +813,11 @@ def decisions(config: Config) -> dict[str, str]: entries = raw.get("decisions", {}) if not isinstance(entries, dict): raise ValidationError("decisions.toml 必须使用 [decisions.]") - return {str(key): str(value.get("status", "open")) for key, value in entries.items() if isinstance(value, dict)} + return { + str(key): str(value.get("status", "open")) + for key, value in entries.items() + if isinstance(value, dict) + } def _assert_task_graph_valid(config: Config) -> None: @@ -542,17 +830,25 @@ def _assert_task_graph_valid(config: Config) -> None: raise ValidationError(f"任务图结构无效:{details}{suffix}") -def check_dispatchable(config: Config, task: Task, *, validate_graph: bool = True) -> None: +def check_dispatchable( + config: Config, task: Task, *, validate_graph: bool = True +) -> None: if validate_graph: _assert_task_graph_valid(config) states = decisions(config) - unresolved = [decision for decision in task.blocked_on if states.get(decision) != "resolved"] + unresolved = [ + decision for decision in task.blocked_on if states.get(decision) != "resolved" + ] if unresolved: - raise DyroError(f"任务 {task.id} 被未 resolved 的决策点阻塞:{', '.join(unresolved)}") + raise DyroError( + f"任务 {task.id} 被未 resolved 的决策点阻塞:{', '.join(unresolved)}" + ) for dependency in task.depends_on: dependency_task = load_task(config, dependency) if status(config, dependency_task) != "done": - raise DyroError(f"任务 {task.id} 依赖 {dependency},当前状态为 {status(config, dependency_task)}") + raise DyroError( + f"任务 {task.id} 依赖 {dependency},当前状态为 {status(config, dependency_task)}" + ) _assert_dependency_integrated(config, dependency_task) if task.conflict_group: active = [ @@ -570,7 +866,9 @@ def check_dispatchable(config: Config, task: Task, *, validate_graph: bool = Tru ) ] if active: - raise DyroError(f"任务 {task.id} 与活跃任务 {', '.join(active)} 共用冲突组 {task.conflict_group}") + raise DyroError( + f"任务 {task.id} 与活跃任务 {', '.join(active)} 共用冲突组 {task.conflict_group}" + ) @dataclass(frozen=True) @@ -783,6 +1081,7 @@ def _reserve_local_execution( expected_contract_sha256: str | None = None, ) -> None: """Check dispatch constraints and atomically reserve the task before starting an Agent.""" + def reserve() -> None: with exclusive_lock(_state_lock_path(task)): _assert_expected_task_contract(task, expected_contract_sha256) @@ -807,7 +1106,9 @@ def reserve() -> None: reserve() -def _assert_expected_task_contract(task: Task, expected_contract_sha256: str | None) -> None: +def _assert_expected_task_contract( + task: Task, expected_contract_sha256: str | None +) -> None: """Fail closed when a supervised Action's pinned Task contract drifted. This executes under the Task state lock for execution and immediately @@ -819,7 +1120,10 @@ def _assert_expected_task_contract(task: Task, expected_contract_sha256: str | N if ( not isinstance(expected_contract_sha256, str) or len(expected_contract_sha256) != 64 - or any(character not in "0123456789abcdef" for character in expected_contract_sha256) + or any( + character not in "0123456789abcdef" + for character in expected_contract_sha256 + ) ): raise ValidationError("受监督 Action 的 Task contract 摘要无效") try: @@ -827,7 +1131,9 @@ def _assert_expected_task_contract(task: Task, expected_contract_sha256: str | N except OSError as exc: raise ValidationError(f"无法读取任务 {task.id} contract") from exc if actual != expected_contract_sha256: - raise DyroError("Task contract 已在受监督 Action 确认后变化;请 objective reconcile 后重新确认") + raise DyroError( + "Task contract 已在受监督 Action 确认后变化;请 objective reconcile 后重新确认" + ) def worktree_root(config: Config, task: Task) -> Path: @@ -835,20 +1141,35 @@ def worktree_root(config: Config, task: Task) -> Path: def _resolved_git_common_dir(path: Path) -> Path: - raw = require_ok(git(path, "rev-parse", "--git-common-dir"), f"读取 Git common dir:{path}").stdout.strip() + raw = require_ok( + git(path, "rev-parse", "--git-common-dir"), f"读取 Git common dir:{path}" + ).stdout.strip() common_dir = Path(raw) - return common_dir.resolve() if common_dir.is_absolute() else (path / common_dir).resolve() + return ( + common_dir.resolve() + if common_dir.is_absolute() + else (path / common_dir).resolve() + ) -def _validate_task_worktree(config: Config, task: Task, repo_id: str, destination: Path, branch: str) -> None: +def _validate_task_worktree( + config: Config, task: Task, repo_id: str, destination: Path, branch: str +) -> None: if git(destination, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true": raise DyroError(f"不是有效的任务 Git worktree:{destination}") - top_level = require_ok(git(destination, "rev-parse", "--show-toplevel"), f"读取 {repo_id} worktree 根目录").stdout.strip() + top_level = require_ok( + git(destination, "rev-parse", "--show-toplevel"), + f"读取 {repo_id} worktree 根目录", + ).stdout.strip() if Path(top_level).resolve() != destination.resolve(): raise DyroError(f"任务 worktree 根目录错误:{destination} 实际为 {top_level}") - current = require_ok(git(destination, "branch", "--show-current"), f"读取 {repo_id} 任务分支").stdout.strip() + current = require_ok( + git(destination, "branch", "--show-current"), f"读取 {repo_id} 任务分支" + ).stdout.strip() if current != branch: - raise DyroError(f"任务 worktree 分支错误:{destination} 当前 {current or 'DETACHED'},期望 {branch}") + raise DyroError( + f"任务 worktree 分支错误:{destination} 当前 {current or 'DETACHED'},期望 {branch}" + ) anchor = repository_path(config, repo_id) if _resolved_git_common_dir(destination) != _resolved_git_common_dir(anchor): raise DyroError(f"任务 worktree 不属于配置的仓库 anchor:{destination}") @@ -859,7 +1180,9 @@ def existing_task_workspace(config: Config, task: Task) -> Path: root = worktree_root(config, task) if not root.is_dir(): - raise DyroError(f"任务 {task.id} 尚未创建可进入的工作区。下一步:dyro task run {task.id}") + raise DyroError( + f"任务 {task.id} 尚未创建可进入的工作区。下一步:dyro task run {task.id}" + ) branch = f"{config.policy.task_branch_prefix}{task.id}" for repo_id in task.repositories: destination = root / config.repositories[repo_id].mount @@ -871,27 +1194,42 @@ def existing_task_workspace(config: Config, task: Task) -> Path: return root -def _ensure_task_worktrees(config: Config, task: Task, line: Line, *, dry_run: bool = False) -> Path: +def _ensure_task_worktrees( + config: Config, task: Task, line: Line, *, dry_run: bool = False +) -> Path: root = worktree_root(config, task) branch = f"{config.policy.task_branch_prefix}{task.id}" - not_on_line = [repo_id for repo_id in task.repositories if repo_id not in line.repositories] + not_on_line = [ + repo_id for repo_id in task.repositories if repo_id not in line.repositories + ] if not_on_line: - raise ValidationError(f"任务 {task.id} 引用的仓库不在开发线 {line.id}:{', '.join(not_on_line)}") + raise ValidationError( + f"任务 {task.id} 引用的仓库不在开发线 {line.id}:{', '.join(not_on_line)}" + ) for repo_id in task.repositories: anchor = repository_path(config, repo_id) destination = root / config.repositories[repo_id].mount if destination.exists(): _validate_task_worktree(config, task, repo_id, destination, branch) continue - require_ok(git(anchor, "rev-parse", "--verify", f"{line.branch}^{{commit}}"), f"校验 {repo_id} 开发线基线") - branch_exists = git(anchor, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").code == 0 + require_ok( + git(anchor, "rev-parse", "--verify", f"{line.branch}^{{commit}}"), + f"校验 {repo_id} 开发线基线", + ) + branch_exists = ( + git(anchor, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").code + == 0 + ) command: tuple[str, ...] = ("worktree", "add") if not branch_exists: command += ("-b", branch) command += (str(destination), branch if branch_exists else line.branch) if not dry_run: destination.parent.mkdir(parents=True, exist_ok=True) - require_ok(git(anchor, *command, dry_run=dry_run, timeout=300), f"创建任务 worktree {repo_id}") + require_ok( + git(anchor, *command, dry_run=dry_run, timeout=300), + f"创建任务 worktree {repo_id}", + ) return root @@ -903,15 +1241,20 @@ def _collect_task_heads(config: Config, task: Task) -> dict[str, str]: destination = root / config.repositories[repo_id].mount _validate_task_worktree(config, task, repo_id, destination, branch) dirty = require_ok( - git(destination, "status", "--porcelain=v1", "-uall"), f"读取 {repo_id} 任务 worktree 状态" + git(destination, "status", "--porcelain=v1", "-uall"), + f"读取 {repo_id} 任务 worktree 状态", ).stdout.strip() if dirty: raise DyroError(f"任务 worktree 不干净,必须先提交全部改动:{destination}") - heads[repo_id] = require_ok(git(destination, "rev-parse", "HEAD"), f"读取 {repo_id} 任务 HEAD").stdout.strip() + heads[repo_id] = require_ok( + git(destination, "rev-parse", "HEAD"), f"读取 {repo_id} 任务 HEAD" + ).stdout.strip() return heads -def _task_heads_payload(config: Config, task: Task, heads: dict[str, str]) -> dict[str, object]: +def _task_heads_payload( + config: Config, task: Task, heads: dict[str, str] +) -> dict[str, object]: return { "schema_version": 1, "task_id": task.id, @@ -921,7 +1264,9 @@ def _task_heads_payload(config: Config, task: Task, heads: dict[str, str]) -> di } -def _validate_task_heads_payload(config: Config, task: Task, payload: object) -> dict[str, str]: +def _validate_task_heads_payload( + config: Config, task: Task, payload: object +) -> dict[str, str]: expected_branch = f"{config.policy.task_branch_prefix}{task.id}" if not isinstance(payload, dict): raise ValidationError("任务 HEAD 证据必须是 JSON 对象") @@ -933,7 +1278,9 @@ def _validate_task_heads_payload(config: Config, task: Task, payload: object) -> or not isinstance(payload.get("branch"), str) or not isinstance(repositories, dict) ): - raise ValidationError("任务 HEAD 证据的 schema_version、task_id、line、branch 或 repositories 无效") + raise ValidationError( + "任务 HEAD 证据的 schema_version、task_id、line、branch 或 repositories 无效" + ) if payload["branch"] != expected_branch: raise ValidationError(f"任务 HEAD 证据分支错误:期望 {expected_branch}") if set(repositories) != set(task.repositories): @@ -949,7 +1296,9 @@ def _validate_task_heads_payload(config: Config, task: Task, payload: object) -> def _serialize_task_heads(config: Config, task: Task, heads: dict[str, str]) -> bytes: payload = _task_heads_payload(config, task, heads) - return (json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode() + return ( + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + ).encode() def _record_task_heads(config: Config, task: Task) -> str: @@ -990,8 +1339,14 @@ def _assert_task_heads_current(config: Config, task: Task) -> dict[str, str]: expected = _load_task_heads(config, task) current = _collect_task_heads(config, task) if current != expected: - changed = sorted(repo_id for repo_id in task.repositories if current.get(repo_id) != expected.get(repo_id)) - raise DyroError(f"任务代码已偏离已记录 HEAD,必须重新执行与复核:{', '.join(changed)}") + changed = sorted( + repo_id + for repo_id in task.repositories + if current.get(repo_id) != expected.get(repo_id) + ) + raise DyroError( + f"任务代码已偏离已记录 HEAD,必须重新执行与复核:{', '.join(changed)}" + ) return expected @@ -1016,7 +1371,11 @@ def _review_decision(task: Task) -> tuple[str, str, str]: return "", "", "" content = review.read_text(encoding="utf-8") lines = content.splitlines() - verdict = VERDICT_RE.match(lines[0]).group(1).upper() if lines and VERDICT_RE.match(lines[0]) else "" + verdict = ( + VERDICT_RE.match(lines[0]).group(1).upper() + if lines and VERDICT_RE.match(lines[0]) + else "" + ) receipt_hash = RECEIPT_SHA_RE.search(content) task_heads_hash = TASK_HEADS_SHA_RE.search(content) return ( @@ -1026,7 +1385,9 @@ def _review_decision(task: Task) -> tuple[str, str, str]: ) -def _external_execution_and_reviewer_principals(config: Config, task: Task) -> tuple[str, str]: +def _external_execution_and_reviewer_principals( + config: Config, task: Task +) -> tuple[str, str]: """Return the independently authenticated execution and review principals.""" from .signing import trusted_key_principal @@ -1068,10 +1429,19 @@ def _valid_external_signoff(config: Config, task: Task) -> bool: signoff = json.loads(signoff_path.read_text(encoding="utf-8")) except json.JSONDecodeError: return False - if not isinstance(signoff, dict) or not isinstance(signoff.get("approver"), str) or not signoff["approver"].strip(): + if ( + not isinstance(signoff, dict) + or not isinstance(signoff.get("approver"), str) + or not signoff["approver"].strip() + ): return False try: - from .signing import signature_key_id, trusted_key_principal, trusted_keys_directory, verify_record + from .signing import ( + signature_key_id, + trusted_key_principal, + trusted_keys_directory, + verify_record, + ) verify_record( signoff, @@ -1083,13 +1453,17 @@ def _valid_external_signoff(config: Config, task: Task) -> bool: signoff_key_id = signature_key_id(signoff) if signoff_key_id is None: return False - approver_principal = trusted_key_principal(config.root, "signoff", signoff_key_id) + approver_principal = trusted_key_principal( + config.root, "signoff", signoff_key_id + ) if ( signoff.get("actor") != signoff["approver"] or approver_principal != signoff["approver"] ): return False - execution_principal, reviewer_principal = _external_execution_and_reviewer_principals(config, task) + execution_principal, reviewer_principal = ( + _external_execution_and_reviewer_principals(config, task) + ) if approver_principal in (execution_principal, reviewer_principal): return False except (DyroError, ValidationError): @@ -1100,11 +1474,15 @@ def _valid_external_signoff(config: Config, task: Task) -> bool: try: receipt_hash = _file_sha256(resolve_evidence_path(task.directory, "receipt.md")) review_hash = _file_sha256(task.directory / "review.md") - task_heads_hash = _file_sha256(resolve_evidence_path(task.directory, TASK_HEADS_FILE)) + task_heads_hash = _file_sha256( + resolve_evidence_path(task.directory, TASK_HEADS_FILE) + ) except DyroError: return False review_content = (task.directory / "review.md").read_text(encoding="utf-8") - binding_matches, expected_binding, _ = validate_review_binding(task.directory, review_content) + binding_matches, expected_binding, _ = validate_review_binding( + task.directory, review_content + ) if not binding_matches: return False if config.policy.execution_mode == "local": @@ -1169,13 +1547,24 @@ def _require_local_execution(config: Config, action: str, *, dry_run: bool) -> N ) -def _adapter_argv(config: Config, agent: str, mode: str, *, workspace: Path, prompt: str, task: Task) -> tuple[str, ...]: +def _adapter_argv( + config: Config, agent: str, mode: str, *, workspace: Path, prompt: str, task: Task +) -> tuple[str, ...]: try: adapter = config.adapters[agent] except KeyError as exc: - raise ValidationError(f"任务 {task.id} 使用的 Agent adapter 未配置:{agent}") from exc + raise ValidationError( + f"任务 {task.id} 使用的 Agent adapter 未配置:{agent}" + ) from exc template = adapter.write if mode == "write" else adapter.read - return expand_argv(template, workspace=workspace, root=config.root, prompt=prompt, task=task.id, line=task.line) + return expand_argv( + template, + workspace=workspace, + root=config.root, + prompt=prompt, + task=task.id, + line=task.line, + ) def _prompt(task: Task, phase: str, workspace: Path) -> str: @@ -1218,7 +1607,9 @@ def _capture(task: Task, filename: str, output: str, *, dry_run: bool = False) - return target -def _copy_external_evidence(task: Task, source: Path, target_name: str, *, dry_run: bool = False) -> Path: +def _copy_external_evidence( + task: Task, source: Path, target_name: str, *, dry_run: bool = False +) -> Path: if not source.is_file(): raise DyroError(f"外部证据文件不存在:{source}") relative = Path(target_name) @@ -1230,10 +1621,14 @@ def _copy_external_evidence(task: Task, source: Path, target_name: str, *, dry_r return target -def _validate_external_gates(task: Task, receipt_sha256: str, gates: Path | None) -> tuple[bytes, tuple[tuple[str, bytes], ...]]: +def _validate_external_gates( + task: Task, receipt_sha256: str, gates: Path | None +) -> tuple[bytes, tuple[tuple[str, bytes], ...]]: if gates is None: if task.gates: - raise DyroError(f"任务 {task.id} 配置了门禁,导入执行证据时必须提供 --gates") + raise DyroError( + f"任务 {task.id} 配置了门禁,导入执行证据时必须提供 --gates" + ) return b"", () if not gates.is_file(): raise DyroError(f"外部门禁证据文件不存在:{gates}") @@ -1242,7 +1637,11 @@ def _validate_external_gates(task: Task, receipt_sha256: str, gates: Path | None payload = json.loads(data) except json.JSONDecodeError as exc: raise ValidationError(f"外部门禁证据必须是 JSON:{gates}") from exc - if not isinstance(payload, dict) or payload.get("schema_version") != 1 or payload.get("task_id") != task.id: + if ( + not isinstance(payload, dict) + or payload.get("schema_version") != 1 + or payload.get("task_id") != task.id + ): raise ValidationError("外部门禁证据的 schema_version 或 task_id 无效") if payload.get("receipt_sha256") != receipt_sha256: raise DyroError("外部门禁证据未绑定当前回执") @@ -1254,22 +1653,38 @@ def _validate_external_gates(task: Task, receipt_sha256: str, gates: Path | None logs: list[tuple[str, bytes]] = [] evidence_root = gates.parent.resolve() for entry in entries: - if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or isinstance(entry.get("exit_code"), bool) or not isinstance(entry.get("exit_code"), int): + if ( + not isinstance(entry, dict) + or not isinstance(entry.get("name"), str) + or isinstance(entry.get("exit_code"), bool) + or not isinstance(entry.get("exit_code"), int) + ): raise ValidationError("外部门禁条目必须包含 name 和整数 exit_code") name = entry["name"] if name in observed: raise ValidationError(f"外部门禁证据重复声明门禁:{name}") log = entry.get("log") log_sha256 = entry.get("log_sha256") - if not isinstance(log, str) or not log or Path(log).is_absolute() or ".." in Path(log).parts: - raise ValidationError(f"外部门禁 {name} 必须提供 gates JSON 相对目录内的 log") - if not isinstance(log_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", log_sha256, re.IGNORECASE): + if ( + not isinstance(log, str) + or not log + or Path(log).is_absolute() + or ".." in Path(log).parts + ): + raise ValidationError( + f"外部门禁 {name} 必须提供 gates JSON 相对目录内的 log" + ) + if not isinstance(log_sha256, str) or not re.fullmatch( + r"[0-9a-f]{64}", log_sha256, re.IGNORECASE + ): raise ValidationError(f"外部门禁 {name} 必须提供 log_sha256") log_path = (gates.parent / log).resolve() try: log_path.relative_to(evidence_root) except ValueError as exc: - raise ValidationError(f"外部门禁 {name} 的 log 不得位于 gates JSON 目录外") from exc + raise ValidationError( + f"外部门禁 {name} 的 log 不得位于 gates JSON 目录外" + ) from exc if not log_path.is_file(): raise DyroError(f"外部门禁 {name} 的日志不存在:{log_path}") log_bytes = log_path.read_bytes() @@ -1278,7 +1693,9 @@ def _validate_external_gates(task: Task, receipt_sha256: str, gates: Path | None observed[name] = entry["exit_code"] logs.append((name, log_bytes)) if set(observed) != expected: - raise DyroError(f"外部门禁集合与任务不一致;期望 {', '.join(sorted(expected)) or '-'}") + raise DyroError( + f"外部门禁集合与任务不一致;期望 {', '.join(sorted(expected)) or '-'}" + ) failures = [name for name, exit_code in observed.items() if exit_code != 0] if failures: raise DyroError(f"外部门禁未通过:{', '.join(sorted(failures))}") @@ -1287,7 +1704,9 @@ def _validate_external_gates(task: Task, receipt_sha256: str, gates: Path | None def _validate_external_heads(config: Config, task: Task, heads: Path | None) -> bytes: if heads is None: - raise DyroError(f"任务 {task.id} 完成时必须提供 --heads,绑定执行后的逐仓 Git HEAD") + raise DyroError( + f"任务 {task.id} 完成时必须提供 --heads,绑定执行后的逐仓 Git HEAD" + ) if not heads.is_file(): raise DyroError(f"外部任务 HEAD 证据文件不存在:{heads}") data = heads.read_bytes() @@ -1317,13 +1736,23 @@ def run_gates(config: Config, task: Task, *, dry_run: bool = False) -> bool: all_passed = True for index, gate in enumerate(task.gates, start=1): cwd = root / gate.cwd - argv = expand_argv(gate.argv, workspace=root, root=config.root, task=task.id, line=task.line) + argv = expand_argv( + gate.argv, workspace=root, root=config.root, task=task.id, line=task.line + ) result = run(argv, cwd=cwd, timeout=gate.timeout_seconds, dry_run=dry_run) _capture(task, f"gate-{index}.log", result.stdout, dry_run=dry_run) passed = result.code == 0 all_passed = all_passed and passed if not dry_run: - ledger(config, task.id, "gate", name=gate.name, argv=list(argv), passed=passed, exit_code=result.code) + ledger( + config, + task.id, + "gate", + name=gate.name, + argv=list(argv), + passed=passed, + exit_code=result.code, + ) return all_passed @@ -1367,7 +1796,9 @@ def run_task( ) -def _run_task(config: Config, task: Task, *, dry_run: bool, reserved: bool = False) -> str: +def _run_task( + config: Config, task: Task, *, dry_run: bool, reserved: bool = False +) -> str: if not reserved: _reserve_local_execution( config, @@ -1383,11 +1814,27 @@ def _run_task(config: Config, task: Task, *, dry_run: bool, reserved: bool = Fal if not dry_run: set_status(config, task, "failed") raise - argv = _adapter_argv(config, task.executor, "write" if task.risk == "write" else "read", workspace=workspace, prompt=_prompt(task, "executor", workspace), task=task) - result = run(argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run) + argv = _adapter_argv( + config, + task.executor, + "write" if task.risk == "write" else "read", + workspace=workspace, + prompt=_prompt(task, "executor", workspace), + task=task, + ) + result = run( + argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run + ) _capture(task, "executor.log", result.stdout, dry_run=dry_run) if not dry_run: - ledger(config, task.id, "executor", agent=task.executor, argv=list(argv), exit_code=result.code) + ledger( + config, + task.id, + "executor", + agent=task.executor, + argv=list(argv), + exit_code=result.code, + ) if result.code != 0: set_status(config, task, "failed", dry_run=dry_run) return "failed" @@ -1456,14 +1903,22 @@ def _import_execution_evidence( if not receipt.is_file(): raise DyroError(f"外部回执文件不存在:{receipt}") if provenance is None and not allow_legacy_provenance: - raise DyroError("外部执行证据缺少 provenance;旧证据必须显式使用 --allow-legacy") + raise DyroError( + "外部执行证据缺少 provenance;旧证据必须显式使用 --allow-legacy" + ) receipt_bytes = receipt.read_bytes() receipt_hash = hashlib.sha256(receipt_bytes).hexdigest() receipt_lines = receipt_bytes.decode("utf-8").splitlines() receipt_match = RESULT_RE.match(receipt_lines[0]) if receipt_lines else None result = receipt_match.group(1).upper() if receipt_match else "" - gate_bytes, gate_logs = _validate_external_gates(task, receipt_hash, gates) if result == "DONE" else (b"", ()) - task_heads_bytes = _validate_external_heads(config, task, heads) if result == "DONE" else b"" + gate_bytes, gate_logs = ( + _validate_external_gates(task, receipt_hash, gates) + if result == "DONE" + else (b"", ()) + ) + task_heads_bytes = ( + _validate_external_heads(config, task, heads) if result == "DONE" else b"" + ) claim_binding = ( execution_claim_binding(task) if getattr(config.policy, "require_signed_execution", False) @@ -1484,12 +1939,17 @@ def _import_execution_evidence( result=result, expected_plan=expected_plan, gates_sha256=hashlib.sha256(gate_bytes).hexdigest() if gate_bytes else "", - task_heads_sha256=hashlib.sha256(task_heads_bytes).hexdigest() if task_heads_bytes else "", + task_heads_sha256=hashlib.sha256(task_heads_bytes).hexdigest() + if task_heads_bytes + else "", trusted_keys_dir=trusted_keys_directory(config.root, "execution"), require_signature=getattr(config.policy, "require_signed_execution", False), dry_run=True, ) - if claim_binding is not None and signature_key_id(external_attempt) != claim_binding["execution_key_id"]: + if ( + claim_binding is not None + and signature_key_id(external_attempt) != claim_binding["execution_key_id"] + ): raise ValidationError("execution signature key ID 与当前 claim 不匹配") if claim_binding is not None: execution_principal = trusted_key_principal( @@ -1497,16 +1957,28 @@ def _import_execution_evidence( "execution", str(claim_binding["execution_key_id"]), ) - if external_attempt.get("actor") != claim_binding["runner"] or execution_principal != claim_binding["runner"]: - raise ValidationError("execution signature actor 必须等于当前 claim runner principal") + if ( + external_attempt.get("actor") != claim_binding["runner"] + or execution_principal != claim_binding["runner"] + ): + raise ValidationError( + "execution signature actor 必须等于当前 claim runner principal" + ) if dry_run: - return "review" if result == "DONE" else "waiting_answer" if result == "QUESTION" else "failed" + return ( + "review" + if result == "DONE" + else "waiting_answer" + if result == "QUESTION" + else "failed" + ) from .provenance import persist_external_execution_attempt generation_files: dict[str | Path, bytes] = { "receipt.md": receipt_bytes, "provenance.json": ( - json.dumps(external_attempt, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + json.dumps(external_attempt, ensure_ascii=False, sort_keys=True, indent=2) + + "\n" ).encode("utf-8"), } if gate_bytes: @@ -1539,7 +2011,9 @@ def collect_attempt_artifact(target: Path, content: bytes) -> None: "external_execution_import", runner=claim["runner"], receipt_sha256=receipt_hash, - task_heads_sha256=hashlib.sha256(task_heads_bytes).hexdigest() if task_heads_bytes else "", + task_heads_sha256=hashlib.sha256(task_heads_bytes).hexdigest() + if task_heads_bytes + else "", run_id=external_attempt["run_id"], attempt_id=external_attempt["attempt_id"], plan_sha256=external_attempt["plan_sha256"], @@ -1555,12 +2029,16 @@ def collect_attempt_artifact(target: Path, content: bytes) -> None: return "review" -def answer_task(config: Config, task: Task, answer: str, *, dry_run: bool = False) -> str: +def answer_task( + config: Config, task: Task, answer: str, *, dry_run: bool = False +) -> str: if config.policy.execution_mode == "external": with exclusive_lock(_execution_lock_path(task), timeout_seconds=1.0): claim = _require_external_claim(config, task) if status(config, task) != "waiting_answer": - raise DyroError(f"任务 {task.id} 当前不是 waiting_answer,不能记录外部续跑答案") + raise DyroError( + f"任务 {task.id} 当前不是 waiting_answer,不能记录外部续跑答案" + ) if dry_run: return "dry-run" atomic_write_text(task.directory / "answers.md", answer.rstrip() + "\n") @@ -1606,7 +2084,9 @@ def answer_task(config: Config, task: Task, answer: str, *, dry_run: bool = Fals ) -def _answer_task(config: Config, task: Task, answer: str, *, dry_run: bool, reserved: bool = False) -> str: +def _answer_task( + config: Config, task: Task, answer: str, *, dry_run: bool, reserved: bool = False +) -> str: if not reserved: _reserve_local_execution( config, @@ -1624,8 +2104,17 @@ def _answer_task(config: Config, task: Task, answer: str, *, dry_run: bool, rese if not dry_run: set_status(config, task, "failed") raise - argv = _adapter_argv(config, task.executor, "write" if task.risk == "write" else "read", workspace=workspace, prompt=_prompt(task, "continuation", workspace), task=task) - result = run(argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run) + argv = _adapter_argv( + config, + task.executor, + "write" if task.risk == "write" else "read", + workspace=workspace, + prompt=_prompt(task, "continuation", workspace), + task=task, + ) + result = run( + argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run + ) _capture(task, "executor-continuation.log", result.stdout, dry_run=dry_run) if result.code != 0: set_status(config, task, "failed", dry_run=dry_run) @@ -1652,9 +2141,17 @@ def _answer_task(config: Config, task: Task, answer: str, *, dry_run: bool, rese def _apply_review_decision(config: Config, task: Task, *, dry_run: bool = False) -> str: verdict, reviewed_receipt_hash, reviewed_task_heads_hash = _review_decision(task) receipt_hash = _file_sha256(resolve_evidence_path(task.directory, "receipt.md")) - task_heads_hash = _file_sha256(resolve_evidence_path(task.directory, TASK_HEADS_FILE)) - review_content = (task.directory / "review.md").read_text(encoding="utf-8") if (task.directory / "review.md").is_file() else "" - binding_matches, expected_binding, reviewed_binding = validate_review_binding(task.directory, review_content) + task_heads_hash = _file_sha256( + resolve_evidence_path(task.directory, TASK_HEADS_FILE) + ) + review_content = ( + (task.directory / "review.md").read_text(encoding="utf-8") + if (task.directory / "review.md").is_file() + else "" + ) + binding_matches, expected_binding, reviewed_binding = validate_review_binding( + task.directory, review_content + ) if verdict in ("PASS", "FAIL") and not binding_matches: if not dry_run: ledger( @@ -1669,7 +2166,10 @@ def _apply_review_decision(config: Config, task: Task, *, dry_run: bool = False) ) return "review" if verdict == "PASS": - if reviewed_receipt_hash != receipt_hash or reviewed_task_heads_hash != task_heads_hash: + if ( + reviewed_receipt_hash != receipt_hash + or reviewed_task_heads_hash != task_heads_hash + ): if not dry_run: ledger( config, @@ -1682,7 +2182,11 @@ def _apply_review_decision(config: Config, task: Task, *, dry_run: bool = False) reviewed_task_heads_sha256=reviewed_task_heads_hash, ) return "review" - next_status = "review_pending_signoff" if config.policy.require_external_signoff else "done" + next_status = ( + "review_pending_signoff" + if config.policy.require_external_signoff + else "done" + ) if config.policy.execution_mode == "local": _assert_task_heads_current(config, task) _set_quality_gate_status(config, task, next_status, dry_run=dry_run) @@ -1715,9 +2219,19 @@ def review_task( ) -> str: _require_local_execution(config, "复核", dry_run=dry_run) if dry_run: - return _review_task(config, task, dry_run=True, expected_contract_sha256=expected_contract_sha256) + return _review_task( + config, + task, + dry_run=True, + expected_contract_sha256=expected_contract_sha256, + ) with exclusive_lock(_review_lock_path(task), timeout_seconds=1.0): - return _review_task(config, task, dry_run=False, expected_contract_sha256=expected_contract_sha256) + return _review_task( + config, + task, + dry_run=False, + expected_contract_sha256=expected_contract_sha256, + ) def _review_task( @@ -1735,16 +2249,34 @@ def _review_task( raise DyroError(f"任务 worktree 不存在:{workspace}") if not dry_run: _assert_task_heads_current(config, task) - argv = _adapter_argv(config, task.reviewer, "read", workspace=workspace, prompt=_prompt(task, "reviewer", workspace), task=task) - result = run(argv, cwd=workspace, timeout=task.review_timeout_minutes * 60, dry_run=dry_run) + argv = _adapter_argv( + config, + task.reviewer, + "read", + workspace=workspace, + prompt=_prompt(task, "reviewer", workspace), + task=task, + ) + result = run( + argv, cwd=workspace, timeout=task.review_timeout_minutes * 60, dry_run=dry_run + ) _capture(task, "reviewer.log", result.stdout, dry_run=dry_run) if not dry_run: - ledger(config, task.id, "review", agent=task.reviewer, argv=list(argv), exit_code=result.code) + ledger( + config, + task.id, + "review", + agent=task.reviewer, + argv=list(argv), + exit_code=result.code, + ) try: _assert_task_heads_current(config, task) except (DyroError, ValidationError) as exc: ledger(config, task.id, "review_source_changed", error=str(exc)) - raise DyroError(f"复核期间任务源码发生变化,拒绝接受复核结果:{exc}") from exc + raise DyroError( + f"复核期间任务源码发生变化,拒绝接受复核结果:{exc}" + ) from exc if result.code != 0: return "review" if dry_run: @@ -1752,12 +2284,16 @@ def _review_task( return _apply_review_decision(config, task) -def import_review_evidence(config: Config, task: Task, *, review: Path, dry_run: bool = False) -> str: +def import_review_evidence( + config: Config, task: Task, *, review: Path, dry_run: bool = False +) -> str: with exclusive_lock(_state_lock_path(task)): return _import_review_evidence(config, task, review=review, dry_run=dry_run) -def _import_review_evidence(config: Config, task: Task, *, review: Path, dry_run: bool = False) -> str: +def _import_review_evidence( + config: Config, task: Task, *, review: Path, dry_run: bool = False +) -> str: """Import a receipt-bound independent review, signed when policy requires it.""" if status(config, task) != "review": raise DyroError(f"仅 review 任务可导入复核证据:{task.id}") @@ -1772,10 +2308,18 @@ def _import_review_evidence(config: Config, task: Task, *, review: Path, dry_run ) if config.policy.execution_mode == "external": claim = _require_external_claim(config, task) - if not evidence.signed or evidence.key_id is None or evidence.principal_id is None: - raise ValidationError("external review 必须使用带 principal 的 signed review") + if ( + not evidence.signed + or evidence.key_id is None + or evidence.principal_id is None + ): + raise ValidationError( + "external review 必须使用带 principal 的 signed review" + ) execution_key_id = str(claim.get("execution_key_id", "")) - execution_principal = trusted_key_principal(config.root, "execution", execution_key_id) + execution_principal = trusted_key_principal( + config.root, "execution", execution_key_id + ) if evidence.principal_id == execution_principal: raise ValidationError("execution claimant 不得复核自己的结果") reviewer = evidence.principal_id @@ -1786,7 +2330,11 @@ def _import_review_evidence(config: Config, task: Task, *, review: Path, dry_run if dry_run: return "dry-run" atomic_write_bytes(task.directory / "review.md", evidence.content) - if evidence.signed and evidence.key_id is not None and evidence.principal_id is not None: + if ( + evidence.signed + and evidence.key_id is not None + and evidence.principal_id is not None + ): atomic_write_text( task.directory / REVIEW_IDENTITY_FILE, json.dumps( @@ -1853,7 +2401,9 @@ def _signoff_task( raise ValidationError("签收人不能为空") verdict, reviewed_receipt_hash, reviewed_task_heads_hash = _review_decision(task) receipt_hash = _file_sha256(resolve_evidence_path(task.directory, "receipt.md")) - task_heads_hash = _file_sha256(resolve_evidence_path(task.directory, TASK_HEADS_FILE)) + task_heads_hash = _file_sha256( + resolve_evidence_path(task.directory, TASK_HEADS_FILE) + ) if ( verdict != "PASS" or reviewed_receipt_hash != receipt_hash @@ -1861,7 +2411,9 @@ def _signoff_task( ): raise DyroError("复核结论未通过或未绑定当前回执与任务 HEAD;请重新复核") review_content = (task.directory / "review.md").read_text(encoding="utf-8") - binding_matches, expected_binding, _ = validate_review_binding(task.directory, review_content) + binding_matches, expected_binding, _ = validate_review_binding( + task.directory, review_content + ) if not binding_matches: raise DyroError("复核结论未绑定当前 execution attempt 与 plan;请重新复核") signoff = { @@ -1877,7 +2429,12 @@ def _signoff_task( } if (signing_key is None) != (key_id is None): raise ValidationError("--signing-key 与 --key-id 必须同时提供") - from .signing import sign_record, trusted_key_principal, trusted_keys_directory, verify_record + from .signing import ( + sign_record, + trusted_key_principal, + trusted_keys_directory, + verify_record, + ) if signing_key is not None and key_id is not None: signoff = sign_record( @@ -1898,11 +2455,18 @@ def _signoff_task( approver_principal = trusted_key_principal(config.root, "signoff", key_id) if approver_principal != approver or signoff.get("actor") != approver: raise ValidationError("signoff actor 必须等于 signoff key 的 principal") - execution_principal, reviewer_principal = _external_execution_and_reviewer_principals(config, task) + execution_principal, reviewer_principal = ( + _external_execution_and_reviewer_principals(config, task) + ) if approver_principal in (execution_principal, reviewer_principal): - raise ValidationError("signoff principal 必须独立于 execution 与 review principal") + raise ValidationError( + "signoff principal 必须独立于 execution 与 review principal" + ) if not dry_run: - atomic_write_text(task.directory / "signoff.json", json.dumps(signoff, ensure_ascii=False, sort_keys=True, indent=2) + "\n") + atomic_write_text( + task.directory / "signoff.json", + json.dumps(signoff, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) _set_quality_gate_status(config, task, "done") ledger( config, @@ -1926,7 +2490,9 @@ def _prepare_merge( dry_run: bool, ) -> tuple[Line, tuple[MergePlan, ...]]: if push and not config.policy.allow_push: - raise DyroError("当前 Profile 禁止 push;请在 dyro.toml 的 policy.allow_push 显式开启") + raise DyroError( + "当前 Profile 禁止 push;请在 dyro.toml 的 policy.allow_push 显式开启" + ) line = get_line(config, task.line) task_heads = _assert_task_heads_current(config, task) plans: list[MergePlan] = [] @@ -1934,24 +2500,41 @@ def _prepare_merge( target = line_repository_path(config, line, repo_id) if git(target, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true": raise DyroError(f"开发线 worktree 不存在或不是 Git:{target}") - dirty = require_ok(git(target, "status", "--porcelain=v1", "-uall"), f"读取 {repo_id} 状态").stdout.strip() + dirty = require_ok( + git(target, "status", "--porcelain=v1", "-uall"), f"读取 {repo_id} 状态" + ).stdout.strip() if dirty: raise DyroError(f"开发线仓库不干净,拒绝合并:{target}") - current = require_ok(git(target, "branch", "--show-current"), f"读取 {repo_id} 分支").stdout.strip() + current = require_ok( + git(target, "branch", "--show-current"), f"读取 {repo_id} 分支" + ).stdout.strip() if current != line.branch: - raise DyroError(f"开发线仓库分支错误:{target} 当前 {current or 'DETACHED'},期望 {line.branch}") - original_head = require_ok(git(target, "rev-parse", "HEAD"), f"读取 {repo_id} 开发线 HEAD").stdout.strip() + raise DyroError( + f"开发线仓库分支错误:{target} 当前 {current or 'DETACHED'},期望 {line.branch}" + ) + original_head = require_ok( + git(target, "rev-parse", "HEAD"), f"读取 {repo_id} 开发线 HEAD" + ).stdout.strip() plans.append(MergePlan(repo_id, target, task_heads[repo_id], original_head)) if push: for plan in plans: require_ok( - git(plan.target, "push", "--dry-run", "origin", line.branch, dry_run=dry_run), + git( + plan.target, + "push", + "--dry-run", + "origin", + line.branch, + dry_run=dry_run, + ), f"预检推送 {plan.repository}", ) return line, tuple(plans) -def _rollback_merges(plans: Iterable[MergePlan], committed_heads: dict[str, str]) -> list[str]: +def _rollback_merges( + plans: Iterable[MergePlan], committed_heads: dict[str, str] +) -> list[str]: failures: list[str] = [] for plan in reversed(tuple(plans)): merge_head = git(plan.target, "rev-parse", "--verify", "-q", "MERGE_HEAD") @@ -1966,27 +2549,45 @@ def _rollback_merges(plans: Iterable[MergePlan], committed_heads: dict[str, str] failures.append(f"{plan.repository}: cannot read HEAD during rollback") continue if current.stdout.strip() != committed_head: - failures.append(f"{plan.repository}: HEAD changed concurrently; manual recovery required") + failures.append( + f"{plan.repository}: HEAD changed concurrently; manual recovery required" + ) continue result = git(plan.target, "reset", "--keep", plan.original_head) if result.code != 0: - failures.append(f"{plan.repository}: {result.stdout.strip() or 'rollback failed'}") + failures.append( + f"{plan.repository}: {result.stdout.strip() or 'rollback failed'}" + ) return failures -def _merge_task_repositories(config: Config, task: Task, *, push: bool, dry_run: bool) -> None: +def _merge_task_repositories( + config: Config, task: Task, *, push: bool, dry_run: bool +) -> None: # Serialize merges into the same delivery line across concurrent dyro processes. - with exclusive_lock(_merge_lock_path(config, task.line), timeout_seconds=MERGE_LOCK_TIMEOUT_SECONDS): + with exclusive_lock( + _merge_lock_path(config, task.line), timeout_seconds=MERGE_LOCK_TIMEOUT_SECONDS + ): _merge_task_repositories_locked(config, task, push=push, dry_run=dry_run) -def _merge_task_repositories_locked(config: Config, task: Task, *, push: bool, dry_run: bool) -> None: +def _merge_task_repositories_locked( + config: Config, task: Task, *, push: bool, dry_run: bool +) -> None: line, plans = _prepare_merge(config, task, push=push, dry_run=dry_run) message = f"merge(task): {task.id} {task.title}" if dry_run: for plan in plans: require_ok( - git(plan.target, "merge", "--no-ff", "--no-commit", plan.source_head, dry_run=True, timeout=300), + git( + plan.target, + "merge", + "--no-ff", + "--no-commit", + plan.source_head, + dry_run=True, + timeout=300, + ), f"合并 {plan.repository}", ) return @@ -1994,13 +2595,24 @@ def _merge_task_repositories_locked(config: Config, task: Task, *, push: bool, d committed_heads: dict[str, str] = {} try: for plan in plans: - result = git(plan.target, "merge", "--no-ff", "--no-commit", plan.source_head, timeout=300) + result = git( + plan.target, + "merge", + "--no-ff", + "--no-commit", + plan.source_head, + timeout=300, + ) require_ok(result, f"合并 {plan.repository}") for plan in plans: if git(plan.target, "rev-parse", "--verify", "-q", "MERGE_HEAD").code == 0: - require_ok(git(plan.target, "commit", "-m", message, timeout=300), f"提交 {plan.repository} 合并") + require_ok( + git(plan.target, "commit", "-m", message, timeout=300), + f"提交 {plan.repository} 合并", + ) committed_heads[plan.repository] = require_ok( - git(plan.target, "rev-parse", "HEAD"), f"读取 {plan.repository} 合并提交" + git(plan.target, "rev-parse", "HEAD"), + f"读取 {plan.repository} 合并提交", ).stdout.strip() except DyroError as exc: recovery_failures = _rollback_merges(plans, committed_heads) @@ -2013,7 +2625,9 @@ def _merge_task_repositories_locked(config: Config, task: Task, *, push: bool, d recovery_failures=recovery_failures, ) if recovery_failures: - raise DyroError(f"{exc}\n自动恢复未完全成功:{'; '.join(recovery_failures)}") from exc + raise DyroError( + f"{exc}\n自动恢复未完全成功:{'; '.join(recovery_failures)}" + ) from exc raise pushed: list[str] = [] @@ -2036,7 +2650,9 @@ def _merge_task_repositories_locked(config: Config, task: Task, *, push: bool, d pushed.append(plan.repository) for plan in plans: - result_head = require_ok(git(plan.target, "rev-parse", "HEAD"), f"读取 {plan.repository} 合并结果").stdout.strip() + result_head = require_ok( + git(plan.target, "rev-parse", "HEAD"), f"读取 {plan.repository} 合并结果" + ).stdout.strip() ledger( config, task.id, @@ -2050,7 +2666,9 @@ def _merge_task_repositories_locked(config: Config, task: Task, *, push: bool, d ) -def merge_task(config: Config, task: Task, *, push: bool = False, dry_run: bool = False) -> None: +def merge_task( + config: Config, task: Task, *, push: bool = False, dry_run: bool = False +) -> None: _require_local_execution(config, "合并", dry_run=dry_run) if status(config, task) != "done": raise DyroError(f"仅 done 任务可合并:{task.id}") @@ -2058,7 +2676,9 @@ def merge_task(config: Config, task: Task, *, push: bool = False, dry_run: bool raise DyroError( "仅具有有效的独立复核、当前回执与任务 HEAD 绑定的 done 任务可合并" ) - if config.policy.require_external_signoff and not _valid_external_signoff(config, task): + if config.policy.require_external_signoff and not _valid_external_signoff( + config, task + ): raise DyroError("当前 Profile 要求有效的外部签收后才能合并") _merge_task_repositories(config, task, push=push, dry_run=dry_run) @@ -2095,9 +2715,16 @@ def maintain_evidence_generations( def board(config: Config) -> str: - rows = ["# DyroEngineeringFlow task board", "", "| Task | Line | Status | Risk | Depends |", "| --- | --- | --- | --- | --- |"] + rows = [ + "# DyroEngineeringFlow task board", + "", + "| Task | Line | Status | Risk | Depends |", + "| --- | --- | --- | --- | --- |", + ] for task in list_tasks(config): - rows.append(f"| {task.id} | {task.line} | {status(config, task)} | {task.risk} | {', '.join(task.depends_on) or '-'} |") + rows.append( + f"| {task.id} | {task.line} | {status(config, task)} | {task.risk} | {', '.join(task.depends_on) or '-'} |" + ) return "\n".join(rows) + "\n" @@ -2113,7 +2740,9 @@ def stats(config: Config) -> dict[str, dict[str, int]]: agent = event.get("agent") if not agent: continue - counters = result.setdefault(str(agent), {"executor": 0, "executor_ok": 0, "review": 0, "review_ok": 0}) + counters = result.setdefault( + str(agent), {"executor": 0, "executor_ok": 0, "review": 0, "review_ok": 0} + ) if event.get("phase") == "executor": counters["executor"] += 1 if event.get("exit_code") == 0: @@ -2146,7 +2775,12 @@ def loop_tasks(config: Config, *, dry_run: bool = False) -> list[tuple[str, str] if task.id not in ready_ids: continue try: - outcomes.append((task.id, run_task(config, task, dry_run=dry_run, legacy_scheduler=True))) + outcomes.append( + ( + task.id, + run_task(config, task, dry_run=dry_run, legacy_scheduler=True), + ) + ) except DyroError as exc: outcomes.append((task.id, f"skipped: {exc}")) for task in plan_tasks(config).review: @@ -2157,7 +2791,9 @@ def loop_tasks(config: Config, *, dry_run: bool = False) -> list[tuple[str, str] return outcomes -def task_template(task_id: str, title: str, line: str, repository: str, mount: str) -> str: +def task_template( + task_id: str, title: str, line: str, repository: str, mount: str +) -> str: quoted_title = json.dumps(title, ensure_ascii=False) quoted_mount = json.dumps(mount, ensure_ascii=False) return f'''schema_version = 1 diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 30500be..8c154cd 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -9,6 +9,7 @@ from .config import Config, external_security_errors, validate_id from .errors import DyroError, ValidationError from .process import git, require_ok +from .read_limits import ReadBudget from .state import atomic_write_text @@ -75,12 +76,12 @@ def _write_line(config: Config, line: Line, *, dry_run: bool = False) -> None: atomic_write_text(path, "\n".join(chunks)) -def _parse_line(path: Path) -> Line: +def _parse_line_content(path: Path, content: bytes) -> Line: import tomllib try: - raw = tomllib.loads(path.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: + raw = tomllib.loads(content.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError, RecursionError) as exc: raise ValidationError(f"开发线清单格式错误:{path}: {exc}") from exc if raw.get("schema_version") not in (1, 2): raise ValidationError(f"不支持的开发线清单版本:{path}") @@ -114,6 +115,24 @@ def _parse_line(path: Path) -> Line: return Line(line_id, kind, branch, base, repositories, repository_bases, storage_modes) +def _parse_line(path: Path) -> Line: + return _parse_line_content(path, path.read_bytes()) + + +def load_line_bounded(path: Path, budget: ReadBudget, *, workspace_root: Path) -> Line: + content = budget.read_regular_bytes_at( + root=workspace_root, + directory=path.parent, + name=path.name, + maximum_bytes=budget.limits.line_manifest_bytes, + label="line manifest", + ) + line = _parse_line_content(path, content) + if path.stem != line.id: + raise ValidationError(f"开发线文件名与清单 ID 不一致:{path.stem} != {line.id}") + return line + + def list_lines(config: Config, kind: str | None = None) -> list[Line]: wanted = (kind,) if kind else ("line", "hotfix") lines: list[Line] = [] diff --git a/tests/test_hub.py b/tests/test_hub.py index 14edd6d..091bfd8 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -17,6 +17,7 @@ _choose_tool, _macos_app_name, _openclaw_needs_setup, + _parse_repository_selection, home_tools, sort_home_tools, ) @@ -41,6 +42,42 @@ from .support import WorkspaceCase, shell +class RepositorySelectionParsingTests(unittest.TestCase): + def test_accepts_indices_ids_and_mixed_tokens(self) -> None: + repositories = ("miniapp", "pc-web", "common-msv", "ai-agent", "video-engine") + selected, error = _parse_repository_selection("1,3", repositories) + self.assertIsNone(error) + self.assertEqual(selected, ("miniapp", "common-msv")) + + selected, error = _parse_repository_selection("pc-web,video-engine", repositories) + self.assertIsNone(error) + self.assertEqual(selected, ("pc-web", "video-engine")) + + selected, error = _parse_repository_selection("2, ai-agent, 2", repositories) + self.assertIsNone(error) + self.assertEqual(selected, ("pc-web", "ai-agent")) + + def test_rejects_out_of_range_and_unknown_tokens(self) -> None: + repositories = ("miniapp", "pc-web") + selected, error = _parse_repository_selection("3", repositories) + self.assertIsNone(selected) + self.assertIn("序号超出范围", error or "") + + selected, error = _parse_repository_selection("missing", repositories) + self.assertIsNone(selected) + self.assertIn("未配置的仓库", error or "") + + def test_numeric_repository_id_wins_over_index(self) -> None: + repositories = ("api", "1", "svc") + selected, error = _parse_repository_selection("1", repositories) + self.assertIsNone(error) + self.assertEqual(selected, ("1",)) + + selected, error = _parse_repository_selection("3", repositories) + self.assertIsNone(error) + self.assertEqual(selected, ("svc",)) + + class RegistryTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="dyro-hub-") @@ -482,7 +519,7 @@ def test_home_new_feature_limits_base_adjustment_to_selected_repositories( shell("git", "checkout", "-b", "release", cwd=web) add_workspace(self.root, name="demo", make_default=True) - answers = iter(["3", "FEATURE-WEB", "2", "web", "2", "release", "yes"]) + answers = iter(["3", "FEATURE-WEB", "2", "2", "2", "release", "yes"]) output = StringIO() with ( patch("dyro.home.interactive_terminal", return_value=True), @@ -494,6 +531,8 @@ def test_home_new_feature_limits_base_adjustment_to_selected_repositories( rendered = output.getvalue() self.assertIn("可选仓库:", rendered) + self.assertIn(" 1) api", rendered) + self.assertIn(" 2) web", rendered) self.assertNotIn("当前仓库基线:", rendered) line = get_line(load(self.root), "FEATURE-WEB", "line") self.assertEqual(line.repositories, ("web",)) @@ -521,7 +560,8 @@ def test_home_hotfix_limits_baseline_scope_to_selected_repositories(self) -> Non shell("git", "checkout", "-b", "release", cwd=web) add_workspace(self.root, name="demo", make_default=True) - answers = iter(["4", "INC-WEB", "2", "web", "", "yes"]) + # Custom repo pick: "2" is the index of web (api=1, web=2). + answers = iter(["4", "INC-WEB", "2", "2", "", "yes"]) output = StringIO() with ( patch("dyro.home.interactive_terminal", return_value=True), @@ -533,6 +573,8 @@ def test_home_hotfix_limits_baseline_scope_to_selected_repositories(self) -> Non rendered = output.getvalue() self.assertIn("步骤:问题 ID → 参与仓库 → 生产基线 → 创建确认", rendered) + self.assertIn(" 1) api", rendered) + self.assertIn(" 2) web", rendered) self.assertIn("发布分支 release", rendered) line = get_line(load(self.root), "INC-WEB", "hotfix") self.assertEqual(line.repositories, ("web",)) diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..20c292d --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +import json +import os +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch + +from dyro.cli import main +from dyro.errors import DyroError +from dyro.integrations import ( + IntegrationState, + install_integration, + integration_status, + uninstall_integration, +) +from dyro.integrations import manager + + +class IntegrationManagerTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="dyro-integrations-") + self.root = Path(self.tmp.name) + self.codex_home = self.root / "codex" + self.claude_home = self.root / "claude" + self.dyro_home = self.root / "dyro" + self.fake_home = self.root / "home" + self.fake_home.mkdir() + self.environment = patch.dict( + os.environ, + { + "CODEX_HOME": str(self.codex_home), + "DYRO_HOME": str(self.dyro_home), + "HOME": str(self.fake_home), + "DYRO_NO_UPDATE_CHECK": "1", + }, + clear=False, + ) + self.environment.start() + + def tearDown(self) -> None: + self.environment.stop() + self.tmp.cleanup() + + @property + def mirror(self) -> Path: + return self.dyro_home / "skills" / "dyro-control-plane" + + @property + def avatar(self) -> Path: + return self.codex_home / "skills" / "dyro-control-plane" + + @property + def claude_avatar(self) -> Path: + return self.claude_home / "skills" / "dyro-control-plane" + + @property + def manifest(self) -> Path: + return self.dyro_home / "integrations" / "skill.json" + + @property + def legacy_manifest(self) -> Path: + return self.dyro_home / "integrations" / "codex.json" + + def _host_homes(self, *, claude: bool = False) -> dict[str, Path]: + homes = {"codex": self.codex_home} + if claude: + homes["claude"] = self.claude_home + return homes + + def _tree_snapshot(self) -> tuple[tuple[str, str, int], ...]: + rows: list[tuple[str, str, int]] = [] + for path in sorted(self.root.rglob("*")): + relative = path.relative_to(self.root).as_posix() + kind = ( + "symlink" if path.is_symlink() else "dir" if path.is_dir() else "file" + ) + size = path.lstat().st_size + rows.append((relative, kind, size)) + return tuple(rows) + + def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: + skill = manager._asset_root() / "SKILL.md" + metadata = manager._asset_root() / "agents" / "openai.yaml" + + content = skill.read_text(encoding="utf-8") + self.assertLessEqual(len(content.encode("utf-8")), 8 * 1024) + self.assertNotIn("TODO", content) + frontmatter = content.split("---", 2)[1] + keys = { + line.split(":", 1)[0] for line in frontmatter.splitlines() if line.strip() + } + self.assertEqual(keys, {"name", "description"}) + self.assertIn("name: dyro-control-plane", frontmatter) + self.assertIn("$dyro-control-plane", metadata.read_text(encoding="utf-8")) + for line in metadata.read_text(encoding="utf-8").splitlines(): + if ": " in line: + self.assertTrue(line.split(": ", 1)[1].startswith('"')) + + def test_status_and_dry_run_are_strictly_zero_write(self) -> None: + before = self._tree_snapshot() + status = integration_status("skill") + plan = install_integration("skill", yes=False, dry_run=True) + uninstall_plan = uninstall_integration("codex", yes=False, dry_run=True) + + self.assertEqual(status.state, IntegrationState.ABSENT) + self.assertEqual(plan.status.state, IntegrationState.ABSENT) + self.assertEqual(uninstall_plan.status.state, IntegrationState.ABSENT) + self.assertEqual(self._tree_snapshot(), before) + + def test_install_creates_mirror_and_avatar_symlink(self) -> None: + with self.assertRaisesRegex(DyroError, "--yes"): + install_integration("skill", yes=False) + + installed = install_integration("skill", yes=True) + self.assertEqual(installed.status.state, IntegrationState.CURRENT) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + self.assertFalse(self.mirror.is_symlink()) + self.assertTrue(self.avatar.is_symlink()) + self.assertEqual(self.avatar.resolve(), self.mirror.resolve()) + self.assertTrue(self.avatar.joinpath("SKILL.md").is_file()) + manifest = json.loads(self.manifest.read_text(encoding="utf-8")) + self.assertEqual(manifest["integration"], "skill") + self.assertEqual(manifest["schema_version"], 2) + self.assertEqual(manifest["mirror"], str(self.mirror)) + self.assertEqual(set(manifest["files"]), {"SKILL.md", "agents/openai.yaml"}) + self.assertIn("codex", manifest["avatars"]) + + before = self._tree_snapshot() + again = install_integration("codex", yes=True) + self.assertEqual(again.status.state, IntegrationState.CURRENT) + self.assertEqual(self._tree_snapshot(), before) + + sibling = self.codex_home / "skills" / "user-skill.txt" + sibling.write_text("keep\n", encoding="utf-8") + removed = uninstall_integration("skill", yes=True) + self.assertEqual(removed.status.state, IntegrationState.ABSENT) + self.assertFalse(self.mirror.exists()) + self.assertFalse(self.avatar.exists() or self.avatar.is_symlink()) + self.assertTrue(sibling.is_file()) + + def test_install_attaches_avatars_for_multiple_detected_hosts(self) -> None: + self.claude_home.mkdir() + result = install_integration( + "skill", + yes=True, + host_homes=self._host_homes(claude=True), + ) + self.assertEqual(result.status.state, IntegrationState.CURRENT) + self.assertTrue(self.avatar.is_symlink()) + self.assertTrue(self.claude_avatar.is_symlink()) + self.assertEqual(self.claude_avatar.resolve(), self.mirror.resolve()) + hosts = {row.host for row in result.status.avatars} + self.assertEqual(hosts, {"codex", "claude"}) + + def test_unowned_conflict_is_never_overwritten_or_removed(self) -> None: + self.avatar.mkdir(parents=True) + foreign = self.avatar / "SKILL.md" + foreign.write_text("foreign\n", encoding="utf-8") + + status = integration_status("skill") + self.assertEqual(status.state, IntegrationState.UNOWNED_CONFLICT) + with self.assertRaisesRegex(DyroError, "拒绝覆盖"): + install_integration("skill", yes=True) + # Unowned paths are not Dyro-owned, so uninstall also refuses. + with self.assertRaisesRegex(DyroError, "拒绝删除|unowned_conflict"): + uninstall_integration("skill", yes=True) + self.assertEqual(foreign.read_text(encoding="utf-8"), "foreign\n") + + def test_owned_drift_via_avatar_blocks_upgrade_and_uninstall(self) -> None: + install_integration("skill", yes=True) + self.avatar.joinpath("SKILL.md").write_text("drift\n", encoding="utf-8") + + self.assertEqual(integration_status("skill").state, IntegrationState.DRIFTED) + with self.assertRaisesRegex(DyroError, "drifted"): + install_integration("skill", yes=True) + with self.assertRaisesRegex(DyroError, "drifted"): + uninstall_integration("skill", yes=True) + self.assertEqual(self.mirror.joinpath("SKILL.md").read_text(), "drift\n") + + def test_outdated_owned_asset_can_upgrade(self) -> None: + install_integration("skill", yes=True) + manifest = json.loads(self.manifest.read_text(encoding="utf-8")) + manifest["asset_version"] = manifest["asset_version"] + 1 + # Keep digest consistent with files map for parser validity. + self.manifest.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + self.assertEqual(integration_status("skill").state, IntegrationState.OUTDATED) + result = install_integration("skill", yes=True) + self.assertEqual(result.status.state, IntegrationState.CURRENT) + + def test_missing_avatar_is_outdated_and_repairable(self) -> None: + install_integration("skill", yes=True) + self.avatar.unlink() + self.assertEqual(integration_status("skill").state, IntegrationState.OUTDATED) + repaired = install_integration("skill", yes=True) + self.assertEqual(repaired.status.state, IntegrationState.CURRENT) + self.assertTrue(self.avatar.is_symlink()) + + def test_legacy_codex_copy_migrates_to_mirror_and_avatar(self) -> None: + self.avatar.mkdir(parents=True) + files = manager._asset_inventory() + for relative, _digest in files.items(): + destination = self.avatar / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes((manager._asset_root() / relative).read_bytes()) + legacy = { + "schema_version": 1, + "integration": "codex", + "asset_version": manager.ASSET_VERSION, + "asset_digest": manager._asset_digest(files), + "target": str(self.avatar), + "files": files, + } + self.legacy_manifest.parent.mkdir(parents=True, exist_ok=True) + self.legacy_manifest.write_text( + json.dumps(legacy, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + status = integration_status("codex") + self.assertEqual(status.state, IntegrationState.OUTDATED) + migrated = install_integration("skill", yes=True) + self.assertEqual(migrated.status.state, IntegrationState.CURRENT) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + self.assertTrue(self.avatar.is_symlink()) + self.assertEqual(self.avatar.resolve(), self.mirror.resolve()) + self.assertFalse(self.legacy_manifest.exists()) + self.assertTrue(self.manifest.is_file()) + + def test_stale_manifest_and_recovery_marker_fail_closed(self) -> None: + install_integration("skill", yes=True) + shutil.rmtree(self.mirror) + self.assertEqual( + integration_status("skill").state, IntegrationState.STALE_MANIFEST + ) + + self.manifest.unlink() + transaction = self.dyro_home / "integrations" / "skill.transaction.json" + transaction.write_text("{}\n", encoding="utf-8") + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + with self.assertRaisesRegex(DyroError, "recovery_required"): + install_integration("skill", yes=True) + + def test_symlink_avatar_to_foreign_path_is_conflict(self) -> None: + outside = self.root / "outside" + outside.mkdir() + outside_file = outside / "sentinel" + outside_file.write_text("keep\n", encoding="utf-8") + self.avatar.parent.mkdir(parents=True) + self.avatar.symlink_to(outside, target_is_directory=True) + + self.assertEqual( + integration_status("skill").state, + IntegrationState.UNOWNED_CONFLICT, + ) + with self.assertRaises(DyroError): + install_integration("skill", yes=True) + self.assertEqual(outside_file.read_text(encoding="utf-8"), "keep\n") + + self.avatar.unlink() + state_outside = self.root / "state-outside" + state_outside.mkdir() + self.dyro_home.symlink_to(state_outside, target_is_directory=True) + self.assertEqual( + integration_status("skill").state, + IntegrationState.RECOVERY_REQUIRED, + ) + + def test_install_failure_rolls_back_mirror_and_manifest(self) -> None: + real_atomic_write = manager.atomic_write_text + + def fail_manifest(path: Path, content: str) -> None: + if path == self.manifest: + raise OSError("injected manifest failure") + real_atomic_write(path, content) + + with ( + patch.object(manager, "atomic_write_text", side_effect=fail_manifest), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertFalse(self.mirror.exists()) + self.assertFalse(self.manifest.exists()) + self.assertFalse(self.avatar.exists() or self.avatar.is_symlink()) + self.assertEqual(integration_status("skill").state, IntegrationState.ABSENT) + + def test_absent_rollback_dangling_mirror_keeps_recovery_marker(self) -> None: + real_atomic_write = manager.atomic_write_text + real_remove_tree = manager._remove_tree + missing = self.root / "missing-target" + + def fail_manifest(path: Path, content: str) -> None: + if path == self.manifest: + raise OSError("injected manifest failure") + real_atomic_write(path, content) + + def replace_mirror_with_symlink(path: Path) -> None: + real_remove_tree(path) + if path == self.mirror: + path.symlink_to(missing, target_is_directory=True) + + with ( + patch.object(manager, "atomic_write_text", side_effect=fail_manifest), + patch.object( + manager, "_remove_tree", side_effect=replace_mirror_with_symlink + ), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertTrue(self.mirror.is_symlink()) + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + + def test_absent_rollback_dangling_manifest_keeps_recovery_marker(self) -> None: + real_atomic_write = manager.atomic_write_text + missing = self.root / "missing-manifest" + + def replace_manifest_with_symlink(path: Path, content: str) -> None: + if path == self.manifest: + path.symlink_to(missing) + raise OSError("injected manifest failure") + real_atomic_write(path, content) + + with ( + patch.object( + manager, + "atomic_write_text", + side_effect=replace_manifest_with_symlink, + ), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertTrue(self.manifest.is_symlink()) + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + + def test_upgrade_cleanup_failure_keeps_committed_state_recoverable(self) -> None: + install_integration("skill", yes=True) + manifest = json.loads(self.manifest.read_text(encoding="utf-8")) + manifest["asset_version"] = manifest["asset_version"] + 1 + self.manifest.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + real_remove_tree = manager._remove_tree + injected = False + + def fail_first_backup(path: Path) -> None: + nonlocal injected + if not injected and ".backup-" in path.name: + injected = True + raise OSError("injected backup cleanup failure") + real_remove_tree(path) + + with ( + patch.object(manager, "_remove_tree", side_effect=fail_first_backup), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + self.assertTrue( + (self.dyro_home / "integrations" / "skill.transaction.json").exists() + ) + + def test_committed_upgrade_unlink_failure_keeps_recovery_marker(self) -> None: + install_integration("skill", yes=True) + manifest = json.loads(self.manifest.read_text(encoding="utf-8")) + manifest["asset_version"] = manifest["asset_version"] + 1 + self.manifest.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + with ( + patch.object( + manager, + "_unlink_transaction", + side_effect=OSError("injected unlink failure"), + ), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + + def test_committed_upgrade_fsync_failure_recreates_recovery_marker(self) -> None: + install_integration("skill", yes=True) + manifest = json.loads(self.manifest.read_text(encoding="utf-8")) + manifest["asset_version"] = manifest["asset_version"] + 1 + self.manifest.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + real_fsync_directory = manager.fsync_directory + transaction = self.dyro_home / "integrations" / "skill.transaction.json" + + def fail_after_unlink(path: Path) -> None: + if path == transaction.parent and not transaction.exists(): + raise OSError("injected directory fsync failure") + real_fsync_directory(path) + + with ( + patch.object(manager, "fsync_directory", side_effect=fail_after_unlink), + self.assertRaisesRegex(OSError, "injected"), + ): + install_integration("skill", yes=True) + + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + self.assertTrue(transaction.exists()) + + def test_committed_uninstall_cleanup_failure_keeps_recovery_marker(self) -> None: + install_integration("skill", yes=True) + with ( + patch.object( + manager, "_remove_tree", side_effect=OSError("injected delete") + ), + self.assertRaisesRegex(OSError, "injected"), + ): + uninstall_integration("skill", yes=True) + + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + self.assertFalse(self.mirror.exists()) + self.assertFalse(self.manifest.exists()) + + def test_uninstall_precommit_failure_restores_owned_installation(self) -> None: + install_integration("skill", yes=True) + real_atomic_write = manager.atomic_write_text + transaction = self.dyro_home / "integrations" / "skill.transaction.json" + + def fail_committed_marker(path: Path, content: str) -> None: + if path == transaction and '"phase":"committed"' in content: + raise OSError("injected committed marker failure") + real_atomic_write(path, content) + + with ( + patch.object( + manager, "atomic_write_text", side_effect=fail_committed_marker + ), + self.assertRaisesRegex(OSError, "injected"), + ): + uninstall_integration("skill", yes=True) + + self.assertEqual(integration_status("skill").state, IntegrationState.CURRENT) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + self.assertTrue(self.avatar.is_symlink()) + + def test_uninstall_rollback_manifest_race_keeps_recovery_marker(self) -> None: + install_integration("skill", yes=True) + real_atomic_write = manager.atomic_write_text + real_inventory = manager._inventory + transaction = self.dyro_home / "integrations" / "skill.transaction.json" + alternate_mirror = self.root / "different-mirror" + + def fail_committed_marker(path: Path, content: str) -> None: + if path == transaction and '"phase":"committed"' in content: + raise OSError("injected committed marker failure") + real_atomic_write(path, content) + + def mutate_during_verification(path: Path) -> dict[str, str]: + result = real_inventory(path) + if path == self.mirror and transaction.exists() and self.manifest.exists(): + payload = json.loads(self.manifest.read_text(encoding="utf-8")) + payload["mirror"] = str(alternate_mirror) + self.manifest.write_text(json.dumps(payload), encoding="utf-8") + return result + + with ( + patch.object( + manager, "atomic_write_text", side_effect=fail_committed_marker + ), + patch.object(manager, "_inventory", side_effect=mutate_during_verification), + self.assertRaisesRegex(OSError, "injected"), + ): + uninstall_integration("skill", yes=True) + + self.assertEqual( + integration_status("skill").state, IntegrationState.RECOVERY_REQUIRED + ) + self.assertTrue(transaction.exists()) + + def test_nested_symlink_in_codex_home_path_is_rejected(self) -> None: + actual = self.root / "actual" + actual.joinpath("codex").mkdir(parents=True) + alias = self.root / "alias" + alias.symlink_to(actual, target_is_directory=True) + escaped_home = alias / "codex" + + with self.assertRaisesRegex(DyroError, "不安全|unowned_conflict|拒绝覆盖"): + install_integration("skill", yes=True, codex_home=escaped_home) + self.assertFalse(actual.joinpath("codex", "skills").exists()) + + def test_missing_home_below_symlink_is_rejected_before_any_write(self) -> None: + actual = self.root / "actual-missing" + actual.mkdir() + alias = self.root / "alias-missing" + alias.symlink_to(actual, target_is_directory=True) + escaped_home = alias / "new-codex-home" + + with self.assertRaisesRegex(DyroError, "不安全|unowned_conflict|拒绝覆盖"): + install_integration("skill", yes=True, codex_home=escaped_home) + self.assertFalse(actual.joinpath("new-codex-home").exists()) + + def test_status_reports_unsafe_missing_home_before_absent(self) -> None: + actual = self.root / "status-actual" + actual.mkdir() + alias = self.root / "status-alias" + alias.symlink_to(actual, target_is_directory=True) + escaped_home = alias / "new-codex-home" + + status = integration_status("skill", codex_home=escaped_home) + + self.assertEqual(status.state, IntegrationState.UNOWNED_CONFLICT) + self.assertTrue( + "不安全" in status.detail + or any("不安全" in row.detail for row in status.avatars), + msg=f"detail={status.detail!r} avatars={status.avatars!r}", + ) + self.assertFalse(actual.joinpath("new-codex-home").exists()) + + def _write_legacy_manifest(self, target: Path, files: dict[str, str]) -> None: + self.legacy_manifest.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": 1, + "integration": "codex", + "asset_version": manager.ASSET_VERSION, + "asset_digest": manager._asset_digest(files), + "target": str(target), + "files": files, + } + self.legacy_manifest.write_text( + json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + def test_unbound_legacy_target_is_not_deleted_on_uninstall(self) -> None: + victim = self.root / "victim_dir" + victim.mkdir() + files = manager._asset_inventory() + for relative in files: + destination = victim / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes((manager._asset_root() / relative).read_bytes()) + self._write_legacy_manifest(victim, files) + + status = integration_status("skill") + self.assertEqual(status.state, IntegrationState.ABSENT) + plan = uninstall_integration("skill", yes=True) + self.assertEqual(plan.status.state, IntegrationState.ABSENT) + self.assertTrue(victim.exists()) + self.assertTrue((victim / "SKILL.md").is_file()) + + def test_forged_legacy_over_foreign_avatar_is_refused(self) -> None: + self.avatar.mkdir(parents=True) + (self.avatar / "SKILL.md").write_text("FOREIGN_SKILL_CONTENT\n", encoding="utf-8") + foreign_files = manager._inventory(self.avatar) + self._write_legacy_manifest(self.avatar, foreign_files) + + status = integration_status("skill") + self.assertEqual(status.state, IntegrationState.UNOWNED_CONFLICT) + with self.assertRaisesRegex(DyroError, "拒绝覆盖|unowned_conflict"): + install_integration("skill", yes=True) + self.assertEqual( + (self.avatar / "SKILL.md").read_text(encoding="utf-8"), + "FOREIGN_SKILL_CONTENT\n", + ) + self.assertFalse(self.avatar.is_symlink()) + + def test_plan_surfaces_missing_host_blocker(self) -> None: + with patch.dict(os.environ): + for key in ("CODEX_HOME", "CLAUDE_HOME", "AGENTS_HOME", "CURSOR_HOME"): + os.environ.pop(key, None) + plan = install_integration("skill", yes=False, dry_run=True) + self.assertEqual(plan.status.state, IntegrationState.ABSENT) + self.assertFalse(plan.status.avatars) + self.assertTrue( + any("未检测到宿主目录" in change for change in plan.changes), + msg=plan.changes, + ) + + def test_cli_status_dry_run_install_and_confirmation_gate(self) -> None: + output = StringIO() + before = self._tree_snapshot() + with redirect_stdout(output): + main(["integration", "status", "skill"]) + main(["--dry-run", "integration", "install", "skill"]) + self.assertIn("skill\tabsent", output.getvalue()) + self.assertIn("DRY RUN: install skill", output.getvalue()) + self.assertEqual(self._tree_snapshot(), before) + + preview = StringIO() + with redirect_stdout(preview): + main(["integration", "install", "codex"]) + main(["integration", "install", "codex", "--dry-run"]) + self.assertIn("DRY RUN: install codex", preview.getvalue()) + self.assertIn("重新运行并添加 --yes", preview.getvalue()) + self.assertEqual(self._tree_snapshot(), before) + + receipt = StringIO() + with redirect_stdout(receipt): + main(["integration", "install", "skill", "--yes"]) + main(["integration", "status", "skill"]) + main(["integration", "uninstall", "skill", "--yes"]) + text = receipt.getvalue() + self.assertIn(f"创建镜像 {self.mirror}", text) + self.assertIn(f"创建分身 {self.avatar}", text) + self.assertIn("avatar\tcodex\tcurrent", text) + self.assertIn(f"移除镜像 {self.mirror}", text) + self.assertEqual(integration_status("skill").state, IntegrationState.ABSENT) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_source.py b/tests/test_release_source.py index 1d6b089..5bd4bc6 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -75,3 +75,31 @@ def test_rejects_checkout_that_does_not_match_the_tag(self) -> None: release_tag="v1.2.3", trusted_ref="main", ) + + def test_publish_workflow_requires_successful_exact_sha_ci_gate(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "pypi-publish.yml").read_text( + encoding="utf-8" + ) + + self.assertIn("actions: read", workflow) + self.assertIn("actions/workflows/ci.yml/runs", workflow) + self.assertIn('-f "head_sha=${release_sha}"', workflow) + self.assertIn("-f event=push", workflow) + self.assertIn('"${status}" == "completed"', workflow) + self.assertIn('"${conclusion}" != "success"', workflow) + self.assertIn("ci-gate-run.tsv", workflow) + self.assertNotIn("dyro-bridge-zero-effect-evidence", workflow) + self.assertNotIn( + "Agent Bridge source/wheel/sdist gate (Ubuntu 24.04)", workflow + ) + self.assertNotIn("bridge-gate-run.tsv", workflow) + + def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + + self.assertNotIn("bridge-zero-effects", workflow) + self.assertNotIn("dyro-bridge-zero-effect-evidence", workflow) + self.assertNotIn("verify_bridge_zero_effects.py", workflow) + self.assertNotIn("Agent Bridge source/wheel/sdist gate", workflow) diff --git a/uv.lock b/uv.lock index 89db82a..4d1ef1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "backports-tarfile" @@ -282,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.6.2" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -332,7 +336,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [