Skip to content

feat(hub): publish oversized artifacts direct to R2, bypassing the Worker body cap - #3025

Merged
kovtcharov-amd merged 5 commits into
mainfrom
feat/hub-direct-r2-upload
Aug 20, 2026
Merged

feat(hub): publish oversized artifacts direct to R2, bypassing the Worker body cap#3025
kovtcharov-amd merged 5 commits into
mainfrom
feat/hub-direct-r2-upload

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Why this matters

The Agent UI installers have never been publishable to the hub, and the error blamed the wrong thing. Each one is 106–135 MiB, and Cloudflare caps a Worker request body at 100 MB on Free/Pro, so they are rejected with a 413 by the edge before the Worker executes. MAX_ARTIFACT_BYTES (250 MiB) was never consulted — the HTML error body is Cloudflare's, not ours — so raising it would have changed nothing. email and terminal-hub only work because they are small (43.5 MiB and under).

Artifacts at or above 90 MiB now go straight into the same R2 bucket over the S3 API, which has no such cap, and are published by reference: the POST carries the artifact's coordinates instead of its bytes.

Test plan

  • Add R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY repo secrets (Cloudflare → R2 → Manage API Tokens → Object Read & Write on gaia-hub)
  • cd workers/agent-hub && npm test — 214 pass, 10 of them new
  • pytest hub/agents/email/python/tests/test_publish_to_r2_by_reference.py — 7 pass
  • Dispatch release_components.yml with dry_run=false and confirm agent-ui publishes where it previously 413'd
  • Confirm terminal-hub and email still take the inline path unchanged
🔍 How the integrity guarantee survives

The Worker no longer sees these bytes, so "the publisher said so" would be an easy accidental outcome. It isn't: before recording anything, the Worker heads the object and checks its size and SHA-256 against what R2 itself stored at PUT time.

R2 keeps a whole-object SHA-256 only for single-part uploads. An object without one is refused (artifact_unverifiable) rather than trusted — so the uploader uses put_object with ChecksumSHA256, never upload_file, which would switch to multipart and silently drop the checksum. A test pins that call shape, because the failure mode is a green upload followed by a rejected publish, mid-release.

Immutability needed rethinking rather than reusing. Inline, the object's presence in R2 is the record, so heading it is the right check. By reference the object always exists by the time the Worker is called, so that same check would 409 against the caller's own upload — the record is the agent manifest, which only lists artifacts this endpoint accepted.

Both new-path tests were verified non-vacuous by mutating the verifier to trust the caller's claim; the hash-mismatch and missing-checksum cases fail as they should.

New secrets are R2 S3 credentials, distinct from the CLOUDFLARE_API_TOKEN that deploys the Worker. Missing any of the three fails loudly naming all of them — never a fallback to the Worker path, which cannot work at these sizes.

Stacked on #2991 (merged) and independent of #3018.

Ovtcharov added 2 commits August 19, 2026 16:47
…at all

The Agent UI installers have never been publishable. Every one is 106-135 MiB
and a Worker request body is capped by the Cloudflare plan — 100 MB on Free and
Pro — so they 413 at the edge before the Worker runs. MAX_ARTIFACT_BYTES was
never the constraint and raising it changes nothing; the HTML error body is
Cloudflare's, not ours. terminal-hub and email only work because they are small.

POST /publish now accepts an artifact either inline, as today, or by reference:
CI PUTs the bytes straight into R2 over the S3 API, which has no such cap, then
sends `artifact_ref_{filename,sha256,size,content_type}` instead of the file.

The Worker not seeing the bytes must not become "the publisher said so". Before
recording anything it heads the object and checks the size and the SHA-256
against what R2 itself stored at PUT time. R2 keeps a whole-object SHA-256 only
for single-part uploads, so an object without one is REFUSED rather than
accepted on trust — CI has to upload single-part with x-amz-checksum-sha256.

Immutability needed rethinking rather than reusing. Inline, the object's
presence is the record, so heading it is the right check. By reference the
object always exists by the time we are called, so that check would 409 against
the caller's own upload; the record is the agent manifest, which only lists
artifacts this endpoint accepted.

10 tests cover the new path, including the two that matter: a hash that does not
match the stored bytes, and an object R2 could not checksum. Both were confirmed
to fail when the verifier is mutated to trust the caller's claim.
… the Worker

The publisher now routes any artifact at or above 90 MiB into R2 over the S3
API and publishes it by reference, rather than streaming it through a Worker
request that Cloudflare rejects at 100 MB. The threshold sits below the real
cap on purpose: an artifact growing into the limit should change lanes before
it starts 413ing mid-release, not after.

put_object, never upload_file. R2 records a whole-object SHA-256 only for
single-part uploads, and the Worker refuses to publish an object it cannot
verify — upload_file switches to multipart above its own threshold and drops
the checksum, which would turn a green release into an unverifiable one.

Shared by every hub publisher, so email and terminal-hub inherit it; both stay
on the inline path today because they are small, and a test pins that so this
does not quietly change how the working agents publish.

Needs R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and CLOUDFLARE_ACCOUNT_ID. These
are R2 S3 credentials, NOT the CLOUDFLARE_API_TOKEN that deploys the Worker.
Missing any of them fails loudly naming all three rather than falling back to
the Worker path, which cannot work for these sizes.
@github-actions github-actions Bot added devops DevOps/infrastructure changes agent::email Email agent changes sidecar Agent sidecar contract / harness labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes

This makes the Agent UI installers publishable at all — they're 106–135 MiB and Cloudflare rejects them at the edge before the Worker runs — by uploading them straight to R2 and having the Worker verify the stored object instead of the bytes on the wire. The verification design is solid: the hub checks size and hash against what R2 itself recorded, and refuses anything it can't verify rather than trusting the publisher.

One thing needs fixing before merge:

  • A re-publish can overwrite an already-published installer and still report success. The release script uploads the file to R2 first, then asks the hub to record it. The hub correctly refuses the second recording as immutable — but by then the stored file has already been replaced. The catalog keeps the old checksum while the bucket serves the new bytes, and the script's "already published, identical bytes" check re-downloads the file it just overwrote, so it always agrees with itself and the release job exits green. Upload only after confirming the file isn't already published, so the existing mismatch check can do its job.

Worth addressing in the same pass: the size ceiling that guards the normal publish path isn't applied to the new one, so an artifact of any size can be recorded. The README section added here still describes that ceiling as if it covered everything.

Real-world evidence

The evidence bundle exercised both lanes against a live Worker (wrangler dev), not just unit tests, and it materially supports the verification half of this change:

  • The real publisher CLI published inline (201, server SHA verified) and re-ran as an idempotent 409.
  • POST /publish by reference returned 201 and recorded the R2-held size and hash; every refusal was driven live — artifact_not_uploaded (404), artifact_unverifiable (409, no whole-object SHA), artifact_mismatch on both wrong hash and wrong size (409), version_exists (409), malformed sha/size (400), both parts at once (400), bad token (401) — with the catalog re-read afterwards and unchanged.
  • A real 94,371,841-byte file with the R2 credentials unset failed loudly and made no POST.
  • Spot regression: a by-reference artifact downloaded and hashed to its catalog value, the inline artifact still served, and gaia hub list rendered the published agent.

Marked not exercised: the boto3 put_object leg into real R2 (no credentials on that runner — its no-credential branch was proven, and the call shape is covered by new unit tests). Agent UI screenshot is legitimately N/A — no UI file changed. The overwrite issue above is a static-review finding; nothing in the bundle contradicts it, because the bundle never re-published differing bytes for the same filename.

🔍 Technical details

🔴 Critical

The R2 PUT precedes the immutability check, so a re-publish overwrites published bytes and reports success (hub/agents/email/python/packaging/publish_to_r2.py:226)

_upload_to_r2 runs unconditionally before the POST. The Worker's by-reference alreadyPublished guard keys on the agent manifest (workers/agent-hub/src/publish.ts:347) and fires after the object at agents/<id>/<version>/<filename> has already been replaced. The 409 handler then calls _download_sha256, which fetches the bytes just written — so remote_sha == local_sha always holds and it prints OK 409 — already published with identical bytes and exits 0. Result: the catalog's sha256/size_bytes describe the old artifact while R2 serves the new one, breaking install-time lock verification, with no failing signal anywhere.

The inline lane is immune (the Worker rejects before BUCKET.put). This fires whenever an oversized artifact is re-published for the same version with different bytes — a re-cut installer on an existing tag — which is exactly the case the immutability rule exists to catch. A plain agent-ui job re-run re-downloads identical release assets, so it won't fire every time.

Fix: skip the upload when the filename is already published, which also restores the 409 mismatch check's meaning:

    by_reference = size >= DIRECT_UPLOAD_THRESHOLD
    if by_reference:
        # Never overwrite: the Worker's immutability check runs after this PUT,
        # so uploading first would replace published bytes and leave the catalog
        # describing the old ones.
        head = requests.head(
            f"{base_url.rstrip('/')}/agents/{agent_id}/{version}/{filename}",
            timeout=60,
        )
        if head.status_code == 200:
            print(
                f"[publish] {filename} already in R2 — skipping upload; the "
                "POST below will 409 and the bytes are verified there.",
                flush=True,
            )
        else:
            _upload_to_r2(
                artifact_path, f"agents/{agent_id}/{version}/{filename}", local_sha
            )

(A HEAD against the public download route is the smallest change; reading the catalog manifest works too. Either way the existing 409 branch then compares stored vs. local bytes and fails loudly on divergence.)

🟡 Important

By-reference publishes bypass MAX_ARTIFACT_BYTES (workers/agent-hub/src/publish.ts:360)

maxBytes(env) is enforced only in the inline branch; verifyUploadedArtifact has no ceiling, so an object of any size gets recorded. The README section added in this PR (workers/agent-hub/README.md:276) still presents MAX_ARTIFACT_BYTES as the artifact size cap. The default (250 MiB) comfortably clears the 135 MiB installers, so enforcing it costs nothing:

  if (head.size !== claimedSize) {
    throw new HttpError(
      409,
      "artifact_mismatch",
      `Object at ${key} is ${head.size} bytes but the publish claims ${claimedSize}. ` +
        `Re-upload the artifact; the catalog was not modified.`
    );
  }
  const limit = maxBytes(env);
  if (head.size > limit) {
    throw new HttpError(
      413,
      "artifact_too_large",
      `Object at ${key} is ${head.size} bytes, over the ${limit}-byte limit. ` +
        `The catalog was not modified.`
    );
  }

🟢 Minor

No preflight for the three new R2 secrets (.github/workflows/release_components.yml:422). Require the publish token guards GAIA_HUB_TOKEN but not the R2 credentials, so the agent-ui job can wait up to 60 minutes for assets and download ~400 MB before failing on a missing secret. Extending that same step is a two-line change.

The sibling publisher didn't get the change (hub/agents/gaia/python/packaging/publish_to_r2.py). It's a near-identical copy of this script and the flagship agent's frozen sidecar is the other artifact plausibly heading toward 100 MB. Worth either porting the lane or noting in that file's docstring that oversized artifacts aren't supported there.

substr is deprecated (workers/agent-hub/test/fake-r2.ts:723):

  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);

Strengths

  • The threat this introduces — the hub no longer sees the bytes — is met head-on rather than papered over. Refusing an object R2 has no whole-object SHA-256 for (artifact_unverifiable) closes the multipart hole that would have quietly downgraded verification to a publisher's claim, and both the Python and Worker tests pin that specific regression.
  • The tests assert call shape, not just invocation: put_object over upload_file (single-part), base64 over hex for ChecksumSHA256, and the exact artifact_ref_* field names — the failures that would only appear mid-release.
  • Moving the immutability check from the bucket to the manifest for this lane is subtle and correct, and both the comment at publish.ts:340 and a dedicated test explain why it had to move.
  • The missing-credentials path fails loudly with all three variable names and the dashboard steps to create them, rather than falling back to a lane that 413s.

…gaps

The critical one was mine and it failed silently, which is the worst shape.
The publisher PUT to R2 before asking the Worker to record the artifact, but
the by-reference immutability guard keys on the agent manifest and so fires
only after that PUT. Re-publishing different bytes for a filename already
published therefore replaced the stored object, left the catalog holding the
OLD hash and size, and then the 409 handler re-downloaded the bytes it had
just written — agreed with itself, printed "already published with identical
bytes", and exited 0. Install-time verification would have been broken for
every user with nothing failing anywhere. The publisher now checks before
uploading, which also restores the meaning of that 409.

