fix: clone URLs without ".git", and file writes that silently corrupted non-base64 content #5559
AI Triage
(no summary)
Priority: medium Risk area: mixed
Suggested labels: (no label suggestions) Suggested reviewers: (no reviewer suggestions)
Suggestions only — nothing has been applied. The PR author stays in control.
AI review unavailable
The platform's AI balance is exhausted, so AI generation is temporarily unavailable. Nothing was lost. You can queue this as a repair for the internal agent from the repository's Health page, or try again once the balance is restored. The PR is otherwise unchanged.
Correction to the PR description — one motivating claim was wrong.
The description says the suffix-less 404 "had been stranding unpushed commits on a single workstation." That is not true and should not be read as part of the case for this change.
Commit cb3ff9ec on that machine was on its bare repo the whole time (refs/heads/fix/vercel-project-scoped-token, verified over Tailscale). It has since merged to that platform's main as 080ac089 and deployed. The push failure that prompted this investigation was a local DNS resolution problem on one workstation — its modem's resolver NXDOMAIN'd the host while the authoritative nameservers and every public recursor answered correctly. That is not a defect in this codebase, and no work was ever at risk.
What remains true, and is the whole justification for this change:
- Git does not append
.gitto a remote URL.git clone https://host/o/rrequests/o/r/info/refs?service=git-upload-packverbatim. - Measured directly against a running instance, bypassing DNS:
/ccantynz-alt/Vapron.git/info/refs?service=git-upload-pack -> 401 /ccantynz-alt/Vapron/info/refs?service=git-upload-pack -> 404 - So an origin configured without the suffix — the URL a human copies out of the browser address bar — fails with "repository not found" on every clone and push. GitHub and Gitea both serve either spelling; we served one.
- The rate limiter being mounted on the suffixed path only, and the private-repo gate returning an HTML 404 where a 401 challenge is required, are both independently real and are what most of this diff addresses.
The bug is real and measured. The urgency I attached to it was borrowed from a second-hand report I did not verify, and scaling a claim past its evidence is exactly the failure this project has been bitten by before. Correcting it here so the permanent record matches what was actually established.
Defect #2 (the silent base64 mis-decode) is unaffected by any of this — it was reproduced directly and independently.
Blast radius of defect #2: zero. No data was corrupted.
The affected integration enumerated all 47 repos under ccantynz and fetched index.html from every one that has it. All four are intact, well-formed HTML — no mojibake, no truncation (marco-demo 98KB, sharon-maxwell-realestate 50KB, voxlen, vapron-starter-static).
The corrupting path was genuinely reachable, but never executed against a real publish: that client's GLUECRON_API_KEY is unset in its production environment, so it sat in mock mode. The bug was live; nothing went through it. Recording that here so nobody goes hunting for damaged files — and so the fix isn't mistaken for incident remediation. It's prevention.
The other known caller of this endpoint (Vapron's MCP client) base64-encodes on the caller's behalf before sending, so every payload it ever sent was valid base64 — squarely in the class this change leaves byte-for-byte untouched.
Separately: the git plumbing API is now proven end-to-end in production, which closes the one thing I had verified by reading rather than execution. The full chain ran live against a throwaway repo:
POST /git/blobs -> 201 ac8cc050... size 19
POST /git/trees -> 201 1a6339f8...
GET /git/refs/heads/main -> 200 parent 940caf66...
POST /git/commits -> 201 e8574b0d... (tree + parents linked)
PATCH /git/refs/heads/main -> 200 ref now at e8574b0d
GET /tree/main?recursive=1 -> 200 {"tree":[{"path":"plumb.txt","size":19}],"totalCount":1}The branch moved and the tree reflects it. blobs -> trees -> commits -> PATCH refs is a working atomic multi-file commit path over the authenticated API — worth knowing, because it is strictly better than shelling out to git push with a PAT in argv, and because /git/blobs accepts encoding: "utf-8" natively and therefore never touches the trap this PR fixes.
Cross-repo impact
See what breaks downstream if this PR merges.
This pull request was merged into main.
Two independent defects, both found by cross-checking notes with sessions working on other platforms that consume this API. Both were reachable from real, customer-facing paths.
1. Clone and push 404'd unless the URL carried ".git"
Git never appends
.gitto a remote URL —git clone https://host/o/rrequests/o/r/info/refs?service=git-upload-packverbatim. We only registered/:owner/:repo.git/..., so the URL a human copies out of the browser address bar failed on every clone and push. GitHub and Gitea both serve either spelling.Measured on a downstream instance running this code:
An origin configured without the suffix failed every push with "repository not found". On that box it had been stranding unpushed commits on a single workstation — and that repo's GitHub copy was deleted by choice, so the platform is its only source of truth.
The part that needed care.
app.tsxmounts a private-repo gate on/:owner/:repo/*that answers an HTML 404 for a repo the viewer cannot see. Git traffic used to miss that gate purely by URL shape —:repocaptured"name.git", which resolves to no repo. Suffix-less git paths do match it, and an HTML 404 there would replace the 401 challenge a git client needs in order to retry with credentials — breaking clone and push of private repos for legitimate collaborators, while still looking secure from the outside. The exemption is now stated explicitly instead of inherited from a URL quirk; the git routes' owngitAccessGatekeeps the privacy behaviour identical. Tests assertcontent-type: text/plainprecisely so that regression cannot pass silently.2. File-write APIs silently corrupted non-base64 content
PUT /api/v2/repos/:o/:r/contents/:pathand its GitHub-compat twin both did:try { bytes = Buffer.from(body.content, "base64") } catch { return 400 }Buffer.from(s, "base64")never throws. It discards every character outside the base64 alphabet and decodes the remainder — so the catch was dead code and the 400 could not fire. A caller sending a plain UTF-8 string got201 Createdand a file of binary garbage:The write reported success, so the corruption stayed invisible until someone opened the file. This was live on a publish path that ships generated sites.
Sending a raw string is the natural mistake here:
POST /git/blobson this same API acceptsencoding: "utf-8" | "base64"and defaults to utf-8. The two write routes disagreed about whatcontentmeans — the usual two-sources-of-truth shape.contentstays base64 by default (GitHub's semantics, and what our own/api/v2endpoint map promises). Flipping the default would corrupt the callers who are currently correct, by writing their base64 out as literal text. Instead: callers may stateencodingexplicitly using the vocabulary/git/blobsalready accepts, and base64 payloads are now validated, so a plain string is a loud 400 that names the fix. Unpadded and line-wrapped base64 stay accepted, so no currently-working caller can break.Verification
bun run typecheckcleanDATABASE_URL, plus existinglfs-store.tsempty-catch debt)Not changed — flagging for a decision
PUT /contents/:pathis strictly owner-only (user.id !== owner.id -> 403) and is not collaborator-aware, unlike the workflow-dispatch endpoint beside it which resolves real access viaresolveRepoAccess. A write collaborator gets 403 on file writes. That is an ACL inconsistency, not obviously a bug, so I did not change auth semantics on my own initiative.🤖 Generated with Claude Code