From c170ff92c1c5c82c31048cf9a9245c13efdaddd1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 02:58:45 -0400 Subject: [PATCH] feat: acquire verified relay artifacts Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 4 + .../ssh-relay-runtime-workflow.test.mjs | 2 + ...elease-implementation-checklist-summary.md | 24 +- ...github-release-implementation-checklist.md | 108 +++++++- ...y-artifact-acquisition-integration.test.ts | 74 +++++ .../ssh-relay-artifact-acquisition.test.ts | 254 ++++++++++++++++++ .../ssh/ssh-relay-artifact-acquisition.ts | 151 +++++++++++ .../ssh-relay-artifact-cache-entry-fixture.ts | 14 +- 8 files changed, 620 insertions(+), 11 deletions(-) create mode 100644 src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts create mode 100644 src/main/ssh/ssh-relay-artifact-acquisition.test.ts create mode 100644 src/main/ssh/ssh-relay-artifact-acquisition.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 42549c3eca4..c2cbd60d792 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -273,6 +273,8 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts \ src/main/ssh/ssh-relay-artifact-cache-population.test.ts \ src/main/ssh/ssh-relay-artifact-cache-population-integration.test.ts \ + src/main/ssh/ssh-relay-artifact-acquisition.test.ts \ + src/main/ssh/ssh-relay-artifact-acquisition-integration.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 \ @@ -816,6 +818,8 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-resolution.test.ts ` src/main/ssh/ssh-relay-artifact-cache-population.test.ts ` src/main/ssh/ssh-relay-artifact-cache-population-integration.test.ts ` + src/main/ssh/ssh-relay-artifact-acquisition.test.ts ` + src/main/ssh/ssh-relay-artifact-acquisition-integration.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 b76eb938a0e..816923aee75 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -131,6 +131,8 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-artifact-cache-resolution.test.ts', 'ssh-relay-artifact-cache-population.test.ts', 'ssh-relay-artifact-cache-population-integration.test.ts', + 'ssh-relay-artifact-acquisition.test.ts', + 'ssh-relay-artifact-acquisition-integration.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 da603a87bf3..b0e26f06779 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,9 @@ 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 — audit the next smallest -disconnected acquisition composition after exact-head acceptance of cold-cache population, -2026-07-15, Codex implementation owner.** +Active checkpoint: **Milestone 5 / Work Package 4 desktop resolver/cache — checkpoint and prove the +locally green disconnected warm/cold acquisition composition on exact-head native CI, 2026-07-15, +Codex implementation owner.** `E-M5-ARTIFACT-CACHE-RESOLUTION-CI-001` closes warm-cache commit `22031fa68` on all six native clients, both Linux supplements, Windows x64 baseline, PR Checks, Golden E2E, and computer-use; Windows arm64 remains the expected build-26200 rejection against 26100. `E-M5-ARTIFACT-CACHE-RESOLUTION-LOCAL-001` passes 9/9 purpose tests, @@ -64,6 +64,24 @@ repeated Windows x64 lock-release race is closed. The next package is an audit o disconnected acquisition composition only. Electron/startup, live proxy/network, SSH, settings, fallback, tuple enablement, release publication, and default behavior remain absent, and legacy remains the sole production path. +`E-M5-ARTIFACT-CACHE-ACQUISITION-AUDIT-001` fixes the next narrow boundary: explicit verified +manifest/host/cache-root inputs; unavailable/compatibility short circuits; a source-qualified leased +ready result from a warm hit without download; and exactly one accepted cold-population call on a +verified miss. Final identity and cancellation are checked before exposure, with lease release on +failure. A real Linux/Windows cold→warm client-offline integration must fetch exactly once. Manifest +loading, Electron/startup, proxy policy, SSH, settings, fallback classification, tuple enablement, +publication, and defaults remain absent. +`E-M5-ARTIFACT-ACQUISITION-LOCAL-RED-001` records the expected two-suite missing-module failure for +nine purpose cases plus real Linux/Windows cold→warm client-offline integration before +implementation. +`E-M5-ARTIFACT-ACQUISITION-LOCAL-001` is green: 11/11 purpose/integration and 18/18 focused+ +workflow-oracle tests pass; 350/350 non-full-size relay and 280/280 release-script tests plus +typecheck, lint, format, diff, and protected-resolver gates pass. Real Linux tar/Brotli and Windows +ZIP paths perform exactly one verified cold fetch, release the lease, then acquire warm while the +next client fetch is configured offline. Ready results are source-qualified and leased; identity, +cancellation, and all lower-layer errors fail closed without classification. The package remains +disconnected from manifest loading, Electron/startup, proxy policy, SSH, settings, fallback, tuple +enablement, publication, and defaults. Exact-head all-six native proof is next. `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 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 f8ff6434356..b08e4f468e3 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 next smallest disconnected acquisition composition after exact-head acceptance of cold-cache population**. Require the eventual startup caller to supply Orca's pre-`app.setName()` canonical user-data path; keep production manifest/resources, proxy, SSH/settings/tuples/release publication/default behavior absent. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **CI CLOSED / NEXT AUDIT — 2026-07-15, Codex implementation owner** — `E-M5-ARTIFACT-CACHE-LOCK-RELEASE-CI-001` closes correction commit `bd240049ffb9de196ce7cbd0f772639f4ba310f3` and accepts the cold-cache population package: all six primary native Node 24 jobs, both Linux oldest-userland supplements, Windows x64 baseline, PR Checks, Golden E2E, and computer-use pass. Windows arm64 retains only the declared hosted build-26200 rejection against required 26100. The next package may audit a disconnected acquisition boundary only; do not add Electron/startup, SSH, setting, fallback, tuple enablement, release publication, or default-path behavior. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Current phase: Milestone 5 / Work Package 4 desktop resolver/cache — **In progress — 2026-07-15, Codex implementation owner: checkpoint and prove the locally green disconnected warm/cold acquisition composition on exact-head native CI**. Require the eventual startup caller to supply Orca's pre-`app.setName()` canonical user-data path; keep production manifest/resources, proxy, SSH/settings/tuples/release publication/default behavior absent. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
+Session checkpoint: **LOCAL GREEN / CI REQUIRED — 2026-07-15, Codex implementation owner** — `E-M5-ARTIFACT-ACQUISITION-LOCAL-RED-001` precedes `E-M5-ARTIFACT-ACQUISITION-LOCAL-001`: 11/11 purpose/integration, 18/18 focused+workflow-oracle, 350/350 non-full-size relay, 280/280 release-script, typecheck, lint, format, diff, and protected-resolver gates pass. Real Linux tar/Brotli and Windows ZIP cases prove one cold verified fetch followed by warm acquisition while the client fetch is offline. No Electron/startup state, fallback classification, SSH/settings, tuple enablement, release publication, or default behavior is connected. Exact-head all-six native proof is required before the next package. Merging to `main` remains prohibited, SignPath is deferred, and 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) @@ -147,11 +147,11 @@ same change as the work it records. 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 next - smallest disconnected acquisition composition now that the cache-lock correction and cold-cache - population are exact-head accepted. The eventual startup caller, not an SSH module, must supply - `getCanonicalUserDataPath()` captured before `app.setName()`; keep proxy integration, SSH, mode, - tuple, publication, fallback, and defaults separately gated. +- Active package: **In progress — 2026-07-15, Codex implementation owner** — checkpoint and prove + the locally green disconnected warm/cold acquisition composition on all six native clients. The + eventual startup caller, not an SSH module, must supply `getCanonicalUserDataPath()` captured + before `app.setName()`; keep proxy integration, SSH, mode, tuple, publication, fallback, 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 @@ -14736,6 +14736,100 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 4 files / 21 tests enablement, release publication, or default behavior. Legacy remains the only production path; the next package may only audit the next disconnected acquisition composition. +### E-M5-ARTIFACT-CACHE-ACQUISITION-AUDIT-001 — warm/cold leased acquisition boundary + +- Date/owner: 2026-07-15, Codex implementation owner. +- Audit: complete reads of the accepted warm resolution and cold population implementations/tests, + official-manifest and cache-root boundaries, selected-artifact identity, real Linux/Windows cache + fixtures, and the native workflow oracle. A production-caller search confirms both accepted + capabilities remain disconnected outside tests. +- Finding: the smallest next composition consumes an explicit already-verified official manifest, + probed host evidence, absolute cache root, and optional cancellation signal. It returns + unavailable/compatibility-legacy unchanged, maps a verified warm hit to a source-qualified leased + ready result without download, and invokes the accepted cold population exactly once only for a + verified miss. A successful cold result is mapped to the same leased ready shape with `download` + source. No cache miss escapes this boundary. +- Safety contract: final artifact/entry tuple and content identities must match; every returned entry + snapshot/result is frozen; cancellation or identity failure after a warm/cold lease is acquired + releases that lease before rejecting. Resolution, download, integrity, publication, lease, and + cleanup failures propagate unchanged without availability/fallback classification. Concurrent + cold misses retain the accepted unique staging and content-lock publication safety; download + deduplication is deliberately not invented in this composition. +- Required RED: unavailable and compatibility short circuits, warm zero-download result, exact + miss→population order/call identity, warm/cold post-lease cancellation release, inconsistent + population identity failure/release, and fail-closed resolution/population errors. A real + Linux/Windows integration must prove cold download followed by warm client-offline acquisition + with exactly one client fetch. +- Deliberately absent: official-manifest loading, canonical user-data/Electron/startup adaptation, + proxy/certificate policy, eviction scheduling, SSH transfer/install/launch, `ORCA_RELAY_PATH`, + per-target Beta settings, fallback state machine/reason classification, tuple enablement, release + publication, production resources, and default behavior. The primary HTML plan is unchanged. + +### E-M5-ARTIFACT-ACQUISITION-LOCAL-RED-001 — warm/cold 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-acquisition.test.ts + src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts`. +- Result: expected FAIL, two failed suites / zero tests in 616 ms because the purpose-named + `ssh-relay-artifact-acquisition` module does not exist. +- Fixed contract: nine purpose cases define unavailable/legacy zero-population short circuits, + frozen cache/download ready results, exact resolve→populate miss ordering, pre-abort, warm/cold + post-lease cancellation release, inconsistent-entry rejection/release, and unclassified error + propagation. Two real Linux tar/Brotli and Windows ZIP cases require a cold verified fetch followed + by a warm acquisition while the client fetch is configured offline, with exactly one fetch total. +- Consumer disconnection: only the two purpose/integration suites import the absent module. No + Electron/startup, product/SSH, setting, fallback, tuple, publication, or default caller exists. + +### E-M5-ARTIFACT-ACQUISITION-LOCAL-001 — disconnected warm/cold client acquisition + +- Date/owner: 2026-07-15, Codex implementation owner. +- Implementation: `ssh-relay-artifact-acquisition.ts` composes only accepted cache resolution and + cold population behind explicit verified-manifest, host-evidence, cache-root, and cancellation + inputs. Unavailable/compatibility results return frozen without population; a warm hit returns a + frozen `ready/cache` leased entry; only a verified miss invokes population and returns the same + shape as `ready/download`. Artifact release/content/archive and entry tuple/content identities are + checked before exposure. Cancellation or identity failure after lease acquisition releases the + lease; every lower-layer error propagates without fallback classification. +- Purpose/integration command: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 + src/main/ssh/ssh-relay-artifact-acquisition.test.ts + src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts` — PASS, 2 files / 11 tests in + 1.66 s. Nine purpose cases cover both short circuits, warm/download result shape and operation + order, pre-abort, warm/cold post-lease cancellation, identity failure/release, and unclassified + resolver/population errors. Two real Linux tar/Brotli and Windows ZIP cases pass exact signed + fixture bytes through the default selector, downloader, strict publication/tree proof, lease, and + warm lookup: cold fetch count is one, the lease is released, and a second acquisition succeeds + from cache while the next client fetch is configured to fail; total fetch count remains one. +- Focused plus native workflow oracle: `/usr/bin/time -l pnpm exec vitest run --config + config/vitest.config.ts --maxWorkers=1 + src/main/ssh/ssh-relay-artifact-acquisition.test.ts + src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts + config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 3 files / 18 tests in 2.90 s Vitest, + 5.50 s wall, 132,792,320-byte maximum RSS. Both purpose suites occur exactly once in POSIX and + PowerShell native commands. +- Broader 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, + 32 files / 350 tests; 3 declared full-size files/tests skipped; accepted rerun 28.77 s Vitest, + 30.56 s wall, 243,253,248-byte maximum RSS. An earlier completed invocation returned only partial + captured output from the command runner and is not counted; the exact command was rerun to an + observed exit code 0 and complete summary. +- 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 22.48 s Vitest, + 27.42 s wall, 195,969,024-byte maximum RSS. +- Repository gates: targeted `pnpm exec oxfmt --write` completes on six implementation/workflow + files in 472 ms. `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. `git diff --check` + passes and the protected `ssh-remote-node-resolution.ts`/test pair has zero package diff. Local + Node 26.0.0 emits only the expected Node-24 engine warning; exact Node 24 proof remains pending. +- Boundary/gate: the fixture exposes its already-verified manifest and exact host evidence only to + support real acquisition tests. There is still no official-manifest loader, Electron/startup or + product caller, proxy policy/live credential path, eviction scheduler, SSH transfer/install, + `ORCA_RELAY_PATH`, Beta setting, fallback classification, tuple enablement, release publication, + production resource, or default-path change. Legacy remains unchanged. Require exact-head all-six + native and adjacent PR/E2E proof before advancing. + ### E-M5-ARTIFACT-CACHE-POPULATION-LOCAL-001 — disconnected cold-cache population - Date/owner: 2026-07-15, Codex implementation owner. diff --git a/src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts b/src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts new file mode 100644 index 00000000000..b486f09c7e8 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-acquisition-integration.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { acquireSshRelayArtifact } from './ssh-relay-artifact-acquisition' +import { createSshRelayArtifactCacheEntryFixture } from './ssh-relay-artifact-cache-entry-fixture' + +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + netFetchMock.mockReset() + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('SSH relay artifact warm/cold acquisition integration', () => { + it.each(['linux', 'win32'] as const)( + 'downloads the %s fixture once and then acquires it while the client is offline', + async (os) => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-artifact-acquisition-integration-')) + temporaryDirectories.push(root) + const fixture = await createSshRelayArtifactCacheEntryFixture({ root, os }) + const archive = await readFile(fixture.archivePath) + netFetchMock.mockResolvedValueOnce( + new Response(archive, { + status: 200, + headers: { 'content-length': String(archive.length) } + }) + ) + const input = { + officialManifest: fixture.officialManifest, + host: fixture.host, + cacheRoot: join(root, 'cache') + } + + const cold = await acquireSshRelayArtifact(input) + if (cold.kind !== 'ready') { + throw new Error(`Expected ready cold acquisition, got ${cold.kind}`) + } + expect(cold).toMatchObject({ + source: 'download', + artifact: { contentId: fixture.artifact.contentId }, + entry: { contentId: fixture.artifact.contentId } + }) + await cold.lease.release() + + netFetchMock.mockRejectedValueOnce(new Error('client is offline')) + const warm = await acquireSshRelayArtifact(input) + if (warm.kind !== 'ready') { + throw new Error(`Expected ready warm acquisition, got ${warm.kind}`) + } + try { + expect(warm).toMatchObject({ + source: 'cache', + artifact: { contentId: fixture.artifact.contentId }, + entry: { contentId: fixture.artifact.contentId } + }) + await expect(warm.lease.assertOwned()).resolves.toBeUndefined() + } finally { + await warm.lease.release() + } + expect(netFetchMock).toHaveBeenCalledTimes(1) + } + ) +}) diff --git a/src/main/ssh/ssh-relay-artifact-acquisition.test.ts b/src/main/ssh/ssh-relay-artifact-acquisition.test.ts new file mode 100644 index 00000000000..ca6615777c7 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-acquisition.test.ts @@ -0,0 +1,254 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import nacl from 'tweetnacl' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + acquireSshRelayArtifact, + type SshRelayArtifactAcquisitionOperations +} from './ssh-relay-artifact-acquisition' +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import type { SshRelayArtifactCacheEntry } from './ssh-relay-artifact-cache-entry-verification' +import type { SshRelayArtifactCacheInUseLease } from './ssh-relay-artifact-cache-in-use-lease' +import { + selectSshRelayArtifact, + type SshRelaySelectedArtifact +} from './ssh-relay-artifact-selector' +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-artifact-acquisition') +const host = { + 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 artifact(official: SshRelayOfficialManifest): SshRelaySelectedArtifact { + const selected = selectSshRelayArtifact(official.manifest, host) + if (selected.kind !== 'selected') { + throw new Error(`Expected selected acquisition fixture, got ${selected.reason}`) + } + return selected +} + +function entryFor(selected: ReturnType): SshRelayArtifactCacheEntry { + const entryPath = join(cacheRoot, 'entries', selected.contentId.slice('sha256:'.length)) + return { + contentId: selected.contentId, + tupleId: selected.tupleId, + entryPath, + archivePath: join(entryPath, selected.archive.name), + runtimeRoot: join(entryPath, 'runtime'), + proofPath: join(entryPath, 'proof.json'), + files: selected.archive.fileCount, + expandedBytes: selected.archive.expandedSize + } +} + +function lease(token = 'b'.repeat(32)): SshRelayArtifactCacheInUseLease { + return { + leasePath: join(cacheRoot, 'in-use', token), + token, + assertOwned: vi.fn(async () => {}), + release: vi.fn(async () => {}) + } +} + +const operations = { + resolve: vi.fn(), + populate: vi.fn() +} + +beforeEach(() => { + operations.resolve.mockReset() + operations.populate.mockReset() +}) + +describe('SSH relay artifact warm/cold acquisition', () => { + it('returns unavailable without attempting cold population', async () => { + operations.resolve.mockResolvedValue({ + kind: 'unavailable', + reason: 'official-manifest-unavailable' + }) + + const result = await acquireSshRelayArtifact( + { officialManifest: null, host, cacheRoot }, + operations + ) + + expect(result).toEqual({ kind: 'unavailable', reason: 'official-manifest-unavailable' }) + expect(Object.isFrozen(result)).toBe(true) + expect(operations.populate).not.toHaveBeenCalled() + }) + + it('returns compatibility legacy without attempting cold population', async () => { + operations.resolve.mockResolvedValue({ kind: 'legacy', reason: 'kernel-too-old' }) + + await expect( + acquireSshRelayArtifact({ officialManifest: officialManifest(), host, cacheRoot }, operations) + ).resolves.toEqual({ kind: 'legacy', reason: 'kernel-too-old' }) + expect(operations.populate).not.toHaveBeenCalled() + }) + + it('maps a warm hit to a frozen cache-sourced leased result without download', async () => { + const official = officialManifest() + const selected = artifact(official) + const entry = entryFor(selected) + const acquired = lease() + operations.resolve.mockResolvedValue({ + kind: 'cache-hit', + artifact: selected, + entry, + lease: acquired + }) + + const result = await acquireSshRelayArtifact( + { officialManifest: official, host, cacheRoot }, + operations + ) + + expect(result).toEqual({ + kind: 'ready', + source: 'cache', + artifact: selected, + entry, + lease: acquired + }) + expect(Object.isFrozen(result)).toBe(true) + expect(result.kind === 'ready' && Object.isFrozen(result.entry)).toBe(true) + expect(operations.populate).not.toHaveBeenCalled() + }) + + it('populates exactly once after a verified miss and returns a download-sourced lease', async () => { + const official = officialManifest() + const selected = artifact(official) + const entry = entryFor(selected) + const acquired = lease('c'.repeat(32)) + operations.resolve.mockResolvedValue({ kind: 'cache-miss', artifact: selected }) + operations.populate.mockResolvedValue({ artifact: selected, entry, lease: acquired }) + + const result = await acquireSshRelayArtifact( + { officialManifest: official, host, cacheRoot }, + operations + ) + + expect(result).toMatchObject({ kind: 'ready', source: 'download', artifact: selected, entry }) + expect(operations.resolve).toHaveBeenCalledTimes(1) + expect(operations.populate).toHaveBeenCalledTimes(1) + expect(operations.populate).toHaveBeenCalledWith({ + cacheRoot, + artifact: selected, + signal: undefined + }) + expect(operations.resolve.mock.invocationCallOrder[0]).toBeLessThan( + operations.populate.mock.invocationCallOrder[0] + ) + }) + + it('settles a pre-aborted request before resolution', async () => { + const controller = new AbortController() + controller.abort(new Error('cancel artifact acquisition')) + + await expect( + acquireSshRelayArtifact( + { + officialManifest: officialManifest(), + host, + cacheRoot, + signal: controller.signal + }, + operations + ) + ).rejects.toThrow(/cancel artifact acquisition/i) + expect(operations.resolve).not.toHaveBeenCalled() + }) + + it.each(['cache', 'download'] as const)( + 'releases a %s lease when cancellation wins before exposure', + async (source) => { + const official = officialManifest() + const selected = artifact(official) + const entry = entryFor(selected) + const acquired = lease(source === 'cache' ? 'd'.repeat(32) : 'e'.repeat(32)) + const controller = new AbortController() + if (source === 'cache') { + operations.resolve.mockImplementation(async () => { + controller.abort(new Error('cancel after warm lease')) + return { kind: 'cache-hit', artifact: selected, entry, lease: acquired } + }) + } else { + operations.resolve.mockResolvedValue({ kind: 'cache-miss', artifact: selected }) + operations.populate.mockImplementation(async () => { + controller.abort(new Error('cancel after cold lease')) + return { artifact: selected, entry, lease: acquired } + }) + } + + await expect( + acquireSshRelayArtifact( + { officialManifest: official, host, cacheRoot, signal: controller.signal }, + operations + ) + ).rejects.toThrow(source === 'cache' ? /warm lease/i : /cold lease/i) + expect(acquired.release).toHaveBeenCalledTimes(1) + } + ) + + it('rejects inconsistent cold identity and releases the acquired lease', async () => { + const official = officialManifest() + const selected = artifact(official) + const entry = entryFor(selected) + const acquired = lease('f'.repeat(32)) + operations.resolve.mockResolvedValue({ kind: 'cache-miss', artifact: selected }) + operations.populate.mockResolvedValue({ + artifact: selected, + entry: { ...entry, tupleId: 'win32-x64' }, + lease: acquired + }) + + await expect( + acquireSshRelayArtifact({ officialManifest: official, host, cacheRoot }, operations) + ).rejects.toThrow(/identity|tuple|content/i) + expect(acquired.release).toHaveBeenCalledTimes(1) + }) + + it('propagates warm resolution and cold population failures without classification', async () => { + operations.resolve.mockRejectedValueOnce(new Error('cache integrity failure')) + await expect( + acquireSshRelayArtifact({ officialManifest: officialManifest(), host, cacheRoot }, operations) + ).rejects.toThrow(/cache integrity failure/i) + + const official = officialManifest() + operations.resolve.mockResolvedValueOnce({ + kind: 'cache-miss', + artifact: artifact(official) + }) + operations.populate.mockRejectedValueOnce(new Error('certificate verification failed')) + await expect( + acquireSshRelayArtifact({ officialManifest: official, host, cacheRoot }, operations) + ).rejects.toThrow(/certificate verification failed/i) + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-acquisition.ts b/src/main/ssh/ssh-relay-artifact-acquisition.ts new file mode 100644 index 00000000000..4208c53a437 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-acquisition.ts @@ -0,0 +1,151 @@ +import type { SshRelayArtifactCacheEntry } from './ssh-relay-artifact-cache-entry-verification' +import type { SshRelayArtifactCacheInUseLease } from './ssh-relay-artifact-cache-in-use-lease' +import { + populateSshRelayArtifactCache, + type SshRelayArtifactCachePopulation +} from './ssh-relay-artifact-cache-population' +import { resolveSshRelayArtifactCache } from './ssh-relay-artifact-cache-resolution' +import type { + SshRelayArtifactLegacyReason, + SshRelayHostEvidence, + SshRelaySelectedArtifact +} from './ssh-relay-artifact-selector' +import type { SshRelayOfficialManifest } from './ssh-relay-official-manifest' + +export type SshRelayArtifactAcquisitionOperations = Readonly<{ + resolve: typeof resolveSshRelayArtifactCache + populate: typeof populateSshRelayArtifactCache +}> + +const DEFAULT_OPERATIONS: SshRelayArtifactAcquisitionOperations = Object.freeze({ + resolve: resolveSshRelayArtifactCache, + populate: populateSshRelayArtifactCache +}) + +export type SshRelayArtifactAcquisition = + | Readonly<{ kind: 'unavailable'; reason: 'official-manifest-unavailable' }> + | Readonly<{ kind: 'legacy'; reason: SshRelayArtifactLegacyReason }> + | Readonly<{ + kind: 'ready' + source: 'cache' | 'download' + artifact: SshRelaySelectedArtifact + entry: Readonly + lease: SshRelayArtifactCacheInUseLease + }> + +function sameArtifactIdentity( + expected: SshRelaySelectedArtifact, + actual: SshRelaySelectedArtifact +): boolean { + return ( + actual.tupleId === expected.tupleId && + actual.contentId === expected.contentId && + actual.releaseTag === expected.releaseTag && + actual.archive.name === expected.archive.name && + actual.archive.sha256 === expected.archive.sha256 + ) +} + +function sameEntryIdentity( + artifact: SshRelaySelectedArtifact, + entry: SshRelayArtifactCacheEntry +): boolean { + return entry.tupleId === artifact.tupleId && entry.contentId === artifact.contentId +} + +async function readyResult({ + source, + expectedArtifact, + actualArtifact, + entry, + lease, + signal +}: { + source: 'cache' | 'download' + expectedArtifact: SshRelaySelectedArtifact + actualArtifact: SshRelaySelectedArtifact + entry: SshRelayArtifactCacheEntry + lease: SshRelayArtifactCacheInUseLease + signal?: AbortSignal +}): Promise { + try { + signal?.throwIfAborted() + if ( + !sameArtifactIdentity(expectedArtifact, actualArtifact) || + !sameEntryIdentity(expectedArtifact, entry) + ) { + throw new Error('SSH relay artifact acquisition identity is inconsistent') + } + return Object.freeze({ + kind: 'ready', + source, + artifact: expectedArtifact, + entry: Object.freeze({ ...entry }), + lease + }) + } catch (error) { + await lease.release().catch(() => {}) + throw error + } +} + +function unavailableResult(): SshRelayArtifactAcquisition { + return Object.freeze({ kind: 'unavailable', reason: 'official-manifest-unavailable' }) +} + +function legacyResult(reason: SshRelayArtifactLegacyReason): SshRelayArtifactAcquisition { + return Object.freeze({ kind: 'legacy', reason }) +} + +export async function acquireSshRelayArtifact( + { + officialManifest, + host, + cacheRoot, + signal + }: { + officialManifest: SshRelayOfficialManifest | null + host: SshRelayHostEvidence + cacheRoot: string + signal?: AbortSignal + }, + operations: SshRelayArtifactAcquisitionOperations = DEFAULT_OPERATIONS +): Promise { + signal?.throwIfAborted() + const resolution = await operations.resolve({ officialManifest, host, cacheRoot, signal }) + + if (resolution.kind === 'unavailable') { + signal?.throwIfAborted() + return unavailableResult() + } + if (resolution.kind === 'legacy') { + signal?.throwIfAborted() + return legacyResult(resolution.reason) + } + if (resolution.kind === 'cache-hit') { + return readyResult({ + source: 'cache', + expectedArtifact: resolution.artifact, + actualArtifact: resolution.artifact, + entry: resolution.entry, + lease: resolution.lease, + signal + }) + } + + signal?.throwIfAborted() + // Why: availability and integrity classification belongs to the later fallback state machine. + const populated: SshRelayArtifactCachePopulation = await operations.populate({ + cacheRoot, + artifact: resolution.artifact, + signal + }) + return readyResult({ + source: 'download', + expectedArtifact: resolution.artifact, + actualArtifact: populated.artifact, + entry: populated.entry, + lease: populated.lease, + signal + }) +} diff --git a/src/main/ssh/ssh-relay-artifact-cache-entry-fixture.ts b/src/main/ssh/ssh-relay-artifact-cache-entry-fixture.ts index de2bd48b420..8a49083ab45 100644 --- a/src/main/ssh/ssh-relay-artifact-cache-entry-fixture.ts +++ b/src/main/ssh/ssh-relay-artifact-cache-entry-fixture.ts @@ -16,6 +16,7 @@ import { type SshRelayHostEvidence, type SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' +import type { SshRelayOfficialManifest } from './ssh-relay-official-manifest' import { signSshRelayArtifactManifest, sshRelayManifestKeyId, @@ -103,6 +104,8 @@ export type SshRelayArtifactCacheEntryFixture = { archivePath: string artifact: SshRelaySelectedArtifact fileBytes: ReadonlyMap + host: SshRelayHostEvidence + officialManifest: SshRelayOfficialManifest } export async function createSshRelayArtifactCacheEntryFixture({ @@ -172,5 +175,14 @@ export async function createSshRelayArtifactCacheEntryFixture({ } const archivePath = join(root, tuple.archive.name) await writeFile(archivePath, archive, { mode: 0o600 }) - return { archivePath, artifact, fileBytes } + return { + archivePath, + artifact, + fileBytes, + host, + officialManifest: Object.freeze({ + manifest: verified, + acceptedKeysSha256: sha256(keyPair.publicKey) + }) + } }