The by-reference lane also skipped MAX_ARTIFACT_BYTES, so the ceiling the
README calls the artifact size cap did not cover the one lane that exists for
the largest artifacts. Enforced there too; the 250 MiB default clears the
135 MiB installers.

agent-ui checked its credentials after waiting up to an hour for release assets
and pulling ~400 MB. Moved ahead of the wait and extended to the R2 pair, so a
missing secret fails in seconds.

The flagship publisher is a separate script without this lane, and its frozen
sidecar is the other artifact plausibly heading past 100 MB. Rather than
duplicate the implementation it now refuses loudly at the same threshold and
names what to port, instead of letting a release discover an HTML 413.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

All four fixed in 1a00c25. The 🔴 was mine and it was the dangerous kind — it exits green.

You're right about the ordering. Uploading before the Worker's manifest-keyed immutability check meant a re-publish with different bytes replaced the stored object, left the catalog holding the old hash and size, and then the 409 handler re-downloaded the bytes it had just written — agreed with itself, printed "already published with identical bytes", and exited 0. Install-time verification broken for every user, with nothing failing anywhere. Took your fix: HEAD the download route first, skip the upload if it's there, which also restores the meaning of that 409.

Finding Resolution
🔴 Upload precedes the immutability check Fixed — check first, never overwrite
🟡 By-reference skips MAX_ARTIFACT_BYTES Fixed — enforced in both lanes
🟢 No preflight for the R2 secrets Fixed — and moved ahead of the wait
🟢 Sibling publisher lacks the lane Fixed — refuses loudly, names what to port
🟢 substr deprecated Fixed
🔍 Details

