Skip to content

v1 fetch layer, TypeScript lane: resolve, trust, bytes, cache and lock - #400

Merged
EricAndrechek merged 17 commits into
v1from
v1-ts
Oct 3, 2026
Merged

EricAndrechek merged 17 commits into
v1from
v1-ts

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Summary

The TypeScript lane of the v1 fetch layer (ts/src/ocifetch/), against the frozen contract in spec/fetch-v1/constants.json and the delivery side's agreed spec at commit aa1980f521c791a296d31b68d30cafb4ff6ff2bb. No difference from that spec was found during this lane's work.

  • types.ts — shared types, the PlatformKey/digest helpers, and the repository-root-to-OCI-endpoint URL shape (endpointUrl: an http(s) root gets /v2 spliced in between its origin and its own path; a file:// root does not, since the fixture trees already bake in the literal v2 directory).
  • errors.ts — the v1 error classes, isolated from ../errors.ts (v0).
  • http.ts — retry table (5 attempts, doubling from 4s, Retry-After on 429/503, refused past budget), redirects (Authorization dropped cross-origin), the anonymous bearer-token flow for mirrors, https.Agent({ proxyEnv }) plus tls.getCACertificates('default'|'system') for the system CA store and HTTPS_PROXY, and a file:// reader. An oversize response is CORRUPT, 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 before SOURCE_UNREACHABLE).
  • referrers.ts — the OCI referrers API and the sha256-<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's verify(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 to oci.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 is CORRUPT immediately.
  • 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.json as the source of truth for --offline/resolve_installed, and an optimistic-concurrency index.json writer (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 new extractTarStream export alongside the existing extractTarGz, 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 reports ts changed=false against this PR). The array-form pipeline(...) 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

  • Node's built-in zstd decoder does not continue past the first frame of a concatenated multi-frame stream — measured on this Mac (Node 22.23.2): neither zlib.createZstdDecompress() nor zlib.zstdDecompressSync() decodes a second frame; the stream's own bytesWritten after 'end' reports exactly the first frame's compressed length, with no error. unpack.ts's decompressAllZstdFrames loops one decoder per frame using that count to advance through the buffer — the same "loop over frames" the plan already named for Rust's ruzstd, just also true here. This needs confirming against a real multi-frame fixture once lane 0B's fixtures land.
  • Three wire-level corrections arrived mid-review and are applied here: GET …/manifests/<ref> (tag and digest) now always sends Accept: 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" (see referrers.ts above); 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 an https: request issues a CONNECT to the host named by HTTPS_PROXY before 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 is v1-network's to prove once that job exists.
  • DSSE PAE encoding matches the published test vector exactly.
  • The hand-rolled tar-header fixtures in test/ocifetch/tar-extract.test.ts parse correctly against a plain-JS replica of tar.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 a Retry-After is refused) is set to IDLE_READ_TIMEOUT_S (60s) — not yet pinned by a fixture; adjust once retry-after-over-budget exists.
  • The generic fetchSigned seam 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.
  • --frozen does not honor CHTYPES_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.json and -p tsconfig.test.json — clean.
  • pnpm dlx @biomejs/biome@2.5.13 lint . (the exact lint-ts command) — clean, 65 files.
  • scripts/policy-merge-check.py --check-carve-out — every new ts/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 run against this branch's merge base — ts reports changed=false.
  • Round 2 (commit 0bb8ad5): every required ci.yml context 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 on v1) 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.ts implements the runner contract (reads CHTYPES_V1_CONFORMANCE, skips loudly by name when unset, otherwise starts the scripted server and writes CHTYPES_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

EricAndrechek and others added 6 commits October 1, 2026 20:50
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>
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>
EricAndrechek and others added 2 commits October 2, 2026 03:48
EricAndrechek and others added 2 commits October 3, 2026 12:08
…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
@EricAndrechek

Copy link
Copy Markdown
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):

leg sandbox proofs suite exit pairs (file, http)
v1-conformance (ts node22.21.0), job 111239892910 1a exit 0; 1b exit 0 (uid 1001 = runner uid); 1c survive; 2 curl exit 6 (could not resolve); 3 exit 0, fixture bytes match 1 139 run: 67 passed, 72 failed
v1-conformance (ts node24), job 111239892948 same lines 1 139 run: 67 passed, 72 failed

Failures (identical on both legs), by cause, all binding-side:

  • 56 pairs: a positive spelling gate refused symbolic tags such as t-untrusted-key and b-tampered-layer before any request. Go refuses only the v prefix and -lts/-stable suffix and lets the tag lookup answer, so I matched that. This also affected the trust, bytes and referrers trees.
  • lock pins recorded the layer digest as bundle; the fixture's lock pins carry the bundle blob digest (lock-write-all-platforms, 2 pairs). Fixed (TrustResult.bundleDigest).
  • --frozen rediscovered referrers instead of fetching the pinned bundle blob by digest (frozen-no-discovery, frozen-mirror, 4 pairs), and a missing pin raised UNPUBLISHED instead of PINNED (frozen-unpinned, 2 pairs).
  • update never rewrote the lock (update-re-resolves, 2 pairs).
  • goldens cases fetched the subject manifest instead of its goldens referrer (goldens-artifact-ok, 2 pairs).
  • an index descriptor with a foreign mediaType (unknown-manifest-mediatype) and a descriptor size over the manifest cap (oversize-manifest) were not refused (4 pairs): SOURCE_INCOMPATIBLE and CORRUPT.

Also in 7ea3e5f: the runner now honors CHTYPES_V1_REGISTRY_BASE for the registry transport (skips those pairs loudly when unset), per #411.

spec/fetch-v1/enrolled/ts stays absent until every leg passes. Static checks (tsc for both tsconfigs, biome 2.5.13 lint) are clean; the suite cannot run on this Mac, so the next CI run is the first check of these fixes.

EricAndrechek and others added 7 commits October 3, 2026 12:26
…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
@EricAndrechek
EricAndrechek marked this pull request as ready for review October 3, 2026 17:25
@EricAndrechek
EricAndrechek merged commit 8646b3e into v1 Oct 3, 2026
51 of 53 checks passed
@EricAndrechek
EricAndrechek deleted the v1-ts branch October 3, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant