Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 01:02:32 -07:00
parent bc2b6cd33b
commit 6a867e536a
2 changed files with 57 additions and 8 deletions
+33 -8
View File
@@ -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
+24
View File
@@ -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 <name>` |
| 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