Two tests for the 🔴, and I verified they fail against the old ordering: one that an already-published object is never overwritten, one that a first publish still uploads (so the guard doesn't break the normal path).

Preflight — rather than just extending the step in place, I moved it ahead of "Wait for the installer assets". It was sitting after a 60-minute wait and a ~400 MB download, so a missing secret cost the whole job; now it fails in seconds. It covers the R2 pair as well as GAIA_HUB_TOKEN.

Sibling publisher — porting would have meant a second copy of the upload logic in a 347-line near-duplicate. Instead it now refuses at the same threshold with a message naming exactly what to port (_upload_to_r2 + the artifact_ref_* fields). Better than an HTML 413 discovered after the freeze is paid for; the real answer is sharing the script, which is its own change.

CeilingMAX_ARTIFACT_BYTES now applies to both lanes, with a test.

215 Worker tests and 9 Python tests pass; typecheck and lint clean.

One thing this surfaced that isn't in the diff. The secrets are now on worker-deploy (from #3018), but terminal-hub and agent-ui run in agent-publish and environment secrets don't cross environments — so R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY also need adding to agent-publish, or the publish jobs fail the new preflight.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve

This cleanly solves the real shipping blocker (Agent UI installers at 106-135 MiB exceed Cloudflare's 100 MB Free/Pro request-body cap) without relaxing integrity: the Worker refuses any by-reference artifact whose R2-recorded SHA-256 or size doesn't match the claim, and objects uploaded without a checksum are rejected rather than trusted. The pre-flight HEAD check before the R2 PUT, the manifest-keyed immutability for by-reference vs. bucket-keyed for inline, and the early credential gate in CI are all sound. Test coverage on both the Python and TypeScript sides is thorough.

Two 🟢 nits before you ship:

  1. R2_BUCKET defaults to "gaia-hub" in _upload_to_r2 but is never set in the CI env — the README documents the three R2 secrets but not this optional override. One line in the secrets table would prevent a confusing silent failure if the bucket is ever renamed.

  2. The new WORKER_BODY_LIMIT guard in hub/agents/gaia/python/packaging/publish_to_r2.py has no unit test. The email publisher's pattern covers the happy path and missing-credentials cases; a single test for size >= WORKER_BODY_LIMIT → SystemExit would bring the gaia publisher to the same standard.

🔍 Technical details

Nit 1hub/agents/email/python/packaging/publish_to_r2.py:157

bucket = os.environ.get("R2_BUCKET", "gaia-hub")

The workers/agent-hub/README.md secrets table (lines ~555-562 in this diff) lists R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CLOUDFLARE_ACCOUNT_ID but not R2_BUCKET. Adding a row like R2_BUCKET | optional, defaults to gaia-hub | would make the contract explicit.

Nit 2hub/agents/gaia/python/packaging/publish_to_r2.py:514-522
The size >= WORKER_BODY_LIMIT branch is straightforward but untested. A minimal test along the lines of test_publish_to_r2_by_reference.py::test_the_threshold_sits_below_cloudflares_real_cap would confirm the constant and the error message survive refactors.

No correctness, security, or architecture issues found.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

The core design is sound: single-part PUT to get an R2-recorded checksum, base64 encoding (not hex) for the ChecksumSHA256 boto3 field, per-manifest (not per-object) immutability for by-reference publishes, and Worker verification against R2's own stored checksum rather than the publisher's claim. Tests pin all of these at the call-shape level, which is exactly what matters here.

One finding worth addressing before the next release cycle:

🟡 The 409 handler downloads the full artifact to verify its sha256, with a 120-second timeout. A re-run of a release job takes the by-reference path for installers ≥ 90 MiB: HEAD returns 200 (already in R2), the R2 upload is skipped, the POST to the Worker still fires, the Worker sees the filename in the manifest and returns 409, and _download_sha256 then GETs and buffers the entire 135 MiB file to compare hashes — all within 120 seconds. At ≥ 5 Mbps the GET completes in time; below that, the timeout fires and the re-run fails loudly on what is actually a clean no-op. The local_sha is already in hand at this point (computed from the file on disk), so for the by-reference path the right fix is to HEAD the download URL for the artifact-etag or checksum rather than pulling the body — or, since the first publish verified everything, simply treat 409 on by-reference as success when the pre-upload HEAD returned 200.

Two small nits:

🟢 R2_BUCKET defaults to "gaia-hub" via os.environ.get("R2_BUCKET", "gaia-hub") inside _upload_to_r2. If a staging environment uses a different bucket, the hardcoded fallback would silently write to production. Aligning with the fail-loudly pattern (require it or document the default prominently) is consistent with the rest of the script.

🟢 _upload_to_r2 does not wrap client.put_object() exceptions. A misconfigured endpoint surfaces as a raw botocore.ClientError stack trace rather than the clean SystemExit message the credential-missing path uses. Low-priority in CI but inconsistent.

🔍 Technical details

🟡 409-handler timeout on re-runs of large artifacts

publish_to_r2.py:111-120_download_sha256 issues a requests.get(..., timeout=120) and loads resp.content into memory. For a 135 MiB installer this is ~108 s at 10 Mbps; slower runners exceed the cap.

The path that reaches this code on a re-run:

  1. publish_one:235requests.head(download_url, ...) → 200 (already in R2)
  2. Upload skipped; POST fires with artifact_ref_* fields
  3. Worker: filename in manifest → 409 version_exists
  4. publish_one:333_download_sha256(...) downloads 135 MiB

Simplest fix: when by_reference and the pre-upload HEAD returned 200, treat a Worker 409 as success directly (the pre-upload HEAD + the previous successful publish together imply the stored bytes are correct):

elif resp.status_code == 409:
    if by_reference:
        print(f"[publish] OK 409 — {filename} already published (by-reference, idempotent no-op).", flush=True)
    else:
        remote_sha = _download_sha256(base_url, agent_id, version, filename)
        if remote_sha != local_sha:
            raise SystemExit(...)
        print(f"[publish] OK 409 — already published with identical bytes (idempotent no-op).", flush=True)

Or stream-hash the response to stay under the timeout without buffering 135 MiB.

🟢 R2_BUCKET defaultpublish_to_r2.py:157

🟢 Unwrapped boto3 exceptionspublish_to_r2.py:181

@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit 5f3e7bb Aug 20, 2026
33 of 35 checks passed
@kovtcharov-amd
kovtcharov-amd deleted the feat/hub-direct-r2-upload branch August 20, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::email Email agent changes devops DevOps/infrastructure changes sidecar Agent sidecar contract / harness

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant