OCI remote: one repository name is shared by the under-test and upstream clients, so a registry with both repositories on one host cannot be addressed

Bug Description

A remote OCI run cannot address a registry whose remote and upstream repositories live on the same host, which is Artifact Registry's shape. The suite builds one repository name and uses it against both the repository-under-test client and the upstream client, which differ only by base URL. AR encodes repository identity inside the OCI <name>, so the two sides need two different names and there is no way to supply the second one.

--oci-name-prefix (!268 (merged)) solved the hosted case and is a single value by design. It cannot express two prefixes, and the remote rows did not exist on main when it landed, so this is a second, distinct gap rather than an oversight in that change.

Steps to Reproduce

Needs a build with both the OCI remote rows and --oci-name-prefix. The rows are on sshen/oci-adversarial-pass (85 commits ahead of main, 15 NeedsUpstream descriptors, 15-row remote inventory); the flag is on main.

  1. Create two docker-format repositories on one AR namespace, a hosted upstream and a remote pointing at it, per Track C §C1.
  2. Try to name both. The remote needs <slug>/container/conformance-oci-remote and the upstream needs <slug>/container/conformance-oci-upstream, and only one --oci-name-prefix exists.
  3. Since AR serves both on one host, and /v2/ must be the first path segment so the base URL can only be the host, --registry-url and --upstream-url are necessarily identical:
regconf run --format oci --repository-kind remote \
  --registry-url "https://<ar-host>" \
  --upstream-url  "https://<ar-host>" \
  --oci-name-prefix "<slug>/container/conformance-oci-remote" \
  --credential "bearer:env:AR_TOKEN" \
  --filter 'oci.remote.preflight' --timeout 10m

Reproducibility: always Reproduced independently: yes, on AR staging 2026-09-03

Expected Behavior

A remote OCI run addresses the remote repository for reads and the upstream repository for seeding writes, on a registry where those are two repositories on one host.

Actual Behavior

The run is rejected at config validation, before any request:

upstream-url: must differ from registry-url
exit 2

pkg/conformance/validate.go:556, which compares normalizeBaseURL(registry) against normalizeBaseURL(upstream).

The rejection is correct and is the good outcome. Had it passed, one prefix would point both clients at the same AR repository, so seeding would write into the repository under test and every relay row would pass without any proxying occurring. That silent green is worse than the exit 2, and is the same failure shape Track C §Never reuse a run ID warns about.

Where the single name comes from

remote_seed.go:161   newRemoteFixture(runID, slug, repoSegment) builds ONE
                     repoName via repoNameForRunID and returns it on the
                     fixture.

remote_relay.go:65   relayRow calls newRemoteFixture once, then hands the
                     same fixture to
                       conformance.SeedAndSettle(remoteSeedSpec(oe.Upstream(), fx, ...))
                     for the upstream write and to verdict(ctx, oe, fx) for
                     the read through oe.Client().

module.go:298        upstream: client.New(cfg.UpstreamURL, cred, cfg.HTTPClient)
                     so the two clients differ only by base URL.

config.go:85 (main)  a single OCINamePrefix; no upstream variant.

Two call sites construct a fixture: relayRow and remote_preflight.go:78. But two remote rows build a name without a fixture at all, calling repoNameForRunID directly:

remote_not_found.go:230          repoSegmentRemoteNotFound
remote_write_refused.go:676      repoSegmentRemoteWriteRefusedBlobUpload

Both are correct as they stand, because neither needs an upstream name: not-found reads a coordinate nothing seeded, and write-refused-blob-upload attempts a write against the repository under test. So they want the under-test name and only that.

They still matter to a fix. Under Option A they need confirming, not changing. Under Option B, which replaces the single name with a per-client accessor, they are two more call sites to convert, and a conversion that misses them leaves two rows silently addressing the wrong repository. Count them as part of the surface rather than discovering them mid-change.

Why npm does not hit this

