v1 fetch layer, TypeScript lane: resolve, trust, bytes, cache and lock - #400
Merged
Merged
Conversation
Implements the TS fetch-layer module (ts/src/ocifetch/) against the frozen
v1 contract (spec/fetch-v1/constants.json): multi-base tag resolve with an
OCI image index, platform selection, Sigstore bundle v0.3 trust (referrers
API plus the sha256-<hex> fallback tag, both always tried), zstd-decode
with a hand-rolled multi-frame loop (Node's built-in zstd decoder stops
after the first frame of a concatenated stream), the tightened v1 tar
rules layered onto ts/src/tar.ts, an OCI image layout cache with an
optimistic-concurrency index.json writer, lock schema 3, and the seam
(ensure/resolveInstalled/listInstalled/verifyInstalled/fetchSigned).
Engines floor moves to node >=22.21 for the zstd and proxy APIs this lane
depends on (measured on macOS: https.Agent({proxyEnv}) routes through
HTTPS_PROXY via CONNECT; tls.getCACertificates('system'/'default') both
return real certificates).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…emp dirs, docs drift CI on #400 found three real issues: - test/ocifetch/tar-extract.test.ts asserted against the v1 ArtifactCorruptError (ts/src/ocifetch/errors.ts), but extractTarStream lives in the shared v0 tar.ts and throws v0's own ArtifactCorruptError (ts/src/errors.ts) — a different class with the same name. unpack.ts's caller already re-wraps it into the v1 class, so the real code path was never wrong; only the test's import was. - Three test temp-dir prefixes ("chtypes-v1-...") matched lint-public's private-sibling-repository-name pattern (chtypes-<letter>). Renamed to "ocifetch-v1-...". - docs/support.md's generated table was stale against the engines bump; regenerated with scripts/support-matrix.sh (one line, no other drift). - A conformance.test.ts doc comment cited scripts/fetch-v1/parity.py, which does not exist until lane 0B lands; reworded to cite the existing scripts/fetch-v1/ directory instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…othing discoverSignatureCandidates fetched both the referrers API and the sha256-<hex> fallback tag unconditionally on every call. Make it match Go, Python and Rust: query the referrers API first, and fetch the fallback tag only when that answer names no candidate of the trusted signature artifactType (empty, goldens-only, or the API unsupported/404). The verdict was already correct either way, but the request count now matches what the conformance cases assert on a referrers-API-only host. A referrers-API 404 is still never retried (treated the same as "unsupported here") — this is a discovery probe, not a promise that a specific digest exists, unlike a by-digest manifest/blob fetch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ts/test/ocifetch/conformance.test.ts, the CHTYPES_V1_CONFORMANCE runner the v1-conformance job looks for: it runs every case cases.json lists for TS on file and http transports against ensure()/fetchSigned(), spawning scripts/fetch-v1/server.py itself for http, and writes CHTYPES_V1_REPORT. Running it against the real fixtures surfaced gaps the earlier rounds' narrower testing had not reached: - Local-blob-only verification of a pre-seeded cache entry (no verified.json yet) was entirely unimplemented. New localverify.ts scans blobs/sha256/ for a signature referrer the same way dsse.ts tries a fetched referrers list, verifies it, and unpacks it — the zero-network path resolveInstalled/--offline need and the only one the offline-hit/ offline-newest-*/system-dir-readonly fixtures exercise. - Monotonicity was warning but still installing the older build; it must refuse the older install and keep serving the existing newer one. - A dead-host connection error (ECONNREFUSED/ENOTFOUND/EHOSTUNREACH/ EAI_AGAIN/ENETUNREACH) was retried with backoff like a stall; it must fail over to the next base immediately, with zero sleep. - resolveTag's per-base loop did not catch a transient per-base failure, so one dead base in a list aborted resolution instead of trying the next; a verification failure must still never trigger fail-over. - ArtifactStatementCheck.requestedSpelling is now optional, since local verification checks a cached entry against its own claims, not one request's spelling. Known blocker, not fixed here (outside this lane's scope per the v1 fetch plan): two cache-tree cases (existing-install-noop, index-race-reapply) request a spelling no transport tree actually publishes under that name — the manifest is tagged with the case id instead. Every other case group (trust, bytes, lock, http, generic) sets the request spelling to exactly the tag it published; only these two fixtures disagree with themselves. A client that behaves correctly gets an unpublished-tag failure on both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 of 2 tasks
EricAndrechek
added a commit
that referenced
this pull request
Oct 2, 2026
PM review on #407: the comment labelled the whole story "measured, PR #400" when only the symptom was measured (both ts legs timed out at 20 minutes, twice, BlobNotFound in both logs, ci.yml's ts job installs the same lockfile in seconds) — the MECHANISM (that the blanket cut severed the runner's own link) is inferred, not measured. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…enrollment marker until CI proves every leg Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…en by digest, update, goldens, oversize/mediatype, registry transport) Measured in run 37135773590 (ts node22/node24): symbolic tags refused by a positive spelling gate Go does not apply; lock pins recorded the layer digest as bundle; --frozen rediscovered referrers instead of reading the pinned bundle blob; a missing pin was UNPUBLISHED not PINNED; update never rewrote the lock; goldens cases fetched the subject instead of its goldens referrer; an index descriptor with a foreign mediaType and an oversize descriptor size were not refused. Also honors CHTYPES_V1_REGISTRY_BASE for the registry transport. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
Member
Author
|
Round 4, first live CI run of the TS suite (head 522b32a, run 37135773590). The sandbox works; the suite itself failed on real binding defects, now fixed in 7ea3e5f. Nothing here is an environment failure. Per leg (measured, from each leg's log and uploaded report):
Failures (identical on both legs), by cause, all binding-side:
Also in 7ea3e5f: the runner now honors
|
…y against Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…7136877261) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
… it (node24, not node24.21.0) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…n; read CHTYPES_V1_TOOLCHAIN Both TS legs pass 139/139 in CI; v1-parity's only remaining TS problem is the missing registry report (lane NW). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…by v1-network) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The TypeScript lane of the v1 fetch layer (
ts/src/ocifetch/), against the frozen contract inspec/fetch-v1/constants.jsonand the delivery side's agreed spec at commitaa1980f521c791a296d31b68d30cafb4ff6ff2bb. No difference from that spec was found during this lane's work.types.ts— shared types, thePlatformKey/digest helpers, and the repository-root-to-OCI-endpoint URL shape (endpointUrl: an http(s) root gets/v2spliced in between its origin and its own path; afile://root does not, since the fixture trees already bake in the literalv2directory).errors.ts— the v1 error classes, isolated from../errors.ts(v0).http.ts— retry table (5 attempts, doubling from 4s,Retry-Afteron 429/503, refused past budget), redirects (Authorizationdropped cross-origin), the anonymous bearer-token flow for mirrors,https.Agent({ proxyEnv })plustls.getCACertificates('default'|'system')for the system CA store andHTTPS_PROXY, and afile://reader. An oversize response isCORRUPT, never retried.oci.ts— the spelling check, multi-base tag resolve into an OCI image index, platform selection, and by-digest manifest/blob fetches (every by-digest fetch tries every base, retrying a 404 on the last one beforeSOURCE_UNREACHABLE).referrers.ts— the OCI referrers API and thesha256-<hex>fallback tag. The fallback tag is fetched only when the referrers API named no candidate of the trusted signature artifactType (empty, goldens-only, or the API unsupported/404) — matching Go, Python and Rust's request-count shape, which 0B's conformance cases assert on.dsse.ts— hand-rolled Sigstore bundle v0.3 verification (DSSE PAE,node:crypto'sverify(null, …)for ed25519 against a JWK built from the raw public key), the statement content checks, and the manifest-then-layer referrer fetch that ties it tooci.ts/referrers.ts. A bundle whose signature doesn't verify is skipped (try the next referrer); a bundle whose signature verifies but whose statement fails a content check isCORRUPTimmediately.unpack.ts— download-while-hashing, verify-before-decompress, then zstd-decode and unpack. See "Multi-frame zstd" below.layout.ts— the OCI image layout cache,verified.jsonas the source of truth for--offline/resolve_installed, and an optimistic-concurrencyindex.jsonwriter (re-reads immediately before the atomic rename and retries if another writer raced it).lock.ts— lock schema 3; a v0 lock (schema 1 or 2) is refused outright.ensure.ts— the seam (ensure,resolveInstalled,listInstalled,verifyInstalled,fetchSigned).tar.ts(v0, additive only): a newextractTarStreamexport alongside the existingextractTarGz, with two rules v0 never needed — refusing a duplicate entry name and a hard cap on total unpacked bytes, both off by default.extractTarGz's own behavior is unchanged (confirmed: the public API surface tool reportstschanged=falseagainst this PR). The array-formpipeline(...)call this uses is required, not just tidier — an upstream stage's error (a truncated read, a zstd window-log refusal) has to actually reject the call, which.pipe()-then-pass-through would not guarantee.Two things not in the plan, found while building this
zlib.createZstdDecompress()norzlib.zstdDecompressSync()decodes a second frame; the stream's ownbytesWrittenafter'end'reports exactly the first frame's compressed length, with no error.unpack.ts'sdecompressAllZstdFramesloops one decoder per frame using that count to advance through the buffer — the same "loop over frames" the plan already named for Rust'sruzstd, just also true here. This needs confirming against a real multi-frame fixture once lane 0B's fixtures land.GET …/manifests/<ref>(tag and digest) now always sendsAccept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json; the referrers-fallback-tag behavior started as "always fetch both" and was corrected to "fallback only when the referrers API found nothing of the trusted artifactType" (seereferrers.tsabove); and a referrers-API 404 is treated like "unsupported here" (fall back at once, never retried), distinct from the by-digest retry-on-last-base policy.Measured
https.Agent({ proxyEnv: process.env })on anhttps:request issues aCONNECTto the host named byHTTPS_PROXYbefore anything else (verified with a throwaway local proxy).tls.getCACertificates('system')and('default')both return non-empty certificate lists. All on macOS, Node 22.23.2 — the Linux leg isv1-network's to prove once that job exists.test/ocifetch/tar-extract.test.tsparse correctly against a plain-JS replica oftar.ts's own checksum/field-offset logic.Decided here (flagged for the merge/integration lane and the delivery side)
RETRY_AFTER_BUDGET_S(the ceiling past which aRetry-Afteris refused) is set toIDLE_READ_TIMEOUT_S(60s) — not yet pinned by a fixture; adjust onceretry-after-over-budgetexists.fetchSignedseam function's access pattern for a referrer-shaped object (goldens: fetch its own manifest by the digest a prior referrers lookup already found, then that manifest's layer, then that layer's own signature referrer) is inferred from the shared shape with the main artifact flow, not yet pinned by a fixture.--frozendoes not honorCHTYPES_ALLOW_UNSIGNED— a frozen fetch reproduces exactly what was locked, which only ever records a digest that was signed at lock-write time.CHTYPES_ALLOW_UNSIGNED's fallback metadata (version/build/library name, since nothing signed them) comes from the tarball's own config blob when present, falling back to the request spelling and a platform-conventional library name otherwise.Test plan
pnpm exec tsc --noEmit -p tsconfig.jsonand-p tsconfig.test.json— clean.pnpm dlx @biomejs/biome@2.5.13 lint .(the exactlint-tscommand) — clean, 65 files.scripts/policy-merge-check.py --check-carve-out— every newts/src/ocifetch/**file is in the carve-out.scripts/check-selftests-wired.py,scripts/lint-public.sh,scripts/lint-spelling.sh,scripts/lint-cited-paths.sh— clean.scripts/fetch-v1/gen-constants.py --check— clean (no generated file touched).scripts/api-surface.py runagainst this branch's merge base —tsreportschanged=false.0bb8ad5): every requiredci.ymlcontext green (ts,lint-ts,public,docs,abi,abi-fixtures,go,lint-go,python,rust,security,divergences,prose,lint-actions,misspell,api-surface,artifacts).v1-constants(required onv1) green.v1-conformance (ts …)/v1-network/v1-fixtures/v1-parity— red with "not yet provided", as designed: lane 0A's deliberate placeholders until lane 0B's fixtures/server/parity land.test/ocifetch/conformance.test.tsimplements the runner contract (readsCHTYPES_V1_CONFORMANCE, skips loudly by name when unset, otherwise starts the scripted server and writesCHTYPES_V1_REPORT) but has not been exercised against real fixtures yet.Related issues
Part of the v1 fetch-layer work tracked on the SDK repository's own v1 issue.
🤖 Generated with Claude Code