# PR #22943 (GitCafe 22942): running-server proof matrix for issue #22920

Fix under test: `resolvePrBase` in `apps/server/src/git/GitManager.ts` now checks that a recorded base
(`branch.<b>.t3-merge-base-ref` / `branch.<b>.gh-merge-base`) exists as a remote-tracking ref. If it is
missing, it falls back to the provider default, then `refs/remotes/<primary>/HEAD`, then the literal `main`,
and emits `Base <remote>/<base> is unavailable in this checkout. Using default branch <x>.` in the `pr`
phase label and the completion toast.

- **Before** = merge-base `8d3b061121fe65669d5d43d011b48cd3027a4108`
- **After** = PR head `607524efc8673321b5f2d85a6f76c224e9071daf`

Both versions run as a real `apps/server` process. The client is a node script that talks over WebSocket.
The forge and text generation are stubbed, and nothing reaches a real forge or model.

---

## 0. Shared harness (same for every row, both versions)

### 0.1 Two checkouts
```sh
export PATH="$HOME/.bun/bin:$HOME/.local/bin:$HOME/.local/share/vite-plus/bin:$PATH"
E=/tmp/ev-22943
git -C /home/ubuntu/wt/pr-22943 worktree add --detach $E/wt-before 8d3b061121fe65669d5d43d011b48cd3027a4108
(cd $E/wt-before && vp i)
# after = /home/ubuntu/wt/pr-22943 itself (already at 607524efc8). Confirm with `git rev-parse HEAD` and record it.
```
(When finished, remove `wt-before` with `git worktree remove`. It is the only change made to the PR repo.)

### 0.2 Server process (one per version per row, fresh home each time)
Use the spawn pattern from `apps/server/scripts/verify-background-live.ts:200-262`. Spawn:
`node apps/server/src/bin.ts start --bootstrap-fd 3 --log-level debug` with cwd = that version's checkout.
Write this JSON to fd 3:
`{"mode":"desktop","noBrowser":true,"port":<free>,"t3Home":"/tmp/ev-22943/home-<row>-<ver>","host":"127.0.0.1","desktopBootstrapToken":<uuid>,"tailscaleServeEnabled":false,"tailscaleServePort":443}`.
The server is ready when it logs `startup phase: complete`.

**Never** let `t3Home` point at `~/.t3`. Use a new home dir per run so provider and remote caches cannot carry over between rows.

Server env (replace the inherited env, do not extend it):
```
PATH=/tmp/ev-22943/bin:/usr/bin:/bin          # fake gh + codex first; no real claude/codex/gh
HOME=/tmp/ev-22943/fakehome                   # no ~/.codex, ~/.config/gh, ~/.gitconfig
GIT_CONFIG_GLOBAL=/tmp/ev-22943/gitconfig     # empty file
GIT_CONFIG_NOSYSTEM=1
GIT_TERMINAL_PROMPT=0
NODE_USE_ENV_PROXY=1                          # Node 24: global fetch honours HTTPS_PROXY
HTTPS_PROXY=http://127.0.0.1:<stubPort>
HTTP_PROXY=http://127.0.0.1:<stubPort>
NO_PROXY=127.0.0.1,localhost
NODE_EXTRA_CA_CERTS=/tmp/ev-22943/ca/ca.pem
```
Spawn node by absolute path (`process.execPath`), because PATH no longer contains it.

### 0.3 Auth (no pairing UI needed)
1. `POST /oauth/token` with form fields `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`,
   `subject_token=<bootstrap>`,
   `subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap`, and
   `requested_token_type=urn:ietf:params:oauth:token-type:access_token`. Read `access_token` from the response.
2. Optional, to mirror the UI: `POST /api/projects/mutate` with `{type:"project.create",commandId,projectId,title,workspaceRoot:W}`.
3. `POST /api/auth/websocket-ticket` using `Authorization: Bearer` and the `ORCHESTRATION_PROTOCOL_HEADER`, which returns `{ticket}`.
4. Connect to `ws://127.0.0.1:<port>/ws?orchestrationProtocol=<ORCHESTRATION_PROTOCOL_VERSION>&wsTicket=<ticket>`, then `RpcClient.make(WsRpcGroup)`.

