diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 3cf63d7a535..f8a144310e6 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -282,6 +282,7 @@ jobs: 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-runtime-source-tree.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 \ @@ -834,6 +835,7 @@ jobs: 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-runtime-source-tree.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 81348800f38..444ee12cc19 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -140,6 +140,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-artifact-cache-population-integration.test.ts', 'ssh-relay-artifact-acquisition.test.ts', 'ssh-relay-artifact-acquisition-integration.test.ts', + 'ssh-relay-runtime-source-tree.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 62aeccc7d99..d4dd156931f 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,26 @@ 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 6 / Work Package 5 bounded runtime transfer — host-evidence -composition CI is closed; audit only a disconnected transport-neutral verified source-tree contract -next, 2026-07-15, Codex implementation owner.** +Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected +transport-neutral source-tree contract is locally green; exact-head native proof next, 2026-07-15, +Codex implementation owner.** +`E-M6-SOURCE-TREE-CONTRACT-AUDIT-001` fixes a pure ready-acquisition → immutable descriptor +boundary: exact verified manifest/cache identity and signed limits, deterministic ASCII ordering, +client-native paths, borrowed live-lease assertion, and later shared use by pre-scan plus SFTP, +POSIX tar/no-tar, and Windows PowerShell/.NET transfer. It performs no I/O and owns no cleanup; +filesystem enumeration, streaming, SSH, remote writes/install, product/mode/fallback/default wiring, +publication, and SignPath remain absent. +`E-M6-SOURCE-TREE-CONTRACT-LOCAL-RED-001` records the expected missing-module failure for ten purpose +cases and the native-workflow oracle's six-pass/one-fail result: the suite occurs only in the oracle, +not either runner command. `E-M6-SOURCE-TREE-CONTRACT-LOCAL-CONTENTION-001` rejects an overlapping +local broad run whose existing suites hit 30-second timeouts under four concurrent heavy commands; +timeouts were not relaxed. `E-M6-SOURCE-TREE-CONTRACT-LOCAL-001` is green after isolated reruns: +10/10 purpose, 85/85 focused, 522/522 broader relay with four declared skips, 280/280 release-script, +typecheck, full lint, format, max-lines, diff, protected-resolver, and no-product-import gates pass. +The pure 123-line module rechecks ready acquisition identity/aggregates/root, freezes deterministic +signed descriptors, and borrows without releasing the live cache lease. Exact-head native proof is +next; filesystem pre-scan/streaming, SSH, remote writes/install, product/mode/fallback/default wiring, +publication, and SignPath remain absent. `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, 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 24629de2703..e577c4bcd58 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 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: audit only the smallest disconnected transport-neutral verified source-tree contract after host-composition CI closure**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **HOST COMPOSITION CI CLOSED / TRANSFER CONTRACT AUDIT NEXT — 2026-07-15, Codex implementation owner** — `E-M5-HOST-EVIDENCE-COMPOSITION-CI-001` closes exact-head all-six native client proof, both Linux supplements, Windows x64 baseline, PR Checks, Golden E2E, and computer-use; Windows arm64 retains only the declared build-26200/26100 runner mismatch after full runtime proof. Audit only a pure verified source-tree contract next. Do not open SSH channels, transfer/install bytes, add a product caller, settings/fallback wiring, tuple enablement, publication, or default behavior. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: exact-head native proof for the locally green disconnected transport-neutral verified source-tree contract**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
+Session checkpoint: **SOURCE-TREE CONTRACT LOCAL GREEN / EXACT-HEAD CI NEXT — 2026-07-15, Codex implementation owner** — `E-M6-SOURCE-TREE-CONTRACT-LOCAL-001` passes purpose, workflow, focused, broad relay, release-script, typecheck, lint, format, max-lines, diff, protected-resolver, and no-product-caller gates after separately rerunning two contention-invalidated broad commands. Commit and push the isolated package, then require exact-head all-six native proof before checking the Milestone 6 item or advancing to pre-scan. Do not enumerate the filesystem, open SSH channels, transfer/install bytes, add a product caller, settings/fallback wiring, tuple enablement, publication, or default behavior. 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) @@ -150,9 +150,10 @@ same change as the work it records. - Completed package: disconnected full host-evidence composition is green locally and on every primary native client under E-M5-HOST-EVIDENCE-COMPOSITION-LOCAL-001 and E-M5-HOST-EVIDENCE-COMPOSITION-CI-001. It has no product caller. -- Active package: **In progress — 2026-07-15, Codex implementation owner** — audit only Milestone - 6's smallest transport-neutral verified source-tree contract. Do not open SSH channels, transfer - or install bytes, or add product/settings/fallback/tuple/publication/default behavior. +- Active package: **In progress — 2026-07-15, Codex implementation owner** — exact-head native proof + for Milestone 6's locally green pure transport-neutral verified source-tree contract. Do not + enumerate the filesystem, open SSH channels, transfer or install bytes, or add product/settings/ + fallback/tuple/publication/default behavior. - 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 @@ -1153,7 +1154,11 @@ Current behavior must not be assumed adequate for a much larger runtime: ### Shared transfer contract -- [ ] Define one transport-neutral source-tree contract based on the verified manifest. +- [ ] Define one transport-neutral source-tree contract based on the verified manifest. **In + progress — 2026-07-15, Codex implementation owner; audit complete under + E-M6-SOURCE-TREE-CONTRACT-AUDIT-001; RED and local green proof recorded under + E-M6-SOURCE-TREE-CONTRACT-LOCAL-RED-001 and E-M6-SOURCE-TREE-CONTRACT-LOCAL-001; exact-head + native proof required before completion.** - [ ] Pre-scan and reject local source mutation, symlinks, special files, extra files, and path collisions before creating remote files. - [ ] Stream files with bounded buffers; prohibit whole-tree memory materialization. @@ -16080,6 +16085,126 @@ config/vitest.config.ts --maxWorkers=1` plus the composer, Windows compatibility `ORCA_RELAY_PATH`, tuple enablement, publication, defaults, and SignPath remain open. Legacy remains the sole production/default path and every tuple stays disabled. +### E-M6-SOURCE-TREE-CONTRACT-AUDIT-001 — disconnected authenticated source-tree projection + +- Date/owner: 2026-07-15, Codex implementation owner. +- Exact input boundary: accept only the `ready` result from the existing artifact acquisition. That + result already binds a selected tuple from a signature-verified immutable manifest, a strict + fully verified cache entry, and a live content-scoped in-use lease. Do not accept a raw manifest, + tuple, cache path, archive, or caller-created file list. +- Immutable projection: recheck tuple/content identity, cache aggregate counts, and the canonical + `runtime` root before exposure. Project the authenticated entries into deeply frozen directory + and file descriptors with client-native absolute paths. Use one explicit ASCII path order, + independent of manifest insertion order and filesystem enumeration order. Preserve signed file + role, exact size, SHA-256, and only the declared 0644/0755 modes. +- Limits: inherit the already authenticated schema ceilings rather than adding a second policy: + 5,000 total entries, 250 MiB per file, 350 MiB expanded bytes, and exact signed file-count/ + expanded-byte aggregates. A mismatch fails closed before a descriptor is returned. +- Ownership/cancellation: the projection is synchronous and performs no filesystem or network I/O, + so it has no cancellation point and creates no resource to clean up. It exposes only a borrowed + lease-ownership assertion; it never releases the acquisition's lease. The later orchestration + owner must retain and release that lease only after pre-scan, the selected transfer family, and + cleanup have all settled. The next asynchronous pre-scan/transfer package must accept one exact + `AbortSignal`, check it throughout, and assert the borrowed lease before and after source reads. +- Later consumers: the same contract is the sole expected-tree input for pre-scan and for bounded + SFTP, POSIX tar/no-tar system-SSH, and Windows PowerShell/.NET system-SSH transfer. Transport code + may choose framing/concurrency but may not reorder or weaken the authenticated descriptors. +- Required RED/green proof: Linux and Windows signed tuples; deterministic ordering and native path + construction; exact modes/roles/sizes/hashes/aggregates; deep freezing; ready-only input; identity, + aggregate, and runtime-root rejection; borrowed lease assertion with zero implicit release; no + filesystem/SSH/product import or caller; and exact inclusion in both native workflow families. +- Deliberately absent: filesystem enumeration or mutation detection, file opening/streaming, + channels/SFTP/child processes, remote paths/writes/install, progress, cleanup, settings/modes, + fallback, Electron/startup, `ORCA_RELAY_PATH`, tuple enablement, publication, defaults, and + SignPath. Legacy remains the sole production/default path and every tuple stays disabled. + +### E-M6-SOURCE-TREE-CONTRACT-LOCAL-RED-001 — missing source-tree contract and native proof + +- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0, + pnpm 10.24.0, atop local evidence commit `e76c5f9e8` and the audit/test-only working diff. Exact + Node 24 remains a later native CI gate. +- Purpose command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-runtime-source-tree.test.ts` — EXPECTED FAIL, exit 1: the + absent `ssh-relay-runtime-source-tree` module prevents collection of the ten audited cases; zero + tests run in 600 ms Vitest / 3.20 seconds wall, 132,104,192-byte maximum RSS and 96,212,248-byte + peak memory footprint. +- Workflow-oracle command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 config/scripts/ssh-relay-runtime-workflow.test.mjs` — EXPECTED FAIL, exit 1: six + workflow cases pass and the native-family inclusion oracle fails because the new purpose suite has + one source occurrence rather than the required three; 544 ms Vitest / 3.18 seconds wall, + 131,956,736-byte maximum RSS and 96,245,280-byte peak memory footprint. +- Scope at RED: `git diff --check` passed before the package; protected resolver files had no diff. + No source-tree module, production import/caller, filesystem enumeration, SSH operation, transfer, + remote write, settings/fallback, tuple, publication, or default behavior exists. + +### E-M6-SOURCE-TREE-CONTRACT-LOCAL-CONTENTION-001 — rejected concurrent broad-gate run + +- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64. The first broad + relay, release-script, and full-lint commands were launched together while an unrelated local + Vitest process was also active. Process inspection showed all four CPU/I/O-heavy commands + overlapping for more than six minutes; these results are not accepted as implementation evidence. +- 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` — INVALID/FAIL, 39 files passed, + three skipped, and one existing cache-entry suite failed seven cases only at their 30-second test + timeout; 515 tests passed and four were skipped in 449.11 seconds Vitest / 453.40 seconds wall. +- 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` — INVALID/FAIL, 48 files + passed and two existing suites failed nine cases at 30-second timeouts/teardown races; 271 tests + passed with four unhandled cleanup errors in 445.50 seconds Vitest / 449.76 seconds wall. +- Full lint nevertheless passed every command in 448.00 seconds wall. Because the two test failures + were resource-contention artifacts outside this package, both broad test commands were rerun alone + after process inspection confirmed no competing Vitest/lint process; only those isolated reruns + count below. No timeout was increased and no test or production behavior was weakened. + +### E-M6-SOURCE-TREE-CONTRACT-LOCAL-001 — pure authenticated source-tree contract is locally green + +- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0, + pnpm 10.24.0, atop local evidence commit `e76c5f9e8` plus this isolated package. Exact Node 24 and + every client OS/architecture remain mandatory in native CI. +- Implementation: the 123-line `ssh-relay-runtime-source-tree.ts` accepts only an acquisition-ready + type exported by the existing acquisition module. It rechecks signed tuple/content identity, + verified cache aggregates, and the exact cache `runtime` root; then projects Linux/Darwin/Windows + manifest entries into separately ASCII-sorted, deeply frozen client-native directory/file + descriptors. It preserves signed roles, modes, sizes, and hashes, exposes only a borrowed lease + assertion, and never releases the owner lease. It imports only `node:path`, acquisition types, and + runtime identity types; it performs no I/O and has no product caller. +- Post-format purpose command: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-runtime-source-tree.test.ts` — PASS, + one file / ten tests in 1.64 seconds Vitest / 6.64 seconds wall, 132,792,320-byte maximum RSS and + 96,998,776-byte peak memory footprint. It covers signed Linux/Windows tuples, reversed insertion + order, client-native paths, exact descriptors/aggregates, deep freezing, lease borrowing, and + ready/identity/aggregate/runtime-root rejection. +- Workflow-oracle command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, one file / seven tests in + 1.24 seconds Vitest / 6.23 seconds wall, 133,103,616-byte maximum RSS. Both native workflow command + families now execute the purpose suite exactly once. +- Focused command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1` plus the source-tree, acquisition unit/integration, cache-entry, schema, signature, + and workflow-oracle suites — PASS, seven files / 85 tests in 39.79 seconds Vitest. The observed + 188.64-second wrapper wall time came from the same local command scheduling period, but no focused + test failed or timed out. +- Isolated broader relay rerun: `/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, 40 + files passed / three declared full-size skips, 522 tests passed / four declared platform skips in + 54.54 seconds Vitest / 57.40 seconds wall, 222,511,104-byte maximum RSS. Only the established stale + lock diagnostic and local Node 26 `module.register()` deprecation warning appear. +- Isolated release-script rerun: `/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 35.05 seconds Vitest / 39.91 seconds wall, 190,300,160-byte maximum + RSS. +- Static gates: `/usr/bin/time -l pnpm typecheck` passes in 9.32 seconds wall with + 1,238,499,328-byte maximum RSS. `/usr/bin/time -l pnpm lint` passes every full lint, + switch-exhaustiveness, 41 reliability, 355-entry max-lines, bundled-skill, 9,837 localization- + reference, locale-parity, and zero-candidate localization gate; the 26 warnings remain only in + untouched files. Targeted `oxlint`, targeted `oxfmt --check`, `git diff --check`, protected- + resolver empty diff, no other `src/main` importer, and the 123/226-line implementation/test size + checks pass. No max-lines suppression or vague module is added. +- Boundary/residual gaps: this is a synchronous descriptor contract, not filesystem pre-scan, + mutation detection, streaming, live SSH, SFTP/system-SSH, remote staging/install, or a full-size + transfer. Exact-head all-six Node 24 client proof is required before accepting the item. Every + product/startup/settings/fallback/tuple/publication/default path remains absent; legacy remains the + sole production/default path, every tuple stays disabled, and SignPath stays deferred. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -16138,12 +16263,12 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Audit only Milestone 6's smallest disconnected transport-neutral verified source-tree contract. -Determine the exact authenticated cache-entry/manifest inputs, immutable file ordering, mode and -size/count limits, ownership/cancellation semantics, and which later transfer families consume it -before adding tests or implementation. Do not open an SSH channel, transfer or install bytes, modify -platform detection/selectors, add an Electron/startup consumer, mode wiring, fallback, tuple -enablement, release publication, or default behavior. +Commit and push the locally green Milestone 6 disconnected transport-neutral verified source-tree +contract, then require exact-head all-six native client proof plus adjacent PR checks before accepting +the item or advancing to the filesystem pre-scan package. +Do not enumerate the filesystem, open an SSH channel, transfer or install bytes, modify platform +detection/selectors, add an Electron/startup consumer, mode wiring, fallback, tuple enablement, +release publication, or default behavior. Keep Node upstream `.tar.xz` inputs, Windows ZIP, `ORCA_RELAY_PATH`, existing desktop required-assets behavior, detached-signature byte encoding, Windows arm64 build 26100, macOS 13.5, Linux kernel 4.18, release-cut, desktop builds, publication, and every tuple separately gated. diff --git a/src/main/ssh/ssh-relay-artifact-acquisition.ts b/src/main/ssh/ssh-relay-artifact-acquisition.ts index 4208c53a437..651e2b9bbd6 100644 --- a/src/main/ssh/ssh-relay-artifact-acquisition.ts +++ b/src/main/ssh/ssh-relay-artifact-acquisition.ts @@ -22,16 +22,18 @@ const DEFAULT_OPERATIONS: SshRelayArtifactAcquisitionOperations = Object.freeze( populate: populateSshRelayArtifactCache }) +export type SshRelayArtifactReadyAcquisition = Readonly<{ + kind: 'ready' + source: 'cache' | 'download' + artifact: SshRelaySelectedArtifact + entry: Readonly + lease: SshRelayArtifactCacheInUseLease +}> + 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 - }> + | SshRelayArtifactReadyAcquisition function sameArtifactIdentity( expected: SshRelaySelectedArtifact, diff --git a/src/main/ssh/ssh-relay-runtime-source-tree.test.ts b/src/main/ssh/ssh-relay-runtime-source-tree.test.ts new file mode 100644 index 00000000000..cf7f07111b5 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-source-tree.test.ts @@ -0,0 +1,225 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import nacl from 'tweetnacl' +import { describe, expect, it, vi } from 'vitest' + +import type { SshRelayArtifactReadyAcquisition } from './ssh-relay-artifact-acquisition' +import type { SshRelayArtifactCacheEntry } from './ssh-relay-artifact-cache-entry-verification' +import type { SshRelayArtifactCacheInUseLease } from './ssh-relay-artifact-cache-in-use-lease' +import { + createSshRelayArtifactTestManifest, + createSshRelayWindowsArtifactTestManifest +} from './ssh-relay-artifact-test-manifest' +import { selectSshRelayArtifact } from './ssh-relay-artifact-selector' +import { + signSshRelayArtifactManifest, + sshRelayManifestKeyId, + verifySshRelayArtifactManifest +} from './ssh-relay-manifest-signature' +import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset' +import { computeSshRelayRuntimeContentId, type SshRelayDigest } from './ssh-relay-runtime-identity' +import { createSshRelayRuntimeSourceTree } from './ssh-relay-runtime-source-tree' + +const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) +const cacheRoot = join(tmpdir(), 'orca-relay-runtime-source-tree') + +type Fixture = { + acquisition: SshRelayArtifactReadyAcquisition + assertOwned: ReturnType + release: ReturnType +} + +function readyFixture( + os: 'linux' | 'win32', + { reverseEntries = false }: { reverseEntries?: boolean } = {} +): Fixture { + const manifest = + os === 'win32' + ? createSshRelayWindowsArtifactTestManifest() + : createSshRelayArtifactTestManifest() + const tuple = manifest.tuples[0] + if (reverseEntries) { + tuple.entries.reverse() + tuple.contentId = computeSshRelayRuntimeContentId(tuple) + tuple.archive.name = sshRelayRuntimeArchiveName(tuple.tupleId, tuple.contentId) + } + manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)] + const verified = verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + const host = + os === 'win32' + ? ({ + os, + architecture: 'x64', + processTranslated: false, + build: 22631, + openSshVersion: '9.5p1', + powerShellVersion: '5.1', + dotNetFrameworkRelease: 528040 + } as const) + : ({ + os, + architecture: 'x64', + processTranslated: false, + kernelVersion: '6.8', + libc: { family: 'glibc', version: '2.39' }, + libstdcxxVersion: '6.0.33', + glibcxxVersion: '3.4.33' + } as const) + const artifact = selectSshRelayArtifact(verified, host) + if (artifact.kind !== 'selected') { + throw new Error(`Expected selected source-tree fixture, got ${artifact.reason}`) + } + const entryPath = join(cacheRoot, 'entries', artifact.contentId.slice('sha256:'.length)) + const entry: SshRelayArtifactCacheEntry = { + contentId: artifact.contentId, + tupleId: artifact.tupleId, + entryPath, + archivePath: join(entryPath, artifact.archive.name), + runtimeRoot: join(entryPath, 'runtime'), + proofPath: join(entryPath, 'proof.json'), + files: artifact.archive.fileCount, + expandedBytes: artifact.archive.expandedSize + } + const assertOwned = vi.fn(async () => {}) + const release = vi.fn(async () => {}) + const lease: SshRelayArtifactCacheInUseLease = { + leasePath: join(cacheRoot, 'in-use', 'a'.repeat(32)), + token: 'a'.repeat(32), + assertOwned, + release + } + return { + acquisition: { kind: 'ready', source: 'cache', artifact, entry, lease }, + assertOwned, + release + } +} + +function asciiSorted(paths: string[]): string[] { + return [...paths].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) +} + +const inconsistentCacheEntries: [string, Partial][] = [ + ['tuple identity', { tupleId: 'win32-x64' }], + ['content identity', { contentId: `sha256:${'f'.repeat(64)}` as SshRelayDigest }], + ['file count', { files: 1 }], + ['expanded byte count', { expandedBytes: 1 }] +] + +describe('SSH relay runtime source-tree contract', () => { + it.each(['linux', 'win32'] as const)( + 'projects an authenticated %s acquisition in immutable ASCII order', + (os) => { + const { acquisition } = readyFixture(os, { reverseEntries: true }) + + const tree = createSshRelayRuntimeSourceTree(acquisition) + + const expectedDirectories = asciiSorted( + acquisition.artifact.tuple.entries + .filter((entry) => entry.type === 'directory') + .map((entry) => entry.path) + ) + const expectedFiles = asciiSorted( + acquisition.artifact.tuple.entries + .filter((entry) => entry.type === 'file') + .map((entry) => entry.path) + ) + expect(tree).toMatchObject({ + tupleId: acquisition.artifact.tupleId, + contentId: acquisition.artifact.contentId, + releaseTag: acquisition.artifact.releaseTag, + os: acquisition.artifact.tuple.os, + architecture: acquisition.artifact.tuple.architecture, + runtimeRoot: acquisition.entry.runtimeRoot, + fileCount: acquisition.entry.files, + expandedBytes: acquisition.entry.expandedBytes + }) + expect(tree.directories.map((entry) => entry.path)).toEqual(expectedDirectories) + expect(tree.files.map((entry) => entry.path)).toEqual(expectedFiles) + for (const descriptor of [...tree.directories, ...tree.files]) { + expect(descriptor.localPath).toBe( + join(acquisition.entry.runtimeRoot, ...descriptor.path.split('/')) + ) + } + expect(tree.files).toEqual( + expectedFiles.map((path) => { + const entry = acquisition.artifact.tuple.entries.find( + (candidate) => candidate.path === path + ) + if (!entry || entry.type !== 'file') { + throw new Error(`Missing expected signed file: ${path}`) + } + return { + path, + localPath: join(acquisition.entry.runtimeRoot, ...path.split('/')), + type: 'file', + role: entry.role, + size: entry.size, + mode: entry.mode, + sha256: entry.sha256 + } + }) + ) + } + ) + + it('deeply freezes descriptors without mutating signed manifest insertion order', () => { + const { acquisition } = readyFixture('linux') + const originalOrder = acquisition.artifact.tuple.entries.map((entry) => entry.path) + + const tree = createSshRelayRuntimeSourceTree(acquisition) + + expect(Object.isFrozen(tree)).toBe(true) + expect(Object.isFrozen(tree.directories)).toBe(true) + expect(Object.isFrozen(tree.files)).toBe(true) + expect(tree.directories.every(Object.isFrozen)).toBe(true) + expect(tree.files.every(Object.isFrozen)).toBe(true) + expect(acquisition.artifact.tuple.entries.map((entry) => entry.path)).toEqual(originalOrder) + }) + + it('borrows lease ownership without asserting or releasing it implicitly', async () => { + const { acquisition, assertOwned, release } = readyFixture('linux') + + const tree = createSshRelayRuntimeSourceTree(acquisition) + + expect(assertOwned).not.toHaveBeenCalled() + expect(release).not.toHaveBeenCalled() + await tree.assertLeaseOwned() + expect(assertOwned).toHaveBeenCalledTimes(1) + expect(release).not.toHaveBeenCalled() + }) + + it('rejects a non-ready acquisition', () => { + expect(() => + createSshRelayRuntimeSourceTree({ + kind: 'legacy', + reason: 'kernel-too-old' + } as unknown as SshRelayArtifactReadyAcquisition) + ).toThrow(/ready/i) + }) + + it.each(inconsistentCacheEntries)('rejects inconsistent cache %s', (_name, entryPatch) => { + const { acquisition } = readyFixture('linux') + + expect(() => + createSshRelayRuntimeSourceTree({ + ...acquisition, + entry: { ...acquisition.entry, ...entryPatch } + }) + ).toThrow(/identity|file count|expanded byte/i) + }) + + it('rejects a noncanonical cache runtime root', () => { + const { acquisition } = readyFixture('linux') + + expect(() => + createSshRelayRuntimeSourceTree({ + ...acquisition, + entry: { ...acquisition.entry, runtimeRoot: join(acquisition.entry.entryPath, 'other') } + }) + ).toThrow(/runtime root/i) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-source-tree.ts b/src/main/ssh/ssh-relay-runtime-source-tree.ts new file mode 100644 index 00000000000..c4bf366a91e --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-source-tree.ts @@ -0,0 +1,123 @@ +import { join } from 'node:path' + +import type { SshRelayArtifactReadyAcquisition } from './ssh-relay-artifact-acquisition' +import type { + SshRelayDigest, + SshRelayRuntimeFileRole, + SshRelayRuntimeTupleId +} from './ssh-relay-runtime-identity' + +export type SshRelayRuntimeSourceDirectory = Readonly<{ + path: string + localPath: string + type: 'directory' + mode: 0o755 +}> + +export type SshRelayRuntimeSourceFile = Readonly<{ + path: string + localPath: string + type: 'file' + role: SshRelayRuntimeFileRole + size: number + mode: 0o644 | 0o755 + sha256: SshRelayDigest +}> + +export type SshRelayRuntimeSourceTree = Readonly<{ + tupleId: SshRelayRuntimeTupleId + contentId: SshRelayDigest + releaseTag: string + os: 'linux' | 'darwin' | 'win32' + architecture: 'x64' | 'arm64' + runtimeRoot: string + directories: readonly SshRelayRuntimeSourceDirectory[] + files: readonly SshRelayRuntimeSourceFile[] + fileCount: number + expandedBytes: number + assertLeaseOwned: () => Promise +}> + +function compareAscii(left: { path: string }, right: { path: string }): number { + return left.path < right.path ? -1 : left.path > right.path ? 1 : 0 +} + +export function createSshRelayRuntimeSourceTree( + acquisition: SshRelayArtifactReadyAcquisition +): SshRelayRuntimeSourceTree { + if (!acquisition || acquisition.kind !== 'ready') { + throw new Error('SSH relay runtime source tree requires a ready artifact acquisition') + } + const { artifact, entry, lease } = acquisition + if ( + artifact.tupleId !== artifact.tuple.tupleId || + artifact.contentId !== artifact.tuple.contentId || + entry.tupleId !== artifact.tupleId || + entry.contentId !== artifact.contentId + ) { + throw new Error('SSH relay runtime source tree has inconsistent artifact identity') + } + if (entry.runtimeRoot !== join(entry.entryPath, 'runtime')) { + throw new Error('SSH relay runtime source tree has a noncanonical cache runtime root') + } + + const directories: SshRelayRuntimeSourceDirectory[] = [] + const files: SshRelayRuntimeSourceFile[] = [] + let expandedBytes = 0 + for (const manifestEntry of artifact.tuple.entries) { + const localPath = join(entry.runtimeRoot, ...manifestEntry.path.split('/')) + if (manifestEntry.type === 'directory') { + directories.push( + Object.freeze({ + path: manifestEntry.path, + localPath, + type: manifestEntry.type, + mode: manifestEntry.mode + }) + ) + continue + } + expandedBytes += manifestEntry.size + files.push( + Object.freeze({ + path: manifestEntry.path, + localPath, + type: manifestEntry.type, + role: manifestEntry.role, + size: manifestEntry.size, + mode: manifestEntry.mode, + sha256: manifestEntry.sha256 + }) + ) + } + if (files.length !== artifact.archive.fileCount || entry.files !== artifact.archive.fileCount) { + throw new Error('SSH relay runtime source tree file count disagrees with the signed artifact') + } + if ( + expandedBytes !== artifact.archive.expandedSize || + entry.expandedBytes !== artifact.archive.expandedSize + ) { + throw new Error( + 'SSH relay runtime source tree expanded byte count disagrees with the signed artifact' + ) + } + + directories.sort(compareAscii) + files.sort(compareAscii) + // Why: transports borrow the live cache lease; only their orchestration owner may release it + // after transfer and cleanup have both settled. + const assertLeaseOwned = (): Promise => lease.assertOwned() + return Object.freeze({ + tupleId: artifact.tupleId, + contentId: artifact.contentId, + releaseTag: artifact.releaseTag, + os: artifact.tuple.os, + architecture: artifact.tuple.architecture, + runtimeRoot: entry.runtimeRoot, + directories: Object.freeze(directories), + files: Object.freeze(files), + fileCount: files.length, + expandedBytes, + assertLeaseOwned + }) +}