Conversation
Prefer ECR then GHCR, keep digest pins, and pair checksum with archive per host.
There was a problem hiding this comment.
🤖 AI Review
Adjudicated all 20 reported findings into 19 deduplicated entries. Seventeen are confirmed, one is refuted, and one remains uncertain. The main confirmed risks are regressions for custom registry users and hard-coded OCI authentication; the shared inspection-error misclassification was reported by both reviewers.
Findings
| Severity | Location | Category | Sources | Claim |
|---|---|---|---|---|
| 🟠 MAJOR | packages/stack/src/model/SlimArtifactMirrors.ts:122 |
compatibility |
claude | Applying the existing image-registry override to native artifacts removes the GitHub Releases fallback and requires the custom registry to carry the newly introduced OCI-native artifacts, breaking existing native users whose mirror only contains images. |
| 🟠 MAJOR | packages/stack/src/preparation/SlimNativeOci.ts:32 |
compatibility |
codex | Custom native registries are not resolved through the registry authentication protocol: the client always requests a hard-coded HTTPS /token endpoint before accessing the registry. |
| 🟡 MINOR | packages/stack/src/preparation/SlimServicesSource.ts:386 |
performance |
claude | Every cache miss resolves the upstream checksum twice because ArtifactStore calls checksum before materialize, while materialize ignores that result and fetches candidate metadata again. |
| 🟡 MINOR | packages/stack/src/preparation/RuntimeArtifacts.ts:170 |
error-classification |
claude+codex | Image inspection failures are incorrectly converted into ContainerPullError even though no pull may have occurred. |
| 🟡 MINOR | packages/stack/src/preparation/SlimNativeOci.ts:160 |
integrity |
claude | The OCI archive is not verified against its manifest descriptor digest; archiveDigest is returned but discarded, and verification uses only SHA256SUMS fetched from the same candidate. |
| 🟡 MINOR | packages/stack/src/runtime/resolve-container-image.ts:31 |
error-handling |
claude | When every image pull candidate fails, the resolver discards all failures except the final candidate's error. |
| 🟡 MINOR | packages/stack/src/runtime/resolve-container-image.ts:14 |
test-coverage |
claude | The new container-image resolver has no test covering first-candidate pull failure followed by second-candidate success, or exhaustion of all candidates. |
| 🟡 MINOR | packages/stack/src/preparation/SlimServicesSource.ts:216 |
configuration |
claude | Stack mirror selection bypasses the provided ConfigProvider and project dotenv values by reading the ambient environment directly. |
| 🟡 MINOR | packages/stack/src/preparation/SlimNativeOci.ts:6 |
maintainability |
claude | Four new exports are unused outside SlimNativeOci and are eligible for unused-export failures in the repository's Knip check. |
| 🟡 MINOR | apps/cli/src/command-internal/docker-registry.ts:67 |
behavior-change |
claude | A custom registry override now rewrites slim images to a single custom-registry candidate, changing the previously shipped behavior in which slim references bypassed the override. |
| 🟡 MINOR | apps/cli/src/shared/services/slim-images.ts:149 |
test-coverage |
claude | The newly accepted ECR slim-image namespace is not covered by CLI tests that protect slim-specific runtime specifications. |
| 🟡 MINOR | .github/workflows/mirror-slim-image.yml:190 |
observability |
claude | Best-effort native mirroring relies on a stated daily drift audit, but no such audit is defined in this repository. |
| 🟡 MINOR | packages/stack/src/preparation/SlimNativeOci.ts:60 |
error-handling |
codex | A malformed OCI title annotation can throw a defect and bypass candidate failover. |
| ⚪ NIT | packages/stack/src/preparation/SlimServicesSource.ts:243 |
error-handling |
claude | Native candidate aggregation retains only each failure's message and drops the underlying causes and structured context. |
| ⚪ NIT | packages/stack/src/runtime/resolve-container-image.ts:37 |
dead-code |
claude | The empty-candidate fallback is unreachable and would incorrectly label an unchecked image as cached if reached. |
| ⚪ NIT | packages/stack/src/model/SlimArtifactMirrors.ts:26 |
dead-code |
claude | The detected claude host hint never affects candidate ordering and is behaviorally identical to default. |
| ⚪ NIT | apps/cli/src/command-internal/docker-registry.ts:70 |
style |
claude | Slim registry option construction is duplicated, and overrideValue is evaluated twice in each copy. |
| ⚪ NIT | apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts:263 |
documentation |
claude | The comment saying slim references never pass through a registry mirror is now false. |
Findings outside the diff
- ⚪ NIT
apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts:263— The comment saying slim references never pass through a registry mirror is now false.
Refuted findings (kept for transparency, not posted as review comments)
packages/stack/src/preparation/SlimServicesSource.ts:390(correctness): Reusing compressedPath across candidate attempts can append a later download to a partial earlier archive because no explicit write flag is supplied.
Refuted: Effect FileSystem.sink defaults to write/truncate semantics. The integration test at slim-services.integration.test.ts:575-635 exercises this exact reuse: OCI writes the archive then fails its stale checksum, a later GitHub attempt writes the same path, and the resulting archive extracts successfully.
Stats
Claude findings: 17 · Codex findings: 3 · Confirmed: 17 · Refuted: 1 · Uncertain: 1
Models: claude-opus-5 + gpt-5.6-sol · Trigger: auto · Workflow run
This review runs once per PR. A maintainer can request another with a /ai-review comment.
| if (mapped.kind === "prefix") { | ||
| const host = mapped.value.replace(/\/supabase\/cli\/$/u, "").replace(/\/$/u, ""); | ||
| return [oci(host)]; | ||
| } |
There was a problem hiding this comment.
🟠 MAJOR · compatibility · source: claude
Applying the existing image-registry override to native artifacts removes the GitHub Releases fallback and requires the custom registry to carry the newly introduced OCI-native artifacts, breaking existing native users whose mirror only contains images.
Evidence: packages/stack/src/model/SlimArtifactMirrors.ts:120-125 returns only oci(host) for a prefix override. The pre-PR path in pr.diff always fetched native artifacts from their GitHub release URLs.
Suggested fix: Use a separate native-artifact override, or retain GitHub as a fallback unless users explicitly request registry-only native fetching.
| return `https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull`; | ||
| if (registry === "public.ecr.aws") | ||
| return `https://public.ecr.aws/token/?service=public.ecr.aws&scope=repository:${repository}:pull`; | ||
| return `https://${registry}/token?service=${registry}&scope=repository:${repository}:pull`; |
There was a problem hiding this comment.
🟠 MAJOR · compatibility · source: codex
Custom native registries are not resolved through the registry authentication protocol: the client always requests a hard-coded HTTPS /token endpoint before accessing the registry.
Evidence: packages/stack/src/preparation/SlimNativeOci.ts:27-32 constructs https:///token, and lines 121-123 require that token before requesting the manifest. packages/stack/src/model/SlimArtifactMirrors.ts:122-125 supplies custom registries as the sole candidate.
Suggested fix: Request the V2 resource anonymously first, then parse and follow its WWW-Authenticate challenge using configured credentials and transport settings.
| @@ -307,65 +395,104 @@ export const makeSlimServicesSource = ( | |||
| ]); | |||
| return yield* Effect.gen(function* () { | |||
| const artifact = yield* resolveArtifact(request); | |||
| const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest); | |||
| const manifestText = new TextDecoder().decode(manifestBytes); | |||
| const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( | |||
| manifestText, | |||
| ).pipe( | |||
| Effect.mapError( | |||
| (cause) => | |||
| new StackPreparationError({ | |||
| message: "Slim-services manifest is invalid", | |||
| cause, | |||
| }), | |||
| ), | |||
| ); | |||
| if ( | |||
| typeof manifest !== "object" || | |||
| manifest === null || | |||
| !("service" in manifest) || | |||
| !("version" in manifest) || | |||
| !("target" in manifest) || | |||
| manifest.service !== artifact.service || | |||
| manifest.version !== artifact.version || | |||
| manifest.target !== artifact.target | |||
| ) | |||
| return yield* new StackPreparationError({ | |||
| message: "Slim-services manifest does not match the catalog artifact", | |||
| service: artifact.service, | |||
| version: artifact.version, | |||
| target: artifact.target, | |||
| }); | |||
| const entrypoint = "entrypoint" in manifest ? manifest.entrypoint : undefined; | |||
| const command = "cmd" in manifest ? manifest.cmd : undefined; | |||
| if ( | |||
| (entrypoint !== undefined && | |||
| (!Array.isArray(entrypoint) || | |||
| !entrypoint.every((value) => typeof value === "string") || | |||
| entrypoint.some(unsafeManifestCommand))) || | |||
| (command !== undefined && | |||
| (!Array.isArray(command) || | |||
| !command.every((value) => typeof value === "string") || | |||
| command.some(unsafeManifestCommand))) | |||
| ) | |||
| return yield* new StackPreparationError({ | |||
| message: "Slim-services manifest command is invalid", | |||
| service: artifact.service, | |||
| version: artifact.version, | |||
| }); | |||
| yield* Effect.sync(() => onProgress?.("downloading")).pipe( | |||
| Effect.andThen( | |||
| downloadToFile(artifact.downloadUrl, compressedPath, fetchRequest, expectedSha256), | |||
| ), | |||
| Effect.mapError( | |||
| (cause) => | |||
| new StackPreparationError({ | |||
| message: "Unable to download slim-services archive", | |||
| service: artifact.service, | |||
| version: artifact.version, | |||
| cause, | |||
| }), | |||
| ), | |||
| const options = yield* candidateOptions; | |||
| const archiveName = `${artifact.assetName}.tar.zst`; | |||
| const selectedSha256 = yield* tryCandidates( | |||
| nativeArtifactCandidates(artifact, options), | |||
| (candidate) => | |||
| Effect.gen(function* () { | |||
| const fetched = | |||
| candidate.kind === "github" | |||
| ? { | |||
| manifestBytes: yield* fetchBytes(candidate.manifestUrl, fetchRequest), | |||
| checksumText: new TextDecoder().decode( | |||
| yield* fetchBytes(candidate.checksumUrl, fetchRequest), | |||
| ), | |||
| download: (sha256: string) => | |||
| downloadToFile( | |||
| candidate.downloadUrl, | |||
| compressedPath, | |||
| fetchRequest, | |||
| sha256, | |||
| ), | |||
| } | |||
| : yield* fetchOciNativeTriplet(candidate).pipe( | |||
| Effect.provide(transport(fetchRequest)), | |||
| Effect.map((triplet) => ({ | |||
| manifestBytes: triplet.manifestBytes, | |||
| checksumText: triplet.checksumText, | |||
| download: (sha256: string) => | |||
| downloadToFile( | |||
| triplet.archiveUrl, | |||
| compressedPath, | |||
| fetchRequest, | |||
| sha256, | |||
| triplet.headers, | |||
| ), | |||
| })), | |||
| ); | |||
| // Pair checksum and archive on the same candidate so a stale first-host | |||
| // digest cannot reject a later host's matching bytes. | |||
| const sha256 = yield* parseChecksum(fetched.checksumText, archiveName); | |||
There was a problem hiding this comment.
🟡 MINOR · performance · source: claude
Every cache miss resolves the upstream checksum twice because ArtifactStore calls checksum before materialize, while materialize ignores that result and fetches candidate metadata again.
Evidence: packages/stack/src/preparation/ArtifactStore.ts:694-705 calls source.checksum; packages/stack/src/preparation/SlimServicesSource.ts:386 ignores expectedSha256 and lines 398-436 repeat candidate metadata and checksum resolution.
Suggested fix: Cache checksum metadata between the two operations or let this source materialize without a preliminary checksum lookup.
| resolveAvailableContainerImage(engine, image, process.env, () => | ||
| report("downloading"), | ||
| ).pipe( | ||
| Effect.mapError( | ||
| (cause) => | ||
| new ContainerPullError({ | ||
| message: `Unable to pull container image ${image}`, | ||
| workload: workload.id, | ||
| cause, | ||
| }), | ||
| ), | ||
| ), |
There was a problem hiding this comment.
🟡 MINOR · error-classification · source: claude+codex
Image inspection failures are incorrectly converted into ContainerPullError even though no pull may have occurred.
Evidence: packages/stack/src/runtime/resolve-container-image.ts:26-29 can fail from engine.inspectImage; packages/stack/src/preparation/RuntimeArtifacts.ts:170-180 maps every resolver failure to ContainerPullError with an unable-to-pull message.
Suggested fix: Preserve the operation phase and map inspection failures to ContainerEngineError, reserving ContainerPullError for exhausted pull attempts.
| archiveDigest: archive.digest, | ||
| archiveUrl: `https://${candidate.registry}/v2/${candidate.repository}/blobs/${archive.digest}`, |
There was a problem hiding this comment.
🟡 MINOR · integrity · source: claude
The OCI archive is not verified against its manifest descriptor digest; archiveDigest is returned but discarded, and verification uses only SHA256SUMS fetched from the same candidate.
Evidence: packages/stack/src/preparation/SlimNativeOci.ts:157-163 returns archiveDigest. packages/stack/src/preparation/SlimServicesSource.ts:419-432 omits it and lines 436 and 483 verify the archive only against checksumText.
Suggested fix: Require the descriptor digest to equal the parsed archive checksum and verify downloaded bytes against that digest.
| const failures: Array<string> = []; | ||
| for (const candidate of candidates) { | ||
| const result = yield* Effect.result(tryOne(candidate)); | ||
| if (Result.isSuccess(result)) return result.success; | ||
| failures.push(result.failure.message); | ||
| } | ||
| return yield* new StackPreparationError({ | ||
| message: `Unable to download from all slim artifact sources: ${failures.join("; ")}`, | ||
| }); |
There was a problem hiding this comment.
⚪ NIT · error-handling · source: claude
Native candidate aggregation retains only each failure's message and drops the underlying causes and structured context.
Evidence: packages/stack/src/preparation/SlimServicesSource.ts:243-251 stores result.failure.message strings and creates a new StackPreparationError without cause. StackPreparationError supports cause and service/version context in public/Errors.ts:112-123.
Suggested fix: Attach the collected failures as the aggregate error's cause while retaining the combined user-facing message.
| if (lastError === undefined) | ||
| return yield* engine | ||
| .inspectImage(image) | ||
| .pipe(Effect.as({ image, outcome: "cached" as const })); |
There was a problem hiding this comment.
⚪ NIT · dead-code · source: claude
The empty-candidate fallback is unreachable and would incorrectly label an unchecked image as cached if reached.
Evidence: packages/stack/src/runtime/resolve-container-image.ts:37-40 maps any inspect result to cached without reading present. packages/stack/src/model/SlimArtifactMirrors.ts:79-87 always returns at least one candidate.
Suggested fix: Replace the fallback with an explicit invariant failure or make candidate non-emptiness part of the type.
| if ( | ||
| present(env["CLAUDE_CODE_REMOTE"]) || | ||
| present(env["CLAUDECODE"]) || | ||
| present(env["CLAUDE_CODE"]) | ||
| ) | ||
| return "claude"; |
There was a problem hiding this comment.
⚪ NIT · dead-code · source: claude
The detected claude host hint never affects candidate ordering and is behaviorally identical to default.
Evidence: packages/stack/src/model/SlimArtifactMirrors.ts:26-31 returns claude, but lines 86 and 129 branch only on codex or cursor. SlimArtifactMirrors.unit.test.ts:43-48 explicitly expects Claude and default ordering to match.
Suggested fix: Remove the unused distinction or document why Claude intentionally retains default ordering instead of claiming every marker reorders candidates.
| const candidates = slimImagePullCandidates(imageName, { | ||
| env: mergedEnv(projectEnvValues), | ||
| ...(overrideValue(override) === undefined | ||
| ? {} | ||
| : { registryOverride: overrideValue(override) }), | ||
| }); |
There was a problem hiding this comment.
⚪ NIT · style · source: claude
Slim registry option construction is duplicated, and overrideValue is evaluated twice in each copy.
Evidence: apps/cli/src/command-internal/docker-registry.ts:67-75 and lines 89-97 contain equivalent slimImagePullCandidates option construction with duplicate overrideValue calls.
Suggested fix: Factor one slim-candidate helper and compute overrideValue once.
| const annotations = | ||
| typeof layer["annotations"] === "object" && layer["annotations"] !== null | ||
| ? (layer["annotations"] as Record<string, string>) | ||
| : {}; | ||
| return [{ mediaType, digest, annotations }]; | ||
| }); | ||
| }; | ||
|
|
||
| const titleOf = (layer: OciLayer): string => | ||
| layer.annotations["org.opencontainers.image.title"] ?? ""; |
There was a problem hiding this comment.
🟡 MINOR · error-handling · source: codex
A malformed OCI title annotation can throw a defect and bypass candidate failover.
Evidence: packages/stack/src/preparation/SlimNativeOci.ts:60-63 casts unvalidated annotation values to strings; titleOf at lines 68-69 can return a number at runtime, and predicates at lines 135-144 call endsWith or includes on it.
Suggested fix: Decode descriptor annotations with a schema or require the title value to be a string before using string methods.
Move payload validation, digest checks, ECR repo creation, and native copies out of inline workflow Python/bash so the mirror can be tested and replayed locally.
Summary
service/version/digest.