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.
- Create two
docker-format repositories on one AR namespace, a hosted upstream and a remote pointing at it, per Track C §C1. - Try to name both. The remote needs
<slug>/container/conformance-oci-remoteand the upstream needs<slug>/container/conformance-oci-upstream, and only one--oci-name-prefixexists. - 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-urland--upstream-urlare 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 10mReproducibility: 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 2pkg/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 repoSegmentRemoteWriteRefusedBlobUploadBoth 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:
- An upstream-side name builder alongside
repoNameFor, readingUpstreamNamePrefix()where that one readsNamePrefix(). - A second name on
remoteFixture, built bynewRemoteFixture. - The upstream-facing call sites taking it: five in
remote_seed.go(blobPresent,seedBlob,manifestPresent,seedManifest,readBackManifestAt) and three inremote_listings.go. Every otherfx.RepoNameuse goes throughoe.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 argsAn 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 argumentsSo 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::bugseverity::2— no workaround: no combination of--registry-url,--upstream-urland--oci-name-prefixcan address two repositories on one host, so the entire OCI remote track is unrunnable against Artifact Registry. Notseverity::1: nothing is lost or exposed, and the suite fails closed rather than reporting a false pass.Category:Artifact Registryartifact-registry::devex— closest stream label for validation tooling.backendformat::oci,repo-type::remotebug::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 fromlist --format oci --repository-kind remote, 15NeedsUpstreaminpkg/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
mainwith--oci-name-prefix, where it previously ran none. - Addressing AR by the public host
artifact-registry.staging.gitlab.comrather 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.