From 22031fa686d2a7c96e687bc02c16fdb08bc987d7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 01:32:29 -0400 Subject: [PATCH] feat: resolve verified relay cache entries Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 2 + .../ssh-relay-runtime-workflow.test.mjs | 1 + ...elease-implementation-checklist-summary.md | 20 +- ...github-release-implementation-checklist.md | 141 +++++++++- ...sh-relay-artifact-cache-resolution.test.ts | 248 ++++++++++++++++++ .../ssh-relay-artifact-cache-resolution.ts | 99 +++++++ 6 files changed, 502 insertions(+), 9 deletions(-) create mode 100644 src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts create mode 100644 src/main/ssh/ssh-relay-artifact-cache-resolution.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 3577813a4ec..f88fca55d2c 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -269,6 +269,7 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-in-use-lease.test.ts \ src/main/ssh/ssh-relay-artifact-cache-eviction.test.ts \ src/main/ssh/ssh-relay-artifact-cache-root.test.ts \ + src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts \ src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts \ src/main/ssh/ssh-relay-official-manifest.test.ts \ src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts \ @@ -808,6 +809,7 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-in-use-lease.test.ts ` src/main/ssh/ssh-relay-artifact-cache-eviction.test.ts ` src/main/ssh/ssh-relay-artifact-cache-root.test.ts ` + src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts ` src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts ` src/main/ssh/ssh-relay-official-manifest.test.ts ` src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index ebda96bf749..e8457a3f6fa 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -127,6 +127,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-artifact-cache-in-use-lease.test.ts', 'ssh-relay-artifact-cache-eviction.test.ts', 'ssh-relay-artifact-cache-root.test.ts', + 'ssh-relay-artifact-cache-resolution.test.ts', 'ssh-relay-compiled-manifest-trust.test.ts', 'ssh-relay-official-manifest.test.ts', 'ssh-relay-manifest-accepted-keys.test.ts', diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md index e4c1d48fd94..eab5ec9b61e 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md @@ -9,9 +9,23 @@ keeps commands, hashes, runner identities, timings, and failure details. A checked box means the work has evidence in the detailed ledger. Design approval alone does not complete a box. -Active checkpoint: **Milestone 5 / Work Package 4 desktop resolver/cache — disconnected app-owned -cache-root locally green and wired; commit/all-six native proof next, 2026-07-15, Codex -implementation owner.** `E-M5-ARTIFACT-CACHE-ROOT-LOCAL-001` passes 3/3 purpose tests, the native +Active checkpoint: **Milestone 5 / Work Package 4 desktop resolver/cache — disconnected warm-cache +resolution locally green and wired; commit/all-six native proof next, 2026-07-15, Codex +implementation owner.** `E-M5-ARTIFACT-CACHE-RESOLUTION-LOCAL-001` passes 9/9 purpose tests, +16/16 focused+workflow-oracle tests, 323/323 non-full-size SSH relay tests, 280/280 release-script +tests, typecheck, lint, format, reliability, max-lines, localization, and diff gates. Missing trust +and compatibility legacy results perform no cache I/O; a miss returns only the immutable selected +artifact; a verified hit acquires an in-use lease before exposure; corruption and lease failures +propagate closed. No download, Electron/startup, SSH/Beta mode/tuple/publication/default caller +exists. +`E-M5-ARTIFACT-CACHE-ROOT-CI-001` closes the pure cache-root contract at exact head `aefcaa9a9`: +all six primary native Node 24 jobs, both Linux supplements, Windows x64 baseline, PR Checks, Golden +E2E, and computer-use pass; Windows arm64 build 26200 remains correctly gated against 26100. The +startup-boundary audit found that a late direct `app.getPath('userData')` adapter would be unsafe +because `app.setName()` can change that path. The eventual startup caller must supply Orca's +pre-`app.setName()` canonical path. The next slice may only compose explicit resolver/cache +dependencies; it must add no proxy, SSH/Beta mode/tuple/publication/default behavior. +`E-M5-ARTIFACT-CACHE-ROOT-LOCAL-001` passes 3/3 purpose tests, the native workflow oracle, 314/314 non-full-size SSH relay tests, 280/280 release-script tests, typecheck, lint, format, reliability, max-lines, localization, and diff gates. Both POSIX and PowerShell native artifact commands now run the purpose suite. The pure function accepts only an absolute diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md index a24590f087f..ffbf5746947 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md @@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-15
-Current phase: Milestone 5 / Work Package 4 desktop resolver/cache — **In progress — 2026-07-15, Codex implementation owner: audit the smallest disconnected app-owned relay artifact cache-root contract**. Keep Electron/product consumers, cache orchestration, the build constant, production key/manifest/resources, downloader/SSH/settings/tuples/publication/default behavior absent. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — `E-M5-OFFICIAL-MANIFEST-COMPOSITION-CI-001` closes the immutable-trust/fixed-resource composition at exact head `749f775f15cbd92354fe43e4c142900d4b921614`: all six primary native jobs, both Linux supplements, Windows x64 baseline, PR Checks, Golden E2E, and computer-use pass. Hosted Windows arm64 build 26200 remains correctly rejected against required 26100. Begin only the disconnected cache-root audit/RED; merging to `main` remains prohibited and SignPath is deferred. No Electron resource, downloader, SSH, setting, tuple, publication, or default-path consumer exists. Legacy remains the production default.
+Current phase: Milestone 5 / Work Package 4 desktop resolver/cache — **In progress — 2026-07-15, Codex implementation owner: audit the smallest disconnected resolver/cache composition**. Require the eventual startup caller to supply Orca's pre-`app.setName()` canonical user-data path; keep filesystem orchestration outside import time and production manifest/resources, proxy, SSH/settings/tuples/publication/default behavior absent. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
+Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — `E-M5-ARTIFACT-CACHE-ROOT-CI-001` closes the pure cache-root contract at exact head `aefcaa9a938710e275e3b49ab71446f73b01e075`: all six primary native Node 24 jobs, both Linux supplements, Windows x64 baseline, PR Checks, Golden E2E, and computer-use pass. `E-M5-ARTIFACT-CACHE-STARTUP-BOUNDARY-AUDIT-001` rejects a late direct Electron adapter because `app.setName()` can change `app.getPath('userData')`; the eventual startup caller must pass `getCanonicalUserDataPath()`. Begin only the disconnected resolver/cache composition audit; merging to `main` remains prohibited and SignPath is deferred. No SSH, setting, tuple, publication, or default-path consumer exists. Legacy remains the production default.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693) @@ -142,10 +142,15 @@ same change as the work it records. accounting, and bounded 2 GiB eviction pass locally and on all six native clients under E-M5-ARTIFACT-CACHE-LOCK-CI-001, E-M5-ARTIFACT-CACHE-ENTRY-CI-001, and E-M5-ARTIFACT-CACHE-EVICTION-CI-001. No production consumer exists. -- Active package: **In progress — 2026-07-15, Codex implementation owner** — audit a pure app-owned - relay artifact cache-root derivation from a caller-supplied native user-data path. Keep Electron - `app.getPath`, directory creation, cache lookup/publication/eviction orchestration, environment - overrides, downloader, SSH, mode, tuple, publication, and defaults separately gated. +- Completed package: pure app-owned relay artifact cache-root derivation. An absolute + caller-supplied native user-data path, fixed versioned namespace, environment non-redirection, + local gates, and all-six native Node 24 proof are green under + E-M5-ARTIFACT-CACHE-ROOT-LOCAL-RED-001, E-M5-ARTIFACT-CACHE-ROOT-LOCAL-001, and + E-M5-ARTIFACT-CACHE-ROOT-CI-001. No Electron or product caller exists. +- Active package: **In progress — 2026-07-15, Codex implementation owner** — audit the smallest + disconnected resolver/cache composition over explicit dependencies. The eventual startup caller, + not an SSH module, must supply `getCanonicalUserDataPath()` captured before `app.setName()`. + Keep proxy integration, SSH, mode, tuple, publication, and defaults separately gated. - Completed Work Package 2 gate: target-native Windows source-signature reports from exact-head artifact jobs 87267322867 and 87267322870 were independently downloaded and matched to their identities and signing-stage reports under E-M3-WINDOWS-SOURCE-SIGNATURE-CI-001. PR Checks @@ -14359,6 +14364,130 @@ config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-*.test.m - Remaining proof for this slice: commit the coherent package and require all six primary native Node 24 jobs plus the existing adjacent PR/E2E gates before advancing to another desktop adapter. +### E-M5-ARTIFACT-CACHE-ROOT-CI-001 — all-six native cache-root proof + +- Date/owner: 2026-07-15, Codex implementation owner. +- Exact source/workflow: `aefcaa9a938710e275e3b49ab71446f73b01e075`, artifact run + [29472780636](https://github.com/stablyai/orca/actions/runs/29472780636). The purpose suite passes + within the complete runtime contract step under Node 24.18.0 on all six primary native clients. +- Primary jobs: Linux arm64 87539171165 passes in 3m02s; Linux x64 87539171175 in 2m26s; Windows + x64 87539171177 in 5m53s; macOS arm64 87539171184 in 1m48s; Windows arm64 87539171191 in 10m53s; + and macOS x64 87539171195 in 6m15s. Every job completes deterministic artifact build, exact runtime + smoke, full-size extraction/cache measurement, and unpublished artifact upload after the contract + step. +- Supplements/floors: Linux arm64/x64 supplement jobs 87540022894/87540022920 pass. Windows x64 + baseline job 87540654596 passes. Windows arm64 baseline job 87540654549 verifies and executes the + exact 85,213,511-byte expanded runtime with Node 24.18.0, PTY, watcher, and resource settlement, + then correctly rejects observed build `10.0.26200` against required build 26100 + (`qualified: false`, `osBuild: false`). The tuple remains disabled and this retained floor gate is + the artifact workflow's sole expected failure. +- Adjacent gates: PR Checks run + [29472780621](https://github.com/stablyai/orca/actions/runs/29472780621), job 87539170670, passes in + 14m48s including lint, typecheck, Git compatibility, full tests, unpacked app, and packaged CLI + smoke. Golden E2E run + [29472780625](https://github.com/stablyai/orca/actions/runs/29472780625) passes Linux job + 87539170813 in 4m55s and macOS job 87539170863 in 5m06s. Computer-use run + [29472780628](https://github.com/stablyai/orca/actions/runs/29472780628) passes Windows job + 87539170947 in 4m30s and Ubuntu job 87539170974 in 3m04s. +- Closure: native `node:path` semantics, the fixed versioned namespace, absolute-path rejection, + frozen metadata, and environment non-redirection are proven on Linux, macOS, and Windows x64/ + arm64. No Electron/product caller, filesystem/cache operation, proxy/downloader, SSH, setting, + tuple, publication, or default-path behavior exists; legacy remains unchanged. +- Residual gaps: Electron user-data adaptation, filesystem/cache orchestration, read-only/disk-full/ + quota/inode faults, proxy/certificate/live download, offline transfer, SSH install/launch, native + trust, oldest Windows arm64/macOS/Linux-kernel floors, per-target Beta wiring, and every tuple + remain open. + +### E-M5-ARTIFACT-CACHE-STARTUP-BOUNDARY-AUDIT-001 — canonical user-data path correction + +- Date/owner: 2026-07-15, Codex implementation owner. +- Audit: complete reads of `configureDevUserDataPath`, `initDataPath`, and + `getCanonicalUserDataPath`; startup-order search over `configureDevUserDataPath`, `app.setName`, + and all existing `app.getPath('userData')` consumers; import/caller search for the new cache-root + module. No HTML-plan text prescribes a direct Electron adapter, so the clean primary plan requires + no content correction. +- Finding: Orca deliberately captures canonical user data after dev/E2E `app.setPath` handling but + before `app.setName()`, because a late `app.getPath('userData')` can resolve to a different + directory on case-sensitive filesystems. A new SSH module that calls Electron directly would + therefore risk split cache ownership and bypass the established startup boundary. +- Correction: do not implement the proposed late Electron adapter. Keep + `sshRelayArtifactCacheRoot(userDataPath)` dependency-injected. The eventual startup/product caller + must pass `getCanonicalUserDataPath()` after initialization; tests may pass isolated absolute + paths. No environment variable may replace that path. +- Next package: audit the smallest disconnected resolver/cache composition over the already-proven + official manifest, selector, download, extraction, cache-entry, lease, and eviction capabilities. + It must accept explicit dependencies and add no import-time I/O, Electron/startup caller, proxy, + SSH, setting, tuple, publication, or default behavior. + +### E-M5-ARTIFACT-CACHE-RESOLUTION-AUDIT-001 — warm-cache acquisition seam + +- Date/owner: 2026-07-15, Codex implementation owner. +- Audit: complete reads of official-manifest composition, platform selector, downloader, extractor, + cache entry verification/publication, in-use lease, and eviction modules; export/caller search + confirms every capability remains disconnected outside tests/fixtures. +- Findings: the smallest useful composition is selection plus verified cache lookup plus in-use + lease acquisition. Missing compiled trust and compatibility-selected legacy results must return + before cache I/O. A cache miss must return the immutable selected artifact without downloading; + a verified hit must acquire a lease before exposing a frozen entry snapshot. Relative cache roots, + cancellation, integrity/quarantine errors, and lease failures must propagate without becoming a + miss or legacy result. +- Deliberately separate: download staging, proxy/certificate integration, cold publication, + eviction, availability/fallback classification, canonical startup caller, SSH transfer/install, + `ORCA_RELAY_PATH`, Beta settings, tuples, publication, and defaults. +- Required RED: exact operation order and call counts for unavailable, legacy, miss, and hit; + immutable returned identity; absolute-root and pre-aborted guards; fail-closed lookup-integrity and + lease errors; proof that no downloader, Electron, startup, SSH, or fallback caller is introduced. + +### E-M5-ARTIFACT-CACHE-RESOLUTION-LOCAL-RED-001 — cache resolution composition is absent + +- Date/owner: 2026-07-15, Codex implementation owner. +- Command: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 +src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts`. +- Result: expected FAIL, one failed suite / zero tests in 403 ms because + `./ssh-relay-artifact-cache-resolution` does not exist. Seven purpose cases define unavailable and + compatibility short circuits, immutable miss/hit identity, lease-before-hit exposure, + absolute-root and cancellation guards, and fail-closed integrity/lease propagation. +- Consumer disconnection: only the purpose test imports the absent module. No downloader, Electron, + startup, SSH, setting, fallback, tuple, publication, or default caller exists. + +### E-M5-ARTIFACT-CACHE-RESOLUTION-LOCAL-001 — disconnected warm-cache resolution + +- Date/owner: 2026-07-15, Codex implementation owner. +- Implementation: `ssh-relay-artifact-cache-resolution.ts` composes only the verified official + manifest, compatibility selector, strict cache lookup, and in-use lease. Missing trust and legacy + selections return before cache I/O; cache miss returns the immutable selected artifact; cache hit + returns a frozen verified entry snapshot only after lease acquisition. Integrity/quarantine and + lease errors are not classified or converted here. +- Focused command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts` — PASS, 1 file / 9 tests + in 855 ms Vitest, 2.54 s wall, 131,825,664-byte maximum RSS. Cases cover unavailable/legacy zero- + I/O short circuits, immutable miss, real default cache miss, exact lookup-before-lease hit, frozen + entry identity, absolute root, pre-abort, post-acquisition cancellation release, and fail-closed + integrity/lease errors. +- Native workflow wiring/oracle: the purpose suite is present once in each POSIX and PowerShell + native artifact command. `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts +config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 2 files / 16 tests in 1.68 s Vitest, + 3.58 s wall, 131,989,504-byte maximum RSS. +- Broader SSH relay command: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 --silent=true src/main/ssh/ssh-relay-*.test.ts` — PASS, 27 + files / 323 tests; 3 declared full-size files/tests skipped; 18.04 s Vitest, 19.47 s wall, + 301,465,600-byte maximum RSS. +- Release-script command: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 --silent=true config/scripts/ssh-relay-runtime-*.test.mjs` + — PASS, 50 files / 280 tests in 19.47 s Vitest, 21.25 s wall, 188,203,008-byte maximum RSS. +- Repository gates: targeted `oxfmt --write` completes on six files in 1.895 s; `pnpm typecheck` and + `pnpm lint` pass. Lint includes switch exhaustiveness, 41 reliability gates, no new max-lines + bypass, bundled skill-guide verification, 9,837 localization references and locale parity, and + zero localization coverage candidates. Local Node 26.0.0 emits only the expected engine warning; + exact Node 24 native proof remains pending. +- Boundary: no download staging, proxy/certificate integration, cold publication, eviction, + availability/fallback classification, Electron/startup caller, SSH transfer/install/launch, + `ORCA_RELAY_PATH`, Beta setting, tuple, publication, or default-path change. Legacy remains + unchanged. +- Remaining proof for this slice: commit the coherent package and require all six primary native + Node 24 jobs plus adjacent PR/E2E gates before adding cold download/publication composition. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit diff --git a/src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts b/src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts new file mode 100644 index 00000000000..a045b84c53b --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts @@ -0,0 +1,248 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import nacl from 'tweetnacl' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { SshRelayArtifactCacheIntegrityError } from './ssh-relay-artifact-cache-entry' +import { + resolveSshRelayArtifactCache, + type SshRelayArtifactCacheResolutionOperations +} from './ssh-relay-artifact-cache-resolution' +import type { SshRelayOfficialManifest } from './ssh-relay-official-manifest' +import { + signSshRelayArtifactManifest, + sshRelayManifestKeyId, + verifySshRelayArtifactManifest +} from './ssh-relay-manifest-signature' +import type { SshRelayDigest } from './ssh-relay-runtime-identity' + +const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) +const cacheRoot = join(tmpdir(), 'orca-relay-cache-resolution') +const compatibleHost = { + os: 'linux' as const, + architecture: 'x64' as const, + processTranslated: false, + kernelVersion: '6.8.0', + libc: { family: 'glibc' as const, version: '2.39' }, + libstdcxxVersion: '6.0.33', + glibcxxVersion: '3.4.33' +} + +function officialManifest(): SshRelayOfficialManifest { + const manifest = createSshRelayArtifactTestManifest() + manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)] + return Object.freeze({ + manifest: verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]), + acceptedKeysSha256: `sha256:${'a'.repeat(64)}` as SshRelayDigest + }) +} + +function cacheEntryFor(official: SshRelayOfficialManifest) { + const tuple = official.manifest.tuples[0] + return { + contentId: tuple.contentId, + tupleId: tuple.tupleId, + entryPath: join(cacheRoot, 'entries', tuple.contentId.slice('sha256:'.length)), + archivePath: join(cacheRoot, tuple.archive.name), + runtimeRoot: join(cacheRoot, 'runtime'), + proofPath: join(cacheRoot, 'install-proof.json'), + files: tuple.entries.length, + expandedBytes: tuple.entries.reduce( + (total, value) => total + (value.type === 'file' ? value.size : 0), + 0 + ) + } +} + +const operations = { + lookup: vi.fn(), + acquireInUseLease: vi.fn() +} + +beforeEach(() => { + operations.lookup.mockReset() + operations.acquireInUseLease.mockReset() +}) + +describe('SSH relay artifact cache resolution', () => { + it('returns unavailable before cache I/O when official trust is unprovisioned', async () => { + await expect( + resolveSshRelayArtifactCache( + { officialManifest: null, host: compatibleHost, cacheRoot }, + operations + ) + ).resolves.toEqual({ kind: 'unavailable', reason: 'official-manifest-unavailable' }) + expect(operations.lookup).not.toHaveBeenCalled() + expect(operations.acquireInUseLease).not.toHaveBeenCalled() + }) + + it('returns a compatibility legacy result before cache I/O', async () => { + await expect( + resolveSshRelayArtifactCache( + { + officialManifest: officialManifest(), + host: { ...compatibleHost, processTranslated: true }, + cacheRoot + }, + operations + ) + ).resolves.toEqual({ kind: 'legacy', reason: 'translated-process' }) + expect(operations.lookup).not.toHaveBeenCalled() + expect(operations.acquireInUseLease).not.toHaveBeenCalled() + }) + + it('returns an immutable selected artifact on cache miss without acquiring a lease', async () => { + operations.lookup.mockResolvedValue({ kind: 'miss' }) + + const result = await resolveSshRelayArtifactCache( + { officialManifest: officialManifest(), host: compatibleHost, cacheRoot }, + operations + ) + + expect(result).toMatchObject({ kind: 'cache-miss', artifact: { tupleId: 'linux-x64-glibc' } }) + expect(Object.isFrozen(result)).toBe(true) + expect(operations.lookup).toHaveBeenCalledTimes(1) + expect(operations.lookup.mock.calls[0][0]).toMatchObject({ cacheRoot }) + expect(operations.acquireInUseLease).not.toHaveBeenCalled() + }) + + it('uses the real verified cache lookup on a disconnected miss', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-cache-resolution-real-')) + try { + await expect( + resolveSshRelayArtifactCache({ + officialManifest: officialManifest(), + host: compatibleHost, + cacheRoot: join(root, 'cache') + }) + ).resolves.toMatchObject({ + kind: 'cache-miss', + artifact: { tupleId: 'linux-x64-glibc' } + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('acquires a lease before exposing a frozen verified cache entry', async () => { + const official = officialManifest() + const entry = cacheEntryFor(official) + const lease = { + leasePath: join(cacheRoot, 'in-use', 'lease'), + token: 'b'.repeat(32), + assertOwned: vi.fn(async () => {}), + release: vi.fn(async () => {}) + } + operations.lookup.mockResolvedValue({ kind: 'hit', entry }) + operations.acquireInUseLease.mockResolvedValue(lease) + + const result = await resolveSshRelayArtifactCache( + { officialManifest: official, host: compatibleHost, cacheRoot }, + operations + ) + + expect(result).toMatchObject({ kind: 'cache-hit', entry, lease }) + expect(Object.isFrozen(result)).toBe(true) + expect(result.kind === 'cache-hit' && Object.isFrozen(result.entry)).toBe(true) + expect(operations.acquireInUseLease).toHaveBeenCalledTimes(1) + expect(operations.acquireInUseLease.mock.calls[0][0]).toMatchObject({ cacheRoot, entry }) + expect(operations.lookup.mock.invocationCallOrder[0]).toBeLessThan( + operations.acquireInUseLease.mock.invocationCallOrder[0] + ) + }) + + it('releases an acquired lease when cancellation wins before exposure', async () => { + const official = officialManifest() + const controller = new AbortController() + const release = vi.fn(async () => {}) + operations.lookup.mockResolvedValue({ kind: 'hit', entry: cacheEntryFor(official) }) + operations.acquireInUseLease.mockImplementation(async () => { + controller.abort(new Error('cancel after lease')) + return { + leasePath: join(cacheRoot, 'in-use', 'lease'), + token: 'c'.repeat(32), + assertOwned: vi.fn(async () => {}), + release + } + }) + + await expect( + resolveSshRelayArtifactCache( + { + officialManifest: official, + host: compatibleHost, + cacheRoot, + signal: controller.signal + }, + operations + ) + ).rejects.toThrow(/cancel after lease/i) + expect(release).toHaveBeenCalledTimes(1) + }) + + it('rejects a relative cache root before invoking cache operations', async () => { + await expect( + resolveSshRelayArtifactCache( + { + officialManifest: officialManifest(), + host: compatibleHost, + cacheRoot: 'relative/cache' + }, + operations + ) + ).rejects.toThrow(/absolute|cache root/i) + expect(operations.lookup).not.toHaveBeenCalled() + }) + + it('settles pre-aborted requests without invoking cache operations', async () => { + const controller = new AbortController() + controller.abort(new Error('cancel cache resolution')) + + await expect( + resolveSshRelayArtifactCache( + { + officialManifest: officialManifest(), + host: compatibleHost, + cacheRoot, + signal: controller.signal + }, + operations + ) + ).rejects.toThrow(/cancel cache resolution/i) + expect(operations.lookup).not.toHaveBeenCalled() + }) + + it('propagates cache integrity and lease failures without returning miss or legacy', async () => { + operations.lookup.mockRejectedValueOnce( + new SshRelayArtifactCacheIntegrityError( + 'corrupt cache entry', + join(cacheRoot, 'quarantine', 'corrupt'), + new Error('tree mismatch') + ) + ) + await expect( + resolveSshRelayArtifactCache( + { officialManifest: officialManifest(), host: compatibleHost, cacheRoot }, + operations + ) + ).rejects.toBeInstanceOf(SshRelayArtifactCacheIntegrityError) + + const official = officialManifest() + operations.lookup.mockResolvedValueOnce({ + kind: 'hit', + entry: cacheEntryFor(official) + }) + operations.acquireInUseLease.mockRejectedValueOnce(new Error('lease ownership displaced')) + await expect( + resolveSshRelayArtifactCache( + { officialManifest: official, host: compatibleHost, cacheRoot }, + operations + ) + ).rejects.toThrow(/lease ownership displaced/i) + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-cache-resolution.ts b/src/main/ssh/ssh-relay-artifact-cache-resolution.ts new file mode 100644 index 00000000000..f78296c5b75 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-cache-resolution.ts @@ -0,0 +1,99 @@ +import { isAbsolute } from 'node:path' + +import { + lookupSshRelayArtifactCacheEntry, + type SshRelayArtifactCacheLookup +} from './ssh-relay-artifact-cache-entry' +import type { SshRelayArtifactCacheEntry } from './ssh-relay-artifact-cache-entry-verification' +import { + acquireSshRelayArtifactCacheInUseLease, + type SshRelayArtifactCacheInUseLease +} from './ssh-relay-artifact-cache-in-use-lease' +import { + selectSshRelayArtifact, + type SshRelayArtifactLegacyReason, + type SshRelayHostEvidence, + type SshRelaySelectedArtifact +} from './ssh-relay-artifact-selector' +import type { SshRelayOfficialManifest } from './ssh-relay-official-manifest' + +export type SshRelayArtifactCacheResolutionOperations = Readonly<{ + lookup: typeof lookupSshRelayArtifactCacheEntry + acquireInUseLease: typeof acquireSshRelayArtifactCacheInUseLease +}> + +const DEFAULT_OPERATIONS: SshRelayArtifactCacheResolutionOperations = Object.freeze({ + lookup: lookupSshRelayArtifactCacheEntry, + acquireInUseLease: acquireSshRelayArtifactCacheInUseLease +}) + +export type SshRelayArtifactCacheResolution = + | Readonly<{ kind: 'unavailable'; reason: 'official-manifest-unavailable' }> + | Readonly<{ kind: 'legacy'; reason: SshRelayArtifactLegacyReason }> + | Readonly<{ kind: 'cache-miss'; artifact: SshRelaySelectedArtifact }> + | Readonly<{ + kind: 'cache-hit' + artifact: SshRelaySelectedArtifact + entry: Readonly + lease: SshRelayArtifactCacheInUseLease + }> + +function frozenUnavailable(): SshRelayArtifactCacheResolution { + return Object.freeze({ kind: 'unavailable', reason: 'official-manifest-unavailable' }) +} + +function frozenLegacy(reason: SshRelayArtifactLegacyReason): SshRelayArtifactCacheResolution { + return Object.freeze({ kind: 'legacy', reason }) +} + +function frozenEntry( + lookup: Extract +): Readonly { + return Object.freeze({ ...lookup.entry }) +} + +export async function resolveSshRelayArtifactCache( + { + officialManifest, + host, + cacheRoot, + signal + }: { + officialManifest: SshRelayOfficialManifest | null + host: SshRelayHostEvidence + cacheRoot: string + signal?: AbortSignal + }, + operations: SshRelayArtifactCacheResolutionOperations = DEFAULT_OPERATIONS +): Promise { + signal?.throwIfAborted() + if (officialManifest === null) { + return frozenUnavailable() + } + + const selection = selectSshRelayArtifact(officialManifest.manifest, host) + if (selection.kind === 'legacy') { + return frozenLegacy(selection.reason) + } + if (typeof cacheRoot !== 'string' || !isAbsolute(cacheRoot)) { + throw new Error('SSH relay artifact cache resolution root must be absolute') + } + + signal?.throwIfAborted() + // Why: integrity/quarantine errors intentionally escape this boundary and can never become misses. + const lookup = await operations.lookup({ cacheRoot, artifact: selection, signal }) + signal?.throwIfAborted() + if (lookup.kind === 'miss') { + return Object.freeze({ kind: 'cache-miss', artifact: selection }) + } + + const entry = frozenEntry(lookup) + const lease = await operations.acquireInUseLease({ cacheRoot, entry, signal }) + try { + signal?.throwIfAborted() + return Object.freeze({ kind: 'cache-hit', artifact: selection, entry, lease }) + } catch (error) { + await lease.release().catch(() => {}) + throw error + } +}