fix(clone): recover from GitHub organization SAML auth - #968
Conversation
|
🤖 |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔍 Code Review: fix(clone): recover from GitHub organization SAML auth
Verdict
✅ approve — the fix is correct on both halves and I reproduced its central premise locally. Only minors and nits below; none of them block the merge. needs-qa stays on: this is a cockpit dialog change whose retry path deserves one manual pass in a real browser.
🎯 Summary
A user cloning from a SAML-enforced GitHub organization got an unrecoverable dialog: an overflowing raw gh error and no way to act on it. This PR makes that failure a bounded authorization flow — a narrowly-matched github.com/orgs/…/sso link, an automatic retry when the user comes back to the tab, and a scrollable dialog — and, server-side, pins the clone to the validated HTTPS URL so a global gh SSH preference cannot select a key the organization rejects.
Verification of the central claim
I did not take the transport claim on trust; I reproduced it against gh 2.96.0:
- With
git_protocol: sshset forgithub.com(in a throwawayGH_CONFIG_DIR),gh repo clone octocat/Hello-Worldselects SSH and fails;gh repo clone https://github.com/octocat/Hello-World.gitclones over HTTPS. That is exactly the bugghCloneArgsfixes, and passingowner/reporeally does silently pick the rejected transport. - Forcing HTTPS does not cost private-repo access:
GH_DEBUG=1 gh repo clone <https-url>shows gh injecting its own credential helper —git -c credential.https://github.com.helper=!"gh" auth git-credential clone …— so the clone authenticates with the OAuth token, which is precisely the credential that carries the SAML grant. No SSH-user regression. strings $(command -v gh)confirms gh emitsAuthorize in your web browser: %s, sogithubSsoUrlhas a real source line to match rather than a hypothesised one.
I also spot-checked the regex against realistic gh/git SAML output: it matches both the gh Authorize in your web browser: line and the git ERROR: The 'Org' organization has enabled or enforced SAML SSO … visit … line, and correctly rejects https://evil.example/authorize and the https://github.com.evil.example/orgs/x/sso?… lookalike. The narrow-by-design matcher and its accompanying phishing test are the right call.
Findings
Minor
-
packages/web/src/components/clone-project-dialog.tsx:52— the module docstring still states that errors are shown verbatim and that "paraphrasing them into 'could not clone' is exactly the silent-spinner failure this dialog exists to avoid". The new SSO branch replaces the server's message entirely; the rawghtext — the only thing a user can paste into a bug report — is now unrecoverable from the UI. Either keep it visible (a second muted line, or a<details>under the link) or record the deliberate exception in the docstring, so the comment and the code stop contradicting each other. PerCODE_REVIEW.md("Comments cite the spec or issue that motivated the code"), the stale half is the part that matters here. -
packages/web/src/components/clone-project-dialog.tsx:131— thevisibilitychangeretry path is untested. The new test drives onlyfireEvent.focus(window), so neitherretryWhenVisible'svisibilityState === 'visible'guard nor the double-firessoRetryArmedguard that the comment specifically advertises ("The ref prevents browsers that emit both events from cloning twice") is exercised. A case that hides, then shows, then also firesfocus, asserting exactly one additional POST, would pin the behaviour the comment promises. -
packages/web/src/components/clone-project-dialog.tsx:251— "waiting for you to return; cezar will retry automatically" is a promise the code cannot always keep. A ctrl/cmd-click still fires React'sonClick, soawaitingSsolatches and the copy switches — but the tab opens in the background, the window never blurs and visibility never changes, so neither listener ever fires. The user is left staring at an automatic retry that will not happen; only the manual "Retry clone" button rescues it. Consider arming on the first blur/hide rather than on the click, or softening the copy to keep mentioning the button.
Nit
-
packages/web/src/components/clone-project-dialog.tsx:108— theuseCallbackaroundclonenever actually memoizes. TanStack Query v5 (5.101.2,useMutation.js:40) returns{ ...result, mutate, mutateAsync }— a fresh object on every render — socheckoutchanges identity every render,clonewith it, and theawaitingSsoeffect tears down and re-registers both listeners on every render. Behaviourally harmless, but it advertises a stability it does not have; depending oncheckout.mutate/checkout.isPendinginstead of the whole object would make it real. -
packages/cezar/src/server/checkout.test.ts:94— theghCloneArgscase lives insidedescribe('checkout — repo reference parsing'), but it asserts clone-argument construction, not parsing. Its owndescribewould keep the suite's map honest. -
packages/web/src/components/clone-project-dialog.tsx:30—githubSsoUrlis exported, but nothing imports it (searchedpackages/**/*.{ts,tsx}excludingnode_modulesat44186c69; the only reference is line 72 in the same file). Either drop theexportor have the test import it directly and cover the matcher cases at the unit level.
🧪 Validation Gate
| Command | Status | Evidence or limitation |
|---|---|---|
npm run typecheck |
PASS | exit 0 — contract, client, server and web projects all clean |
npm test |
PASS | 332/332 files, 6395/6395 tests on a clean run (see the flake note below) |
npm run test:unit |
PASS | 36/36 node:test cases, 0 failures |
npm run build |
PASS | server + web built; check:pack ok — 481 files, 85 under web/dist (shell + assets present) |
npm run test:package |
PASS | 16/16 — tarball packed, installed, built CLI exercised |
All five gates were executed at 44186c69, in order. Two environment caveats, neither a finding against this PR:
TMPDIRartifact. This sandbox setsTMPDIRinside the repository, so the first run failed 8 tests across 8 files that create a temp dir and assert it is not a git repo (e.g.projects-api.test.ts:753,expect(body.repo).toBeNull() // tmp dir — not a git repo, which instead resolved up to the real checkout). Re-running withTMPDIR=/tmpis what the table reports.- One nondeterministic flake, not attributable to this diff. A single run hit an unhandled rejection in
packages/cezar/src/workflows/run.test.ts—RunManager.rescueStalledQueue→RunStore.appendEventwriting an.ndjsonafter the temp dir was torn down. It passes in isolation (94/94) and the very next full-suite run was clean (6395/6395), so it is a teardown race under parallelism. The diff touches nothing inruns/orworkflows/, so there is no causal path from this change. Flagging it as a pre-existing flake worth a separate issue rather than holding this PR.
⚠️ CI has not run on this PR
Worth a maintainer's attention before merge: the repository's own CI workflow is at completed/action_required for head 44186c69 — the standard GitHub hold on a fork PR, waiting for a maintainer to click Approve and run workflows. gh pr checks 968 therefore reports only license/cla (SUCCESS), and .github/workflows/ci.yml (typecheck → unit → suites → build → E2E → package) has never executed against this branch.
main is not branch-protected (the required-status-checks API answers 404), so nothing mechanically blocks a merge on that. The gate table above is this review's own local evidence and is not a substitute for the repo's CI — please approve the workflow run before merging. mergeable is MERGEABLE; no conflicts against main.
💥 Breaking Changes
None. RepoRef gains a required cloneUrl, which would be breaking for an external constructor — but RepoRef is not on the package's export surface (@open-mercato/cezar exports only . → dist/index.js and ./app-type), and the only construction site in the repo is parseRepoRef itself, which always populates it. slug is retained for messages and dry-run output, so nothing that read it changes. No route, event name, persisted field or CLI flag is touched, so nothing in BACKWARD_COMPATIBILITY.md applies. CEZ_DRY_RUN=1 still works — dryRunCloneRunner never consults cloneUrl.
🧪 Test Coverage
Good, and proportionate to the change. The server side pins the SAML-safe transport without spawning gh (ghCloneArgs kept pure — the right seam), and parseRepoRef's existing table-driven case was extended to assert cloneUrl for every accepted URL spelling, including the git@github.com: form that motivated the fix. The web side covers both the happy SSO path (compact copy, link target/rel, no leaked authorization_request=, "Retry clone", retry-on-return, dialog close) and the phishing negative. The one gap worth filling is the visibilitychange branch, filed as a minor above.
|
🤖
|
🧪 Manual QA instructions (
|
| Priority | Setup and action | Expected result / boundary |
|---|---|---|
| P0-a | On a machine with gh config set -h github.com git_protocol ssh and an SSH key not authorized for a SAML-enforced org, clone a repo from that org via Add project → Clone from GitHub. Follow the Authorize this GitHub organization link, approve in the browser, return to the cockpit tab. |
The first attempt fails with the SAML panel (not a wall of raw gh output); on return the clone retries by itself and the project appears in the sidebar. Boundary: the error panel must stay inside the dialog — no horizontal overflow, dialog scrolls vertically. |
| P0-b | The regression this revision adds. Take any project cloned by the dialog (P0-a's, or any repo — the transport is forced to HTTPS for everyone now). Confirm git -C <project> config --local --get-all 'credential.https://github.com.helper' prints an empty line followed by !gh auth git-credential. Then start a task on it, let it commit, and use the cockpit's Push. |
The push completes without a credential prompt and without stalling. Boundary: it must not hang — if it sits for ~60s and then reports a timeout, the persisted helper is not being picked up by the task worktree. |
| P0-c | With a project cloned as in P0-b, run gh auth logout, then push again from the cockpit, then gh auth login and retry. |
The push fails with a legible one-line error reasonably quickly, and succeeds again after re-login. Boundary: a push that never returns at all is the failure being watched for here. |
| P1-a | Force a SAML failure (real org, or stub the checkout route's error with a message containing https://github.com/orgs/<org>/sso?authorization_request=<token>). Inspect the error panel. |
Link text reads "Authorize this GitHub organization", opens in a new tab, and an Error details disclosure is present and collapsed. Expanding it shows the complete original gh message. |
| P1-b | From the P1-a panel, ctrl/cmd-click the link (background tab — the window never blurs), then press Retry clone. | The copy reads "…or choose Retry clone" — it must not promise an automatic retry that cannot happen — and the button triggers exactly one new clone attempt. |
| P1-c | From the P1-a panel, click the link normally, switch to another tab/window, then switch back. | Exactly one additional clone attempt fires on return, even in a browser that emits both focus and visibilitychange. Boundary: two POSTs is a failure. |
| P1-d | Trigger a non-SAML clone failure: clone owner/does-not-exist-xyz, and separately a repo whose target folder already exists. |
The raw server message is shown verbatim with no SSO link and no Error details wrapper — the SAML panel must not capture ordinary failures. |
| P1-e | Feed the dialog a malformed SAML URL — a token containing %2F or trailing == (stub the error), and separately a phishing shape such as https://github.com.evil.example/orgs/x/sso?authorization_request=abc. |
No link is rendered in any of these cases; the raw error is shown instead. A rendered link to anything other than https://github.com/orgs/… is a hard fail. |
| P2 | Cold-load the cockpit, open the clone dialog at a narrow width (~380px) and in both light and dark themes, with a long error expanded under Error details. | Long tokens wrap rather than overflow, the dialog scrolls, the disclosure is readable and keyboard-reachable in both themes. |
QA reviewer: move merge-queue → qa when starting. All checks pass → replace qa with merge-queue and qa-approved. A failure → replace qa with qa-failed and report the failed action, expected result, and observation. qaGate is on in this repository, so QA approval still gates the merge.
|
🤖 No blockers or majors. Three minors and three nits are listed in the review for the author to pick up — none of them gate the merge. The full local validation gate (typecheck, Labels: autofix: skipped (not my PR — re-run with --autofix to fix it here) |
|
🤖 |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔁 Re-review: fix(clone): recover from GitHub organization SAML auth
Verdict
❌ request changes — one major finding that the previous pass missed. No new commits since the approved review at 44186c69; this re-review re-ran the full gate and went one step further down the transport change, and that step is what turned the verdict.
The delta in one line: forcing the clone onto HTTPS fixes the clone, but gh's credential injection is one-shot — the checkout it leaves behind has a bare HTTPS origin and no credential path, so cezar's own git push can no longer authenticate for exactly the users this PR targets.
Good news alongside it: CI is now green. The last pass reported .github/workflows/ci.yml stuck at action_required on this fork PR; a maintainer has since approved it and Unit, build, E2E, and package passed in 4m36s.
Disposition of the earlier findings
No commits landed since the previous review, so all six earlier findings still stand unchanged and are restated below. Nothing was regressed, nothing was fixed.
🎯 Summary
A user cloning from a SAML-enforced GitHub organization got an unrecoverable dialog: an overflowing raw gh error and no way to act on it. This PR makes that failure a bounded authorization flow — a narrowly-matched github.com/orgs/…/sso link, an automatic retry when the user returns to the tab, a scrollable dialog — and server-side pins the clone to the validated HTTPS URL so a global gh SSH preference cannot select a key the organization rejects.
Both halves are well-built. The UI half is careful about the things that matter (see the security section — I tried to break the matcher and could not). The server half is correct about the clone and incomplete about what happens after it.
Findings
Major
-
packages/cezar/src/server/checkout.ts:110(and:185) — forcing HTTPS fixes the clone but strands every later git operation on the resulting checkout.cloneUrlnow overrides the user's configured transport for every clone, not just SAML-failing ones.ghmakes that clone work by injecting a credential helper for the clone command only — it does not persist. I verified this againstgh 2.100.0:$ gh repo clone https://github.com/octocat/Hello-World.git hw-https $ git -C hw-https config --local --get-regexp credential (none in local repo config) $ cat hw-https/.git/config [remote "origin"] url = https://github.com/octocat/Hello-World.gitSo the checkout keeps a bare HTTPS
origin, and every subsequent git operation falls back on a globalcredential.https://github.com.helper. A user who rangh auth loginand chose SSH never gets that helper installed — andgh config set -h github.com git_protocol sshis precisely the configuration this PR's own regression evidence describes. With no helper:$ git -c credential.helper= -c "credential.https://github.com.helper=" \ ls-remote https://github.com/<private>.git HEAD fatal: could not read Username for 'https://github.com': terminal prompts disabledThat matters because cezar pushes task branches with raw git, not
gh—packages/cezar/src/server/forge/github.ts:2471:const push = await execTool(['push', '-u', 'origin', branch], worktree, 'git', PUSH_TIMEOUT_MS);
and
execTool(github.ts:2545) inherits the ambient environment withoutGIT_TERMINAL_PROMPT=0— unlikeghCloneRunner, which sets it deliberately on line 206 of the file this PR edits. So the failure mode is not a fast, legible error: the push blocks on a credential prompt untilPUSH_TIMEOUT_MS(60s) and then surfaces as a generic timeout. Task worktrees share the parent repo's config, so every task on that project inherits it.Net effect for an SSH-protocol user: before this PR they cloned over SSH and pushed with their key; after it the clone succeeds and the first
Create PRhangs a minute and fails. The SAML user the PR is written for is in this population too — the fix gets them a working clone and leaves them unable to push from it.Concrete fix, cheapest first:
- Persist what the clone borrowed — after a successful clone,
git -C <target> config credential.https://github.com.helper '!gh auth git-credential'(this is exactly whatgh auth setup-gitwrites globally for HTTPS users), or - narrow the blast radius — keep
ref.slugas the default and fall back tocloneUrlonly after a first attempt fails with a SAML-shaped error, so users whose transport already works are untouched.
Either way, a test in
checkout.test.tsasserting the post-clone remote/credential state would pin it. - Persist what the clone borrowed — after a successful clone,
Minor
-
packages/web/src/components/clone-project-dialog.tsx:30— a truncated match is indistinguishable from a good one, and the raw URL is gone. The token charset is[A-Za-z0-9._~-]+, and the match is unanchored, so a value containing anything else is silently cut short rather than rejected. Probing the actual regex:input result …/sso?authorization_request=ABCDEFGH123456==matches …=ABCDEFGH123456— padding dropped…/sso?authorization_request=ABC%2FDEFmatches …=ABC— badly truncatedBecause a non-null
ssoUrlmakes the dialog replace the server message entirely, the user gets a link that dead-ends on GitHub and no way to see the real URL to open by hand. Anchor the match (require the token to run to a non-URL boundary) or, better, keep the raw text reachable — which also fixes the docstring finding below. The variants with no URL at all (theghGraphQL message without anAuthorizeline, the SSH-flavoured SAML error pointing atdocs.github.com) correctly fall through to the raw error, which is the right degradation. -
packages/web/src/components/clone-project-dialog.tsx:52— the module docstring now contradicts the code. It still states errors are shown verbatim and that "paraphrasing them into 'could not clone' is exactly the silent-spinner failure this dialog exists to avoid". The SSO branch replaces the server's message entirely; the rawghtext — the only thing a user can paste into a bug report — is unrecoverable from the UI. Either keep it visible (a second muted line, or a<details>under the link) or record the deliberate exception in the docstring. PerCODE_REVIEW.md("Comments cite the spec or issue that motivated the code"), the stale half is the part that matters. -
packages/web/src/components/clone-project-dialog.tsx:131— thevisibilitychangeretry path is untested. The new test drives onlyfireEvent.focus(window), so neitherretryWhenVisible'svisibilityState === 'visible'guard nor the double-firessoRetryArmedguard the comment specifically advertises ("The ref prevents browsers that emit both events from cloning twice") is exercised. A case that hides, then shows, then also firesfocus, asserting exactly one additional POST, would pin the behaviour the comment promises. -
packages/web/src/components/clone-project-dialog.tsx:251— "cezar will retry automatically" is a promise the code cannot always keep. A ctrl/cmd-click still fires React'sonClick, soawaitingSsolatches and the copy switches — but the tab opens in the background, the window never blurs and visibility never changes, so neither listener fires. The user waits for a retry that will not happen; only the manual "Retry clone" button rescues it. Arm on the first blur/hide rather than on the click, or soften the copy to keep mentioning the button.
Nit
-
packages/web/src/components/clone-project-dialog.tsx:108— theuseCallbackaroundclonenever memoizes. TanStack Query v5 (5.101.2,useMutation.js:40) returns{ ...result, mutate, mutateAsync }, a fresh object every render, socheckoutchanges identity every render andclonewith it; theawaitingSsoeffect tears down and re-registers both listeners on every render. Harmless (React flushes cleanup and setup in the same commit, so no event can slip through the gap), but it advertises a stability it does not have. Depending oncheckout.mutate/checkout.isPendinginstead of the whole object would make it real. -
packages/cezar/src/server/checkout.test.ts:94— theghCloneArgscase sits indescribe('checkout — repo reference parsing')but asserts clone-argument construction, not parsing. Its owndescribewould keep the suite's map honest. -
packages/web/src/components/clone-project-dialog.tsx:30—githubSsoUrlis exported but nothing imports it. Searchedpackages/**/*.{ts,tsx}excludingnode_modulesat44186c69; the only reference is line 72 in the same file, and the test does not import it either. Either drop theexportor have the test import it and cover the matcher cases at the unit level — which would be the natural home for the truncation minor above.
🔒 Security review of the matcher — it holds up
I tried to break githubSsoUrl rather than take the "narrow by design" comment on trust. Running the actual regex against hostile inputs:
| input | result |
|---|---|
https://github.com.evil.example/orgs/x/sso?authorization_request=abc |
rejected |
remote: visit https://evil.example/authorize |
rejected |
https://evil.example@github.com/orgs/Acme/sso?authorization_request=ZZZ (userinfo trick) |
rejected |
http://github.com/orgs/Acme/sso?authorization_request=ZZZ (scheme downgrade) |
rejected |
https://evil.example/r?next=https://github.com/orgs/Acme/sso?authorization_request=ZZZ |
matches the inner, genuine github.com URL — the attacker's wrapper is stripped, so the user still lands on real GitHub |
Pinning the literal https://github.com/orgs/ prefix is what makes all of this work, and matching on the URL shape rather than on gh's English ("Authorize in your web browser:") is the right call — it is the part of the output GitHub is least likely to reword.
On credential hygiene, no leak. The authorization_request value is a session-scoped nonce, not a bearer credential — redeeming it requires being signed in to GitHub as that user. It is kept out of the rendered text (the test asserts not.toContain('authorization_request=')), it reaches the DOM only as the link's href, and rel="noreferrer" keeps it out of the outbound Referer. checkout-progress events carry gh's raw stderr but go to the in-memory workspace bus only (server.ts:2521) — nothing under .ai/cezar/ is written, so CODE_REVIEW.md's "no secrets in state files" rule is satisfied.
On looping, no loop. ssoRetryArmed (a ref, so it survives the re-render) plus the awaitingSso gate allow exactly one automatic retry per link click: retry() disarms before calling clone(), and clone() disarms again on entry. A second SAML failure does not re-arm, so the user must click the link again. Server-side the retry is safe too — checkoutRepo runs cleanupCheckout before answering (checkout.ts:353), so the retry cannot trip over its own half-written directory and get a 409.
🧪 Validation Gate
All five commands from .ai/agentic.config.json, in order, at 44186c69 in an isolated worktree.
| Command | Status | Evidence |
|---|---|---|
npm run typecheck |
✅ PASS | exit 0 — @open-mercato/cezar (tsc --noEmit -p tsconfig.test.json) and @open-mercato/cezar-web (tsc --noEmit) both clean |
npm test |
✅ PASS | Test Files 332 passed (332) · Tests 6395 passed (6395) · 55.21s |
npm run test:unit |
✅ PASS | tests 36 · pass 35 · fail 0 · skipped 1 · 3563ms |
npm run build |
✅ PASS | server + web built; check:pack ok — 481 files, 85 under web/dist (shell + assets present) |
npm run test:package |
✅ PASS | tests 16 · pass 16 · fail 0 · 24133ms |
One environment caveat, not a finding against this PR: this sandbox sets TMPDIR inside the repository, which false-fails the handful of tests that create a temp dir and assert it is not a git repo. The table above was produced with TMPDIR=/tmp. The flake in workflows/run.test.ts reported by the previous pass did not reproduce this time — 6395/6395 on a single clean run.
✅ CI status
Now green, and this is a change since the last review: Unit, build, E2E, and package SUCCESS (4m36s, run 34284143386), license/cla SUCCESS, Publish npm snapshot skipped. No pending checks. mergeable: MERGEABLE, mergeStateStatus: CLEAN — no conflicts against main.
CI passing does not clear the major above: the broken path is a git push from a user's own machine against their own git config, which CI has no way to exercise.
💥 Breaking Changes
None on a published surface. RepoRef gains a required cloneUrl, which would break an external constructor — but RepoRef is not exported from the package (@open-mercato/cezar exposes only . → dist/index.js, ./app-type and package.json), and the sole construction site is parseRepoRef, which always populates it. slug is retained for messages and dry-run output. POST /api/v1/projects/checkout and the checkout-progress event payload — both protected by BACKWARD_COMPATIBILITY.md §2 — are untouched, and the route-inventory drift guard passes. CEZ_DRY_RUN=1 still works; dryRunCloneRunner never reads cloneUrl.
The behavioural change in the major finding is not a contract break, which is why it is filed as major rather than blocker — but it is a silent change to what a user's checkout looks like afterwards, and it deserves a line in the PR description whichever fix you choose.
🧪 Test Coverage
Proportionate and well-placed. Keeping ghCloneArgs pure is the right seam — the transport choice is pinned without spawning a real gh. parseRepoRef's table-driven case was extended to assert cloneUrl for every accepted spelling, including the git@github.com: form that motivated the fix. The web side covers the happy SSO path (compact copy, link target/rel, no leaked authorization_request=, "Retry clone", retry-on-return, dialog close) and the phishing negative.
Three gaps, all filed above: the visibilitychange branch, the matcher's truncation behaviour, and — the one that matters — no test asserts what the clone leaves behind, which is why the push regression got through two passes.
🤖 Generated with Claude Code
|
@piotrchabros, the clone half of this fix is right, but forcing every clone onto HTTPS leaves the resulting checkout unable to push: Two ways out, both cheap: persist what the clone borrowed ( Four minors and three nits are in the review too — the pick of them is that a truncated SSO match is indistinguishable from a good one while the raw URL is discarded, which leaves the user at a dead link with no recourse. Full review: #968 (review) — the validation gate (typecheck, |
|
🤖 Re-review at The full local validation gate passed at Labels: autofix: skipped (not my PR — re-run with --autofix to fix it here) |
Persist GitHub HTTPS credentials for later pushes and preserve SSO error details. Reject truncated authorization links and cover visibility, focus, and manual retries. Validation: typecheck, 51 focused tests, 36 unit checks, build, and 16 package tests pass. Full suite: 6409 passed, 1 skipped; five workflow cleanup ENOENT rejections also reproduce on unchanged PR head 44186c6. Nine new regression cases fail without the fixes.
|
@pat-lewczuk addressed your review in the update ending at 90738b4:
Validation: typecheck, 51 focused tests, 36 additional unit checks, production build, and 16 package tests pass. Nine new regression cases fail against the previous PR head ( Live browser QA against a SAML-enforced organization was not performed, so the existing QA gate still applies. Ready for re-review. |
|
🤖 Re-review requested by the author at |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔁 Re-review: fix(clone): recover from GitHub organization SAML auth
Verdict
✅ approve — all eight findings from the previous pass are fixed, and the major one is fixed correctly: I reproduced the credential mechanism end to end rather than reading it. No blockers, no majors. Three minors and three nits below; none of them gate the merge. needs-qa stays on — the dialog's three retry paths are now unit-tested, but the real-browser pass is still worth one human.
The delta in one line: the previous pass's major was "the forced-HTTPS clone leaves a checkout that cannot push". persistGhCredentialHelper now writes gh's credential helper into the clone's local config, execTool disables the terminal prompt, and tests pin both — including that a task worktree inherits the helper, which is the specific reason github.ts:2471 was broken.
Disposition of the earlier findings
| Severity | Previous finding | Status |
|---|---|---|
| Major | checkout.ts — forced HTTPS strands every later git operation |
✅ fixed (verified below) |
| Minor | dialog :30 — a truncated match is indistinguishable from a good one |
✅ fixed — end-anchored, plus a githubSsoUrl unit suite |
| Minor | dialog :52 — module docstring contradicts the code |
✅ fixed — docstring amended and the raw error made reachable |
| Minor | dialog :131 — visibilitychange retry path untested |
✅ fixed — it.each(['focus','visibility','manual']) |
| Minor | dialog :251 — "cezar will retry automatically" over-promises on ctrl-click |
✅ fixed — copy now names the button; ctrl-click is a tested case |
| Nit | dialog :108 — useCallback never memoizes |
✅ fixed — depends on mutate / isPending |
| Nit | checkout.test.ts:94 — ghCloneArgs sat in the parsing describe |
✅ fixed — own describe |
| Nit | dialog :30 — githubSsoUrl exported but unimported |
✅ fixed — the test imports it |
Nothing regressed.
🔬 The credential fix, verified rather than read
The previous review's major rested on a claim about gh; this fix rests on a claim about git. Both deserve the same treatment, so I ran the exact configuration the code writes:
$ git config --local --replace-all 'credential.https://github.com.helper' ''
$ git config --local --add 'credential.https://github.com.helper' '!gh auth git-credential'
$ cat .git/config
[credential "https://github.com"]
helper =
helper = !gh auth git-credential
Then the part that actually decides it — does that local pair override whatever the user already has, and does it produce a credential?
$ printf 'protocol=https\nhost=github.com\n\n' \
| git -c 'credential.helper=!echo GLOBALHELPER_SHOULD_NOT_RUN >&2; exit 1' credential fill
protocol=https
host=github.com
username=<redacted>
password=<redacted>
GLOBALHELPER_SHOULD_NOT_RUN never fired and a real credential came back. The empty helper = entry resets the inherited list exactly as gh auth setup-git does, and !gh auth git-credential answers. That is the whole fix, and it holds.
Two details worth calling out as good judgement rather than luck:
- The failure path is safe.
persistGhCredentialHelperreturningfalsetakescheckoutRepo's!outcome.okbranch (checkout.ts:375), which runscleanupCheckoutbefore answering — so a retry gets a cleanmkdirrather than the 409 a leftover directory would produce. - The new test substitutes only
gh(a two-line shim) and lets realgitdo the configuring, then assertsgit worktree addinherits the helper. That is the right seam: it pins the mechanism the major turned on, not a mock of it.
The GIT_TERMINAL_PROMPT=0 half is pinned honestly too — draft-pr-autosave.test.ts sets the variable to 1 in the environment and asserts a pre-push hook observes 0, so the test fails if the env option is ever dropped.
I also re-probed the anchored matcher against the cases that motivated it:
| input | result |
|---|---|
…=ABCDEFGH123456== |
rejected (was: silently truncated) |
…=ABC%2FDEF |
rejected (was: truncated to ABC) |
…=ABC + newline / quote / EOS |
accepted |
https://github.com.evil.example/…, http://github.com/…, https://evil.example@github.com/… |
rejected |
Refusing outright is the right call now that the raw message is always reachable under Error details — the user can still open the URL by hand. No catastrophic backtracking: the lookahead adds one linear rescan, with no nested quantifier.
Findings
Minor
1. packages/cezar/src/server/git-changes.ts:31 — the sibling raw-git push has neither guard, and no timeout at all.
Not a regression from this PR — flagging it because it is the one remaining place that does exactly what execTool was just fixed for, and because the fix is the line you already wrote.
POST /runs/:id/git/push (server.ts:4187) → pushCurrentBranch (git-changes.ts:680) → git(dir, ['push']) at :695. That helper's execFile options set no env — so no GIT_TERMINAL_PROMPT=0 — and no timeout, and execFile's default is 0, meaning unbounded. I confirmed git really does reach for the terminal on a github-shaped HTTPS URL with no usable helper:
$ script -qec 'printf "protocol=https\nhost=example.invalid\n\n" | git -c credential.helper= credential fill' /dev/null
Username for 'https://example.invalid':
It only returned here because script closed the pipe; against a live tty it blocks on that read. So when gh auth git-credential cannot answer — logged out, expired token, or a project cloned before this change — that route never returns at all, which is worse than the 60s stall you just removed from execTool. skills-remote.ts:52 already hardens this way; git-changes.ts is the last one out. Either fold env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } plus a timeout into that helper, or file it as a follow-up.
2. packages/cezar/src/server/checkout.ts:194 — the persist failure paraphrases git's error away, and then discards a clone that worked.
When either git config exits non-zero, persistGhCredentialHelper drops stdout and stderr on the floor and the runner answers a fixed string — "Could not configure GitHub credentials for the checkout. Check directory permissions and retry." — after which cleanupCheckout deletes a clone that genuinely succeeded, possibly ten minutes of a large repo.
Two problems with that. The guess at "directory permissions" can simply be wrong: a git missing from PATH produces the identical message. And this module argues the opposite policy eighty lines down at :281 — "The tail of gh/git's own output IS the error message … paraphrasing them would only lose detail." Capture stderr from the failing git config and append it, the way the clone-failure branch already does.
3. packages/cezar/src/server/checkout.ts:276 — the destructive half of the new branch is untested.
The success path is pinned well. The branch that throws away a successful clone and returns 500 is not exercised anywhere, and it is the one with a destructive side effect. A case that makes git config fail and asserts both the error text and that the target directory was removed would pin it — and would have caught finding 2 as a by-product.
Nit
1. checkout.ts:276 floats the promise with no .catch(). finish is only ever called from inside the .then, so a rejection would leave the clone promise unsettled and checkoutRepo's await run(...) hanging the request permanently. persistGhCredentialHelper cannot realistically reject today — every execFile error arrives through the callback — so this is insurance, not a bug. It is just insurance on the one path with no timeout above it.
2. clone-project-dialog.tsx:263 — the nonce is now in the DOM as text, and the old expect(...).not.toContain('authorization_request=') assertion went with it. That is the direct, correct consequence of making the raw message reachable, and the value is a session-scoped nonce that already travels in the link's href, so nothing is newly exposed. Worth one sentence in the docstring's security note so the next reader does not re-litigate it.
3. clone-project-dialog.tsx:263 — this is the only native <details> in packages/web. Every other disclosure in the cockpit is a shadcn component; an unstyled <summary> inside a text-danger block will not carry the dialog's type scale or focus ring. Cosmetic, and only visible on an error path.
🧪 Validation Gate
All five commands from .ai/agentic.config.json, in order, at 90738b47 in an isolated worktree on a clean npm ci.
| Command | Status | Evidence |
|---|---|---|
npm run typecheck |
✅ PASS | exit 0 — @open-mercato/cezar and @open-mercato/cezar-web both clean |
npm test |
✅ PASS | Test Files 332 passed (332) · Tests 6410 passed (6410) · 116.59s (+15 over the previous pass's 6395) |
npm run test:unit |
✅ PASS | tests 36 · pass 36 · fail 0 |
npm run build |
✅ PASS | server + web built; check:pack ok — 481 files, 85 under web/dist |
npm run test:package |
✅ PASS | tests 16 · pass 16 · fail 0 |
The PR's own three files, run on their own: checkout.test.ts + draft-pr-autosave.test.ts → 30/30; clone-project-dialog.test.tsx → 21/21.
Same environment caveat as last time, not a finding against this PR: this sandbox points TMPDIR inside the repository, which false-fails the handful of tests that create a temp dir and assert it is not a git repo. The table was produced with TMPDIR=/tmp. No flakes on a single clean run.
✅ CI status
Green, and no longer pending: Unit, build, E2E, and package SUCCESS (run 34861711389), license/cla SUCCESS, Publish npm snapshot skipped. mergeable: MERGEABLE, no conflicts against main. mergeStateStatus is BLOCKED only because of the previous changes-requested review, which this one supersedes.
💥 Breaking changes
None on a protected surface. persistGhCredentialHelper is not exported, RepoRef is not reachable from the package's exports map, and POST /api/v1/projects/checkout plus the checkout-progress payload — both under BACKWARD_COMPATIBILITY.md §2 — are untouched. CEZ_DRY_RUN=1 still works: dryRunCloneRunner never reaches the new code.
One behavioural note that deserves a line in the PR body and the CHANGELOG, since it is a file cezar did not previously write: cloning now adds a credential.https://github.com.helper entry to the cloned project's local .git/config, which supersedes a user's global helper for github.com in that repo. That is what gh auth setup-git does globally, so it is the conventional choice and strictly narrower, but a user with a corporate credential helper should not have to discover it from a diff.
🧪 Test coverage
Proportionate, and the new cases target the right things rather than the easy things. The credential test uses real git against a fake gh and follows through to a task worktree — that is the assertion whose absence let the push regression through two passes. The it.each(['focus','visibility','manual']) rewrite covers the visibilityState guard, the double-fire ssoRetryArmed ref, and the ctrl-click path in one table, each asserting exactly one additional POST. The githubSsoUrl suite finally gives the matcher a unit-level home, truncation cases included.
One gap remains, filed as minor 3: the credential-persist failure branch.
🤖 Generated with Claude Code
|
🤖 Re-review at The three minors are follow-up material, not merge blockers: the sibling push helper in Full local gate green at Labels: autofix: skipped (not my PR — re-run with --autofix to fix it here) |
Summary
ghSSH preference cannot select an SSH key lacking the OAuth token's organization grantRegression evidence
Reproduced on a SAML-enforced repository: the refreshed GitHub CLI OAuth token had
MAINTAINaccess over HTTPS while the configured SSH key remained rejected by the organization. Passingowner/repotogh repo cloneselected the rejected SSH transport; passing the validated HTTPS URL cloned successfully.Verification
NODE_ENV=test npx vitest run packages/cezar/src/server/checkout.test.ts(24 tests)npm run typecheck -w @open-mercato/cezarBug-Bounty-Switzerland/bbs-frontendsucceeded and registered ondevelop