From da500caea46ad0d7c71df72b048f37f5f7eaf8e1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 11:29:36 +0000 Subject: [PATCH 1/5] fix(configuration): provision the GitHub origin source binding --- SECURITY.md | 8 +- .../fixtures/fork_head_pull_request.json | 488 +++++++++++++- .../src/advisory/github_runtime.rs | 8 +- .../src/advisory/github_runtime/access.rs | 73 +- .../src/advisory/github_runtime/discovery.rs | 632 +++++++++++++++--- .../src/advisory/github_runtime/gh_cli.rs | 116 +++- .../src/advisory/github_runtime/network.rs | 4 + .../github_runtime/network/test_support.rs | 21 + crates/tracedecay-application/src/delivery.rs | 16 +- crates/tracedecay-cli/src/status_cmd.rs | 50 ++ .../src/configuration/operations.rs | 82 +++ .../src/delivery_api.rs | 17 +- crates/tracedecay-domain/src/configuration.rs | 7 +- .../domain_suite/configuration_contract.rs | 64 +- .../src/configuration/store.rs | 70 +- .../src/configuration/store/control.rs | 12 +- .../src/configuration/store/mutation.rs | 26 +- .../src/handlers/info/status.rs | 8 + crates/tracedecay-project/src/config.rs | 75 ++- crates/tracedecay-project/src/config/tests.rs | 106 ++- .../advisory_cycle_language_journey_test.rs | 2 +- ...guration_protected_preview_journey_test.rs | 109 ++- .../delivery_read_gate_journey_test.rs | 24 +- .../src/daemon/project_open_owners.rs | 47 +- .../project_open_owners/advisory_runtime.rs | 30 +- docs/USER-GUIDE.md | 21 + 26 files changed, 1831 insertions(+), 285 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4afba2b9ee..3d300bda0a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,7 +61,7 @@ Outbound connections are limited to: | Destination | Purpose | Auth | Failure mode | |-------------|---------|------|-------------| -| `api.github.com` | Check for releases and, for explicitly configured review sources, verify and perform repository reads | Public requests by default; optional read-only credential from the OS keyring | Public checks are best effort; configured private access fails closed when credentials or permissions cannot be verified | +| `api.github.com` | Check for releases, discover the pull request for a checkout whose `origin` is on GitHub, and read its reviews and checks | The local GitHub login (`GH_TOKEN`, `gh auth token`, or the git credential helper) when present, anonymous otherwise; optional read-only credential from the OS keyring for configured private sources | Public checks are best effort; an anonymous read GitHub refuses is reported as `denied_no_credential`; configured private access fails closed when credentials or permissions cannot be verified | | `github.com` | Download binary during `tracedecay upgrade` | None (public releases) | Error shown to user | | `huggingface.co` and Hugging Face artifact hosts | Download missing, revision-pinned semantic-model artifacts when semantic auto-download is enabled | None | Semantic retrieval reports model acquisition state or failure; exact, lexical, and graph retrieval remain available | | `tracedecay-counter.enzinol.workers.dev` | Aggregate token-savings counter | None | Silently ignored | @@ -78,7 +78,11 @@ SHA-256 digests before publication, and can be disabled with `HF_HUB_OFFLINE`. ### Credentials and secrets TraceDecay does not require credentials for its default local and public -repository behavior. A user may explicitly configure a private GitHub review +repository behavior. When `GH_TOKEN`, a `gh` login, or a git credential helper +login for `https://github.com` exists, GitHub reads for the checkout's `origin` +use it; the token is read into zeroizing memory per use and never stored. The +git credential helper is asked only for `protocol=https`/`host=github.com`, with +terminal prompts disabled. A user may explicitly configure a private GitHub review source with `access = "os_keyring"` and keyring service/account locators. The secret remains in the operating-system keyring; configuration stores only its locator. The daemon reads it into zeroizing memory, sends it only to GitHub diff --git a/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json b/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json index af0f16bfe5..4549de59ce 100644 --- a/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json +++ b/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json @@ -2,7 +2,7 @@ "capture": { "captured_at": "2026-09-26T01:40:00Z", "pull_request": "https://github.com/dtolnay/anyhow/pull/463", - "note": "Fork-headed pull request: head sb123sb123/anyhow fix/462-new-with-backtrace, base dtolnay/anyhow master." + "note": "Fork-headed pull request: head sb123sb123/anyhow fix/462-new-with-backtrace, base dtolnay/anyhow master. The REST head-ref search, the pull request read, and the GraphQL refusal were captured without a credential." }, "rest_commit_pulls": { "url": "https://api.github.com/repos/dtolnay/anyhow/commits/c7b210a78b32ea90b10860c58983f8c0a742ed03/pulls", @@ -46,5 +46,491 @@ } } } + }, + "rest_head_ref_search": { + "url": "https://api.github.com/search/issues?q=repo:dtolnay/anyhow+is:pr+head:fix/462-new-with-backtrace&per_page=100", + "captured_at": "2026-09-26T10:05:00Z", + "authentication": "none", + "response": { + "total_count": 1, + "incomplete_results": false, + "items": [ + { + "url": "https://api.github.com/repos/dtolnay/anyhow/issues/463", + "repository_url": "https://api.github.com/repos/dtolnay/anyhow", + "labels_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/labels{/name}", + "comments_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/comments", + "events_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/events", + "html_url": "https://github.com/dtolnay/anyhow/pull/463", + "id": 5534236211, + "node_id": "PR_kwDODLEmts8AAAABEgnLPg", + "number": 463, + "title": "Add Error::new_with_backtrace", + "user": { + "login": "sb123sb123", + "id": 152394158, + "node_id": "U_kgDOCRVZrg", + "avatar_url": "https://avatars.githubusercontent.com/u/152394158?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/sb123sb123", + "html_url": "https://github.com/sb123sb123", + "followers_url": "https://api.github.com/users/sb123sb123/followers", + "following_url": "https://api.github.com/users/sb123sb123/following{/other_user}", + "gists_url": "https://api.github.com/users/sb123sb123/gists{/gist_id}", + "starred_url": "https://api.github.com/users/sb123sb123/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/sb123sb123/subscriptions", + "organizations_url": "https://api.github.com/users/sb123sb123/orgs", + "repos_url": "https://api.github.com/users/sb123sb123/repos", + "events_url": "https://api.github.com/users/sb123sb123/events{/privacy}", + "received_events_url": "https://api.github.com/users/sb123sb123/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [], + "state": "open", + "locked": false, + "assignees": [], + "milestone": null, + "comments": 0, + "created_at": "2026-09-22T01:26:35Z", + "updated_at": "2026-09-22T01:26:35Z", + "closed_at": null, + "assignee": null, + "author_association": "NONE", + "active_lock_reason": null, + "draft": false, + "pull_request": { + "url": "https://api.github.com/repos/dtolnay/anyhow/pulls/463", + "html_url": "https://github.com/dtolnay/anyhow/pull/463", + "diff_url": "https://github.com/dtolnay/anyhow/pull/463.diff", + "patch_url": "https://github.com/dtolnay/anyhow/pull/463.patch", + "merged_at": null + }, + "body": "Closes https://github.com/dtolnay/anyhow/issues/462\n\n## Summary\n\n`Error::new` captures or discovers a backtrace while constructing the error, but stable Rust does not provide a way to attach a backtrace that was captured earlier. This is needed by callers that capture a backtrace before constructing their anyhow error.\n\nThis adds `Error::new_with_backtrace(error, backtrace)` under the existing `std` feature. It stores the supplied backtrace through the existing error construction path, preserving the existing error chain and default `Error::new` behavior. A regression test verifies that the supplied backtrace is retained.\n\n## Tests\n\n- `cargo fmt --all -- --check`\n- `cargo test` (including 26 doctests)\n- `cargo clippy --all-targets -- -D warnings`\n- `cargo doc --no-deps`\n- `cargo check --all-features`\n- `cargo check --no-default-features`\n- `cargo +nightly test --test test_backtrace -- --nocapture`\n- `cargo +nightly check --no-default-features`\n\nAI assistance: OpenAI Codex (GPT-5.6 Luna)\n", + "reactions": { + "url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/timeline", + "performed_via_github_app": null, + "state_reason": null, + "score": 1.0 + } + ], + "search_type": "lexical" + } + }, + "rest_pull_request": { + "url": "https://api.github.com/repos/dtolnay/anyhow/pulls/463", + "captured_at": "2026-09-26T10:05:00Z", + "authentication": "none", + "response": { + "url": "https://api.github.com/repos/dtolnay/anyhow/pulls/463", + "id": 4597599038, + "node_id": "PR_kwDODLEmts8AAAABEgnLPg", + "html_url": "https://github.com/dtolnay/anyhow/pull/463", + "diff_url": "https://github.com/dtolnay/anyhow/pull/463.diff", + "patch_url": "https://github.com/dtolnay/anyhow/pull/463.patch", + "issue_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463", + "commits_url": "https://api.github.com/repos/dtolnay/anyhow/pulls/463/commits", + "review_comments_url": "https://api.github.com/repos/dtolnay/anyhow/pulls/463/comments", + "review_comment_url": "https://api.github.com/repos/dtolnay/anyhow/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/dtolnay/anyhow/issues/463/comments", + "statuses_url": "https://api.github.com/repos/dtolnay/anyhow/statuses/c7b210a78b32ea90b10860c58983f8c0a742ed03", + "number": 463, + "state": "open", + "locked": false, + "title": "Add Error::new_with_backtrace", + "user": { + "login": "sb123sb123", + "id": 152394158, + "node_id": "U_kgDOCRVZrg", + "avatar_url": "https://avatars.githubusercontent.com/u/152394158?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/sb123sb123", + "html_url": "https://github.com/sb123sb123", + "followers_url": "https://api.github.com/users/sb123sb123/followers", + "following_url": "https://api.github.com/users/sb123sb123/following{/other_user}", + "gists_url": "https://api.github.com/users/sb123sb123/gists{/gist_id}", + "starred_url": "https://api.github.com/users/sb123sb123/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/sb123sb123/subscriptions", + "organizations_url": "https://api.github.com/users/sb123sb123/orgs", + "repos_url": "https://api.github.com/users/sb123sb123/repos", + "events_url": "https://api.github.com/users/sb123sb123/events{/privacy}", + "received_events_url": "https://api.github.com/users/sb123sb123/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": "Closes https://github.com/dtolnay/anyhow/issues/462\n\n## Summary\n\n`Error::new` captures or discovers a backtrace while constructing the error, but stable Rust does not provide a way to attach a backtrace that was captured earlier. This is needed by callers that capture a backtrace before constructing their anyhow error.\n\nThis adds `Error::new_with_backtrace(error, backtrace)` under the existing `std` feature. It stores the supplied backtrace through the existing error construction path, preserving the existing error chain and default `Error::new` behavior. A regression test verifies that the supplied backtrace is retained.\n\n## Tests\n\n- `cargo fmt --all -- --check`\n- `cargo test` (including 26 doctests)\n- `cargo clippy --all-targets -- -D warnings`\n- `cargo doc --no-deps`\n- `cargo check --all-features`\n- `cargo check --no-default-features`\n- `cargo +nightly test --test test_backtrace -- --nocapture`\n- `cargo +nightly check --no-default-features`\n\nAI assistance: OpenAI Codex (GPT-5.6 Luna)\n", + "labels": [], + "milestone": null, + "active_lock_reason": null, + "created_at": "2026-09-22T01:26:35Z", + "updated_at": "2026-09-22T01:26:35Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": "16ea46ead30f375e9f1d6fe27177b38646281098", + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_teams": [], + "head": { + "label": "sb123sb123:fix/462-new-with-backtrace", + "ref": "fix/462-new-with-backtrace", + "repo": { + "id": 1380714535, + "node_id": "R_kgDOUkwIJw", + "name": "anyhow", + "full_name": "sb123sb123/anyhow", + "owner": { + "login": "sb123sb123", + "id": 152394158, + "node_id": "U_kgDOCRVZrg", + "avatar_url": "https://avatars.githubusercontent.com/u/152394158?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/sb123sb123", + "html_url": "https://github.com/sb123sb123", + "followers_url": "https://api.github.com/users/sb123sb123/followers", + "following_url": "https://api.github.com/users/sb123sb123/following{/other_user}", + "gists_url": "https://api.github.com/users/sb123sb123/gists{/gist_id}", + "starred_url": "https://api.github.com/users/sb123sb123/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/sb123sb123/subscriptions", + "organizations_url": "https://api.github.com/users/sb123sb123/orgs", + "repos_url": "https://api.github.com/users/sb123sb123/repos", + "events_url": "https://api.github.com/users/sb123sb123/events{/privacy}", + "received_events_url": "https://api.github.com/users/sb123sb123/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "private": false, + "fork": true, + "archived": false, + "disabled": false, + "description": "Flexible concrete Error type built on std::error::Error", + "homepage": "", + "language": null, + "visibility": "public", + "is_template": false, + "topics": [], + "forks_count": 0, + "forks": 0, + "stargazers_count": 0, + "watchers_count": 0, + "watchers": 0, + "open_issues_count": 0, + "open_issues": 0, + "size": 1110, + "default_branch": "master", + "has_issues": false, + "has_projects": true, + "has_wiki": false, + "has_pages": false, + "has_downloads": false, + "has_discussions": false, + "has_pull_requests": true, + "pushed_at": "2026-09-22T01:26:05Z", + "created_at": "2026-09-22T01:21:04Z", + "updated_at": "2026-09-22T01:21:05Z", + "allow_forking": true, + "web_commit_signoff_required": false, + "pull_request_creation_policy": "all", + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "spdx_id": "Apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "url": "https://api.github.com/repos/sb123sb123/anyhow", + "html_url": "https://github.com/sb123sb123/anyhow", + "archive_url": "https://api.github.com/repos/sb123sb123/anyhow/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/sb123sb123/anyhow/assignees{/user}", + "blobs_url": "https://api.github.com/repos/sb123sb123/anyhow/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/sb123sb123/anyhow/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/sb123sb123/anyhow/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/sb123sb123/anyhow/comments{/number}", + "commits_url": "https://api.github.com/repos/sb123sb123/anyhow/commits{/sha}", + "compare_url": "https://api.github.com/repos/sb123sb123/anyhow/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/sb123sb123/anyhow/contents/{+path}", + "contributors_url": "https://api.github.com/repos/sb123sb123/anyhow/contributors", + "deployments_url": "https://api.github.com/repos/sb123sb123/anyhow/deployments", + "downloads_url": "https://api.github.com/repos/sb123sb123/anyhow/downloads", + "events_url": "https://api.github.com/repos/sb123sb123/anyhow/events", + "forks_url": "https://api.github.com/repos/sb123sb123/anyhow/forks", + "git_commits_url": "https://api.github.com/repos/sb123sb123/anyhow/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/sb123sb123/anyhow/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/sb123sb123/anyhow/git/tags{/sha}", + "git_url": "git://github.com/sb123sb123/anyhow.git", + "issue_comment_url": "https://api.github.com/repos/sb123sb123/anyhow/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/sb123sb123/anyhow/issues/events{/number}", + "issues_url": "https://api.github.com/repos/sb123sb123/anyhow/issues{/number}", + "keys_url": "https://api.github.com/repos/sb123sb123/anyhow/keys{/key_id}", + "labels_url": "https://api.github.com/repos/sb123sb123/anyhow/labels{/name}", + "languages_url": "https://api.github.com/repos/sb123sb123/anyhow/languages", + "merges_url": "https://api.github.com/repos/sb123sb123/anyhow/merges", + "milestones_url": "https://api.github.com/repos/sb123sb123/anyhow/milestones{/number}", + "notifications_url": "https://api.github.com/repos/sb123sb123/anyhow/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/sb123sb123/anyhow/pulls{/number}", + "releases_url": "https://api.github.com/repos/sb123sb123/anyhow/releases{/id}", + "ssh_url": "git@github.com:sb123sb123/anyhow.git", + "stargazers_url": "https://api.github.com/repos/sb123sb123/anyhow/stargazers", + "statuses_url": "https://api.github.com/repos/sb123sb123/anyhow/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/sb123sb123/anyhow/subscribers", + "subscription_url": "https://api.github.com/repos/sb123sb123/anyhow/subscription", + "tags_url": "https://api.github.com/repos/sb123sb123/anyhow/tags", + "teams_url": "https://api.github.com/repos/sb123sb123/anyhow/teams", + "trees_url": "https://api.github.com/repos/sb123sb123/anyhow/git/trees{/sha}", + "clone_url": "https://github.com/sb123sb123/anyhow.git", + "mirror_url": null, + "hooks_url": "https://api.github.com/repos/sb123sb123/anyhow/hooks", + "svn_url": "https://github.com/sb123sb123/anyhow" + }, + "sha": "c7b210a78b32ea90b10860c58983f8c0a742ed03", + "user": { + "login": "sb123sb123", + "id": 152394158, + "node_id": "U_kgDOCRVZrg", + "avatar_url": "https://avatars.githubusercontent.com/u/152394158?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/sb123sb123", + "html_url": "https://github.com/sb123sb123", + "followers_url": "https://api.github.com/users/sb123sb123/followers", + "following_url": "https://api.github.com/users/sb123sb123/following{/other_user}", + "gists_url": "https://api.github.com/users/sb123sb123/gists{/gist_id}", + "starred_url": "https://api.github.com/users/sb123sb123/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/sb123sb123/subscriptions", + "organizations_url": "https://api.github.com/users/sb123sb123/orgs", + "repos_url": "https://api.github.com/users/sb123sb123/repos", + "events_url": "https://api.github.com/users/sb123sb123/events{/privacy}", + "received_events_url": "https://api.github.com/users/sb123sb123/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } + }, + "base": { + "label": "dtolnay:master", + "ref": "master", + "repo": { + "id": 212936374, + "node_id": "MDEwOlJlcG9zaXRvcnkyMTI5MzYzNzQ=", + "name": "anyhow", + "full_name": "dtolnay/anyhow", + "owner": { + "login": "dtolnay", + "id": 1940490, + "node_id": "MDQ6VXNlcjE5NDA0OTA=", + "avatar_url": "https://avatars.githubusercontent.com/u/1940490?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/dtolnay", + "html_url": "https://github.com/dtolnay", + "followers_url": "https://api.github.com/users/dtolnay/followers", + "following_url": "https://api.github.com/users/dtolnay/following{/other_user}", + "gists_url": "https://api.github.com/users/dtolnay/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dtolnay/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dtolnay/subscriptions", + "organizations_url": "https://api.github.com/users/dtolnay/orgs", + "repos_url": "https://api.github.com/users/dtolnay/repos", + "events_url": "https://api.github.com/users/dtolnay/events{/privacy}", + "received_events_url": "https://api.github.com/users/dtolnay/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "private": false, + "fork": false, + "archived": false, + "disabled": false, + "description": "Flexible concrete Error type built on std::error::Error", + "homepage": "", + "language": "Rust", + "visibility": "public", + "is_template": false, + "topics": [], + "forks_count": 225, + "forks": 225, + "stargazers_count": 6658, + "watchers_count": 6658, + "watchers": 6658, + "open_issues_count": 46, + "open_issues": 46, + "size": 1126, + "default_branch": "master", + "has_issues": true, + "has_projects": false, + "has_wiki": false, + "has_pages": false, + "has_downloads": false, + "has_discussions": false, + "has_pull_requests": true, + "pushed_at": "2026-08-22T02:43:42Z", + "created_at": "2019-10-05T03:04:29Z", + "updated_at": "2026-09-26T08:48:50Z", + "allow_forking": true, + "web_commit_signoff_required": false, + "pull_request_creation_policy": "all", + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "spdx_id": "Apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "url": "https://api.github.com/repos/dtolnay/anyhow", + "html_url": "https://github.com/dtolnay/anyhow", + "archive_url": "https://api.github.com/repos/dtolnay/anyhow/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/dtolnay/anyhow/assignees{/user}", + "blobs_url": "https://api.github.com/repos/dtolnay/anyhow/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/dtolnay/anyhow/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/dtolnay/anyhow/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/dtolnay/anyhow/comments{/number}", + "commits_url": "https://api.github.com/repos/dtolnay/anyhow/commits{/sha}", + "compare_url": "https://api.github.com/repos/dtolnay/anyhow/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/dtolnay/anyhow/contents/{+path}", + "contributors_url": "https://api.github.com/repos/dtolnay/anyhow/contributors", + "deployments_url": "https://api.github.com/repos/dtolnay/anyhow/deployments", + "downloads_url": "https://api.github.com/repos/dtolnay/anyhow/downloads", + "events_url": "https://api.github.com/repos/dtolnay/anyhow/events", + "forks_url": "https://api.github.com/repos/dtolnay/anyhow/forks", + "git_commits_url": "https://api.github.com/repos/dtolnay/anyhow/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/dtolnay/anyhow/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/dtolnay/anyhow/git/tags{/sha}", + "git_url": "git://github.com/dtolnay/anyhow.git", + "issue_comment_url": "https://api.github.com/repos/dtolnay/anyhow/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/dtolnay/anyhow/issues/events{/number}", + "issues_url": "https://api.github.com/repos/dtolnay/anyhow/issues{/number}", + "keys_url": "https://api.github.com/repos/dtolnay/anyhow/keys{/key_id}", + "labels_url": "https://api.github.com/repos/dtolnay/anyhow/labels{/name}", + "languages_url": "https://api.github.com/repos/dtolnay/anyhow/languages", + "merges_url": "https://api.github.com/repos/dtolnay/anyhow/merges", + "milestones_url": "https://api.github.com/repos/dtolnay/anyhow/milestones{/number}", + "notifications_url": "https://api.github.com/repos/dtolnay/anyhow/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/dtolnay/anyhow/pulls{/number}", + "releases_url": "https://api.github.com/repos/dtolnay/anyhow/releases{/id}", + "ssh_url": "git@github.com:dtolnay/anyhow.git", + "stargazers_url": "https://api.github.com/repos/dtolnay/anyhow/stargazers", + "statuses_url": "https://api.github.com/repos/dtolnay/anyhow/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/dtolnay/anyhow/subscribers", + "subscription_url": "https://api.github.com/repos/dtolnay/anyhow/subscription", + "tags_url": "https://api.github.com/repos/dtolnay/anyhow/tags", + "teams_url": "https://api.github.com/repos/dtolnay/anyhow/teams", + "trees_url": "https://api.github.com/repos/dtolnay/anyhow/git/trees{/sha}", + "clone_url": "https://github.com/dtolnay/anyhow.git", + "mirror_url": null, + "hooks_url": "https://api.github.com/repos/dtolnay/anyhow/hooks", + "svn_url": "https://github.com/dtolnay/anyhow" + }, + "sha": "c63b279f3f4af2b02ca6267d9eb47d6d10497f69", + "user": { + "login": "dtolnay", + "id": 1940490, + "node_id": "MDQ6VXNlcjE5NDA0OTA=", + "avatar_url": "https://avatars.githubusercontent.com/u/1940490?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/dtolnay", + "html_url": "https://github.com/dtolnay", + "followers_url": "https://api.github.com/users/dtolnay/followers", + "following_url": "https://api.github.com/users/dtolnay/following{/other_user}", + "gists_url": "https://api.github.com/users/dtolnay/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dtolnay/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dtolnay/subscriptions", + "organizations_url": "https://api.github.com/users/dtolnay/orgs", + "repos_url": "https://api.github.com/users/dtolnay/repos", + "events_url": "https://api.github.com/users/dtolnay/events{/privacy}", + "received_events_url": "https://api.github.com/users/dtolnay/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } + }, + "_links": { + "comments": { + "href": "https://api.github.com/repos/dtolnay/anyhow/issues/463/comments" + }, + "commits": { + "href": "https://api.github.com/repos/dtolnay/anyhow/pulls/463/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/dtolnay/anyhow/statuses/c7b210a78b32ea90b10860c58983f8c0a742ed03" + }, + "html": { + "href": "https://github.com/dtolnay/anyhow/pull/463" + }, + "issue": { + "href": "https://api.github.com/repos/dtolnay/anyhow/issues/463" + }, + "review_comments": { + "href": "https://api.github.com/repos/dtolnay/anyhow/pulls/463/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/dtolnay/anyhow/pulls/comments{/number}" + }, + "self": { + "href": "https://api.github.com/repos/dtolnay/anyhow/pulls/463" + } + }, + "author_association": "NONE", + "auto_merge": null, + "draft": false, + "merged": false, + "mergeable": true, + "rebaseable": true, + "mergeable_state": "unstable", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": true, + "commits": 1, + "additions": 29, + "deletions": 0, + "changed_files": 2 + } + }, + "graphql_anonymous": { + "url": "https://api.github.com/graphql", + "captured_at": "2026-09-26T10:06:00Z", + "authentication": "none", + "status": 403, + "headers": { + "x-ratelimit-limit": "0", + "x-ratelimit-remaining": "0", + "x-ratelimit-used": "0", + "x-ratelimit-resource": "graphql", + "x-ratelimit-reset": "1790422033" + }, + "response": { + "message": "API rate limit exceeded for 208.69.79.206. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", + "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" + } + }, + "rest_head_ref_search_unseen_repository": { + "url": "https://api.github.com/search/issues?q=repo:tracedecay-fixture/private-origin+is:pr+head:main&per_page=100", + "captured_at": "2026-09-26T10:30:00Z", + "authentication": "none", + "status": 422, + "response": { + "message": "Validation Failed", + "errors": [ + { + "message": "The listed users and repositories cannot be searched either because the resources do not exist or you do not have permission to view them.", + "resource": "Search", + "field": "q", + "code": "invalid" + } + ], + "documentation_url": "https://docs.github.com/v3/search/", + "status": "422" + } } } diff --git a/crates/tracedecay-application/src/advisory/github_runtime.rs b/crates/tracedecay-application/src/advisory/github_runtime.rs index 84d03f9f1b..13c6b303dd 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime.rs @@ -43,7 +43,10 @@ use tracedecay_domain::{ManifestDigest, canonical_sha256}; use super::{GitHubReadOnlyTransport, GitHubRestDescriptorV1, context_allows_feedback_operation}; -pub use access::ConfiguredGitHubSourceAccessAuthorityV1; +pub use access::{ + ConfiguredGitHubSourceAccessAuthorityV1, DAEMON_GITHUB_ORIGIN_SOURCE_BINDING_ID, + daemon_owned_github_source_binding_v1, github_repository_from_remote_v1, +}; pub use anchors::{ GitHubReviewBodyEvidenceAuthorityV1, GitHubReviewBodyEvidenceV1, GitHubReviewBodyReadOutcomeV1, ProjectGitHubAnchorAuthorityV1, ProjectGitHubRegistrarAuthoritiesV1, @@ -60,7 +63,8 @@ pub use decoder::{ }; pub use discovery::{ GitHubDiscoveryControlV1, GitHubExactCommitDiscoveryOutcomeV1, GitHubExactCommitPullRequestV1, - discover_exact_commit_pull_request_v1, + GitHubPullRequestDiscoveryKindV1, GitHubSourceStateV1, GitHubSourceStatusV1, + discover_exact_commit_pull_request_v1, github_source_status_v1, record_github_source_status_v1, }; pub use dto::{ GitHubActionsCheckRunOutputV1, GitHubActionsCheckRunV1, GitHubActionsCheckSuiteRefV1, diff --git a/crates/tracedecay-application/src/advisory/github_runtime/access.rs b/crates/tracedecay-application/src/advisory/github_runtime/access.rs index 30604a1385..61a74f66db 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/access.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/access.rs @@ -3,10 +3,13 @@ use tracedecay_contracts::feedback::{ GITHUB_REVIEW_INGEST_CAPABILITY_ID_V1, GitHubReviewReadRequestV1, feedback_surface_operation, }; use tracedecay_contracts::{AuthorizationRequest, ResolvedScope, now_micros}; -use tracedecay_domain::configuration::SourceKindV1; -use tracedecay_domain::{LocatorDigest, canonical_sha256}; +use tracedecay_domain::configuration::{ + AuthorityRef, ScopeSourceBinding, SourceBindingId, SourceKindV1, +}; +use tracedecay_domain::feedback::GitHubPullRequestIdV1; +use tracedecay_domain::{LocatorDigest, ProjectId, canonical_sha256}; -use super::{GitHubProviderLifecycleV1, GitHubSourceAccessAuthorityV1}; +use super::{GitHubProviderLifecycleV1, GitHubRepositoryTargetV1, GitHubSourceAccessAuthorityV1}; use crate::advisory::ci_runtime::{CiSourceAccessAuthorityV1, CiSourceAccessOutcomeV1}; use crate::source_authorization::{ ProjectSourceAccessOutcome, project_source_access_snapshot_for_request, @@ -151,6 +154,10 @@ where } } +/// Identifier of the one daemon-owned GitHub binding derived from a +/// checkout's `origin` remote. +pub const DAEMON_GITHUB_ORIGIN_SOURCE_BINDING_ID: &str = "binding.tracedecay-daemon.github-origin"; + fn github_source_locator(repository_owner: &str, repository_name: &str) -> Option { if repository_owner.is_empty() || repository_name.is_empty() { return None; @@ -163,3 +170,63 @@ fn github_source_locator(repository_owner: &str, repository_name: &str) -> Optio .ok()?; LocatorDigest::new(digest.as_str()).ok() } + +/// The daemon-owned GitHub source binding for `owner/repository`, the +/// repository GitHub reads for this project are authorized against. +pub fn daemon_owned_github_source_binding_v1( + project_id: &ProjectId, + repository_owner: &str, + repository_name: &str, +) -> Option { + ScopeSourceBinding::new( + SourceBindingId::new(DAEMON_GITHUB_ORIGIN_SOURCE_BINDING_ID).ok()?, + SourceKindV1::GitHub, + github_source_locator(repository_owner, repository_name)?, + AuthorityRef::Project(project_id.clone()), + ) + .ok() +} + +/// `(owner, repository)` of a `github.com` remote URL, or `None` for any +/// other host, credential-bearing URL, or path shape. +pub fn github_repository_from_remote_v1(remote: &str) -> Option<(String, String)> { + let (owner, repository) = if let Ok(url) = url::Url::parse(remote) { + if (url.scheme() != "https" && url.scheme() != "ssh") + || !url.host_str()?.eq_ignore_ascii_case("github.com") + || url.password().is_some() + || (url.scheme() == "https" && !url.username().is_empty()) + || (url.scheme() == "ssh" && url.username() != "git") + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let segments = url.path_segments()?.collect::>(); + if segments.len() != 2 { + return None; + } + (segments[0].to_owned(), segments[1].to_owned()) + } else { + let remote = remote.strip_prefix("git@github.com:")?; + let mut segments = remote.split('/'); + let owner = segments.next()?; + let repository = segments.next()?; + if segments.next().is_some() { + return None; + } + (owner.to_owned(), repository.to_owned()) + }; + let repository = repository + .strip_suffix(".git") + .unwrap_or(&repository) + .to_owned(); + let target = GitHubRepositoryTargetV1 { + owner, + repository, + pull_request_number: 1, + pull_request_id: GitHubPullRequestIdV1::new("1").ok()?, + }; + target + .validate() + .then_some((target.owner, target.repository)) +} diff --git a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs index 202afd0f82..bc2ad3fecf 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs @@ -1,8 +1,10 @@ -use std::sync::Arc; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use tracedecay_domain::feedback::{GitHubPullRequestIdV1, GitHubReviewRateLimitCheckpointV1}; use tracedecay_domain::{CommitId, UtcMicros}; @@ -17,6 +19,11 @@ use super::{ const GITHUB_DISCOVERY_PAGE_SIZE_V1: usize = 100; const MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1: usize = 1024 * 1024; +// ponytail: each anonymous candidate costs one of the 60 hourly anonymous +// requests, so a head-branch name shared by more open pull requests than this +// (a popular fork's `patch-1`) is Unavailable rather than scanned; a +// credential lifts the bound through the GraphQL route. +const MAX_ANONYMOUS_HEAD_REF_CANDIDATES_V1: usize = 10; /// Pull requests of the checkout's repository whose head branch has the /// checkout's branch name, wherever that head lives. GitHub's @@ -159,13 +166,159 @@ struct HeadRefBaseRepositoryV1 { owner: HeadRefLoginV1, } +/// How this project's GitHub source is read. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GitHubSourceStateV1 { + /// A credential authorizes the reads. + Bound, + /// No credential is available; the repository is read anonymously as a + /// public repository. + UnauthenticatedPublic, + /// No credential is available and GitHub refused the anonymous read, so + /// the repository is private or absent. + DeniedNoCredential, +} + +impl GitHubSourceStateV1 { + /// The state a discovery with `credential` settled in. `None` is a + /// discovery that was not attempted. + pub fn observed( + credential: &GitHubReadOnlyCredentialV1, + discovery: Option<&GitHubExactCommitDiscoveryOutcomeV1>, + ) -> Self { + if !credential.is_anonymous() { + Self::Bound + } else if discovery == Some(&GitHubExactCommitDiscoveryOutcomeV1::Denied) { + Self::DeniedNoCredential + } else { + Self::UnauthenticatedPublic + } + } + + /// What the operator does to reach [`Self::Bound`], if anything. + pub const fn remedy(self) -> Option<&'static str> { + match self { + Self::Bound => None, + Self::UnauthenticatedPublic => Some( + "reads are anonymous (60 requests/hour); run `gh auth login` or set GH_TOKEN to read with a credential", + ), + Self::DeniedNoCredential => Some( + "GitHub refused an anonymous read of this repository; run `gh auth login` or set GH_TOKEN with read access, then reopen the project", + ), + } + } +} + +/// Pull-request discovery outcome for the checkout's exact head. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GitHubPullRequestDiscoveryKindV1 { + Found, + NotFound, + Ambiguous, + RateLimited, + Denied, + Unavailable, + /// GitHub source access was not granted for this scope. + NotAttempted, +} + +/// The GitHub source of one project as status reports it. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GitHubSourceStatusV1 { + /// `owner/name` of the checkout's `origin` remote. + pub repository: String, + pub state: GitHubSourceStateV1, + pub remedy: Option, + pub pull_request_discovery: GitHubPullRequestDiscoveryKindV1, + pub pull_request: Option, + /// `owner/name` the discovered pull request's head lives in. + pub head_repository: Option, +} + +impl GitHubSourceStatusV1 { + pub fn observed( + repository_owner: &str, + repository_name: &str, + credential: &GitHubReadOnlyCredentialV1, + discovery: Option<&GitHubExactCommitDiscoveryOutcomeV1>, + ) -> Self { + let state = GitHubSourceStateV1::observed(credential, discovery); + let (kind, found) = match discovery { + None => (GitHubPullRequestDiscoveryKindV1::NotAttempted, None), + Some(GitHubExactCommitDiscoveryOutcomeV1::Found(pull)) => { + (GitHubPullRequestDiscoveryKindV1::Found, Some(pull)) + } + Some(GitHubExactCommitDiscoveryOutcomeV1::NotFound) => { + (GitHubPullRequestDiscoveryKindV1::NotFound, None) + } + Some(GitHubExactCommitDiscoveryOutcomeV1::Ambiguous) => { + (GitHubPullRequestDiscoveryKindV1::Ambiguous, None) + } + Some(GitHubExactCommitDiscoveryOutcomeV1::RateLimited { .. }) => { + (GitHubPullRequestDiscoveryKindV1::RateLimited, None) + } + Some(GitHubExactCommitDiscoveryOutcomeV1::Denied) => { + (GitHubPullRequestDiscoveryKindV1::Denied, None) + } + Some(GitHubExactCommitDiscoveryOutcomeV1::Unavailable) => { + (GitHubPullRequestDiscoveryKindV1::Unavailable, None) + } + }; + Self { + repository: format!("{repository_owner}/{repository_name}"), + state, + remedy: state.remedy().map(str::to_owned), + pull_request_discovery: kind, + pull_request: found.map(|pull| pull.target.pull_request_number), + head_repository: found.map(|pull| { + format!( + "{}/{}", + pull.head_repository_owner, pull.head_repository_name + ) + }), + } + } +} + +type GitHubSourceStatusRegistryV1 = Mutex>; + +fn github_source_status_registry_v1() -> &'static GitHubSourceStatusRegistryV1 { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +/// Retains the latest GitHub source observation of the project at +/// `project_root`, replacing the one a previous open recorded. +pub fn record_github_source_status_v1(project_root: &Path, status: GitHubSourceStatusV1) { + github_source_status_registry_v1() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(project_root.to_path_buf(), status); +} + +/// The GitHub source observation the project at `project_root` last +/// recorded. `None` means none was observed in this daemon: the checkout has +/// no GitHub `origin`, or its advisory owner has not mounted yet. +pub fn github_source_status_v1(project_root: &Path) -> Option { + github_source_status_registry_v1() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(project_root) + .cloned() +} + /// Discovers the unique pull request filed against `owner/repository` whose /// head branch is `head_ref_name` at exactly `head_commit`, including heads /// that live in a fork. /// -/// Acquisition is one bounded static GraphQL query per scan; the scan result -/// must agree across scans before it is trusted. GitHub's GraphQL API refuses -/// unauthenticated reads, so an anonymous credential yields `Denied`. +/// With a credential, acquisition is one bounded static GraphQL query per +/// scan. GitHub's GraphQL API refuses unauthenticated reads, so an anonymous +/// scan uses the REST issue search's `head:` qualifier and reads each +/// candidate pull request for its exact head. Either way the scan result must +/// agree across scans before it is trusted. #[hotpath::measure(label = "usecases.github_network.discover_pr")] pub fn discover_exact_commit_pull_request_v1( owner: &str, @@ -176,7 +329,7 @@ pub fn discover_exact_commit_pull_request_v1( credential: &GitHubReadOnlyCredentialV1, control: &GitHubDiscoveryControlV1, ) -> GitHubExactCommitDiscoveryOutcomeV1 { - if !valid_graphql_uri(&config.graphql_uri) { + if !valid_https_uri(&config.graphql_uri) || !valid_https_uri(&config.rest_base_uri) { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; } let builder = ureq::Agent::config_builder() @@ -212,7 +365,11 @@ fn discover_with_agent( credential: &GitHubReadOnlyCredentialV1, control: &GitHubDiscoveryControlV1, ) -> GitHubExactCommitDiscoveryOutcomeV1 { - let scan = || scan_head_ref_pull_requests_v1(agent, request, config, credential, control); + let scan = || match credential.authorization_header_for(GitHubReadPermissionV1::PullRequests) { + Ok(Some(_)) => scan_head_ref_pull_requests_v1(agent, request, config, credential, control), + Ok(None) => scan_public_head_ref_pull_requests_v1(agent, request, config, control), + Err(()) => GitHubExactCommitDiscoveryOutcomeV1::Denied, + }; let first = scan(); if !discovery_outcome_requires_consensus(&first) { return first; @@ -253,27 +410,235 @@ fn discovery_consensus( } } -fn scan_head_ref_pull_requests_v1( +/// The per-request timeout a scan may still spend, or `None` when the +/// request or the remaining budget cannot admit one. +fn admitted_request_timeout( + request: &DiscoveryRequestV1<'_>, + config: &GitHubHttpReadConfigV1, + control: &GitHubDiscoveryControlV1, +) -> Option { + let request_timeout = config.request_timeout.min(control.remaining()?); + (valid_path_segment(request.owner) + && valid_path_segment(request.repository) + && valid_head_ref_name(request.head_ref_name) + && valid_full_git_oid(request.head_commit.as_str()) + && !request_timeout.is_zero() + && !config.connect_timeout.is_zero() + && !config.socket_timeout.is_zero()) + .then_some(request_timeout) +} + +/// A non-success provider status as its discovery state. `None` is 200. +fn refused_status( + response: &ureq::http::Response, +) -> Option { + let checkpoint = rate_limit_checkpoint(response.headers()); + match response.status().as_u16() { + 200 => None, + // REST answers a repository the caller cannot see as 404 (a read) or + // 422 (a search qualifier naming it). + 401 | 404 | 422 => Some(GitHubExactCommitDiscoveryOutcomeV1::Denied), + 403 => { + let retry_at = retry_after_at(response.headers()); + if checkpoint + .as_ref() + .is_none_or(|checkpoint| checkpoint.remaining != 0) + && retry_at.is_none() + { + return Some(GitHubExactCommitDiscoveryOutcomeV1::Denied); + } + Some(GitHubExactCommitDiscoveryOutcomeV1::RateLimited { + retry_at, + checkpoint, + }) + } + 429 => Some(GitHubExactCommitDiscoveryOutcomeV1::RateLimited { + retry_at: retry_after_at(response.headers()), + checkpoint, + }), + _ => Some(GitHubExactCommitDiscoveryOutcomeV1::Unavailable), + } +} + +fn read_bounded_body(response: &mut ureq::http::Response) -> Option> { + response + .body_mut() + .with_config() + .limit(MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1 as u64) + .read_to_vec() + .ok() +} + +/// One anonymous REST `GET`, answered as its body or its discovery state. +fn public_rest_get( + agent: &ureq::Agent, + url: &str, + config: &GitHubHttpReadConfigV1, + request_timeout: Duration, + control: &GitHubDiscoveryControlV1, +) -> Result, GitHubExactCommitDiscoveryOutcomeV1> { + let response = agent + .get(url) + .config() + .timeout_global(Some(request_timeout)) + .timeout_connect(Some(config.connect_timeout.min(request_timeout))) + .timeout_recv_response(Some(config.socket_timeout.min(request_timeout))) + .timeout_recv_body(Some(config.socket_timeout.min(request_timeout))) + .build() + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "tracedecay-github-read") + .call(); + if control.remaining().is_none() { + return Err(GitHubExactCommitDiscoveryOutcomeV1::Unavailable); + } + let Ok(mut response) = response else { + return Err(GitHubExactCommitDiscoveryOutcomeV1::Unavailable); + }; + if let Some(refused) = refused_status(&response) { + return Err(refused); + } + read_bounded_body(&mut response).ok_or(GitHubExactCommitDiscoveryOutcomeV1::Unavailable) +} + +#[derive(Deserialize)] +struct HeadRefSearchV1 { + total_count: usize, + incomplete_results: bool, + items: Vec, +} + +#[derive(Deserialize)] +struct HeadRefSearchItemV1 { + number: u64, +} + +#[derive(Deserialize)] +struct RestPullRequestHeadRefV1 { + id: u64, + number: u64, + head: RestPullRequestBranchV1, + base: RestPullRequestBranchV1, +} + +#[derive(Deserialize)] +struct RestPullRequestBranchV1 { + #[serde(rename = "ref")] + name: String, + sha: String, + repo: Option, +} + +#[derive(Deserialize)] +struct RestPullRequestRepositoryV1 { + name: String, + owner: HeadRefLoginV1, +} + +/// Anonymous discovery: the issue search's `head:` qualifier names every pull +/// request of the repository from a branch of that name, in any fork, and +/// each candidate's own read pins its exact head commit and repository. +fn scan_public_head_ref_pull_requests_v1( agent: &ureq::Agent, request: &DiscoveryRequestV1<'_>, config: &GitHubHttpReadConfigV1, - credential: &GitHubReadOnlyCredentialV1, control: &GitHubDiscoveryControlV1, ) -> GitHubExactCommitDiscoveryOutcomeV1 { - let Some(remaining) = control.remaining() else { + let Some(request_timeout) = admitted_request_timeout(request, config, control) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + let rest_base = config.rest_base_uri.trim_end_matches('/'); + let Ok(mut search) = Url::parse(&format!("{rest_base}/search/issues")) else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; }; - let request_timeout = config.request_timeout.min(remaining); - if !valid_path_segment(request.owner) - || !valid_path_segment(request.repository) - || !valid_head_ref_name(request.head_ref_name) - || !valid_full_git_oid(request.head_commit.as_str()) - || request_timeout.is_zero() - || config.connect_timeout.is_zero() - || config.socket_timeout.is_zero() + search + .query_pairs_mut() + .append_pair( + "q", + &format!( + "repo:{}/{} is:pr head:{}", + request.owner, request.repository, request.head_ref_name + ), + ) + .append_pair("per_page", &GITHUB_DISCOVERY_PAGE_SIZE_V1.to_string()); + let body = match public_rest_get(agent, search.as_str(), config, request_timeout, control) { + Ok(body) => body, + Err(outcome) => return outcome, + }; + let Ok(found) = serde_json::from_slice::(&body) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + if found.incomplete_results + || found.total_count != found.items.len() + || found.items.len() > MAX_ANONYMOUS_HEAD_REF_CANDIDATES_V1 { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; } + let mut matches = Vec::new(); + for candidate in found.items { + let url = format!( + "{rest_base}/repos/{}/{}/pulls/{}", + request.owner, request.repository, candidate.number + ); + let body = match public_rest_get(agent, &url, config, request_timeout, control) { + Ok(body) => body, + Err(outcome) => return outcome, + }; + let Ok(pull) = serde_json::from_slice::(&body) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + if pull.number != candidate.number { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + } + if pull.head.sha != request.head_commit.as_str() || pull.head.name != request.head_ref_name + { + continue; + } + let (Some(head_repository), Some(base_repository)) = (pull.head.repo, pull.base.repo) + else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + let Some(found) = exact_pull_request( + request, + HeadRefPullRequestV1 { + database_id: pull.id, + number: pull.number, + head_ref_oid: pull.head.sha, + base_ref_oid: pull.base.sha, + head_repository_owner: Some(head_repository.owner), + head_repository: Some(HeadRefNameV1 { + name: head_repository.name, + }), + base_repository: Some(HeadRefBaseRepositoryV1 { + name: base_repository.name, + owner: base_repository.owner, + }), + }, + ) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + matches.push(found); + if matches.len() > 1 { + return GitHubExactCommitDiscoveryOutcomeV1::Ambiguous; + } + } + matches + .pop() + .map_or(GitHubExactCommitDiscoveryOutcomeV1::NotFound, |pull| { + GitHubExactCommitDiscoveryOutcomeV1::Found(pull) + }) +} + +fn scan_head_ref_pull_requests_v1( + agent: &ureq::Agent, + request: &DiscoveryRequestV1<'_>, + config: &GitHubHttpReadConfigV1, + credential: &GitHubReadOnlyCredentialV1, + control: &GitHubDiscoveryControlV1, +) -> GitHubExactCommitDiscoveryOutcomeV1 { + let Some(request_timeout) = admitted_request_timeout(request, config, control) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; let authorization = match credential.authorization_header_for(GitHubReadPermissionV1::PullRequests) { Ok(authorization) => authorization, @@ -313,38 +678,13 @@ fn scan_head_ref_pull_requests_v1( { return GitHubExactCommitDiscoveryOutcomeV1::Denied; } - let checkpoint = rate_limit_checkpoint(response.headers()); - match response.status().as_u16() { - 200 => {} - 401 => return GitHubExactCommitDiscoveryOutcomeV1::Denied, - 403 => { - let retry_at = retry_after_at(response.headers()); - if checkpoint - .as_ref() - .is_none_or(|checkpoint| checkpoint.remaining != 0) - && retry_at.is_none() - { - return GitHubExactCommitDiscoveryOutcomeV1::Denied; - } - return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { - retry_at, - checkpoint, - }; - } - 429 => { - return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { - retry_at: retry_after_at(response.headers()), - checkpoint, - }; - } - _ => return GitHubExactCommitDiscoveryOutcomeV1::Unavailable, + if matches!(response.status().as_u16(), 404 | 422) { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; } - let Ok(body) = response - .body_mut() - .with_config() - .limit(MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1 as u64) - .read_to_vec() - else { + if let Some(refused) = refused_status(&response) { + return refused; + } + let Some(body) = read_bounded_body(&mut response) else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; }; let Ok(envelope) = serde_json::from_slice::(&body) else { @@ -425,7 +765,7 @@ fn exact_pull_request( }) } -fn valid_graphql_uri(value: &str) -> bool { +fn valid_https_uri(value: &str) -> bool { Url::parse(value).is_ok_and(|url| { url.scheme() == "https" && url.host_str().is_some() @@ -458,9 +798,19 @@ fn valid_head_ref_name(value: &str) -> bool { #[cfg(test)] mod tests { + use std::collections::BTreeSet; use std::net::TcpListener; - use super::super::network::test_support::{read_http_request_with_headers, write_http_json}; + use super::super::network::test_support::{ + read_http_request_with_headers, write_http_json, write_http_response, + }; + use super::super::{ + GitHubReadOnlyCredentialAuthorityOutcomeV1, GitHubReadOnlyCredentialAuthorityV1, + GitHubReadOnlyCredentialSecretV1, RegisteredGitHubReadOnlyCredentialV1, + register_github_read_only_credential_authority_v1, + resolve_registered_github_read_only_credential_v1, + unregister_github_read_only_credential_authority_v1, + }; use super::*; const FORK_HEAD_FIXTURE: &str = include_str!("../fixtures/fork_head_pull_request.json"); @@ -480,29 +830,51 @@ mod tests { }) } - /// Serves the cached GitHub answers for dtolnay/anyhow#463 to `requests` - /// discovery requests: the commit-associated REST route answers `[]` for - /// a fork head, the head-ref GraphQL query names the pull request. - fn serve_fork_head_fixture(requests: usize) -> (String, std::thread::JoinHandle<()>) { + /// Serves the cached GitHub answers for dtolnay/anyhow#463 as GitHub gave + /// them: GraphQL refuses a request without `Authorization` (rate limit 0) + /// and answers the head-ref query for one with it; the REST issue search + /// and pull request read answer anonymously. + fn serve_fork_head_fixture(requests: usize) -> (String, std::thread::JoinHandle>) { let fixture: serde_json::Value = serde_json::from_str(FORK_HEAD_FIXTURE).unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { + let mut seen = Vec::new(); for _ in 0..requests { let (mut stream, _) = listener.accept().unwrap(); let (headers, _) = read_http_request_with_headers(&mut stream); - let response = if headers.starts_with("POST /graphql ") { - &fixture["graphql_head_ref"]["response"] + let request_line = headers.lines().next().unwrap_or_default().to_owned(); + let authorized = headers.to_ascii_lowercase().contains("\r\nauthorization:"); + if request_line.starts_with("POST /graphql ") && !authorized { + let refusal = &fixture["graphql_anonymous"]; + let headers = refusal["headers"] + .as_object() + .unwrap() + .iter() + .map(|(name, value)| (name.as_str(), value.as_str().unwrap())) + .collect::>(); + write_http_response(&mut stream, 403, &headers, &refusal["response"]); + } else if request_line.starts_with("POST /graphql ") { + write_http_json(&mut stream, &fixture["graphql_head_ref"]["response"]); + } else if request_line.starts_with("GET /search/issues?") { + write_http_json(&mut stream, &fixture["rest_head_ref_search"]["response"]); + } else if request_line.starts_with("GET /repos/dtolnay/anyhow/pulls/463 ") { + write_http_json(&mut stream, &fixture["rest_pull_request"]["response"]); } else { - &fixture["rest_commit_pulls"]["response"] - }; - write_http_json(&mut stream, response); + write_http_response(&mut stream, 404, &[], &serde_json::json!({})); + } + seen.push(request_line); } + seen }); (format!("http://{address}"), server) } - fn discover_against(base_uri: &str, head_commit: &str) -> GitHubExactCommitDiscoveryOutcomeV1 { + fn discover_against( + base_uri: &str, + head_commit: &str, + credential: &GitHubReadOnlyCredentialV1, + ) -> GitHubExactCommitDiscoveryOutcomeV1 { let agent: ureq::Agent = ureq::Agent::config_builder() .https_only(false) .http_status_as_error(false) @@ -521,36 +893,140 @@ mod tests { graphql_uri: format!("{base_uri}/graphql"), ..GitHubHttpReadConfigV1::default() }, - &GitHubReadOnlyCredentialV1::anonymous(), + credential, &GitHubDiscoveryControlV1::bounded(Instant::now() + Duration::from_secs(15)), ) } + fn anyhow_463() -> GitHubExactCommitDiscoveryOutcomeV1 { + GitHubExactCommitDiscoveryOutcomeV1::Found(GitHubExactCommitPullRequestV1 { + target: GitHubRepositoryTargetV1 { + owner: "dtolnay".to_owned(), + repository: "anyhow".to_owned(), + pull_request_number: 463, + pull_request_id: GitHubPullRequestIdV1::new("4597599038").unwrap(), + }, + head_repository_owner: "sb123sb123".to_owned(), + head_repository_name: "anyhow".to_owned(), + base_commit_id: CommitId::new("c63b279f3f4af2b02ca6267d9eb47d6d10497f69").unwrap(), + head_commit_id: CommitId::new("c7b210a78b32ea90b10860c58983f8c0a742ed03").unwrap(), + }) + } + + struct FixtureTokenAuthority; + + impl GitHubReadOnlyCredentialAuthorityV1 for FixtureTokenAuthority { + fn resolve( + &self, + _repository_owner: &str, + _repository_name: &str, + ) -> GitHubReadOnlyCredentialAuthorityOutcomeV1 { + GitHubReadOnlyCredentialAuthorityOutcomeV1::Verified { + secret: GitHubReadOnlyCredentialSecretV1::new("github_pat_fixture_discovery") + .unwrap(), + exact_permissions: BTreeSet::from([GitHubReadPermissionV1::PullRequests]), + } + } + } + #[test] - fn fork_headed_pull_request_is_found_with_its_head_repository() { - let (base_uri, server) = serve_fork_head_fixture(4); + fn anonymous_discovery_finds_a_fork_headed_pull_request_by_rest_head_ref_search() { + let (base_uri, server) = serve_fork_head_fixture(8); + let anonymous = GitHubReadOnlyCredentialV1::anonymous(); + let found = discover_against( + &base_uri, + "c7b210a78b32ea90b10860c58983f8c0a742ed03", + &anonymous, + ); + assert_eq!(found, anyhow_463()); assert_eq!( - discover_against(&base_uri, "c7b210a78b32ea90b10860c58983f8c0a742ed03"), - GitHubExactCommitDiscoveryOutcomeV1::Found(GitHubExactCommitPullRequestV1 { - target: GitHubRepositoryTargetV1 { - owner: "dtolnay".to_owned(), - repository: "anyhow".to_owned(), - pull_request_number: 463, - pull_request_id: GitHubPullRequestIdV1::new("4597599038").unwrap(), - }, - head_repository_owner: "sb123sb123".to_owned(), - head_repository_name: "anyhow".to_owned(), - base_commit_id: CommitId::new("c63b279f3f4af2b02ca6267d9eb47d6d10497f69").unwrap(), - head_commit_id: CommitId::new("c7b210a78b32ea90b10860c58983f8c0a742ed03").unwrap(), - }) + GitHubSourceStatusV1::observed("dtolnay", "anyhow", &anonymous, Some(&found)), + GitHubSourceStatusV1 { + repository: "dtolnay/anyhow".to_owned(), + state: GitHubSourceStateV1::UnauthenticatedPublic, + remedy: Some( + "reads are anonymous (60 requests/hour); run `gh auth login` or set GH_TOKEN to read with a credential" + .to_owned() + ), + pull_request_discovery: GitHubPullRequestDiscoveryKindV1::Found, + pull_request: Some(463), + head_repository: Some("sb123sb123/anyhow".to_owned()), + } ); assert_eq!( - discover_against(&base_uri, "0000000000000000000000000000000000000001"), + discover_against( + &base_uri, + "0000000000000000000000000000000000000001", + &anonymous, + ), GitHubExactCommitDiscoveryOutcomeV1::NotFound, "a local head the pull request no longer points at must not admit it" ); + let seen = server.join().unwrap(); + assert!( + seen.iter().all(|line| line.starts_with("GET ")), + "anonymous discovery must never ask GraphQL: {seen:?}" + ); + } + + #[test] + fn credentialed_discovery_reads_the_head_ref_graphql_query() { + let authority: Arc = + Arc::new(FixtureTokenAuthority); + assert!(register_github_read_only_credential_authority_v1( + "dtolnay", "anyhow", &authority, + )); + let RegisteredGitHubReadOnlyCredentialV1::Verified(credential) = + resolve_registered_github_read_only_credential_v1("dtolnay", "anyhow") + else { + panic!("the fixture token must resolve"); + }; + let (base_uri, server) = serve_fork_head_fixture(2); + + let found = discover_against( + &base_uri, + "c7b210a78b32ea90b10860c58983f8c0a742ed03", + &credential, + ); + assert!(unregister_github_read_only_credential_authority_v1( + "dtolnay", "anyhow", &authority, + )); + assert_eq!(found, anyhow_463()); + assert_eq!( + GitHubSourceStateV1::observed(&credential, Some(&found)), + GitHubSourceStateV1::Bound + ); + assert_eq!( + server.join().unwrap(), + ["POST /graphql HTTP/1.1", "POST /graphql HTTP/1.1"] + ); + } + + #[test] + fn anonymous_discovery_of_a_repository_github_will_not_show_is_denied_no_credential() { + let fixture: serde_json::Value = serde_json::from_str(FORK_HEAD_FIXTURE).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request_with_headers(&mut stream); + let refusal = &fixture["rest_head_ref_search_unseen_repository"]; + write_http_response(&mut stream, 422, &[], &refusal["response"]); + }); + let anonymous = GitHubReadOnlyCredentialV1::anonymous(); + + let refused = discover_against( + &format!("http://{address}"), + "c7b210a78b32ea90b10860c58983f8c0a742ed03", + &anonymous, + ); server.join().unwrap(); + assert_eq!(refused, GitHubExactCommitDiscoveryOutcomeV1::Denied); + assert_eq!( + GitHubSourceStateV1::observed(&anonymous, Some(&refused)), + GitHubSourceStateV1::DeniedNoCredential + ); } #[test] diff --git a/crates/tracedecay-application/src/advisory/github_runtime/gh_cli.rs b/crates/tracedecay-application/src/advisory/github_runtime/gh_cli.rs index 60396c529b..1812774fe6 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/gh_cli.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/gh_cli.rs @@ -1,31 +1,34 @@ -//! Read-only GitHub credential authority backed by the user's existing `gh` -//! CLI login. +//! Read-only GitHub credential authority backed by the user's existing local +//! GitHub login. //! //! An unauthenticated GitHub client is allowed 60 requests per hour. The same -//! requests carrying the token that `gh auth token` already holds are allowed -//! 5,000 per hour. This module turns that local login into a -//! [`GitHubReadOnlyCredentialAuthorityV1`] so public-repository reads stop -//! burning the anonymous budget, without `TraceDecay` ever storing, logging, or -//! persisting a token byte. +//! requests carrying a token the user already holds are allowed 5,000 per +//! hour. The token is taken, in order, from `GH_TOKEN`, from `gh auth token`, +//! and from the git credential helper for `https://github.com`. This module +//! turns that local login into a [`GitHubReadOnlyCredentialAuthorityV1`] so +//! reads stop burning the anonymous budget, without `TraceDecay` ever storing, +//! logging, or persisting a token byte. //! //! # Token handling //! //! * Token bytes exist only inside [`Zeroizing`] containers and the //! [`GitHubReadOnlyCredentialSecretV1`] newtype, which itself derives neither //! `Debug` nor Serde. -//! * The probe is invoked as an argv array, never a shell string, with -//! `stdin` and `stderr` both `Stdio::null()` so provider diagnostics can -//! never reach a `TraceDecay` log. -//! * Every failure mode - `gh` absent, not logged in, non-zero exit, empty or -//! oversized or non-UTF-8 output, a hung child, a poisoned lock - degrades to -//! "no credential", never to an error. The caller then reads anonymously. +//! * Each probe is invoked as an argv array, never a shell string, with +//! `stderr` as `Stdio::null()` so provider diagnostics can never reach a +//! `TraceDecay` log. The git credential probe writes only the fixed +//! `protocol`/`host` request to `stdin` and runs with terminal prompts +//! disabled, so it can never block on a user. +//! * Every failure mode - no login, non-zero exit, empty or oversized or +//! non-UTF-8 output, a hung child, a poisoned lock - degrades to "no +//! credential", never to an error. The caller then reads anonymously. //! * Under `cfg(test)` and the `test-transport` feature the default source is //! a stub that returns `None`, so no test build can spawn `gh` or observe a //! developer's real login. use std::collections::{BTreeMap, BTreeSet}; #[cfg(not(any(test, feature = "test-transport")))] -use std::io::Read; +use std::io::{Read, Write}; #[cfg(not(any(test, feature = "test-transport")))] use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex, OnceLock}; @@ -47,6 +50,15 @@ const GH_EXECUTABLE_V1: &str = "gh"; /// Exact argv passed to `GH_EXECUTABLE_V1`. #[cfg(not(any(test, feature = "test-transport")))] const GH_AUTH_TOKEN_ARGV_V1: [&str; 2] = ["auth", "token"]; +/// Environment variable `gh` itself reads its token from. +#[cfg(not(any(test, feature = "test-transport")))] +const GH_TOKEN_ENV_V1: &str = "GH_TOKEN"; +/// Exact argv asking the git credential helper for a stored GitHub login. +#[cfg(not(any(test, feature = "test-transport")))] +const GIT_CREDENTIAL_FILL_ARGV_V1: [&str; 2] = ["credential", "fill"]; +/// The only request the git credential probe ever sends. +#[cfg(not(any(test, feature = "test-transport")))] +const GIT_CREDENTIAL_GITHUB_REQUEST_V1: &[u8] = b"protocol=https\nhost=github.com\n\n"; /// Upper bound on accepted token bytes, matching the secret newtype's own cap. /// Compiled with the production probe, and with unit tests that share the cap. #[cfg(any(test, not(feature = "test-transport")))] @@ -70,17 +82,20 @@ pub trait GhCliTokenSourceV1: Send + Sync { fn token(&self) -> Option>; } -/// Production source: runs `gh auth token` as a bounded child process. +/// Production source: `GH_TOKEN`, then `gh auth token`, then the git +/// credential helper, the latter two as bounded child processes. /// /// Absent from every test build, so a test cannot construct the one type that -/// can spawn a provider probe. +/// can read the environment or spawn a provider probe. #[cfg(not(any(test, feature = "test-transport")))] -pub struct GhAuthTokenCommandSourceV1; +pub struct LocalGitHubLoginTokenSourceV1; #[cfg(not(any(test, feature = "test-transport")))] -impl GhCliTokenSourceV1 for GhAuthTokenCommandSourceV1 { +impl GhCliTokenSourceV1 for LocalGitHubLoginTokenSourceV1 { fn token(&self) -> Option> { - probe_gh_auth_token_v1() + env_github_token_v1() + .or_else(probe_gh_auth_token_v1) + .or_else(probe_git_credential_v1) } } @@ -95,15 +110,63 @@ impl GhCliTokenSourceV1 for NullGhCliTokenSourceV1 { } } +#[cfg(not(any(test, feature = "test-transport")))] +fn env_github_token_v1() -> Option> { + let token = Zeroizing::new(std::env::var(GH_TOKEN_ENV_V1).ok()?); + let trimmed = Zeroizing::new(token.trim().to_owned()); + (!trimmed.is_empty() && trimmed.len() <= MAX_GH_TOKEN_BYTES_V1).then_some(trimmed) +} + #[cfg(not(any(test, feature = "test-transport")))] fn probe_gh_auth_token_v1() -> Option> { - let mut child = Command::new(GH_EXECUTABLE_V1) - .args(GH_AUTH_TOKEN_ARGV_V1) - .stdin(Stdio::null()) + let mut command = Command::new(GH_EXECUTABLE_V1); + command.args(GH_AUTH_TOKEN_ARGV_V1).stdin(Stdio::null()); + let output = run_bounded_probe_v1(command, None)?; + let text = Zeroizing::new(String::from_utf8(output.to_vec()).ok()?); + let trimmed = Zeroizing::new(text.trim().to_owned()); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// The `password` a git credential helper stores for `https://github.com`. +#[cfg(not(any(test, feature = "test-transport")))] +fn probe_git_credential_v1() -> Option> { + let mut command = Command::new("git"); + command + .args(GIT_CREDENTIAL_FILL_ARGV_V1) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GCM_INTERACTIVE", "never") + .env_remove("GIT_ASKPASS") + .env_remove("SSH_ASKPASS") + .stdin(Stdio::piped()); + let output = run_bounded_probe_v1(command, Some(GIT_CREDENTIAL_GITHUB_REQUEST_V1))?; + let text = Zeroizing::new(String::from_utf8(output.to_vec()).ok()?); + let password = text + .lines() + .find_map(|line| line.strip_prefix("password="))?; + let password = Zeroizing::new(password.trim().to_owned()); + (!password.is_empty()).then_some(password) +} + +/// Runs one local credential probe to completion within +/// [`MAX_GH_PROBE_DURATION_V1`], returning its stdout on success. +#[cfg(not(any(test, feature = "test-transport")))] +fn run_bounded_probe_v1(mut command: Command, input: Option<&[u8]>) -> Option>> { + let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + if let Some(input) = input { + let written = child + .stdin + .take() + .is_some_and(|mut stdin| stdin.write_all(input).is_ok()); + if !written { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } let deadline = Instant::now() + MAX_GH_PROBE_DURATION_V1; let status = loop { match child.try_wait() { @@ -126,12 +189,7 @@ fn probe_gh_auth_token_v1() -> Option> { .take(MAX_GH_TOKEN_BYTES_V1 as u64 + 1) .read_to_end(&mut bytes) .ok()?; - if bytes.len() > MAX_GH_TOKEN_BYTES_V1 { - return None; - } - let text = Zeroizing::new(String::from_utf8(bytes.to_vec()).ok()?); - let trimmed = Zeroizing::new(text.trim().to_owned()); - (!trimmed.is_empty()).then_some(trimmed) + (bytes.len() <= MAX_GH_TOKEN_BYTES_V1).then_some(bytes) } fn default_gh_cli_token_source_v1() -> Arc { @@ -141,7 +199,7 @@ fn default_gh_cli_token_source_v1() -> Arc { } #[cfg(not(any(test, feature = "test-transport")))] { - Arc::new(GhAuthTokenCommandSourceV1) + Arc::new(LocalGitHubLoginTokenSourceV1) } } diff --git a/crates/tracedecay-application/src/advisory/github_runtime/network.rs b/crates/tracedecay-application/src/advisory/github_runtime/network.rs index cf386fedb6..1bc7ea9c2a 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/network.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/network.rs @@ -626,6 +626,10 @@ impl GitHubReadOnlyCredentialV1 { } } + pub fn is_anonymous(&self) -> bool { + matches!(self.kind, GitHubReadOnlyCredentialKindV1::Anonymous) + } + pub fn permits(&self, permission: GitHubReadPermissionV1) -> bool { !matches!( self.authorization_for_stored_repository(permission), diff --git a/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs b/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs index 98d2303655..41fb82d0fc 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs @@ -62,3 +62,24 @@ pub(in crate::advisory::github_runtime) fn write_http_json( .unwrap(); stream.write_all(&body).unwrap(); } + +/// Writes one response with an exact status line and extra headers, as a +/// captured provider refusal carries them. +pub(in crate::advisory::github_runtime) fn write_http_response( + stream: &mut TcpStream, + status: u16, + headers: &[(&str, &str)], + value: &serde_json::Value, +) { + let body = serde_json::to_vec(value).unwrap(); + let mut head = format!("HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\n"); + for (name, value) in headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )); + stream.write_all(head.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); +} diff --git a/crates/tracedecay-application/src/delivery.rs b/crates/tracedecay-application/src/delivery.rs index 3eecedcd43..a641d9e546 100644 --- a/crates/tracedecay-application/src/delivery.rs +++ b/crates/tracedecay-application/src/delivery.rs @@ -357,7 +357,8 @@ pub enum ProjectDeliveryReadOutcomeV1 { }, Denied, /// Project-open resolved no GitHub provider for this checkout; the exact - /// typed gate tells "configure a token" apart from "broken". + /// typed gate tells a checkout without a GitHub remote or with a refused + /// credential apart from a broken authority. NotMounted { gate: ProjectDeliveryProviderMountGateV1, }, @@ -370,9 +371,6 @@ pub enum ProjectDeliveryReadOutcomeV1 { pub enum ProjectDeliveryProviderMountGateV1 { /// The admitted checkout has no recognizable GitHub remote. NoGitRemote, - /// No GitHub read-only credential is configured for this profile and - /// repository, and the repository is not registered as public. - GitHubCredentialNotConfigured, /// A credential configuration exists but was refused (rejected, missing /// at resolution, or write-capable), so reads stay unmounted. GitHubAccessRefused, @@ -954,9 +952,7 @@ fn delivery_inbox_provider( }, ), ProjectDeliveryReadOutcomeV1::NotMounted { - gate: - ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured - | ProjectDeliveryProviderMountGateV1::NoGitRemote, + gate: ProjectDeliveryProviderMountGateV1::NoGitRemote, } => ( ProjectDeliveryProviderStateV1::NotConfigured, None, @@ -2721,7 +2717,7 @@ mod tests { }, ); not_configured.delivery = ProjectDeliveryReadOutcomeV1::NotMounted { - gate: ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured, + gate: ProjectDeliveryProviderMountGateV1::NoGitRemote, }; let mut denied = inbox_source( "project.delivery-denied", @@ -3120,7 +3116,7 @@ mod tests { let context = test_context(&scope); let handle = gated_project_delivery_read_handle_v1( scope.clone(), - ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured, + ProjectDeliveryProviderMountGateV1::NoGitRemote, ); let request = ProjectDeliveryReadRequestV1 { kind: ProjectDeliveryReadKindV1::Overview, @@ -3135,7 +3131,7 @@ mod tests { assert_eq!( handle.read(&context, &request, &control).await, ProjectDeliveryReadOutcomeV1::NotMounted { - gate: ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured, + gate: ProjectDeliveryProviderMountGateV1::NoGitRemote, } ); diff --git a/crates/tracedecay-cli/src/status_cmd.rs b/crates/tracedecay-cli/src/status_cmd.rs index 7bf05ccb32..763d24c424 100644 --- a/crates/tracedecay-cli/src/status_cmd.rs +++ b/crates/tracedecay-cli/src/status_cmd.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::future::Future; use std::io::IsTerminal; use std::path::Path; @@ -5,6 +6,9 @@ use std::time::Duration; use serde_json::Value; use tokio::time::{Instant, timeout_at}; +use tracedecay_application::advisory::github_runtime::{ + GitHubPullRequestDiscoveryKindV1, GitHubSourceStateV1, GitHubSourceStatusV1, +}; use tracedecay_contracts::project_open::{ ProjectOpenStatusReasonV1, ProjectOpenStatusStateV1, ProjectOpenStatusV1, }; @@ -181,6 +185,43 @@ fn compact_status_tool_args() -> Value { }) } +/// One status line naming how the project's GitHub source is read, followed +/// by the operator remedy when it is not credential-bound. +fn github_source_line(source: &GitHubSourceStatusV1) -> String { + let state = match source.state { + GitHubSourceStateV1::Bound => "bound", + GitHubSourceStateV1::UnauthenticatedPublic => "unauthenticated_public", + GitHubSourceStateV1::DeniedNoCredential => "denied_no_credential", + }; + let discovery = match (source.pull_request_discovery, source.pull_request) { + (GitHubPullRequestDiscoveryKindV1::Found, Some(number)) => format!( + "PR #{number} found (head {})", + source + .head_repository + .as_deref() + .unwrap_or(&source.repository) + ), + (kind, _) => format!("PR discovery {}", pull_request_discovery_label(kind)), + }; + let mut line = format!("GitHub {}: {state} · {discovery}", source.repository); + if let Some(remedy) = &source.remedy { + let _ = write!(line, "\n remedy: {remedy}"); + } + line +} + +const fn pull_request_discovery_label(kind: GitHubPullRequestDiscoveryKindV1) -> &'static str { + match kind { + GitHubPullRequestDiscoveryKindV1::Found => "found", + GitHubPullRequestDiscoveryKindV1::NotFound => "not_found", + GitHubPullRequestDiscoveryKindV1::Ambiguous => "ambiguous", + GitHubPullRequestDiscoveryKindV1::RateLimited => "rate_limited", + GitHubPullRequestDiscoveryKindV1::Denied => "denied", + GitHubPullRequestDiscoveryKindV1::Unavailable => "unavailable", + GitHubPullRequestDiscoveryKindV1::NotAttempted => "not_attempted", + } +} + fn schema_convergence_line(finding: &SchemaConvergenceFindingV1) -> String { let progress = match &finding.progress { Some(SchemaConvergenceProgressV1::Rows { done, remaining }) => { @@ -449,6 +490,12 @@ async fn handle_status_command_within( .map(serde_json::from_value) .transpose()? .unwrap_or_default(); + let github_source: Option = daemon_status + .get("github_source") + .filter(|source| source.get("state") != Some(&Value::from("not_observed"))) + .cloned() + .map(serde_json::from_value) + .transpose()?; let show_online = stdout_is_terminal && upload_enabled; // The worldwide counter and country flags are decoration served from the // local cache: the render below never waits on the network. When a cache @@ -508,6 +555,9 @@ async fn handle_status_command_within( } } } + if let Some(source) = &github_source { + println!("{}", github_source_line(source)); + } }); // A parked deterministic contract violation must be visible on the plain diff --git a/crates/tracedecay-configuration/src/configuration/operations.rs b/crates/tracedecay-configuration/src/configuration/operations.rs index 8e104f712e..e8019dca02 100644 --- a/crates/tracedecay-configuration/src/configuration/operations.rs +++ b/crates/tracedecay-configuration/src/configuration/operations.rs @@ -23,6 +23,7 @@ use tracedecay_global_db::configuration::contracts::types::{ ConfigurationMutationAuthority, ConfigurationMutationReceipt, ConfigurationRollbackRequest, DirectConfigurationMutation, ResolvedSetting, SettingSummary, }; +use tracedecay_global_db::configuration::store::protected_change_snapshot_v1; /// One transport-neutral control-plane contract. CLI, MCP, HTTP, dashboard, /// and Doctor call this shape rather than rebuilding mutation semantics. @@ -234,6 +235,7 @@ where if current.revision_id != expected_revision { return Err(ConfigurationError::RevisionConflict); } + protected_change_snapshot_v1(¤t.snapshot, &change, ¤t.revision_id)?; let current_authorization = self .authorize_mutation( &authority, @@ -993,6 +995,86 @@ mod tests { ); } + /// A preview answers exactly what apply's validator answers: a change + /// apply refuses gets no plan, and the refusal is apply's typed reason. + #[tokio::test] + async fn protected_dry_run_refuses_what_apply_refuses_with_the_same_reason() { + let revision_id: ConfigurationRevisionId = id("configuration.revision.parity"); + let scope_digest = digest('a'); + let policy_digest = policy_digest('b'); + let snapshot = + ConfigurationSnapshotV1::new(BTreeMap::default(), BTreeMap::default()).unwrap(); + let store = Store { + current: ConfigurationCurrentStateV1 { + revision_id: revision_id.clone(), + snapshot: snapshot.clone(), + }, + saved: Mutex::new(None), + replay: Mutex::new(None), + }; + let authorization = Authorization { + current: CurrentConfigurationMutationAuthorizationV1 { + grant_revision: 1, + grant_digest: digest('c'), + scope_digest: scope_digest.clone(), + policy_epoch: 7, + policy_digest: policy_digest.clone(), + }, + }; + let authority = ConfigurationMutationAuthority { + receipt: ConfigurationMutationGrantReceiptV1::issue( + id::("configuration.grant-receipt.parity"), + id::("configuration.grant.parity"), + id::("actor.configuration.parity"), + ConfigurationMutationOperationV1::ProtectedDryRun, + scope_digest.clone(), + revision_id.clone(), + 7, + policy_digest.clone(), + ConfigurationMutationSinkV1::ConfigurationStore, + ConfigurationMutationEffectV1::CreateProtectedChangePlan, + None, + UtcMicros(1), + UtcMicros(100), + ) + .unwrap(), + }; + let registry = ConfigurationRegistry::core().unwrap(); + let scope = Scope { + evidence: ScopeRevalidationEvidenceV1 { + resolved_scope_digest: scope_digest, + membership_digest: None, + authorization_policy_digest: policy_digest, + policy_epoch: 7, + }, + }; + let operations = ConfigurationControlPlaneOperations::new( + ®istry, + &store, + &scope, + &authorization, + clock, + ); + let unbind_absent = ProtectedChange::UnbindSource { + binding_id: id::("binding.configuration.absent"), + }; + + let preview = operations + .dry_run_protected_change(authority, unbind_absent.clone(), revision_id.clone()) + .await; + + assert_eq!(preview, Err(ConfigurationError::PlanStale)); + assert_eq!( + protected_change_snapshot_v1(&snapshot, &unbind_absent, &revision_id).map(|_| ()), + Err(ConfigurationError::PlanStale), + "apply's validator refuses the same change for the same reason" + ); + assert!( + store.saved.lock().unwrap().is_none(), + "a refused preview persists no plan" + ); + } + #[tokio::test] async fn protected_apply_restart_replays_original_receipt_after_plan_expiry() { let actor_id: ActorId = id("actor.configuration.replay"); diff --git a/crates/tracedecay-dashboard-api/src/delivery_api.rs b/crates/tracedecay-dashboard-api/src/delivery_api.rs index 84a4747bba..7c8041a925 100644 --- a/crates/tracedecay-dashboard-api/src/delivery_api.rs +++ b/crates/tracedecay-dashboard-api/src/delivery_api.rs @@ -1624,16 +1624,13 @@ fn delivery_projections(outcome: ProjectDeliveryReadOutcomeV1) -> DeliverySource } } -/// The exact project-open gate, rendered so a reader can tell "configure a -/// token" apart from "broken". +/// The exact project-open gate, rendered so a reader can tell an +/// unmountable provider apart from a broken one. fn provider_mount_gate_reason(gate: ProjectDeliveryProviderMountGateV1) -> &'static str { match gate { ProjectDeliveryProviderMountGateV1::NoGitRemote => { "the admitted checkout has no recognizable GitHub remote, so no provider read can be mounted" } - ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured => { - "no GitHub read-only credential is configured for this profile and repository. Configure a token (or register the repository as public) to mount provider reads" - } ProjectDeliveryProviderMountGateV1::GitHubAccessRefused => { "the configured GitHub credential was refused for this repository (missing, rejected, or write-capable), so provider reads stay unmounted" } @@ -2504,16 +2501,14 @@ mod tests { #[test] fn provider_mount_gate_serves_an_actionable_reason_distinct_from_broken() { let gated = delivery_projections(ProjectDeliveryReadOutcomeV1::NotMounted { - gate: ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured, + gate: ProjectDeliveryProviderMountGateV1::NoGitRemote, }); let DeliveryProjectionV1::Unavailable { reason, .. } = gated.pull_requests else { panic!("a gated mount must project as typed unavailable"); }; assert!( - // Case-insensitive: the contract is that the gate names the step, - // not where the sentence happens to break around it. - reason.to_ascii_lowercase().contains("configure a token"), - "the credential gate must tell the reader what to do: {reason}" + reason.contains("no recognizable GitHub remote"), + "the gate must name why no provider read mounted: {reason}" ); let generic = delivery_projections(ProjectDeliveryReadOutcomeV1::Unavailable); @@ -2526,7 +2521,7 @@ mod tests { }; assert_ne!( reason, generic_reason, - "a missing credential must be distinguishable from a broken authority" + "a missing remote must be distinguishable from a broken authority" ); let refused = delivery_projections(ProjectDeliveryReadOutcomeV1::NotMounted { diff --git a/crates/tracedecay-domain/src/configuration.rs b/crates/tracedecay-domain/src/configuration.rs index de911109ff..1554d806bd 100644 --- a/crates/tracedecay-domain/src/configuration.rs +++ b/crates/tracedecay-domain/src/configuration.rs @@ -1948,7 +1948,9 @@ impl ConfigurationSnapshotV1 { }) { return Err(ProtectedChangeSnapshotError::Stale); } - bindings.push(binding.clone()); + let index = + bindings.partition_point(|candidate| candidate.binding_id < binding.binding_id); + bindings.insert(index, binding.clone()); replace_protected_effective_value( &mut effective_values, &mut provenance, @@ -2025,7 +2027,8 @@ impl ConfigurationSnapshotV1 { { rules[index] = rule.clone(); } else { - rules.push(rule.clone()); + let index = rules.partition_point(|candidate| candidate.rule_id < rule.rule_id); + rules.insert(index, rule.clone()); } replace_protected_effective_value( &mut effective_values, diff --git a/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs b/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs index e99ee1972a..3ddd2e3459 100644 --- a/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs @@ -1,12 +1,14 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use tracedecay_domain::configuration::{ - AccessRuleId, AuthorityRef, CapabilityResolutionContextV1, ConfigurationGrantId, - ConfigurationGrantReceiptId, ConfigurationIdempotencyKey, ConfigurationMutationEffectV1, + AccessRuleId, AuthorityRef, CandidateDispositionV1, CapabilityResolutionContextV1, + ConfigurationCandidateV1, ConfigurationGrantId, ConfigurationGrantReceiptId, + ConfigurationIdempotencyKey, ConfigurationLayerIdV1, ConfigurationMutationEffectV1, ConfigurationMutationGrantReceiptV1, ConfigurationMutationOperationV1, - ConfigurationMutationSinkV1, ConfigurationRevisionId, RuleEffect, ScopeAccessRule, - ScopeAccessSubjectV1, ScopeSourceBinding, SourceBindingId, SourceKindV1, UserProfileId, - resolve_restrictive_capabilities, + ConfigurationMutationSinkV1, ConfigurationRevisionId, ConfigurationSnapshotV1, + ConfigurationValueV1, ProtectedChange, RuleEffect, SOURCE_BINDINGS_SETTING_KEY, + ScopeAccessRule, ScopeAccessSubjectV1, ScopeSourceBinding, SettingKey, SourceBindingId, + SourceKindV1, UserProfileId, resolve_restrictive_capabilities, }; use tracedecay_domain::{ AccessPolicyDigest, ActorId, CapabilityId, LocatorDigest, ProjectId, UtcMicros, @@ -41,6 +43,56 @@ fn projectless_hermes_binding_cannot_be_reused_for_other_source_kinds() { assert!(invalid.is_err(), "only projectless Hermes is representable"); } +/// A bound source whose id sorts before an existing binding lands in +/// canonical order, so the resulting snapshot validates. +#[test] +fn bind_source_inserts_in_canonical_binding_order() { + let project = AuthorityRef::Project(id::("project.bind-order")); + let daemon = ScopeSourceBinding::new( + id::("binding.tracedecay-daemon.project-open"), + SourceKindV1::Cursor, + locator_digest('a'), + project.clone(), + ) + .unwrap(); + let github = ScopeSourceBinding::new( + id::("binding.github.rust-lang-log"), + SourceKindV1::GitHub, + locator_digest('b'), + project, + ) + .unwrap(); + let key = SettingKey::new(SOURCE_BINDINGS_SETTING_KEY).unwrap(); + let snapshot = ConfigurationSnapshotV1::new( + BTreeMap::from([( + key.clone(), + ConfigurationValueV1::SourceBindings(vec![daemon.clone()]), + )]), + BTreeMap::from([( + key.clone(), + vec![ConfigurationCandidateV1 { + layer: ConfigurationLayerIdV1::Default, + revision_id: id::("configuration.revision.bind-base"), + disposition: CandidateDispositionV1::Winning, + safe_reason: None, + }], + )]), + ) + .unwrap(); + + let bound = snapshot + .apply_protected_change( + &ProtectedChange::BindSource(github.clone()), + &id::("configuration.revision.bind-order"), + ) + .expect("an out-of-order binding id still binds"); + + assert_eq!( + bound.effective_values.get(&key), + Some(&ConfigurationValueV1::SourceBindings(vec![github, daemon])) + ); +} + #[test] fn deny_rules_union_before_allow_rules_intersect() { let read = id::("capability.read"); diff --git a/crates/tracedecay-global-db/src/configuration/store.rs b/crates/tracedecay-global-db/src/configuration/store.rs index 08d17e0c97..4dca93423e 100644 --- a/crates/tracedecay-global-db/src/configuration/store.rs +++ b/crates/tracedecay-global-db/src/configuration/store.rs @@ -22,9 +22,8 @@ use tracedecay_domain::configuration::{ LCM_SUMMARIZER_EXECUTABLES_SETTING_KEY, ProtectedChange, ProtectedChangePlan, ProtectedChangeSnapshotError, RETIRED_CORE_SETTING_KEYS_V1, RedactedConfigurationChangeV1, RollbackModeV1, RuleEffect, SOURCE_BINDINGS_SETTING_KEY, - SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, ScopeControlOperationV1, ScopeSourceBinding, - SettingKey, SourceKindV1, USER_CODE_INDEX_WORKERS_SETTING_KEY, UserProfileId, - WORK_TOPOLOGY_POLICY_SETTING_KEY, + SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, ScopeControlOperationV1, SettingKey, SourceKindV1, + USER_CODE_INDEX_WORKERS_SETTING_KEY, UserProfileId, WORK_TOPOLOGY_POLICY_SETTING_KEY, }; use tracedecay_domain::{AccessPolicyDigest, ActorId, ManifestDigest, UtcMicros, canonical_sha256}; #[cfg(test)] @@ -54,8 +53,7 @@ use activation::{ use codec::{StoredConfigurationProtectedOperationV1, invalid_store_data, unavailable_store}; use mutation::{ ConfigurationCommitDraft, commit_direct_in_transaction_with_registry, - current_state_from_transaction, derived_identifier, map_protected_change_snapshot_error, - map_store_error, + current_state_from_transaction, derived_identifier, map_store_error, }; use read::read_revision_from_executor; use read::{ @@ -63,7 +61,9 @@ use read::{ }; use revision::{insert_revision, insert_revision_with_registry}; -pub use mutation::{ConfigurationDirectCommitOutcomeV1, commit_direct_in_transaction}; +pub use mutation::{ + ConfigurationDirectCommitOutcomeV1, commit_direct_in_transaction, protected_change_snapshot_v1, +}; #[derive(Debug, Error)] pub enum ConfigurationStorageError { @@ -242,28 +242,37 @@ impl<'db> GlobalDbConfigurationControlStore<'db> { } } - /// Republishes the daemon-owned project source binding with the locator - /// digest of a moved or renamed checkout whose identity the registry has - /// already re-verified. + /// Publishes one daemon-owned source binding: the project-open binding + /// rebound to a moved checkout's locator digest, or the GitHub binding + /// derived from the checkout's `origin` remote. /// - /// This is a daemon-owned identity-preserving heal, not an operator scope - /// change: the caller must have already resolved `binding.authority` to - /// the exact registered project for the current root, and the stored - /// binding must match on binding id, source kind, and authority so only - /// the derived locator digest changes. The write is a compare-and-swap - /// against the revision the caller read; concurrent mutation surfaces as - /// a typed `RevisionConflict` and the caller re-reads. + /// This is a daemon-owned identity-preserving write, not an operator + /// scope change: the caller derives `binding` from identity it already + /// holds (the registered project and its checkout). Only `BindSource` and + /// `RebindSource` are accepted, and both pass the same validator as a + /// protected apply. The write is a compare-and-swap against the revision + /// the caller read; concurrent mutation surfaces as a typed + /// `RevisionConflict` and the caller re-reads. #[hotpath::measure(future = true, label = "global_db.configuration.persist.rebind")] - pub async fn rebind_daemon_project_source_binding( + pub async fn publish_daemon_source_binding( &self, expected_revision_id: &ConfigurationRevisionId, - binding: &ScopeSourceBinding, + change: &ProtectedChange, occurred_at: UtcMicros, ) -> Result { expected_revision_id .validate() .map_err(ConfigurationError::validation)?; - binding.validate().map_err(ConfigurationError::validation)?; + let operation_kind = match change { + ProtectedChange::BindSource(_) => "daemon_source_binding_bind", + ProtectedChange::RebindSource(_) => "daemon_source_binding_rebind", + _ => { + return Err(ConfigurationError::validation_message( + "a daemon-owned source binding write must bind or rebind a source", + )); + } + }; + change.validate().map_err(ConfigurationError::validation)?; let transaction = self .db .begin_write_transaction() @@ -274,11 +283,9 @@ impl<'db> GlobalDbConfigurationControlStore<'db> { if ¤t.revision_id != expected_revision_id { return Err(ConfigurationError::RevisionConflict); } - let operation_digest = canonical_sha256(&( - "tracedecay.configuration.daemon-source-binding-rebind.v1", - binding, - )) - .map_err(ConfigurationError::validation)?; + let operation_digest = + canonical_sha256(&("tracedecay.configuration.daemon-source-binding.v1", change)) + .map_err(ConfigurationError::validation)?; let next_revision_id: ConfigurationRevisionId = derived_identifier( "configuration.revision.v1", &canonical_sha256(&( @@ -287,24 +294,19 @@ impl<'db> GlobalDbConfigurationControlStore<'db> { &operation_digest, )) .map_err(ConfigurationError::validation)?, - "configuration rebind revision id", + "configuration daemon source binding revision id", )?; - let snapshot = current - .snapshot - .apply_protected_change( - &ProtectedChange::RebindSource(binding.clone()), - &next_revision_id, - ) - .map_err(map_protected_change_snapshot_error)?; + let snapshot = + protected_change_snapshot_v1(¤t.snapshot, change, &next_revision_id)?; validate_snapshot_registry_completeness(&snapshot).map_err(map_store_error)?; - let actor_id = ActorId::new("actor.tracedecay-daemon.source-binding-rebind".to_owned()) + let actor_id = ActorId::new("actor.tracedecay-daemon.source-binding".to_owned()) .map_err(ConfigurationError::validation)?; let revision = ConfigurationRevisionRecordV1 { revision_id: next_revision_id.clone(), parent_revision_id: Some(expected_revision_id.clone()), snapshot, actor_id, - operation_kind: "daemon_source_binding_rebind".to_owned(), + operation_kind: operation_kind.to_owned(), created_at: occurred_at, }; insert_revision(&transaction, &revision) diff --git a/crates/tracedecay-global-db/src/configuration/store/control.rs b/crates/tracedecay-global-db/src/configuration/store/control.rs index 4b5e1bcbfd..ae5a0f36ac 100644 --- a/crates/tracedecay-global-db/src/configuration/store/control.rs +++ b/crates/tracedecay-global-db/src/configuration/store/control.rs @@ -4,9 +4,9 @@ use super::activation::latest_component_activation_states; use super::audit::{audit_from_transaction, insert_dry_run_audit_event}; use super::mutation::{ build_configuration_commit, commit_configuration_transaction, commit_direct_in_transaction, - current_state_from_transaction, derived_identifier, map_protected_change_snapshot_error, - map_store_error, replay_control_receipt, result_revision_id, rollback_redacted_changes, - validate_apply_request, validate_plan_evidence, + current_state_from_transaction, derived_identifier, map_store_error, + protected_change_snapshot_v1, replay_control_receipt, result_revision_id, + rollback_redacted_changes, validate_apply_request, validate_plan_evidence, }; use super::read::{read_change_plan_from_executor, read_revision_from_executor}; use super::write::insert_change_plan; @@ -240,10 +240,8 @@ impl ConfigurationControlStore for GlobalDbConfigurationControlStore<'_> { &request.idempotency_key, &request.operation_digest, )?; - let snapshot = current - .snapshot - .apply_protected_change(change, &next_revision_id) - .map_err(map_protected_change_snapshot_error)?; + let snapshot = + protected_change_snapshot_v1(¤t.snapshot, change, &next_revision_id)?; let sealed_target = StoredConfigurationProtectedOperationV1::from(&record.operation); let (commit, sealed_target_reference) = build_configuration_commit( diff --git a/crates/tracedecay-global-db/src/configuration/store/mutation.rs b/crates/tracedecay-global-db/src/configuration/store/mutation.rs index 9f2f8febfa..aa4673d5df 100644 --- a/crates/tracedecay-global-db/src/configuration/store/mutation.rs +++ b/crates/tracedecay-global-db/src/configuration/store/mutation.rs @@ -28,10 +28,11 @@ use super::{ ConfigurationRegistry, ConfigurationRevisionId, ConfigurationRevisionRecordV1, ConfigurationSettlementAuthorityV1, ConfigurationSnapshotV1, ConfigurationStoreError, ConfigurationStoreResult, ConfigurationValueV1, DirectConfigurationMutation, Executor, - ManifestDigest, ProtectedChangePlan, ProtectedChangeSnapshotError, QueryExecutor, - RedactedConfigurationChangeV1, Row, SOURCE_BINDINGS_SETTING_KEY, ScopeControlOperationV1, - ScopeRevalidationEvidenceV1, SettingKey, UtcMicros, WORK_TOPOLOGY_POLICY_SETTING_KEY, - canonical_sha256, invalid_store_data, params, registry_default_candidate, unavailable_store, + ManifestDigest, ProtectedChange, ProtectedChangePlan, ProtectedChangeSnapshotError, + QueryExecutor, RedactedConfigurationChangeV1, Row, SOURCE_BINDINGS_SETTING_KEY, + ScopeControlOperationV1, ScopeRevalidationEvidenceV1, SettingKey, UtcMicros, + WORK_TOPOLOGY_POLICY_SETTING_KEY, canonical_sha256, invalid_store_data, params, + registry_default_candidate, unavailable_store, }; pub(super) fn decode_stored_mutation_receipt( @@ -368,9 +369,20 @@ pub(super) async fn commit_configuration_transaction_with_registry( Ok(commit.receipt.clone()) } -pub(super) fn map_protected_change_snapshot_error( - error: ProtectedChangeSnapshotError, -) -> ConfigurationError { +/// The one validator for a protected change: preview and apply both derive +/// the candidate snapshot here, so a preview accepts exactly what apply +/// commits and refuses with apply's typed reason. +pub fn protected_change_snapshot_v1( + snapshot: &ConfigurationSnapshotV1, + change: &ProtectedChange, + revision_id: &ConfigurationRevisionId, +) -> Result { + snapshot + .apply_protected_change(change, revision_id) + .map_err(map_protected_change_snapshot_error) +} + +fn map_protected_change_snapshot_error(error: ProtectedChangeSnapshotError) -> ConfigurationError { match error { ProtectedChangeSnapshotError::Stale => ConfigurationError::PlanStale, ProtectedChangeSnapshotError::Domain(error) => ConfigurationError::validation(error), diff --git a/crates/tracedecay-mcp/src/handlers/info/status.rs b/crates/tracedecay-mcp/src/handlers/info/status.rs index c348cfd820..1bfdac8abe 100644 --- a/crates/tracedecay-mcp/src/handlers/info/status.rs +++ b/crates/tracedecay-mcp/src/handlers/info/status.rs @@ -3,6 +3,7 @@ use std::path::Path; use serde_json::{Value, json}; +use tracedecay_application::advisory::github_runtime::github_source_status_v1; use tracedecay_application::tracedecay::BranchDiagnostics; use tracedecay_contracts::code_index_freshness::{ CodeIndexFreshnessCoverageV1, CodeIndexReadinessWaitOutcomeV1, CodeIndexReadinessWaitReadV1, @@ -447,6 +448,13 @@ pub async fn handle_status( ready_serving_source.is_some_and(|source| source.current_source_verified), ); output["code_index_freshness"] = code_index_freshness; + output["github_source"] = match github_source_status_v1(ctx.project_root()) { + Some(source) => serde_json::to_value(&source)?, + None => json!({ + "state": "not_observed", + "reason": "the checkout has no GitHub origin, or its advisory owner has not mounted in this daemon", + }), + }; if include_storage_health { let mut storage_health = serde_json::to_value( hotpath::future!( diff --git a/crates/tracedecay-project/src/config.rs b/crates/tracedecay-project/src/config.rs index 916b67ae0c..a8504ba250 100644 --- a/crates/tracedecay-project/src/config.rs +++ b/crates/tracedecay-project/src/config.rs @@ -6,8 +6,12 @@ use tracedecay_contracts::clock::now_micros; use tracedecay_domain::ProjectId; use tracedecay_domain::configuration::{ CodeIndexWorkerSelectionV1, ConfigurationLayerIdV1, ConfigurationRevisionId, - ConfigurationValueV1, SOURCE_BINDINGS_SETTING_KEY, ScopeSourceBinding, SettingKey, - UserProfileId, + ConfigurationValueV1, ProtectedChange, SOURCE_BINDINGS_SETTING_KEY, ScopeSourceBinding, + SettingKey, UserProfileId, +}; + +use tracedecay_application::advisory::github_runtime::{ + daemon_owned_github_source_binding_v1, github_repository_from_remote_v1, }; use tracedecay_configuration::config::{PinnedRuntimeConfiguration, RuntimeTraceDecayConfig}; @@ -435,9 +439,9 @@ async fn open_runtime_configuration_from_store( // A concurrent open may have won the swap; adopt what it // published and re-verify it exactly. current = match store - .rebind_daemon_project_source_binding( + .publish_daemon_source_binding( ¤t.revision_id, - &daemon_binding, + &ProtectedChange::RebindSource(daemon_binding.clone()), now_micros(), ) .await @@ -454,12 +458,75 @@ async fn open_runtime_configuration_from_store( } } } + let current = provision_github_origin_source_binding(store, &target, current).await?; let configuration = PinnedRuntimeConfiguration::new(target, current.revision_id, current.snapshot)?; install_pinned_runtime_configuration(configuration.clone()); Ok(configuration) } +/// Binds the GitHub repository of the checkout's `origin` remote as this +/// project's GitHub source, so pull-request discovery and review reads are +/// authorized for exactly that repository. +/// +/// An operator-bound GitHub source for another repository is left alone: a +/// project has exactly one GitHub binding and the operator's choice wins. The +/// daemon-owned binding follows `origin` when the remote changes. +async fn provision_github_origin_source_binding( + store: &GlobalDbConfigurationControlStore<'_>, + target: &RuntimeConfigurationTarget, + mut current: ConfigurationCurrentStateV1, +) -> Result { + let Some((owner, repository)) = + tracedecay_runtime_core::git::git_remote_url(&target.project_root) + .as_deref() + .and_then(github_repository_from_remote_v1) + else { + return Ok(current); + }; + let binding = daemon_owned_github_source_binding_v1(&target.project_id, &owner, &repository) + .ok_or_else(|| { + config_error(format!( + "GitHub source binding for {owner}/{repository} could not be derived" + )) + })?; + let source_bindings_key = source_bindings_setting_key()?; + for _ in 0..2 { + let Some(ConfigurationValueV1::SourceBindings(bindings)) = + current.snapshot.effective_values.get(&source_bindings_key) + else { + return Err(TraceDecayError::reset_required( + "configuration", + "canonical configuration source bindings are missing", + )); + }; + let github = bindings.iter().find(|candidate| { + candidate.source_kind == binding.source_kind && candidate.authority == binding.authority + }); + let change = match github { + Some(existing) if existing.source_locator_digest == binding.source_locator_digest => { + return Ok(current); + } + Some(existing) if existing.binding_id != binding.binding_id => return Ok(current), + Some(_) => ProtectedChange::RebindSource(binding.clone()), + None => ProtectedChange::BindSource(binding.clone()), + }; + match store + .publish_daemon_source_binding(¤t.revision_id, &change, now_micros()) + .await + { + Ok(state) => return Ok(state), + Err(ConfigurationError::RevisionConflict) => { + current = store.current().await.map_err(map_configuration_error)?; + } + Err(error) => return Err(map_configuration_error(error)), + } + } + Err(config_error( + "GitHub source binding lost two consecutive configuration revision races", + )) +} + /// Test-only convenience wrapper over /// [`open_runtime_configuration_for_registered_database`] that returns just the /// pinned snapshot. Production open paths keep the full diff --git a/crates/tracedecay-project/src/config/tests.rs b/crates/tracedecay-project/src/config/tests.rs index 5b20d575a4..c945d6db52 100644 --- a/crates/tracedecay-project/src/config/tests.rs +++ b/crates/tracedecay-project/src/config/tests.rs @@ -212,6 +212,7 @@ mod runtime_configuration_cutover { use std::collections::BTreeMap; use tempfile::TempDir; + use tracedecay_application::advisory::github_runtime::daemon_owned_github_source_binding_v1; use tracedecay_domain::configuration::{ AuthorityRef, ConfigurationGrantId, ConfigurationGrantReceiptId, ConfigurationIdempotencyKey, ConfigurationLayerIdV1, ConfigurationMutationEffectV1, @@ -219,7 +220,7 @@ mod runtime_configuration_cutover { ConfigurationMutationSinkV1, ConfigurationRevisionId, ConfigurationValueV1, DIAGNOSTICS_PREWARM_SETTING_KEY, INDEX_NATIVE_GRAPH_ACTIVATION_SETTING_KEY, SOURCE_BINDINGS_SETTING_KEY, SYNC_AUTO_WATCH_SETTING_KEY, ScopeSourceBinding, SettingKey, - SourceBindingId, + SourceBindingId, SourceKindV1, }; use tracedecay_domain::{AccessPolicyDigest, ActorId, ProjectId, UtcMicros}; @@ -909,6 +910,109 @@ mod runtime_configuration_cutover { ); } + fn github_source_bindings( + configuration: &PinnedRuntimeConfiguration, + ) -> Vec<(String, tracedecay_domain::LocatorDigest)> { + let key = SettingKey::new(SOURCE_BINDINGS_SETTING_KEY).expect("source bindings key"); + let Some(ConfigurationValueV1::SourceBindings(bindings)) = + configuration.snapshot().effective_values.get(&key) + else { + panic!("configuration carries no source bindings"); + }; + bindings + .iter() + .filter(|binding| binding.source_kind == SourceKindV1::GitHub) + .map(|binding| { + ( + binding.binding_id.as_str().to_owned(), + binding.source_locator_digest.clone(), + ) + }) + .collect() + } + + /// A fresh init binds the checkout's `origin` as the project's GitHub + /// source; the binding follows `origin` when the remote changes. + #[cfg(unix)] + #[tokio::test] + async fn fresh_open_binds_the_github_origin_as_the_project_github_source() { + let _profile = crate::config::PinnedUserDataDir::new(); + let root = TempDir::new().expect("temporary root"); + let checkout = root.path().join("anyhow"); + std::fs::create_dir_all(&checkout).expect("create checkout"); + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(&checkout) + .output() + .expect("run git"); + assert!(output.status.success(), "git {args:?} failed"); + }; + git(&["init", "-b", "fix/462-new-with-backtrace", "--quiet"]); + git(&[ + "remote", + "add", + "origin", + "https://github.com/dtolnay/anyhow.git", + ]); + let project_id = project_id("proj_runtime_github_origin"); + tracedecay_runtime_core::storage::pin_fixture_repository_identity( + &checkout, + project_id.as_str(), + ) + .expect("write enrollment marker"); + let layout = + tracedecay_runtime_core::storage::resolve_layout_for_current_profile(&checkout) + .expect("resolve store layout"); + std::fs::create_dir_all(&layout.data_root).expect("create data root"); + let runtime = HostAdmissionTestRuntimeV1::project( + tracedecay_runtime_core::storage::default_profile_root().unwrap(), + &checkout, + project_id.clone(), + ) + .await + .expect("open retained project runtime"); + let expected = |owner: &str, repository: &str| { + let binding = daemon_owned_github_source_binding_v1(&project_id, owner, repository) + .expect("GitHub origin binding"); + vec![( + binding.binding_id.as_str().to_owned(), + binding.source_locator_digest, + )] + }; + + let initial = runtime + .ensure_runtime_configuration_for_test(&checkout, &layout) + .await + .expect("fresh open"); + assert_eq!( + github_source_bindings(&initial), + expected("dtolnay", "anyhow") + ); + assert_eq!( + github_source_bindings(&initial)[0].0, + "binding.tracedecay-daemon.github-origin" + ); + + let reopened = runtime + .ensure_runtime_configuration_for_test(&checkout, &layout) + .await + .expect("reopen"); + assert_eq!(reopened.revision_id(), initial.revision_id()); + + git(&[ + "remote", + "set-url", + "origin", + "git@github.com:rust-lang/log.git", + ]); + let moved = runtime + .ensure_runtime_configuration_for_test(&checkout, &layout) + .await + .expect("open after the remote moved"); + assert_eq!(github_source_bindings(&moved), expected("rust-lang", "log")); + } + /// Moving or renaming a checkout changes only the path-derived locator /// digest; the registry still resolves the same registered project. The /// open path must republish the daemon binding with the new digest as a diff --git a/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs index 10759b1994..5f6f74018d 100644 --- a/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs @@ -63,7 +63,7 @@ async fn typescript_only_checkout_runs_the_pull_request_advisory_cycle() { "remote", "add", "origin", - "https://github.com/tracedecay-fixture/typescript-admission.git", + "https://git.example.invalid/tracedecay-fixture/typescript-admission.git", ], ); git(&project, &["add", "."]); diff --git a/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs index 74b2191aa4..eadbcbd0b7 100644 --- a/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs @@ -10,12 +10,15 @@ use std::path::Path; use serde_json::{Value, json}; use tempfile::TempDir; -use tracedecay_contracts::ConfigurationProtectedPreviewRequestV1; +use tracedecay_contracts::{ + ConfigurationProtectedApplyRequestV1, ConfigurationProtectedPreviewRequestV1, +}; use tracedecay_domain::configuration::{ - AccessRuleId, AuthorityRef, ConfigurationRevisionId, ProtectedChange, RuleEffect, - ScopeAccessRule, ScopeAccessSubjectV1, SourceBindingId, SourceKindV1, + AccessRuleId, AuthorityRef, ConfigurationIdempotencyKey, ConfigurationRevisionId, + ProtectedChange, ProtectedChangePlan, RuleEffect, ScopeAccessRule, ScopeAccessSubjectV1, + ScopeSourceBinding, SourceBindingId, SourceKindV1, }; -use tracedecay_domain::{CapabilityId, ManifestDigest}; +use tracedecay_domain::{CapabilityId, LocatorDigest, ManifestDigest}; use super::journey_test_support::{git, tool_answer}; use super::*; @@ -201,29 +204,25 @@ async fn protected_preview_redacts_the_change_and_refuses_stale_or_invalid_input &[ACCESS_RULE_ID, DENIED_CAPABILITY], ); + // Apply refuses to unbind a binding the snapshot does not hold, so the + // preview must refuse with apply's typed reason instead of issuing a plan + // apply would reject. let unbind = ProtectedChange::UnbindSource { binding_id: SourceBindingId::new(ABSENT_BINDING_ID).expect("binding identity"), }; - let unbind_digest = unbind - .compute_digest() - .expect("unbind digest") - .as_str() - .to_owned(); - assert_ne!( - access_digest, unbind_digest, - "the two submitted changes must not share a digest" - ); let (refused, unbound) = call_preview(&harness, &project, preview_arguments(&unbind, &revision)).await; - assert!(!refused, "unbind preview was refused: {unbound}"); - assert_redacted_plan( + assert!( + refused, + "an absent-binding unbind must be refused: {unbound}" + ); + assert_problem( &unbound, - revision.as_str(), - "scope.source_bindings.v1", - "source_unbind", - before_digest.as_str(), - &unbind_digest, - &[ABSENT_BINDING_ID], + "stale", + "configuration.stale", + "The configuration preview is stale", + "after_revalidate", + json!(["refresh"]), ); let mut stale = preview_arguments(&access_rule, &revision); @@ -273,3 +272,71 @@ async fn protected_preview_redacts_the_change_and_refuses_stale_or_invalid_input harness.shutdown().await; } + +/// A GitHub source binding whose id sorts before the project-open binding +/// previews and applies to the same outcome: both place it in canonical +/// binding order. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn protected_bind_source_preview_and_apply_share_one_outcome() { + let isolation = TempDir::new().expect("journey isolation"); + let project = isolation.path().join("project"); + initialize_project(&project); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("production composition"); + let graph = harness.server(&project).expect("project server").cg().await; + let project_id = graph + .configuration_runtime() + .configuration_target() + .project_id + .clone(); + let revision = graph + .configuration_runtime() + .client() + .current() + .await + .expect("current configuration") + .revision_id() + .clone(); + drop(graph); + + let bind = ProtectedChange::BindSource( + ScopeSourceBinding::new( + SourceBindingId::new("binding.github.rust-lang-log").expect("binding identity"), + SourceKindV1::GitHub, + LocatorDigest::new(format!("sha256:{}", "d".repeat(64))).expect("locator digest"), + AuthorityRef::Project(project_id), + ) + .expect("GitHub source binding"), + ); + let (refused, preview) = + call_preview(&harness, &project, preview_arguments(&bind, &revision)).await; + assert!(!refused, "bind preview was refused: {preview}"); + let plan: ProtectedChangePlan = + serde_json::from_value(preview["outcome"]["value"]["payload"].clone()) + .expect("protected change plan"); + let mut apply = serde_json::to_value(ConfigurationProtectedApplyRequestV1 { + plan_id: plan.plan_id, + expected_base_revision_id: revision, + operation_digest: plan.operation_digest, + idempotency_key: ConfigurationIdempotencyKey::new( + "configuration.bind-github-before-daemon", + ) + .expect("idempotency key"), + }) + .expect("protected apply arguments"); + apply["format"] = json!("json"); + let response = harness + .call_tool(&project, "tracedecay_configuration_protected_apply", apply) + .await + .expect("protected apply tools/call"); + let (refused, applied) = tool_answer(&response); + assert!( + !refused, + "apply refused the plan its preview issued: {applied}" + ); + assert_eq!(applied["outcome"]["outcome"], "effect", "{applied}"); + + harness.shutdown().await; +} diff --git a/crates/tracedecay/src/daemon/production_harness/delivery_read_gate_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/delivery_read_gate_journey_test.rs index 5ed8fecc03..94937749be 100644 --- a/crates/tracedecay/src/daemon/production_harness/delivery_read_gate_journey_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/delivery_read_gate_journey_test.rs @@ -1,9 +1,9 @@ //! Delivery read gate journey over the production project composition. //! //! Project open must register the daemon-owned Delivery read authority even -//! when no GitHub credential is configured, so the dashboard reads the exact -//! typed mount gate instead of a generic missing-authority answer. The gate -//! must stay readable while the feedback/advisory owners remain deferred. +//! when no GitHub provider can mount, so the dashboard reads the exact typed +//! mount gate instead of a generic missing-authority answer. The gate must +//! stay readable while the feedback/advisory owners remain deferred. use std::collections::BTreeSet; use std::time::{Duration, Instant}; @@ -27,7 +27,7 @@ use super::journey_test_support::git; use super::*; #[tokio::test] -async fn project_open_registers_the_typed_delivery_gate_without_a_github_credential() { +async fn project_open_registers_the_typed_delivery_gate_without_a_github_remote() { let isolation = tempfile::TempDir::new().expect("production harness isolation"); let project = isolation.path().join("project"); std::fs::create_dir_all(&project).expect("project root"); @@ -37,15 +37,15 @@ async fn project_open_registers_the_typed_delivery_gate_without_a_github_credent ) .expect("project source"); git(&project, &["init", "--quiet", "-b", "main"]); - // A recognizable GitHub remote with no registered credential is the exact - // production shape behind the GitHubCredentialNotConfigured gate. + // A remote on a host other than github.com is the exact production shape + // behind the NoGitRemote gate. git( &project, &[ "remote", "add", "origin", - "https://github.com/tracedecay-fixture/delivery-gate-journey.git", + "https://git.example.invalid/tracedecay-fixture/delivery-gate-journey.git", ], ); git(&project, &["add", "."]); @@ -66,7 +66,7 @@ async fn project_open_registers_the_typed_delivery_gate_without_a_github_credent let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) .await - .expect("production composition opens without a GitHub credential"); + .expect("production composition opens without a GitHub remote"); let resources = harness.resources.as_ref().expect("live harness resources"); let canonical_project = resources .servers @@ -79,7 +79,9 @@ async fn project_open_registers_the_typed_delivery_gate_without_a_github_credent .service .delivery_read_authority(Some(&canonical_project)) .await - .expect("project open must register the Delivery read authority even without a credential"); + .expect( + "project open must register the Delivery read authority even without a GitHub remote", + ); let scope = authority.scope().clone(); let grant = CapabilityGrantSnapshot::new( @@ -119,9 +121,9 @@ async fn project_open_registers_the_typed_delivery_gate_without_a_github_credent assert_eq!( authority.handle().read(&context, &request, &control).await, ProjectDeliveryReadOutcomeV1::NotMounted { - gate: ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured, + gate: ProjectDeliveryProviderMountGateV1::NoGitRemote, }, - "the registered Delivery read must answer with the exact typed credential gate" + "the registered Delivery read must answer with the exact typed remote gate" ); harness.shutdown().await; diff --git a/crates/tracedecay/src/daemon/project_open_owners.rs b/crates/tracedecay/src/daemon/project_open_owners.rs index 8dbc5aa842..6d2ee9fe9a 100644 --- a/crates/tracedecay/src/daemon/project_open_owners.rs +++ b/crates/tracedecay/src/daemon/project_open_owners.rs @@ -10,10 +10,9 @@ use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; -use tracedecay_application::advisory::GitHubRepositoryTargetV1; +use tracedecay_application::advisory::github_runtime::github_repository_from_remote_v1; use tracedecay_application::project_open_authorization::project_open_work_grant; use tracedecay_contracts::{ApplicationContractError, ResolvedScope, now_micros}; -use tracedecay_domain::feedback::GitHubPullRequestIdV1; use tracedecay_domain::{ProjectId, UtcMicros, canonical_sha256}; use super::DaemonInvocationState; @@ -507,7 +506,7 @@ pub(super) async fn register_project_open_production_owners( // the sole producer of canonical provider observations and anchors. if tracedecay_runtime_core::git::git_remote_url(project_root) .as_deref() - .and_then(github_repository_from_remote) + .and_then(github_repository_from_remote_v1) .is_some() { let stack_coordinator = invocation.github_stack_coordinator(); @@ -911,48 +910,6 @@ async fn register_production_lsp_owner( .await } -fn github_repository_from_remote(remote: &str) -> Option<(String, String)> { - let (owner, repository) = if let Ok(url) = url::Url::parse(remote) { - if (url.scheme() != "https" && url.scheme() != "ssh") - || !url.host_str()?.eq_ignore_ascii_case("github.com") - || url.password().is_some() - || (url.scheme() == "https" && !url.username().is_empty()) - || (url.scheme() == "ssh" && url.username() != "git") - || url.query().is_some() - || url.fragment().is_some() - { - return None; - } - let segments = url.path_segments()?.collect::>(); - if segments.len() != 2 { - return None; - } - (segments[0].to_owned(), segments[1].to_owned()) - } else { - let remote = remote.strip_prefix("git@github.com:")?; - let mut segments = remote.split('/'); - let owner = segments.next()?; - let repository = segments.next()?; - if segments.next().is_some() { - return None; - } - (owner.to_owned(), repository.to_owned()) - }; - let repository = repository - .strip_suffix(".git") - .unwrap_or(&repository) - .to_owned(); - let target = GitHubRepositoryTargetV1 { - owner, - repository, - pull_request_number: 1, - pull_request_id: GitHubPullRequestIdV1::new("1").ok()?, - }; - target - .validate() - .then_some((target.owner, target.repository)) -} - pub(super) fn project_open_retained_grant( access: &ProjectSourceAccessSnapshot, observed_at: UtcMicros, diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs index 9a0138698f..d071bcfa0a 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs @@ -9,9 +9,10 @@ use sha2::{Digest, Sha256}; use tracedecay_application::advisory::github_runtime::{ ConfiguredGitHubSourceAccessAuthorityV1, GitHubDiscoveryControlV1, GitHubExactCommitDiscoveryOutcomeV1, GitHubProviderLifecycleV1, GitHubSourceAccessAuthorityV1, - ProfileGitHubReadOnlyCredentialMountOutcomeV1, RegisteredGitHubReadOnlyCredentialV1, - discover_exact_commit_pull_request_v1, public_repository_read_credential_v1, - resolve_registered_github_read_only_credential_v1, + GitHubSourceStatusV1, ProfileGitHubReadOnlyCredentialMountOutcomeV1, + RegisteredGitHubReadOnlyCredentialV1, discover_exact_commit_pull_request_v1, + github_repository_from_remote_v1, public_repository_read_credential_v1, + record_github_source_status_v1, resolve_registered_github_read_only_credential_v1, }; use tracedecay_application::advisory::{ AdvisoryCycleControl, AdvisoryCycleOutcome, AdvisoryCycleRequest, AdvisoryHookDeliveryV1, @@ -1861,8 +1862,8 @@ async fn register_production_advisory_owner( /// checkout as its own project-open component, before and independent of the /// feedback/advisory owners whose mounts can stay deferred behind a sealed /// code-index generation. A provider mount gate is retained as a typed -/// Delivery answer so the dashboard can tell "configure a token" apart from -/// "broken" even while the advisory chain never mounts. +/// Delivery answer so the dashboard can tell an unmountable provider apart +/// from a broken one even while the advisory chain never mounts. async fn register_project_delivery_read_authority( invocation: &DaemonInvocationState, project_root: &Path, @@ -2168,7 +2169,7 @@ fn resolve_production_github_provider_access( let Some(remote_url) = tracedecay_runtime_core::git::git_remote_url(project_root) else { return Err(ProjectDeliveryProviderMountGateV1::NoGitRemote); }; - let Some((owner, repository)) = super::github_repository_from_remote(&remote_url) else { + let Some((owner, repository)) = github_repository_from_remote_v1(&remote_url) else { return Err(ProjectDeliveryProviderMountGateV1::NoGitRemote); }; let profile_id = &state.session_db.binding().shard_id.profile_id; @@ -2177,12 +2178,13 @@ fn resolve_production_github_provider_access( &owner, &repository, ) { - ProfileGitHubReadOnlyCredentialMountOutcomeV1::Public => { + // A repository the profile does not name is read as the `origin` + // project-open bound: with the local GitHub login when there is one, + // anonymously otherwise. + ProfileGitHubReadOnlyCredentialMountOutcomeV1::Public + | ProfileGitHubReadOnlyCredentialMountOutcomeV1::NotConfigured => { public_repository_read_credential_v1(&owner, &repository) } - ProfileGitHubReadOnlyCredentialMountOutcomeV1::NotConfigured => { - return Err(ProjectDeliveryProviderMountGateV1::GitHubCredentialNotConfigured); - } ProfileGitHubReadOnlyCredentialMountOutcomeV1::Rejected => { return Err(ProjectDeliveryProviderMountGateV1::GitHubAccessRefused); } @@ -2336,6 +2338,14 @@ async fn discover_production_pull_request( } _ => None, }; + let source = GitHubSourceStatusV1::observed(owner, repository, credential, discovery.as_ref()); + tracing::info!( + event = "github_source", + state = ?source.state, + repository = %source.repository, + project = %project_root.display(), + ); + record_github_source_status_v1(project_root, source); match &discovery { Some(GitHubExactCommitDiscoveryOutcomeV1::Found(pull)) => tracing::info!( event = "github_pull_request_discovery", diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 532fbb25ce..32b8e94ebb 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -880,6 +880,27 @@ receives ordinary request metadata, including the connection source address and the TraceDecay user agent. A timeout or unavailable service means release metadata is unavailable, not that no update exists. +### GitHub source and pull-request discovery + +`tracedecay init`, and every later project open, binds the GitHub repository +of the checkout's `origin` remote as the project's GitHub source +(`binding.tracedecay-daemon.github-origin` in `scope.source_bindings.v1`). The +binding follows `origin` when the remote changes. A GitHub binding you added +yourself for another repository is left in place: a project has exactly one +GitHub source. + +Reads use the first credential available, in this order: `GH_TOKEN`, the +`gh auth token` login, then the git credential helper's stored login for +`https://github.com`. TraceDecay never stores the token. `tracedecay status` +reports how the source is read, the pull-request discovery outcome for the +checkout's exact head, and a remedy when there is one: + +| `github_source` state | Meaning | +|---|---| +| `bound` | A credential authorizes the reads. | +| `unauthenticated_public` | No credential was found, so the repository is read anonymously as a public repository. That allows 60 requests per hour. Discovery uses the REST issue search's `head:` qualifier, which also finds fork-headed pull requests. | +| `denied_no_credential` | No credential was found and GitHub refused the anonymous read: the repository is private or absent. Run `gh auth login`, or set `GH_TOKEN` to a token with read access, then reopen the project. | + ### Private GitHub review sources An explicitly configured private GitHub review source can use an optional From 9201acb4ca8c2167f07c7e2c7a257a0316cc52dc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 12:27:06 +0000 Subject: [PATCH 2/5] fix(delivery): read anonymous review comments over REST --- ...ust_lang_log_741_review_comments.rest.json | 219 ++++++++++++++++++ .../src/advisory/runtime/cycle.rs | 20 +- crates/tracedecay-application/src/delivery.rs | 153 +++++++----- .../code_index_scheduler/tests/residency.rs | 47 ++++ .../project_open_owners/advisory_runtime.rs | 94 ++++---- 5 files changed, 427 insertions(+), 106 deletions(-) create mode 100644 crates/tracedecay-application/src/advisory/fixtures/rust_lang_log_741_review_comments.rest.json diff --git a/crates/tracedecay-application/src/advisory/fixtures/rust_lang_log_741_review_comments.rest.json b/crates/tracedecay-application/src/advisory/fixtures/rust_lang_log_741_review_comments.rest.json new file mode 100644 index 0000000000..30e05701ce --- /dev/null +++ b/crates/tracedecay-application/src/advisory/fixtures/rust_lang_log_741_review_comments.rest.json @@ -0,0 +1,219 @@ +{ + "capture": { + "captured_at": "2026-09-26T12:20:00Z", + "method": "GET", + "url": "https://api.github.com/repos/rust-lang/log/pulls/741/comments?per_page=100", + "authentication": "none", + "documentation": "https://docs.github.com/en/rest/pulls/comments#list-review-comments-on-a-pull-request" + }, + "response": [ + { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069686687", + "pull_request_review_id": 5275645212, + "id": 4069686687, + "node_id": "PRRC_kwDOAarcas7ykn2f", + "diff_hunk": "@@ -123,12 +120,24 @@ jobs:\n with:\n components: clippy\n toolchain: \"1.71\"\n+ - run: cargo run --verbose --manifest-path test_max_level_features/Cargo.toml\n+ - run: cargo run --verbose --manifest-path test_max_level_features/Cargo.toml --release\n+\n+ msrv-test:\n+ name: MSRV test\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6.0.2\n+ with:\n+ persist-credentials: false\n+ - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1\n+ with:\n+ components: clippy", + "path": ".github/workflows/main.yml", + "commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "original_commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "user": { + "login": "Thomasdezeeuw", + "id": 3159064, + "node_id": "MDQ6VXNlcjMxNTkwNjQ=", + "avatar_url": "https://avatars.githubusercontent.com/u/3159064?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Thomasdezeeuw", + "html_url": "https://github.com/Thomasdezeeuw", + "followers_url": "https://api.github.com/users/Thomasdezeeuw/followers", + "following_url": "https://api.github.com/users/Thomasdezeeuw/following{/other_user}", + "gists_url": "https://api.github.com/users/Thomasdezeeuw/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Thomasdezeeuw/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Thomasdezeeuw/subscriptions", + "organizations_url": "https://api.github.com/users/Thomasdezeeuw/orgs", + "repos_url": "https://api.github.com/users/Thomasdezeeuw/repos", + "events_url": "https://api.github.com/users/Thomasdezeeuw/events{/privacy}", + "received_events_url": "https://api.github.com/users/Thomasdezeeuw/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": "I don't think we need Clippy here? Same for the msrv-check job.\n\n*[View changes since the review](https://triagebot.infra.rust-lang.org/gh-changes-since/rust-lang/log/741/8034743dd9d7f7583bd9a670271483d176130911..1a4b67cfc41237e673dafd0dfc414577f0b5d327)*", + "created_at": "2026-09-22T08:19:31Z", + "updated_at": "2026-09-22T08:26:05Z", + "html_url": "https://github.com/rust-lang/log/pull/741#discussion_r4069686687", + "pull_request_url": "https://api.github.com/repos/rust-lang/log/pulls/741", + "_links": { + "self": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069686687" + }, + "html": { + "href": "https://github.com/rust-lang/log/pull/741#discussion_r4069686687" + }, + "pull_request": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/741" + } + }, + "reactions": { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069686687/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "start_line": null, + "original_start_line": null, + "start_side": null, + "line": 135, + "original_line": 135, + "side": "RIGHT", + "author_association": "COLLABORATOR", + "original_position": 39, + "position": 39, + "subject_type": "line" + }, + { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069691901", + "pull_request_review_id": 5275645212, + "id": 4069691901, + "node_id": "PRRC_kwDOAarcas7ykpH9", + "diff_hunk": "@@ -111,9 +108,9 @@ jobs:\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_serde\"\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_std\"\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_sval kv_serde\"\n-\n- msrv:\n- name: MSRV\n+ \n+ msrv-check:\n+ name: MSRV build", + "path": ".github/workflows/main.yml", + "commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "original_commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "user": { + "login": "Thomasdezeeuw", + "id": 3159064, + "node_id": "MDQ6VXNlcjMxNTkwNjQ=", + "avatar_url": "https://avatars.githubusercontent.com/u/3159064?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Thomasdezeeuw", + "html_url": "https://github.com/Thomasdezeeuw", + "followers_url": "https://api.github.com/users/Thomasdezeeuw/followers", + "following_url": "https://api.github.com/users/Thomasdezeeuw/following{/other_user}", + "gists_url": "https://api.github.com/users/Thomasdezeeuw/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Thomasdezeeuw/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Thomasdezeeuw/subscriptions", + "organizations_url": "https://api.github.com/users/Thomasdezeeuw/orgs", + "repos_url": "https://api.github.com/users/Thomasdezeeuw/repos", + "events_url": "https://api.github.com/users/Thomasdezeeuw/events{/privacy}", + "received_events_url": "https://api.github.com/users/Thomasdezeeuw/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": "Nit: the job short name is check, the display name is build and we're actually running of the test, probably want to make that consistent.\n\n*[View changes since the review](https://triagebot.infra.rust-lang.org/gh-changes-since/rust-lang/log/741/8034743dd9d7f7583bd9a670271483d176130911..1a4b67cfc41237e673dafd0dfc414577f0b5d327)*", + "created_at": "2026-09-22T08:20:15Z", + "updated_at": "2026-09-22T08:26:06Z", + "html_url": "https://github.com/rust-lang/log/pull/741#discussion_r4069691901", + "pull_request_url": "https://api.github.com/repos/rust-lang/log/pulls/741", + "_links": { + "self": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069691901" + }, + "html": { + "href": "https://github.com/rust-lang/log/pull/741#discussion_r4069691901" + }, + "pull_request": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/741" + } + }, + "reactions": { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069691901/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "start_line": null, + "original_start_line": null, + "start_side": null, + "line": 113, + "original_line": 113, + "side": "RIGHT", + "author_association": "COLLABORATOR", + "original_position": 19, + "position": 19, + "subject_type": "line" + }, + { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069777906", + "pull_request_review_id": 5275761549, + "id": 4069777906, + "node_id": "PRRC_kwDOAarcas7yk-Hy", + "diff_hunk": "@@ -111,9 +108,9 @@ jobs:\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_serde\"\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_std\"\n - run: cargo build --verbose -Z minimal-versions --features \"kv kv_sval kv_serde\"\n-\n- msrv:\n- name: MSRV\n+ \n+ msrv-check:\n+ name: MSRV build", + "path": ".github/workflows/main.yml", + "commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "original_commit_id": "1a4b67cfc41237e673dafd0dfc414577f0b5d327", + "user": { + "login": "KodrAus", + "id": 6721458, + "node_id": "MDQ6VXNlcjY3MjE0NTg=", + "avatar_url": "https://avatars.githubusercontent.com/u/6721458?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/KodrAus", + "html_url": "https://github.com/KodrAus", + "followers_url": "https://api.github.com/users/KodrAus/followers", + "following_url": "https://api.github.com/users/KodrAus/following{/other_user}", + "gists_url": "https://api.github.com/users/KodrAus/gists{/gist_id}", + "starred_url": "https://api.github.com/users/KodrAus/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/KodrAus/subscriptions", + "organizations_url": "https://api.github.com/users/KodrAus/orgs", + "repos_url": "https://api.github.com/users/KodrAus/repos", + "events_url": "https://api.github.com/users/KodrAus/events{/privacy}", + "received_events_url": "https://api.github.com/users/KodrAus/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": "Fair 👍 I’ll clean these up", + "created_at": "2026-09-22T08:32:38Z", + "updated_at": "2026-09-22T08:32:39Z", + "html_url": "https://github.com/rust-lang/log/pull/741#discussion_r4069777906", + "pull_request_url": "https://api.github.com/repos/rust-lang/log/pulls/741", + "_links": { + "self": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069777906" + }, + "html": { + "href": "https://github.com/rust-lang/log/pull/741#discussion_r4069777906" + }, + "pull_request": { + "href": "https://api.github.com/repos/rust-lang/log/pulls/741" + } + }, + "reactions": { + "url": "https://api.github.com/repos/rust-lang/log/pulls/comments/4069777906/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "start_line": null, + "original_start_line": null, + "start_side": null, + "line": 113, + "original_line": 113, + "side": "RIGHT", + "in_reply_to_id": 4069691901, + "author_association": "CONTRIBUTOR", + "original_position": 19, + "position": 19, + "subject_type": "line" + } + ] +} diff --git a/crates/tracedecay-application/src/advisory/runtime/cycle.rs b/crates/tracedecay-application/src/advisory/runtime/cycle.rs index 30b70159fd..0de9aaa927 100644 --- a/crates/tracedecay-application/src/advisory/runtime/cycle.rs +++ b/crates/tracedecay-application/src/advisory/runtime/cycle.rs @@ -165,18 +165,18 @@ where } }; // Retain the allowlisted pull-request identity read beside a - // usable thread refresh so Delivery can serve PR title, state, + // usable review refresh so Delivery can serve PR title, state, // and diff shape. It contributes no advisory findings and a - // rate-limited or denied thread refresh never spends a second + // rate-limited or denied review refresh never spends a second // provider read. - if provider_request.operation - == GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads - && matches!( - outcome, - GitHubReviewRefreshOutcomeV1::Stored(_) - | GitHubReviewRefreshOutcomeV1::Stale - ) - { + if matches!( + provider_request.operation, + GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads + | GitHubReviewReadOperationV1::RestListPullRequestReviewComments + ) && matches!( + outcome, + GitHubReviewRefreshOutcomeV1::Stored(_) | GitHubReviewRefreshOutcomeV1::Stale + ) { let identity_request = GitHubReviewReadRequestV1 { operation: GitHubReviewReadOperationV1::RestGetPullRequest, scope: provider_request.scope.clone(), diff --git a/crates/tracedecay-application/src/delivery.rs b/crates/tracedecay-application/src/delivery.rs index a641d9e546..16af631e53 100644 --- a/crates/tracedecay-application/src/delivery.rs +++ b/crates/tracedecay-application/src/delivery.rs @@ -2236,7 +2236,7 @@ mod tests { GitHubGraphQlReadRequestV1, GitHubOfficialResponseDecoderV1, GitHubProviderLifecycleV1, GitHubReadNetworkMetadataV1, GitHubReadNetworkOutcomeV1, GitHubReadNetworkResponseV1, GitHubReadNetworkStatusV1, GitHubReadOnlyConnector, GitHubReadOnlyDescriptorSetV1, - GitHubReadOnlyNetworkAuthorityV1, GitHubReadOnlyRuntimeTransportV1, + GitHubReadOnlyNetworkAuthorityV1, GitHubReadOnlyRuntimeTransportV1, GitHubRestDescriptorV1, GitHubRestReadRequestV1, GitHubReviewAnchorSeedV1, GitHubReviewProviderIdentityV1, GitHubReviewRefreshCoordinatorV1, GitHubReviewRefreshOutcomeV1, }; @@ -3317,22 +3317,11 @@ mod tests { assert!(github_http_is_official(&GitHubHttpReadConfigV1::default())); } - struct ReviewThreadsNetwork(Vec); + /// Answers every read with one captured GitHub body. + struct CapturedReviewNetwork(Vec); - impl GitHubReadOnlyNetworkAuthorityV1 for ReviewThreadsNetwork { - fn get<'a>( - &'a self, - _context: &'a RequestContext, - _request: &'a GitHubRestReadRequestV1, - ) -> FeedbackPortFuture<'a, GitHubReadNetworkOutcomeV1> { - Box::pin(async { GitHubReadNetworkOutcomeV1::Unavailable }) - } - - fn query<'a>( - &'a self, - _context: &'a RequestContext, - _request: &'a GitHubGraphQlReadRequestV1, - ) -> FeedbackPortFuture<'a, GitHubReadNetworkOutcomeV1> { + impl CapturedReviewNetwork { + fn answer(&self) -> FeedbackPortFuture<'_, GitHubReadNetworkOutcomeV1> { let body = self.0.clone(); Box::pin(async move { GitHubReadNetworkOutcomeV1::Response(GitHubReadNetworkResponseV1 { @@ -3349,6 +3338,24 @@ mod tests { } } + impl GitHubReadOnlyNetworkAuthorityV1 for CapturedReviewNetwork { + fn get<'a>( + &'a self, + _context: &'a RequestContext, + _request: &'a GitHubRestReadRequestV1, + ) -> FeedbackPortFuture<'a, GitHubReadNetworkOutcomeV1> { + self.answer() + } + + fn query<'a>( + &'a self, + _context: &'a RequestContext, + _request: &'a GitHubGraphQlReadRequestV1, + ) -> FeedbackPortFuture<'a, GitHubReadNetworkOutcomeV1> { + self.answer() + } + } + struct ReviewAnchors; impl GitHubCanonicalReviewAnchorAuthorityV1 for ReviewAnchors { @@ -3450,41 +3457,26 @@ mod tests { .unwrap() } - /// rust-lang/log#741 as GitHub served it, with the reply body replaced - /// by one the privacy sanitizer must refuse. - #[tokio::test] - async fn one_quarantined_review_body_leaves_the_rest_of_the_pull_request_published() { - let mut capture: serde_json::Value = serde_json::from_str(include_str!( - "advisory/fixtures/rust_lang_log_741_review_threads.graphql.json" - )) - .unwrap(); - let mut response = capture["response"].take(); - let mut comments = response["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"] - .as_array_mut() - .unwrap() - .iter_mut() - .flat_map(|thread| thread["comments"]["nodes"].as_array_mut().unwrap()) - .collect::>(); - assert_eq!(comments.len(), 3); - let reply = comments - .iter_mut() - .find(|comment| comment["databaseId"] == 4_069_777_906_u64) - .unwrap(); - reply["bodyText"] = json!("vault_passphrase: ordinary-value\n broken: [unclosed\n"); - + /// Refreshes one captured rust-lang/log#741 review read through the + /// production decoder, runtime transport, refresh coordinator and store, + /// then reads the Delivery pull-request lane. + async fn delivered_review_lane( + operation: GitHubReviewReadOperationV1, + response: &serde_json::Value, + ) -> (GitHubReviewRefreshOutcomeV1, ProjectDeliveryGitHubSourceV1) { let scope = FeedbackScopeV1 { - project_id: ProjectId::new("project.delivery-review-quarantine").unwrap(), - repository_id: RepositoryId::new("repository.delivery-review-quarantine").unwrap(), - worktree_id: WorktreeId::new("worktree.delivery-review-quarantine").unwrap(), + project_id: ProjectId::new("project.delivery-review-lane").unwrap(), + repository_id: RepositoryId::new("repository.delivery-review-lane").unwrap(), + worktree_id: WorktreeId::new("worktree.delivery-review-lane").unwrap(), branch_ref: "refs/heads/ci/msrv-build-vs-test".to_owned(), head_commit_id: CommitId::new("1a4b67cfc41237e673dafd0dfc414577f0b5d327").unwrap(), }; let context = github_review_context(&scope); let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("delivery-review-quarantine.db"); + let path = temp.path().join("delivery-review-lane.db"); crate::register_test_schema_installer(); let database_authority = - DatabaseAuthority::acquire_test(&path, "delivery-review-quarantine").unwrap(); + DatabaseAuthority::acquire_test(&path, "delivery-review-lane").unwrap(); let (database, _) = Database::publish_test_runtime( &path, &database_authority, @@ -3509,11 +3501,14 @@ mod tests { .unwrap(); let transport = GitHubReadOnlyRuntimeTransportV1::new( store.clone(), - ReviewThreadsNetwork(serde_json::to_vec(&response).unwrap()), + CapturedReviewNetwork(serde_json::to_vec(response).unwrap()), decoder, ); let connector = GitHubReadOnlyConnector::new( - GitHubReadOnlyDescriptorSetV1::new(Vec::new()).unwrap(), + GitHubReadOnlyDescriptorSetV1::new(vec![GitHubRestDescriptorV1 { + operation: GitHubReviewReadOperationV1::RestListPullRequestReviewComments, + }]) + .unwrap(), transport, NoRemap, ) @@ -3521,14 +3516,14 @@ mod tests { let coordinator = GitHubReviewRefreshCoordinatorV1::new(connector, store, ReadySourceAccess); let request = GitHubReviewReadRequestV1 { - operation: GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads, + operation, scope: scope.clone(), pull_request_id: GitHubPullRequestIdV1::new("2797381726").unwrap(), }; let refresh = coordinator.refresh(&context, &request).await; let authority = ProjectDeliveryReadAuthorityV1 { - profile_id: UserProfileId::new("profile.delivery-review-quarantine").unwrap(), + profile_id: UserProfileId::new("profile.delivery-review-lane").unwrap(), scope: scope.clone(), github_reviews: ProjectGitHubReviewStoreV1::new(database.clone(), scope.clone()) .unwrap(), @@ -3550,11 +3545,38 @@ mod tests { else { panic!("the delivery read must answer for its own scope"); }; - let ProjectDeliveryGitHubSourceV1::Ready { timeline } = snapshot.github_reviews else { - panic!( - "the pull-request lane must publish: {:?} after refresh {refresh:?}", - snapshot.github_reviews - ); + (refresh, snapshot.github_reviews) + } + + /// rust-lang/log#741 as GitHub served it, with the reply body replaced + /// by one the privacy sanitizer must refuse. + #[tokio::test] + async fn one_quarantined_review_body_leaves_the_rest_of_the_pull_request_published() { + let mut capture: serde_json::Value = serde_json::from_str(include_str!( + "advisory/fixtures/rust_lang_log_741_review_threads.graphql.json" + )) + .unwrap(); + let mut response = capture["response"].take(); + let mut comments = response["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"] + .as_array_mut() + .unwrap() + .iter_mut() + .flat_map(|thread| thread["comments"]["nodes"].as_array_mut().unwrap()) + .collect::>(); + assert_eq!(comments.len(), 3); + let reply = comments + .iter_mut() + .find(|comment| comment["databaseId"] == 4_069_777_906_u64) + .unwrap(); + reply["bodyText"] = json!("vault_passphrase: ordinary-value\n broken: [unclosed\n"); + + let (refresh, lane) = delivered_review_lane( + GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads, + &response, + ) + .await; + let ProjectDeliveryGitHubSourceV1::Ready { timeline } = lane else { + panic!("the pull-request lane must publish: {lane:?} after refresh {refresh:?}"); }; assert!(matches!(refresh, GitHubReviewRefreshOutcomeV1::Stored(_))); assert_eq!( @@ -3579,6 +3601,33 @@ mod tests { ); } + /// An anonymously read public repository ingests its review comments + /// through REST, which GitHub serves without a credential. + #[tokio::test] + async fn anonymous_rest_review_comments_publish_the_pull_request_lane() { + let capture: serde_json::Value = serde_json::from_str(include_str!( + "advisory/fixtures/rust_lang_log_741_review_comments.rest.json" + )) + .unwrap(); + + let (refresh, lane) = delivered_review_lane( + GitHubReviewReadOperationV1::RestListPullRequestReviewComments, + &capture["response"], + ) + .await; + let ProjectDeliveryGitHubSourceV1::Ready { timeline } = lane else { + panic!("the pull-request lane must publish: {lane:?} after refresh {refresh:?}"); + }; + assert_eq!( + timeline + .review_items + .iter() + .map(|item| item.comment_id.as_str()) + .collect::>(), + ["4069686687", "4069691901", "4069777906"] + ); + } + #[tokio::test] async fn mismatched_live_head_is_retained_for_source_local_stale_projection() { let fixture = diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs index b44e4d2724..b3dbe45b7b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs @@ -122,6 +122,53 @@ async fn an_idle_worktree_gives_back_its_decode_and_search_still_answers_fresh() registry.shutdown().await; } +/// The advisory cycle resolves its generation through this lookup. After the +/// idle window released the decode, the first lookup must answer with the +/// still-current sealed generation, not only the retry. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_first_feedback_lookup_after_an_idle_release_answers() { + let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); + let store = TempDir::new().expect("store root"); + let owners = Arc::new(ResidentOwnersV1::new(IDLE_WINDOW)); + let (registry, scope) = mounted_core_query_worktree_in( + CodeIndexSchedulerRegistryV1::new(1).with_resident_owners(Arc::clone(&owners)), + &fixture, + &store, + ) + .await; + let seated = wait_for_live_complete_generation(®istry, fixture.path()) + .await + .generation() + .manifest() + .generation_id + .clone(); + let before = registry + .latest_feedback_generation_for_scope(fixture.path(), &scope) + .await + .expect("a seated generation answers the feedback lookup"); + assert_eq!(before.metadata().manifest().generation_id, seated); + + let released = owners.release_idle(Instant::now() + IDLE_WINDOW); + assert_eq!( + released + .iter() + .map(|release| (release.kind, release.cause)) + .collect::>(), + [( + ResidentOwnerKindV1::DecodedGeneration, + ResidentOwnerReleaseCauseV1::Idle + )] + ); + + let first = registry + .latest_feedback_generation_for_scope(fixture.path(), &scope) + .await + .map(|generation| generation.metadata().manifest().generation_id.clone()); + assert_eq!(first, Some(seated)); + + registry.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn generation_swaps_keep_retained_bytes_flat() { const FIRST: &str = "fn main() { first(); }\nfn first() {}\n"; diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs index d071bcfa0a..88fa9c91ad 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs @@ -126,7 +126,7 @@ struct ProjectOpenAdvisoryFeedbackCycleV1 { producer: Arc, root_uri: String, feedback_scope: FeedbackScopeV1, - github_pull_request_id: Option, + github_review_read: Option<(GitHubPullRequestIdV1, GitHubReviewReadOperationV1)>, ci_discovery_config: Option, proximity_read: FeedbackProximityReadRuntimeV1, hook_config_root: std::path::PathBuf, @@ -267,47 +267,46 @@ impl ProjectOpenAdvisoryFeedbackCycleV1 { ); LspRuntimeFailure::new("feedback-cycle-advisory-operation") })?; - let outcome = pin - .registration - .runtime() - .run_once( - &invocation.context, - AdvisoryCycleControl { - operation, - deadline, - }, - AdvisoryCycleRequest { - feedback: invocation.request, - github: self.github_pull_request_id.clone().map(|pull_request_id| { - GitHubReviewReadRequestV1 { - operation: - GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads, + let outcome = + pin.registration + .runtime() + .run_once( + &invocation.context, + AdvisoryCycleControl { + operation, + deadline, + }, + AdvisoryCycleRequest { + feedback: invocation.request, + github: self.github_review_read.clone().map( + |(pull_request_id, operation)| GitHubReviewReadRequestV1 { + operation, + scope: self.feedback_scope.clone(), + pull_request_id, + }, + ), + ci, + proximity: Some(ProximityEvaluationRequestV1 { scope: self.feedback_scope.clone(), - pull_request_id, - } - }), - ci, - proximity: Some(ProximityEvaluationRequestV1 { - scope: self.feedback_scope.clone(), - observed_at, - }), - validity: tracedecay_contracts::AdvisoryFindingValidityWindowV1 { - valid_at: observed_at, - expires_at, + observed_at, + }), + validity: tracedecay_contracts::AdvisoryFindingValidityWindowV1 { + valid_at: observed_at, + expires_at, + }, }, - }, - ) - .await - .map_err(|error| { - tracing::warn!( - target: "tracedecay::feedback_advisory_cycle", - project_id = self.feedback_scope.project_id.as_str(), - worktree_id = self.feedback_scope.worktree_id.as_str(), - ?error, - "advisory feedback cycle execution failed" - ); - LspRuntimeFailure::new(error.lsp_failure_class()) - })?; + ) + .await + .map_err(|error| { + tracing::warn!( + target: "tracedecay::feedback_advisory_cycle", + project_id = self.feedback_scope.project_id.as_str(), + worktree_id = self.feedback_scope.worktree_id.as_str(), + ?error, + "advisory feedback cycle execution failed" + ); + LspRuntimeFailure::new(error.lsp_failure_class()) + })?; if outcome.publication().is_some() { self.deliver_completed_publication(&pin.registration, &outcome); } @@ -1640,9 +1639,16 @@ async fn register_production_advisory_owner( let (github, github_source_access, ci_config) = remote.map_or((None, None, None), |remote| { (remote.github, Some(remote.github_source_access), remote.ci) }); - let github_pull_request_id = github - .as_ref() - .map(|github| github.target.pull_request_id.clone()); + // GitHub's GraphQL API refuses anonymous reads, so an anonymously read + // public repository ingests its review comments through REST. + let github_review_read = github.as_ref().map(|github| { + let operation = if github.credential.is_anonymous() { + GitHubReviewReadOperationV1::RestListPullRequestReviewComments + } else { + GitHubReviewReadOperationV1::GraphQlQueryPullRequestReviewThreads + }; + (github.target.pull_request_id.clone(), operation) + }); let ci_discovery_config = ci_config.clone(); let (ci_retained, ci_code_anchors) = production_ci_observation_stores(invocation, project_root, state, &feedback_scope)?; @@ -1743,7 +1749,7 @@ async fn register_production_advisory_owner( producer, root_uri: state.admitted_root_uri.clone(), feedback_scope: feedback_scope.clone(), - github_pull_request_id, + github_review_read, ci_discovery_config, proximity_read, hook_config_root: state.graph.hook_store_layout().data_root.clone(), From 31b34e3c5d58be77a2b9d99085d5a4979f23c142 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 12:57:33 +0000 Subject: [PATCH 3/5] fix(delivery): share a code anchor across same-line review comments --- .../src/advisory/github_runtime/anchors.rs | 13 +++++------ .../advisory_runtime_acceptance.rs | 23 +++++++++++++++++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-application/src/advisory/github_runtime/anchors.rs b/crates/tracedecay-application/src/advisory/github_runtime/anchors.rs index f55b4ae6e8..e02f85ddbd 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/anchors.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/anchors.rs @@ -252,7 +252,7 @@ impl ProjectGitHubAnchorAuthorityV1 { seed: &GitHubReviewAnchorSeedV1, stored: StoredGitHubAnchorV1, ) -> Option { - if !same_original_locator(&stored.seed, seed) { + if !same_code_location(&stored.seed, seed) { return None; } let original = stored.anchors.original; @@ -936,12 +936,11 @@ fn body_sanitization_receipt( .ok() } -fn same_original_locator( - left: &GitHubReviewAnchorSeedV1, - right: &GitHubReviewAnchorSeedV1, -) -> bool { - left.comment_id == right.comment_id - && left.path == right.path +/// The code anchor is keyed by location alone, so every comment on the same +/// original lines (a reply thread) shares it; the per-comment author, body, +/// and URL anchors are derived from each comment's own seed. +fn same_code_location(left: &GitHubReviewAnchorSeedV1, right: &GitHubReviewAnchorSeedV1) -> bool { + left.path == right.path && left.original_commit_id == right.original_commit_id && left.original_start_line == right.original_start_line && left.original_line == right.original_line diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/advisory_runtime_acceptance.rs b/crates/tracedecay/tests/runtime_acceptance_suite/advisory_runtime_acceptance.rs index 10a585049d..ece40de0c9 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/advisory_runtime_acceptance.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/advisory_runtime_acceptance.rs @@ -641,11 +641,30 @@ async fn retained_review_body_expansion_rechecks_exact_scope_and_source_access() current_start_line: Some(2), current_line: Some(2), }; + let reply_body = "Agreed, the second call can reuse it."; + let reply_seed = GitHubReviewAnchorSeedV1 { + comment_id: GitHubReviewCommentIdV1::new("3556767426").unwrap(), + author_node_id: "MDQ6VXNlcjE=".to_owned(), + body_digest: ManifestDigest::new(format!( + "sha256:{}", + hex::encode(Sha256::digest(reply_body)) + )) + .unwrap(), + retained_body: reply_body.to_owned(), + safe_url: "https://github.com/ScriptedAlchemy/tracedecay/pull/421#discussion_r3556767426" + .to_owned(), + ..seed.clone() + }; let batch = authority - .resolve_many(&request, &[seed, second_seed]) + .resolve_many(&request, &[seed, second_seed, reply_seed]) .await .expect("canonical body anchors"); - assert_eq!(batch.len(), 2); + assert_eq!(batch.len(), 3); + assert_eq!( + batch[2].original, batch[0].original, + "a reply on the same lines shares the code anchor" + ); + assert_ne!(batch[2].body_anchor, batch[0].body_anchor); assert_eq!( batch[0].original.span, Some(SourceSpan { From fa7df8c4c86ce4516c3c025a60c72f66ab3dd9fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 13:01:04 +0000 Subject: [PATCH 4/5] test(code-index): move the idle lookup check to its own change --- .../code_index_scheduler/tests/residency.rs | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs index b3dbe45b7b..b44e4d2724 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs @@ -122,53 +122,6 @@ async fn an_idle_worktree_gives_back_its_decode_and_search_still_answers_fresh() registry.shutdown().await; } -/// The advisory cycle resolves its generation through this lookup. After the -/// idle window released the decode, the first lookup must answer with the -/// still-current sealed generation, not only the retry. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn the_first_feedback_lookup_after_an_idle_release_answers() { - let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); - let store = TempDir::new().expect("store root"); - let owners = Arc::new(ResidentOwnersV1::new(IDLE_WINDOW)); - let (registry, scope) = mounted_core_query_worktree_in( - CodeIndexSchedulerRegistryV1::new(1).with_resident_owners(Arc::clone(&owners)), - &fixture, - &store, - ) - .await; - let seated = wait_for_live_complete_generation(®istry, fixture.path()) - .await - .generation() - .manifest() - .generation_id - .clone(); - let before = registry - .latest_feedback_generation_for_scope(fixture.path(), &scope) - .await - .expect("a seated generation answers the feedback lookup"); - assert_eq!(before.metadata().manifest().generation_id, seated); - - let released = owners.release_idle(Instant::now() + IDLE_WINDOW); - assert_eq!( - released - .iter() - .map(|release| (release.kind, release.cause)) - .collect::>(), - [( - ResidentOwnerKindV1::DecodedGeneration, - ResidentOwnerReleaseCauseV1::Idle - )] - ); - - let first = registry - .latest_feedback_generation_for_scope(fixture.path(), &scope) - .await - .map(|generation| generation.metadata().manifest().generation_id.clone()); - assert_eq!(first, Some(seated)); - - registry.shutdown().await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn generation_swaps_keep_retained_bytes_flat() { const FIRST: &str = "fn main() { first(); }\nfn first() {}\n"; From 7e84af6336cdcfc0e84b2a36d8989e6967db34ca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 13:19:26 +0000 Subject: [PATCH 5/5] fix(delivery): box the anonymous discovery refusal --- .../src/advisory/github_runtime/discovery.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs index bc2ad3fecf..59c2c1bbda 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs @@ -476,7 +476,7 @@ fn public_rest_get( config: &GitHubHttpReadConfigV1, request_timeout: Duration, control: &GitHubDiscoveryControlV1, -) -> Result, GitHubExactCommitDiscoveryOutcomeV1> { +) -> Result, Box> { let response = agent .get(url) .config() @@ -490,15 +490,16 @@ fn public_rest_get( .header("User-Agent", "tracedecay-github-read") .call(); if control.remaining().is_none() { - return Err(GitHubExactCommitDiscoveryOutcomeV1::Unavailable); + return Err(Box::new(GitHubExactCommitDiscoveryOutcomeV1::Unavailable)); } let Ok(mut response) = response else { - return Err(GitHubExactCommitDiscoveryOutcomeV1::Unavailable); + return Err(Box::new(GitHubExactCommitDiscoveryOutcomeV1::Unavailable)); }; if let Some(refused) = refused_status(&response) { - return Err(refused); + return Err(Box::new(refused)); } - read_bounded_body(&mut response).ok_or(GitHubExactCommitDiscoveryOutcomeV1::Unavailable) + read_bounded_body(&mut response) + .ok_or_else(|| Box::new(GitHubExactCommitDiscoveryOutcomeV1::Unavailable)) } #[derive(Deserialize)] @@ -563,7 +564,7 @@ fn scan_public_head_ref_pull_requests_v1( .append_pair("per_page", &GITHUB_DISCOVERY_PAGE_SIZE_V1.to_string()); let body = match public_rest_get(agent, search.as_str(), config, request_timeout, control) { Ok(body) => body, - Err(outcome) => return outcome, + Err(outcome) => return *outcome, }; let Ok(found) = serde_json::from_slice::(&body) else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; @@ -582,7 +583,7 @@ fn scan_public_head_ref_pull_requests_v1( ); let body = match public_rest_get(agent, &url, config, request_timeout, control) { Ok(body) => body, - Err(outcome) => return outcome, + Err(outcome) => return *outcome, }; let Ok(pull) = serde_json::from_slice::(&body) else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable;