diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 157a3d5..302b9f8 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -108,6 +108,33 @@ jobs: - name: Build distributions run: uv run python -m build + - name: Reject generated bytecode in distributions + run: | + uv run python - <<'PY' + from pathlib import Path, PurePosixPath + import tarfile + import zipfile + + def forbidden(name: str) -> bool: + path = PurePosixPath(name) + return "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"} + + for artifact in Path("dist").glob("dyro-*"): + if artifact.suffix == ".whl": + with zipfile.ZipFile(artifact) as archive: + names = archive.namelist() + elif artifact.name.endswith(".tar.gz"): + with tarfile.open(artifact, "r:gz") as archive: + names = archive.getnames() + else: + continue + leaked = [name for name in names if forbidden(name)] + if leaked: + raise SystemExit( + f"generated bytecode found in {artifact.name}: {leaked[:5]}" + ) + PY + - name: Verify installed wheel and sdist outside checkout run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 0ee059f..77129e3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ dist/ # Optional local Mermaid→PNG exports (docs use GitHub Mermaid; do not commit) docs/images/diagrams/*.png docs/images/diagrams/zh/*.png + +# Local handoffs and point-in-time agent review/evidence exports +plans/*-handoff-*.md +docs/superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dc11621..d4219cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## Unreleased +- Expand the packaged `dyro-control-plane` Skill into a host-neutral, + intent-routed read-only control surface for workspace health, lines, Change + Sets, integrations, and Objective attention/graph/tick/plan observations. +- Add stable JSON views for `workspace list`, `status`, `doctor`, `next`, + `line list`, `changeset list|verify`, `integration status`, and + `objective list|status` while preserving existing text output by default. +- Make `objective list` and `objective status` strictly zero-write by refusing + to recover an interrupted Objective transaction during observation. +- Run control-plane Git observations with `git --no-optional-locks` so status, + doctor, and Change Set verification cannot refresh Git index metadata. +- Bind workspace-local `next --format json` handoffs to their resolved alias or + absolute root, and only offer bootstrap when every failure is an absent + repository with a configured remote. +- Return one stable JSON error envelope for machine-facing runtime failures and + use deadline-, byte-, record-, and symlink-bounded reads for Profile, line, + Change Set, integration, Objective, Task, evidence, and Git observations. +- Keep machine-facing Objective completion consistent with text views by + checking Task integration evidence and Git ancestry inside the same budget; + reject unsafe bootstrap targets before `next` can hand off a mutation. +- Minimize Agent-visible local metadata: workspace and Skill integration JSON + and health diagnostics omit absolute paths by default, expose them only through explicit + `--include-paths`, and let the Skill skip global discovery when an alias is + already known. +- Let Enter confirm the already-previewed feature worktree plan while keeping + `b` as the explicit route back to baseline selection. +- Exclude generated Python bytecode from source and wheel distributions, even + when release tests imported packaged integration assets before the build. + ## 0.6.4 - 2026-08-12 - Guide control-plane Skill install during interactive `dyro setup` (preview in diff --git a/MANIFEST.in b/MANIFEST.in index bc2fe46..ac43400 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,3 +7,4 @@ include docs/workspace-blueprints.md include examples/blueprints/acme-platform.toml recursive-include src/dyro/console/assets * recursive-include src/dyro/integrations/assets * +global-exclude *.py[cod] diff --git a/docs/adr/0006-agent-bridge-phase-0.md b/docs/adr/0006-agent-bridge-phase-0.md index ed76c9d..42bb604 100644 --- a/docs/adr/0006-agent-bridge-phase-0.md +++ b/docs/adr/0006-agent-bridge-phase-0.md @@ -15,12 +15,11 @@ 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: +one v1. A point-in-time adversarial design review rejected that scope. Its +follow-up closure resolved eight implementation-blueprint findings and +approved only the S1 contract/catalog step. The durable decisions from those +reviews are incorporated into this ADR and the acceptance matrix. Current +source disproves two key assumptions: - `task gates` executes configured argv, writes gate logs, and appends the ledger; it is not a read operation. diff --git a/docs/designs/agent-bridge-operation-inventory.md b/docs/designs/agent-bridge-operation-inventory.md index 880a229..0fb1a2a 100644 --- a/docs/designs/agent-bridge-operation-inventory.md +++ b/docs/designs/agent-bridge-operation-inventory.md @@ -4,8 +4,8 @@ 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) +Review outcomes are incorporated into ADR 0006 and the Phase 0 acceptance +matrix; point-in-time review exports are not part of the product documentation. ## 1. Purpose 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 deleted file mode 100644 index 8d63911..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/FINAL-QUALITY-GATE.md +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 6997fe8..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/INDEX.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 2260b26..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/RELEASE-READINESS.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index fb44229..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/artifact-meta.json +++ /dev/null @@ -1 +0,0 @@ -{"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 deleted file mode 100644 index 2b8e945..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/bridge-job.json +++ /dev/null @@ -1 +0,0 @@ -{"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 deleted file mode 100644 index 47a1a19..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/ci-run.json +++ /dev/null @@ -1 +0,0 @@ -{"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 deleted file mode 100644 index 1caac0a..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/code-review.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 36dbe62..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f01-codex-discovery.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "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 deleted file mode 100644 index 0b2a563..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f02-sandbox.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index f235a48..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f03-journeys.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "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 deleted file mode 100644 index 949aa34..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/host/f04-context-budget.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 26fa445..0000000 --- a/docs/superpowers/evidence/agent-bridge-phase-0-abca42c/six-report-summary.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "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-07-31-full-project-adversarial-review-board.md b/docs/superpowers/reviews/2026-07-31-full-project-adversarial-review-board.md deleted file mode 100644 index 4eca386..0000000 --- a/docs/superpowers/reviews/2026-07-31-full-project-adversarial-review-board.md +++ /dev/null @@ -1,396 +0,0 @@ -# Dyro 全项目对抗式复核审查委员会 - -日期:2026-07-31 - -范围: - -- 基线:`origin/main@80819b5e73e185fcd9dea0752feece66b07fb229`(`v0.5.1`) -- Core:`src/dyro/` -- 本地 Agent dispatch:`experiments/local_agent_dispatch/` -- 供应链、发布与制品:`pyproject.toml`、`uv.lock`、`.github/workflows/`、GitHub Release / PyPI 工作流 -- 用户 CLI 路径、状态/证据/签名/并发持久化及文档契约 - -审查材料: - -- 当前隔离工作区:`/private/tmp/dyro-adversarial-release-review-20260731` -- 发布标签:`v0.5.1` -- 发布工作流:`https://github.com/DandreYang/DyroEngineeringFlow/actions/runs/30624818485` - -固定决策: - -- Docker-backed 外部 TypeScript semantic runtime 已从 Dyro 主项目移出;不得重新引入。 -- Dyro 保持控制平面;本地 dispatch 不能取得 review、sign-off、merge、push 或发布权限。 -- `v0.5.1` 已创建 Release,PyPI 上传处于人工环境审批边界;本次审查不会批准或上传。 - -## 规则 - -1. 审查员只编辑自己的签名章节。 -2. 源码、可执行制品和真实 GitHub 状态优先于文档或历史结论。 -3. 不可由本地或可读远端证实的项目标为“须人工核”。 -4. 发现按 P0/P1/P2 分类,并给出可定位证据和复现/验证方法。 -5. 必须尝试推翻自己的假设;禁止只用同一种文本搜索作为验证。 -6. 不报告纯风格偏好;优先安全、授权、完整性、发布、数据一致性和用户可理解性。 - ---- - -# Atlas:安全、供应链与发布审查章节 - -审查员:Atlas -时间:2026-07-31(Asia/Taipei) -结论:**当前已发布制品的源码完整性可复证,未发现 P0;但下一次生产发布为 No-Go,直至关闭 P1-A1、P1-A2 和 P1-A4。** `v0.5.1` 已实际上传 PyPI,不能再把它描述为“等待人工审批”。 - -## 发现 - -### 已复证的正向事实(用于反驳误报) - -- `v0.5.1` 的 GitHub Release 指向 `80819b5e73e185fcd9dea0752feece66b07fb229`;`git ls-remote --tags origin v0.5.1` 与 `git merge-base --is-ancestor origin/main` 均成立。 -- 远端工作流 `30624818485` 的 **Build and validate distributions** 与 **Publish to PyPI** 两个 job 都是 `success`;PyPI `dyro/0.5.1` 已在 `2026-07-31T10:50:36Z`(wheel)和 `10:50:37Z`(sdist)上传,不再是等待状态。 -- 直接下载 PyPI 两个文件并按 JSON 索引 SHA-256 复算均匹配;wheel 的 48 个 `.py` 与 tag `80819b5` 逐字节一致,sdist 的 75 个可追踪源文件也逐字节一致;两者均不含 `external_workflow_runner`。wheel `RECORD` 的 53 个带哈希条目也全部通过。因此“已移出的 Docker/TS runtime 被重新发布”这一假设被推翻。 -- `.github/workflows/` 的 `actions/checkout`、`setup-python`、`setup-uv`、`setup-node`、`upload-artifact`、`download-artifact` 与 `pypa/gh-action-pypi-publish` 都是完整 SHA pin;逐一用其远端 tag 的 peeled commit 比对,均与注释版本匹配。前次对 `pypa` 的误差来自没有解引用 annotated tag;`v1.14.1^{}` 正是配置的 `ba38be9…`。 -- PyPI Integrity API 对公开 wheel 返回 `200`:publish attestation 的 subject SHA-256 为 `999b0163…77a19f`,publisher 为 `GitHub / DandreYang/DyroEngineeringFlow / pypi-publish.yml / pypi`。这与公开文件哈希及 workflow 身份一致;传统 JSON 字段 `has_sig=false` 不代表缺少 PEP 740 provenance。 - -### P0 - -无已证实 P0。以上 PyPI 制品、Release tag 和实际源码之间的独立字节比对通过,不能把已发布的 `0.5.1` 误报为制品篡改或错误包含已移出 runtime。 - -### P1-A1:手动发布入口没有证明“输入确为受信 tag,且该 tag 对应受保护的主线提交” - -**证据:** - -- [`.github/workflows/pypi-publish.yml:6-12`](../../../.github/workflows/pypi-publish.yml) 接收任意字符串 `release_tag`;[23-26](../../../.github/workflows/pypi-publish.yml) 把它直接交给 `actions/checkout` 的 `ref`。 -- [42-56](../../../.github/workflows/pypi-publish.yml) 唯一的来源身份检查只是 `RELEASE_TAG == "v" + pyproject.project.version`。没有验证 `refs/tags/$RELEASE_TAG` 存在、没有验证 `GITHUB_SHA` 等于该 tag 的 commit、没有验证 annotated/signature,也没有验证该 commit 是 `origin/main` 的祖先。 -- [`docs/publishing.md:47`](../../publishing.md) 却承诺手动入口“checkout 该 tag,并严格校验 tag 必须等于版本”。源码实际严格校验的只有**名字字符串**,不是 tag 类型或来源链。 - -**影响:** 在未来版本中,具有 Actions 调度和环境审批能力的人可以让工作流对“名字看似 `vX.Y.Z`”但来源未被流程证明的 ref 构建。即使 OIDC 不泄露长期 PyPI token,也无法由发布记录回答“这个包是否确实来自受保护主线的批准 tag”。这会使误发布或内部凭据失陷后的追责、撤销与复现变弱。 - -**复证方式:** 静态读取上述 shell:它没有任何 `git rev-parse refs/tags/...`、`git cat-file`、`git verify-tag`、`git merge-base` 或 `GITHUB_SHA` 比较;向 `workflow_dispatch` 输入文本只会参与 52-55 行字符串比较。当前 `v0.5.1` 恰好满足主线祖先关系,是发布者操作正确,不是工作流强制的结论。 - -**建议修复:** 把 ref 验证抽为受单元测试的脚本。手动入口只 checkout `refs/tags/${RELEASE_TAG}`,以完整 history 获取 `origin/main`,然后验证:tag ref 存在、解析后的 commit 等于 checkout SHA、该 commit 可达受信主线;若采用 tag 签名策略,再拒绝 lightweight/未验证签名的 tag。Release event 同样走该脚本,避免两条入口漂移。 - -**验收:** 在临时 Git 仓库覆盖并拒绝:同名 branch、轻量或错误 tag、tag 指向非主线 commit、tag/`pyproject` 版本不匹配;只接受受信 tag → `main` 祖先链。发布日志须输出 tag commit SHA 和主线验证结果。 - -### P1-A2:仓库与 PyPI 环境并未形成独立、不可绕过的生产审批边界 - -**远端事实(2026-07-31 只读 API):** - -- `GET /repos/DandreYang/DyroEngineeringFlow/branches/main/protection` 返回 `404 Branch not protected`,`GET .../rulesets` 返回 `[]`;即 `main` 没有可见的 PR/状态检查/推送限制规则。 -- `GET .../actions/permissions/workflow` 显示默认 workflow 权限为 `write`;虽然当前两个工作流各自显式收紧权限,未来直接推入 `main` 的工作流并不继承该最小权限约束。 -- `GET .../environments/pypi` 显示:唯一 required reviewer 是 `Dandre126`,`prevent_self_review=false`、`can_admins_bypass=true`、`deployment_branch_policy=null`。 - -**影响:** 对单一管理员而言,修改发布工作流/主线、创建 Release 和批准环境都可以由同一身份完成;人工环境门仍能阻止偶发操作,但不是独立审查,也不限制发布来源分支。若目标是生产供应链的双人或抗账号失陷控制,这一边界不足。 - -**复证方式:** 使用上述 GitHub REST 端点即可重现,无需审批、发布或改动仓库。 - -**建议修复:** 这是治理决策,须人工核:若要求职责分离,至少启用 `main` ruleset(PR、通过 CI、限制直接 push),对 `v*` 设创建/更新/删除限制,PyPI environment 启用受信来源分支/标签规则、禁止管理员 bypass、启用 prevent-self-review,并配置第二位独立 reviewer。若团队明确只有一位受信发布人,应在发布政策中明示这是“单人手工确认”而非独立审批,并把账户恢复、MFA、PAT/SSH key 轮换写入运行手册。 - -**验收:** 用非管理员协作者和发布者本人分别验证:不能直接改主线/发布工作流、不能移动受保护 tag、不能自行批准 PyPI deployment;另以受信 tag 成功通过一次审批演练。单人模式则须由项目所有者显式接受该剩余风险。 - -### P1-A3:发布流程绕过已提交的依赖锁与构建工具版本,削弱可重复性和供应链审计 - -**证据:** - -- [`uv.lock:1-4`](../../../uv.lock) 声明锁文件;其 [`cryptography` 记录](../../../uv.lock) 固定为 `49.0.0` 且包含下载哈希,项目的直接依赖元数据见 [`pyproject.toml:11-14`](../../../pyproject.toml)。常规 CI 确实在 [`ci.yml:37-41`](../../../.github/workflows/ci.yml) 用 `uv lock --check` 和 `uv sync --locked`。 -- 发布工作流却在 [`pypi-publish.yml:33-40`](../../../.github/workflows/pypi-publish.yml) 执行未约束的 `pip install --upgrade build twine` 与 `pip install --editable .`,再在 [58-59](../../../.github/workflows/pypi-publish.yml) 用该环境构建;它从不校验或安装 `uv.lock`。 - -**影响:** 相同 Git tag 的“发布测试通过”会随 PyPI 上的 `cryptography`、构建后端、`build`、`twine` 最新版本而变化,且发布记录不能回答实际测试/构建用了哪些解析版本和哈希。`v0.5.1` 的公开包已由字节比对确认正确;风险在于后续版本的不可重现与上游供应链漂移,而非把当前包误判为损坏。 - -**复证方式:** 对比上述两条 workflow 路径即可复现:CI 使用 lock,发布路径只调用裸 `pip`。在无缓存/上游发布新兼容版本的 runner 上运行 release build 即会得到不同解析结果。 - -**建议修复:** 发布 job 使用与 CI 相同、SHA pin 的 `setup-uv`、`uv lock --check` 和 `uv sync --locked --all-extras --dev` 执行测试;固定 PEP 517 backend 与 release tooling(或将其以受审计 constraints/lock 提供);生成并保留 `pip inspect`/SBOM、wheel/sdist SHA-256 作为 release artifact。不要把终端用户的 `Requires-Dist >=` 改成不必要的精确锁定;目标是锁定**发布构建环境**。 - -**验收:** 断网或只允许 lock 中工件的环境可以完成测试/构建;构建日志与制品附带解析清单和 SHA;修改 lock 后未更新发布流程必须失败。对两个独立 runner 比较解包后的源码、METADATA 与制品哈希(或明确记录并解释时间戳导致的可接受字节差异)。 - -### P1-A4:已公开的 0.5.1 被同时标注为 “Unreleased” 和 “Pre-Alpha”,发布状态对用户不准确 - -**证据:** - -- [`CHANGELOG.md:3-14`](../../../CHANGELOG.md) 仍写 `## 0.5.1 - Unreleased`。 -- [`pyproject.toml:7`](../../../pyproject.toml) 是 `0.5.1`,但 [`19-27`](../../../pyproject.toml) 的 PyPI classifier 仍是 `Development Status :: 2 - Pre-Alpha`。 -- 远端 Release `v0.5.1` 已发布,且 PyPI JSON 已列出 `0.5.1`,工作流 `30624818485` 的上传 job 成功;这不是预发布或待审批状态。 - -**影响:** 用户通过 PyPI 看到的是一个正式可安装版本,却被元数据与变更记录告知“未发布/Pre-Alpha”;这会误导上线评估、支持预期与安全修复渠道判断,也使发布者无法从仓库得到准确的事故时间线。 - -**复证方式:** 打开上述文件和 PyPI `https://pypi.org/pypi/dyro/0.5.1/json`;下载的 wheel METADATA 也包含该 classifier。 - -**建议修复:** 立即把 changelog 条目标记为实际发布日期,并由产品所有者决定真实成熟度后调整 classifier(若仍非生产级,必须调整“可生产上线”的对外文案而非伪装为稳定)。在 release checklist 中加入“changelog 状态、PyPI classifier、Release 状态三者一致”的自动检查。 - -**验收:** 新提交的 `CHANGELOG` 日期、wheel METADATA classifier、GitHub Release 与 PyPI 版本均一致;新增测试/脚本在版本标为 `Unreleased` 时拒绝创建正式 Release。 - -### P1-A5:不存在 PyPI 误发布/漏洞发布的撤回与用户通知运行手册,当前公开版本没有预先定义的止血窗口 - -**证据:** - -- [`docs/publishing.md:24-47`](../../publishing.md) 只描述“如何发布”和发布后安装;未定义 yanking、GitHub Release 更正、受影响用户通知、替代版本、Trusted Publisher/账户失陷处置或恢复目标。对 `README*`、`docs/`、workflow 进行发布事故关键词和实际文档阅读,只找到产品内部 task/hotfix/key-revoke,而没有 PyPI release incident 流程。 -- 远端 PyPI JSON 在本审查时仍显示 `0.5.1` 的两个文件 `yanked=false`;其首次公网暴露时间分别是 `2026-07-31T10:50:36Z` 与 `10:50:37Z`。这是**潜在误发布的暴露窗口起点**,不是“已发现密钥泄露”的证据。 -- [PyPI yanking 文档](https://docs.pypi.org/project-management/yanking/) 明确 yanking 只能对整个 release,且精确 `==`/`===` 版本约束仍可能安装该版本;它是非破坏性的,不能撤销已经下载的制品。 - -**影响:** 如果未来发现错误源码、依赖供应链事故或凭据泄露,现有团队无法从仓库获知谁在几分钟内 yank、yank 后如何让 `==` 用户停止使用、是否保留/更正 GitHub Release、何时发布递增修复版本或何时轮换可信发布者。等待临时协调会扩大受影响用户的窗口。 - -**复证方式:** 读取上述发布文档并查询 PyPI `https://pypi.org/pypi/dyro/0.5.1/json` 的 `yanked` 字段;然后对照 PyPI 官方 yanking 行为。无需执行 yank、删除或重新发布。 - -**建议修复:** 在 `docs/publishing.md` 增加一页可执行的发布事故 runbook:分级、明确 incident owner 与 PyPI maintainer、在目标时限内对**整个**版本 yank 并填写原因、保留 tag/证据而非篡改历史、标记/更正 GitHub Release、发布递增修复版本、向固定版本用户/安全公告通知、以及在发布身份失陷时移除/替换 Trusted Publisher 和 GitHub 权限。演练要使用 TestPyPI 或专用演练版本,不能把生产 `0.5.1` 当作测试对象。 - -**验收:** 经人工批准的 runbook 中包含发布后 `5/15/60` 分钟责任与沟通动作、yank reason 模板和固定版本用户的升级说明;在 TestPyPI 完成一次“错误发布 → yank → 递增修复”桌面或自动化演练,证据链接回 release checklist。 - -### P2-A1:Intel macOS 的最终用户安装路径没有在发布矩阵中复证 - -**证据:** [`ci.yml:52-100`](../../../.github/workflows/ci.yml) 只对 wheel/sdist 做 Ubuntu 安装验证并在 Windows 做 import/fail-closed 验证,没有 macOS job。实际在本审查主机(macOS x86_64、Python 3.13)用公开的 `dyro-0.5.1` wheel 安装时,pip 选择 `cryptography-49.0.0.tar.gz` 而不是预编译 wheel,进入 source build;`pyproject.toml:11-14` 只给出了下界。 - -**影响:** README 的 `pipx install dyro` 在 Intel macOS 上可能要求 Rust/C 编译链,安装耗时和失败面与 Ubuntu/Windows CI 不同。当前审查没有等待本机 source build 完成,故不能声称它必然失败。 - -**复证方式:** 在无缓存的 Intel macOS Python 3.11–3.14 环境执行 `pipx install dyro==0.5.1 -v`,记录 resolver 是否获得 binary wheel 或进入 `cryptography` source build,并运行 `dyro --version` 与 `dyro dispatch doctor`。 - -**建议修复与验收:** 将 macOS arm64/x86_64 clean-install smoke 纳入发布前检查,或在安装文档明确源构建先决条件和支持范围;在干净 macOS 环境完成一次 `pipx` 安装与两个 CLI smoke 后关闭。 - -## 须人工核 - -- `0.5.1` 的 PyPI publish attestation 已通过 Integrity API 可读性核验;是否还要求更强的 SLSA source provenance、SBOM 保留期及漏洞响应 SLA,需由发布所有者确定。 -- P1-A2 的职责分离与管理员 bypass 不能由代码替代。单人发布模式可以继续运行,但必须由项目所有者书面接受它不是独立审批这一风险。 - -## Go/No-Go - -| 范围 | 结论 | 依据 | -| --- | --- | --- | -| 已发布 `v0.5.1` 制品完整性 | Go(已发布,不可回滚为“未发布”) | 远端 tag、成功 workflow、PyPI hash、wheel/sdist 对 tag 的独立逐字节比对均通过。 | -| 下一次生产发布 | No-Go | 必须先关闭 P1-A1(受信 tag/source 证明)、P1-A2(明确并落实审批治理)、P1-A3(锁定发布构建环境)和 P1-A5(误发布撤回运行手册);P1-A4 应在随后的补丁发布前纠正对外状态。 | -| 当前用户安装兼容性 | Conditional Go | Linux/Windows release smoke 已成功;Intel macOS 路径为 P2,须完成真实 clean-install 验收或明确支持边界。 | - ---- - -# Curie:本地 dispatch、Provider 适配与进程生命周期审查章节 - -审查员:Curie -时间:2026-07-31 -结论:**No-Go(本地 dispatch 作为真实 Provider 工作入口)**。Core 的 gate/merge 边界未被本审查发现可绕过,但 dispatch 仍有一个 P0 机密外泄路径,以及四项会产生虚假可用性或不受限访问的 P1。不能把本地 Provider dispatch 宣称为生产可用,直至 P0 关闭且 P1 的安全默认与用户路径被修复。 - -## 发现 - -### P0-CURIE-01:任务文本、现代凭据和模型回显均可越过机密守卫进入远端 Provider 或本地持久化 - -证据: - -- [`experiments/local_agent_dispatch/task_contract.py:58-63`](../../../experiments/local_agent_dispatch/task_contract.py) 对五段任务文本只检查非空和单字段长度;没有调用任何机密检测或脱敏逻辑。 -- [`experiments/local_agent_dispatch/adapters/subprocess_cli.py:65-87`](../../../experiments/local_agent_dispatch/adapters/subprocess_cli.py) 将 `briefing`、`locations`、`objective`、`constraints`、`output_contract` 原样拼进 Provider prompt。设计文档还建议在 objective 粘贴完整错误/日志([`docs/designs/optional-local-agent-dispatch.md:84-88`](../../designs/optional-local-agent-dispatch.md))。 -- [`experiments/local_agent_dispatch/context_guard.py:33-40`](../../../experiments/local_agent_dispatch/context_guard.py) 仅覆盖少量旧格式;本审查用非真实样例验证,`OPENAI_API_KEY=sk-proj-...` 与 `GITHUB_TOKEN=github_pat_...` 都得到 `allowed=True`。 -- [`experiments/local_agent_dispatch/adapters/subprocess_cli.py:114-129`](../../../experiments/local_agent_dispatch/adapters/subprocess_cli.py) 接受 model summary/evidence 的任意文本;[`experiments/local_agent_dispatch/result_envelope.py:68-82`](../../../experiments/local_agent_dispatch/result_envelope.py) 随后把它们放入结果记录。相同的非真实样例可被 `_parse_model_json` 原样接受于 summary 和 evidence claim。 - -影响:用户把 CI 日志、issue 文本或源码片段交给 dispatch 时,未识别的 token 可被发送给 Codex/Claude;Provider 也可把已见内容回显,继而落盘为 run/panel 结果。现有「文件注入前机密守卫」的产品承诺不覆盖任务文本和结果文本,形成实际的机密外泄边界缺口。 - -复证:不启动真实 Provider,执行了下列只读构造检查: - -```sh -PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -c '... parse_task_contract(... objective="token=sk-..."); print(token in _build_prompt(contract, {}))' -# secret_accepted=True; secret_in_prompt=True - -PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -c '... print(check_content("OPENAI_API_KEY=sk-proj-...").allowed); print(check_content("GITHUB_TOKEN=github_pat_...").allowed)' -# True; True -``` - -建议修复:建立唯一的、版本化且可测试的 secret scanner/redactor,并在以下边界 fail-closed 或脱敏后再流转:TaskContract 五段文本、每个 context 文件、Provider stdout 的 summary/evidence/warnings、持久化前的 error 文本。规则至少覆盖当前 Provider 与 GitHub token 格式,且采用长度/总量上限;禁止把原始命中值写入日志或错误信息。不要通过重新引入 Docker 规避此问题。 - -验收:新增参数化测试,证明 task 文本命中 token 时 Provider `run` 永不调用;现代 token 格式的 context 被拒绝;模拟 Provider 回显 token 后 run/panel/state 文件和 stderr 中均不含明文 token。对正常源码和无 token 结果保持兼容。 - -### P1-CURIE-02:`dispatch run --dry-run` 对无效契约和未知 Provider 返回成功,给出虚假预检绿灯 - -证据:[`experiments/local_agent_dispatch/cli.py:67-81`](../../../experiments/local_agent_dispatch/cli.py) 在读取 JSON 后,若 `args.dry_run` 就直接打印 `action=dispatch-run` 并返回 `0`;它没有调用 `parse_task_contract`、文件守卫、Provider 注册表或项目根校验。真实命令(未创建状态、未启动 Provider)已复现: - -```sh -printf '{}\n' | .venv/bin/python -m experiments.local_agent_dispatch \ - --dry-run run --project . --stdin --backend definitely-not-a-provider -# 输出 dry_run=true,EXIT=0 -``` - -影响:用户会把空任务、越界 files、strict/edit 冲突或拼错的 Provider 当作「可执行的计划」;在首次接入或发布前预检时尤其容易误判。 - -建议修复:dry-run 应执行纯本地的 `parse_task_contract`、project/files/context 预检和 Provider ID/能力解析,但不得启动认证探测或 Provider。输出显式的 `valid`、`resolved_backend` 与不可执行原因;不合法输入退出码必须为 `2`。 - -验收:空 JSON、未知 backend、零匹配 files、secret context、strict+edit 在 dry-run 均失败且不创建 state/不 spawn;有效输入输出已解析 backend 和所需人工动作。 - -### P1-CURIE-03:`backend=auto` 在没有真实 Provider 时会把离线 echo 模拟器包装成高置信度的已完成任务 - -证据: - -- [`experiments/local_agent_dispatch/adapters/registry.py:44-55`](../../../experiments/local_agent_dispatch/adapters/registry.py) 的 auto 顺序包含 `echo`,且它满足 available/authenticated。 -- [`experiments/local_agent_dispatch/adapters/echo.py:17-21,48-54`](../../../experiments/local_agent_dispatch/adapters/echo.py) 把 echo 声明为可用且已认证,并返回 `status="ok"`、`confidence="high"`。 -- [`experiments/local_agent_dispatch/supervisor.py:776-787`](../../../experiments/local_agent_dispatch/supervisor.py) 将该 `ok` 转成 persisted `completed`;evidence locator 还能产生 1.0 的 `verified_ratio`。唯一提示在 warning,缺少机器可执行的 simulated 标记。 -- 不启动真实 Provider 的 registry mock 复证:仅保留 `EchoAdapter` 时,`get_adapter("auto").id == "echo"`。 - -影响:没有已登录真实 Provider 时,普通用户提交默认 `auto` 任务会得到 `completed/high/verified_ratio=1.0`,但实际上没有模型分析。虽然 Core 不以此作为 gate,这是明显的用户可理解性和自动化消费风险。 - -建议修复:从 `auto` 候选中移除 echo;没有真实、已认证 Provider 时 fail-closed 并列出发现/登录指引。echo 仅允许显式 `--backend echo`(或显式 `--allow-offline-simulation`),且结果必须包含不可忽略的 `simulated=true` / `execution_kind=offline`,不得以可被上游误认为成功的完成结论呈现。 - -验收:mock 全部真实 Provider 不可用时,auto 非零退出且不创建 run;显式 echo 返回明确 simulation 类型,CLI/UI/skill 的文案不会将其与真实 Provider 并列为「已认证」。 - -### P1-CURIE-04:已确认的 Provider 自动发现与首次选择体验尚未实现;route 记录也不参与实际调度 - -证据: - -- [`experiments/local_agent_dispatch/adapters/registry.py:13-19`](../../../experiments/local_agent_dispatch/adapters/registry.py) 固定注册 `echo`、`codex`、`claude`;没有可扩展 Provider descriptor 或对 Cursor、OpenCode、Grok、Hermes、Kimi 等本机 CLI 的检测。 -- [`experiments/local_agent_dispatch/skill_render.py:30-35`](../../../experiments/local_agent_dispatch/skill_render.py) 的 `route add` 可以保存任意 backend 字符串;但 route 只在同文件的渲染路径读取([`skill_render.py:38-112`](../../../experiments/local_agent_dispatch/skill_render.py)),[`panel.py:23-45`](../../../experiments/local_agent_dispatch/panel.py) 和 registry 均不消费这些偏好。 -- 主 CLI 的 profile adapter 预设也只有 `codex`/`noop`([`src/dyro/cli.py:1120-1128`](../../../src/dyro/cli.py)),与 dispatch 的 Claude 支持不一致。 - -影响:用户此前明确选择「自动检查本地可用 Provider,或首次使用时让用户选择」。当前只能得到两个硬编码真实 Provider;保存的“用户路由”不会影响执行,且可保存永远不可用的名称。用户会在 setup、`backends`、`route add` 和实际 run 之间遭遇不一致。 - -建议修复:设计受控的 Provider descriptor/adapter 入口(每个 Provider 有 command、非交互 auth probe、最小环境白名单、能力和隔离声明),先以安全的本机命令发现生成候选列表,再在首次真实 dispatch 要求选择/确认并保存**经校验**的偏好。执行时必须读取该偏好或显式 backend,并显示最终 Provider。未知或未认证 Provider 不能写入 route。保持 Dyro 为控制平面;不重新引入 Docker 或外部 semantic runtime。 - -验收:用临时 PATH 的假 CLI 覆盖至少 Codex、Claude、Cursor、OpenCode、Kimi 的发现/未认证/已认证三态;首次选择持久化后会真实影响 `auto` 的排序;route add 对未知/未认证名称拒绝;无真实 Provider 时给出可操作安装/登录说明而非 echo 成功。 - -### P1-CURIE-05:真实 Provider 的默认非 strict 路径只靠提示词限制 files,不会物理限制其读取项目中未列入的文件 - -证据: - -- [`experiments/local_agent_dispatch/supervisor.py:656-677`](../../../experiments/local_agent_dispatch/supervisor.py) 对默认 `strict=false` 令 `work_cwd=project_root`;只有 strict 才创建 shadow。 -- Codex 仅使用 read-only sandbox([`subprocess_cli.py:286-301`](../../../experiments/local_agent_dispatch/adapters/subprocess_cli.py)),Claude 显式授予通用 `Read`(edit 模式为 `Read,Edit`,[`subprocess_cli.py:350-365`](../../../experiments/local_agent_dispatch/adapters/subprocess_cli.py))。两者没有 files allowlist 传给工具层。 -- prompt 中的 “Use only the context supplied below” 只是指令([`subprocess_cli.py:77-80`](../../../experiments/local_agent_dispatch/adapters/subprocess_cli.py));设计文档也承认 Codex/Claude 不具备 strict isolation([`docs/designs/optional-local-agent-dispatch.md:174-182`](../../designs/optional-local-agent-dispatch.md))。 - -影响:`files` 只限制注入文本,不能限制真实 Provider 工具读取同一项目里的未列文件。默认 `strict=false` 容易被当作「文件白名单已生效」;一旦 context 中存在提示注入或任务含糊,敏感项目文件可被读取并回显到结果。此项不建议用 Docker 回退;当前固定决策已移出该 runtime。 - -建议修复:在真实 Provider dispatch 前要求显式 acknowledgement(例如 `--allow-unconfined-provider`)并将结果标识为 `isolation=best_effort`;生产默认应在可证明物理隔离前 fail-closed。长期方案是每个 Provider 的本机隔离能力经独立验证后才可声明 strict,或使用只包含批准文件的本机投影目录并明确其边界。 - -验收:默认真实 Provider 请求在无已验证 isolation 时明确拒绝并说明原因;带显式 acknowledgement 的结果包含 non-strict 风险标记;Provider-specific strict capability 需用独立的文件越界读取测试证明后才能启用。 - -### 须人工核:进程身份与 Windows/POSIX 部署基线 - -- 本审查的受限 macOS 沙箱禁止执行 `/bin/ps`。因此 [`process_identity.py:21-55`](../../../experiments/local_agent_dispatch/process_identity.py) 退化为 `unknown-...` token,随后 [`process_identity.py:109-118`](../../../experiments/local_agent_dispatch/process_identity.py) 返回 self-match false。focused suite 的 92 项中 91 通过、1 项失败:`ProcessIdentityTests.test_current_identity_matches_self`。这不能证明 GitHub Linux CI 或真实 macOS 终端存在同一故障,故标为**须人工核**;若产品需要在受限执行器中运行,则应把该路径升级为 P1 并改为可验证的原生进程身份来源/显式 unsupported。 -- Windows 的当前承诺是 import/discovery 可用、`run`/`panel`/worker fail-closed([`supervisor.py:64-69`](../../../experiments/local_agent_dispatch/supervisor.py),[`README.md:40-43`](../../../experiments/local_agent_dispatch/README.md))。须在干净 Windows 主机实际安装 wheel 后验证:`dyro dispatch --dry-run doctor` 不写状态/不启动 CLI,`dyro dispatch run` 给出清晰 POSIX 限制错误。不得把未实测的 Windows 执行能力写入发布声明。 - -已执行验证: - -- `PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m unittest tests.test_local_agent_dispatch tests.test_local_agent_dispatch_l1_l4 tests.test_adversarial_remediation_dispatch -v`:92 项,91 pass、1 fail、1 skip;唯一失败如上,且受 `/bin/ps` 沙箱限制影响。 -- 所有复现均为本地解析/构造或 echo 测试;未启动 Codex、Claude 或任何外部 Provider,未创建外部资源。 - -## Go/No-Go - -- Local dispatch(真实 Provider):**No-Go**。先关闭 P0-CURIE-01;随后完成 P1-CURIE-02 至 P1-CURIE-05 的安全默认、真实 Provider 选择与 dry-run 可信度修复,并在真实 macOS/Linux 与 Windows discovery 场景完成上述人工核。 -- Local dispatch(仅显式 echo 协议测试):**Conditional Go**。可保留为开发/测试工具,但必须在 P1-CURIE-03 修复前禁止它成为 `auto` 的成功回退。 -- 进程清理与租约:源码与大部分对抗单测表明 fail-closed 方向正确;本受限沙箱无法提供生产级进程身份闭环证据,不能以本次本地结果替代目标主机验证。 - ---- - -# Turing:Core 用户流程、状态/证据/签名与 CLI 体验审查章节 - -审查员:Turing -时间:2026-07-31(Asia/Taipei) -结论:**No-Go(Core 任务生命周期)**。已复证 3 个 P1:默认本地任务可绕过独立 review 直接合并、真实执行失败会遗留不可恢复的 `in_progress`,以及文档承诺的外部 `QUESTION → answer → 下一份证据` 续跑由内置 CLI 生成器实际无法完成。另有 1 个会触发/放大该恢复问题的 P2 输入校验缺口。未发现可由本审查复证的 P0;但 P1 未关闭前不能把 Core 描述为可生产上线。 - -## 发现 - -### 已复证的正向事实(用于排除误报) - -- `run → review → review → merge` 的标准本地路径、要求外部 sign-off 的路径,以及带签名外部 CLI 的端到端路径均通过了现有测试;问题不是正常路径完全不可用,而是 public CLI/生成器存在没有走该路径的反例。 -- 使用 `UV_CACHE_DIR=/tmp/dyro-adversarial-uv-cache uv run python -m unittest discover -s tests -t . -q` 复跑全套测试,退出码为 0。Docker 相关的集成用例仍依环境预期跳过;该结果不能抵消以下用真实 Core API/CLI 语义构造出的反例。 - -### P1-TURING-01:`task status` 可跳过独立 review,直接将默认任务合并 - -**证据:** - -- [`src/dyro/cli.py:530-537`](../../../src/dyro/cli.py) 的公开 `dyro task status TASK done` 直接调用 `set_status`;没有 `--force`、review receipt 或 reviewer 身份要求。 -- [`src/dyro/tasks.py:37-47`](../../../src/dyro/tasks.py) 允许 `review → done`;[`423-436`](../../../src/dyro/tasks.py) 的 `set_status` 只校验图上的状态迁移。默认 profile 不要求 external sign-off 时,它不会验证 review evidence。 -- [`src/dyro/tasks.py:1809-1813`](../../../src/dyro/tasks.py) 的 `merge_task` 只要求状态为 `done`,不会再次绑定/验证已接受的 review。 -- 这与 [`README.md:455-457`](../../../README.md) 和 [`docs/diagrams.md:112`](../../diagrams.md) 所承诺的“独立 review PASS 后才能 done”不一致。 - -**最小复现:** 用测试工作区创建默认 `local` 任务,先运行至 `review`,不执行 `review_task`。随后走与 CLI 相同的 `set_status(config, task, "done")`,再调用 `merge_task`:输出为 `run=review`、`status=done review_exists=False`、`merge=ok`。任务 ledger 只记录 `phase=status, from_status=review, to_status=done`,没有 review acceptance、reviewer 或理由。该 API 是上述 CLI 命令的直接实现,不依赖篡改 state 文件。 - -**影响:** 单机操作员可以误操作或用脚本绕过产品反复强调的独立审查,未审查变更会进入 delivery line。该问题不是外部权限提升,但破坏了 Dyro 自己的发布完整性与可审计性承诺。 - -**建议修复边界:** `task status` 应只提供观察或安全恢复,禁止它进入 `review`、`review_pending_signoff`、`done` 等质量门状态;把 `done` 收敛到 `_apply_review_decision`/`_signoff_task` 等私有路径,并让 `merge_task` 重新验证有效、绑定当前 task head 的 accepted review/sign-off。若保留管理员恢复,另设显式 `task recover --force --reason`,在 ledger 记录 actor/reason/override,且不能单独解除 merge 所需的 review 证据。 - -**验收:** CLI 与直接 public API 对 `review → done` 均拒绝;伪造/缺失 review 时 merge 拒绝;正常 review、外部 sign-off 和签名 evidence 仍可完成;帮助、README 和状态图同一语义。 - -### P1-TURING-02:Agent 或 gate 的真实运行异常只失败 attempt,任务永久停在 `in_progress` - -**证据:** - -- [`src/dyro/tasks.py:680-719`](../../../src/dyro/tasks.py) 的 `_complete_execution_attempt` 在 executor 异常时将 execution attempt 标为 `failed`、写 `attempt_failed` 后重新抛出,但没有把 task 转为 `failed`。 -- `run_task` 经 [`1143-1170`](../../../src/dyro/tasks.py) 调用该 helper,实际 Provider/gate 执行在 [`1190`](../../../src/dyro/tasks.py);answer 后 continuation 同样经 [`1396-1423`](../../../src/dyro/tasks.py) 走此路径。 -- [`src/dyro/process.py:36-49`](../../../src/dyro/process.py) 会对缺失命令和超时抛 `DyroError`。这两个正常故障面没有被 task 状态恢复处理。 - -**最小复现:** 配置 `noop.write = definitely-missing-dyro-agent` 后运行任务,得到 `DyroError: 找不到可执行命令…`,随后读取 state:`task.status=in_progress`、`attempt.status=failed`。将任务 timeout 配为 `0` 时,`/usr/bin/true` 抛出 `命令超时(0s)` 后也留下 `in_progress`。当前 `run_task` 只允许 `backlog/assigned/failed` 进入,`answer_task` 只接受 `waiting_answer`,因此用户不能通过正常 retry/answer 自救,只能调用前述不安全的任意状态命令。 - -**影响:** 常见本机安装、PATH、Provider、gate 或超时故障会产生“任务正在运行”的假象并卡死用户流程;attempt ledger 与 task 状态相互矛盾,后续操作的恢复语义不可预测。 - -**建议修复边界:** 对执行/continuation/gate 的异常,在确认 task 仍属于该 attempt 且状态为 `in_progress` 后原子化迁移为 `failed`,保留原始异常为主错误、避免 ledger/cleanup 异常覆盖它;不得把已进入 review 的失败错误回退成执行失败。随后提供受控 retry,新的 attempt 必须单调递增。 - -**验收:** 缺失 adapter、超时、gate 启动失败和 answer continuation 失败都产生 `attempt=failed` 与 `task=failed`,可重试且生成新 attempt;异常文案/退出码保留;并发 worker 不能把其他 generation 的 task 标失败。 - -### P1-TURING-03:外部 `QUESTION → answer → 下一份 evidence` 是文档承诺,但内置证据生成器必然产生 attempt 冲突 - -**证据:** - -- [`README.md:363`](../../../README.md) 宣称外部 runner 返回 `QUESTION` 后,`task answer` 保留 claim、任务回到 `assigned` 并接收下一份 evidence。 -- [`src/dyro/evidence.py:231-259`](../../../src/dyro/evidence.py) 构建 external execution record 时未传入任务已有 attempt 序号;[`src/dyro/provenance.py:201-230`](../../../src/dyro/provenance.py) 的 `build_external_attempt_record` 每次随机生成 run/attempt ID,却硬编码 `attempt_number: 1`。 -- [`src/dyro/provenance.py:385-414`](../../../src/dyro/provenance.py) 对同一 task 的同号不同 attempt ID 正确地 fail-closed;因此它会拒绝该内置生成器的第二份 bundle。 - -**最小复现:** 用 external profile 的内置 `evidence build` 生成 `QUESTION` bundle,claim/import 后任务为 `waiting_answer`;调用 `answer_task` 后状态为 `assigned`。再次用同一内置生成器生成 `DONE` bundle 并 import,得到 `ValidationError: external attempt 序号冲突:TASK-…`,任务仍为 `assigned`。现有 `test_external_question_can_be_answered_by_the_claimed_runner` 仅覆盖 answer,没有覆盖下一次 import,因此未发现该断链。 - -**影响:** 用户完全按公开 CLI/README 操作时,外部问题续跑无法闭环;已获回答和 claim 无法让下一轮 proof 导入。若通过手改 evidence 绕过,又会削弱 provenance 的单调性保障。 - -**建议修复边界:** 证据 build 不能使用 runner-local随机 attempt 计数。由 Core 在 claim/answer 时保留并签发或预约下一期 attempt number,生成器带入同一 `run_id` 和严格单调的 attempt number,import 对 reservation/claim generation 绑定校验。不要为“修复”而放宽同号冲突拒绝;若暂不支持续跑,应从 CLI/README 删除该承诺并明确 fail state。 - -**验收:** 用实际 CLI 覆盖 unsigned 与 signed 两套 `QUESTION → answer → DONE`;第二份 bundle 成功后 lineage 为同 run、递增 attempt,重放/并行旧 bundle/跨 claim 的 bundle 均被拒绝,且失败不会改变 task 或 claim。 - -### P2-TURING-01:task manifest 的时限字段被隐式强转,`bool`、字符串、零和负数可在开始后才触发异常 - -**证据与复现:** [`src/dyro/tasks.py:148`](../../../src/dyro/tasks.py) 对 gate timeout 使用 `int(...)`;[`163-164`](../../../src/dyro/tasks.py) 同样转换 task/review timeout。将 template 替换为 `timeout_minutes = true`、`review_timeout_minutes = "0"`、`timeout_seconds = -1`,`_parse_task` 成功得到 `1, 0, -1`;其中 `timeout_minutes=0` 可直接触发 P1-TURING-02 的 stuck 状态。 - -**建议修复与验收:** 在 `_parse_task` 的持久化/预约前使用单一严格整数校验:拒绝 bool、字符串、零、负数、非有限数,设定合理上限;错误不得创建 attempt 或改变 task 状态。补充每个 timeout 字段的边界表和一个「无状态写入」回归测试。 - -## Go/No-Go - -| 范围 | 结论 | 依据 | -| --- | --- | --- | -| Core 本地 run/review/merge | **No-Go** | P1-TURING-01 使默认 public CLI 绕过 review 并合并;P1-TURING-02 令常见失败不可恢复。 | -| Core 外部 evidence 续跑 | **No-Go** | P1-TURING-03 与文档/CLI 承诺相矛盾,QUESTION 后不能导入下一份内置生成的 evidence。 | -| 签名与基础 evidence 校验 | Conditional Go | 已有签名/完整性回归通过,但必须在以上状态机修复后重新跑端到端覆盖,不能以此取代 review 与 attempt lineage 绑定。 | - -**关闭条件:** 先以回归测试关闭三个 P1,再重跑全套测试和 `init/setup/doctor/start/task/evidence/review/signoff/merge` 的干净单机 CLI smoke;有任一状态/证据契约变更时,同步更新 README、状态图和 machine-readable JSON 示例。 - ---- - -# Final Arbitration - -仲裁者:Codex -时间:2026-07-31(Asia/Taipei) - -## 1. 最终结论 - -源码层的 P0/P1 已完成修复并由独立代码复审复核;本轮没有重新引入 Docker 或外部 TypeScript semantic runtime。候选版本为 `0.5.2`,但**整体仍为发布 No-Go**:公开的 `dyro 0.5.1` 当前仍未 yank,且 PR 仍需独立批准。PR #14 在 `b68580a` 已完成 8 项必需 CI(Linux Python 3.11–3.14、Windows、Intel macOS、wheel/sdist 与 TypeScript);macOS runner 已从不再列为标准 runner 的 `macos-13` 迁移至受支持的 `macos-15-intel`,保持 Intel 覆盖。2026-07-31 已实际启用 `main` 和 PyPI Environment 的远端治理;剩余发布处置必须在 PyPI 项目控制台完成,不能由本地代码替代。 - -已关闭的源码问题包括:任务/Provider 输出/异常的机密检测和脱敏;真实 Provider 的显式非物理隔离确认与只读上下文投影;`auto`/Panel 不再回退 echo;发现但未审计的 CLI 不可调度;公共状态命令不能跨越质量门;merge 重验 review/signoff;外部 QUESTION 续跑的同 run 单调 provenance;严格超时校验;tag 来源、锁文件和分发构建门禁。 - -## 2. 模块 Go/No-Go - -| 模块 | 结论 | 依据 | -| --- | --- | --- | -| Core | Conditional Go | 公开状态旁路已封堵;merge 会重验当前 review/signoff 与 task HEAD;全量单测和 PR 目标平台 CI 已通过。 | -| Local dispatch | Conditional Go | 已 fail-closed、Provider 投影/显式确认/模拟标签均落地;尚未实际执行 Codex/Claude,Windows 仍只承诺 import/discovery fail-closed。 | -| 供应链与发布 | No-Go | 发布工作流与远端治理已收紧,8 项 PR CI 已通过;但 0.5.1 仍显示 `yanked=false`,且合并与 PyPI 发布均需要独立审批。 | -| 整体 | No-Go | 先 yank 0.5.1、取得独立 PR 批准并合并,再可把 0.5.2 标为可发布。 | - -## 3. P0 Required Fixes - -1. **已关闭(代码):** 任务五段文本、上下文、Provider JSON、告警和 Provider 异常在持久化前统一经过机密守卫;命中时只保留通用脱敏错误。回归用例验证 token 不会写进 run JSON。 -2. **发布前必须人工完成:** 在 PyPI 项目管理页 yank `0.5.1`,原因使用不含机密的“security fix available in 0.5.2”;保留 tag/Release 作为取证指针,并按 `docs/publishing.md` 通知精确版本 pin 用户升级。2026-07-31 的实时 PyPI JSON 显示该版本仍为 `yanked=false`。 - -## 4. P1 / P2 - -| 编号 | 处置 | 复证 | -| --- | --- | --- | -| CURIE-02 | 已关闭:dry-run 执行纯本地契约、上下文和 backend 预检,未知 backend 返回 2 且不创建状态。 | `CliTests.test_dry_run_validates_contract_and_known_backend_without_state_or_probe` | -| CURIE-03/04/05 | 已关闭:auto/Panel 无 echo 回退;route 只接受已认证集成 Provider;Cursor/OpenCode/Grok/Hermes/Kimi 为发现但不可调度;真实只读路径投影白名单并要求确认。 | registry/panel/route/投影回归 | -| TURING-01/02/03 | 已关闭:质量门私有化、merge 重验、异常任务转 failed、external continuation 同 run 单调递增。 | `tests.test_tasks`、`tests.test_provenance` | -| TURING-01 P2 | 已关闭:task/gate/review timeout 拒绝 bool、字符串、零、负数和超限值。 | timeout manifest 回归 | -| ATLAS-01/03/04/05 | 已关闭:验证 tag checkout/main ancestry、锁定 uv 构建、记录制品哈希、Beta classifier/released changelog、事故/yank runbook。 | `tests.test_release_source`、build、twine strict | -| ATLAS-02 | 已关闭:启用 main protection(PR、1 个批准、最新推送复审、讨论解决、管理员受约束、8 个 CI check)及 PyPI Environment 分离审批、禁 self-review/admin bypass、仅受保护分支。 | GitHub REST 更新回执:2026-07-31。 | - -## 5. Requires Human Verification - -1. PR #14 的 8 项 GitHub CI 已通过;在无新提交的前提下,取得独立审批后再合并,保护规则会强制复核最新推送与所有必需 check。 -2. 在受控 macOS/Linux 主机分别执行一次真实 Codex 与 Claude 的只读最小任务;确认 `CODEX_HOME`/`CLAUDE_CONFIG_DIR` 等显式授权配置下的登录与 JSON 协议,并核对未列文件不在投影目录。不得以 echo 替代。 -3. 维持 GitHub 远端治理:`main` 已要求 PR review/CI 且禁止直接 push/force push;PyPI Environment 已要求非发起人审批、禁止 self-review/admin bypass,并限制到受保护分支。 -4. 完成 `0.5.1` PyPI yank 和事故记录;确认项目页的 yanked 标记后,再创建 `v0.5.2` Release。 -5. 在 TestPyPI 先执行一次发布与 yank 通告演练;0.5.2 不能覆盖 0.5.1,必须走新 tag、完整 CI 与 Environment 审批。 - -已执行复证:`uv lock` / `uv sync --locked --all-extras --dev`、全量 `unittest discover`(退出码 0;受限沙箱中不可证明 process identity 的测试按设计 skip)、ruff、compileall、`python -m build`、`twine check --strict`,以及干净 CPython 3.13 环境对 wheel/sdist 的独立 `experiments.local_agent_dispatch` import/doctor smoke。独立代码审查未发现新增 P0/P1/P2。 - -最终签名:Codex 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 deleted file mode 100644 index d9270d1..0000000 --- a/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-design-adversarial-review-board.md +++ /dev/null @@ -1,522 +0,0 @@ -# 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 deleted file mode 100644 index 5c800d5..0000000 --- a/docs/superpowers/reviews/2026-08-06-dyro-agent-bridge-phase-0-design-closure-review.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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 deleted file mode 100644 index c2b0115..0000000 --- a/docs/superpowers/reviews/2026-08-10-dyro-agent-bridge-phase-0-fix-adversarial-review-board.md +++ /dev/null @@ -1,452 +0,0 @@ -# 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 deleted file mode 100644 index 34c7759..0000000 --- a/docs/superpowers/reviews/2026-08-12-dyro-0.6.3-release-adversarial-review-board.md +++ /dev/null @@ -1,542 +0,0 @@ -# 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/superpowers/reviews/2026-08-12-dyro-skill-lifecycle-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-12-dyro-skill-lifecycle-adversarial-review-board.md deleted file mode 100644 index 128932c..0000000 --- a/docs/superpowers/reviews/2026-08-12-dyro-skill-lifecycle-adversarial-review-board.md +++ /dev/null @@ -1,498 +0,0 @@ -# Dyro Skill Lifecycle Adversarial Review Board - -Date: 2026-08-12 - -Scope: -- Repository: `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev/dyroengineeringflow` -- Branch: `feat/dev` (uncommitted working tree on top of `7bb8c64`) -- Question: Are the Skill lifecycle hooks (setup guide + post-update sync + startup repair) safe and ready to land? - -Reviewed Materials: -- Working tree diff (exclude user WIP plans): `/tmp/dyro-skill-lifecycle.diff` -- `src/dyro/cli.py` (`_setup_skill_preference`, `_apply_setup_personal_preferences`, `cmd_integration_sync`, `_refresh_skill_via_new_cli`, `_maybe_sync_managed_skill`, daily-update wiring) -- `src/dyro/integrations/manager.py` (`sync_managed_skill`) -- `src/dyro/integrations/__init__.py` -- Tests: `tests/test_cli.py`, `tests/test_integrations.py`, `tests/test_updates.py` -- Docs: `CHANGELOG.md` Unreleased, `docs/updates.md`, localized READMEs - -SSOT: -- Product: setup guided first install; package update syncs **managed** Skill only; interactive startup repairs **OUTDATED** managed Skill; never first-install on upgrade/startup -- Ownership/fail-closed Skill install from 0.6.3 (legacy target allowlist, packaged-asset check) remains binding -- Preview-first / `--yes` for explicit install; automatic paths may use `--yes` only for managed upgrade/repair - -## 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: bugs, security, regressions, missing tests, misleading UX first. -6. Do not reopen “silent first-install for everyone” unless source proves current opt-in is unsafe. - -## Fixed Decisions - -- First-time Skill install remains opt-in (setup or explicit CLI). -- Post-update / startup paths must not first-install absent Skills. -- Post-update sync should use fresh `dyro` entry point when possible (new package assets). -- Conflict / recovery / unowned states remain fail-closed (soft warn, no mutate). - -## Open Micro-Decisions - -1. Should startup Skill sync share the daily-update interactive gate only, or also run for other interactive commands? -2. When post-update CLI sync fails, is “retry next launch” enough, or must update exit non-zero? -3. Should non-interactive `dyro setup --non-interactive` gain an explicit `--install-skill` flag in this change? - ---- - -# Claude Review Section - -Reviewer: Claude (Composer) -Time: 2026-08-12 -Verdict: Conditional Go - -## Summary - -End-to-end contract is **correct in source**: `sync_managed_skill` gates `ABSENT` on `allow_first_install`; setup uses preview (`dry_run=True`) then apply (`yes=True`) only after plan confirmation; post-update and startup paths pass `allow_first_install=False`; startup repair triggers **only** on `IntegrationState.OUTDATED`. No path silently first-installs an `ABSENT` Skill on upgrade or launch. Remaining issues are UX accuracy and test gaps, not lifecycle safety. - -## Evidence - -### P0 — None - -No blocker found. `ABSENT` + `allow_first_install=False` returns `None` before `install_integration` (`manager.py:1035-1036`). Startup gate is `status.state is not IntegrationState.OUTDATED` (`cli.py:3855-3856`). Post-update subprocess invokes `integration sync skill --yes` (`cli.py:3827-3828`), which sets `allow_first_install=False` (`cli.py:1560-1563`). - -### P1 - -| ID | Finding | Evidence | -|----|---------|----------| -| P1-1 | **Setup completion misreports Skill outcome on soft-fail.** `_apply_setup_personal_preferences` prints a warning and returns early on `DyroError`, but callers always invoke `_print_setup_completion`, which prints `Skill:已请求安装 / 同步` whenever `preferences.install_skill` is true—never whether apply succeeded. | `cli.py:659-669`, `cli.py:782-783`, `cli.py:867-868`, `cli.py:912-913` | -| P1-2 | **Post-update Skill sync is untested at the subprocess boundary.** `_refresh_skill_via_new_cli` is wired from `cmd_update_now` and `_maybe_run_daily_update` (`cli.py:1618-1619`, `3810`) but has no direct test (only mock-assert in auto-patch test). Subprocess failure modes (non-zero exit, timeout, missing `dyro` on PATH) are unverified. | `cli.py:3815-3846`, `tests/test_updates.py:520-521` (mock only) | - -### P2 - -| ID | Finding | Evidence | -|----|---------|----------| -| P2-1 | **Startup OUTDATED repair includes legacy Codex migration without session opt-in.** Pre-existing state machine marks legacy installs `OUTDATED` when manifest is absent (`manager.py:566-574`); new startup hook auto-mutates with `--yes`. Not `ABSENT` first-install, but silent migration—align docs if intentional. | `manager.py:566-574`, `cli.py:3849-3872` | -| P2-2 | **Same-session post auto-patch drift if subprocess sync fails.** After in-process auto-patch, running interpreter still holds old `ASSET_VERSION`; `_maybe_sync_managed_skill` may see `CURRENT` and skip. Recovery depends on subprocess refresh or next launch. Acceptable per "retry next launch" but worth documenting. | `cli.py:3796-3810`, `3855-3856`, `manager.py:699-730` | -| P2-3 | **Test gaps:** no test that conflict states (`DRIFTED`, `UNOWNED_CONFLICT`, etc.) force `_setup_skill_preference()` → `False`; no end-to-end interactive setup Skill preview→apply test; no CLI test for `cmd_integration_sync`. | `cli.py:567-580`, `tests/test_cli.py:573-612` | -| P2-4 | **`cmd_integration_sync` preview path:** when `plan is None`, message conflates ABSENT and CURRENT ("未安装或已是当前版本"). Accurate but coarse for operator debugging. | `cli.py:1565-1566` | - -## Verified Contracts (pass) - -| Path | `allow_first_install` | First-install possible? | -|------|----------------------|-------------------------| -| Setup preview | `True`, `dry_run=True` | Preview only, no writes (`cli.py:640`) | -| Setup apply | `True`, `yes=True` | Yes, after user confirms plan (`cli.py:661`, `904-912`) | -| `integration sync` CLI | `False` | No (`cli.py:1560-1563`) | -| Post-update subprocess | via sync CLI → `False` | No (`cli.py:3827-3828`) | -| Startup `_maybe_sync_managed_skill` | `False`, OUTDATED only | No (`cli.py:3855-3859`) | -| Setup dry-run | apply skipped | No (`cli.py:901-903`, `824-826`) | -| Non-interactive setup | skill preference not invoked | No (`cli.py:1016+`) | - -Setup conflict/recovery states are fail-closed in the preference step (`cli.py:567-580`), consistent with SSOT. - -## Required Fixes (for unconditional Go) - -1. **P1-1:** Track Skill apply outcome (success / soft-fail / skipped) and reflect it in `_print_setup_completion` instead of echoing `preferences.install_skill` alone. -2. **P1-2:** Add tests for `_refresh_skill_via_new_cli`: success stdout passthrough, non-zero exit warning, timeout/OSError, missing `dyro` on PATH; assert `cmd_update_now` calls it only when `perform_update` returns `True`. - -## Open Micro-Decisions — Vote - -| # | Question | Vote | Rationale | -|---|----------|------|-----------| -| 1 | Startup sync: share daily-update gate only? | **Yes — keep current gate** | Matches docs (`dyro` / `home` / `start`, interactive, not dry-run). `_should_run_daily_update` at `cli.py:3780-3784`; hook at `3888-3890`. Broader scope adds surprise mutations on administrative commands. | -| 2 | Post-update sync failure: "retry next launch" enough? | **Yes** | Package update already succeeded; non-zero exit would punish a partial best-effort. Subprocess + startup OUTDATED repair provide two retries (`cli.py:3837-3842`, `3857-3865`). | -| 3 | `--install-skill` for non-interactive setup in this change? | **Defer** | Non-interactive path correctly skips Skill today (`1016+`). Flag is useful but orthogonal; ship lifecycle hooks first. | - ---- - -# OpenCode Review Section - -Reviewer: OpenCode (Composer) -Time: 2026-08-12 -Verdict: **Request changes — do not land until P0 is fixed** - -## P0 - -**1. Same-session stale overwrite after successful auto-update refresh** - -**Evidence chain:** - -- `src/dyro/cli.py` `main()` (L3888–3890): on interactive `dyro` / `home` / `start`, unconditionally runs `_maybe_run_daily_update()` then `_maybe_sync_managed_skill()` in the same process turn. -- `src/dyro/cli.py` `_maybe_run_daily_update()` (L3796–3810): when `auto_patch` and patch available, calls `perform_update(..., yes=True)`; on `updated=True`, calls `_refresh_skill_via_new_cli()` then returns — but does **not** skip the subsequent `_maybe_sync_managed_skill()` in `main()`. -- `src/dyro/cli.py` `_refresh_skill_via_new_cli()` (L3815–3846): spawns subprocess `[dyro_bin, "integration", "sync", "skill", "--yes"]` using a **new** `dyro` entry point (fresh wheel assets on disk). -- `src/dyro/cli.py` `_maybe_sync_managed_skill()` (L3849–3872): if `integration_status("skill").state is IntegrationState.OUTDATED`, calls `sync_managed_skill(yes=True, allow_first_install=False)` **in-process** via `install_integration()`. -- `src/dyro/integrations/manager.py` `integration_status()` (L699–730): freshness compares manifest `asset_digest` against `_asset_digest(_asset_inventory())` loaded from the **currently running** Python package (`ASSET_VERSION`, packaged skill files). - -**Failure mode:** - -1. Old in-process Dyro (pre-patch) runs `perform_update()`; disk now has new package. -2. Subprocess refresh writes Skill mirror/manifest with **new** package `asset_digest`. -3. Old process still loads `_asset_inventory()` from **old** wheel; manifest digest ≠ old desired digest → status `OUTDATED` (L724–730). -4. `_maybe_sync_managed_skill()` reinstalls Skill content from **stale** in-process assets, overwriting the subprocess sync. - -This violates board SSOT (“post-update sync should use fresh `dyro` entry point when possible”) and can silently regress Skill assets on the launch that auto-patched. - -**Required fix:** Skip `_maybe_sync_managed_skill()` when auto-update or post-update refresh already ran in the same `main()` turn; or re-exec into the new entry point before any in-process Skill mutation; or have `_maybe_run_daily_update()` return a flag consumed by `main()`. - -## P1 - -**2. `_refresh_skill_via_new_cli()` PATH binding not tied to upgrade target** (`src/dyro/cli.py` L3817–3828) - -Uses `shutil.which("dyro")` with inherited `PATH`. After `pip --user`, `pipx`, or `uv tool` upgrade, the first `dyro` on `PATH` may not be the installation just updated (multiple installs, shadowed `~/.local/bin`, dev venv vs global wrapper). Subprocess may invoke wrong CLI/assets while parent treats refresh as best-effort complete. - -**Required fix:** Derive entry point from active install context (`build_update_plan` / `sys.prefix` / pipx-venv bin); pass explicit `env`; do not rely on bare `which("dyro")`. - -**3. Recursion correctly avoided; fallback still uses stale assets** - -Subprocess `integration sync` does not re-enter daily update (`_should_run_daily_update()` L3780: `command` must be `{None, "home", "start"}`). When subprocess refresh **fails**, `_maybe_sync_managed_skill()` still runs in-process with old assets — acceptable until restart, but P0 “success then overwrite” is strictly worse. - -**4. Test coverage gaps on automatic paths** - -Present: `tests/test_updates.py` mocks `_refresh_skill_via_new_cli` on daily auto-patch success/failure; `test_startup_syncs_outdated_managed_skill`; `tests/test_integrations.py` `test_sync_managed_skill_*`. - -Missing (required before ship): - -| Gap | Risk | -|-----|------| -| No test that `_refresh_skill_via_new_cli` invokes expected argv / handles non-zero exit / timeout | Subprocess regressions undetected | -| No test that `cmd_update_now` (L1618–1619) calls refresh when `perform_update` returns `True` | Wiring drift vs daily path | -| **No regression test for P0** (auto-update + successful refresh → `_maybe_sync_managed_skill` must not run in-process) | P0 can reappear | -| No CLI tests for `cmd_integration_sync` (L1557–1570): preview gate, `--yes`, `--dry-run`, ABSENT no-op | DRY-RUN/`--yes` contract untested | -| No test that refresh is not called on dry-run / failed update | False-positive side effects | - -## P2 - -**5. DRY-RUN / `--yes` semantics: install vs sync** - -Shared preview gate in `cmd_integration_install` (L1549–1554) and `cmd_integration_sync` (L1557–1570): - -```python -preview = args.dry_run or not args.yes -``` - -- `install`: always produces a plan via `install_integration()` including `ABSENT`; preview prints `DRY RUN:` — good. -- `sync`: `sync_managed_skill(..., allow_first_install=False)` (`manager.py` L1033–1036) returns `None` for `ABSENT`/`CURRENT`; CLI prints `无需同步;Skill 未安装或已是当前版本。` — correct upgrade-only semantics, conflates two states, not CLI-tested. -- `--yes` + `--dry-run` → preview-only, no writes — matches install/uninstall — good. -- Automatic paths (`_refresh_skill_via_new_cli`, `_maybe_sync_managed_skill`, setup apply via `_apply_setup_personal_preferences` L659–673) bypass preview/`--yes` for managed repair only — aligned with board SSOT. - -**6. Minor UX / docs** - -- Setup preview uses `sync_managed_skill(yes=False, dry_run=True, allow_first_install=True)` (`cli.py` L640); apply uses `yes=True` (L659); dry-run setup returns before apply (L901–903) — verified. -- Localized READMEs and `docs/updates.md` document `integration sync skill --yes`; no CLI test locks upgrade-only contract. - -## Required Fixes (summary) - -1. **P0:** Prevent `_maybe_sync_managed_skill()` in `main()` (L3890) after successful auto-update / post-update refresh in the same invocation. -2. **P1:** Resolve post-update `dyro` binary from upgrade target, not global `PATH`/`which`. -3. **P1:** Add tests for `_refresh_skill_via_new_cli`, `cmd_update_now` → refresh, P0 regression, and `integration sync` CLI preview/`--yes`/`--dry-run`. -4. **P2:** CLI test or clearer messaging distinguishing `ABSENT` vs `CURRENT` on `integration sync`. - ---- - -# Hermes Review Section - -Reviewer: Hermes (Security) -Time: 2026-08-12 -Verdict: **Conditional Go** - -## Hunt Results (evidence-backed) - -### P0 — None - -No remotely exploitable path found that first-installs on startup/update, bypasses legacy allowlist / packaged-asset checks, or overwrites foreign skills via the new auto-`yes` paths. `sync_managed_skill()` delegates to `install_integration()`; conflict/recovery states still hit `_require_mutable_state()` fail-closed (`manager.py:898-914`, `1037-1044`). Foreign avatar protection remains intact (`manager.py:969-991`; tests `test_unowned_conflict_is_never_overwritten_or_removed`, `test_forged_legacy_over_foreign_avatar_is_refused` in `tests/test_integrations.py`). - -### P1 — Post-update subprocess trusts `PATH` for `dyro` binary - -**Category:** A08 Integrity / local execution hijack -**Location:** `cli.py:3815-3833` (`_refresh_skill_via_new_cli`) -**Exploitability:** Local; attacker who can prepend to `PATH` (or win a race right after package update) before auto-sync runs -**Blast radius:** Arbitrary code execution as the user, invoked with fixed args `[dyro, integration, sync, skill, --yes]` — can mutate managed Skill mirror/avatars under that identity - -**Evidence:** After `perform_update()` / auto-patch (`cli.py:1618-1619`, `3808-3810`), code resolves the binary via `shutil.which("dyro")` and executes it. Args are list-form (no shell injection — good), but the **binary choice is unconstrained**. A trojan `dyro` on `PATH` is fully trusted. - -**Remediation:** -```python -# BAD — trusts PATH -dyro_bin = shutil.which("dyro") -subprocess.run([dyro_bin, "integration", "sync", "skill", "--yes"], ...) - -# GOOD — pin to the interpreter/entry point that just updated -subprocess.run( - [sys.executable, "-m", "dyro.cli", "integration", "sync", "skill", "--yes"], - env={**os.environ, "PATH": sanitized_minimal_path}, - ... -) -``` - -**Condition to ship:** Pin post-update re-exec to the freshly installed entry point (or pass an explicit resolved path from the updater), not bare `which("dyro")`. - -### P1 — Startup auto-sync mutates agent homes with `yes=True` and no per-run preview - -**Category:** A04 Insecure Design / consent boundary (managed-only) -**Location:** `cli.py:3849-3872` (`_maybe_sync_managed_skill`), wired from `main()` at `3888-3890` -**Exploitability:** Local interactive user; runs on every interactive `dyro` / `dyro home` / `dyro start` when state is `OUTDATED` -**Blast radius:** Atomic mirror upgrade + avatar repair across detected agent homes for **already-managed** installs only (`allow_first_install=False`) - -**Evidence:** Gate is correct for scope — `_should_run_daily_update()` limits to `{None, home, start}` + TTY (`3772-3784`); absent Skills are skipped (`manager.py:1035-1036`). Foreign paths still fail-closed or are skipped (`manager.py:987-991`). - -**Residual risk:** By product intent, but still privileged mutation without a dry-run/confirm step on each launch. Acceptable only if changelog/docs clearly state “interactive startup auto-repairs managed Skill.” - -**Condition to ship:** Document the behavior prominently (partially done in `CHANGELOG.md` / `docs/updates.md`); consider logging a one-line audit trail of changed paths. - -### P2 — Auto-patch bundles silent Skill `--yes` sync - -**Category:** A04 / consent chaining -**Location:** `cli.py:3796-3810` → `_refresh_skill_via_new_cli()` -**Evidence:** User enabling `auto_patch` consents to `perform_update(..., yes=True)`; on success, Skill sync also runs with `--yes` without a separate prompt. Mitigated because subprocess sync path sets `allow_first_install=False` (`cmd_integration_sync`, `cli.py:1557-1564`). -**Remediation:** Mention in setup copy that auto-patch includes managed-Skill sync; optional env opt-out (e.g. `DYRO_NO_SKILL_SYNC`). - -### P2 — Setup skill question nudges install on Enter - -**Category:** A04 / consent UX -**Location:** `cli.py:589-597` (`default="1"`) -**Evidence:** Empty input selects install. **Mitigated** by later plan preview (`640-647`) and final apply gate `_ask_yes_no(..., default=False)` (`994-996`, `904-906`). Not a bypass, but increases mis-click/Enter-through risk before the hard stop. -**Remediation:** Default skill choice to `"2"` (defer) or require non-empty confirmation for option 1. - -### P2 — Missing adversarial tests for sync fail-closed on conflict states - -**Category:** A05 Security Misconfiguration / test gap -**Location:** `tests/test_integrations.py` (only `ABSENT` skip + `OUTDATED` upgrade covered at `595-611`) -**Evidence:** `install_integration` refusal for `UNOWNED_CONFLICT` / `DRIFTED` is tested (`161-183`), but **`sync_managed_skill()` is not explicitly tested** to propagate those failures on auto/CLI sync paths. -**Remediation:** Add tests: `sync_managed_skill(yes=True, allow_first_install=False)` raises/soft-fails on `DRIFTED`, `UNOWNED_CONFLICT`, `RECOVERY_REQUIRED`. - -### P2 — Cleared hunts - -- **Subprocess injection:** argv lists, no `shell=True`, no user-controlled args (`cli.py:3827-3828`, `manager.py:272-273`). -- **Startup scope / non-interactive gating:** sync only on interactive `{None, home, start}`; not on `setup`, `integration`, `update`, etc. (`3772-3790`, `3888-3890`). -- **Legacy allowlist / packaged-asset bypass:** `sync_managed_skill()` → same `_legacy_owned_copy(..., require_current_assets=True)` and avatar allowlist as manual install (`manager.py:453-481`, `954-978`, `1011-1044`). -- **First install without consent on startup/update:** all automatic paths use `allow_first_install=False`; first install only via setup + final confirm or `integration install --yes`. - -## Security Checklist - -- [x] No hardcoded secrets in diff -- [x] Automatic `yes=True` paths cannot first-install absent Skills -- [x] Foreign-skill overwrite protections preserved in `manager.py` -- [x] Legacy allowlist / packaged-asset checks not bypassed by sync -- [x] Subprocess args not injectable (list argv, no shell) -- [ ] Post-update re-exec pinned to trusted entry point (**P1 open**) -- [ ] Adversarial tests for sync on conflict/recovery states (**P2 open**) -- [ ] Dependency audit not run in review environment (须人工核) - -**Ship recommendation:** **Conditional Go** — land after PATH-pinned post-update re-exec (P1). P2 items are hardening/docs/tests, not blockers if P1 is fixed or explicitly accepted with a tracked follow-up. - ---- - -# Agy Review Section - -Reviewer: Agy -Time: 2026-08-12 -Verdict: Conditional Go - -## Product / UX Findings - -### P1 — No-host setup still recommends a known-fail path -Evidence: `src/dyro/cli.py` `_setup_skill_preference` — when `status.avatars` is empty, option 1 is `尝试安装(无宿主时会失败并提示,可稍后重试)(推荐)` with `default="1"`. Apply then hits manager fail-closed (`没有可挂接的宿主分身;拒绝只安装孤立镜像`) and soft-fails. -Issue: Copy discloses failure, but “推荐” + Enter-default still steers users into a guaranteed soft-fail on hostless machines. That is not honest recommendation semantics. -Required fix: If no hosts, remove `(推荐)` from option 1, default to option 2 (“稍后手动安装”), and/or rephrase option 1 as non-recommended “仍要尝试(预期失败)”. - -### P1 — Setup completion overclaims after soft-fail -Evidence: `_apply_setup_personal_preferences` warns and returns on `DyroError`; `_print_setup_completion` still prints `Skill:已请求安装 / 同步` whenever `preferences.install_skill` is true. -Issue: End-of-setup summary reads as “we took your install request seriously / it is in flight,” not “request failed; Skill absent.” -Required fix: Completion must reflect outcome: success / skipped-current / failed-soft (with the same remediation tip). Prefer tracking apply result, not the preference bit alone. - -### P1 — Plan preview can look actionable when apply cannot succeed -Evidence: `_render_setup_personal_preferences` summarizes `安装 / 同步控制面 Skill(镜像 + 宿主分身)` while `plan_integration` for ABSENT+no hosts lists `未检测到宿主目录…` plus `(预览)将创建镜像` / manifest; apply refuses orphan mirror. -Issue: Summary line overpromises “宿主分身”; preview bullets mix blocker with optimistic “将创建” language. Confirming the plan feels like approving a real install. -Required fix: When no hosts, plan summary must lead with blocker (e.g. “无法安装:未检测到宿主”) and avoid “将创建镜像/分身” wording that implies a successful write path. - -### P2 — Soft-fail messaging quality (mostly good; small gaps) -Good: setup apply soft-fail + `install --dry-run` tip; post-update `_refresh_skill_via_new_cli` best-effort warnings; startup repair soft-fail + `sync --dry-run`. -Gaps: -- Post-update “下次启动将重试” is only true for interactive `dyro` / `home` / `start` (same `_should_run_daily_update` gate), and is skipped when `DYRO_NO_UPDATE_CHECK` is set—undocumented coupling. -- `cmd_integration_sync` prints `无需同步;Skill 未安装或已是当前版本` — collapses two meanings; hurts sync vs install discoverability. - -Required fix (P2): Split sync no-op copy (`未安装,请用 install` vs `已是当前版本`); document that `DYRO_NO_UPDATE_CHECK` also skips startup Skill repair, or decouple the gates (see micro-decision 1). - -### P2 — Docs vs behavior / daily-update story -Accurate: `docs/updates.md` Control-plane Skill section matches SSOT (setup opt-in, post-update managed sync via fresh entrypoint, startup OUTDATED repair, no first-install on upgrade/startup). CHANGELOG Unreleased matches. Daily PyPI check story (once/local day, non-blocking, confirm-by-default, patch auto opt-in) remains accurate; Skill hooks are additive. -Gaps: -- README locales describe auto sync/repair but mostly omit `dyro integration sync skill` (discoverability of sync vs install weaker than `docs/updates.md`). -- README “Daily check” narrative unchanged; Skill repair sharing that interactive gate / env opt-out is easy to miss. -- `README.pt-BR.md`: `repararam` → should be `reparam` (grammar). - -Required fix (P2): One README sentence naming `install` (first-time) vs `sync` (managed upgrade-only); note env/gate coupling if kept; fix pt-BR typo. - -### P2 — Sync vs install discoverability -CLI `integration sync` help (`仅升级已托管的 Skill`) is clear; docs/updates comment helps. Setup conflict path only points at `install`, which is correct for first-time/conflict. Main gap is README + sync no-op copy (above). - -## Micro-decision votes - -1. **Startup Skill sync gate** - Vote: Keep command scope to interactive `dyro` / `home` / `start` only (do not expand to arbitrary interactive commands). Prefer decoupling Skill repair from `DYRO_NO_UPDATE_CHECK` (update opt-out should not silently disable Skill repair), or document the coupling explicitly in `docs/updates.md`. - -2. **Post-update sync failure vs exit code** - Vote: “Retry next launch” is enough. Do not make package update exit non-zero when companion Skill sync fails (best-effort companion; update success remains the primary outcome). Keep visible warning. - -3. **`dyro setup --non-interactive --install-skill`** - Vote: Out of scope for this change. Keep non-interactive free of Skill side effects; first install stays interactive setup or explicit `dyro integration install skill --yes`. - -## Go / No-Go - -- Go / No-Go: Conditional Go -- Blocking for honest UX: P1 recommendation default + completion honesty + no-host plan wording. -- Not blocking: P2 docs/discoverability polish, sync no-op copy, pt-BR typo. - -## Required Fixes (executable) - -1. No hosts → do not mark install as recommended; default to defer. -2. Setup completion reflects install outcome, not preference alone. -3. No-host plan summary/blocker-first; drop optimistic “将创建…” success framing. -4. (P2) Clarify sync no-op; README install vs sync; document or decouple `DYRO_NO_UPDATE_CHECK` vs Skill repair; fix pt-BR. - ---- - -# Grok Review Section - -Reviewer: Grok -Time: 2026-08-12 -Verdict: Go - -## Scorecard - -| # | Claim | Result | -|---|--------|--------| -| 1 | `sync_managed_skill(allow_first_install=False)` never installs ABSENT | **PASS** | -| 2 | `_maybe_sync_managed_skill` only acts on OUTDATED | **PASS** | -| 3 | Setup can first-install only after plan confirm | **PASS** | -| 4 | Post-update uses subprocess sync not in-process old assets | **PASS** | -| 5 | Daily update check still gated to interactive home/start/default | **PASS** | - -## Findings (source-verified) - -1. **PASS** — `manager.py` `sync_managed_skill`: ABSENT + `not allow_first_install` → `return None` before `install_integration`. Covered by `test_sync_managed_skill_skips_absent_without_first_install`. - -2. **PASS** — `cli.py` `_maybe_sync_managed_skill`: early return unless `status.state is IntegrationState.OUTDATED`; sync call uses `allow_first_install=False` (defense in depth). - -3. **PASS** — Interactive setup: preference → plan render (dry-run preview with `allow_first_install=True`) → `_ask_yes_no` / `--yes` → `_apply_setup_personal_preferences` → mutating sync. Non-interactive setup never calls skill install. Only mutating `allow_first_install=True` site is post-confirm apply. - -4. **PASS** — `_refresh_skill_via_new_cli` uses `subprocess.run([dyro_bin, "integration", "sync", "skill", "--yes"])`; `cmd_integration_sync` sets `allow_first_install=False`. Wired from `cmd_update_now` and patch auto-update success. No in-process `sync_managed_skill` on those paths. - -5. **PASS** — `_should_run_daily_update` requires `command in {None, "home", "start"}`, TTY interactive, not dry-run, not `DYRO_NO_UPDATE_CHECK`. Sole call site in `main()`. - -## P2 (non-blocking) - -- **P2:** `_refresh_skill_via_new_cli` resolves via `shutil.which("dyro")`, not Scripts next to the updated `sys.executable`; wrong PATH shadow could sync via a different binary (still subprocess; soft-fail if missing). -- **P2:** No direct unit test that `_maybe_sync_managed_skill` no-ops on ABSENT (logic is clear; only OUTDATED path tested). - ---- - -# Final Arbitration - -Arbiter: Cursor Root (parent agent) -Time: 2026-08-12 Asia/Taipei -Final verdict: **No-Go for land** until P0 closed · **Conditional Go** after P0 + listed P1s - -## 1. Final Verdict - -- May this Skill lifecycle WIP land as-is: **No-Go** -- After P0 + required P1s: **Conditional Go** -- First-install / fail-closed ownership contracts: **hold** (Grok scorecard 1–3/5 + Hermes clear; not reopened) -- Blocking reason: OpenCode P0 same-session stale overwrite is **source-verified** - -## 2. Seat Summary - -| Seat | Verdict | Arbiter note | -| --- | --- | --- | -| Claude | Conditional Go | Correct on consent gates; missed P0 overwrite chain | -| OpenCode | Request changes (P0) | **Upheld** — P0 is decisive | -| Hermes | Conditional Go | No security P0; PATH pin upheld as P1 integrity | -| Agy | Conditional Go | UX honesty P1s upheld (not merge-blockers once P0 fixed, but must-fix before “honest setup” claim) | -| Grok | Go | Scorecard PASS upheld for allow_first_install; claim #4 incomplete — did not examine post-refresh in-process follow-up | - -## 3. P0 Required Fixes - -### P0-F1: Same-session stale Skill overwrite after auto-patch refresh - -Evidence: -- `cli.py` `main()` always runs `_maybe_sync_managed_skill()` after `_maybe_run_daily_update()` -- Successful auto-patch → `_refresh_skill_via_new_cli()` writes **new** digests via subprocess -- Still-running old interpreter then evaluates `integration_status` against **old** `_asset_inventory()` → `OUTDATED` → in-process `install_integration` overwrites with stale assets (`manager.py:699-730`) - -Decision: -- After a successful package update + Skill refresh in the same process turn, **must not** run in-process `_maybe_sync_managed_skill()` -- Minimal fix: `_maybe_run_daily_update()` returns whether refresh already ran (or update succeeded); `main()` skips startup sync when true -- Acceptable alternate: re-exec into new entry point before any in-process Skill mutation - -Acceptance: -- Unit/integration test: auto-patch success + mocked successful `_refresh_skill_via_new_cli` ⇒ `_maybe_sync_managed_skill` / in-process `sync_managed_skill` **not** invoked -- Manual mental model: “fresh subprocess sync is last writer in that turn” - -## 4. P1 Required Before Unconditional Land - -### P1-F1: Setup completion honesty (Claude P1-1 + Agy) -- Track Skill apply outcome; `_print_setup_completion` must not claim `已请求安装 / 同步` after soft-fail - -### P1-F2: Pin post-update Skill sync entry point (Hermes + OpenCode) -- Do not rely on bare `shutil.which("dyro")` alone -- Prefer updater-resolved bin, or `sys.executable -m dyro` / Scripts-next-to-prefix after `perform_update` documents the target -- Note: Hermes’ `sys.executable -m` alone is **insufficient** right after in-place upgrade if the running interpreter still loads the old package from memory; prefer the **new** install’s console script / re-exec. Pinning still beats PATH `which`. - -### P1-F3: No-host setup honesty (Agy) -- No hosts → do not label option 1 `(推荐)`; default to defer -- Plan summary blocker-first (no optimistic “将创建镜像/分身” as if apply will succeed) - -### P1-F4: Regression + boundary tests (OpenCode + Claude) -- P0 regression test (mandatory) -- `_refresh_skill_via_new_cli` success / non-zero / timeout / missing binary -- `cmd_update_now` calls refresh only when `perform_update` returns True -- At least one CLI test for `integration sync` upgrade-only / ABSENT no-op - -## 5. P2 Follow-ups (non-blocking) - -- Split `integration sync` no-op copy: ABSENT vs CURRENT -- Document or decouple `DYRO_NO_UPDATE_CHECK` vs startup Skill repair (Agy/Claude votes: keep command gate; prefer decouple env or document) -- README install vs sync one-liner; pt-BR `repararam` → `reparam` -- Conflict-state tests for `sync_managed_skill` -- Docs note: startup OUTDATED may migrate legacy installs (Claude P2-1) - -## 6. Open Micro-Decisions — Arbitration - -| # | Decision | -| --- | --- | -| 1 | **Keep** startup Skill sync on interactive `dyro` / `home` / `start` only. Prefer **document** `DYRO_NO_UPDATE_CHECK` coupling in this change; optional later decouple is P2. | -| 2 | Post-update Skill sync failure: **retry next launch**; do **not** fail the package update exit code. | -| 3 | `--install-skill` for non-interactive setup: **out of scope** this change. | - -## 7. Instructions For The Execution Agent - -1. Fix **P0-F1** in `cli.py` `main` / `_maybe_run_daily_update` wiring first. -2. Fix **P1-F2** entry-point resolution in `_refresh_skill_via_new_cli`. -3. Fix **P1-F1** + **P1-F3** setup UX honesty. -4. Add **P1-F4** tests; run focused suite: `tests/test_updates.py`, `tests/test_cli.py`, `tests/test_integrations.py`. -5. Do not touch user WIP plans (`plans/dyro-agent-bridge-*.md`). -6. Do not bump version / publish; keep CHANGELOG under Unreleased until P0/P1 closed. -7. Re-open board only if new P0 appears; otherwise mark P0-F1 closed in a short follow-up note under Final Arbitration. - -## 8. Requires Human Verification - -- Real multi-install PATH layouts (pipx vs uv tool vs venv) after `update now` — 须人工核 for P1-F2 completeness -- Hostless interactive setup UX after P1-F3 — 须人工核 - -Final signature: Cursor Root (parent agent) - ---- - -## Follow-up (execution) - -Time: 2026-08-12 Asia/Taipei - -| Item | Status | -| --- | --- | -| P0-F1 same-turn stale overwrite | **closed** — `main()` skips `_maybe_sync_managed_skill` when `_maybe_run_daily_update()` returns True after refresh | -| P1-F1 setup completion honesty | **closed** — apply returns outcome; completion uses it | -| P1-F2 pin Skill sync entry point | **closed** — `_fresh_dyro_argv` (Scripts/bin beside `sys.executable`, else `-m dyro`) | -| P1-F3 no-host setup honesty | **closed** — defer default; blocker-first plan summary; install dry-run no longer pretends orphan mirror create | -| P1-F4 regression/boundary tests | **closed** — auto-patch skip sync; refresh argv/exit; update now wiring; sync CLI; setup UX | -| Bare `dyro update` ≡ check | **closed** (separate product ask, same WIP) | - -Updated land posture: **Conditional Go → ready for focused re-test / land after green suite** (no remaining arbitration P0). diff --git a/plans/dyro-agent-bridge-phase-0.md b/plans/dyro-agent-bridge-phase-0.md index cbe69f9..0ddd3c5 100644 --- a/plans/dyro-agent-bridge-phase-0.md +++ b/plans/dyro-agent-bridge-phase-0.md @@ -11,8 +11,6 @@ Authority: - [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. diff --git a/src/dyro/changesets.py b/src/dyro/changesets.py index 8bd548a..9e447d9 100644 --- a/src/dyro/changesets.py +++ b/src/dyro/changesets.py @@ -3,13 +3,21 @@ from dataclasses import dataclass from datetime import datetime, timezone import json +import os from pathlib import Path +import stat import tomllib from typing import Iterable from .config import Config, validate_id from .errors import DyroError, ValidationError -from .process import git, require_ok +from .process import git_read, require_ok +from .read_limits import ( + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) from .state import atomic_write_text from .workspace import get_line, line_repository_path @@ -51,10 +59,10 @@ def _write(config: Config, changeset: ChangeSet) -> None: atomic_write_text(path, "\n".join((*chunks, ""))) -def _parse(path: Path) -> ChangeSet: +def _parse_content(path: Path, content: bytes) -> ChangeSet: 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"Change Set 格式错误:{path}: {exc}") from exc if raw.get("schema_version") != 1: raise ValidationError(f"不支持的 Change Set 版本:{path}") @@ -74,14 +82,74 @@ def _parse(path: Path) -> ChangeSet: return ChangeSet(changeset_id, line, branch, repositories, {repository: str(heads_raw[repository]) for repository in repositories}, created_at) -def get_changeset(config: Config, changeset_id: str) -> ChangeSet: +def _parse(path: Path) -> ChangeSet: + return _parse_content(path, path.read_bytes()) + + +def get_changeset( + config: Config, + changeset_id: str, + *, + read_budget: ReadBudget | None = None, +) -> ChangeSet: path = _path(config, changeset_id) + if read_budget is not None: + try: + content = read_budget.read_regular_bytes_at( + root=config.root, + directory=config.changesets_dir, + name=path.name, + maximum_bytes=read_budget.limits.changeset_manifest_bytes, + label="Change Set manifest", + ) + except FileNotFoundError as exc: + raise DyroError(f"未找到 Change Set:{changeset_id}") from exc + return _parse_content(path, content) if not path.is_file(): raise DyroError(f"未找到 Change Set:{changeset_id}") return _parse(path) -def list_changesets(config: Config) -> list[ChangeSet]: +def list_changesets( + config: Config, *, read_budget: ReadBudget | None = None +) -> list[ChangeSet]: + if read_budget is not None: + paths: list[Path] = [] + with read_budget.open_safe_directory_chain( + config.root, config.changesets_dir, allow_missing=True + ) as directory_fd: + if directory_fd is None: + return [] + names = sorted( + name + for name in bounded_directory_names( + directory_fd, + read_budget, + maximum_records=read_budget.limits.changeset_records, + label="Change Set", + ) + if name.endswith(".toml") + ) + for name in names: + try: + info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Change Set manifest is not a safe regular file", + ) from exc + if not stat.S_ISREG(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Change Set manifest is not a safe regular file", + ) + path = config.changesets_dir / name + read_budget.bind_file_identity(path, (info.st_dev, info.st_ino)) + paths.append(path) + return [ + get_changeset(config, path.stem, read_budget=read_budget) + for path in paths + ] if not config.changesets_dir.exists(): return [] return [_parse(path) for path in sorted(config.changesets_dir.glob("*.toml"))] @@ -109,15 +177,23 @@ def create_changeset( heads: dict[str, str] = {} for repository in selected: target = line_repository_path(config, line, repository) - if git(target, "rev-parse", "--git-dir").code != 0: + if git_read(target, "rev-parse", "--git-dir").code != 0: raise DyroError(f"Change Set 开发线仓库不存在或不是 Git:{target}") - branch = require_ok(git(target, "branch", "--show-current"), f"读取 {repository} 分支").stdout.strip() + branch = require_ok( + git_read(target, "branch", "--show-current"), + f"读取 {repository} 分支", + ).stdout.strip() if branch != line.branch: raise DyroError(f"Change Set 要求 {repository} 位于 {line.branch},当前为 {branch or 'DETACHED'}") - dirty = require_ok(git(target, "status", "--porcelain=v1", "-uall"), f"读取 {repository} 状态").stdout.strip() + dirty = require_ok( + git_read(target, "status", "--porcelain=v1", "-uall"), + f"读取 {repository} 状态", + ).stdout.strip() if dirty: raise DyroError(f"Change Set 拒绝记录未提交改动:{target}") - heads[repository] = require_ok(git(target, "rev-parse", "HEAD"), f"读取 {repository} HEAD").stdout.strip() + heads[repository] = require_ok( + git_read(target, "rev-parse", "HEAD"), f"读取 {repository} HEAD" + ).stdout.strip() changeset = ChangeSet( id=changeset_id, line=line.id, @@ -131,26 +207,55 @@ def create_changeset( return changeset -def verify_changeset(config: Config, changeset: ChangeSet) -> list[str]: +def verify_changeset( + config: Config, + changeset: ChangeSet, + *, + read_budget: ReadBudget | None = None, +) -> list[str]: findings: list[str] = [] - line = get_line(config, changeset.line) + line = get_line(config, changeset.line, read_budget=read_budget) if line.branch != changeset.branch: findings.append(f"FAIL changeset {changeset.id}: registered line branch changed from {changeset.branch} to {line.branch}") return findings for repository in changeset.repositories: target = line_repository_path(config, line, repository) - if git(target, "rev-parse", "--git-dir").code != 0: + if ( + git_read( + target, + "rev-parse", + "--git-dir", + read_budget=read_budget, + ).code + != 0 + ): findings.append(f"FAIL {repository}: missing delivery-line repository") continue - branch = git(target, "branch", "--show-current") + branch = git_read( + target, + "branch", + "--show-current", + read_budget=read_budget, + ) if branch.code != 0 or branch.stdout.strip() != changeset.branch: findings.append(f"FAIL {repository}: expected branch {changeset.branch}, found {branch.stdout.strip() or 'DETACHED'}") continue - dirty = git(target, "status", "--porcelain=v1", "-uall") + dirty = git_read( + target, + "status", + "--porcelain=v1", + "-uall", + read_budget=read_budget, + ) if dirty.code != 0 or dirty.stdout.strip(): findings.append(f"FAIL {repository}: delivery-line repository is dirty") continue - head = git(target, "rev-parse", "HEAD") + head = git_read( + target, + "rev-parse", + "HEAD", + read_budget=read_budget, + ) if head.code != 0 or head.stdout.strip() != changeset.heads[repository]: findings.append(f"FAIL {repository}: HEAD differs from pinned Change Set") continue diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 580c9fd..8b9240d 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -28,7 +28,7 @@ list_changesets, verify_changeset, ) -from .config import CONFIG_NAME, Config, load, validate_id +from .config import CONFIG_NAME, Config, load, load_profile_exact, validate_id from .console.launcher import launch_console, render_console_plan from .continuation.attention import ( build_attention_projection, @@ -49,8 +49,19 @@ render_projection_json, render_projection_mermaid, ) -from .continuation.resolution import resolve_line, resolve_workspace -from .continuation.snapshot import build_scheduler_snapshot +from .continuation.resolution import ( + ResolvedWorkspace, + WorkspaceResolutionError, + WorkspaceResolutionFailure, + WorkspaceResolutionSource, + resolve_line, + resolve_workspace, + resolve_workspace_readonly, +) +from .continuation.snapshot import ( + build_scheduler_snapshot, + build_scheduler_snapshot_bounded, +) from .continuation.store import ( add_objective_target, create_objective, @@ -98,6 +109,7 @@ add_workspace, get_workspace, load_registry, + load_registry_bounded, preview_workspace_registration, remove_workspace, set_default_workspace, @@ -123,7 +135,9 @@ render_setup_plan, repository_input_from_path, sibling_workspace_for, + validate_bootstrap_destination, ) +from .read_limits import ObservationLimits, ReadBudget, ReadLimitCode, ReadLimitError from .profile import ( append_adapter, command_adapter, @@ -234,6 +248,42 @@ def _config(args: argparse.Namespace) -> Config: root_arg = getattr(args, "root", None) workspace_arg = getattr(args, "workspace_alias", None) + if getattr(args, "format", None) == "json": + budget = _control_plane_budget(args) + if root_arg: + root = Path(root_arg).expanduser() + if not root.is_absolute(): + root = Path.cwd() / root + try: + profile = load_profile_exact(root, budget) + except ReadLimitError as exc: + if exc.code is not ReadLimitCode.UNSAFE_FILE: + raise + raise WorkspaceResolutionError( + WorkspaceResolutionFailure.LOCAL_PROFILE_INVALID + ) from exc + 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 + resolved = ResolvedWorkspace( + profile, + WorkspaceResolutionSource.EXPLICIT, + None, + ) + else: + resolved = resolve_workspace_readonly( + start=None, + workspace=workspace_arg, + cwd=Path.cwd().absolute(), + budget=budget, + ) + setattr(args, "_control_plane_resolution", resolved) + return resolved.profile.config if root_arg: root = Path(root_arg).expanduser() elif workspace_arg: @@ -250,6 +300,15 @@ def _config(args: argparse.Namespace) -> Config: return load(root) +def _control_plane_budget(args: argparse.Namespace) -> ReadBudget: + existing = getattr(args, "_control_plane_read_budget", None) + if isinstance(existing, ReadBudget): + return existing + budget = ReadBudget(ObservationLimits()) + setattr(args, "_control_plane_read_budget", budget) + return budget + + def _repositories(raw: str | None) -> list[str] | None: if raw is None: return None @@ -344,6 +403,173 @@ def _print_objective(config: Config, record) -> None: ) +def _print_control_plane_json( + kind: str, *, stream=None, **payload: object +) -> None: + print( + json.dumps( + {"schema_version": 1, "kind": kind, **payload}, + ensure_ascii=False, + sort_keys=True, + indent=2, + ), + file=stream, + ) + + +def _finding_payload(finding: str) -> dict[str, str]: + status, separator, message = finding.partition(" ") + return { + "status": status if separator else "UNKNOWN", + "message": message if separator else finding, + } + + +def _doctor_finding_payload( + finding: str, *, include_paths: bool +) -> dict[str, str]: + payload = _finding_payload(finding) + if include_paths: + return payload + message = payload["message"] + if not message.startswith("repository "): + return payload + identity, separator, detail = message.partition(": ") + if not separator: + payload["message"] = "repository: unavailable" + elif payload["status"] == "PASS": + payload["message"] = f"{identity}: ready" + elif detail.startswith("missing or not Git:"): + payload["message"] = f"{identity}: missing or not Git" + else: + payload["message"] = f"{identity}: unavailable" + return payload + + +def _status_payload( + config: Config, *, read_budget: ReadBudget | None = None +) -> dict[str, object]: + return { + "workspace": config.name, + "rows": [ + { + "scope": scope, + "repository": repository, + "branch": branch, + "head": head, + "upstream": upstream, + "dirty_count": dirty, + } + for scope, repository, branch, head, upstream, dirty in status_rows( + config, read_budget=read_budget + ) + ], + } + + +def _control_plane_command(args: argparse.Namespace) -> str: + parts: list[str] = [] + for attribute in ( + "command", + "workspace_command", + "integration_command", + "line_command", + "changeset_command", + "objective_command", + "objective_scope_command", + ): + value = getattr(args, attribute, None) + if isinstance(value, str) and value and value not in parts: + parts.append(value) + return " ".join(parts) or "dyro" + + +def _control_plane_error_code( + args: argparse.Namespace, exc: BaseException +) -> str: + code = getattr(exc, "code", None) + if hasattr(code, "value"): + return str(code.value) + if isinstance(code, str) and code: + return code + if isinstance(exc, ReadLimitError): + return exc.code.value + if isinstance(exc, ValidationError): + return "VALIDATION_ERROR" + if isinstance(exc, OSError): + return "IO_ERROR" + command = getattr(args, "command", "") + return { + "changeset": "CHANGESET_UNAVAILABLE", + "doctor": "WORKSPACE_UNHEALTHY", + "integration": "INTEGRATION_UNAVAILABLE", + "line": "LINE_UNAVAILABLE", + "next": "NEXT_STEP_UNAVAILABLE", + "objective": "OBJECTIVE_UNAVAILABLE", + "status": "WORKSPACE_OBSERVATION_FAILED", + "workspace": "WORKSPACE_REGISTRY_UNAVAILABLE", + }.get(command, "DYRO_ERROR") + + +def _print_control_plane_error( + args: argparse.Namespace, exc: BaseException +) -> None: + _print_control_plane_json( + "error", + stream=sys.stderr, + code=_control_plane_error_code(args, exc), + command=_control_plane_command(args), + retryable=False, + ) + + +def _workspace_selector_argv( + args: argparse.Namespace, config: Config +) -> tuple[str, ...]: + alias = getattr(args, "workspace_alias", None) + if alias: + return ("dyro", "--workspace", alias) + return ("dyro", "--root", str(config.root)) + + +def _scoped_command( + args: argparse.Namespace, config: Config, *command: str +) -> str: + return shlex.join((*_workspace_selector_argv(args, config), *command)) + + +def _objective_payload( + config: Config, + record, + *, + detailed: bool, + read_budget: ReadBudget | None = None, +) -> dict[str, object]: + if read_budget is None: + derived_result = derive_objective_result(config, record) + else: + snapshot = build_scheduler_snapshot_bounded( + config, objective=record, budget=read_budget + ) + derived_result = build_continuation_plan(snapshot).completion.value + payload: dict[str, object] = { + "id": record.objective.id, + "operator_state": record.operator_state, + "derived_result": derived_result, + "revision": record.revision, + "line": record.objective.line, + "targets": list(record.objective.targets), + } + if detailed: + payload.update( + { + "scope": list(record.scope), + "contract_sha256": record.contract_sha256, + } + ) + return payload + + def _print_command(argv: tuple[str, ...]) -> None: print("$ " + shlex.join(argv)) @@ -1196,12 +1422,27 @@ def cmd_bootstrap(args: argparse.Namespace) -> None: def cmd_doctor(args: argparse.Namespace) -> None: config = _config(args) + budget = _control_plane_budget(args) if args.format == "json" else None + findings = doctor(config, read_budget=budget) + failures = [item for item in findings if item.startswith("FAIL")] + if args.format == "json": + _print_control_plane_json( + "doctor", + workspace=config.name, + passed=not failures, + findings=[ + _doctor_finding_payload(item, include_paths=args.include_paths) + for item in findings + ], + ) + if failures: + raise SystemExit(2) + return print("\n" + title("━━ Dyro 健康检查 ━━")) print(muted(f"Profile:{config.name} · 检查仓库、基线与隔离工作区。")) - findings = doctor(config) for finding in findings: _print_doctor_finding(finding) - if any(item.startswith("FAIL") for item in findings): + if failures: raise DyroError("doctor 发现结构错误") print("\n" + success("检查通过。") + " 下一步:" + terminal_value("dyro")) @@ -1284,22 +1525,47 @@ def cmd_workspace_add(args: argparse.Namespace) -> None: def cmd_workspace_list(args: argparse.Namespace) -> None: - registry = load_registry() + budget = _control_plane_budget(args) if args.format == "json" else None + registry = load_registry_bounded(budget) if budget is not None else load_registry() + rows: list[dict[str, object]] = [] + for record in registry.workspaces: + try: + if budget is None: + load(record.root) + else: + load_profile_exact(record.root, budget) + except (DyroError, OSError, ValidationError): + available = False + else: + available = True + row: dict[str, object] = { + "name": record.name, + "default": record.name == registry.default, + "available": available, + } + if args.include_paths: + row["root"] = str(record.root) + rows.append(row) + if args.format == "json": + _print_control_plane_json( + "workspace_list", + default=registry.default or None, + workspaces=rows, + ) + return if not registry.workspaces: print("还没有登记全局工作区。下一步:dyro workspace add <路径>") return print("\n" + title("━━ 全局工作区 ━━")) print(muted("这里只管理首页入口,不会移动或删除项目文件。")) print(muted(f"{'默认':4} {'名称':20} {'状态':8} 路径")) - for record in registry.workspaces: + for record, row in zip(registry.workspaces, rows, strict=True): marker = ( success(f"{'●':4}") if record.name == registry.default else muted(f"{'·':4}") ) - try: - load(record.root) - except (DyroError, ValidationError): + if not row["available"]: state = danger(f"{'不可用':8}") else: state = success(f"{'可用':8}") @@ -1418,6 +1684,37 @@ def cmd_join(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None: + if args.format == "json": + budget = _control_plane_budget(args) + if not args.all: + _print_control_plane_json( + "workspace_status", + **_status_payload(_config(args), read_budget=budget), + ) + return + registry = load_registry_bounded(budget) + workspaces: list[dict[str, object]] = [] + for record in registry.workspaces: + try: + config = load_profile_exact(record.root, budget).config + except (DyroError, OSError, ValidationError) as exc: + workspaces.append( + { + "workspace": record.name, + "available": False, + "error_code": _control_plane_error_code(args, exc), + "rows": [], + } + ) + else: + workspaces.append( + { + "available": True, + **_status_payload(config, read_budget=budget), + } + ) + _print_control_plane_json("workspace_status_all", workspaces=workspaces) + return if args.all: print_all_status() return @@ -1558,7 +1855,29 @@ def cmd_tool_pin(args: argparse.Namespace) -> None: def cmd_integration_status(args: argparse.Namespace) -> None: - status = integration_status(args.id) + status = integration_status( + args.id, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) + if args.format == "json": + avatars: list[dict[str, object]] = [] + for avatar in status.avatars: + row: dict[str, object] = { + "host": avatar.host, + "state": avatar.state, + } + if args.include_paths: + row.update(path=str(avatar.path), detail=avatar.detail) + avatars.append(row) + payload: dict[str, object] = { + "integration": status.integration, + "state": status.state.value, + "avatars": avatars, + } + if args.include_paths: + payload.update(target=str(status.target), detail=status.detail) + _print_control_plane_json("integration_status", **payload) + return print(f"{status.integration}\t{status.state.value}\t{status.target}") print(status.detail) for avatar in status.avatars: @@ -1750,51 +2069,216 @@ def cmd_start(args: argparse.Namespace) -> None: cmd_open(open_args) +def _bootstrap_destination_safe(config: Config, relative: str) -> bool: + try: + validate_bootstrap_destination(config, relative) + except (DyroError, OSError): + return False + return True + + def cmd_next(args: argparse.Namespace) -> None: """Give newcomers one safe, concrete next step without making changes.""" try: config = _config(args) + except WorkspaceResolutionError as exc: + if ( + getattr(args, "workspace_alias", None) + or getattr(args, "root", None) + or exc.code is not WorkspaceResolutionFailure.WORKSPACE_NOT_FOUND + ): + raise + if args.format == "json": + _print_control_plane_json( + "next_step", + state="workspace_missing", + summary="尚未发现 Dyro 工作区。", + commands=[], + mutation_available=False, + required_choice="join_existing_or_setup_new", + ) + return + print("尚未发现 Dyro 工作区。") + print("加入团队项目:dyro join <蓝图地址>") + print("设置一个新项目:dyro setup") + return except ValidationError: + if args.format == "json" and ( + getattr(args, "workspace_alias", None) or getattr(args, "root", None) + ): + raise + if args.format == "json": + _print_control_plane_json( + "next_step", + state="workspace_missing", + summary="尚未发现 Dyro 工作区。", + commands=[], + mutation_available=False, + required_choice="join_existing_or_setup_new", + ) + return print("尚未发现 Dyro 工作区。") print("加入团队项目:dyro join <蓝图地址>") print("设置一个新项目:dyro setup") return - findings = doctor(config) + budget = _control_plane_budget(args) if args.format == "json" else None + findings = doctor(config, read_budget=budget) failures = [finding for finding in findings if finding.startswith("FAIL")] if failures: + absent_bootstrap_ids = { + repo_id + for repo_id, repository in config.repositories.items() + if repository.remote + and not (config.root / repository.path).exists() + and not (config.root / repository.path).is_symlink() + and _bootstrap_destination_safe(config, repository.path) + } + expected_bootstrap_failures = { + f"FAIL repository {repo_id}: missing or not Git: " + f"{config.root / config.repositories[repo_id].path}" + for repo_id in absent_bootstrap_ids + } + bootstrap_applicable = ( + bool(absent_bootstrap_ids) and set(failures) == expected_bootstrap_failures + ) + repair_commands = ( + [_scoped_command(args, config, "bootstrap", "--yes")] + if bootstrap_applicable + else [] + ) + if args.format == "json": + _print_control_plane_json( + "next_step", + state="needs_repair", + summary="工作区还不能开始任务。", + commands=repair_commands, + diagnostic_commands=[_scoped_command(args, config, "doctor")], + mutation_available=bootstrap_applicable, + findings=[_finding_payload(item) for item in failures], + ) + return print("工作区还不能开始任务:") for finding in failures: print(" " + finding) - print( - "下一步:dyro doctor;若仓库缺失且已配置 remote,则运行 dyro bootstrap --yes" - ) + print(f"修复后运行:{_scoped_command(args, config, 'doctor')}") + if bootstrap_applicable: + print( + "缺失仓库均已配置 remote,可运行:" + + _scoped_command(args, config, "bootstrap", "--yes") + ) return - lines = list_lines(config) + lines = list_lines(config, read_budget=budget) if not lines: - print("Profile 已就绪,但还没有开发线。下一步:dyro line create dev --yes") + command = _scoped_command(args, config, "line", "create", "dev", "--yes") + if args.format == "json": + _print_control_plane_json( + "next_step", + state="needs_line", + summary="Profile 已就绪,但还没有开发线。", + commands=[command], + mutation_available=True, + ) + return + print(f"Profile 已就绪,但还没有开发线。下一步:{command}") return if not config.adapters: if shutil.which("codex"): + command = _scoped_command( + args, config, "agent", "add", "codex", "--preset", "codex" + ) + if args.format == "json": + _print_control_plane_json( + "next_step", + state="needs_agent", + summary="工作区已就绪,检测到 Codex 尚未加入 Profile。", + commands=[command], + mutation_available=True, + ) + return print( - "工作区已就绪,检测到 Codex 尚未加入 Profile。下一步:dyro agent add codex --preset codex" + f"工作区已就绪,检测到 Codex 尚未加入 Profile。下一步:{command}" ) else: + command = _scoped_command( + args, config, "agent", "add", "", "--command", "…" + ) + if args.format == "json": + _print_control_plane_json( + "next_step", + state="needs_agent", + summary="工作区已就绪,但尚未配置可启动的 Agent。", + commands=[], + mutation_available=False, + required_inputs=["agent_id", "agent_command"], + ) + return print( - "工作区已就绪,但尚未配置可启动的 Agent。下一步:dyro agent add --command '…'" + f"工作区已就绪,但尚未配置可启动的 Agent。下一步:{command}" ) return if len(lines) == 1 and len(config.adapters) == 1: + command = _scoped_command( + args, + config, + "start", + "--line", + lines[0].id, + "--agent", + next(iter(config.adapters)), + ) + if args.format == "json": + _print_control_plane_json( + "next_step", + state="ready", + summary="工作区已就绪。", + commands=[command], + mutation_available=True, + ) + return print( - f"工作区已就绪。下一步:dyro start --line {lines[0].id} --agent {next(iter(config.adapters))}" + f"工作区已就绪。下一步:{command}" ) return - print("工作区已就绪。下一步:dyro start") + if args.format == "json": + _print_control_plane_json( + "next_step", + state="ready", + summary="工作区已就绪。", + commands=[_scoped_command(args, config, "start")], + mutation_available=True, + ) + return + print(f"工作区已就绪。下一步:{_scoped_command(args, config, 'start')}") def cmd_line_list(args: argparse.Namespace) -> None: config = _config(args) - lines = list_lines(config, args.kind) + budget = _control_plane_budget(args) if args.format == "json" else None + lines = list_lines(config, args.kind, read_budget=budget) + if args.format == "json": + _print_control_plane_json( + "line_list", + workspace=config.name, + lines=[ + { + "kind": line.kind, + "id": line.id, + "branch": line.branch, + "base": line.base, + "repositories": [ + { + "id": repository, + "base": line.base_for(repository), + "storage": line.storage_for(repository), + } + for repository in line.repositories + ], + } + for line in lines + ], + ) + return if not lines: print("暂无已登记开发线") return @@ -1868,7 +2352,25 @@ def cmd_changeset_create(args: argparse.Namespace) -> None: def cmd_changeset_list(args: argparse.Namespace) -> None: - changesets = list_changesets(_config(args)) + config = _config(args) + budget = _control_plane_budget(args) if args.format == "json" else None + changesets = list_changesets(config, read_budget=budget) + if args.format == "json": + _print_control_plane_json( + "changeset_list", + changesets=[ + { + "id": changeset.id, + "line": changeset.line, + "branch": changeset.branch, + "repositories": list(changeset.repositories), + "heads": changeset.heads, + "created_at": changeset.created_at, + } + for changeset in changesets + ], + ) + return if not changesets: print("暂无 Change Set") return @@ -1881,10 +2383,26 @@ def cmd_changeset_list(args: argparse.Namespace) -> None: def cmd_changeset_verify(args: argparse.Namespace) -> None: config = _config(args) - findings = verify_changeset(config, get_changeset(config, args.id)) + budget = _control_plane_budget(args) if args.format == "json" else None + findings = verify_changeset( + config, + get_changeset(config, args.id, read_budget=budget), + read_budget=budget, + ) + failures = [finding for finding in findings if finding.startswith("FAIL")] + if args.format == "json": + _print_control_plane_json( + "changeset_verification", + changeset=args.id, + passed=not failures, + findings=[_finding_payload(item) for item in findings], + ) + if failures: + raise SystemExit(2) + return for finding in findings: print(finding) - if any(finding.startswith("FAIL") for finding in findings): + if failures: raise DyroError(f"Change Set {args.id} 未通过核验") @@ -2493,7 +3011,20 @@ def cmd_objective_start(args: argparse.Namespace) -> None: def cmd_objective_list(args: argparse.Namespace) -> None: config = _config(args) - records = list_objectives(config) + budget = _control_plane_budget(args) if args.format == "json" else None + records = list_objectives(config, recover=False, read_budget=budget) + if args.format == "json": + _print_control_plane_json( + "objective_list", + workspace=config.name, + objectives=[ + _objective_payload( + config, record, detailed=False, read_budget=budget + ) + for record in records + ], + ) + return if not records: print( "暂无 Objective。下一步:dyro objective start --file --yes" @@ -2506,7 +3037,19 @@ def cmd_objective_list(args: argparse.Namespace) -> None: def cmd_objective_status(args: argparse.Namespace) -> None: config = _config(args) - record = get_objective(config, args.id) + budget = _control_plane_budget(args) if args.format == "json" else None + record = get_objective( + config, args.id, recover=False, read_budget=budget + ) + if args.format == "json": + _print_control_plane_json( + "objective_status", + workspace=config.name, + objective=_objective_payload( + config, record, detailed=True, read_budget=budget + ), + ) + return print(f"Objective: {record.objective.id}") print(f"Operator state: {record.operator_state}") print(f"Derived result: {derive_objective_result(config, record)}") @@ -2517,15 +3060,32 @@ def cmd_objective_status(args: argparse.Namespace) -> None: print(f"Contract SHA-256: {record.contract_sha256}") -def _read_objective_plan(config: Config, objective_id: str): +def _read_objective_plan( + config: Config, + objective_id: str, + *, + read_budget: ReadBudget | None = None, +): """Build an Objective plan without recovery, mutation, dispatch, or agents.""" - record = get_objective(config, objective_id, recover=False) - snapshot = build_scheduler_snapshot(config, objective=record) + record = get_objective( + config, objective_id, recover=False, read_budget=read_budget + ) + snapshot = ( + build_scheduler_snapshot(config, objective=record) + if read_budget is None + else build_scheduler_snapshot_bounded( + config, objective=record, budget=read_budget + ) + ) return snapshot, build_continuation_plan(snapshot) def cmd_objective_plan(args: argparse.Namespace) -> None: - _, plan = _read_objective_plan(_config(args), args.id) + _, plan = _read_objective_plan( + _config(args), + args.id, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) if args.format == "json": print( json.dumps( @@ -2540,7 +3100,11 @@ def cmd_objective_plan(args: argparse.Namespace) -> None: def cmd_objective_explain(args: argparse.Namespace) -> None: - _, plan = _read_objective_plan(_config(args), args.id) + _, plan = _read_objective_plan( + _config(args), + args.id, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) if args.format == "json": print( json.dumps( @@ -2555,7 +3119,11 @@ def cmd_objective_explain(args: argparse.Namespace) -> None: def cmd_objective_graph(args: argparse.Namespace) -> None: - snapshot, plan = _read_objective_plan(_config(args), args.id) + snapshot, plan = _read_objective_plan( + _config(args), + args.id, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) projection = build_scheduler_projection(snapshot, plan) if args.format == "json": print(render_projection_json(projection)) @@ -2566,8 +3134,19 @@ def cmd_objective_graph(args: argparse.Namespace) -> None: def cmd_objective_tick(args: argparse.Namespace) -> None: """Preview the next bounded Objective mutation wave without applying it.""" config = _config(args) - record = get_objective(config, args.id, recover=False) - snapshot = build_scheduler_snapshot(config, objective=record) + record = get_objective( + config, + args.id, + recover=False, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) + snapshot = ( + build_scheduler_snapshot(config, objective=record) + if args.format != "json" + else build_scheduler_snapshot_bounded( + config, objective=record, budget=_control_plane_budget(args) + ) + ) plan = build_continuation_plan(snapshot) tick = build_scheduler_tick( snapshot, plan, max_parallel=record.objective.budget.max_parallel @@ -2581,8 +3160,19 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: def cmd_objective_attention(args: argparse.Namespace) -> None: """Render the safe, deterministic attention view without mutating state.""" config = _config(args) - record = get_objective(config, args.id, recover=False) - snapshot = build_scheduler_snapshot(config, objective=record) + record = get_objective( + config, + args.id, + recover=False, + read_budget=_control_plane_budget(args) if args.format == "json" else None, + ) + snapshot = ( + build_scheduler_snapshot(config, objective=record) + if args.format != "json" + else build_scheduler_snapshot_bounded( + config, objective=record, budget=_control_plane_budget(args) + ) + ) plan = build_continuation_plan(snapshot) scheduler = build_scheduler_projection(snapshot, plan) projection = build_attention_projection( @@ -2980,9 +3570,18 @@ def build_parser() -> argparse.ArgumentParser: "--default", action="store_true", help="设为裸 dyro 的默认项目" ) workspace_add.set_defaults(func=cmd_workspace_add) - workspace_sub.add_parser("list", help="显示已登记工作区及可用状态").set_defaults( - func=cmd_workspace_list + workspace_list = workspace_sub.add_parser( + "list", help="显示已登记工作区及可用状态" + ) + workspace_list.add_argument( + "--format", choices=("text", "json"), default="text" ) + workspace_list.add_argument( + "--include-paths", + action="store_true", + help="在 JSON 中显式包含本机工作区绝对路径", + ) + workspace_list.set_defaults(func=cmd_workspace_list) workspace_default = workspace_sub.add_parser( "default", help="设置裸 dyro 的默认项目" ) @@ -3047,7 +3646,16 @@ def build_parser() -> argparse.ArgumentParser: ) join.set_defaults(func=cmd_join) - sub.add_parser("doctor", help="验证动态工作区结构").set_defaults(func=cmd_doctor) + doctor_parser = sub.add_parser("doctor", help="验证动态工作区结构") + doctor_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) + doctor_parser.add_argument( + "--include-paths", + action="store_true", + help="在 JSON 中显式包含本机诊断路径", + ) + doctor_parser.set_defaults(func=cmd_doctor) terminology = sub.add_parser("terminology", help="使用仓库外策略扫描候选术语") terminology_sub = terminology.add_subparsers( dest="terminology_command", required=True @@ -3076,6 +3684,9 @@ def build_parser() -> argparse.ArgumentParser: status_parser.add_argument( "--all", action="store_true", help="汇总所有全局登记工作区" ) + status_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) status_parser.set_defaults(func=cmd_status) bootstrap_parser = sub.add_parser( "bootstrap", help="clone 配置了 remote 的缺失仓库 anchor" @@ -3145,6 +3756,14 @@ def build_parser() -> argparse.ArgumentParser: "status", help="只读检查集成状态" ) integration_status_parser.add_argument("id", choices=("skill", "codex")) + integration_status_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) + integration_status_parser.add_argument( + "--include-paths", + action="store_true", + help="在 JSON 中显式包含本机集成路径与路径相关细节", + ) integration_status_parser.set_defaults(func=cmd_integration_status) integration_install_parser = integration_sub.add_parser( "install", help="预览或安装 Dyro 自有集成资产(镜像+分身)" @@ -3347,14 +3966,17 @@ def build_parser() -> argparse.ArgumentParser: start.add_argument("--agent") start.add_argument("--prompt", default="") start.set_defaults(func=cmd_start) - sub.add_parser("next", help="根据当前状态给出新手的唯一安全下一步").set_defaults( - func=cmd_next + next_parser = sub.add_parser("next", help="根据当前状态给出新手的唯一安全下一步") + next_parser.add_argument( + "--format", choices=("text", "json"), default="text" ) + next_parser.set_defaults(func=cmd_next) line = sub.add_parser("line", help="功能开发线") line_sub = line.add_subparsers(dest="line_command", required=True) line_list = line_sub.add_parser("list") line_list.add_argument("--kind", choices=("line", "hotfix")) + line_list.add_argument("--format", choices=("text", "json"), default="text") line_list.set_defaults(func=cmd_line_list) line_create = line_sub.add_parser("create") line_create.add_argument("id") @@ -3407,9 +4029,16 @@ def build_parser() -> argparse.ArgumentParser: changeset_create.add_argument("--line", required=True) changeset_create.add_argument("--repos", help="逗号分隔;默认该开发线全部仓库") changeset_create.set_defaults(func=cmd_changeset_create) - changeset_sub.add_parser("list").set_defaults(func=cmd_changeset_list) + changeset_list = changeset_sub.add_parser("list") + changeset_list.add_argument( + "--format", choices=("text", "json"), default="text" + ) + changeset_list.set_defaults(func=cmd_changeset_list) changeset_verify = changeset_sub.add_parser("verify") changeset_verify.add_argument("id") + changeset_verify.add_argument( + "--format", choices=("text", "json"), default="text" + ) changeset_verify.set_defaults(func=cmd_changeset_verify) objective = sub.add_parser( @@ -3432,13 +4061,18 @@ def build_parser() -> argparse.ArgumentParser: ) objective_start.add_argument("--yes", action="store_true") objective_start.set_defaults(func=cmd_objective_start) - objective_sub.add_parser("list", help="列出已接受的 Objective").set_defaults( - func=cmd_objective_list + objective_list = objective_sub.add_parser("list", help="列出已接受的 Objective") + objective_list.add_argument( + "--format", choices=("text", "json"), default="text" ) + objective_list.set_defaults(func=cmd_objective_list) objective_status = objective_sub.add_parser( "status", help="显示 Objective 状态和派生结果" ) objective_status.add_argument("id") + objective_status.add_argument( + "--format", choices=("text", "json"), default="text" + ) objective_status.set_defaults(func=cmd_objective_status) objective_plan = objective_sub.add_parser( "plan", help="只读生成确定性 Objective action plan,不执行任务" @@ -3926,6 +4560,7 @@ def main(argv: list[str] | None = None) -> None: # The optional local dispatch surface ships in the dyro wheel. raw = list(sys.argv[1:] if argv is None else argv) parser = build_parser() + args: argparse.Namespace | None = None try: experiment = _route_experiment_surface(raw) if experiment is not None and experiment[0] == "dispatch": @@ -3943,8 +4578,25 @@ def main(argv: list[str] | None = None) -> None: else: cmd_home(args) except DyroError as exc: + if args is not None and getattr(args, "format", None) == "json": + _print_control_plane_error(args, exc) + raise SystemExit(2) from None parser.exit(2, danger(f"错误:{exc}\n", stream=sys.stderr)) + except OSError as exc: + if args is not None and getattr(args, "format", None) == "json": + _print_control_plane_error(args, exc) + raise SystemExit(2) from None + raise except (KeyboardInterrupt, EOFError): + if args is not None and getattr(args, "format", None) == "json": + _print_control_plane_json( + "error", + stream=sys.stderr, + code="INTERRUPTED", + command=_control_plane_command(args), + retryable=False, + ) + raise SystemExit(130) from None parser.exit( 130, muted( diff --git a/src/dyro/continuation/objective_storage.py b/src/dyro/continuation/objective_storage.py index 72ff596..f365ed2 100644 --- a/src/dyro/continuation/objective_storage.py +++ b/src/dyro/continuation/objective_storage.py @@ -19,7 +19,12 @@ 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 ..read_limits import ( + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) from ..state import open_safe_child_directory, open_safe_directory from .models import Objective, RequestedMode @@ -131,7 +136,9 @@ def open_objective_directory( os.close(workspace_fd) -def list_objective_ids(config: Config) -> tuple[str, ...]: +def list_objective_ids( + config: Config, *, budget: ReadBudget | None = None +) -> tuple[str, ...]: """Return only verified Objective directory names from a stable root FD.""" if os.name == "nt": raise DyroError( @@ -141,6 +148,42 @@ def list_objective_ids(config: Config) -> tuple[str, ...]: raise DyroError( "当前平台缺少安全的 Objective 持久化能力;拒绝访问以避免路径逃逸" ) + if budget is not None: + with budget.open_safe_directory_chain( + config.root, config.objectives_dir, allow_missing=True + ) as objectives_fd: + if objectives_fd is None: + return () + names = sorted( + bounded_directory_names( + objectives_fd, + budget, + maximum_records=budget.limits.objective_records, + label="Objective", + ) + ) + result: list[str] = [] + for name in names: + if name == "objectives.lock": + continue + try: + validate_id(name, "Objective ID") + info = os.stat(name, dir_fd=objectives_fd, follow_symlinks=False) + except (OSError, ValidationError) as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Objective root contains an unsafe entry", + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Objective root contains an unsafe entry", + ) + budget.bind_directory_identity( + config.objectives_dir / name, (info.st_dev, info.st_ino) + ) + result.append(name) + return tuple(result) workspace_fd = open_safe_directory(config.root) dyro_fd: int | None = None objectives_fd: int | None = None diff --git a/src/dyro/continuation/snapshot.py b/src/dyro/continuation/snapshot.py index b3ab0b2..f5c35b8 100644 --- a/src/dyro/continuation/snapshot.py +++ b/src/dyro/continuation/snapshot.py @@ -21,7 +21,9 @@ from ..errors import DyroError, ValidationError from .. import graph as task_graph from .. import tasks as task_module +from ..read_limits import ReadBudget from ..tasks import Task +from ..workspace import list_lines from .objective_storage import StoredObjective @@ -259,6 +261,82 @@ def build_scheduler_snapshot( ) +def build_scheduler_snapshot_bounded( + config: Config, + *, + objective: StoredObjective, + budget: ReadBudget, + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), +) -> SchedulerSnapshot: + """Sample planner facts through bounded, symlink-safe manifest reads.""" + + observed_at = _utc(clock()) + known_line_ids = frozenset( + line.id for line in list_lines(config, read_budget=budget) + ) + sampled = tuple( + task_module.load_task_planning_bounded( + config, + task_id, + budget, + known_line_ids=known_line_ids, + ) + for task_id in task_module.list_task_ids_bounded(config, budget) + ) + known_tasks = tuple(item[0] for item in sampled) + statuses = {task.id: current for task, current, _digest in sampled} + digests = {task.id: digest for task, _current, digest in sampled} + decisions = task_module.decisions_bounded(config, budget) + graph = task_graph.TaskGraph( + line=None, + tasks=known_tasks, + known_tasks=known_tasks, + decisions=decisions, + execution_mode=config.policy.execution_mode, + ) + issues = task_graph.validate_task_graph(graph) + if issues: + details = "; ".join(issue.message for issue in issues[:5]) + raise ValidationError(f"任务图结构无效:{details}") + + integration_required_ids = { + dependency for task in known_tasks for dependency in task.depends_on + } + integration_required_ids.update(objective.objective.targets) + facts = tuple( + SchedulerTaskSnapshot( + task=task, + status=statuses[task.id], + external_claim_active=( + config.policy.execution_mode == "external" + and statuses[task.id] == "assigned" + and task_module.external_claim_active_bounded( + config, task, budget, now=observed_at + ) + ), + integration_state=( + task_module.dependency_integration_state_bounded( + config, task, budget + ) + if statuses[task.id] == "done" + and task.id in integration_required_ids + else "not_required" + ), + contract_sha256=digests[task.id], + ) + for task in known_tasks + ) + candidate_ids = tuple(task.id for task in known_tasks) + return build_scheduler_snapshot_from_facts( + tasks=facts, + decisions=tuple(sorted(decisions.items())), + execution_mode=config.policy.execution_mode, + candidate_ids=candidate_ids, + objective=objective, + observed_at=observed_at, + ) + + def build_scheduler_snapshot_from_facts( *, tasks: Iterable[SchedulerTaskSnapshot], diff --git a/src/dyro/continuation/store.py b/src/dyro/continuation/store.py index 10e2316..972d948 100644 --- a/src/dyro/continuation/store.py +++ b/src/dyro/continuation/store.py @@ -474,20 +474,37 @@ def create_objective( def _list_objectives_unlocked( - config: Config, *, recover: bool + config: Config, *, recover: bool, read_budget: ReadBudget | None = None ) -> list[StoredObjective]: records: list[StoredObjective] = [] - for objective_id in list_objective_ids(config): - with open_objective_directory(config, objective_id) as directory: + for objective_id in list_objective_ids(config, budget=read_budget): + with open_objective_directory( + config, objective_id, budget=read_budget + ) as directory: records.append( - _read_stored(config, objective_id, recover=recover, directory=directory) + _read_stored( + config, + objective_id, + recover=recover, + directory=directory, + budget=read_budget, + ) ) return records -def list_objectives(config: Config, *, recover: bool = True) -> list[StoredObjective]: +def list_objectives( + config: Config, + *, + recover: bool = True, + read_budget: ReadBudget | None = None, +) -> list[StoredObjective]: if not recover: - return _list_objectives_unlocked(config, recover=recover) + return _list_objectives_unlocked( + config, recover=recover, read_budget=read_budget + ) + if read_budget is not None: + raise ValidationError("bounded Objective read 不允许恢复未完成事务") # Recovery and normal reads share the writer's lock. This prevents a # reader from observing a pending marker immediately after its initial # scan, then treating a live transaction as an abandoned one. diff --git a/src/dyro/home.py b/src/dyro/home.py index 273e722..96606dd 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -1488,13 +1488,13 @@ def _create_line_from_home(config: Config, dry_run: bool) -> Line | None: if dry_run: print("DRY RUN: 已展示创建计划;不会创建 Git worktree。") return None - confirmation = ( - input("\n确认创建这些隔离 worktree?[y/b/N;b 返回基线]:").strip().lower() - ) + confirmation = input( + "\n确认创建这些隔离 worktree?[Y/b/n;回车确认,b 返回基线]:" + ).strip().lower() if confirmation in {"b", "back", "返回"}: step = 3 continue - if confirmation not in {"y", "yes"}: + if confirmation not in {"", "y", "yes"}: print("已取消;没有修改任何 Git 工作区。") return None try: diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md index 87b23d4..8e01ae3 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -1,30 +1,55 @@ --- 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. +description: Inspect Dyro control-plane state and prepare bounded read-only explanations and plans from a coding agent. Use for registered workspace discovery, workspace health, status, or safe-next-step questions, development-line or Change Set inspection, integration health, or existing Objective status, blockers, attention, graph, wave preview, and plan requests. Never use for execution or delivery mutations. --- # 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. +Treat Dyro as the delivery control plane. Run only the allowlisted observations below, prefer their JSON output, and leave every state-changing action to the user in Dyro. -## Workflow +## Read-only routing -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. +When the request already supplies a workspace alias, skip global discovery and use that alias directly. Otherwise start with `dyro workspace list --format json`; when more than one workspace is available, ask the user to choose an alias and never guess. Then use the narrowest matching command: -## Safety Boundary +- Git state: `dyro --workspace status --format json` +- Health: `dyro --workspace doctor --format json` +- One safe next step: `dyro --workspace next --format json` +- Lines or hotfixes: `dyro --workspace line list [--kind line|hotfix] --format json` +- Change Sets: `dyro --workspace changeset list --format json` or `dyro --workspace changeset verify --format json` +- Installed Skill health: `dyro integration status skill --format json` +- Objective inventory or facts: `dyro --workspace objective list --format json` or `dyro --workspace objective status --format json` +- Objective blockers or human attention: `dyro --workspace objective attention --format json` +- Objective dependency graph: `dyro --workspace objective graph --format json` +- Objective next-wave preview: `dyro --workspace objective tick --format json` +- Objective plan: `dyro --workspace objective plan --format json` -- Do not run or imitate `dispatch`, `objective apply`, task execution, merge, push, release, or publish. +Use only an existing Objective or Change Set ID returned by Dyro or supplied by the user. A non-zero exit, unavailable workspace, pending transaction, failed finding, missing field, or partial observation is unknown or blocked—not ready. + +Treat local paths and workspace inventory as sensitive metadata. Never add `--include-paths` to any command, request paths only to enrich a summary, or repeat a local path in the response unless the user supplied that exact path and it is necessary to identify the requested workspace. Keep Task IDs, branch names, and commit identifiers to the minimum needed for the requested observation. + +For `--format json`, accept exactly one JSON document. If `kind` is `error`, report its stable `code` and `command` as blocked evidence; do not infer from missing details or retry with a write-capable command. Treat malformed JSON, mixed human/JSON output, or multiple JSON documents as a failed observation. + +## Response contract + +Keep the handoff short and separate evidence from judgment: + +1. `Observed`: facts directly returned by the CLI, including failed or unavailable findings. +2. `Inferred`: bounded interpretation; label it explicitly and do not upgrade it to observed truth. +3. `Unknown`: missing runtime, environment, approval, or integration evidence. +4. `Plan`: read-only steps or the returned Objective plan, with blockers before actionable items. +5. `User action`: at most one exact Dyro mutation command from `next.commands` to review and run personally, or state that no safe action is established. + +Only hand off a workspace-scoped mutation when the returned command retains an explicit `--workspace ` or absolute `--root ` selector. Never reconstruct, shorten, retarget, or strip that selector. When `next.commands` is empty or `mutation_available` is false, do not manufacture a mutation from `diagnostic_commands`, findings, or prose. + +Use P0/P1/P2 and Go/No-Go only when the user explicitly requests an adversarial review or release decision. A CLI summary alone is never final runtime or production acceptance. + +## Hard safety boundary + +- Do not run `console`; it opens a local server and may launch a browser. +- Do not run `dispatch`, `objective apply`, Objective lifecycle mutations, `task gates`, task execution or lifecycle commands, line/hotfix/Change Set creation, integration install/sync/uninstall, setup/join/bootstrap/update, `open`, or `start`. +- Do not merge, push, sign off, release, publish, delete, or edit project files. - 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. +- Do not treat a command printed by `doctor`, `next`, a plan, or an error as permission to run it. +- Do not copy an unscoped workspace mutation into `User action`; fail closed if a future CLI response omits its selector. +- If a requested observation is outside the allowlist, explain the limitation instead of substituting a write-capable command. +- End after observation and planning. Any mutation stays a clearly labeled, user-controlled handoff. diff --git a/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml b/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml index 7d8baee..0b3d063 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml +++ b/src/dyro/integrations/assets/dyro-control-plane/agents/openai.yaml @@ -1,4 +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." + short_description: "Inspect and plan Dyro work safely" + default_prompt: "Use $dyro-control-plane to inspect Dyro control-plane state and prepare a bounded read-only plan." diff --git a/src/dyro/integrations/manager.py b/src/dyro/integrations/manager.py index 582f3c0..f9fecab 100644 --- a/src/dyro/integrations/manager.py +++ b/src/dyro/integrations/manager.py @@ -9,18 +9,25 @@ import os from pathlib import Path import shutil +import stat import tempfile from typing import Mapping from ..errors import DyroError, ValidationError from ..hub import registry_home +from ..read_limits import ( + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) 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 +ASSET_VERSION = 2 MANIFEST_SCHEMA_VERSION = 2 LEGACY_MANIFEST_SCHEMA_VERSION = 1 _SHA256_PREFIX = "sha256:" @@ -157,19 +164,92 @@ def _sha256(content: bytes) -> str: return _SHA256_PREFIX + hashlib.sha256(content).hexdigest() -def _inventory(root: Path) -> dict[str, str]: +def _inventory( + root: Path, *, read_budget: ReadBudget | None = None +) -> dict[str, str]: if root.is_symlink() or not root.is_dir(): raise ValidationError(f"Skill 镜像必须是普通目录:{root}") + if read_budget is None: + 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}") + files[path.relative_to(root).as_posix()] = _sha256(path.read_bytes()) + if not files: + raise ValidationError("Skill 资产不能为空") + return files + 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()) + records_seen = 0 + directory_flags = ( + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + ) + if not hasattr(os, "O_NOFOLLOW"): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Platform lacks safe Integration traversal support", + ) + + def walk(directory_fd: int, relative_parent: Path) -> None: + nonlocal records_seen + names = bounded_directory_names( + directory_fd, + read_budget, + maximum_records=read_budget.limits.integration_records - records_seen, + label="Integration asset", + ) + records_seen += len(names) + for name in sorted(names): + relative = relative_parent / name + try: + info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Integration asset cannot be safely inspected", + ) from exc + if stat.S_ISLNK(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Integration asset cannot be a symlink", + ) + if stat.S_ISDIR(info.st_mode): + child_fd = os.open(name, directory_flags, dir_fd=directory_fd) + try: + opened = os.fstat(child_fd) + if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Integration asset directory changed during safe open", + ) + read_budget.bind_directory_identity( + root / relative, (opened.st_dev, opened.st_ino) + ) + walk(child_fd, relative) + finally: + os.close(child_fd) + continue + if not stat.S_ISREG(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Integration asset must be a regular file", + ) + content = read_budget.read_regular_bytes_from_directory_fd( + directory_fd, + name=name, + maximum_bytes=read_budget.limits.integration_asset_bytes, + label="Integration asset", + identity_path=root / relative, + ) + files[relative.as_posix()] = _sha256(content) + + with read_budget.open_safe_directory_chain(root, root) as root_fd: + assert root_fd is not None + walk(root_fd, Path()) if not files: raise ValidationError("Skill 资产不能为空") return files @@ -334,9 +414,22 @@ def _validate_file_map(files: object) -> dict[str, str]: return validated -def _parse_manifest(path: Path) -> dict[str, object]: +def _parse_manifest( + path: Path, *, read_budget: ReadBudget | None = None +) -> dict[str, object]: try: - raw = json.loads(path.read_text(encoding="utf-8")) + content = ( + path.read_text(encoding="utf-8") + if read_budget is None + else read_budget.read_regular_text( + path, + maximum_bytes=read_budget.limits.integration_manifest_bytes, + label="Integration ownership manifest", + ) + ) + raw = json.loads(content) + except ReadLimitError: + raise except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValidationError("Integration ownership manifest 无法读取") from exc if not isinstance(raw, dict): @@ -381,9 +474,22 @@ def _parse_manifest(path: Path) -> dict[str, object]: return raw -def _parse_legacy_manifest(path: Path) -> dict[str, object]: +def _parse_legacy_manifest( + path: Path, *, read_budget: ReadBudget | None = None +) -> dict[str, object]: try: - raw = json.loads(path.read_text(encoding="utf-8")) + content = ( + path.read_text(encoding="utf-8") + if read_budget is None + else read_budget.read_regular_text( + path, + maximum_bytes=read_budget.limits.integration_manifest_bytes, + label="Legacy Integration ownership manifest", + ) + ) + raw = json.loads(content) + except ReadLimitError: + raise except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValidationError("Legacy Integration ownership manifest 无法读取") from exc if not isinstance(raw, dict): @@ -456,11 +562,16 @@ def _legacy_owned_copy( expected_target: Path | None = None, allowed_targets: set[Path] | None = None, require_current_assets: bool = True, + read_budget: ReadBudget | None = None, ) -> 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) + manifest = _parse_legacy_manifest( + legacy_manifest_path, read_budget=read_budget + ) + except ReadLimitError: + raise except ValidationError: return None target = Path(str(manifest["target"])) @@ -471,7 +582,7 @@ def _legacy_owned_copy( if target.is_symlink() or not target.is_dir(): return None try: - inventory = _inventory(target) + inventory = _inventory(target, read_budget=read_budget) if inventory != manifest["files"]: return None if require_current_assets and inventory != _asset_inventory(): @@ -488,6 +599,7 @@ def integration_status( host_homes: Mapping[str, Path] | None = None, # Backward-compatible test/API alias for Codex home override. codex_home: Path | None = None, + read_budget: ReadBudget | None = None, ) -> IntegrationStatus: """Inspect Skill mirror/avatar ownership without creating files.""" requested = integration @@ -542,6 +654,7 @@ def integration_status( legacy_manifest_path, allowed_targets=_allowed_legacy_targets(detected), require_current_assets=True, + read_budget=read_budget, ) blocking_avatars: list[AvatarStatus] = [] for row in avatar_rows: @@ -601,7 +714,9 @@ def integration_status( ) try: - manifest = _parse_manifest(manifest_path) + manifest = _parse_manifest(manifest_path, read_budget=read_budget) + except ReadLimitError: + raise except ValidationError as exc: return IntegrationStatus( requested, @@ -640,7 +755,13 @@ def integration_status( tuple(avatar_rows), ) try: - installed = _inventory(mirror) + installed = ( + _inventory(mirror) + if read_budget is None + else _inventory(mirror, read_budget=read_budget) + ) + except ReadLimitError: + raise except ValidationError as exc: return IntegrationStatus( requested, diff --git a/src/dyro/onboarding.py b/src/dyro/onboarding.py index 2d143e0..d37392c 100644 --- a/src/dyro/onboarding.py +++ b/src/dyro/onboarding.py @@ -1,15 +1,19 @@ from __future__ import annotations +from contextlib import contextmanager from dataclasses import dataclass import json import os from pathlib import Path import re +import stat +import tempfile from typing import Callable from .config import CONFIG_NAME, Config, load, validate_id from .errors import DyroError, ValidationError from .process import run +from .read_limits import open_safe_directory_chain from .state import atomic_write_text, exclusive_lock @@ -345,6 +349,153 @@ def ask_for_workspace(name_default: str, ask: Callable[[str], str] = input) -> t return name, repositories, base +def validate_bootstrap_destination(config: Config, relative: str) -> Path: + """Reject clone targets whose path can escape through a symlink parent.""" + + destination = config.root / relative + try: + root_info = config.root.lstat() + except OSError as exc: + raise DyroError(f"bootstrap workspace root 无法读取:{config.root}") from exc + current = config.root + if current.is_symlink() or not current.is_dir(): + raise DyroError(f"bootstrap 路径不能经过符号链接:{current}") + for part in Path(relative).parts: + current /= part + if current.is_symlink(): + raise DyroError(f"bootstrap 路径不能经过符号链接:{current}") + if current != destination and current.exists() and not current.is_dir(): + raise DyroError(f"bootstrap 父路径必须是目录:{current}") + current_root_info = config.root.lstat() + if (current_root_info.st_dev, current_root_info.st_ino) != ( + root_info.st_dev, + root_info.st_ino, + ): + raise DyroError("bootstrap workspace root 在预检期间发生变化") + return destination + + +@contextmanager +def _open_bootstrap_parent(config: Config, relative: str): + """Hold the clone destination parent by descriptor until atomic publish.""" + + required = (os.open, os.mkdir, os.rename, os.stat) + if ( + os.name != "posix" + or not hasattr(os, "O_NOFOLLOW") + or any(item not in os.supports_dir_fd for item in required) + ): + raise DyroError("当前平台缺少安全的 descriptor-bound bootstrap 能力") + parts = Path(relative).parts + if not parts: + raise ValidationError("bootstrap 仓库路径不能为空") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW + with open_safe_directory_chain(config.root, config.root) as root_fd: + assert root_fd is not None + current_fd = os.dup(root_fd) + try: + for part in parts[:-1]: + try: + child_fd = os.open(part, flags, dir_fd=current_fd) + except FileNotFoundError: + os.mkdir(part, mode=0o700, dir_fd=current_fd) + child_fd = os.open(part, flags, dir_fd=current_fd) + parent_fd = current_fd + current_fd = child_fd + os.close(parent_fd) + leaf = parts[-1] + try: + os.stat(leaf, dir_fd=current_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise DyroError(f"拒绝覆盖已有 bootstrap 目标:{config.root / relative}") + yield current_fd, leaf + finally: + os.close(current_fd) + + +def _copy_bootstrap_tree(source: Path, destination_fd: int) -> None: + for entry in os.scandir(source): + info = entry.stat(follow_symlinks=False) + if stat.S_ISLNK(info.st_mode): + os.symlink(os.readlink(entry.path), entry.name, dir_fd=destination_fd) + continue + if stat.S_ISDIR(info.st_mode): + os.mkdir(entry.name, mode=stat.S_IMODE(info.st_mode), dir_fd=destination_fd) + child_fd = os.open( + entry.name, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW, + dir_fd=destination_fd, + ) + try: + _copy_bootstrap_tree(Path(entry.path), child_fd) + os.fchmod(child_fd, stat.S_IMODE(info.st_mode)) + finally: + os.close(child_fd) + continue + if not stat.S_ISREG(info.st_mode): + raise DyroError(f"clone 产物包含不支持的文件类型:{entry.name}") + source_fd = os.open(entry.path, os.O_RDONLY | os.O_NOFOLLOW) + destination_file_fd = os.open( + entry.name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + stat.S_IMODE(info.st_mode), + dir_fd=destination_fd, + ) + try: + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + view = memoryview(chunk) + while view: + written = os.write(destination_file_fd, view) + view = view[written:] + os.fchmod(destination_file_fd, stat.S_IMODE(info.st_mode)) + finally: + os.close(destination_file_fd) + os.close(source_fd) + + +def _clear_bootstrap_directory(directory_fd: int) -> None: + for entry in os.scandir(directory_fd): + info = entry.stat(follow_symlinks=False) + if stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode): + child_fd = os.open( + entry.name, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW, + dir_fd=directory_fd, + ) + try: + _clear_bootstrap_directory(child_fd) + finally: + os.close(child_fd) + os.rmdir(entry.name, dir_fd=directory_fd) + else: + os.unlink(entry.name, dir_fd=directory_fd) + + +def _publish_bootstrap_tree(parent_fd: int, leaf: str, source: Path) -> None: + os.mkdir(leaf, mode=0o700, dir_fd=parent_fd) + destination_fd = os.open( + leaf, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + try: + _copy_bootstrap_tree(source, destination_fd) + except BaseException: + try: + _clear_bootstrap_directory(destination_fd) + os.rmdir(leaf, dir_fd=parent_fd) + except OSError: + pass + raise + finally: + os.close(destination_fd) + + def bootstrap( config: Config, *, @@ -357,8 +508,8 @@ def bootstrap( """ messages: list[str] = [] for repo_id, repo in sorted(config.repositories.items()): - destination = config.root / repo.path - if destination.exists(): + destination = validate_bootstrap_destination(config, repo.path) + if destination.exists() or destination.is_symlink(): check = run(("git", "-C", str(destination), "rev-parse", "--git-dir"), dry_run=False) if check.code == 0: messages.append(f"PASS {repo_id}: 已存在") @@ -373,11 +524,18 @@ def bootstrap( # accepted base guarantees that create_line can resolve a local # anchor without trusting that remote default. command += (f"--branch={branch}",) - command += (repo.remote, str(destination)) - messages.append(("DRY RUN " if dry_run else "CLONE ") + f"{repo_id}: {' '.join(command)}") - if not dry_run: - destination.parent.mkdir(parents=True, exist_ok=True) - result = run(command, timeout=600) + display_command = (*command, repo.remote, str(destination)) + messages.append(("DRY RUN " if dry_run else "CLONE ") + f"{repo_id}: {' '.join(display_command)}") + if dry_run: + continue + with tempfile.TemporaryDirectory(prefix="dyro-bootstrap-") as temp_root: + stage_path = Path(temp_root) / "repository" + result = run( + (*command, repo.remote, str(stage_path)), + timeout=600, + ) if result.code != 0: raise DyroError(f"clone {repo_id} 失败:{result.stdout.strip()}") + with _open_bootstrap_parent(config, repo.path) as (parent_fd, leaf): + _publish_bootstrap_tree(parent_fd, leaf, stage_path) return messages diff --git a/src/dyro/process.py b/src/dyro/process.py index fd1a722..541272b 100644 --- a/src/dyro/process.py +++ b/src/dyro/process.py @@ -3,9 +3,11 @@ from dataclasses import dataclass from pathlib import Path import subprocess +import threading from typing import Iterable from .errors import DyroError +from .read_limits import ReadBudget, ReadLimitCode, ReadLimitError @dataclass(frozen=True) @@ -13,14 +15,90 @@ class Result: argv: tuple[str, ...] code: int stdout: str + output_bytes: int = 0 + + +def _run_with_bounded_output( + args: tuple[str, ...], + *, + cwd: Path | None, + timeout: float | None, + maximum_output_bytes: int, +) -> Result: + if maximum_output_bytes < 1: + raise ReadLimitError( + ReadLimitCode.AGGREGATE_BYTES_EXCEEDED, + "No observation output budget remains", + ) + try: + process = subprocess.Popen( + args, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except FileNotFoundError as exc: + raise DyroError(f"找不到可执行命令:{args[0]}") from exc + + assert process.stdout is not None + captured = bytearray() + output_exceeded = threading.Event() + + def drain() -> None: + while True: + chunk = process.stdout.read(64 * 1024) + if not chunk: + return + remaining = maximum_output_bytes - len(captured) + if len(chunk) > remaining: + if remaining > 0: + captured.extend(chunk[:remaining]) + output_exceeded.set() + process.kill() + return + captured.extend(chunk) + + reader = threading.Thread(target=drain, name="dyro-output-reader", daemon=True) + reader.start() + try: + code = process.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + process.kill() + process.wait() + process.stdout.close() + reader.join(timeout=1) + raise ReadLimitError( + ReadLimitCode.DEADLINE_EXCEEDED, + "Observation subprocess deadline exceeded", + ) from exc + reader.join(timeout=1) + if reader.is_alive(): + process.stdout.close() + reader.join(timeout=1) + if reader.is_alive(): + raise DyroError(f"无法完成命令输出读取:{' '.join(args)}") + process.stdout.close() + if output_exceeded.is_set(): + raise ReadLimitError( + ReadLimitCode.AGGREGATE_BYTES_EXCEEDED, + "Observation subprocess output exceeds the remaining byte budget", + ) + content = bytes(captured) + return Result( + args, + code, + content.decode("utf-8", errors="replace"), + len(content), + ) def run( argv: Iterable[str], *, cwd: Path | None = None, - timeout: int | None = None, + timeout: float | None = None, dry_run: bool = False, + maximum_output_bytes: int | None = None, ) -> Result: """Run an argument vector without a shell. @@ -33,6 +111,13 @@ def run( raise DyroError("拒绝执行空命令") if dry_run: return Result(args, 0, "") + if maximum_output_bytes is not None: + return _run_with_bounded_output( + args, + cwd=cwd, + timeout=timeout, + maximum_output_bytes=maximum_output_bytes, + ) try: completed = subprocess.run( args, @@ -60,3 +145,27 @@ def require_ok(result: Result, context: str) -> Result: def git(repo: Path, *args: str, dry_run: bool = False, timeout: int = 180) -> Result: return run(("git", "-C", str(repo), *args), timeout=timeout, dry_run=dry_run) + + +def git_read( + repo: Path, + *args: str, + dry_run: bool = False, + timeout: float = 180, + read_budget: ReadBudget | None = None, +) -> Result: + """Run a Git observation without optional locks or index refresh writes.""" + bounded_timeout = timeout + maximum_output_bytes = None + if read_budget is not None: + bounded_timeout = min(timeout, read_budget.remaining_seconds()) + maximum_output_bytes = read_budget.remaining_bytes + result = run( + ("git", "--no-optional-locks", "-C", str(repo), *args), + timeout=bounded_timeout, + dry_run=dry_run, + maximum_output_bytes=maximum_output_bytes, + ) + if read_budget is not None: + read_budget.charge_bytes(result.output_bytes) + return result diff --git a/src/dyro/read_limits.py b/src/dyro/read_limits.py index 6f6d93f..4c9d2c9 100644 --- a/src/dyro/read_limits.py +++ b/src/dyro/read_limits.py @@ -45,6 +45,14 @@ def _positive_int(value: int, label: str) -> None: "task_records": 2000, "line_manifest_bytes": 256 * 1024, "line_records": 2000, + "changeset_manifest_bytes": 256 * 1024, + "changeset_records": 2000, + "integration_manifest_bytes": 1024 * 1024, + "integration_asset_bytes": 1024 * 1024, + "integration_records": 100, + "evidence_pointer_bytes": 64 * 1024, + "evidence_manifest_bytes": 1024 * 1024, + "task_heads_bytes": 256 * 1024, "objective_metadata_bytes": 256 * 1024, "objective_events_bytes": 8 * 1024 * 1024, "objective_event_records": 10_000, @@ -65,6 +73,24 @@ class ObservationLimits: 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"] + changeset_manifest_bytes: int = _PROTOCOL_LIMIT_CEILINGS[ + "changeset_manifest_bytes" + ] + changeset_records: int = _PROTOCOL_LIMIT_CEILINGS["changeset_records"] + integration_manifest_bytes: int = _PROTOCOL_LIMIT_CEILINGS[ + "integration_manifest_bytes" + ] + integration_asset_bytes: int = _PROTOCOL_LIMIT_CEILINGS[ + "integration_asset_bytes" + ] + integration_records: int = _PROTOCOL_LIMIT_CEILINGS["integration_records"] + evidence_pointer_bytes: int = _PROTOCOL_LIMIT_CEILINGS[ + "evidence_pointer_bytes" + ] + evidence_manifest_bytes: int = _PROTOCOL_LIMIT_CEILINGS[ + "evidence_manifest_bytes" + ] + task_heads_bytes: int = _PROTOCOL_LIMIT_CEILINGS["task_heads_bytes"] 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"] @@ -257,6 +283,10 @@ def __post_init__(self) -> None: def bytes_read(self) -> int: return self._bytes_read + @property + def remaining_bytes(self) -> int: + return self.limits.aggregate_bytes - self._bytes_read + def check_deadline(self) -> None: current = self.monotonic() if ( @@ -298,6 +328,15 @@ def _charge(self, size: int) -> None: ) self._bytes_read += size + def charge_bytes(self, size: int) -> None: + """Charge bytes captured from a bounded observation subprocess.""" + + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ValidationError("observation bytes 必须是非负整数") + self.check_deadline() + self._charge(size) + self.check_deadline() + def _root_identity(self, root: Path) -> tuple[int, int]: absolute = _checked_absolute(root, "workspace root") key = str(absolute) @@ -579,6 +618,39 @@ def read_regular_text_at( ).decode("utf-8") +def bounded_directory_names( + directory_fd: int, + budget: ReadBudget, + *, + maximum_records: int, + label: str, +) -> tuple[str, ...]: + """Enumerate at most ``maximum_records`` names without preloading a directory.""" + + if isinstance(maximum_records, bool) or maximum_records < 0: + raise ValidationError("maximum_records 必须是非负整数") + names: list[str] = [] + try: + with os.scandir(directory_fd) as entries: + for entry in entries: + budget.check_deadline() + if len(names) >= maximum_records: + raise ReadLimitError( + ReadLimitCode.RECORD_LIMIT_EXCEEDED, + f"{label} record limit exceeded", + ) + names.append(entry.name) + except ReadLimitError: + raise + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + f"{label} directory cannot be safely enumerated", + ) from exc + budget.check_deadline() + return tuple(names) + + def require_safe_directory_chain( root: Path, directory: Path, *, allow_missing: bool = False ) -> bool: diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index 6202a27..ba74c98 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -4,8 +4,10 @@ from datetime import datetime, timedelta, timezone import hashlib import json +import os from pathlib import Path import re +import stat import tomllib import uuid from typing import Any, Iterable @@ -18,6 +20,10 @@ validate_id, ) from .evidence_store import ( + CURRENT_EVIDENCE_FILE, + EVIDENCE_GENERATIONS_DIR, + GENERATION_PATTERN, + MANIFEST_FILE, EvidenceGeneration, cleanup_evidence_generations, list_evidence_generations, @@ -25,8 +31,13 @@ resolve_evidence_path, ) from .errors import DyroError, ValidationError -from .process import git, require_ok, run -from .read_limits import ReadBudget +from .process import git, git_read, require_ok, run +from .read_limits import ( + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) from .provenance import ( ExecutionAttempt, begin_execution_attempt, @@ -410,6 +421,104 @@ def list_tasks(config: Config) -> list[Task]: ] +def list_task_ids_bounded(config: Config, budget: ReadBudget) -> tuple[str, ...]: + """Enumerate Task directories without following symlinks or exceeding limits.""" + + with budget.open_safe_directory_chain( + config.root, config.task_specs_dir, allow_missing=True + ) as directory_fd: + if directory_fd is None: + return () + task_ids: list[str] = [] + for name in sorted( + bounded_directory_names( + directory_fd, + budget, + maximum_records=budget.limits.task_records, + label="Task", + ) + ): + try: + info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Task root contains an unsafe entry", + ) from exc + if stat.S_ISLNK(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Task root contains an unsafe entry", + ) + if not stat.S_ISDIR(info.st_mode): + continue + validate_id(name, "任务 ID") + budget.bind_directory_identity( + config.task_specs_dir / name, (info.st_dev, info.st_ino) + ) + task_ids.append(name) + return tuple(task_ids) + + +def decisions_bounded(config: Config, budget: ReadBudget) -> dict[str, str]: + """Read decision facts through the machine-facing bounded reader.""" + + try: + content = budget.read_regular_bytes_at( + root=config.root, + directory=config.decisions_file.parent, + name=config.decisions_file.name, + maximum_bytes=budget.limits.task_manifest_bytes, + label="decisions.toml", + ) + except FileNotFoundError: + return {} + try: + raw = tomllib.loads(content.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError, RecursionError) as exc: + raise ValidationError("决策点格式错误") from exc + 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) + } + + +def external_claim_active_bounded( + config: Config, + task: Task, + budget: ReadBudget, + *, + now: datetime, +) -> bool: + """Read claim liveness without following a Task-directory symlink.""" + + try: + content = budget.read_regular_bytes_at( + root=config.root, + directory=task.directory, + name=_claim_path(task).name, + maximum_bytes=budget.limits.task_manifest_bytes, + label="task claim", + ) + except FileNotFoundError: + return False + try: + payload = json.loads(content.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError, RecursionError) 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) + ): + raise ValidationError(f"任务 {task.id} 领取记录无效") + return not _claim_expired(payload, now=now) + + def status(config: Config, task: Task) -> str: file = task.directory / "status" current = file.read_text(encoding="utf-8").strip() if file.exists() else "backlog" @@ -1319,6 +1428,125 @@ def _load_task_heads(config: Config, task: Task) -> dict[str, str]: return _validate_task_heads_payload(config, task, payload) +def _json_object_bounded(content: bytes, label: str) -> dict[str, Any]: + try: + payload = json.loads(content) + except (UnicodeError, json.JSONDecodeError, RecursionError) as exc: + raise ValidationError(f"{label} 不是有效 JSON") from exc + if not isinstance(payload, dict): + raise ValidationError(f"{label} 必须是 JSON 对象") + return payload + + +def _load_task_heads_bounded( + config: Config, task: Task, budget: ReadBudget +) -> dict[str, str]: + """Load legacy or imported task-head evidence through bounded safe reads.""" + + try: + pointer_content = budget.read_regular_bytes_at( + root=config.root, + directory=task.directory, + name=CURRENT_EVIDENCE_FILE, + maximum_bytes=budget.limits.evidence_pointer_bytes, + label="Current evidence pointer", + ) + except FileNotFoundError: + heads_content = budget.read_regular_bytes_at( + root=config.root, + directory=task.directory, + name=TASK_HEADS_FILE, + maximum_bytes=budget.limits.task_heads_bytes, + label="Task heads evidence", + ) + return _validate_task_heads_payload( + config, + task, + _json_object_bounded(heads_content, "任务 HEAD 证据"), + ) + + pointer = _json_object_bounded(pointer_content, "当前证据指针") + generation = pointer.get("generation") + manifest_sha256 = pointer.get("manifest_sha256") + if ( + pointer.get("schema_version") != 1 + or not isinstance(generation, str) + or GENERATION_PATTERN.fullmatch(generation) is None + or not isinstance(manifest_sha256, str) + or len(manifest_sha256) != 64 + ): + raise ValidationError("当前证据指针格式无效") + generation_directory = ( + task.directory / EVIDENCE_GENERATIONS_DIR / generation + ) + manifest_content = budget.read_regular_bytes_at( + root=config.root, + directory=generation_directory, + name=MANIFEST_FILE, + maximum_bytes=budget.limits.evidence_manifest_bytes, + label="Evidence generation manifest", + ) + if hashlib.sha256(manifest_content).hexdigest() != manifest_sha256: + raise ValidationError("当前证据世代 manifest 哈希不匹配") + manifest = _json_object_bounded(manifest_content, "证据世代 manifest") + files = manifest.get("files") + if ( + manifest.get("schema_version") != 1 + or manifest.get("generation") != generation + or not isinstance(files, dict) + ): + raise ValidationError("证据世代 manifest 格式无效") + heads_content = budget.read_regular_bytes_at( + root=config.root, + directory=generation_directory, + name=TASK_HEADS_FILE, + maximum_bytes=budget.limits.task_heads_bytes, + label="Task heads evidence", + ) + entry = files.get(TASK_HEADS_FILE) + if ( + not isinstance(entry, dict) + or not isinstance(entry.get("sha256"), str) + or isinstance(entry.get("size"), bool) + or not isinstance(entry.get("size"), int) + or len(heads_content) != entry["size"] + or hashlib.sha256(heads_content).hexdigest() != entry["sha256"] + ): + raise ValidationError("不可变任务 HEAD 证据缺失或哈希不匹配") + return _validate_task_heads_payload( + config, + task, + _json_object_bounded(heads_content, "任务 HEAD 证据"), + ) + + +def dependency_integration_state_bounded( + config: Config, task: Task, budget: ReadBudget +) -> str: + """Return the real integration state within the shared observation budget.""" + + try: + line = get_line(config, task.line, read_budget=budget) + heads = _load_task_heads_bounded(config, task, budget) + for repository_id, task_head in heads.items(): + destination = line_repository_path(config, line, repository_id) + result = git_read( + destination, + "merge-base", + "--is-ancestor", + task_head, + "HEAD", + read_budget=budget, + ) + if result.code != 0: + return "pending" + except ReadLimitError: + raise + except (DyroError, OSError, ValidationError): + return "pending" + return "integrated" + + def _assert_dependency_integrated(config: Config, task: Task) -> None: line = get_line(config, task.line) heads = _load_task_heads(config, task) diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 8c154cd..3c0bcb7 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -2,14 +2,21 @@ from dataclasses import dataclass, field import json +import os from pathlib import Path import shutil +import stat from typing import Iterable, Mapping 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 .process import git, git_read, require_ok +from .read_limits import ( + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) from .state import atomic_write_text @@ -133,8 +140,78 @@ def load_line_bounded(path: Path, budget: ReadBudget, *, workspace_root: Path) - return line -def list_lines(config: Config, kind: str | None = None) -> list[Line]: +def _list_lines_bounded( + config: Config, wanted: tuple[str, ...], budget: ReadBudget +) -> list[Line]: + entries: list[tuple[str, Path, str]] = [] + records_seen = 0 + for current_kind in wanted: + parent = ( + config.lines_state_dir + if current_kind == "line" + else config.hotfixes_state_dir + ) + with budget.open_safe_directory_chain( + config.root, parent, allow_missing=True + ) as directory_fd: + if directory_fd is None: + continue + names = bounded_directory_names( + directory_fd, + budget, + maximum_records=budget.limits.line_records - records_seen, + label="Line manifest", + ) + records_seen += len(names) + for name in names: + if not name.endswith(".toml"): + continue + entries.append((current_kind, parent, name)) + + lines: list[Line] = [] + for current_kind, parent, name in sorted(entries): + with budget.open_safe_directory_chain(config.root, parent) as directory_fd: + assert directory_fd is not None + try: + info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Line manifest is not a safe regular file", + ) from exc + if not stat.S_ISREG(info.st_mode): + raise ReadLimitError( + ReadLimitCode.UNSAFE_FILE, + "Line manifest is not a safe regular file", + ) + path = parent / name + budget.bind_file_identity(path, (info.st_dev, info.st_ino)) + content = budget.read_regular_bytes_from_directory_fd( + directory_fd, + name=name, + maximum_bytes=budget.limits.line_manifest_bytes, + label="line manifest", + identity_path=path, + ) + line = _parse_line_content(path, content) + if path.stem != line.id or line.kind != current_kind: + raise ValidationError(f"开发线文件名或类型与清单不一致:{path}") + lines.append(line) + return lines + + +def list_lines( + config: Config, + kind: str | None = None, + *, + read_budget: ReadBudget | None = None, +) -> list[Line]: wanted = (kind,) if kind else ("line", "hotfix") + if read_budget is not None: + return sorted( + _list_lines_bounded(config, wanted, read_budget), + key=lambda line: (line.kind, line.id), + ) lines: list[Line] = [] for current_kind in wanted: parent = config.lines_state_dir if current_kind == "line" else config.hotfixes_state_dir @@ -143,8 +220,18 @@ def list_lines(config: Config, kind: str | None = None) -> list[Line]: return sorted(lines, key=lambda line: (line.kind, line.id)) -def get_line(config: Config, line_id: str, kind: str | None = None) -> Line: - matches = [line for line in list_lines(config, kind) if line.id == line_id] +def get_line( + config: Config, + line_id: str, + kind: str | None = None, + *, + read_budget: ReadBudget | None = None, +) -> Line: + matches = [ + line + for line in list_lines(config, kind, read_budget=read_budget) + if line.id == line_id + ] if not matches: raise DyroError(f"未登记的开发线:{line_id}") if len(matches) > 1: @@ -169,12 +256,22 @@ def line_repository_path(config: Config, line: Line, repo_id: str) -> Path: return line_root(config, line) / repo.mount -def _is_git_repo(path: Path) -> bool: - return git(path, "rev-parse", "--git-dir").code == 0 +def _is_git_repo(path: Path, *, read_budget: ReadBudget | None = None) -> bool: + return ( + git_read( + path, + "rev-parse", + "--git-dir", + read_budget=read_budget, + ).code + == 0 + ) def _ensure_clean(path: Path) -> None: - result = require_ok(git(path, "status", "--porcelain=v1", "-uall"), f"读取 {path} 状态") + result = require_ok( + git_read(path, "status", "--porcelain=v1", "-uall"), f"读取 {path} 状态" + ) if result.stdout.strip(): raise DyroError(f"仓库不干净,拒绝创建或合并 worktree:{path}") @@ -282,14 +379,17 @@ def _plan_line_creation( raise DyroError(f"仓库 anchor 不存在或不是 Git 仓库:{anchor}") _ensure_clean(anchor) repo_base = line.base_for(repo_id) - require_ok(git(anchor, "rev-parse", "--verify", f"{repo_base}^{{commit}}"), f"校验 {repo_id} 基线 {repo_base}") + require_ok( + git_read(anchor, "rev-parse", "--verify", f"{repo_base}^{{commit}}"), + f"校验 {repo_id} 基线 {repo_base}", + ) if destination.exists() or destination.is_symlink(): raise DyroError(f"worktree 目标已存在:{destination}") - branch_check = git( + branch_check = git_read( anchor, "show-ref", "--verify", "--quiet", f"refs/heads/{line.branch}" ) if branch_check.code == 0: - ancestry = git( + ancestry = git_read( anchor, "merge-base", "--is-ancestor", repo_base, line.branch ) if ancestry.code != 0: @@ -297,7 +397,10 @@ def _plan_line_creation( f"{repo_id} 既有分支 {line.branch} 不包含声明的基线 {repo_base}" ) if line.storage_for(repo_id) == "anchor-reference": - anchor_branch = require_ok(git(anchor, "branch", "--show-current"), f"读取 {repo_id} anchor 分支").stdout.strip() + anchor_branch = require_ok( + git_read(anchor, "branch", "--show-current"), + f"读取 {repo_id} anchor 分支", + ).stdout.strip() if anchor_branch != line.branch: raise DyroError( f"{repo_id} 的 anchor-reference 要求 anchor 正位于 {line.branch}," @@ -399,57 +502,108 @@ def create_line( return line -def _short_status(path: Path) -> tuple[str, str, str, int]: - branch = require_ok(git(path, "branch", "--show-current"), f"读取 {path} 分支").stdout.strip() or "DETACHED" - head = require_ok(git(path, "rev-parse", "--short=12", "HEAD"), f"读取 {path} HEAD").stdout.strip() - upstream_result = git(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") +def _short_status( + path: Path, *, read_budget: ReadBudget | None = None +) -> tuple[str, str, str, int]: + branch = ( + require_ok( + git_read( + path, + "branch", + "--show-current", + read_budget=read_budget, + ), + f"读取 {path} 分支", + ).stdout.strip() + or "DETACHED" + ) + head = require_ok( + git_read( + path, + "rev-parse", + "--short=12", + "HEAD", + read_budget=read_budget, + ), + f"读取 {path} HEAD", + ).stdout.strip() + upstream_result = git_read( + path, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + read_budget=read_budget, + ) upstream = upstream_result.stdout.strip() if upstream_result.code == 0 else "-" - dirty = len(require_ok(git(path, "status", "--porcelain=v1", "-uall"), f"读取 {path} 状态").stdout.splitlines()) + dirty = len( + require_ok( + git_read( + path, + "status", + "--porcelain=v1", + "-uall", + read_budget=read_budget, + ), + f"读取 {path} 状态", + ).stdout.splitlines() + ) return branch, head, upstream, dirty -def status_rows(config: Config) -> list[tuple[str, str, str, str, str, int]]: +def status_rows( + config: Config, *, read_budget: ReadBudget | None = None +) -> list[tuple[str, str, str, str, str, int]]: rows: list[tuple[str, str, str, str, str, int]] = [] for repo_id in sorted(config.repositories): path = repository_path(config, repo_id) - if _is_git_repo(path): - branch, head, upstream, dirty = _short_status(path) + if _is_git_repo(path, read_budget=read_budget): + branch, head, upstream, dirty = _short_status( + path, read_budget=read_budget + ) rows.append(("anchor", repo_id, branch, head, upstream, dirty)) else: rows.append(("anchor", repo_id, "MISSING", "-", "-", -1)) - for line in list_lines(config): + for line in list_lines(config, read_budget=read_budget): for repo_id in line.repositories: path = line_repository_path(config, line, repo_id) - if _is_git_repo(path): - branch, head, upstream, dirty = _short_status(path) + if _is_git_repo(path, read_budget=read_budget): + branch, head, upstream, dirty = _short_status( + path, read_budget=read_budget + ) rows.append((f"{line.kind}:{line.id}", repo_id, branch, head, upstream, dirty)) else: rows.append((f"{line.kind}:{line.id}", repo_id, "MISSING", "-", "-", -1)) return rows -def doctor(config: Config) -> list[str]: +def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str]: """Return diagnostics. Callers decide whether any FAIL means non-zero.""" findings: list[str] = [] for requirement in external_security_errors(config.policy): findings.append(f"FAIL external Profile requires {requirement}") - root_git = _is_git_repo(config.root) + root_git = _is_git_repo(config.root, read_budget=read_budget) findings.append(("WARN" if root_git else "PASS") + " workspace root " + ("is a Git repository" if root_git else "is not a Git repository")) for repo_id in sorted(config.repositories): anchor = repository_path(config, repo_id) - if _is_git_repo(anchor): + if _is_git_repo(anchor, read_budget=read_budget): findings.append(f"PASS repository {repo_id}: {anchor}") else: findings.append(f"FAIL repository {repo_id}: missing or not Git: {anchor}") - for line in list_lines(config): + for line in list_lines(config, read_budget=read_budget): for repo_id in line.repositories: anchor = repository_path(config, repo_id) worktree = line_repository_path(config, line, repo_id) storage_mode = line.storage_for(repo_id) - if not _is_git_repo(worktree): + if not _is_git_repo(worktree, read_budget=read_budget): findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: missing worktree") continue - actual_branch = git(worktree, "branch", "--show-current") + actual_branch = git_read( + worktree, + "branch", + "--show-current", + read_budget=read_budget, + ) if actual_branch.code != 0 or actual_branch.stdout.strip() != line.branch: actual = actual_branch.stdout.strip() if actual_branch.code == 0 else "UNREADABLE" findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: expected {line.branch}, found {actual or 'DETACHED'}") @@ -465,8 +619,20 @@ def doctor(config: Config) -> list[str]: if worktree.is_symlink(): findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: linked-worktree cannot be a symlink") continue - anchor_common = git(anchor, "rev-parse", "--path-format=absolute", "--git-common-dir") - worktree_common = git(worktree, "rev-parse", "--path-format=absolute", "--git-common-dir") + anchor_common = git_read( + anchor, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + read_budget=read_budget, + ) + worktree_common = git_read( + worktree, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + read_budget=read_budget, + ) if anchor_common.code == 0 and worktree_common.code == 0 and anchor_common.stdout.strip() == worktree_common.stdout.strip(): findings.append(f"PASS {line.kind}:{line.id}/{repo_id}: linked to configured anchor") else: diff --git a/tests/test_changesets.py b/tests/test_changesets.py index c571c03..ddb6d0f 100644 --- a/tests/test_changesets.py +++ b/tests/test_changesets.py @@ -1,11 +1,50 @@ +from pathlib import Path +import os + from dyro.changesets import create_changeset, get_changeset, verify_changeset from dyro.config import load +from dyro.process import git, require_ok from dyro.workspace import create_line from .support import WorkspaceCase class ChangeSetTests(WorkspaceCase): + def test_changeset_verification_does_not_refresh_the_index(self) -> None: + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + changeset = create_changeset( + config, changeset_id="alpha-ready", line_id="alpha" + ) + delivery = self.root / "versions/alpha/services/api" + index = Path( + require_ok( + git( + delivery, + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ), + "读取 worktree index", + ).stdout.strip() + ) + tracked = delivery / "README.md" + tracked_stat = tracked.stat() + os.utime( + tracked, + ns=(tracked_stat.st_atime_ns, tracked_stat.st_mtime_ns + 2_000_000_000), + ) + before_bytes = index.read_bytes() + before_mtime = index.stat().st_mtime_ns + + findings = verify_changeset(config, changeset) + + self.assertFalse(any(item.startswith("FAIL") for item in findings), findings) + self.assertEqual(index.read_bytes(), before_bytes) + self.assertEqual(index.stat().st_mtime_ns, before_mtime) + self.assertFalse(index.with_name("index.lock").exists()) + def test_changeset_pins_and_verifies_delivery_line_heads(self) -> None: config = load(self.root) create_line(config, line_id="alpha", branch="feat/alpha", base="main") diff --git a/tests/test_cli.py b/tests/test_cli.py index b0f34d7..8fad288 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from pathlib import Path import json import os +import subprocess import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout @@ -17,12 +18,14 @@ ) from dyro.changesets import get_changeset from dyro.config import load +from dyro.continuation.store import pause_objective +from dyro.evidence_store import publish_evidence_generation from dyro.home import HomeTool from dyro.hub import load_registry from dyro.tasks import load_task, status, task_template from dyro.tooling import ToolState, load_tool_preferences from dyro.updates import load_update_state -from dyro.workspace import create_line, get_line +from dyro.workspace import create_line, get_line, line_repository_path from .support import WorkspaceCase @@ -109,6 +112,38 @@ def test_init_creates_workspace_contract(self) -> None: self.assertTrue((root / ".dyro/tasks").is_dir()) self.assertEqual(load(root).name, "demo") + def test_workspace_list_json_is_structured_and_identifies_the_default(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: + root = Path(tmp) / "workspace" + main(["init", str(root), "--name", "demo"]) + main(["workspace", "add", str(root), "--default"]) + + output = StringIO() + with redirect_stdout(output): + main(["workspace", "list", "--format", "json"]) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload["schema_version"], 1) + self.assertEqual(payload["kind"], "workspace_list") + self.assertEqual(payload["default"], "demo") + self.assertEqual(payload["workspaces"][0]["name"], "demo") + self.assertTrue(payload["workspaces"][0]["available"]) + self.assertNotIn("root", payload["workspaces"][0]) + + output = StringIO() + with redirect_stdout(output): + main( + [ + "workspace", + "list", + "--format", + "json", + "--include-paths", + ] + ) + with_paths = json.loads(output.getvalue()) + self.assertEqual(with_paths["workspaces"][0]["root"], str(root.resolve())) + def test_setup_presentation_uses_semantic_color_when_enabled(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: root = Path(tmp) / "workspace" @@ -853,6 +888,531 @@ def setUp(self) -> None: ) directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + def _read_json(self, *argv: str) -> dict[str, object]: + output = StringIO() + with redirect_stdout(output): + main(["--root", str(self.root), *argv, "--format", "json"]) + return json.loads(output.getvalue()) + + def _start_release_objective(self) -> None: + main( + [ + "--root", + str(self.root), + "objective", + "start", + "--id", + "release", + "--title", + "Release", + "--line", + "alpha", + "--targets", + "TASK-A", + "--yes", + ] + ) + + def test_control_plane_workspace_views_have_stable_json_shapes(self) -> None: + doctor_payload = self._read_json("doctor") + self.assertEqual(doctor_payload["kind"], "doctor") + self.assertTrue(doctor_payload["passed"]) + self.assertTrue(doctor_payload["findings"]) + rendered_doctor = json.dumps(doctor_payload) + self.assertNotIn(str(self.root.resolve()), rendered_doctor) + self.assertNotIn(str(self.anchor.resolve()), rendered_doctor) + self.assertTrue( + any( + finding["message"] == "repository api: ready" + for finding in doctor_payload["findings"] + ) + ) + + doctor_with_paths = self._read_json("doctor", "--include-paths") + self.assertIn(str(self.anchor.resolve()), json.dumps(doctor_with_paths)) + + status_payload = self._read_json("status") + self.assertEqual(status_payload["kind"], "workspace_status") + self.assertEqual(status_payload["workspace"], "test-workspace") + self.assertEqual(status_payload["rows"][0]["scope"], "anchor") + + next_payload = self._read_json("next") + self.assertEqual(next_payload["kind"], "next_step") + self.assertEqual(next_payload["state"], "ready") + self.assertEqual( + next_payload["commands"], + [f"dyro --root {self.root.resolve()} start --line alpha --agent noop"], + ) + + lines_payload = self._read_json("line", "list") + self.assertEqual(lines_payload["kind"], "line_list") + self.assertEqual(lines_payload["lines"][0]["id"], "alpha") + self.assertEqual( + lines_payload["lines"][0]["repositories"][0]["storage"], + "linked-worktree", + ) + + def test_control_plane_next_preserves_an_explicit_workspace_selector(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home: + with patch.dict(os.environ, {"DYRO_HOME": registry_home}, clear=False): + main( + [ + "workspace", + "add", + str(self.root), + "--name", + "selected", + "--default", + ] + ) + output = StringIO() + with redirect_stdout(output): + main( + [ + "--workspace", + "selected", + "next", + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + self.assertEqual( + payload["commands"], + ["dyro --workspace selected start --line alpha --agent noop"], + ) + + def test_control_plane_json_runtime_errors_use_one_stable_envelope(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home: + stdout = StringIO() + stderr = StringIO() + with ( + patch.dict(os.environ, {"DYRO_HOME": registry_home}, clear=False), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--workspace", + "missing", + "status", + "--format", + "json", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual( + json.loads(stderr.getvalue()), + { + "schema_version": 1, + "kind": "error", + "code": "WORKSPACE_NOT_REGISTERED", + "command": "status", + "retryable": False, + }, + ) + + stdout = StringIO() + stderr = StringIO() + with tempfile.TemporaryDirectory(prefix="dyro-missing-") as missing_home: + missing_root = Path(missing_home) / "missing-workspace" + with ( + patch.dict(os.environ, {"DYRO_HOME": missing_home}, clear=False), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(missing_root), + "next", + "--format", + "json", + ] + ) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual( + json.loads(stderr.getvalue())["code"], "LOCAL_PROFILE_INVALID" + ) + + def test_control_plane_next_rejects_a_broken_local_profile(self) -> None: + (self.root / "dyro.toml").write_text("not valid toml = [", encoding="utf-8") + stdout = StringIO() + stderr = StringIO() + with ( + patch("dyro.cli.Path.cwd", return_value=self.root), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["next", "--format", "json"]) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual( + json.loads(stderr.getvalue())["code"], "LOCAL_PROFILE_INVALID" + ) + + def test_control_plane_doctor_failure_is_one_json_result(self) -> None: + self.anchor.rename(self.root / "api-missing") + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "doctor", + "--format", + "json", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stderr.getvalue(), "") + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["kind"], "doctor") + self.assertFalse(payload["passed"]) + + def test_control_plane_json_interrupt_is_one_stable_envelope(self) -> None: + stdout = StringIO() + stderr = StringIO() + with ( + patch("dyro.cli.doctor", side_effect=KeyboardInterrupt), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "doctor", + "--format", + "json", + ] + ) + + self.assertEqual(raised.exception.code, 130) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(json.loads(stderr.getvalue())["code"], "INTERRUPTED") + + def test_control_plane_next_only_offers_applicable_bootstrap(self) -> None: + (self.config.lines_state_dir / "alpha.toml").unlink() + self.anchor.rename(self.root / "api-missing") + unavailable = self._read_json("next") + self.assertFalse(unavailable["mutation_available"]) + self.assertEqual(unavailable["commands"], []) + self.assertEqual( + unavailable["diagnostic_commands"], + [f"dyro --root {self.root.resolve()} doctor"], + ) + + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + 'mount = "services/api"', + 'mount = "services/api"\nremote = "https://example.invalid/api.git"', + ), + encoding="utf-8", + ) + applicable = self._read_json("next") + self.assertTrue(applicable["mutation_available"]) + self.assertEqual( + applicable["commands"], + [f"dyro --root {self.root.resolve()} bootstrap --yes"], + ) + + def test_control_plane_next_never_offers_bootstrap_through_symlink_parent( + self, + ) -> None: + (self.config.lines_state_dir / "alpha.toml").unlink() + self.anchor.rename(self.root / "api-missing") + outside = self.root.parent / f"{self.root.name}-outside" + outside.mkdir() + escape = self.root / "escape" + escape.symlink_to(outside, target_is_directory=True) + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8") + .replace('path = "repositories/api"', 'path = "escape/api"') + .replace( + 'mount = "services/api"', + 'mount = "services/api"\nremote = "https://example.invalid/api.git"', + ), + encoding="utf-8", + ) + + payload = self._read_json("next") + + self.assertFalse(payload["mutation_available"]) + self.assertEqual(payload["commands"], []) + self.assertFalse((outside / "api").exists()) + + def test_control_plane_rejects_symlinked_line_and_changeset_manifests(self) -> None: + line_path = self.config.lines_state_dir / "alpha.toml" + line_target = self.root / "outside-line.toml" + line_target.write_bytes(line_path.read_bytes()) + line_path.unlink() + line_path.symlink_to(line_target) + + stderr = StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: + main( + [ + "--root", + str(self.root), + "line", + "list", + "--format", + "json", + ] + ) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(json.loads(stderr.getvalue())["code"], "UNSAFE_FILE") + + line_path.unlink() + line_path.write_bytes(line_target.read_bytes()) + main( + [ + "--root", + str(self.root), + "changeset", + "create", + "release-candidate", + "--line", + "alpha", + ] + ) + changeset_path = self.config.changesets_dir / "release-candidate.toml" + changeset_target = self.root / "outside-changeset.toml" + changeset_target.write_bytes(changeset_path.read_bytes()) + changeset_path.unlink() + changeset_path.symlink_to(changeset_target) + + stderr = StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: + main( + [ + "--root", + str(self.root), + "changeset", + "list", + "--format", + "json", + ] + ) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(json.loads(stderr.getvalue())["code"], "UNSAFE_FILE") + + def test_control_plane_rejects_a_symlinked_profile(self) -> None: + profile = self.root / "dyro.toml" + target = self.root / "outside-profile.toml" + target.write_bytes(profile.read_bytes()) + profile.unlink() + profile.symlink_to(target) + + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "status", + "--format", + "json", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual( + json.loads(stderr.getvalue())["code"], "LOCAL_PROFILE_INVALID" + ) + + def test_control_plane_objective_plan_rejects_a_symlinked_task(self) -> None: + self._start_release_objective() + task_directory = self.config.task_specs_dir / "TASK-A" + outside = self.root / "outside-task" + task_directory.rename(outside) + task_directory.symlink_to(outside, target_is_directory=True) + + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "objective", + "plan", + "release", + "--format", + "json", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(json.loads(stderr.getvalue())["code"], "UNSAFE_FILE") + + def test_control_plane_changeset_views_have_stable_json_shapes(self) -> None: + main( + [ + "--root", + str(self.root), + "changeset", + "create", + "release-candidate", + "--line", + "alpha", + ] + ) + + listed = self._read_json("changeset", "list") + self.assertEqual(listed["kind"], "changeset_list") + self.assertEqual(listed["changesets"][0]["id"], "release-candidate") + self.assertEqual(set(listed["changesets"][0]["heads"]), {"api"}) + + verified = self._read_json( + "changeset", "verify", "release-candidate" + ) + self.assertEqual(verified["kind"], "changeset_verification") + self.assertTrue(verified["passed"]) + self.assertEqual(verified["findings"][0]["status"], "PASS") + + def test_objective_list_and_status_json_are_strictly_non_recovering(self) -> None: + self._start_release_objective() + + listed = self._read_json("objective", "list") + self.assertEqual(listed["kind"], "objective_list") + self.assertEqual(listed["objectives"][0]["id"], "release") + detailed = self._read_json("objective", "status", "release") + self.assertEqual(detailed["kind"], "objective_status") + self.assertEqual(detailed["objective"]["derived_result"], "incomplete") + + with patch( + "dyro.continuation.objective_storage.write_projection", + side_effect=OSError("simulated crash"), + ): + with self.assertRaisesRegex(OSError, "simulated crash"): + pause_objective(self.config, "release") + + objective_dir = self.config.objectives_dir / "release" + before = { + path.relative_to(objective_dir): path.read_bytes() + for path in objective_dir.rglob("*") + if path.is_file() + } + self.assertIn(Path("pending.json"), before) + + for argv in ( + ("objective", "list", "--format", "json"), + ("objective", "status", "release", "--format", "json"), + ): + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["--root", str(self.root), *argv]) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + error = json.loads(stderr.getvalue()) + self.assertEqual(error["kind"], "error") + self.assertEqual(error["code"], "OBJECTIVE_UNAVAILABLE") + after = { + path.relative_to(objective_dir): path.read_bytes() + for path in objective_dir.rglob("*") + if path.is_file() + } + self.assertEqual(after, before) + + def test_objective_json_reports_completed_integrated_target(self) -> None: + self._start_release_objective() + task_directory = self.config.task_specs_dir / "TASK-A" + task_directory.joinpath("status").write_text("done\n", encoding="utf-8") + line = get_line(self.config, "alpha") + target = line_repository_path(self.config, line, "api") + head = subprocess.run( + ["git", "-C", str(target), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + task_directory.joinpath("task-heads.json").write_text( + json.dumps( + { + "schema_version": 1, + "task_id": "TASK-A", + "line": "alpha", + "branch": "task/TASK-A", + "repositories": {"api": head}, + } + ), + encoding="utf-8", + ) + + detailed = self._read_json("objective", "status", "release") + + self.assertEqual(detailed["objective"]["derived_result"], "complete") + + def test_objective_json_rejects_unmanifested_imported_task_heads(self) -> None: + self._start_release_objective() + task_directory = self.config.task_specs_dir / "TASK-A" + task_directory.joinpath("status").write_text("done\n", encoding="utf-8") + line = get_line(self.config, "alpha") + target = line_repository_path(self.config, line, "api") + head = subprocess.run( + ["git", "-C", str(target), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + generation = publish_evidence_generation( + task_directory, + "attempt-1", + {"receipt.md": b"result: DONE\n"}, + ) + generation.chmod(0o700) + generation.joinpath("task-heads.json").write_text( + json.dumps( + { + "schema_version": 1, + "task_id": "TASK-A", + "line": "alpha", + "branch": "task/TASK-A", + "repositories": {"api": head}, + } + ), + encoding="utf-8", + ) + + detailed = self._read_json("objective", "status", "release") + + self.assertEqual(detailed["objective"]["derived_result"], "incomplete") + def test_objective_start_dry_run_has_zero_writes_and_lifecycle_commands_work( self, ) -> None: diff --git a/tests/test_hub.py b/tests/test_hub.py index 091bfd8..4423554 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -340,11 +340,11 @@ def test_home_new_feature_entrypoint_creates_confirmed_isolated_worktree( self, ) -> None: add_workspace(self.root, name="demo", make_default=True) - answers = iter(["3", "FEATURE-20260804", "", "yes"]) + answers = iter(["3", "FEATURE-20260804", "", ""]) output = StringIO() with ( patch("dyro.home.interactive_terminal", return_value=True), - patch("builtins.input", side_effect=lambda _: next(answers)), + patch("builtins.input", side_effect=lambda _: next(answers)) as input_mock, patch("dyro.home._choose_tool", return_value=None), redirect_stdout(output), ): @@ -357,6 +357,12 @@ def test_home_new_feature_entrypoint_creates_confirmed_isolated_worktree( self.assertIn("[2/3] 参与仓库", rendered) self.assertIn("main(工作区默认基线)(推荐)", rendered) self.assertIn("━━ 创建前确认 ━━", rendered) + self.assertTrue( + any( + "[Y/b/n;回车确认,b 返回基线]" in call.args[0] + for call in input_mock.call_args_list + ) + ) self.assertIn("已创建功能开发线:FEATURE-20260804", rendered) line = get_line(load(self.root), "FEATURE-20260804", "line") self.assertEqual(line.base, "main") diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 8612be2..0eae602 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path +import re import shutil import tempfile import unittest @@ -97,11 +98,87 @@ def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: } self.assertEqual(keys, {"name", "description"}) self.assertIn("name: dyro-control-plane", frontmatter) + self.assertIn("coding agent", frontmatter) + self.assertNotIn("from Codex", frontmatter) + for command in ( + "workspace list --format json", + "status --format json", + "doctor --format json", + "objective attention --format json", + "objective plan --format json", + ): + self.assertIn(command, content) + for forbidden_action in ("`console`", "`dispatch`", "`task gates`"): + self.assertIn(forbidden_action, content) + self.assertIn("skip global discovery", content) + self.assertIn("Never add `--include-paths`", content) + for private_pattern in ( + r"/Users/[^<\s]", + r"/home/[^<\s]", + r"[A-Za-z]:[\\\\/]+Users[\\\\/]", + r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", + r"session[_ -]?id", + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + ): + self.assertIsNone( + re.search(private_pattern, content, flags=re.IGNORECASE), + msg=private_pattern, + ) 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_integration_status_json_is_structured(self) -> None: + output = StringIO() + with redirect_stdout(output): + main(["integration", "status", "skill", "--format", "json"]) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload["schema_version"], 1) + self.assertEqual(payload["kind"], "integration_status") + self.assertEqual(payload["integration"], "skill") + self.assertEqual(payload["state"], "absent") + self.assertEqual(payload["avatars"][0]["host"], "codex") + self.assertEqual(payload["avatars"][0]["state"], "missing") + self.assertNotIn("target", payload) + self.assertNotIn("detail", payload) + self.assertNotIn("path", payload["avatars"][0]) + self.assertNotIn("detail", payload["avatars"][0]) + + output = StringIO() + with redirect_stdout(output): + main( + [ + "integration", + "status", + "skill", + "--format", + "json", + "--include-paths", + ] + ) + with_paths = json.loads(output.getvalue()) + self.assertEqual(with_paths["target"], str(self.mirror)) + self.assertEqual(with_paths["avatars"][0]["path"], str(self.avatar)) + + def test_integration_status_json_rejects_an_oversized_manifest(self) -> None: + install_integration("skill", yes=True) + self.manifest.write_bytes(b"{" + b"x" * (1024 * 1024)) + stdout = StringIO() + stderr = StringIO() + + with ( + redirect_stdout(stdout), + patch("sys.stderr", stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["integration", "status", "skill", "--format", "json"]) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(json.loads(stderr.getvalue())["code"], "FILE_TOO_LARGE") + def test_status_and_dry_run_are_strictly_zero_write(self) -> None: before = self._tree_snapshot() status = integration_status("skill") diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index d0da9ba..9d56c80 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -1,5 +1,5 @@ from dyro.config import load -from dyro.errors import ValidationError +from dyro.errors import DyroError, ValidationError from dyro.onboarding import ( RepositoryInput, SetupPlan, @@ -34,6 +34,24 @@ def test_bootstrap_clones_only_missing_anchor(self) -> None: self.assertTrue(any(message.startswith("CLONE api") for message in messages)) self.assertTrue((self.root / "repositories/cloned-api/.git").exists()) + def test_bootstrap_rejects_a_symlinked_destination_parent(self) -> None: + outside = self.root.parent / f"{self.root.name}-outside" + outside.mkdir() + (self.root / "escape").symlink_to(outside, target_is_directory=True) + profile = (self.root / "dyro.toml").read_text(encoding="utf-8") + profile = profile.replace( + 'path = "repositories/api"', 'path = "escape/api"' + ).replace( + 'mount = "services/api"', + f'mount = "services/api"\nremote = "{self.anchor}"', + ) + (self.root / "dyro.toml").write_text(profile, encoding="utf-8") + + with self.assertRaisesRegex(DyroError, "符号链接"): + bootstrap(load(self.root)) + + self.assertFalse((outside / "api").exists()) + def test_discover_repositories_uses_workspace_relative_paths(self) -> None: from .support import shell diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..081a6cd --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,76 @@ +from pathlib import Path +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +from dyro.process import Result, git_read, run +from dyro.read_limits import ( + ObservationLimits, + ReadBudget, + ReadLimitCode, + ReadLimitError, + bounded_directory_names, +) + + +class BoundedProcessTests(unittest.TestCase): + def test_directory_enumeration_stops_at_record_limit_without_listdir(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name in ("a", "b", "c"): + root.joinpath(name).touch() + descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + with ( + patch("dyro.read_limits.os.listdir", side_effect=AssertionError), + self.assertRaises(ReadLimitError) as raised, + ): + bounded_directory_names( + descriptor, + ReadBudget(ObservationLimits()), + maximum_records=2, + label="test", + ) + finally: + os.close(descriptor) + + self.assertIs( + raised.exception.code, + ReadLimitCode.RECORD_LIMIT_EXCEEDED, + ) + + def test_bounded_run_stops_when_output_exceeds_budget(self) -> None: + with self.assertRaises(ReadLimitError) as raised: + run( + (sys.executable, "-c", "print('x' * 4096)"), + timeout=2, + maximum_output_bytes=128, + ) + + self.assertIs( + raised.exception.code, + ReadLimitCode.AGGREGATE_BYTES_EXCEEDED, + ) + + def test_git_read_uses_remaining_deadline_and_charges_output(self) -> None: + budget = ReadBudget(ObservationLimits()) + with patch( + "dyro.process.run", + return_value=Result(("git",), 0, "ok\n", 3), + ) as observed_run: + result = git_read(Path("/tmp/repo"), "status", read_budget=budget) + + self.assertEqual(result.stdout, "ok\n") + self.assertEqual(budget.bytes_read, 3) + call = observed_run.call_args + self.assertLessEqual(call.kwargs["timeout"], 5.0) + self.assertEqual( + call.kwargs["maximum_output_bytes"], + 64 * 1024 * 1024, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 4c0ed74..979dada 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -40,3 +40,8 @@ def test_current_package_version_has_a_valid_changelog_entry(self) -> None: def test_release_tag_rejects_an_unreleased_changelog_entry(self) -> None: with self.assertRaisesRegex(AssertionError, "dated changelog"): _assert_release_changelog("0.5.3", "Unreleased", "v0.5.3") + + def test_source_distribution_excludes_generated_python_bytecode(self) -> None: + manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8") + + self.assertIn("global-exclude *.py[cod]", manifest.splitlines()) diff --git a/tests/test_workspace.py b/tests/test_workspace.py index f5ef8fd..9cad900 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -1,4 +1,5 @@ from pathlib import Path +import os from dyro.config import load from dyro.errors import DyroError @@ -9,12 +10,32 @@ line_repository_path, list_lines, preflight_line, + status_rows, ) from .support import WorkspaceCase, shell class WorkspaceTests(WorkspaceCase): + def test_control_plane_git_observations_do_not_refresh_the_index(self) -> None: + config = load(self.root) + tracked = self.anchor / "README.md" + tracked_stat = tracked.stat() + os.utime( + tracked, + ns=(tracked_stat.st_atime_ns, tracked_stat.st_mtime_ns + 2_000_000_000), + ) + index = self.anchor / ".git/index" + before_bytes = index.read_bytes() + before_mtime = index.stat().st_mtime_ns + + status_rows(config) + doctor(config) + + self.assertEqual(index.read_bytes(), before_bytes) + self.assertEqual(index.stat().st_mtime_ns, before_mtime) + self.assertFalse(index.with_name("index.lock").exists()) + def test_create_line_and_dynamic_doctor(self) -> None: config = load(self.root) line = create_line(config, line_id="alpha", branch="feat/alpha", base="main")