From 6a867e536a6a3b22ee7c4b5f11d1219a7505f5ed Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 01:02:32 -0700 Subject: [PATCH] Merge nucleic/mellow-dewy-falcon-rjhr into main --- Sources/RunnerHost/Doctor.swift | 41 ++++++++++++++++++++++++++------- docs/troubleshooting.md | 24 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/Sources/RunnerHost/Doctor.swift b/Sources/RunnerHost/Doctor.swift index eced3de..08cb35f 100644 --- a/Sources/RunnerHost/Doctor.swift +++ b/Sources/RunnerHost/Doctor.swift @@ -88,8 +88,10 @@ public enum Doctor { /// than at the first poll. /// 8. **Registration token resolvable** from file, inline value, or (if /// enabled) the API. - /// 9. **Runner download URL is live**, via a `HEAD` expecting 200. Catches a - /// version bump that no longer has a darwin-arm64 asset. + /// 9. **Runner download URL is live**, via a one-byte ranged `GET` — the + /// same verb the real download uses, because the presigned redirect + /// target is signed per method. Catches a version bump that no longer + /// has a darwin-arm64 asset. /// 10. **Local Network privacy note** (informational). On macOS 15+ the /// first attempt to reach a guest over the NAT link can be blocked by /// the Local Network permission prompt, which a background agent cannot @@ -455,16 +457,26 @@ public enum Doctor { ) } - var request = URLRequest(url: url) - request.httpMethod = "HEAD" - request.timeoutInterval = 15 - + // A ranged GET, not a HEAD. gitea.com answers an asset request with a + // 303 to a presigned object-storage URL, and the signature covers the + // *method of the request that minted it*: ask with HEAD and you get a + // HEAD-signed URL. URLSession then follows the 303 and — per RFC 7231 + // §6.4.4 — rewrites the method to GET, so the signed URL is replayed + // with the one verb it was not signed for and the store answers 403 + // SignatureDoesNotMatch. Probing with the same verb the real download + // uses is the only way to make the answer mean anything. `bytes=0-0` + // keeps it to one byte instead of the whole 20-plus MB asset. do { - let (_, response) = try await URLSession.shared.data(for: request) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let status = try await probeStatus(url: url, method: "GET", range: "bytes=0-0") if status <= 399 { return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)") } + // A host that rejects ranges outright still deserves a second look + // before we call the asset missing. + let fallback = try await probeStatus(url: url, method: "HEAD", range: nil) + if fallback <= 399 { + return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(fallback)") + } return DoctorCheck( name: name, result: .warn, @@ -483,6 +495,19 @@ public enum Doctor { } } + /// Issues one probe request and reports its status code, or 0 if the + /// response was not HTTP. + private static func probeStatus(url: URL, method: String, range: String?) async throws -> Int { + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 15 + if let range { + request.setValue(range, forHTTPHeaderField: "Range") + } + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse)?.statusCode ?? 0 + } + /// Warns about token files readable by other users on this Mac. public static func checkTokenFilePermissions(config: RunnerConfig) -> [DoctorCheck] { let insecure = config.insecureTokenFilePaths diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4f80745..f2e5dba 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,6 +29,7 @@ gitea-macos-runner service status | Job stays queued forever | Label mismatch, or the daemon isn't running/reaching Gitea | Use bare label names in `runs-on`; match `runner.labels`; check daemon logs | | `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision ` | | Runner rows piling up in the Gitea UI | VMs killed uncleanly; registrations orphaned | Reconcile loop cleans them; force it by restarting the daemon; delete manually if needed | +| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing | | Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` | | `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild | @@ -261,6 +262,29 @@ registrations that have already been spent cannot receive jobs). --- +## `doctor` warns "runner download url → 403" + +**Symptom.** `doctor` reports the runner download URL as a 403, but pasting the same URL into a +browser downloads the binary fine: + +``` +! runner download url https://gitea.com/gitea/runner/releases/download/v3.0.2/… → 403 +``` + +**Cause.** gitea.com does not serve release assets itself. It answers with a `303 See Other` +pointing at a presigned object-storage URL, and that signature covers the HTTP method of the +request that minted it. A `HEAD` probe therefore gets a HEAD-signed URL — and then the HTTP client, +following the 303, rewrites the method to `GET` (RFC 7231 §6.4.4) and replays the signed URL with +the one verb it was not signed for. The store answers `403 SignatureDoesNotMatch`. Nothing is +actually wrong with the asset. + +**Fix.** Upgrade — the check now probes with a one-byte ranged `GET` (`Range: bytes=0-0`), the same +verb `image provision` uses for the real download, and falls back to a `HEAD` only if the range is +rejected. If you still see a 403 after upgrading, the asset really is gone: check `runner.version` +and `runner.runnerDownloadURL` for a `darwin-arm64` build. + +--- + ## Disk filling up **Symptom.** Free space falls steadily; the daemon starts refusing to launch VMs, citing