fix(server): name the asking project for a pull request read through another checkout#20728
Part of #16455
Problem
A thread can link a hosted pull request whose stored repository name matches no local checkout, for example a link saved as old-org/app before the repository moved to acme/app. requireProject then reads it through another project on that host, so that project's credentials serve the read. detail, summary and preview name that lending project as the pull request's project. A handoff that takes its target from them then opens the thread and runs the checkout in the lender instead of in the project the user asked from. The app check below shows this on main for Check out on the Pull Requests page opened at a URL that names the asking project; the issue reports it for Resolve, Fix and Checkout.
The issue's service-level repro uses projects unrelated (added first, acme/unrelated) and app (acme/app). There, service.detail({ projectId: "app", host: "github.com", repository: "old-org/app", number: 123 }) returns projectId: "unrelated" and workspaceRoot: "/unrelated". The persisted read is keyed by the lender as well. So two projects that read one borrowed pull request share one entry, and the second gets the first one's target. A turn in the asking project also does not refresh that read.
Change
In requireProject, a borrowed route now records the asking project as checkout when that project is supported on the reference's host.
detailtakesprojectId,projectTitleandworkspaceRootfrom the asking project.summaryandpreviewtakeprojectIdfrom it.- The canonical reference and the persisted read key follow the asking project. Each asking project keeps its own cached copy, and
refreshAfterTurnfor that project invalidates it. - Recovery after a stack action refreshes the asking project and the lending project, since the lender can hold its own cached read of the same pull request.
- Provider reads, their credentials and the credential workspace in the persisted key still use the lending project.
- The asking project is the
projectIdthe request carries. This PR does not change which project a client sends. - If the asking project has no supported checkout on that host, the lending project stays the target, as before. The host compared is the checkout host the app already resolves for each project. For GitHub and GitHub Enterprise that is the first part of the normalized remote, and nothing rewrites
ssh.github.comor an SSH host to its web host. So an asking checkout cloned throughssh.github.com, or a GitHub Enterprise checkout cloned through an SSH host other than its web host, counts as another host and the lending project stays the target, as onmain. Forgejo differs: an HTTP checkout uses its remote URL's host and port, and an SSH checkout the app refines to Forgejo uses the detected instance's web host, as the existing test "routes explicit Forgejo HTTP authorities through SSH checkouts after refinement" shows. Azure DevOps references never take this route: Azure checkouts carry a repository key, so a reference resolves through a matching checkout or not at all, and this PR does not change them.
Two asking projects that read one borrowed pull request keep separate copies. A turn or action in each refreshes that project's copy. Until then, strict reads can return the old copy for up to the read cache's lifetime (60 s for an open pull request), and reads that accept a held summary keep it. Two checkouts of one repository already behave this way on main. No driver, contract or client changes.
Scope and approval
Part of #16455. The triage confirms that the borrowed project is presented as the pull request's project. It lists options that keep reads working through another checkout's credentials while not using that project as the checkout target. The reporter suggested the same: "keep the borrowed-credentials route for reads, but don't present the borrowed project as the PR's checkout". This PR names the asking project instead. This PR does not check whether that project is a checkout of the repository; the guard in #20727 does. The triage leaves the fix direction to a maintainer, so it does not approve this choice.
On its own, this PR moves the handoff from the lending project to the asking project only where the request names the asking project. In the app check below, that request came from opening the Pull Requests page at a hand-built URL that names both the asking project and the stored, old repository name, as a typed or shared link would; a caller outside the UI could send the same request. There the target moved to the asking project, which is the right checkout when it holds the renamed repository. When the asking project is unrelated as well, for example a frontend thread that links a backend pull request, this PR alone still checks the pull request out there, as the app check also shows. #20727 refuses that case before any write.
The app's own links do not send that request for an old-name link. Its link handler opens a pull request link in the project that is a checkout of it, or, when it opens the link in a panel, in the first project on the host, here lender. The thread-details pull request rows resolve their project the same way (findProjectOnChangeRequestHost in ThreadDetailsPrRows.tsx) and put that project's id in the reference (ThreadDetailsPrRow.tsx). The Pull Requests list filtered to renamed had nothing to click, because the pull request is stored under the old name. In the app check at this head:
- Opening the pull request from its link in the sidebar put
lenderin the request before it reached this code, and the handoff landed inlender, as onmain. - Opening it from the pull request row under Version Control in thread details, then Check out, also landed in
lender. Nothing new was written, because thelenderworktree from the sidebar attempt already existed. - In one way of opening a thread's linked pull request, the panel offered no Check out at all.
So on those routes, including the issue's Resolve and Fix from the thread-details row, this PR alone leaves the target on the lending project, and #20727 refuses a checkout there. Changing which project the client sends is outside this PR. On main (read in the code at bf990c625e and again at 6a71716edb), the branch picker's "Checkout pull request" dialog already checks out in the asking project: ChatView passes the active project's workspaceRoot as the dialog's cwd, and usePreparePullRequestThreadAction sends that cwd to preparePullRequestThread. So this PR adds no new write path.
This splits #18278, which had grown to 982 changed lines. This PR carries #18278's borrowed-read change with the same code. #20727 carries the checkout guard. The two PRs share no files and neither needs the other's code. #18278 will be closed in favour of the two.
The direction question on #18278 (this comment from the juliusmarminge account) is about the guard's handling of a fork clone whose base remote is not named upstream. That question stays with #20727, which links our reply; it still awaits a maintainer ruling. This PR does not change which checkouts the guard accepts.
Credit to @Absterrg0, whose closed #16514 first served such a reference from the requesting project before any other project on the host. This PR differs in one way: the read keeps the lending project's checkout and credentials, and only the checkout target, the cache key and the refresh move to the asking project.
Verification
Linux x64, Node 24. The runs below compare this PR's head 2a55b2a00f with bf990c625e, the main commit it branches from. The tests use the service's fake providers.
From apps/server, I ran this on bf990c625e with this PR's PullRequestService.test.ts copied in, then at 2a55b2a00f:
| 1 | vp test run --reporter=verbose src/pullRequest/PullRequestService.test.ts |
- Main
bf990c625e: 4 failed, 206 passed (210).- "names the requesting project, not the one lending its credentials, for a pull request no checkout names":
expected 'unrelated' to equal 'app'. - "keeps each requesting project's checkout when two projects read one borrowed pull request":
expected 'unrelated' to equal 'web'. - "refreshes a borrowed pull request after a turn in the project that asked for it":
expected 1 to equal 2reads. - "refreshes the asking project's sibling layers after a borrowed stack action fails": fails at its priming reads (
expected 1 to equal 2), because both projects shared one cached entry on main.
- "names the requesting project, not the one lending its credentials, for a pull request no checkout names":
2a55b2a00f: 210/210 passed.- Two controls pass on both: the lending project stays the target when the asking project has no checkout, and when its only checkout is on another host of the same kind (
ghe.example.com). Without this PR's same-host condition, the second control fails.
PullRequestService tests on main with this PR's test file
Main bf990c625e with this PR's PullRequestService.test.ts: 4 failed, 206 passed. Both lender controls pass, including the asking project with a checkout only on another host.
PullRequestService tests at this PR's head
2a55b2a00f: 210 of 210 passed, including the 6 new tests and the other-host control.
Borrowed read target, main and this PR
A test-only probe ran the renamed-link test: a copy of the test file that writes the values to a file before the test's assertions, not part of this PR. For old-org/app#1 asked from app, main names the lending project unrelated in preview, detail and summary. At 2a55b2a00f all three name app; on both, the provider reads run in /unrelated.
App check
The web app at this head (2a55b2a00f, without #20727) and at bf990c625e for comparison ran in dev mode through the repository's dev runner, each with its own state directory and a fresh browser profile, driven through the page in Chromium on Linux. No agent turn ran. The repositories and the pull request are synthetic: a local stub answers the hosting reads, a git wrapper sends fetches to local bare repositories, and each project's setup action writes a marker file. Branches, remotes, worktrees and setup markers were read from disk before and after each step. The stored pull request is fixture/base #7, under the repository's old name. renamed is a checkout of the new name, fixture/renamed. unrelated has origin fixture/other, and lender (origin fixture/lender) is first in the workspace's read order. Each run opened the Pull Requests page by navigating straight to a hand-built URL, /pull-requests?repository=fixture%2Fbase&number=7&selectedHost=github.com&selectedProjectId=<project>&projectId=<project> plus the environment id, which names the selected project and the stored pull request. It then chose Check out, then In a separate worktree. No click in the app builds that URL for an old-name link (see Scope).
renamedselected, at2a55b2a00f: the draft, the new branch, remote and worktree, and the setup run are all inrenamed. Onbf990c625e, the same input lands inlender, with a new branch, remote and worktree and a setup run there, andrenamedstays unchanged.unrelatedselected, at2a55b2a00f: the checkout is written intounrelated(branch, remotes, worktree and setup). This PR alone does not refuse it.
Asking project and stored repository on the Pull Requests page
2a55b2a00f: the Pull Requests page opened at the hand-built URL, with the Project filter set to renamed and fixture/base #7 open. The filtered list shows nothing to click. The yellow banner and the empty comments come from the minimal stub and are not under test.
Handoff from the Pull Requests page with renamed selected
2a55b2a00f: Check out, then In a separate worktree, opens a renamed draft on feature/ui. The composer reads Current worktree; the new worktree, branch and setup run are from the disk records.
Same handoff on main
Main bf990c625e, same hand-built URL and input: the draft opens in lender on feature/ui.
Handoff with unrelated selected
2a55b2a00f with unrelated selected: the draft opens in unrelated on feature/ui, and the disk records show the checkout written there. This PR alone does not refuse it.
Checks at 2a55b2a00f:
- From
apps/server,tsc-rs --noEmit: exit 0, no errors. Its 151 Effect-plugin suggestions are on lines this PR does not change. - From the repository root,
vp lint --report-unused-disable-directivesandvp fmt --checkon the two changed files: exit 0, clean. - From the repository root,
vp run knip:check: exit 0.
Not checked:
- A desktop build, and the app on macOS or Windows.
- The app against real hosting. The app check used a stub and synthetic repositories.
- The thread-details row's Resolve and Fix controls themselves. The fixture pull request has no merge conflicts or failing checks, and thread details offered only Merge and Changes, so they were not run. From the code, those rows send the first project on the host, as described under Scope, so with this PR alone they stay on the lending project.
- Routes other than the hand-built Pull Requests URL and the three described under Scope.
- An asking checkout cloned through
ssh.github.comor through a GitHub Enterprise SSH host that differs from its web host. The fallback to the lending project described above is read from the code, not run. - Real agent-turn events, a turn in the lending project, a mutation seen from a second asking project, and viewed-file invalidation.
- The full repository test suite and upstream CI.
Generated with Claude Opus 5.5 in T3 Code Nightly
Checks
Added 1 commit
- fix(server): name the asking project for a pull request read through another checkout2a55b2a
Approvability
Verdict: Approved at 2a55b2a
Macroscope's review found this PR approvable — This is a focused server-side bug fix that separates the asking project’s checkout/cache identity from the lending project’s credentials without changing provider contracts or defaults. Regression tests cover the corrected handoff, cache refresh behavior, and existing fallback cases.
You can add or adjust custom eligibility rules. Learn more.
No actionable comments were generated in the recent review.
ℹ️ Recent review info
⚙️ Run configuration
- Configuration used: Path: .coderabbit.config.ts
- Review profile: CHILL
- Plan: Advanced
- Run ID:
be747bd6-820b-414d-b83b-e1ee40ffe4f7
📥 Commits
Reviewing files that changed from the base of the PR and between 79ad856b4e72b6d6f1409aca98bf439c1d170a94 and 2a55b2a00fdfa25c21fdefdce3c6a1fa4e7c854f.
📒 Files selected for processing: 2
apps/server/src/pullRequest/PullRequestService.test.tsapps/server/src/pullRequest/PullRequestService.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.
Walkthrough
Borrowed pull-request reads now use the requesting project’s checkout identity for returned metadata and persisted-read cache keys when a matching checkout exists. Stack actions refresh summaries for both the requesting and credential-lending projects.
Changes: Borrowed Pull-Request Context
Layer / File(s) Summary Resolve checkout context
apps/server/src/pullRequest/PullRequestService.ts,apps/server/src/pullRequest/PullRequestService.test.tsRoutes record a matching checkout project. Summary, detail, and preview results use its identity when available; detail results also use its title and workspace root. Tests cover borrowed-read identities and cases where no matching checkout exists. Scope and refresh borrowed-read cache
apps/server/src/pullRequest/PullRequestService.ts,apps/server/src/pullRequest/PullRequestService.test.tsPersisted-read cache keys use the checkout project ID when available. Tests cover cache reuse and refresh after a turn in the requesting project. Refresh summaries after stack actions
apps/server/src/pullRequest/PullRequestService.ts,apps/server/src/pullRequest/PullRequestService.test.tsStack actions refresh summaries for both the credentials project and checkout project, with duplicate IDs removed. Tests cover a failed borrowed stack action. Estimated code review effort: 3 (Moderate) | ~20 minutes
Change: Bug fix
Suggested reviewers:
juliusmarminge
Merge Risk: External image Minimal · up to 2a55b
Borrowed reads follow the asking project's checkout and refresh after its turns. The lender supplies credentials; its turns do not need to invalidate those reads. This change is mergeable after normal checks.
Security architecture risk: External image Low · up to 2a55b
The change directs handoffs to the requesting project instead of the project lending credentials. Provider actions retain their existing permission checks and credential context. Remaining uncertainty concerns cache freshness across project-routing changes, rather than a demonstrated authorization bypass.
Retained concerns No architecture-level concerns identified.
Security review details
Security Blast Radius
- inferred — The traced effect spans the requesting project's thread and working tree, plus the remote pull request accessed through existing lender credentials. Handoff requires an explicit action; merely reading detail does not invoke checkout. Checkout preparation can run the selected project's setup script, making correct workspace identity security-relevant.
Security Findings and Attack Paths
- inferred — No introduced provider-authorization bypass was demonstrated in the inspected path. Although returned checkout metadata changes, the web action runner submits the original reference, and the server resolves provider ownership and requests permissions rather than treating cached detail as an authorization grant.
Trust Boundaries and Controls
- observed — Provider reads and actions continue using the lender's workspace and resolved repository. Existing expected-account verification also uses the lender credential context. Credential borrowing is pre-existing behavior, not authority introduced by the new checkout field.
Resilience and Maintainability Implications
- inferred — Non-stack mutation invalidation follows the canonical requesting-project reference, so a separately cached lender view can remain stale. This is a cache-consistency limitation, not a demonstrated permission bypass: action permission lookup calls the provider independently. Persistent entries expire after 60 seconds, although held summary behavior means that expiry is not a universal display-freshness guarantee.
Pre-merge checks: Passed 4
Check name Status Explanation Linked Issues check Passed Check skipped because no linked issues were found for this pull request. Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request. Title check Passed The title clearly and concisely describes the main change: naming the asking project for borrowed pull-request reads. Description check Passed The description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the behavior change, affected routes, testing results, limitations, and linked issue c… Full details: Description check
Explanation
The description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the behavior change, affected routes, testing results, limitations, and linked issue context. It is detailed and directly related to the pull request.
Finishing Touches
🧪 Generate unit tests (beta)
- Create a new PR
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Added 1 commit
- Merge upstream/main into fix/16455-borrowed-read-target18edd14
Note
Written by gpt-6.1-sol on behalf of Maria
Verified in a real dev environment
verified unchanged head 18edd14c51a1db7bd7300de44db97e4ff3f85734 on linux, against github/main at 15fe283272. ran one background dev server at a time, Playwright from the repo dependencies, and /usr/bin/google-chrome. projects were added through the app from real github clones. hosting reads used real github; provider turns used codex gpt-6.1-sol. no mocks or seeded fixtures.
- on main, preview/detail/summary requested from the asking fork returned the lender project and workspace. clicking check out → in a separate worktree opened the lender draft. materializing the branch in that unrelated repository failed.
- on this head, the same explicit-project pull requests URL returned the asking fork in all three responses. checkout created its worktree, opened its draft, and reported success. a real codex turn there ran
pwdandgit rev-parse HEAD, confirming the asking project worktree and exact head above. - two asking projects retained their own identities for the same borrowed pr. after the real turn, the asking project summary observation advanced from
1793476439327to1793476465567, within the 60-second cache lifetime. - nearby reads passed: an exact repository match retained its checkout; an asking project on git.cafe reading a github reference retained the github lender fallback.
- blacksmith passed all 96 existing service tests and
vp run check:changed d53873b6a4799e87afd63c8d288ef0089f5a3748(the recorded pr base): format, lint, both knip passes, graphql, affected typechecks, and tests. no source changes or push were needed.
command and server response transcript
before: main opens the lending project for the asking fork
after: the pr opens the asking fork worktree successfully
real codex turn confirms the worktree and exact pr head
checkout interaction in the running app
Not verified
native mobile, electron, macos/windows, remote/relay connections, other hosting providers, real repository rename redirects, separate github accounts per checkout, viewed-file invalidation, and failed stack-action recovery. no merge, rebase, or other remote mutation was exercised.
this verifies the service behavior when the request names the asking project. the existing sidebar/thread-details link routing can select the lender before reaching this service; this pr does not change that routing. resolve/fix controls were not exercised. the checkout recording uses the explicit-project URL documented in the pr, then the actual app controls.