npm carries the repository in the base URL (https://host/{slug}/npm/{repo}/), so --registry-url and --upstream-url differ naturally and one shared name is correct. OCI is forced to put the repository inside <name> because /v2/ must be the first path segment, so the base URL can only be the host.

A remote npm run against the same AR namespace works: 13 rows, 7 pass, 6 fail, all six failures pre-existing and tracked.

Suggested Fix

Option A is chosen and its first half has merged. !277 (merged) landed --oci-upstream-name-prefix, Config.OCIUpstreamNamePrefix, the two cross-flag rules, the format-qualified URL-equality rule, the ociEnv.UpstreamNamePrefix() accessor and the one site that resolves the empty-means-reuse default, plus the S04, S07 and S08 amendments. It does not close this issue and does not claim to: closes_issues is empty.

What is left is the wiring, and it can only be written where the rows live. pkg/conformance/oci/remote_*.go does not exist on main, so UpstreamNamePrefix() has no production caller there. The remaining work sits on the unmerged stack !266 (merged) -> !271 (merged) -> !272 (merged) -> !273 (merged) and is:

  1. An upstream-side name builder alongside repoNameFor, reading UpstreamNamePrefix() where that one reads NamePrefix().
  2. A second name on remoteFixture, built by newRemoteFixture.
  3. The upstream-facing call sites taking it: five in remote_seed.go (blobPresent, seedBlob, manifestPresent, seedManifest, readBackManifestAt) and three in remote_listings.go. Every other fx.RepoName use goes through oe.Client() and is the under-test name, correctly.

The ordering this creates is the part worth reading before starting. The URL-equality carve-out is on main now, and the two names are not. So a run that sets two differing prefixes is admitted by validation today, and on the stack as it stands newRemoteFixture still builds one name:

repoName, err := repoNameForRunID(runID, repoSegment)   // remote_seed.go, two args

An implementer who does only the obvious half, making the rows honour --oci-name-prefix, and misses the half that is not obvious, addressing the upstream by the other name, gets: validation admits the pair, seeding writes into the repository under test, the relay reads the same coordinate back, and every relay row passes with no proxying having occurred. That is the silent green §Addressing two repositories on one host properties 2 and 4 exist to refuse, reached through a call site rather than through configuration. The gate is open and the road is not paved yet.

One backstop exists, and it covers less than it looks like it does. repoNameForRunID took a prefix parameter in !268 (merged), so merging the stack onto current main fails to compile at exactly three sites:

pkg/conformance/oci/remote_seed.go:162          not enough arguments
pkg/conformance/oci/remote_not_found.go:230     not enough arguments
pkg/conformance/oci/remote_write_refused.go:676 not enough arguments

So newRemoteFixture cannot be overlooked. remote_not_found.go:230 and remote_write_refused.go:676 both address the repository under test and take repoNameFor. What no compiler catches is which name is filled in at newRemoteFixture once it compiles, which is the whole of item 2 and 3 above.

Options B and C from the original triage are recorded below for the record. Option B remains the stronger answer to the class of bug and is what property 4 asks for in prose: pair each name with the client that addresses it so a call site cannot mismatch them. Whoever writes items 1 to 3 should decide between "a second field plus discipline" and "a type that refuses the mismatch" rather than treat A's merged half as having settled it.

Option B: make name construction per-client. newRemoteFixture returns a builder, or the fixture carries a nameFor(client) accessor, and each call site asks for the name matching the client it is about to use. More invasive than A at the type level but removes the class of bug rather than adding a field: with A, a future row that hands the wrong name to the wrong client still compiles. Worth weighing against how !268 (merged)'s own two-function split turned out to be exactly that kind of trap (see ae1252f).

Option C: move the prefix onto pkg/client/oci.Client. Each client prepends its own prefix, so --registry-url and --upstream-url stay hosts and the two prefixes travel with the two clients. Conceptually the cleanest place, since the prefix is a property of how an endpoint is addressed. The catch, which is why !268 (merged) did not do this: the conformance code would then hold an unprefixed name while the registry echoes the prefixed one, so any assertion comparing a response's name against the row's own would break. Nothing asserts that today, which is what makes C tempting, and it is also what makes C fragile: the first row that does compare will be wrong.

The validate.go revisit this section originally called for is done, in !277 (merged): the equality rule now admits identical base URLs when the two OCI names differ, and the rejection's Reason names the second remedy under --format=oci. S08 §Addressing two repositories on one host is the amendment that pins it.

Suggested DRI

Conformance tooling, DRI @radbatnag, reviewers @mkhalifa3 and @sylviashen. The closed-beta.md workstream table notes that DRI needs re-confirming. S16 OCI remote (@radbatnag) owns the rows this blocks, which points the same way.

Unassigned, per triage policy.

Labels to Apply

  • type::bug
  • severity::2 — no workaround: no combination of --registry-url, --upstream-url and --oci-name-prefix can address two repositories on one host, so the entire OCI remote track is unrunnable against Artifact Registry. Not severity::1: nothing is lost or exposed, and the suite fails closed rather than reporting a false pass.
  • Category:Artifact Registry
  • artifact-registry::devex — closest stream label for validation tooling.
  • backend
  • format::oci, repo-type::remote
  • bug::functional
  • No AR-Blocks::*: does not block an Artifact Registry release. It does block using the suite as a release gate for OCI proxying.

Additional Context

Environment Artifact Registry staging
Org slug ar-registry
Repositories conformance-oci-upstream and conformance-oci-remote, both docker, created 2026-09-03 and left in place
Format OCI
Auth method token exchange, bearer. AR's /v2/ accepts only Bearer; Basic is 401 even with __token__
Client and version sshen/oci-adversarial-pass at 3611734, plus a local stand-in for --oci-name-prefix
First observed (UTC) 2026-09-03 15:02
Correlation ID None — rejected at config validation, so no request was made.
Workaround none known

What was already verified, so it need not be redone:

  • The remote rows exist and the gate is open on sshen/oci-adversarial-pass: 15 rows from list --format oci --repository-kind remote, 15 NeedsUpstream in pkg/conformance/oci/module.go.
  • The branch builds, and the two repositories create cleanly. The remote's connection test answers passed: true, http_status: 401, which is expected here rather than a credential problem: Track C §C2 records that the container probe is always unauthenticated because container upstreams expect a token exchange the probe skips.
  • The prefix mechanism itself works against AR. Hosted OCI runs 47 of 47 rows on main with --oci-name-prefix, where it previously ran none.
  • Addressing AR by the public host artifact-registry.staging.gitlab.com rather than the Runway host matters on this track for npm; it is not what blocks OCI.

One other row was expected to cost this run, and now does not. #57 (closed) reads Docker-Content-Digest off the post-redirect response, and oci.remote.head-parity was the one remote row exposed to it: its blob arm (checkBlobHeadParity, remote_head.go) reads the header off a GetBlob. !279 (merged) fixes #57 (closed) in pkg/client/oci, and the warm re-run of that row passes with it applied. Hosted OCI on main plus !279 (merged) is 43 of 47 with zero failures and four legitimate skips, so nothing else is queued behind this issue.

The other blob rows were never exposed, and deliberately so. blobDigestRelayVerdict hashes the received body instead of reading res.Digest, and its own comment gives the reason: reading the verdict would make the row's pass depend on a header S08 assigns to oci.remote.docker-content-digest. oci.remote.blob-range compares bytes and Content-Range. oci.remote.docker-content-digest reads the header off a manifest GET and HEAD, which Artifact Registry serves directly rather than redirecting to storage. Seeding is unaffected: remote_seed.go reaches a blob only through HeadBlob against the upstream, and discards the result.

remote_head.go is the only file under pkg/conformance/oci/ matching remote_*.go that reads DockerContentDigest at all.

#55 (closed) does not apply here; checked, so it need not be re-checked. That defect needs a manual redirect re-issue to exist, and OCI has none: grep -rl ErrUseLastResponse pkg/client/oci/ returns nothing, and the single hit under pkg/conformance/oci/ is in negative_unauth_test.go, a test. OCI lets net/http follow redirects, and the stdlib already drops Authorization on a cross-host hop. So the OCI seeding path is not exposed to it, unlike npm's.

Edited by Sylvia Shen