The bootstrap token carries admin scopes, which include `AuthSourceControlWriteScope`, the scope
`git.runStackedAction` requires (`packages/contracts/src/clientRpcPermissions.ts:38`).
Do **not** use `T3CODE_DEV_AUTH_TOKEN` or `vp run dev`. Those are for the web dev flow and are not needed here.

### 0.4 RPC that drives Create PR
`client["git.runStackedAction"]({ actionId: <uuid>, cwd: W, action: "create_pr" })`
This is a **stream** of `GitActionProgressEvent` (`packages/contracts/src/rpc.ts:1379`).
- Omit `threadId`. Adding it pulls in an `orchestration:read` scope requirement and the PR-link side effect.
- Omit `commitMessage`, `filePaths` and `featureBranch`, because `create_pr` does not commit or push.
- Collect **every** event: each `phase_started` `label` (the after-version puts the notice here), the
  final result (`pr.status`, `pr.baseBranch`, `pr.headBranch`, `pr.url`, `toast.title`, `toast.description`),
  or the failure cause.

### 0.5 Forge stub: HTTPS CONNECT proxy + TLS terminator for `github.invalid`
Why it is built this way:
- A plain file-path remote is detected as provider `unknown`, and then `createChangeRequest` fails with
  "No unknown source control provider is registered" before any forge request is made.
- The GitHub provider talks to the REST and GraphQL **API** directly. It does not run `gh pr create`.
- `github.invalid` passes `isGitHubHost` (it has a `github` DNS label), so it is detected as "GitHub Self-Hosted".
  The `.invalid` TLD can never resolve, so no request can leak to a real forge.
- For a self-hosted GitHub host, the API URLs are `https://github.invalid/api/v3/...` and
  `https://github.invalid/api/graphql` (`GitHubRequestPolicy.ts:113-125`).

Stub behaviour (one node process, logs every request as JSONL to `$E/<row>-<ver>/forge.jsonl`):
- Accept `CONNECT github.invalid:443` only. **Refuse every other CONNECT with 403 and log it.**
  This is the leak detector.
- Terminate TLS with a leaf cert for `DNS:github.invalid`, signed by a throwaway CA at `$E/ca/ca.pem` (openssl).
- `GET /api/v3/repos/acme/demo`: return `200 {"full_name":"acme/demo","html_url":"https://github.invalid/acme/demo","ssh_url":"git@github.invalid:acme/demo.git","default_branch":<ROW_DEFAULT>}`.
  `ROW_DEFAULT` is `"main"` for R1-R3. For R4, return **404** (or `default_branch:null`).
- `POST /api/graphql`: log `operationName`/query and variables. For the head lookup (`listPullRequestsByHead`, which uses
  `repository.pullRequests`), return `{"data":{"repository":{"pullRequests":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}}`.
  If the server log shows a decode error, adjust the shape.
- `POST /api/v3/repos/acme/demo/pulls`: **this is the "request sent to the forge"**. Log the full JSON body
  (`base`, `head`, `title`, `body`, `maintainer_can_modify`) and return `201` with a minimal PR JSON.
- Log anything else (for example `/rate_limit`, `/user`, or the git smart-HTTP `/acme/demo.git/info/refs` from the background status fetch)
  and return 404.

Fake `$E/bin/gh` (logs argv to `$E/<row>-<ver>/gh.log`):
- `auth token --hostname github.invalid` prints `fake-token` and exits 0.
- Anything else exits 1.

### 0.6 Text-generation stub: fake `$E/bin/codex`
The default `textGenerationModelSelection` is instance `codex`, and the binary defaults to `codex` on PATH
(`CodexTextGeneration.ts:268`). The fake must handle:
- `mcp list --json`: print `[]` and exit 0.
- `--version`: print `codex-cli 0.0.0-fake`.
- `exec ... --output-last-message <path> -`: read stdin (the prompt) and save it to
  `$E/<row>-<ver>/prompt-<n>.txt`, then write `{"title":"Fixture PR","body":"fixture body"}` to `<path>` and exit 0.
- Log argv for every call.

The prompt shows which base and commit range the server used. Capture the lines containing the base
branch and the commit summary ("Child commit" alone, or "Parent commit" + "Child commit").

### 0.7 Git fixture template (real local bare repo acting as the remote)
```sh
R=$E/<row>; B=$R/remote.git; W=$R/work; U=https://github.invalid/acme/demo.git
git init -q --bare -b main $B                     # LOCAL BARE REPO ACTING AS THE REMOTE
git init -q -b main $W && cd $W
git config user.name Fixture; git config user.email fixture@example.invalid
git remote add origin $U                          # fetch URL = forge identity (github.invalid)
git config url."$B".pushInsteadOf $U              # pushes go to the bare repo
F() { git -c url."$B".insteadOf=$U "$@"; }        # use ONLY for fixture fetches
echo m>m; git add m; git commit -qm "Main commit"; git push -q -u origin main
git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
git switch -qc parent; echo p>p; git add p; git commit -qm "Parent commit"
#   <row-specific: push parent or not>
git switch -qc child;  echo c>c; git add c; git commit -qm "Child commit"; git push -q -u origin child
git config branch.child.gh-merge-base parent      # what gh / createWorktree(baseRefName: parent) records
```
This recipe was smoke-tested on this machine. `git remote -v` shows the `github.invalid` fetch URL and the
bare-repo push URL, and `fetch --prune` through `F` prunes `refs/remotes/origin/parent`.

Before each run, capture: `git -C $B for-each-ref`, `git -C $W for-each-ref refs/remotes refs/heads`,
`git -C $W config --get-regexp '^branch\.child\.'`, `git -C $W symbolic-ref -q refs/remotes/origin/HEAD`.

Optional variant for real T3 recording: instead of `git config branch.child.gh-merge-base`, create the child with
`vcs.createWorktree({cwd:W, refName:"parent", newRefName:"child", baseRefName:"parent", path:<dir>})`, then commit
and push in that worktree and use it as the `runStackedAction` cwd. This records `gh-merge-base=parent` with no
`t3-merge-base-ref`. Passing `baseRefName:"origin/parent"` records `t3-merge-base-ref=refs/remotes/origin/parent`.

---

## Rows

| Row | Fixture delta | Stub default_branch | Expected BEFORE (8d3b0611) | Expected AFTER (607524ef) | Differs? |
|---|---|---|---|---|---|
| **R1** recorded base on remote | `git push -q -u origin parent` | `"main"` | base `parent`; POST `/pulls` `{base:"parent",head:"child"}`; prompt range = Child only; no notice | identical to before | **No** (regression guard) |
| **R2a** pushed, deleted on remote, fetched `--prune` | push parent; then `git -C $B branch -D parent`; `F fetch -q --prune origin` (origin/parent gone, local `parent` remains) | `"main"` | base `parent` (local branch accepted); POST `{base:"parent"}`, which real GitHub rejects | base `main`; POST `{base:"main",head:"child"}`; prompt range = Parent + Child; pr-phase label and toast description contain `Base origin/parent is unavailable in this checkout. Using default branch main.` | **Yes** |
| **R2b** pushed, deleted on remote, **no** prune (stale ref) | as R2a but skip the fetch (or plain `F fetch` without `--prune`); `refs/remotes/origin/parent` is still present | `"main"` | base `parent`; POST `{base:"parent"}` | **same as before**: base `parent`, no notice. This is a known limitation stated in the PR body (the remote-tracking cache is trusted, and validation does no fetch) | **No** (document it, don't hide it) |
| **R3** never pushed | do not push parent | `"main"` | base `parent`; POST `{base:"parent"}` (the #22920 `base invalid` bug) | base `main`; POST `{base:"main"}`; notice as in R2a; range = Parent + Child | **Yes** |
| **R4a** default unresolvable + recorded base missing | build on **`trunk`** instead of `main`: `git init -b trunk`, push `trunk`, **no** `refs/remotes/origin/HEAD` (skip the symbolic-ref line); bare HEAD stays `refs/heads/main` (dangling = "unset"); parent **not** pushed; **no local or remote `main` anywhere** | **404** on `GET /repos/acme/demo` | base `parent`; POST `{base:"parent"}`, silent wrong base | provider default null (404 is swallowed), `symbolic-ref refs/remotes/origin/HEAD` fails, so the literal `"main"` is used. A pr-phase label with `... Using default branch main.` is emitted, then `readRangeContext` runs `git log main..HEAD` on a revision that does not exist, and the **RPC fails with a GitCommandError**. **Zero** POST `/pulls`, and fake codex is never called for `exec`. The error surfaces with no silent wrong base | **Yes** |
| **R4b** default unresolvable, no recorded base (control) | R4a fixture but `git config --unset branch.child.gh-merge-base` | 404 | literal `main`, then git error, no POST | identical to before (`missingRemoteBase` is null, so no notice) | **No** (shows the fallback chain itself is unchanged) |

Exact `R4a` message text depends on how remote matching resolves: `origin/parent` if `gitHubApiHostForRemote` matches
origin to target `github.invalid/acme/demo`, otherwise `github.invalid/acme/demo/parent`. Record whichever appears.

### What to capture per row × version
1. The fixture state dump (0.7) and the server version SHA (`git rev-parse HEAD` of the checkout that was spawned).
2. The full `git.runStackedAction` event stream (JSON), especially `phase_started` labels and the final result or error.
3. `forge.jsonl`, specifically the `POST /pulls` body. "No POST" is itself the evidence for R4. Also record any refused CONNECTs. There must be **none**
   to a host other than `github.invalid`.
4. `gh.log`, the codex argv log, and `prompt-*.txt` (base branch + commit summary lines).
5. `server.log`, with the bootstrap token redacted.
6. A one-line table per row: `chosen base | POST base/head | notice | error`.

---

## Risks / unknowns for the capturer
- **`NODE_USE_ENV_PROXY`** must be honoured by Effect's fetch client on Node v24.22.1. Smoke-test it first. The first
  `create_pr` call must show `CONNECT github.invalid:443` in the stub log. If it does not, the fallback is an
  `--import` preload that patches `dns.lookup` so that `github.invalid` resolves to 127.0.0.1, with the stub listening on 443.
  That needs root, so prefer the proxy. Do **not** edit `/etc/hosts`, because that is outside the repo.
- **GraphQL response shape** for `listPullRequestsByHead` is a best guess. Iterate on server-log decode errors.
  An `opened_existing` result means the stub returned a PR, which is wrong.
- `findOpenPr` runs again after the POST. If the stub returns an empty list there too, the result is `status:"created"`
  without `url`. That is fine. Returning the created PR on the second lookup is optional and makes `pr.url`/`baseBranch` come from the stub.
- **Background status fetch** (`GitVcsDriverCore.ts:1300ff`) will try `git fetch origin` against
  `https://github.invalid/...` through the proxy. The stub must 404 it, and it must not change refs. It must not be
  pointed at the bare repo, or it would silently "prune" R2b and "un-stale" the state mid-run. Re-dump
  `for-each-ref` **after** each run and confirm it did not change.
- The provider-detection cache (`PROVIDER_DETECTION_CACHE_TTL`) and the `unknownRemoteCache` live per server.
  Use a fresh server and home per row and version.
- The fake codex must exit 0 for `mcp list --json`, or text generation logs a warning but continues. That is fine either way.
  Provider status probes may call `codex` with other args, so make the fake tolerant (exit 0, log).
- `HOME`/`GIT_CONFIG_GLOBAL` isolation matters. The real `~/.gitconfig` may contain `insteadOf` rules or a credential helper.
- R4a before-version: confirm that the POST really is sent with `base:"parent"`. The before-code's
  `resolveBaseRangeRef("parent")` resolves the local branch, so the range read succeeds.
- Text from `wt-before` may differ slightly (the before version's `runPrStep` has no `message`). Only compare
  base, POST body, notice, and error.
