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 3a80f276bdd..64f21b06483 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 @@ -2,7 +2,7 @@ Date created: 2026-07-14
Last updated: 2026-07-14
-Current phase: Milestone 1 decision closure — Work Package 0 is CI-green in draft PR #8724; no bundled-runtime path is enabled; runner and per-target Beta rollout policies are recorded
+Current phase: Milestone 3 / Work Package 2 target-native runtime assembly — Work Package 1 contracts are CI-green; cross-family remote infrastructure and measured baselines remain open; no bundled-runtime path is enabled
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) @@ -52,19 +52,30 @@ same change as the work it records. E-M0-STATIC-002, E-M0-LIVE-002, E-M0-CI-001). Draft PR [#8724](https://github.com/stablyai/orca/pull/8724) is open and CI-green at implementation head `94e58d83e`. +- Completed package: Work Package 1's disconnected manifest, content-identity, signature, + release-asset, and conservative selector contracts are locally and CI-green under E-M2-RED-001, + E-M2-CONTRACT-001, and E-M2-CI-001. They remain isolated in stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) at implementation commit `b9d80a4cb`; no + deploy/resolver call site is connected and no tuple is enabled. +- Active package: Work Package 2 target-native runtime assembly, archive inspection, executable + smoke, SBOM, and provenance only. It may produce test artifacts but must not publish, resolve, + transfer, install, launch, or enable them. - Production behavior: unchanged; Orca embeds relay JavaScript and installs `node-pty` plus `@parcel/watcher` with remote npm. - New runtime assets published: none. - Bundled runtime enabled: no. -- Declared supported bundled tuples: none until the baseline and runner decisions below are closed. +- Declared supported bundled tuples: none until the required target-native and two-layer live + evidence cells are complete. - Validation orchestration: GitHub Actions is the primary runner and evidence surface under - E-M1-RUNNER-DECISION-001; exact native labels and representative remote targets remain open. + E-M1-RUNNER-DECISION-001; exact native labels are recorded, while representative cross-family + remote targets remain open. - Rollout control: existing per-SSH-target configuration; legacy is the default and the bundled runtime is an explicit per-target Beta opt-in under E-M1-ROLLOUT-DECISION-001. - Legacy fallback removal: not authorized. -- Next required action: close the remaining Milestone 1 baselines, provenance/trust, numeric-budget, - and concrete runner/remote decisions. Begin the contract-only package on its own branch/PR - boundary; do not add Work Package 1 implementation to PR #8724. +- Next required action: create the isolated stacked Work Package 2 branch/PR and implement the first + target-native runtime assembly plus archive inspection and executable smoke without publication or + production consumption. Keep cross-family remote infrastructure and measured legacy baseline gates + open and do not introduce resolver, transfer, rollout, or default behavior. ## Non-Negotiable Invariants @@ -155,19 +166,51 @@ No production implementation begins until every blocking item has an owner and d ### Supported runtime baselines -**In progress — 2026-07-14, Codex implementation owner:** resolve the baseline, provenance, trust, -runner/remote, and numeric-budget decisions with authoritative evidence before starting Work Package -1 implementation on a separate branch/PR boundary. - -- [ ] Define the oldest supported glibc version. -- [ ] Define the oldest supported libstdc++/C++ ABI level. -- [ ] Define the minimum supported Linux kernel. -- [ ] Define supported musl distribution and musl-version baselines. -- [ ] Define minimum supported macOS version for x64 and arm64. -- [ ] Define minimum supported Windows build and OpenSSH/PowerShell baseline. -- [ ] Decide whether Rosetta-hosted shells select x64, arm64, or legacy based on the actual remote - process architecture. -- [ ] Document currently supported legacy tuples separately from proposed bundled tuples. +- [x] Define the oldest supported glibc version as 2.28 for initial Linux x64/arm64 candidates. + (E-M1-BASELINE-001) +- [x] Define the oldest supported libstdc++/C++ ABI level as libstdc++ 6.0.25 with + `GLIBCXX_3.4.25`. (E-M1-BASELINE-001) +- [x] Define the minimum supported Linux kernel as 4.18. (E-M1-BASELINE-001) +- [x] Define no enabled musl baseline initially. Alpine/musl remains a selector and legacy-path test + family until Orca produces and proves its own target-native Node/runtime build; no unofficial + Node binary is accepted. (E-M1-BASELINE-001, E-M1-NODE-PROVENANCE-001) +- [x] Define macOS 13.5 as the minimum for both x64 and arm64 candidates. (E-M1-BASELINE-001) +- [x] Define the initial Windows candidates as Windows 10 22H2 build 19045 or Server 2022 build + 20348 for x64, and Windows 11 24H2 build 26100 for arm64, with OpenSSH for Windows 8.1p1, + Windows PowerShell 5.1, and .NET Framework 4.8 as minimum bootstrap primitives. + (E-M1-BASELINE-001) +- [x] Treat a Rosetta-translated shell as x64 process architecture, but keep it on legacy until an + x64 artifact passes a live Rosetta SSH cell. A native arm64 shell selects arm64; detection + never forces a native-architecture artifact across a translated process boundary. + (E-M1-BASELINE-001) +- [x] Document current legacy platform families separately from bundled candidates and distinguish a + code-declared family from a live-evidenced fallback claim. (E-M1-LEGACY-INVENTORY-001) + +Decision owner: Codex implementation owner for #8450. Decision authority: conservative +implementation boundary under the user-approved legacy-default Beta rollout. These are minimum +eligibility constraints, not support claims: every candidate remains disabled until its two-layer +live evidence cells pass. + +The initial candidate families are Linux glibc x64/arm64, macOS x64/arm64, and Windows x64/arm64. +Linux musl, WSL, Rosetta-translated macOS, and any unlisted OS/architecture remain legacy-only. +Version parsing is exact and fail-conservative; an unknown, missing, older, or conflicting baseline +probe selects legacy rather than guessing a compatible artifact. + +| Remote family | Current legacy path | Proposed bundled candidate | Live fallback evidence in this project | +| -------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------- | +| Linux x64 glibc | Declared `linux-x64`; coherent remote Node/npm plus remote native npm install | `linux-x64-glibc` | None yet | +| Linux arm64 glibc | Declared `linux-arm64`; coherent remote Node/npm plus remote native npm install | `linux-arm64-glibc` | E-M0-LIVE-002 (Ubuntu 24.04 arm64) | +| Linux x64/arm64 musl | Shares current Linux platform key; compatibility depends on legacy npm/native build | None initially; selector returns legacy | None | +| macOS x64 | Declared `darwin-x64`; coherent remote Node/npm plus remote native npm install | `darwin-x64` | None | +| macOS arm64 | Declared `darwin-arm64`; coherent remote Node/npm plus remote native npm install | `darwin-arm64` | None | +| Windows x64 | Declared `win32-x64`; coherent `node.exe`/`npm.cmd` plus remote native install | `win32-x64` | None | +| Windows arm64 | Declared `win32-arm64`; coherent `node.exe`/`npm.cmd` plus remote native install | `win32-arm64` | None | +| WSL/Rosetta/other | No separate current platform identity; behavior follows detected process family | No candidate until a separately identified live cell | None | + +“Declared” above means the current source accepts the platform and has deterministic unit coverage; +it is not a claim that this project has a passing live SSH fallback cell. Automatic legacy fallback +for a bundled tuple is permitted only where the last column has current evidence for the relevant +family/transport conditions. ### Validation runner and network topology @@ -193,73 +236,208 @@ the exact commit under test without introducing a second release-control plane. do not assume networking between unrelated hosted jobs. (E-M1-RUNNER-DECISION-001) - [x] Keep any tuple without an available qualifying native runner or reachable representative remote disabled and on its proven legacy path. (E-M1-RUNNER-DECISION-001) -- [ ] Inventory the repository's available GitHub-hosted and approved self-hosted runner labels; - record which are native, their image/version update policy, capacity, and architecture. +- [x] Inventory the repository's available GitHub-hosted runner labels and record native + architecture, image/version policy, and unreserved capacity. No approved self-hosted SSH + target pool currently exists. (E-M1-RUNNER-INVENTORY-001) - [ ] Name and pin the representative POSIX and Windows remote images/snapshots used for Layer B, including their OpenSSH and bootstrap-primitive baselines and network-egress controls. -- [ ] Select a repeatable GitHub runner class or approved dedicated runner for numeric regression - baselines. If hosted-runner variance prevents the Milestone 1 thresholds from being evaluated, - use the dedicated runner for the affected metric rather than weakening the threshold. +- [x] Use the explicit native GitHub runner labels below for paired legacy-versus-bundled regression + measurements in the same job, with local network shaping and at least ten samples per path. If + three qualifying reruns have over 15% coefficient of variation or disagree on the threshold, + require an approved dedicated runner; do not weaken the threshold. (E-M1-BUDGET-DECISION-001) + +BLOCKED for cross-family live evidence, not for contract-only work: the Codex implementation owner +owns repo-local fixture/workflow definitions, but a repository release administrator must approve an +ephemeral cloud credential/environment or a self-hosted target pool before immutable POSIX and +Windows Layer B snapshots can be named as reachable infrastructure. The unblock record must include +provider, snapshot IDs, credential owner, egress policy, teardown SLA, and cost/capacity owner. Safe +default: no Layer B cell and no bundled tuple is enabled; continue schema/selector and local +same-family test work without secrets. + +#### Native runner inventory decision + +Decision owner: Codex implementation owner for #8450. Image authority: GitHub-hosted runner image +catalog; repository workflow inventory at this branch. Pin explicit OS labels in new relay workflows +and record the resolved image version from every job because GitHub refreshes images weekly. +`*-latest` is not permitted for qualifying tuple evidence. + +| Client/build architecture | Qualifying label | Native image family | Capacity/update rule | +| ------------------------- | ------------------ | ------------------- | ------------------------------------------------------------------------------------ | +| Linux x64 | `ubuntu-24.04` | Ubuntu 24.04 x64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | +| Linux arm64 | `ubuntu-24.04-arm` | Ubuntu 24.04 arm64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | +| macOS x64 | `macos-15-intel` | macOS 15 x64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | +| macOS arm64 | `macos-15` | macOS 15 arm64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | +| Windows x64 | `windows-2022` | Server 2022 x64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | +| Windows arm64 | `windows-11-arm` | Windows 11 arm64 | GitHub-hosted ephemeral, unreserved/queue-dependent; weekly image, log exact version | + +The repository already uses `ubuntu-latest`, `ubuntu-24.04-arm`, `macos-15`, `windows-2022`, and a +third-party macOS release label. Existing `latest` and third-party labels may remain for their +current workflows but do not qualify a relay tuple unless the relay job pins the explicit label and +records its resolved image. No approved self-hosted SSH target pool, reserved hosted-runner capacity, +or cross-family remote endpoint is inferred from this inventory; those unresolved Layer B cells keep +the affected tuple disabled. ### Remote bootstrap primitives -- [ ] Define the exact POSIX baseline, including POSIX `sh`, byte-preserving stdin-to-file via `cat` - or a proven equivalent, and required `mkdir`/`rm`/`mv`/`chmod`/`test`/`nohup` semantics. -- [ ] Define the exact Windows OpenSSH, PowerShell, and .NET baseline plus binary file I/O, exclusive - staging, atomic rename, permission, and detached-process primitives. -- [ ] Decide how each required primitive is capability-probed without Node or Python and how probe - results are scoped to the remote host/connection. -- [ ] Define missing-command behavior as compatibility failure, eligible for legacy in `auto` only - where that legacy tuple is proven. -- [ ] Record BusyBox variants whose semantics satisfy the POSIX baseline and disable bundled mode for - variants that do not. +- [x] Define the exact POSIX baseline as POSIX `sh` with builtin `command`/`printf` plus + byte-preserving `cat`, `mkdir`, `rm`, `mv`, `chmod`, `test`, and `nohup`; no Node, Python, + Perl, tar, base64, checksum tool, or compiler is permitted. (E-M1-BOOTSTRAP-DECISION-001) +- [x] Define the exact Windows baseline as OpenSSH for Windows 8.1p1, Windows PowerShell 5.1, and + .NET Framework 4.8 with `FileStream`, `FileMode.CreateNew`, 64 KiB binary reads/writes, + same-volume `Directory.Move`, ACL-preserving filesystem operations, and the existing detached + relay launch mechanism. (E-M1-BOOTSTRAP-DECISION-001) +- [x] Capability-probe required semantics with a client-generated nonce and byte fixture, without + Node or Python, once per authenticated SSH connection. Never persist or share the result + across hosts or reconnect generations. (E-M1-BOOTSTRAP-DECISION-001) +- [x] Treat missing or semantically incorrect bootstrap primitives as compatibility failure, + eligible for legacy in `auto` only where that exact legacy family has current evidence. + (E-M1-BOOTSTRAP-DECISION-001) +- [x] Record no BusyBox-only variant as qualified initially. Ubuntu 24.04 arm64 BusyBox 1.36.1 lacks + the required `nohup` applet and therefore selects legacy; any BusyBox host must pass the full + semantic probe with external required commands before it can become a candidate. + (E-M1-BUSYBOX-001) + +#### Bootstrap primitive decision + +POSIX system SSH and built-in SSH2 both open a dedicated stdin byte stream for each manifest file. +The remote command creates that file with restrictive permissions and `cat > file`; the client sends +exactly the declared size and closes stdin. The initial semantic probe round-trips a binary fixture +containing NUL, CR, LF, high-bit bytes, and no trailing newline through the same path. Per-file +channels are bounded and become sequential under `MaxSessions=1`. Full staged-tree integrity is +still checked later by transferred bundled Node, not by trusting `cat` or a remote checksum command. + +Windows transfer sends raw bytes to a purpose-built PowerShell script that reads +`Console.OpenStandardInput()` into a 64 KiB buffer and writes with `FileStream`. A `CreateNew` lock +file containing an unguessable client token establishes exclusive staging ownership before the +directory is created; every later command verifies that token. The reader rejects early EOF and one +extra byte beyond the declared safe-integer size. It never materializes the file as a string, +JSON/base64 value, or whole-file buffer. Publish uses same-volume rename only after bundled-Node +tree verification and native probes. Probe artifacts and locks are removed on success, failure, or +cancellation; cleanup settlement is part of the timeout oracle. ### Node runtime ownership -- [ ] Select the bundled Node major/minor/patch and support/EOL policy. -- [ ] Decide the provenance for each Node binary: unchanged official binary, Orca-built binary, or - another documented source. -- [ ] Decide how musl Node binaries are produced. Do not use an unreviewed unofficial binary source. -- [ ] Define the Node update cadence and CVE response SLA. -- [ ] Define refresh identity when only Node, CVE remediation, native code, or a signing key changes. - Every refresh must ship through a new desktop tag/build with a newly embedded manifest. -- [ ] Decide which upstream signatures are preserved and which Orca-built binaries require native - signing. +- [x] Pin the first runtime contract to official Node v24.18.0 LTS (Krypton). Stay on Node 24 LTS + until a separately reviewed major upgrade; never silently float a desktop build to a newer + patch. (E-M1-NODE-PROVENANCE-001) +- [x] Use unchanged official nodejs.org binaries for Linux glibc x64/arm64, macOS x64/arm64, and + Windows x64/arm64. Build all relay code and native dependencies in target-native Orca jobs; + do not substitute cross-built Node executables. (E-M1-NODE-PROVENANCE-001) +- [x] Produce no musl Node binary in the initial release. A later musl candidate requires an + Orca-owned target-native source build, reproducible provenance, signing, and the same live + gates; unofficial community binaries are prohibited. (E-M1-NODE-PROVENANCE-001) +- [x] Check Node security/release status at least weekly and at every desktop release cut. Triage a + published Node/runtime CVE within one business day; ship an applicable critical fix within + seven calendar days and high-severity fix within fourteen, or disable the affected bundled + tuple while legacy remains available. (E-M1-NODE-PROVENANCE-001) +- [x] Require every Node patch, CVE remediation, native-code change, dependency change, manifest-key + change, or accepted-key change to create a new runtime content ID and a new desktop tag/build + with a newly embedded signed manifest. Old clients remain pinned. (E-M1-NODE-PROVENANCE-001) +- [x] Verify the detached Node `SHASUMS256.txt.sig` against a build-input-pinned Node release + keyring before extraction, record the signer fingerprint and source archive digest in + provenance, and preserve official executable bytes/signatures. Orca-built native executables + and libraries still require the target-native signing policy decided below. + (E-M1-NODE-PROVENANCE-001) ### Trust and signing -- [ ] Choose manifest signature algorithm and library based on existing Orca dependencies and - platform availability. -- [ ] Define canonical manifest serialization byte-for-byte. -- [ ] Define signing-key creation, storage, protected-environment access, auditability, and least - privilege. -- [ ] Define key IDs, accepted-key embedding, dual-key rotation window, revocation response, and - emergency replacement. Revocation and emergency replacement ship through the desktop update - path; old clients remain pinned and no mutable freshness lookup is introduced. -- [ ] Define exact-tag anti-rollback behavior and whether downgrading the desktop may use older - manifests. -- [ ] Decide whether keyless build provenance supplements or replaces any long-lived CI credential. -- [ ] Define macOS code-signing/notarization requirements for Node, `spawn-helper`, and native - modules. -- [ ] Define Windows Authenticode requirements for Node, `.node`, DLL, and helper executables. -- [ ] Define WDAC, Gatekeeper, antivirus, and endpoint-protection validation environments. +- [x] Use Ed25519 detached signatures through the existing direct `tweetnacl@1.0.3` dependency; + reject non-64-byte signatures, non-32-byte public keys, unknown algorithms, and unknown keys. + (E-M1-TRUST-DECISION-001) +- [x] Canonicalize only a fully validated unsigned manifest projection: fixed schema field order, + tuples sorted by canonical tuple ID, files sorted by portable relative path, safe integers, + portable ASCII identity/path fields, no optional `undefined`, and UTF-8 `JSON.stringify` + bytes with no whitespace or trailing newline. Signatures are outside the signed projection. + (E-M1-TRUST-DECISION-001) +- [x] Generate Ed25519 keys offline, store only the base64-encoded 32-byte seed in a tag-restricted + `relay-runtime-manifest-signing` GitHub Environment with required reviewers, expose it only to + the fail-closed aggregate job, pin every action by commit SHA, grant no write permission except + the separate draft-release upload job, and rely on environment deployment logs for audit. + (E-M1-TRUST-DECISION-001) +- [x] Define key ID as `sha256:`. Embed the + accepted key set in the desktop. Rotation dual-signs at least two consecutive desktop releases + and 30 days; old-key removal/revocation and emergency replacement ship only in a new desktop + build. Old clients and manifests remain pinned with no freshness lookup. + (E-M1-TRUST-DECISION-001) +- [x] Require embedded manifest tag/channel/version to equal the compiled desktop identity and use + only that exact release URL. A newer desktop never accepts an older manifest. A user-initiated + desktop downgrade may use its own embedded older manifest/cache namespace but cannot rewrite a + newer content-addressed install. (E-M1-TRUST-DECISION-001) +- [x] Use GitHub artifact attestations/keyless OIDC provenance as a supplement, never as a replacement + for the offline-verifiable embedded Ed25519 manifest signature. (E-M1-TRUST-DECISION-001) +- [x] Preserve valid official Node signatures. Sign every Orca-built macOS executable, `.node`, + dylib, and `spawn-helper` with the existing Developer ID Application identity before final + hashes; verify strict codesign and notarized containing-app provenance on a native runner. + (E-M1-TRUST-DECISION-001) +- [x] Preserve valid upstream Authenticode signatures and send every unsigned Orca-built Windows + `.exe`, `.dll`, and `.node` through the existing SignPath inner-binary flow before final hashes; + verify `Get-AuthenticodeSignature` and signer policy on returned bytes. + (E-M1-TRUST-DECISION-001) +- [x] Define target-native trust environments: clean macOS 13.5 x64/arm64 snapshots with quarantine + plus Gatekeeper/codesign assessment; Windows Server 2022 x64 and Windows 11 24H2 arm64 + snapshots with current Microsoft Defender signatures; and a disposable Windows 11 24H2 x64 + VM with the release WDAC policy in audit then enforced mode. Record snapshot/policy/signature + IDs in every run; third-party EDR observations may supplement but not replace these gates. + (E-M1-ENDPOINT-DECISION-001) + +#### Manifest-key operational gate + +Decision owner: Codex implementation owner for #8450. Operational owners: repository release +administrators for GitHub Environment/reviewer policy, existing Apple credential owners for macOS, +and existing SignPath organization approvers for Windows. No key or secret is created by this +decision. Runtime publication remains blocked until the protected environment, two test keys, +dual-sign/unknown-key/revocation rehearsals, action-SHA allowlist, and access audit are executable. + +The signing job receives canonical unsigned bytes and returns only key ID plus signature. The +aggregate job reconstructs and verifies the signed projection before emitting the final manifest. +The desktop repeats the same validation and rejects duplicates before cryptographic verification. +At least one accepted signature is required, every signature entry must be unique, and a malformed +or unknown extra signature fails closed rather than being ignored. Key compromise cannot alter an +old client's embedded manifest or archive hashes; emergency response stops asset publication and +ships a new desktop build with revised accepted keys. + +The trust-environment decision does not imply those snapshots exist in an approved pool. Gatekeeper +tests add a quarantine attribute to the downloaded runtime before assessment and execution. Windows +tests update Defender intelligence, scan the unpacked tree, verify every PE signature, run native +PTY/watcher smoke, then repeat under the enforced WDAC policy. Detection, quarantine, or policy +denial is release-blocking and must retain logs without weakening the security product. ### Operational budgets and rollout policy - [ ] Record current legacy cold-install and warm-connect baselines on representative networks. -- [ ] Set numeric local cache size and eviction budgets. -- [ ] Set archive compressed-size, expanded-size, file-count, and per-file limits. -- [ ] Set local download, extraction, transfer, remote verification, launch, cancellation, and total - bootstrap time budgets. -- [ ] Set desktop and remote peak-memory budgets for every transfer path. -- [ ] Set SFTP concurrency and open-channel limits. -- [ ] Define acceptable warm-path and cold-path latency regressions numerically. -- [ ] Define how long legacy fallback remains and the evidence required before narrowing it. +- [x] Set the local verified-runtime cache to 2 GiB, with an atomic LRU that never evicts referenced, + locked, staging, current-manifest, or running content; retain at least current plus previous + content per used tuple when they fit. (E-M1-BUDGET-DECISION-001) +- [x] Reject an archive over 100 MiB compressed, 350 MiB expanded, 5,000 entries, 250 MiB for one + file, a path over 240 UTF-8 bytes, or aggregate metadata arithmetic outside safe integers. + (E-M1-BUDGET-DECISION-001) +- [x] Set stage ceilings: cache lookup 2 s, connect/DNS availability decision 15 s, download 5 min, + extraction 2 min, transfer 20 min, remote full-tree verification 3 min, native/smoke probes + 2 min, launch/handshake 30 s, cancellation-and-join 10 s, and total cold bootstrap 30 min. + (E-M1-BUDGET-DECISION-001) +- [x] Limit incremental transfer/extraction memory to 64 MiB on the desktop and 32 MiB on the remote, + excluding the launched relay's measured steady-state runtime; no single buffer exceeds 1 MiB. + (E-M1-BUDGET-DECISION-001) +- [x] Limit SFTP to four in-flight files and four bootstrap channels by default, reduce to one after + the existing `MaxSessions=1` capability result, and never exceed eight open file handles in + either process. (E-M1-BUDGET-DECISION-001) +- [x] Require warm-connect p95 to be no more than 100 ms or 10% slower than paired legacy, whichever + allowance is larger. Cold p95 must not exceed the smaller of the 30-minute hard cap or 115% of + the measured bytes/throughput lower bound plus five minutes at 1/10/100 Mbps and 50/100/200 ms + RTT. Offline/connectivity classification completes within 20 s, and legacy starts within 10 s + after any eligible failure is classified and bundled work is joined. (E-M1-BUDGET-DECISION-001) +- [x] Keep automatic legacy fallback through at least two stable releases and 30 days after any + separately authorized default-on change. Narrowing fallback or removing legacy requires a + separate review plus complete matrix, soak, support, and rollback evidence. + (E-M1-BUDGET-DECISION-001) - [x] Use existing per-SSH-target configuration for rollout control; do not add a rollout backend. This closes the control-mechanism decision, not its implementation. (E-M1-ROLLOUT-DECISION-001) -- [ ] Decide user-facing behavior when the client is offline, the asset is absent locally, the - remote cache is absent, and legacy prerequisites are unavailable. +- [x] Define offline/missing-asset behavior: use a verified client cache without GitHub or remote + egress; otherwise classified offline/404/cache-unavailable errors may use proven legacy in + Beta `auto`. If legacy prerequisites also fail, return both stage codes and actionable retry, + update, or disable-Beta guidance without hiding the original error. Integrity failures never + fallback. (E-M1-BUDGET-DECISION-001) #### Per-target Beta rollout decision @@ -294,36 +472,46 @@ matrix cells. Suggested modules are provisional; update this file before choosing different names. +**Work Package 1 complete — 2026-07-14, Codex implementation owner:** implemented the pure manifest schema, +canonical unsigned bytes/signature verification, content identity, and fail-conservative +platform/libc selector with hostile-input tests only. No deploy/resolver call site or bundled tuple is +enabled in this package. Local and exact-head draft PR evidence is recorded under E-M2-RED-001, +E-M2-CONTRACT-001, and E-M2-CI-001. + ### Manifest schema -- [ ] Add a versioned relay artifact schema in a concrete domain module such as - `src/main/ssh/ssh-relay-artifact-schema.ts`. -- [ ] Include schema version, exact Orca build tag, relay protocol version, runtime content ID, +- [x] Add a versioned relay artifact schema in `src/main/ssh/ssh-relay-artifact-schema.ts`. + (E-M2-CONTRACT-001) +- [x] Include schema version, exact Orca build tag, relay protocol version, runtime content ID, OS, architecture, libc family/version requirements, minimum OS/kernel requirements, Node version, dependency versions, archive name, archive size, expanded size, file count, and - archive SHA-256. -- [ ] Include every runtime file’s relative path, type, size, SHA-256, and required executable mode. -- [ ] Include signing key ID, signature algorithm/version, creation timestamp, and provenance/SBOM - references without making wall-clock time part of runtime identity. -- [ ] Include target-native code-signing verification attestation, policy/tool identity, and the - exact attested byte hashes inside the signed manifest. -- [ ] Reject duplicate tuple entries, duplicate/case-colliding paths, unsafe Windows names, + archive SHA-256. (E-M2-CONTRACT-001) +- [x] Include every runtime file’s relative path, type, size, SHA-256, and required executable mode. + (E-M2-CONTRACT-001) +- [x] Include signing key ID, signature algorithm/version, creation timestamp, and provenance/SBOM + references without making wall-clock time part of runtime identity. (E-M2-CONTRACT-001) +- [x] Include target-native code-signing verification attestation, policy/tool identity, and the + exact attested byte hashes inside the signed manifest. (E-M2-CONTRACT-001) +- [x] Reject duplicate tuple entries, duplicate/case-colliding paths, unsafe Windows names, unsupported schema versions, missing required files, extra native platform packages, and - inconsistent aggregate sizes. -- [ ] Define exact archive naming for stable, RC, and perf builds. -- [ ] Define exact direct release URL construction. Runtime code must not call the GitHub API or use - a mutable `latest` URL. + inconsistent aggregate sizes. (E-M2-CONTRACT-001) +- [x] Define exact archive naming for stable, RC, and perf builds. (E-M2-CONTRACT-001) +- [x] Define exact direct release URL construction. Runtime code must not call the GitHub API or use + a mutable `latest` URL. (E-M2-CONTRACT-001) ### Content identity -- [ ] Compute the content identity from canonical full-runtime metadata and file digests, including +- [x] Compute the content identity from canonical full-runtime metadata and file digests, including Node, relay entries, native modules, helper executables, and runtime JavaScript. -- [ ] Prove changing only Node changes the identity. -- [ ] Prove changing only `node-pty` changes the identity. -- [ ] Prove changing only `@parcel/watcher` changes the identity. -- [ ] Prove changing only `relay-watcher.js` changes the identity. -- [ ] Prove mode changes for executable files change the identity or are otherwise authenticated. -- [ ] Prove metadata that should not affect execution does not create nondeterministic identities. + (E-M2-CONTRACT-001) +- [x] Prove changing only Node changes the identity. (E-M2-CONTRACT-001) +- [x] Prove changing only `node-pty` changes the identity. (E-M2-CONTRACT-001) +- [x] Prove changing only `@parcel/watcher` changes the identity. (E-M2-CONTRACT-001) +- [x] Prove changing only `relay-watcher.js` changes the identity. (E-M2-CONTRACT-001) +- [x] Prove mode changes for executable files change the identity or are otherwise authenticated. + (E-M2-CONTRACT-001) +- [x] Prove metadata that should not affect execution does not create nondeterministic identities. + (E-M2-CONTRACT-001) - [ ] Update remote directory parsing and GC rules for the new mode-qualified identity without breaking legacy directory recognition. @@ -344,6 +532,11 @@ and reviewed compatibility contract. ## Milestone 3 — Build Minimal Self-Contained Runtime Archives +**In progress — 2026-07-14, Codex implementation owner:** isolate target-native runtime assembly, +archive inspection, bundled-Node/native-module/PTY/watcher smoke, SBOM, and provenance capabilities +behind purpose-named scripts and CI artifacts. No release publication, desktop resolver, SSH +transfer, install, fallback, rollout setting, or tuple enablement belongs in this package. + Each runtime must contain only the executable closure required by the relay. - [ ] Replace or extend `config/scripts/build-relay.mjs` without weakening its existing relay and @@ -929,8 +1122,8 @@ Update status and evidence as work begins. Do not combine these into one large b | Work package | Scope | Default behavior change | Status | PR/evidence | | ------------------------- | ------------------------------------------------------------------------------------------ | --------------------------- | --------------------------------------- | ----------------------------------------------------------------------- | | 0. #8450 legacy fix | Coherent Node/npm selection and live repro | Fixes legacy selection only | Complete and CI-green in draft PR #8724 | E-M0-UNIT-002, E-M0-LIVE-002, E-M0-STATIC-002, E-M0-PR-001, E-M0-CI-001 | -| 1. Contract and selectors | Manifest schema, identity, platform/libc selection, hostile inputs | None | Not started | — | -| 2. Runtime builds | Per-tuple assembly, native smoke, SBOM/provenance/signing | None | Not started | — | +| 1. Contract and selectors | Manifest schema, identity, platform/libc selection, hostile inputs | None | Complete and CI-green in draft PR #8728 | `b9d80a4cb`; E-M2-RED-001, E-M2-CONTRACT-001, E-M2-CI-001 | +| 2. Runtime builds | Per-tuple assembly, native smoke, SBOM/provenance/signing | None | In progress | No implementation evidence yet | | 3. Release publication | Prerequisite DAG, embedded manifest, draft upload/read-back gates | Asset-only | Not started | — | | 4. Desktop resolver/cache | Verified download, extraction, cache, offline behavior | None/forced mode only | Not started | — | | 5. Transfer/install | Bounded transports, structured sentinel, bundled launch behind per-target Beta/forced mode | Per-target opt-in only | Not started | — | @@ -979,7 +1172,7 @@ focused commands as their scripts/tests are introduced. ### Commands/scripts that must be added or formally identified -- [ ] Manifest/schema/identity unit-test command. +- [x] Manifest/schema/identity/signature/selector unit-test command. (E-M2-CONTRACT-001) - [ ] Per-tuple runtime assembly command. - [ ] Per-tuple archive inspection and native smoke command. - [ ] Embedded-manifest extraction/comparison command for every packaged app. @@ -1510,23 +1703,486 @@ fragmentLinks=9`. - Follow-up: implement and test the target configuration/UI in its reviewable work package after the Work Package 0 boundary and remaining Milestone 1 blockers are closed. +### E-M1-BASELINE-001 — Conservative initial runtime eligibility baselines + +- Date: 2026-07-14 +- Commit SHA / PR: `a84d52dc6`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; authoritative Node v24.18.0 release/build metadata fetched over + HTTPS +- Remote: no SSH remote; local AlmaLinux 8 arm64 container reported glibc 2.28 and + `libstdc++-8.5.0` +- Transport/network: HTTPS to nodejs.org and raw.githubusercontent.com; local Docker execution only +- Exact command: + + ```sh + curl -fsSL https://nodejs.org/dist/index.json | jq -r '[.[] | select(.version | startswith("v24."))][0] | {version,date,lts,security,files}' + curl -fsSL https://raw.githubusercontent.com/nodejs/node/v24.18.0/BUILDING.md | sed -n '65,190p' + docker run --rm almalinux:8 sh -lc 'uname -m; getconf GNU_LIBC_VERSION; rpm -q libstdc++ glibc' + ``` + +- Result: PASS for a decision record. Node v24.18.0 is LTS and publishes official Linux glibc, + macOS, and Windows archives for x64/arm64. Its supported-platform contract states Linux kernel + 4.18, glibc 2.28, libstdc++ 6.0.25/`GLIBCXX_3.4.25`, macOS 13.5, Windows 10/Server 2016 x64, + and Windows 10 arm64 minimums. Orca adopts those Linux/macOS floors and stricter Windows floors + aligned with the declared SSH bootstrap primitives. +- Duration and resource metrics: network reads under 1 s each; local AlmaLinux probe 0.6 s; no + runtime load or memory metric applies to the policy decision +- Artifact/log/trace link: + - https://nodejs.org/dist/v24.18.0/ + - https://github.com/nodejs/node/blob/v24.18.0/BUILDING.md#supported-platforms +- Oracle proved: the recorded minimums do not claim compatibility below the upstream official Node + binary ABI/OS floors, and musl is excluded because Node publishes no official musl archive. +- Does not prove: any Orca runtime archive, native module, SSH primitive, Rosetta behavior, Windows + host, minimum-kernel VM, or enabled tuple. Every candidate remains disabled pending live evidence. +- Checklist items satisfied: Milestone 1 glibc, libstdc++, kernel, musl, macOS, Windows, and Rosetta + eligibility decisions. +- Follow-up: encode these floors in the manifest/selector contract and fill every target-native live + cell before enabling a tuple. + +### E-M1-NODE-PROVENANCE-001 — Pinned official Node 24 LTS provenance policy + +- Date: 2026-07-14 +- Commit SHA / PR: `a84d52dc6`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native orchestrating an Ubuntu 24.04 arm64 Docker container for GPG + verification +- Remote: not applicable; provenance inputs were verified locally before any extraction or transfer +- Transport/network: HTTPS to nodejs.org and the Node release-key repository; no SSH +- Exact command: + + ```sh + curl -fsSL https://nodejs.org/dist/v24.18.0/SHASUMS256.txt | rg 'node-v24\.18\.0-(linux-(x64|arm64)|darwin-(x64|arm64)|win-(x64|arm64))\.(tar\.xz|zip)$' + docker run --rm ubuntu:24.04 bash -lc 'apt-get update -qq && apt-get install -y -qq ca-certificates curl gpgv >/dev/null && curl -fsSL https://github.com/nodejs/release-keys/raw/refs/heads/main/gpg-only-active-keys/pubring.kbx -o /tmp/nodejs-active.kbx && curl -fsSL https://nodejs.org/dist/v24.18.0/SHASUMS256.txt -o /tmp/SHASUMS256.txt && curl -fsSL https://nodejs.org/dist/v24.18.0/SHASUMS256.txt.sig -o /tmp/SHASUMS256.txt.sig && gpgv --keyring /tmp/nodejs-active.kbx /tmp/SHASUMS256.txt.sig /tmp/SHASUMS256.txt && sha256sum /tmp/SHASUMS256.txt' + ``` + +- Result: PASS; detached signature timestamp `2026-06-23T23:07:59Z` verified with active Node release + key `C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C` (Richard Lau); verified checksum-file SHA-256 + `3927bab574a00ca0560c9583fe19655ba19603a1c5851414e4325d34ac50e469` +- Duration and resource metrics: checksum fetch 0.3 s; clean-container key/signature verification + 11.9 s including package installation; resource usage not instrumented +- Artifact/log/trace link: + - https://nodejs.org/dist/v24.18.0/SHASUMS256.txt + - https://nodejs.org/dist/v24.18.0/SHASUMS256.txt.sig + - https://github.com/nodejs/release-keys +- Oracle proved: the selected six upstream archives exist and their checksum list has a valid + signature from the active Node release keyring. The policy pins the exact patch and requires a + new desktop identity for every refresh. +- Does not prove: that future CI pins rather than mutably fetches the keyring, verifies archives, + preserves native signatures, builds native modules, meets the CVE SLA, or signs Orca artifacts. + Those remain implementation/release gates. +- Checklist items satisfied: Milestone 1 Node version, provenance, musl-source, update/CVE, refresh + identity, and upstream-signature decisions. +- Follow-up: vendor a reviewed keyring/input digest into the build contract, implement fail-closed + verification, and add target-native signing/provenance jobs. + +### E-M1-RUNNER-INVENTORY-001 — Native GitHub-hosted runner catalog + +- Date: 2026-07-14 +- Commit SHA / PR: `a84d52dc6`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; GitHub runner-image catalog plus repository workflow inventory +- Remote: not applicable; no SSH target is claimed +- Transport/network: HTTPS to the GitHub runner-image catalog and GitHub Actions logs +- Exact command: + + ```sh + curl -fsSL https://raw.githubusercontent.com/actions/runner-images/main/README.md | sed -n '/Available Images/,/Beta Images/p' + rg -n 'runs-on:' .github/workflows + gh run view 29325885071 --repo stablyai/orca --job 87061960266 --log + gh run view 29325884997 --repo stablyai/orca --job 87061960091 --log + ``` + +- Result: PASS for inventory. The catalog exposes native x64/arm64 Linux, macOS, and Windows labels + selected above and documents weekly image refresh. PR evidence independently resolved + `ubuntu-latest` to x64 `ubuntu-24.04` image `20260705.232.1` and `macos-15` to arm64 + `macos-15-arm64` image `20260706.0213.1`. +- Duration and resource metrics: catalog fetch 0.2 s; workflow scan 0.1 s; prior log reads under 3 s; + hosted capacity is unreserved and queue-dependent rather than a fixed resource guarantee +- Artifact/log/trace link: + - https://github.com/actions/runner-images#available-images + - E-M0-CI-001 job links +- Oracle proved: explicit native labels exist for the six client/build architectures and new relay + workflows can avoid mutable `latest` aliases while recording exact resolved images. +- Does not prove: repository access to paid capacity at a future run, runner availability, SSH + server reachability, cross-family networking, image stability, or any runtime tuple. No approved + self-hosted target pool was found or inferred. +- Checklist items satisfied: Milestone 1 runner-label/architecture/image-update/capacity inventory. +- Follow-up: pin representative remote images/snapshots, provision cross-family endpoints, and run + a minimal native label smoke before assigning any evidence cell. + +### E-M1-DECISION-DOC-001 — Baseline/provenance plan-content validation + +- Date: 2026-07-14 +- Commit SHA / PR: `a84d52dc6`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0 +- Remote: not applicable; documentation/content validation only +- Transport/network: local files only +- Exact command: purpose-built inline Node validation using `marked` and `parse5`, followed by + `pnpm exec oxfmt --check` for both plan artifacts and `git diff --check` +- Result: PASS; `links=1 fences=2 htmlIds=11 fragmentLinks=9`, no parse/fragment/duplicate-ID, + formatting, or whitespace findings +- Duration and resource metrics: structural validation 42 ms; formatter check 243 ms; resource + usage not instrumented +- Artifact/log/trace link: this checklist and the linked HTML plan +- Oracle proved: both artifacts contain the baseline, Node provenance, musl legacy-only, and explicit + native-runner inventory decisions and remain structurally valid. +- Does not prove: implementation, live compatibility, runner access, SSH transport, signing, or any + enabled tuple. +- Checklist items satisfied: evidence-backed synchronization of the Milestone 1 decision content in + both required plan artifacts. +- Follow-up: continue the remaining Milestone 1 gates and keep contract implementation in stacked + draft PR #8728 with no default behavior change. + +### E-M1-LEGACY-INVENTORY-001 — Current legacy families versus bundled candidates + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Vitest Node environment +- Remote: none; source-declared platform identities and deterministic tests only +- Transport/network: local files only +- Exact command: + `pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/relay-protocol.test.ts src/main/ssh/ssh-remote-platform-detection.test.ts` +- Result: PASS; 35/35 tests across two files, including Linux/macOS/Windows x64/arm64 parsing and + conservative unsupported-platform rejection +- Duration and resource metrics: 253 ms; memory, channels, and files not instrumented +- Artifact/log/trace link: local Vitest stdout; current `RelayPlatform` source and the living table +- Oracle proved: current source has six deterministic legacy platform identities and rejects unknown + OS/architectures; the plan now separates those identities from libc-aware bundled candidates and + from live fallback claims. +- Does not prove: a live SSH connection, native npm install, PTY/watcher health, libc compatibility, + or fallback on any family. E-M0-LIVE-002 remains the only live legacy evidence in this project. +- Checklist items satisfied: Milestone 1 legacy-versus-bundled family inventory. +- Follow-up: fill current legacy E2E cells before allowing automatic fallback for each corresponding + bundled tuple. + +### E-M1-BOOTSTRAP-DECISION-001 — No-Node POSIX and bounded Windows primitive contract + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Docker Desktop native arm64 Linux containers +- Remote: local Ubuntu 24.04, Debian 12/bookworm-slim, and AlmaLinux 8 arm64 containers; no SSH + daemon, Windows host, or BusyBox image +- Transport/network: Docker stdin/stdout only; the fixture exercised the proposed raw per-file + stream but not an authenticated SSH channel +- Exact command: inline Node harness spawned each image with `docker run --rm -i ... sh -c`, sent a + ten-byte fixture containing NUL/CR/LF/high-bit bytes, and required exact output after + `mkdir`/`cat`/`chmod`/`mv`/`test`/`nohup`/`rm` +- Result: PASS; exact ten-byte round trip on Ubuntu 24.04, Debian bookworm-slim, and AlmaLinux 8 +- Duration and resource metrics: Ubuntu 4,384 ms, Debian 569 ms, AlmaLinux 506 ms including container + startup; one stdin/stdout stream; peak memory and open-file counts not instrumented +- Artifact/log/trace link: local harness stdout recorded in the implementation session +- Oracle proved: the declared POSIX command subset can preserve the binary fixture without Node, + Python, Perl, tar, base64, or checksum tools on three glibc userlands. +- Does not prove: SSH framing, cancellation, short/extra input rejection, concurrency, + `MaxSessions=1`, BusyBox, Windows PowerShell/.NET, exclusive lock races, full-size transfer, or + complete-tree verification. +- Checklist items satisfied: Milestone 1 POSIX/Windows primitive, per-connection probe, and + compatibility-failure decisions only. Windows is a specified contract, not executable evidence. +- Follow-up: turn this into purpose-named unit/live tests, add BusyBox and bounded Windows binary + transfer, and do not enable any tuple from this local semantic probe. + +### E-M1-BUSYBOX-001 — BusyBox-only primitive baseline fails closed + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Ubuntu 24.04 arm64 Docker container +- Remote: BusyBox v1.36.1, Ubuntu package `1:1.36.1-6ubuntu3.1`; no SSH daemon +- Transport/network: Docker stdin and Ubuntu package mirror; no SSH +- Exact command: install BusyBox, enumerate applets, install its applet symlinks into an isolated + `PATH`, then run the declared `sh`/`cat`/`mkdir`/`rm`/`mv`/`chmod`/`test`/`nohup` semantic probe +- Result: PASS for fail-conservative classification; the probe exited 127 because this BusyBox build + provides all declared applets except `nohup`, so it is ineligible and must select legacy +- Duration and resource metrics: 4.6 s including package installation; one container/process stream; + peak memory not instrumented +- Artifact/log/trace link: local stdout recorded + `BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) multi-call binary`; applet inventory omitted `nohup` +- Oracle proved: the selector cannot infer compatibility from “BusyBox 1.36.1” alone and the exact + declared primitive probe rejects this variant rather than attempting a partial bundled install. +- Does not prove: Alpine's BusyBox build, BusyBox plus an external `nohup`, SSH behavior, or any + passing BusyBox tuple. No BusyBox bundled support is claimed. +- Checklist items satisfied: Milestone 1 initial BusyBox variant record and deterministic disable + rule. +- Follow-up: add this missing-`nohup` case to the committed selector/primitive suite and retain + legacy unless a complete BusyBox environment passes live SSH evidence. + +### E-M1-BUDGET-DECISION-001 — Fail-closed resource, timeout, and rollout budgets + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: documentation decision in the macOS 26.2 arm64 native worktree +- Remote: not applicable; measured legacy and bundled baselines remain open +- Transport/network: not applicable; the decision defines later 1/10/100 Mbps and 50/100/200 ms + shaped tests +- Exact command: plan/checklist content validation plus arithmetic review of every byte/count/time + ceiling; no runtime benchmark is represented by this entry +- Result: PASS for recording release-blocking upper bounds and paired comparison rules; no + performance gate is claimed passed +- Duration and resource metrics: decision/content validation only; runtime metrics intentionally + absent until the baseline harness is implemented +- Artifact/log/trace link: “Operational budgets and rollout policy” in this checklist and “Hard + resource and latency budgets” in the HTML plan +- Oracle proved: implementation now has explicit rejection, cache, memory, concurrency, timeout, + cancellation, warm/cold latency, fallback-delay, and legacy-retention thresholds to test against. +- Does not prove: that the limits fit the final full-size archive, that either implementation stays + below them, hosted-runner stability, or any baseline/regression result. Those boxes remain open. +- Checklist items satisfied: Milestone 1 cache/archive/time/memory/channel/latency/fallback-duration, + paired-runner-method, and offline/missing-asset policy decisions. +- Follow-up: measure legacy first, instrument file/channel/memory/cancellation metrics, and revise + both plan files before implementation only if evidence proves a limit infeasible. + +### E-M1-TRUST-DECISION-001 — Manifest and native-signing trust policy + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native for local Ed25519 probe; historical native Windows Server 2022 x64 + GitHub-hosted signing rehearsal jobs +- Remote: not applicable; no SSH transfer or target-native execution of a relay artifact +- Transport/network: local `tweetnacl` sign/verify plus GitHub Actions/SignPath artifact round trips +- Exact command: + + ```sh + rg -n 'tweetnacl|CSC_LINK|APPLE_ID|SIGNPATH|SignPath|Get-AuthenticodeSignature' package.json .github/workflows config + node --input-type=module + gh run view 28987534795 --repo stablyai/orca --json status,conclusion,createdAt,updatedAt,url,jobs + gh run view 28988432001 --repo stablyai/orca --json status,conclusion,createdAt,updatedAt,url,jobs + ``` + +- Result: PASS for technical feasibility and existing Windows signing evidence. The local probe made + and verified a 64-byte Ed25519 signature with `tweetnacl`, derived the SHA-256 public-key ID, and + rejected a changed payload. SignPath test-policy run `28987534795` and release-policy run + `28988432001` both signed/restored inner PE files, rebuilt/signed the installer, and passed + end-to-end signature verification. +- Duration and resource metrics: local Ed25519 probe under 0.1 s; test SignPath job 10m7s; production + SignPath rehearsal 17m24s; signing memory not instrumented +- Artifact/log/trace link: + - https://github.com/stablyai/orca/actions/runs/28987534795/job/86019904578 + - https://github.com/stablyai/orca/actions/runs/28988432001/job/86022677749 +- Oracle proved: the chosen signature primitive works through an already packaged dependency, and + the repository's existing Windows release machinery can return signed inner native bytes before + final packaging/hashing. +- Does not prove: canonical serializer implementation, protected manifest environment/secret, + dual-key rotation, revocation, action-SHA pinning, artifact attestations, macOS relay-native + signing/notarization, key custody, WDAC/Gatekeeper/AV, or any signed relay runtime. Those remain + release-blocking implementation/operational gates. +- Checklist items satisfied: Milestone 1 algorithm, canonical-byte, key-custody policy, rotation, + anti-rollback, keyless-provenance, and native macOS/Windows signing decisions only. +- Follow-up: implement hostile-input/canonical/signature tests without real secrets, then provision + and rehearse the protected environment before any release workflow can sign a manifest. + +### E-M1-ENDPOINT-DECISION-001 — Native trust validation environment contract + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: documentation decision in the macOS 26.2 arm64 native worktree +- Remote: no approved macOS 13.5, Windows Server 2022/Windows 11 arm64, or WDAC-enforced snapshot was + executed +- Transport/network: not applicable +- Exact command: repository signing-workflow inspection plus synchronized checklist/HTML content + validation; no endpoint-protection command is represented by this decision entry +- Result: PASS for defining exact target-native environments and fail-closed outcomes; all execution + gates remain open +- Duration and resource metrics: decision/content validation only +- Artifact/log/trace link: Milestone 1 trust section and HTML “OS-native trust controls” risk +- Oracle proved: future Gatekeeper/Defender/WDAC evidence has concrete OS/architecture, quarantine, + policy mode, identity logging, and release-blocking requirements. +- Does not prove: snapshot/provider availability, policy ownership, actual signature acceptance, + antivirus behavior, native loading, PTY/watcher smoke, or any endpoint-protection pass. +- Checklist items satisfied: Milestone 1 endpoint-protection environment decision only. +- Follow-up: provision or approve the snapshot pool and keep every affected tuple disabled until the + target-native runs pass. + +### E-M1-DECISION-DOC-002 — Remaining Milestone 1 plan-content validation + +- Date: 2026-07-14 +- Commit SHA / PR: `5c37e8efe`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0 +- Remote: not applicable; documentation/content validation only +- Transport/network: local files only +- Exact command: purpose-built inline Node validation using `marked` and `parse5`, required-content + assertions for all newly recorded Milestone 1 decisions, `pnpm exec oxfmt --check` on both + artifacts, and `git diff --check` +- Result: PASS; `links=1 fences=2 htmlIds=11 fragmentLinks=9`, no + parse/fragment/duplicate-ID/content/format/whitespace findings +- Duration and resource metrics: structural validation 42 ms; formatter check 253 ms; resource + usage not instrumented +- Artifact/log/trace link: this checklist and the linked HTML plan +- Oracle proved: both artifacts contain synchronized legacy-family, bootstrap, BusyBox, + offline/fallback, numeric-budget, manifest-trust, native-signing, and endpoint-environment decisions + and remain structurally valid. +- Does not prove: implementation, measured budget compliance, key provisioning, native trust, live + transfer, or any enabled tuple. +- Checklist items satisfied: evidence-backed synchronization of the remaining safely decidable + Milestone 1 policy content. +- Follow-up: keep unresolved remote-pool and measured-baseline gates open while contract-only work + proceeds without secrets or a runtime behavior switch. + +### E-M2-RED-001 — Missing contract modules and canonical-vector red baseline + +- Date: 2026-07-14 +- Commit SHA / PR: `959bc05caca7e5ccb4c69b47c1bf6f5b47671c57` plus uncommitted Work + Package 1 tests; stacked draft PR [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0 +- Remote: not applicable; pure local contract tests +- Transport/network: local Vitest only; no SSH or GitHub request +- Exact command: + + ```sh + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/ssh/ssh-relay-artifact-schema.test.ts \ + src/main/ssh/ssh-relay-runtime-identity.test.ts \ + src/main/ssh/ssh-relay-manifest-signature.test.ts \ + src/main/ssh/ssh-relay-release-asset.test.ts \ + src/main/ssh/ssh-relay-artifact-selector.test.ts + ``` + +- Result: FAIL as the intended executable red baseline: signature and selector suites failed to + import because their production modules did not exist; the identity vector still contained its + explicit replacement marker; and one native-package fixture reached the undeclared-parent guard + before the intended native-package guard. Four files failed, one passed, with 35 tests executed + (33 passed and 2 failed) plus two import-failed suites. +- Duration and resource metrics: 292 ms Vitest duration; no runtime-transfer resources apply. +- Artifact/log/trace link: local command output retained in the implementation session; test paths + above are the durable repro. +- Oracle proved: the new tests were discriminating before the missing signature/selector modules and + canonical vector were implemented, and the native-package fixture needed to preserve tree + consistency to reach its intended oracle. +- Does not prove: behavior before all schema/identity files existed, live SSH, a real archive, + cryptographic key custody, target-native execution, or any production-path failure. +- Checklist items satisfied: red half of the Work Package 1 contract-test gate only. +- Follow-up: implemented and superseded for green behavior by E-M2-CONTRACT-001; retained as red + history. + +### E-M2-CONTRACT-001 — Manifest, identity, signature, URL, and selector contracts + +- Date: 2026-07-14 +- Commit SHA / PR: `b9d80a4cbca43c344a3a5fbdc669fa97c7527da0`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728); PR CI evidence pending +- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0 (repository requests Node 24) +- Remote: not applicable; pure local schema, cryptography, identity, release-name, and selection tests +- Transport/network: local Vitest/static tools only; no SSH, release download, or GitHub API call +- Exact command: + + ```sh + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/ssh/ssh-relay-artifact-schema.test.ts \ + src/main/ssh/ssh-relay-runtime-identity.test.ts \ + src/main/ssh/ssh-relay-manifest-signature.test.ts \ + src/main/ssh/ssh-relay-release-asset.test.ts \ + src/main/ssh/ssh-relay-artifact-selector.test.ts + pnpm run typecheck + pnpm run lint + pnpm run check:max-lines-ratchet + pnpm exec oxfmt --check \ + src/main/ssh/ssh-relay-artifact-consistency.ts \ + src/main/ssh/ssh-relay-artifact-path-policy.ts \ + src/main/ssh/ssh-relay-artifact-schema.ts \ + src/main/ssh/ssh-relay-artifact-selector.ts \ + src/main/ssh/ssh-relay-manifest-signature.ts \ + src/main/ssh/ssh-relay-release-asset.ts \ + src/main/ssh/ssh-relay-runtime-identity.ts \ + src/main/ssh/ssh-relay-artifact-schema.test.ts \ + src/main/ssh/ssh-relay-artifact-selector.test.ts \ + src/main/ssh/ssh-relay-manifest-signature.test.ts \ + src/main/ssh/ssh-relay-release-asset.test.ts \ + src/main/ssh/ssh-relay-runtime-identity.test.ts \ + src/main/ssh/ssh-relay-artifact-test-manifest.ts \ + docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md + git diff --cached --check + ``` + +- Result: PASS locally. Five suites passed with 68/68 tests; Node/CLI/web typecheck passed; the full + repository lint/reliability/localization chain exited zero with pre-existing warnings only; the + 355-entry max-lines ratchet reported no new bypass; touched-file formatting and tracked diff checks + passed. +- Duration and resource metrics: final Vitest duration 540 ms (1.33 s wall); typecheck 3.91 s; full + lint 23.62 s; formatting check 874 ms; max-lines and diff checks under 1 s. Runtime memory, + channels, handles, files, and cancellation do not apply to this disconnected pure contract layer + and were not instrumented. +- Artifact/log/trace link: local command output; canonical runtime identity vector + `sha256:5afe9c8094ec61a5eec6f7be6d1035faacee7362871985c74cc6ee6aceea8677`; canonical + unsigned-manifest byte hash `e78bf4416628a91055035dc7926035cbf633f29d3618be34e041c6dc5e0794fb`; + draft PR #8728 +- Oracle proved: strict versioned parsing; bounded sizes/counts; portable path and special-entry + rejection; required runtime closure and executable Node mode; tuple/archive/attestation + cross-consistency; stable content identity; exact stable/RC/perf tag and direct-URL rules; initial + and dual Ed25519 signing with canonical unsigned bytes; unknown/malformed/duplicate/mismatched + key failure; conservative Linux glibc/musl, macOS, Windows-bootstrap, translated-process, unknown, + old, unavailable, and ambiguous selection. +- Does not prove: Node 24 execution, real archive parsing/extraction, local cache/download, target + probes, SSH/SFTP/system-SSH transfer, runtime build/native loading, signing environment custody, + release publication, fallback state, UI rollout, performance, or any live matrix cell. No module + is connected to a production call site and no tuple is enabled. +- Checklist items satisfied: Milestone 2 manifest-schema, signed-content, content-identity, + release-name/URL, schema-level hostile path/type, and pure selector contract items explicitly + marked above; verification command inventory entry. +- Follow-up: push and record the exact GitHub Actions result, then begin Work Package 2 target-native + runtime assembly without production consumption. + +### E-M2-CI-001 — Work Package 1 full PR verification and packaging gate + +- Date: 2026-07-14 +- Commit SHA / PR: exact checked head `6732d911a8e81ad071925e68d3a60fec2e3e452a`, containing + implementation commit `b9d80a4cbca43c344a3a5fbdc669fa97c7527da0`; stacked draft PR + [#8728](https://github.com/stablyai/orca/pull/8728) +- Runner: GitHub-hosted native x64 `ubuntu-latest`, resolved to Ubuntu 24.04 image + `20260705.232.1`; image provisioner `20260624.560`; runner `2.335.1`; Node v24.18.0 +- Remote: not applicable; the workflow provisioned no SSH server and exercised no remote tuple +- Transport/network: GitHub Actions checkout/package traffic only; no SSH/SFTP/system-SSH runtime + transfer +- Exact command: + + ```sh + gh run watch 29329963564 --repo stablyai/orca --exit-status + gh pr view 8728 --repo stablyai/orca \ + --json url,isDraft,state,headRefOid,baseRefOid,mergeStateStatus,statusCheckRollup + gh api repos/stablyai/orca/actions/jobs/87075275741 + gh run view 29329963564 --repo stablyai/orca --job 87075275741 --log + ``` + +- Result: PASS. The `verify` job completed successfully on the exact PR head; repository lint, + reliability gates, max-lines, typecheck, Git 2.25 compatibility, 2,803 passing test files with + 29,695 passing tests, unpacked Linux app packaging, packaged daemon load, and packaged CLI smoke + all passed. Nine test files and 55 tests were intentionally skipped by the existing suite. +- Duration and resource metrics: 11m52s from `2026-07-14T11:44:44Z` to + `2026-07-14T11:56:36Z`; the workflow did not report peak memory, channel, handle, or file counts. +- Artifact/log/trace link: + https://github.com/stablyai/orca/actions/runs/29329963564/job/87075275741 +- Oracle proved: Work Package 1 compiles and passes the repository's full PR regression/package gate + under the pinned Node 24 runtime on a native Linux x64 runner; PR #8728 is draft, open, and reports + a clean merge state at the checked head. +- Does not prove: live SSH, Linux arm64, macOS, Windows, musl, archive assembly/extraction, native + runtime execution, signing, release assets, download/cache, transfer/install, fallback, UI, + performance budgets, or any enabled tuple. The existing relay build exercised by packaging remains + the legacy JavaScript build, not a self-contained runtime artifact. +- Checklist items satisfied: Work Package 1 exact-head PR CI gate only; no live matrix or runtime + tuple cell. +- Follow-up: keep PR #8728 draft and isolated; begin Work Package 2 on a new stacked branch with no + production consumer. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit owner and promotion condition. -| Gap | Current behavior | Risk | Owner | Promotion/removal condition | Status | -| ---------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------- | ------ | -| Bundled runtime not implemented | Remote Node/npm installer | #8450/#1693 failure classes remain | Unassigned | Milestones 1–14 | Open | -| Bundled support matrix undeclared | Legacy support only where proven | Cannot claim tuple coverage | Unassigned | Baselines and real runners decided | Open | -| musl Node provenance undecided | Legacy eligibility only | Untrusted/incompatible binary risk | Unassigned | Reviewed runtime source/build | Open | -| Windows arm64 real runner undecided | Legacy eligibility only | Cross-build may hide runtime failure | Unassigned | Native runner and full proof | Open | -| Linux arm64 real runner undecided | Existing Docker evidence is not full runtime matrix | Native regression may escape | Unassigned | Native full-matrix proof | Open | -| No numeric perf budgets yet | Current legacy behavior | “No regression” is unmeasurable | Unassigned | Baselines and thresholds recorded | Open | -| Signing/key lifecycle undecided | GitHub app-release trust only | Relay-specific supply-chain gap | Unassigned | Milestone 1 trust decisions | Open | -| Remote bootstrap primitive baselines undecided | Existing transport-specific shell assumptions | Hidden tool dependency or corrupt transfer | Unassigned | Milestone 1 POSIX/Windows decisions and live tests | Open | -| WP0 commit/PR boundary absent | Uncommitted, independently scoped worktree diff | Starting WP1 now would mix review packages | User authorization required | Separately authorized WP0 commit/PR or equivalent reviewed boundary | Open | -| Repository-wide lint failure outside WP0 | Touched-file oxlint passes; full lint stops on untouched renderer exhaustiveness error | Required handoff gate cannot be claimed green | Existing renderer owner unassigned | Fix or explicitly baseline E-M0-LINT-001, then rerun `pnpm run lint` | Open | +| Gap | Current behavior | Risk | Owner | Promotion/removal condition | Status | +| ------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------ | +| Bundled runtime not implemented | Proven legacy path only; WP1 contracts have no production consumer | #8450/#1693 environment failures remain | Codex implementation owner | Work Packages 2–7 plus Milestones 3–14 | Open | +| No bundled tuple enabled | Every target's default and effective mode remains legacy | No bundled support claim can be made | Codex implementation owner | Complete target-native build/trust and both required live-evidence layers | Open | +| Cross-family Layer B remotes unavailable | GitHub native runner labels exist; no approved reachable target pool | Client/remote integration gaps may escape | Repository release administrator + implementation owner | Approve provider/snapshots/credentials/egress/teardown/cost owner | BLOCKED | +| Musl has no accepted official Node binary | Musl is deliberately legacy-only | Unofficial binary would break provenance trust | Codex implementation owner | Orca-owned target-native source build, signing, provenance, and live gates | ACCEPTED GAP | +| Native arm64 live matrices incomplete | Hosted Linux/Windows arm64 labels exist; full SSH/runtime cells do not | Cross-build or unit tests may hide native bugs | Codex implementation owner | Full native archive, trust, SFTP/system-SSH, RPC, and baseline evidence | Open | +| Legacy performance baseline unmeasured | Numeric budgets exist; paired cold/warm measurements do not | Regression thresholds lack a measured baseline | Codex implementation owner | Purpose-built paired harness with ten samples on pinned runner classes | Open | +| Manifest signing environment unprovisioned | Ed25519/key-rotation policy exists; no protected runtime signing secret | Runtime assets cannot be safely published | Repository release administrator | Protected environment, reviewers, two test keys, rehearsals, and access audit | BLOCKED | +| Bootstrap primitives lack full live proof | POSIX/Windows contracts exist; bounded SSH implementations do not | Hidden dependency or transfer corruption | Codex implementation owner | Purpose-named full-size SFTP/POSIX/Windows system-SSH live suites | Open | ## Final Definition of Done @@ -1567,8 +2223,9 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Place the implementation/live-proven Milestone 0 diff behind its separately authorized commit/PR -boundary and resolve or explicitly baseline E-M0-LINT-001. Then assign owners and close Milestone -1’s support baseline, remote bootstrap primitives, Node provenance/refresh policy, signing -lifecycle, transfer/resource budgets, and rollout-policy decisions. Do not start a production -default-path change before those decisions are reflected here. +Create the isolated stacked Work Package 2 branch/PR and implement target-native runtime assembly, +archive inspection, and executable bundled-Node/native-module/PTY/watcher smoke as artifact-only +capabilities. The cross-family Layer B target pool, protected manifest-signing environment, and +paired legacy performance baseline remain release/default-path blockers; no publication, desktop +resolver, SSH transfer/install, per-target Beta, fallback, or default behavior may be connected by +this package. diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html index 86ac88d7063..64201e24438 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html @@ -1070,10 +1070,10 @@

Build for the runtime, architecture, and libc together.

linux-arm64-glibcDecision-gated candidate
- linux-x64-muslGate on runtime source + linux-x64-muslLegacy-only; Orca build required
- linux-arm64-muslGate on real runner + linux-arm64-muslLegacy-only; Orca build required
@@ -1086,10 +1086,24 @@

Build for the runtime, architecture, and libc together.

win32-x64Decision-gated candidate
-
win32-arm64Gate on real runner
+
+ win32-arm64Decision-gated candidate +
+
+ Legacy families are not bundled evidence +

+ Current source recognizes Linux, macOS, and Windows remotes on x64 and arm64 and uses + coherent remote Node/npm plus the remote native-install path. Linux legacy identity + does not distinguish glibc from musl. The bundled selector introduces that distinction + and starts with musl disabled. A source-declared family or passing unit test is not a + fallback claim: this project currently has live legacy evidence only for Ubuntu 24.04 + arm64 over built-in SSH2/SFTP, so every other fallback cell remains open. +

+
+
Promotion rule

@@ -1101,11 +1115,31 @@

Build for the runtime, architecture, and libc together.

+
+ Initial immutable runtime baseline +

+ The first contract pins unchanged official Node v24.18.0 LTS binaries. Linux glibc + candidates require kernel 4.18, glibc 2.28, and libstdc++ 6.0.25 with + GLIBCXX_3.4.25; macOS candidates require 13.5. Initial Windows x64 + candidates require Windows 10 22H2 build 19045 or Server 2022 build 20348, and arm64 + requires Windows 11 24H2 build 26100. Windows bootstrap additionally requires OpenSSH + for Windows 8.1p1, Windows PowerShell 5.1, and .NET Framework 4.8. +

+

+ Node's detached checksum signature is verified against a build-input-pinned official + release keyring before extraction. Musl has no accepted official binary for this + package, so musl remains legacy-only until Orca owns and proves a target-native source + build. Rosetta-translated shells remain legacy until the x64 artifact passes a live + Rosetta SSH cell. These are selector eligibility constraints, not tuple enablement; + every candidate still needs the complete two-layer evidence. +

+
+
Archive contents
    -
  • Bundled Node executable pinned to one version.
  • +
  • Unmodified official Node v24.18.0 executable pinned by source archive hash.
  • relay.js and crash-isolated relay-watcher.js.
  • Orca-patched node-pty@1.1.0 built for the bundled runtime.
  • @@ -1133,12 +1167,21 @@

    Build for the runtime, architecture, and libc together.

    are uploaded.
  • - A protected release environment signs canonical, versioned manifest bytes with - narrowly scoped key access. + Ed25519 signs a fixed-order, schema-validated UTF-8 JSON projection through the + existing tweetnacl dependency. Tuple and file arrays are sorted, + identity fields are portable ASCII, numbers are safe integers, and signatures + remain outside the signed projection. +
  • +
  • + An offline-generated 32-byte seed exists only in a tag-restricted + relay-runtime-manifest-signing GitHub Environment with required + reviewers. Only the aggregate signing job receives it; runtime publication stays + blocked until that environment and its access audit exist.
  • - Define key IDs, dual-sign rotation, revocation response, and exact-tag - anti-rollback before implementation. + Key IDs are SHA-256 of the raw 32-byte public key. Rotation dual-signs two desktop + releases and at least 30 days; revocation and emergency replacement use a new + desktop build. Unknown, duplicate, or malformed signature entries fail closed.
  • Every Node/CVE/runtime/key change ships as a new desktop tag/build with a newly @@ -1149,6 +1192,15 @@

    Build for the runtime, architecture, and libc together.

    There is no mutable freshness channel or runtime-only latest lookup; emergency refresh and key response use the normal desktop update path.
  • +
  • + The embedded manifest tag/channel/version must equal the compiled desktop + identity. A newer desktop rejects an older manifest; an explicit desktop downgrade + uses only its own embedded manifest and isolated cache identity. +
  • +
  • + GitHub keyless artifact attestations supplement build provenance but never replace + the offline-verifiable embedded Ed25519 signature. +
  • Archive identity includes every executable and native artifact.
  • Downloads use /releases/download/<exact-tag>/<asset>, @@ -1159,8 +1211,11 @@

    Build for the runtime, architecture, and libc together.

    transfer.
  • - Preserve official Node signatures where possible and sign Orca-built macOS and - Windows native code. + Preserve valid official Node signatures. Before final hashes, sign every + Orca-built macOS executable, .node, dylib, and + spawn-helper with the existing Developer ID identity, and send every + unsigned Windows .exe, DLL, and .node through the + existing SignPath inner-binary flow.
  • Native build/signing jobs verify platform-native trust on native runners and put @@ -1199,29 +1254,61 @@

    Build for the runtime, architecture, and libc together.

  • Cover BusyBox, Alpine, old glibc, containers, Rosetta, startup noise, and - ambiguous output in selector and live tests. + ambiguous output in selector and live tests. No BusyBox-only variant is initially + qualified: Ubuntu's BusyBox 1.36.1 lacks nohup and selects legacy.
  • - Define the exact POSIX baseline: POSIX sh, byte-preserving - stdin-to-file via cat or a proven equivalent, and required + The POSIX baseline is POSIX sh with builtin + command/printf, byte-preserving stdin-to-file via + cat, and required mkdir/rm/mv/chmod/test/nohup - semantics. + semantics. It excludes Node, Python, Perl, tar, base64, checksum tools, and + compilers.
  • - Define the exact Windows OpenSSH PowerShell/.NET versions and binary - file/rename/permission/process primitives; missing primitives are an explicit - compatibility result. + Windows requires OpenSSH 8.1p1, Windows PowerShell 5.1, and .NET Framework 4.8. A + bounded 64 KiB FileStream loop writes raw stdin under an exclusive + FileMode.CreateNew staging lock; it rejects early EOF and excess + bytes and uses same-volume publish.
  • - The no-tar path needs no Node, Python, or archive program: Orca frames manifest - files locally and streams each file over SSH into exclusive staging. + Both families run a nonce-bound binary-fidelity semantic probe once per + authenticated connection. Missing or incorrect primitives are compatibility + failures; probe results never survive reconnect or cross host boundaries. +
  • +
  • + The no-tar path opens one bounded stdin byte stream per manifest file and sends + exactly the declared size into exclusive staging. It becomes sequential under + MaxSessions=1; transferred bundled Node later hashes the complete + tree.
+ +
+ Hard resource and latency budgets +

+ The local verified cache is capped at 2 GiB. Archives are rejected above 100 MiB + compressed, 350 MiB expanded, 5,000 entries, or 250 MiB for one file. Incremental + transfer/extraction memory is capped at 64 MiB on the desktop and 32 MiB remotely; + buffers stay at or below 1 MiB. SFTP starts at four files/channels and never exceeds + eight open files, dropping to one for MaxSessions=1. +

+

+ Cold bootstrap is capped at 30 minutes with named sub-budgets, and cancellation must + join within 10 seconds. Warm p95 may be at most 100 ms or 10% slower than paired + legacy, whichever allowance is larger. Cold p95 may not exceed the smaller of 30 + minutes or 115% of measured bytes/throughput time plus five minutes. Offline failures + classify within 20 seconds and eligible legacy starts within 10 seconds after + classification and join. Native GitHub jobs compare at least ten paired samples under + the same network shaping; inconclusive hosted variance requires a dedicated runner + rather than a weaker threshold. +

+
@@ -1266,6 +1353,22 @@

Fallback is part of the design, not an afterthought.

Upload and validate Remote may remain fully offline + + Client offline or asset absent, with no verified local cache + + Use proven legacy in per-target Beta auto; forced bundled fails at + the exact availability stage + + No GitHub request is ever delegated to the SSH host + + + Bundled availability failure and legacy prerequisites also fail + + Report both stage codes with retry, update, and disable-Beta guidance; preserve + the original bundled error + + No false fallback-success claim or hidden retry loop + Detected local or remote cache corruption @@ -1552,6 +1655,15 @@

Prove production behavior, failure behavior, and rollback behavior.

runner class or a dedicated runner when hosted-runner variance would make the agreed threshold inconclusive.

+

+ Qualifying jobs pin explicit native labels: ubuntu-24.04 and + ubuntu-24.04-arm, macos-15-intel and macos-15, + plus windows-2022 and windows-11-arm. GitHub refreshes these + images weekly, so each job logs the resolved image version; *-latest and + third-party runner labels do not qualify a tuple merely because another repository + workflow uses them. Hosted capacity is unreserved, and no approved self-hosted SSH + target pool or cross-family endpoint is assumed. +

Release gates

@@ -1709,8 +1821,11 @@

Resolve these before default-on.

OS-native trust controls

- Preserve official Node signatures where possible, sign Orca-built native code, and - test macOS Gatekeeper plus Windows Authenticode, WDAC, and antivirus behavior. + Test quarantined bytes on clean macOS 13.5 x64/arm64 snapshots with Gatekeeper and + strict codesign. Test Server 2022 x64 and Windows 11 24H2 arm64 with current + Defender intelligence, then Windows 11 24H2 x64 with the release WDAC policy in + audit and enforced modes. Record snapshot, policy, and security-intelligence IDs; + any denial blocks release rather than weakening the endpoint control.

diff --git a/src/main/ssh/ssh-relay-artifact-consistency.ts b/src/main/ssh/ssh-relay-artifact-consistency.ts new file mode 100644 index 00000000000..4272beef7f1 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-consistency.ts @@ -0,0 +1,168 @@ +import type { SshRelayRuntimeTuple } from './ssh-relay-artifact-schema' +import { + assertSafeSshRelayArtifactPath, + foldSshRelayArtifactPath +} from './ssh-relay-artifact-path-policy' +import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset' +import { computeSshRelayRuntimeContentId, type SshRelayDigest } from './ssh-relay-runtime-identity' + +function expectedTupleId(tuple: SshRelayRuntimeTuple): string { + if (tuple.os === 'linux' && tuple.compatibility.kind === 'linux') { + return `linux-${tuple.architecture}-${tuple.compatibility.libc.family}` + } + return `${tuple.os}-${tuple.architecture}` +} + +function assertRequiredRuntimeEntries(tuple: SshRelayRuntimeTuple): void { + const files = tuple.entries.filter((entry) => entry.type === 'file') + const expectedPaths = new Map([ + ['node', tuple.os === 'win32' ? 'node.exe' : 'bin/node'], + ['relay', 'relay.js'], + ['relay-watcher', 'relay-watcher.js'] + ]) + for (const role of [ + 'node', + 'relay', + 'relay-watcher', + 'node-pty-native', + 'parcel-watcher-native' + ]) { + const matching = files.filter((file) => file.role === role) + if (matching.length !== 1) { + throw new Error(`Runtime tuple ${tuple.tupleId} requires exactly one ${role} entry`) + } + const expectedPath = expectedPaths.get(role) + if (expectedPath && matching[0].path !== expectedPath) { + throw new Error(`Runtime tuple ${tuple.tupleId} has invalid ${role} path`) + } + } + for (const requiredPath of [ + 'node_modules/node-pty/package.json', + 'node_modules/@parcel/watcher/package.json' + ]) { + if (!files.some((file) => file.path === requiredPath)) { + throw new Error(`Runtime tuple ${tuple.tupleId} is missing ${requiredPath}`) + } + } + if (!files.some((file) => file.role === 'license')) { + throw new Error(`Runtime tuple ${tuple.tupleId} requires bundled license metadata`) + } + const node = files.find((file) => file.role === 'node') + if (node?.mode !== 0o755) { + throw new Error(`Runtime tuple ${tuple.tupleId} requires executable bundled Node mode`) + } +} + +function assertEntryTree(tuple: SshRelayRuntimeTuple): void { + const paths = new Set() + const foldedPaths = new Set() + const directories = new Set( + tuple.entries.filter((entry) => entry.type === 'directory').map((entry) => entry.path) + ) + for (const entry of tuple.entries) { + assertSafeSshRelayArtifactPath(entry.path) + const folded = foldSshRelayArtifactPath(entry.path) + if (paths.has(entry.path) || foldedPaths.has(folded)) { + throw new Error(`Runtime tuple ${tuple.tupleId} has colliding path: ${entry.path}`) + } + paths.add(entry.path) + foldedPaths.add(folded) + const separator = entry.path.lastIndexOf('/') + if (separator > 0 && !directories.has(entry.path.slice(0, separator))) { + throw new Error(`Runtime tuple ${tuple.tupleId} has undeclared parent for ${entry.path}`) + } + } +} + +function expectedWatcherPackage(tuple: SshRelayRuntimeTuple): string { + if (tuple.os === 'linux' && tuple.compatibility.kind === 'linux') { + return `node_modules/@parcel/watcher-linux-${tuple.architecture}-${tuple.compatibility.libc.family}` + } + return `node_modules/@parcel/watcher-${tuple.os}-${tuple.architecture}` +} + +function assertNativePackages(tuple: SshRelayRuntimeTuple): void { + const watcherPackage = expectedWatcherPackage(tuple) + for (const entry of tuple.entries) { + if ( + entry.path.startsWith('node_modules/@parcel/watcher-') && + entry.path !== watcherPackage && + !entry.path.startsWith(`${watcherPackage}/`) + ) { + throw new Error(`Runtime tuple ${tuple.tupleId} contains an extra native watcher package`) + } + } + const expectedPolicy = + tuple.os === 'linux' + ? 'linux-hash-only-v1' + : tuple.os === 'darwin' + ? 'apple-developer-id-v1' + : 'signpath-authenticode-v1' + if (tuple.nativeVerification.policy !== expectedPolicy) { + throw new Error(`Runtime tuple ${tuple.tupleId} has the wrong native verification policy`) + } +} + +function assertNativeAttestation(tuple: SshRelayRuntimeTuple): void { + const attestations = new Map() + for (const attestation of tuple.nativeVerification.files) { + if (attestations.has(attestation.path)) { + throw new Error(`Runtime tuple ${tuple.tupleId} has duplicate native attestation path`) + } + attestations.set(attestation.path, attestation.sha256) + } + const requiredRoles = new Set([ + 'node', + 'node-pty-native', + 'parcel-watcher-native', + 'native-runtime' + ]) + for (const entry of tuple.entries) { + if (entry.type !== 'file' || !requiredRoles.has(entry.role)) { + continue + } + if (attestations.get(entry.path) !== entry.sha256) { + throw new Error( + `Runtime tuple ${tuple.tupleId} has an invalid attested hash for ${entry.path}` + ) + } + } + for (const [path, hash] of attestations) { + const entry = tuple.entries.find((candidate) => candidate.path === path) + if (!entry || entry.type !== 'file' || entry.sha256 !== hash) { + throw new Error( + `Runtime tuple ${tuple.tupleId} attests a missing or mismatched file: ${path}` + ) + } + } +} + +export function assertSshRelayRuntimeTupleConsistency(tuple: SshRelayRuntimeTuple): void { + const expectedCompatibilityKind = tuple.os === 'win32' ? 'windows' : tuple.os + if ( + expectedTupleId(tuple) !== tuple.tupleId || + tuple.compatibility.kind !== expectedCompatibilityKind + ) { + throw new Error(`Runtime tuple identity does not match its platform fields: ${tuple.tupleId}`) + } + assertEntryTree(tuple) + assertRequiredRuntimeEntries(tuple) + assertNativePackages(tuple) + assertNativeAttestation(tuple) + + const files = tuple.entries.filter((entry) => entry.type === 'file') + const expandedSize = files.reduce((total, file) => total + file.size, 0) + if (tuple.archive.fileCount !== files.length) { + throw new Error(`Runtime tuple ${tuple.tupleId} archive file count is inconsistent`) + } + if (tuple.archive.expandedSize !== expandedSize) { + throw new Error(`Runtime tuple ${tuple.tupleId} archive expanded size is inconsistent`) + } + const contentId = computeSshRelayRuntimeContentId(tuple) + if (tuple.contentId !== contentId) { + throw new Error(`Runtime tuple ${tuple.tupleId} content identity is inconsistent`) + } + if (tuple.archive.name !== sshRelayRuntimeArchiveName(tuple.tupleId, tuple.contentId)) { + throw new Error(`Runtime tuple ${tuple.tupleId} archive name is inconsistent`) + } +} diff --git a/src/main/ssh/ssh-relay-artifact-path-policy.ts b/src/main/ssh/ssh-relay-artifact-path-policy.ts new file mode 100644 index 00000000000..504206de3cb --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-path-policy.ts @@ -0,0 +1,44 @@ +export const SSH_RELAY_MAX_RELATIVE_PATH_BYTES = 240 +export const SSH_RELAY_MAX_PATH_DEPTH = 32 + +const PORTABLE_PATH = /^[A-Za-z0-9._@+/-]+$/ +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i + +export function assertSafeSshRelayArtifactPath(relativePath: string): void { + const fail = (reason: string): never => { + throw new Error(`Unsafe artifact path "${relativePath}": ${reason}`) + } + + if ( + !relativePath || + Buffer.byteLength(relativePath, 'utf8') > SSH_RELAY_MAX_RELATIVE_PATH_BYTES + ) { + fail('empty or over the byte limit') + } + if (!PORTABLE_PATH.test(relativePath)) { + fail('contains non-portable characters or separators') + } + if (relativePath.startsWith('/') || relativePath.startsWith('//')) { + fail('absolute and UNC paths are forbidden') + } + + const segments = relativePath.split('/') + if (segments.length > SSH_RELAY_MAX_PATH_DEPTH) { + fail('nesting depth exceeds the limit') + } + for (const segment of segments) { + if (!segment || segment === '.' || segment === '..') { + fail('empty and dot segments are forbidden') + } + if (segment.endsWith('.') || segment.endsWith(' ')) { + fail('Windows-trimmed suffix is forbidden') + } + if (WINDOWS_DEVICE_NAME.test(segment)) { + fail('Windows device name is forbidden') + } + } +} + +export function foldSshRelayArtifactPath(relativePath: string): string { + return relativePath.toLowerCase() +} diff --git a/src/main/ssh/ssh-relay-artifact-schema.test.ts b/src/main/ssh/ssh-relay-artifact-schema.test.ts new file mode 100644 index 00000000000..c057c627818 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-schema.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' + +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { parseSshRelayArtifactManifest } from './ssh-relay-artifact-schema' + +describe('SSH relay artifact manifest schema', () => { + it('accepts a complete internally consistent manifest', () => { + const parsed = parseSshRelayArtifactManifest(createSshRelayArtifactTestManifest()) + + expect(parsed.schemaVersion).toBe(1) + expect(parsed.tuples[0].tupleId).toBe('linux-x64-glibc') + }) + + it('rejects unsupported schema versions, extra fields, and non-canonical timestamps', () => { + const unsupported = createSshRelayArtifactTestManifest() as unknown as Record + unsupported.schemaVersion = 2 + expect(() => parseSshRelayArtifactManifest(unsupported)).toThrow() + + const extra = createSshRelayArtifactTestManifest() as unknown as Record + extra.latest = true + expect(() => parseSshRelayArtifactManifest(extra)).toThrow() + + const invalidDate = createSshRelayArtifactTestManifest() + invalidDate.createdAt = '2026-02-31T00:00:00.000Z' + expect(() => parseSshRelayArtifactManifest(invalidDate)).toThrow(/timestamp/i) + }) + + it('rejects build identity that disagrees with the exact tag', () => { + const manifest = createSshRelayArtifactTestManifest() + manifest.build.channel = 'stable' + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/build identity/i) + }) + + it('rejects duplicate tuple identities', () => { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples.push(structuredClone(manifest.tuples[0])) + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/duplicate tuple/i) + }) + + it('rejects exact and case-folded entry collisions', () => { + for (const path of ['relay.js', 'RELAY.JS']) { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples[0].entries.push({ + path, + type: 'file', + role: 'runtime-javascript', + size: 1, + mode: 0o644, + sha256: `sha256:${'e'.repeat(64)}` + }) + expect(() => parseSshRelayArtifactManifest(manifest), path).toThrow(/colliding path/i) + } + }) + + it.each([ + '/absolute', + '../escape', + 'nested/../escape', + 'C:/drive', + '//server/share', + 'node_modules\\escape', + 'relay.js:stream', + 'CON', + 'aux.txt', + 'trailing.', + 'white space', + `${'a/'.repeat(32)}file`, + 'a'.repeat(241) + ])('rejects unsafe portable path %s', (path) => { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples[0].entries[1].path = path + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/unsafe artifact path/i) + }) + + it('rejects inconsistent aggregate counts and sizes', () => { + const countMismatch = createSshRelayArtifactTestManifest() + countMismatch.tuples[0].archive.fileCount += 1 + expect(() => parseSshRelayArtifactManifest(countMismatch)).toThrow(/file count/i) + + const sizeMismatch = createSshRelayArtifactTestManifest() + sizeMismatch.tuples[0].archive.expandedSize += 1 + expect(() => parseSshRelayArtifactManifest(sizeMismatch)).toThrow(/expanded size/i) + }) + + it('rejects archive, expanded-tree, per-file, and entry-count limit violations', () => { + const archive = createSshRelayArtifactTestManifest() + archive.tuples[0].archive.size = 100 * 1024 * 1024 + 1 + expect(() => parseSshRelayArtifactManifest(archive)).toThrow() + + const expanded = createSshRelayArtifactTestManifest() + expanded.tuples[0].archive.expandedSize = 350 * 1024 * 1024 + 1 + expect(() => parseSshRelayArtifactManifest(expanded)).toThrow() + + const file = createSshRelayArtifactTestManifest() + const relay = file.tuples[0].entries.find( + (entry) => entry.type === 'file' && entry.role === 'relay' + ) + if (!relay || relay.type !== 'file') { + throw new Error('test fixture missing relay file') + } + relay.size = 250 * 1024 * 1024 + 1 + expect(() => parseSshRelayArtifactManifest(file)).toThrow() + + const entries = createSshRelayArtifactTestManifest() + entries.tuples[0].entries = Array.from({ length: 5_001 }, (_, index) => ({ + path: `entry-${index}`, + type: 'directory' as const, + mode: 0o755 as const + })) + expect(() => parseSshRelayArtifactManifest(entries)).toThrow() + }) + + it('rejects undeclared parents and non-file archive entry types', () => { + const missingParent = createSshRelayArtifactTestManifest() + missingParent.tuples[0].entries[1].path = 'missing/node' + expect(() => parseSshRelayArtifactManifest(missingParent)).toThrow(/undeclared parent/i) + + const symlink = createSshRelayArtifactTestManifest() + symlink.tuples[0].entries.push({ + path: 'node-link', + type: 'symlink', + target: 'bin/node' + } as never) + expect(() => parseSshRelayArtifactManifest(symlink)).toThrow() + }) + + it('rejects a missing required executable role', () => { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples[0].entries = manifest.tuples[0].entries.filter( + (entry) => entry.type !== 'file' || entry.role !== 'node-pty-native' + ) + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/node-pty-native/i) + }) + + it('requires bundled license metadata and executable bundled Node mode', () => { + const missingLicense = createSshRelayArtifactTestManifest() + missingLicense.tuples[0].entries = missingLicense.tuples[0].entries.filter( + (entry) => entry.type !== 'file' || entry.role !== 'license' + ) + expect(() => parseSshRelayArtifactManifest(missingLicense)).toThrow(/license/i) + + const nonExecutableNode = createSshRelayArtifactTestManifest() + const node = nonExecutableNode.tuples[0].entries.find( + (entry) => entry.type === 'file' && entry.role === 'node' + ) + if (!node || node.type !== 'file') { + throw new Error('test fixture missing Node') + } + node.mode = 0o644 + expect(() => parseSshRelayArtifactManifest(nonExecutableNode)).toThrow(/executable.*Node/i) + }) + + it('rejects a native optional package for another tuple', () => { + const manifest = createSshRelayArtifactTestManifest() + for (const entry of manifest.tuples[0].entries) { + entry.path = entry.path.replace('watcher-linux-x64-glibc', 'watcher-linux-arm64-glibc') + } + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/native watcher package/i) + }) + + it('rejects native attestation hashes that do not match runtime bytes', () => { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples[0].nativeVerification.files[0].sha256 = `sha256:${'f'.repeat(64)}` + + expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/attested hash/i) + }) + + it('rejects duplicate native attestation paths and the wrong platform policy', () => { + const duplicate = createSshRelayArtifactTestManifest() + duplicate.tuples[0].nativeVerification.files.push( + structuredClone(duplicate.tuples[0].nativeVerification.files[0]) + ) + expect(() => parseSshRelayArtifactManifest(duplicate)).toThrow(/duplicate native attestation/i) + + const wrongPolicy = createSshRelayArtifactTestManifest() + wrongPolicy.tuples[0].nativeVerification.policy = 'apple-developer-id-v1' + expect(() => parseSshRelayArtifactManifest(wrongPolicy)).toThrow(/verification policy/i) + }) + + it('rejects duplicate signature keys and malformed signature bytes', () => { + const duplicate = createSshRelayArtifactTestManifest() + duplicate.signatures.push(structuredClone(duplicate.signatures[0])) + expect(() => parseSshRelayArtifactManifest(duplicate)).toThrow(/duplicate signature/i) + + const malformed = createSshRelayArtifactTestManifest() + malformed.signatures[0].signature = Buffer.alloc(63).toString('base64') + expect(() => parseSshRelayArtifactManifest(malformed)).toThrow(/64 bytes/i) + + const algorithm = createSshRelayArtifactTestManifest() + algorithm.signatures[0].algorithm = 'rsa-v1' as never + expect(() => parseSshRelayArtifactManifest(algorithm)).toThrow() + }) + + it('rejects a content identity or archive name that is not derived from the runtime', () => { + const identityMismatch = createSshRelayArtifactTestManifest() + identityMismatch.tuples[0].contentId = `sha256:${'f'.repeat(64)}` + expect(() => parseSshRelayArtifactManifest(identityMismatch)).toThrow(/content identity/i) + + const nameMismatch = createSshRelayArtifactTestManifest() + nameMismatch.tuples[0].archive.name = 'latest.tar.xz' + expect(() => parseSshRelayArtifactManifest(nameMismatch)).toThrow(/archive name/i) + }) + + it('rejects platform-field conflicts and non-canonical digests', () => { + const conflict = createSshRelayArtifactTestManifest() + conflict.tuples[0].architecture = 'arm64' + expect(() => parseSshRelayArtifactManifest(conflict)).toThrow(/platform fields/i) + + const digest = createSshRelayArtifactTestManifest() + digest.tuples[0].archive.sha256 = `sha256:${'A'.repeat(64)}` + expect(() => parseSshRelayArtifactManifest(digest)).toThrow() + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-schema.ts b/src/main/ssh/ssh-relay-artifact-schema.ts new file mode 100644 index 00000000000..c9c699aa92a --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-schema.ts @@ -0,0 +1,268 @@ +import { z } from 'zod' + +import { assertSshRelayRuntimeTupleConsistency } from './ssh-relay-artifact-consistency' +import { parseSshRelayReleaseTag } from './ssh-relay-release-asset' +import type { SshRelayDigest, SshRelayRuntimeIdentityInput } from './ssh-relay-runtime-identity' + +const MAX_ARCHIVE_SIZE = 100 * 1024 * 1024 +const MAX_EXPANDED_SIZE = 350 * 1024 * 1024 +const MAX_FILE_SIZE = 250 * 1024 * 1024 +const MAX_ENTRIES = 5_000 +const VERSION = /^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/ +const NUMERIC_VERSION = /^\d+\.\d+(?:\.\d+)?$/ +const OPENSSH_VERSION = /^\d+\.\d+p\d+$/ +const ASSET_NAME = /^[A-Za-z0-9._-]+$/ +const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/) +const safeSizeSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) +const versionSchema = z.string().regex(VERSION).max(64) +const numericVersionSchema = z.string().regex(NUMERIC_VERSION).max(64) +const timestampSchema = z + .string() + .regex(TIMESTAMP) + .refine( + (value) => { + const milliseconds = Date.parse(value) + return !Number.isNaN(milliseconds) && new Date(milliseconds).toISOString() === value + }, + { message: 'invalid UTC timestamp' } + ) + +const directoryEntrySchema = z + .object({ path: z.string(), type: z.literal('directory'), mode: z.literal(0o755) }) + .strict() +const fileEntrySchema = z + .object({ + path: z.string(), + type: z.literal('file'), + role: z.enum([ + 'node', + 'relay', + 'relay-watcher', + 'node-pty-native', + 'parcel-watcher-native', + 'native-runtime', + 'runtime-javascript', + 'license' + ]), + size: safeSizeSchema.max(MAX_FILE_SIZE), + mode: z.union([z.literal(0o644), z.literal(0o755)]), + sha256: digestSchema + }) + .strict() +const entrySchema = z.discriminatedUnion('type', [directoryEntrySchema, fileEntrySchema]) + +const glibcSchema = z + .object({ + family: z.literal('glibc'), + minimumVersion: numericVersionSchema, + minimumLibstdcxxVersion: numericVersionSchema, + minimumGlibcxxVersion: numericVersionSchema + }) + .strict() +const muslSchema = z + .object({ + family: z.literal('musl'), + minimumVersion: numericVersionSchema, + minimumLibstdcxxVersion: z.null(), + minimumGlibcxxVersion: z.null() + }) + .strict() +const compatibilitySchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('linux'), + minimumKernelVersion: numericVersionSchema, + libc: z.discriminatedUnion('family', [glibcSchema, muslSchema]) + }) + .strict(), + z.object({ kind: z.literal('darwin'), minimumVersion: numericVersionSchema }).strict(), + z + .object({ + kind: z.literal('windows'), + minimumBuild: safeSizeSchema, + minimumOpenSshVersion: z.string().regex(OPENSSH_VERSION).max(64), + minimumPowerShellVersion: numericVersionSchema, + minimumDotNetFrameworkRelease: safeSizeSchema + }) + .strict() +]) + +const metadataAssetSchema = z + .object({ name: z.string().regex(ASSET_NAME), size: safeSizeSchema, sha256: digestSchema }) + .strict() +const nativeVerificationSchema = z + .object({ + policy: z.enum(['linux-hash-only-v1', 'apple-developer-id-v1', 'signpath-authenticode-v1']), + tool: z + .object({ name: z.string().regex(ASSET_NAME), version: z.string().min(1).max(64) }) + .strict(), + verifiedAt: timestampSchema, + files: z + .array(z.object({ path: z.string(), sha256: digestSchema }).strict()) + .min(1) + .max(MAX_ENTRIES) + }) + .strict() + +const tupleSchema = z + .object({ + tupleId: z.enum([ + 'linux-x64-glibc', + 'linux-arm64-glibc', + 'linux-x64-musl', + 'linux-arm64-musl', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' + ]), + os: z.enum(['linux', 'darwin', 'win32']), + architecture: z.enum(['x64', 'arm64']), + compatibility: compatibilitySchema, + nodeVersion: versionSchema, + dependencies: z + .object({ nodePtyVersion: versionSchema, parcelWatcherVersion: versionSchema }) + .strict(), + entries: z.array(entrySchema).min(1).max(MAX_ENTRIES), + contentId: digestSchema, + archive: z + .object({ + name: z.string().regex(ASSET_NAME), + size: safeSizeSchema.max(MAX_ARCHIVE_SIZE), + expandedSize: safeSizeSchema.max(MAX_EXPANDED_SIZE), + fileCount: safeSizeSchema.max(MAX_ENTRIES), + sha256: digestSchema + }) + .strict(), + metadataAssets: z + .object({ sbom: metadataAssetSchema, provenance: metadataAssetSchema }) + .strict(), + nativeVerification: nativeVerificationSchema + }) + .strict() + +const signatureSchema = z + .object({ + algorithm: z.literal('ed25519-v1'), + keyId: digestSchema, + signature: z + .string() + .regex(/^[A-Za-z0-9+/]+={0,2}$/) + .refine( + (value) => { + const decoded = Buffer.from(value, 'base64') + return decoded.length === 64 && decoded.toString('base64') === value + }, + { message: 'Ed25519 signature must be canonical base64 encoding of 64 bytes' } + ) + }) + .strict() + +const unsignedManifestSchema = z + .object({ + schemaVersion: z.literal(1), + build: z + .object({ + tag: z.string(), + channel: z.enum(['stable', 'rc', 'perf']), + version: z.string(), + relayProtocolVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) + }) + .strict(), + createdAt: timestampSchema, + tuples: z.array(tupleSchema).min(1).max(8) + }) + .strict() +const manifestSchema = unsignedManifestSchema + .extend({ signatures: z.array(signatureSchema).min(1).max(4) }) + .strict() + +export type SshRelayManifestSignature = { + algorithm: 'ed25519-v1' + keyId: SshRelayDigest + signature: string +} +export type SshRelayRuntimeTuple = SshRelayRuntimeIdentityInput & { + contentId: SshRelayDigest + archive: { + name: string + size: number + expandedSize: number + fileCount: number + sha256: SshRelayDigest + } + metadataAssets: { + sbom: { name: string; size: number; sha256: SshRelayDigest } + provenance: { name: string; size: number; sha256: SshRelayDigest } + } + nativeVerification: { + policy: 'linux-hash-only-v1' | 'apple-developer-id-v1' | 'signpath-authenticode-v1' + tool: { name: string; version: string } + verifiedAt: string + files: { path: string; sha256: SshRelayDigest }[] + } +} +export type SshRelayUnsignedArtifactManifest = { + schemaVersion: 1 + build: { + tag: string + channel: 'stable' | 'rc' | 'perf' + version: string + relayProtocolVersion: number + } + createdAt: string + tuples: SshRelayRuntimeTuple[] +} +export type SshRelayArtifactManifest = SshRelayUnsignedArtifactManifest & { + signatures: SshRelayManifestSignature[] +} + +function validateUnsignedManifest( + parsed: SshRelayUnsignedArtifactManifest +): SshRelayUnsignedArtifactManifest { + const release = parseSshRelayReleaseTag(parsed.build.tag) + if (release.channel !== parsed.build.channel || release.version !== parsed.build.version) { + throw new Error('SSH relay manifest build identity does not match its exact release tag') + } + const tupleIds = new Set() + for (const tuple of parsed.tuples) { + if (tupleIds.has(tuple.tupleId)) { + throw new Error(`SSH relay manifest has duplicate tuple: ${tuple.tupleId}`) + } + tupleIds.add(tuple.tupleId) + assertSshRelayRuntimeTupleConsistency(tuple) + } + return parsed +} + +function withoutSignatures(input: unknown): unknown { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return input + } + const { signatures: _signatures, ...unsigned } = input as Record + return unsigned +} + +export function parseSshRelayUnsignedArtifactManifest( + input: unknown +): SshRelayUnsignedArtifactManifest { + const parsed = unsignedManifestSchema.parse( + withoutSignatures(input) + ) as unknown as SshRelayUnsignedArtifactManifest + return validateUnsignedManifest(parsed) +} + +export function parseSshRelayArtifactManifest(input: unknown): SshRelayArtifactManifest { + const parsed = manifestSchema.parse(input) as unknown as SshRelayArtifactManifest + validateUnsignedManifest(parsed) + const signatureKeys = new Set() + for (const signature of parsed.signatures) { + if (signatureKeys.has(signature.keyId)) { + throw new Error(`SSH relay manifest has duplicate signature key: ${signature.keyId}`) + } + signatureKeys.add(signature.keyId) + } + return parsed +} diff --git a/src/main/ssh/ssh-relay-artifact-selector.test.ts b/src/main/ssh/ssh-relay-artifact-selector.test.ts new file mode 100644 index 00000000000..b63a095143f --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-selector.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' + +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { parseSshRelayArtifactManifest } from './ssh-relay-artifact-schema' +import { selectSshRelayArtifact } from './ssh-relay-artifact-selector' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity' +import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset' + +const compatibleLinuxHost = { + 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 windowsManifest() { + const manifest = createSshRelayArtifactTestManifest() + const tuple = structuredClone(manifest.tuples[0]) as unknown as Record + Object.assign(tuple, { + tupleId: 'win32-x64', + os: 'win32', + architecture: 'x64', + compatibility: { + kind: 'windows', + minimumBuild: 20348, + minimumOpenSshVersion: '8.1p1', + minimumPowerShellVersion: '5.1', + minimumDotNetFrameworkRelease: 528040 + } + }) + manifest.tuples = [tuple as never] + return manifest +} + +const compatibleWindowsHost = { + os: 'win32' as const, + architecture: 'x64' as const, + processTranslated: false, + build: 20348, + openSshVersion: '8.1p1', + powerShellVersion: '5.1', + dotNetFrameworkRelease: 528040 +} + +describe('SSH relay artifact selector', () => { + it('selects the single compatible glibc tuple', () => { + const result = selectSshRelayArtifact(createSshRelayArtifactTestManifest(), compatibleLinuxHost) + + expect(result).toMatchObject({ kind: 'selected', tupleId: 'linux-x64-glibc' }) + }) + + it('accepts every Linux compatibility value at its exact minimum', () => { + expect( + selectSshRelayArtifact(createSshRelayArtifactTestManifest(), { + ...compatibleLinuxHost, + kernelVersion: '4.18', + libc: { family: 'glibc', version: '2.28' }, + libstdcxxVersion: '6.0.25', + glibcxxVersion: '3.4.25' + }).kind + ).toBe('selected') + }) + + it('selects the detected libc family when both Linux variants exist', () => { + const manifest = createSshRelayArtifactTestManifest() + const musl = structuredClone(manifest.tuples[0]) + musl.tupleId = 'linux-x64-musl' + musl.compatibility = { + kind: 'linux', + minimumKernelVersion: '4.18', + libc: { + family: 'musl', + minimumVersion: '1.2.5', + minimumLibstdcxxVersion: null, + minimumGlibcxxVersion: null + } + } + for (const entry of musl.entries) { + entry.path = entry.path.replace('watcher-linux-x64-glibc', 'watcher-linux-x64-musl') + } + for (const attestation of musl.nativeVerification.files) { + attestation.path = attestation.path.replace( + 'watcher-linux-x64-glibc', + 'watcher-linux-x64-musl' + ) + } + musl.contentId = computeSshRelayRuntimeContentId(musl) + musl.archive.name = sshRelayRuntimeArchiveName(musl.tupleId, musl.contentId) + manifest.tuples.push(musl) + const parsed = parseSshRelayArtifactManifest(manifest) + + expect(selectSshRelayArtifact(parsed, compatibleLinuxHost)).toMatchObject({ + kind: 'selected', + tupleId: 'linux-x64-glibc' + }) + expect( + selectSshRelayArtifact(parsed, { + ...compatibleLinuxHost, + libc: { family: 'musl', version: '1.2.5' } + }) + ).toMatchObject({ kind: 'selected', tupleId: 'linux-x64-musl' }) + }) + + it.each([ + [{ ...compatibleLinuxHost, kernelVersion: '4.17' }, 'kernel-too-old'], + [ + { ...compatibleLinuxHost, libc: { family: 'glibc' as const, version: '2.27' } }, + 'libc-too-old' + ], + [{ ...compatibleLinuxHost, glibcxxVersion: '3.4.24' }, 'libstdcxx-too-old'], + [{ ...compatibleLinuxHost, libstdcxxVersion: undefined }, 'unknown-libstdcxx'], + [{ ...compatibleLinuxHost, kernelVersion: 'not-a-version' }, 'unknown-kernel'], + [{ ...compatibleLinuxHost, libc: { family: 'unknown' as const } }, 'unknown-libc'], + [ + { ...compatibleLinuxHost, libc: { family: 'musl' as const, version: '1.2.5' } }, + 'tuple-unavailable' + ], + [{ ...compatibleLinuxHost, processTranslated: true }, 'translated-process'], + [{ ...compatibleLinuxHost, architecture: 'arm64' as const }, 'tuple-unavailable'] + ])('selects legacy for incompatible or unknown host evidence', (host, reason) => { + expect(selectSshRelayArtifact(createSshRelayArtifactTestManifest(), host)).toEqual({ + kind: 'legacy', + reason + }) + }) + + it('checks Windows bootstrap versions before selection', () => { + const manifest = windowsManifest() + expect(selectSshRelayArtifact(manifest, compatibleWindowsHost).kind).toBe('selected') + expect(selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, build: 20347 })).toEqual({ + kind: 'legacy', + reason: 'os-too-old' + }) + expect( + selectSshRelayArtifact(manifest, { + ...compatibleWindowsHost, + openSshVersion: '8.0p1' + }) + ).toEqual({ kind: 'legacy', reason: 'openssh-too-old' }) + expect( + selectSshRelayArtifact(manifest, { + ...compatibleWindowsHost, + powerShellVersion: '5.0' + }) + ).toEqual({ kind: 'legacy', reason: 'powershell-too-old' }) + expect( + selectSshRelayArtifact(manifest, { + ...compatibleWindowsHost, + dotNetFrameworkRelease: 528039 + }) + ).toEqual({ kind: 'legacy', reason: 'dotnet-too-old' }) + }) + + it('selects legacy when Windows bootstrap evidence is absent or malformed', () => { + const manifest = windowsManifest() + expect( + selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, openSshVersion: '8.1.0' }) + ).toEqual({ kind: 'legacy', reason: 'unknown-openssh' }) + expect( + selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, powerShellVersion: undefined }) + ).toEqual({ kind: 'legacy', reason: 'unknown-powershell' }) + expect( + selectSshRelayArtifact(manifest, { + ...compatibleWindowsHost, + dotNetFrameworkRelease: undefined + }) + ).toEqual({ kind: 'legacy', reason: 'unknown-dotnet' }) + }) + + it('checks the minimum macOS version before selection', () => { + const manifest = createSshRelayArtifactTestManifest() + const tuple = structuredClone(manifest.tuples[0]) as unknown as Record + Object.assign(tuple, { + tupleId: 'darwin-arm64', + os: 'darwin', + architecture: 'arm64', + compatibility: { kind: 'darwin', minimumVersion: '13.5' } + }) + manifest.tuples = [tuple as never] + + expect( + selectSshRelayArtifact(manifest, { + os: 'darwin', + architecture: 'arm64', + processTranslated: false, + version: '13.4' + }) + ).toEqual({ kind: 'legacy', reason: 'os-too-old' }) + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-selector.ts b/src/main/ssh/ssh-relay-artifact-selector.ts new file mode 100644 index 00000000000..32a118eca75 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-selector.ts @@ -0,0 +1,258 @@ +import type { SshRelayArtifactManifest, SshRelayRuntimeTuple } from './ssh-relay-artifact-schema' + +type SshRelayHostBase = { + architecture: 'x64' | 'arm64' + processTranslated: boolean +} + +export type SshRelayLinuxHostEvidence = SshRelayHostBase & { + os: 'linux' + kernelVersion?: string + libc: { family: 'glibc' | 'musl'; version?: string } | { family: 'unknown' } + libstdcxxVersion?: string + glibcxxVersion?: string +} + +export type SshRelayDarwinHostEvidence = SshRelayHostBase & { + os: 'darwin' + version?: string +} + +export type SshRelayWindowsHostEvidence = SshRelayHostBase & { + os: 'win32' + build?: number + openSshVersion?: string + powerShellVersion?: string + dotNetFrameworkRelease?: number +} + +export type SshRelayHostEvidence = + | SshRelayLinuxHostEvidence + | SshRelayDarwinHostEvidence + | SshRelayWindowsHostEvidence + +export type SshRelayArtifactLegacyReason = + | 'tuple-inconsistent' + | 'tuple-unavailable' + | 'tuple-ambiguous' + | 'translated-process' + | 'unknown-kernel' + | 'kernel-too-old' + | 'unknown-libc' + | 'libc-too-old' + | 'unknown-libstdcxx' + | 'libstdcxx-too-old' + | 'unknown-os-version' + | 'os-too-old' + | 'unknown-openssh' + | 'openssh-too-old' + | 'unknown-powershell' + | 'powershell-too-old' + | 'unknown-dotnet' + | 'dotnet-too-old' + +export type SshRelayArtifactSelection = + | { kind: 'selected'; tupleId: SshRelayRuntimeTuple['tupleId']; tuple: SshRelayRuntimeTuple } + | { kind: 'legacy'; reason: SshRelayArtifactLegacyReason } + +function parseNumericVersion(value: string | undefined): number[] | null { + if (!value) { + return null + } + const match = /^(\d+(?:\.\d+){1,3})(?:-[0-9A-Za-z.-]+)?$/.exec(value) + if (!match) { + return null + } + const components = match[1].split('.').map(Number) + return components.every(Number.isSafeInteger) ? components : null +} + +function parseOpenSshVersion(value: string | undefined): number[] | null { + if (!value) { + return null + } + const match = /^(\d+)\.(\d+)p(\d+)$/.exec(value) + if (!match) { + return null + } + const components = match.slice(1).map(Number) + return components.every(Number.isSafeInteger) ? components : null +} + +function compareComponents(left: number[], right: number[]): number { + const length = Math.max(left.length, right.length) + for (let index = 0; index < length; index += 1) { + const difference = (left[index] ?? 0) - (right[index] ?? 0) + if (difference !== 0) { + return difference + } + } + return 0 +} + +function meetsVersion( + actual: string | undefined, + minimum: string, + parser = parseNumericVersion +): boolean | null { + const actualParts = parser(actual) + const minimumParts = parser(minimum) + if (!actualParts || !minimumParts) { + return null + } + return compareComponents(actualParts, minimumParts) >= 0 +} + +function selectLinux( + tuple: SshRelayRuntimeTuple, + host: SshRelayLinuxHostEvidence +): SshRelayArtifactSelection { + if (tuple.compatibility.kind !== 'linux') { + return { kind: 'legacy', reason: 'tuple-inconsistent' } + } + const kernel = meetsVersion(host.kernelVersion, tuple.compatibility.minimumKernelVersion) + if (kernel === null) { + return { kind: 'legacy', reason: 'unknown-kernel' } + } + if (!kernel) { + return { kind: 'legacy', reason: 'kernel-too-old' } + } + if (host.libc.family === 'unknown') { + return { kind: 'legacy', reason: 'unknown-libc' } + } + if (host.libc.family !== tuple.compatibility.libc.family) { + return { kind: 'legacy', reason: 'tuple-unavailable' } + } + const libc = meetsVersion(host.libc.version, tuple.compatibility.libc.minimumVersion) + if (libc === null) { + return { kind: 'legacy', reason: 'unknown-libc' } + } + if (!libc) { + return { kind: 'legacy', reason: 'libc-too-old' } + } + + if (tuple.compatibility.libc.family === 'glibc') { + const libstdcxx = meetsVersion( + host.libstdcxxVersion, + tuple.compatibility.libc.minimumLibstdcxxVersion + ) + const glibcxx = meetsVersion( + host.glibcxxVersion, + tuple.compatibility.libc.minimumGlibcxxVersion + ) + if (libstdcxx === null || glibcxx === null) { + return { kind: 'legacy', reason: 'unknown-libstdcxx' } + } + if (!libstdcxx || !glibcxx) { + return { kind: 'legacy', reason: 'libstdcxx-too-old' } + } + } + return { kind: 'selected', tupleId: tuple.tupleId, tuple } +} + +function selectDarwin( + tuple: SshRelayRuntimeTuple, + host: SshRelayDarwinHostEvidence +): SshRelayArtifactSelection { + if (tuple.compatibility.kind !== 'darwin') { + return { kind: 'legacy', reason: 'tuple-inconsistent' } + } + const compatible = meetsVersion(host.version, tuple.compatibility.minimumVersion) + if (compatible === null) { + return { kind: 'legacy', reason: 'unknown-os-version' } + } + return compatible + ? { kind: 'selected', tupleId: tuple.tupleId, tuple } + : { kind: 'legacy', reason: 'os-too-old' } +} + +function selectWindows( + tuple: SshRelayRuntimeTuple, + host: SshRelayWindowsHostEvidence +): SshRelayArtifactSelection { + if (tuple.compatibility.kind !== 'windows') { + return { kind: 'legacy', reason: 'tuple-inconsistent' } + } + if (typeof host.build !== 'number' || !Number.isSafeInteger(host.build)) { + return { kind: 'legacy', reason: 'unknown-os-version' } + } + if (host.build < tuple.compatibility.minimumBuild) { + return { kind: 'legacy', reason: 'os-too-old' } + } + const openSsh = meetsVersion( + host.openSshVersion, + tuple.compatibility.minimumOpenSshVersion, + parseOpenSshVersion + ) + if (openSsh === null) { + return { kind: 'legacy', reason: 'unknown-openssh' } + } + if (!openSsh) { + return { kind: 'legacy', reason: 'openssh-too-old' } + } + const powerShell = meetsVersion( + host.powerShellVersion, + tuple.compatibility.minimumPowerShellVersion + ) + if (powerShell === null) { + return { kind: 'legacy', reason: 'unknown-powershell' } + } + if (!powerShell) { + return { kind: 'legacy', reason: 'powershell-too-old' } + } + if ( + typeof host.dotNetFrameworkRelease !== 'number' || + !Number.isSafeInteger(host.dotNetFrameworkRelease) + ) { + return { kind: 'legacy', reason: 'unknown-dotnet' } + } + if (host.dotNetFrameworkRelease < tuple.compatibility.minimumDotNetFrameworkRelease) { + return { kind: 'legacy', reason: 'dotnet-too-old' } + } + return { kind: 'selected', tupleId: tuple.tupleId, tuple } +} + +export function selectSshRelayArtifact( + manifest: SshRelayArtifactManifest, + host: SshRelayHostEvidence +): SshRelayArtifactSelection { + // Why: translated and ambiguous process boundaries stay on the proven legacy path until their + // own live SSH evidence exists; selecting a native artifact here would be an unsafe guess. + if (host.processTranslated) { + return { kind: 'legacy', reason: 'translated-process' } + } + const platformCandidates = manifest.tuples.filter( + (tuple) => tuple.os === host.os && tuple.architecture === host.architecture + ) + if (host.os === 'linux' && host.libc.family === 'unknown') { + return { kind: 'legacy', reason: 'unknown-libc' } + } + const candidates = + host.os === 'linux' + ? platformCandidates.filter( + (tuple) => + tuple.compatibility.kind === 'linux' && + tuple.compatibility.libc.family === host.libc.family + ) + : platformCandidates + if (candidates.length === 0) { + return { + kind: 'legacy', + reason: platformCandidates.some((tuple) => tuple.compatibility.kind !== host.os) + ? 'tuple-inconsistent' + : 'tuple-unavailable' + } + } + if (candidates.length > 1) { + return { kind: 'legacy', reason: 'tuple-ambiguous' } + } + + const tuple = candidates[0] + if (host.os === 'linux') { + return selectLinux(tuple, host) + } + if (host.os === 'darwin') { + return selectDarwin(tuple, host) + } + return selectWindows(tuple, host) +} diff --git a/src/main/ssh/ssh-relay-artifact-test-manifest.ts b/src/main/ssh/ssh-relay-artifact-test-manifest.ts new file mode 100644 index 00000000000..72fc5e3befb --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-test-manifest.ts @@ -0,0 +1,168 @@ +import type { SshRelayArtifactManifest } from './ssh-relay-artifact-schema' +import { + computeSshRelayRuntimeContentId, + type SshRelayRuntimeIdentityInput +} from './ssh-relay-runtime-identity' +import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset' + +const digest = (character: string): `sha256:${string}` => `sha256:${character.repeat(64)}` + +export function createSshRelayArtifactTestManifest(): SshRelayArtifactManifest { + const runtime: SshRelayRuntimeIdentityInput = { + tupleId: 'linux-x64-glibc', + os: 'linux', + architecture: 'x64', + compatibility: { + kind: 'linux', + minimumKernelVersion: '4.18', + libc: { + family: 'glibc', + minimumVersion: '2.28', + minimumLibstdcxxVersion: '6.0.25', + minimumGlibcxxVersion: '3.4.25' + } + }, + nodeVersion: '24.18.0', + dependencies: { + nodePtyVersion: '1.1.0', + parcelWatcherVersion: '2.5.6' + }, + entries: [ + { path: 'bin', type: 'directory', mode: 0o755 }, + { + path: 'bin/node', + type: 'file', + role: 'node', + size: 40, + mode: 0o755, + sha256: digest('1') + }, + { + path: 'relay.js', + type: 'file', + role: 'relay', + size: 20, + mode: 0o644, + sha256: digest('2') + }, + { + path: 'relay-watcher.js', + type: 'file', + role: 'relay-watcher', + size: 15, + mode: 0o644, + sha256: digest('3') + }, + { path: 'node_modules', type: 'directory', mode: 0o755 }, + { path: 'node_modules/node-pty', type: 'directory', mode: 0o755 }, + { + path: 'node_modules/node-pty/package.json', + type: 'file', + role: 'runtime-javascript', + size: 10, + mode: 0o644, + sha256: digest('4') + }, + { path: 'node_modules/node-pty/build', type: 'directory', mode: 0o755 }, + { path: 'node_modules/node-pty/build/Release', type: 'directory', mode: 0o755 }, + { + path: 'node_modules/node-pty/build/Release/pty.node', + type: 'file', + role: 'node-pty-native', + size: 30, + mode: 0o755, + sha256: digest('5') + }, + { path: 'node_modules/@parcel', type: 'directory', mode: 0o755 }, + { path: 'node_modules/@parcel/watcher', type: 'directory', mode: 0o755 }, + { + path: 'node_modules/@parcel/watcher/package.json', + type: 'file', + role: 'runtime-javascript', + size: 10, + mode: 0o644, + sha256: digest('6') + }, + { + path: 'node_modules/@parcel/watcher-linux-x64-glibc', + type: 'directory', + mode: 0o755 + }, + { + path: 'node_modules/@parcel/watcher-linux-x64-glibc/watcher.node', + type: 'file', + role: 'parcel-watcher-native', + size: 25, + mode: 0o755, + sha256: digest('7') + }, + { + path: 'THIRD_PARTY_LICENSES.txt', + type: 'file', + role: 'license', + size: 10, + mode: 0o644, + sha256: digest('8') + } + ] + } + const contentId = computeSshRelayRuntimeContentId(runtime) + const fileEntries = runtime.entries.filter((entry) => entry.type === 'file') + const expandedSize = fileEntries.reduce((total, entry) => total + entry.size, 0) + + return { + schemaVersion: 1, + build: { + tag: 'v1.4.140-rc.1', + channel: 'rc', + version: '1.4.140-rc.1', + relayProtocolVersion: 1 + }, + createdAt: '2026-07-14T00:00:00.000Z', + tuples: [ + { + ...runtime, + contentId, + archive: { + name: sshRelayRuntimeArchiveName(runtime.tupleId, contentId), + size: 100, + expandedSize, + fileCount: fileEntries.length, + sha256: digest('a') + }, + metadataAssets: { + sbom: { + name: 'orca-ssh-relay-runtime-linux-x64-glibc.spdx.json', + size: 50, + sha256: digest('b') + }, + provenance: { + name: 'orca-ssh-relay-runtime-linux-x64-glibc.provenance.json', + size: 50, + sha256: digest('c') + } + }, + nativeVerification: { + policy: 'linux-hash-only-v1', + tool: { name: 'sha256sum', version: '9.4' }, + verifiedAt: '2026-07-14T00:00:00.000Z', + files: [ + { path: 'bin/node', sha256: digest('1') }, + { path: 'node_modules/node-pty/build/Release/pty.node', sha256: digest('5') }, + { + path: 'node_modules/@parcel/watcher-linux-x64-glibc/watcher.node', + sha256: digest('7') + } + ] + } + } + ], + signatures: [ + { + algorithm: 'ed25519-v1', + keyId: digest('d'), + signature: Buffer.alloc(64).toString('base64') + } + ] + } +} diff --git a/src/main/ssh/ssh-relay-manifest-signature.test.ts b/src/main/ssh/ssh-relay-manifest-signature.test.ts new file mode 100644 index 00000000000..474888430dd --- /dev/null +++ b/src/main/ssh/ssh-relay-manifest-signature.test.ts @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto' + +import nacl from 'tweetnacl' +import { describe, expect, it } from 'vitest' + +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { + canonicalUnsignedSshRelayManifestBytes, + signSshRelayArtifactManifest, + sshRelayManifestKeyId, + verifySshRelayArtifactManifest +} from './ssh-relay-manifest-signature' + +const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) + +function signedManifest() { + const manifest = createSshRelayArtifactTestManifest() + manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)] + return manifest +} + +function unsignedManifest() { + const { signatures: _signatures, ...unsigned } = createSshRelayArtifactTestManifest() + return unsigned +} + +describe('SSH relay manifest signatures', () => { + it('canonicalizes object keys independently of insertion order', () => { + const manifest = createSshRelayArtifactTestManifest() + const reordered = { + signatures: manifest.signatures, + tuples: manifest.tuples, + createdAt: manifest.createdAt, + build: manifest.build, + schemaVersion: manifest.schemaVersion + } + + expect(canonicalUnsignedSshRelayManifestBytes(reordered)).toEqual( + canonicalUnsignedSshRelayManifestBytes(manifest) + ) + }) + + it('has a fixed canonical unsigned test vector and ignores array insertion order', () => { + const manifest = createSshRelayArtifactTestManifest() + const reordered = structuredClone(manifest) + reordered.tuples[0].entries.reverse() + reordered.tuples[0].nativeVerification.files.reverse() + reordered.signatures[0].signature = Buffer.alloc(64, 1).toString('base64') + + const canonical = canonicalUnsignedSshRelayManifestBytes(manifest) + expect(canonicalUnsignedSshRelayManifestBytes(reordered)).toEqual(canonical) + expect(createHash('sha256').update(canonical).digest('hex')).toBe( + 'e78bf4416628a91055035dc7926035cbf633f29d3618be34e041c6dc5e0794fb' + ) + }) + + it('creates the first signature from validated unsigned content', () => { + const unsigned = unsignedManifest() + const signature = signSshRelayArtifactManifest(unsigned, keyPair.secretKey) + const manifest = { ...unsigned, signatures: [signature] } + + expect( + verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + ).toEqual(manifest) + }) + + it('rejects invalid key sizes and inconsistent unsigned content before signing', () => { + expect(() => sshRelayManifestKeyId(new Uint8Array(31))).toThrow(/32 bytes/i) + expect(() => signSshRelayArtifactManifest(unsignedManifest(), new Uint8Array(63))).toThrow( + /64 bytes/i + ) + + const inconsistent = unsignedManifest() + inconsistent.tuples[0].contentId = `sha256:${'f'.repeat(64)}` + expect(() => signSshRelayArtifactManifest(inconsistent, keyPair.secretKey)).toThrow( + /content identity/i + ) + }) + + it('verifies a signed manifest with the embedded accepted key', () => { + const manifest = signedManifest() + + const verified = verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + + expect(verified.tuples[0].contentId).toBe(manifest.tuples[0].contentId) + }) + + it('rejects signed content mutation', () => { + const manifest = signedManifest() + manifest.build.relayProtocolVersion += 1 + + expect(() => + verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + ).toThrow(/signature/i) + }) + + it('fails closed for unknown, duplicate, malformed, or mismatched keys', () => { + const manifest = signedManifest() + expect(() => verifySshRelayArtifactManifest(manifest, [])).toThrow(/unknown signing key/i) + + const duplicate = signedManifest() + duplicate.signatures.push(structuredClone(duplicate.signatures[0])) + expect(() => verifySshRelayArtifactManifest(duplicate, [])).toThrow(/duplicate signature/i) + + const malformed = signedManifest() + malformed.signatures[0].signature = Buffer.alloc(63).toString('base64') + expect(() => verifySshRelayArtifactManifest(malformed, [])).toThrow(/64 bytes/i) + + const otherPair = nacl.sign.keyPair() + const mismatch = signedManifest() + expect(() => + verifySshRelayArtifactManifest(mismatch, [ + { keyId: mismatch.signatures[0].keyId, publicKey: otherPair.publicKey } + ]) + ).toThrow(/public key id/i) + }) + + it('accepts dual signatures only when every signing key is accepted', () => { + const manifest = signedManifest() + const nextKeyPair = nacl.sign.keyPair.fromSeed( + Uint8Array.from({ length: 32 }, (_, index) => 31 - index) + ) + manifest.signatures.push(signSshRelayArtifactManifest(manifest, nextKeyPair.secretKey)) + + expect(() => + verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + ).toThrow(/unknown signing key/i) + expect( + verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey }, + { keyId: sshRelayManifestKeyId(nextKeyPair.publicKey), publicKey: nextKeyPair.publicKey } + ]) + ).toEqual(manifest) + }) +}) diff --git a/src/main/ssh/ssh-relay-manifest-signature.ts b/src/main/ssh/ssh-relay-manifest-signature.ts new file mode 100644 index 00000000000..f6131d6167b --- /dev/null +++ b/src/main/ssh/ssh-relay-manifest-signature.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto' + +import nacl from 'tweetnacl' + +import { + parseSshRelayArtifactManifest, + parseSshRelayUnsignedArtifactManifest, + type SshRelayArtifactManifest, + type SshRelayManifestSignature, + type SshRelayRuntimeTuple, + type SshRelayUnsignedArtifactManifest +} from './ssh-relay-artifact-schema' +import type { + SshRelayDigest, + SshRelayRuntimeCompatibility, + SshRelayRuntimeEntry +} from './ssh-relay-runtime-identity' + +export type SshRelayManifestAcceptedKey = { + keyId: SshRelayDigest + publicKey: Uint8Array +} + +function compareAscii(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function canonicalCompatibility(compatibility: SshRelayRuntimeCompatibility): object { + if (compatibility.kind === 'linux') { + return { + kind: compatibility.kind, + minimumKernelVersion: compatibility.minimumKernelVersion, + libc: { + family: compatibility.libc.family, + minimumVersion: compatibility.libc.minimumVersion, + minimumLibstdcxxVersion: compatibility.libc.minimumLibstdcxxVersion, + minimumGlibcxxVersion: compatibility.libc.minimumGlibcxxVersion + } + } + } + if (compatibility.kind === 'darwin') { + return { kind: compatibility.kind, minimumVersion: compatibility.minimumVersion } + } + return { + kind: compatibility.kind, + minimumBuild: compatibility.minimumBuild, + minimumOpenSshVersion: compatibility.minimumOpenSshVersion, + minimumPowerShellVersion: compatibility.minimumPowerShellVersion, + minimumDotNetFrameworkRelease: compatibility.minimumDotNetFrameworkRelease + } +} + +function canonicalEntry(entry: SshRelayRuntimeEntry): object { + if (entry.type === 'directory') { + return { path: entry.path, type: entry.type, mode: entry.mode } + } + return { + path: entry.path, + type: entry.type, + role: entry.role, + size: entry.size, + mode: entry.mode, + sha256: entry.sha256 + } +} + +function canonicalTuple(tuple: SshRelayRuntimeTuple): object { + return { + tupleId: tuple.tupleId, + os: tuple.os, + architecture: tuple.architecture, + compatibility: canonicalCompatibility(tuple.compatibility), + nodeVersion: tuple.nodeVersion, + dependencies: { + nodePtyVersion: tuple.dependencies.nodePtyVersion, + parcelWatcherVersion: tuple.dependencies.parcelWatcherVersion + }, + entries: [...tuple.entries] + .sort((left, right) => compareAscii(left.path, right.path)) + .map(canonicalEntry), + contentId: tuple.contentId, + archive: { + name: tuple.archive.name, + size: tuple.archive.size, + expandedSize: tuple.archive.expandedSize, + fileCount: tuple.archive.fileCount, + sha256: tuple.archive.sha256 + }, + metadataAssets: { + sbom: { + name: tuple.metadataAssets.sbom.name, + size: tuple.metadataAssets.sbom.size, + sha256: tuple.metadataAssets.sbom.sha256 + }, + provenance: { + name: tuple.metadataAssets.provenance.name, + size: tuple.metadataAssets.provenance.size, + sha256: tuple.metadataAssets.provenance.sha256 + } + }, + nativeVerification: { + policy: tuple.nativeVerification.policy, + tool: { + name: tuple.nativeVerification.tool.name, + version: tuple.nativeVerification.tool.version + }, + verifiedAt: tuple.nativeVerification.verifiedAt, + files: [...tuple.nativeVerification.files] + .sort((left, right) => compareAscii(left.path, right.path)) + .map((file) => ({ path: file.path, sha256: file.sha256 })) + } + } +} + +export function canonicalUnsignedSshRelayManifestBytes(input: unknown): Buffer { + const manifest = parseSshRelayUnsignedArtifactManifest(input) + // Why: signatures authenticate a fixed validated projection, never caller insertion order or + // signature-array contents that could vary during key rotation. + const unsignedProjection = { + schemaVersion: manifest.schemaVersion, + build: { + tag: manifest.build.tag, + channel: manifest.build.channel, + version: manifest.build.version, + relayProtocolVersion: manifest.build.relayProtocolVersion + }, + createdAt: manifest.createdAt, + tuples: [...manifest.tuples] + .sort((left, right) => compareAscii(left.tupleId, right.tupleId)) + .map(canonicalTuple) + } + return Buffer.from(JSON.stringify(unsignedProjection), 'utf8') +} + +export function sshRelayManifestKeyId(publicKey: Uint8Array): SshRelayDigest { + if (publicKey.byteLength !== nacl.sign.publicKeyLength) { + throw new Error('SSH relay manifest Ed25519 public key must be 32 bytes') + } + return `sha256:${createHash('sha256').update(publicKey).digest('hex')}` +} + +export function signSshRelayArtifactManifest( + manifest: SshRelayArtifactManifest | SshRelayUnsignedArtifactManifest, + secretKey: Uint8Array +): SshRelayManifestSignature { + if (secretKey.byteLength !== nacl.sign.secretKeyLength) { + throw new Error('SSH relay manifest Ed25519 secret key must be 64 bytes') + } + const publicKey = nacl.sign.keyPair.fromSecretKey(secretKey).publicKey + const signature = nacl.sign.detached(canonicalUnsignedSshRelayManifestBytes(manifest), secretKey) + return { + algorithm: 'ed25519-v1', + keyId: sshRelayManifestKeyId(publicKey), + signature: Buffer.from(signature).toString('base64') + } +} + +function acceptedKeyMap(keys: readonly SshRelayManifestAcceptedKey[]): Map { + const accepted = new Map() + for (const key of keys) { + const derivedKeyId = sshRelayManifestKeyId(key.publicKey) + if (key.keyId !== derivedKeyId) { + throw new Error(`SSH relay manifest public key ID does not match key bytes: ${key.keyId}`) + } + if (accepted.has(key.keyId)) { + throw new Error(`Duplicate accepted SSH relay manifest key: ${key.keyId}`) + } + accepted.set(key.keyId, key.publicKey) + } + return accepted +} + +export function verifySshRelayArtifactManifest( + input: unknown, + acceptedKeys: readonly SshRelayManifestAcceptedKey[] +): SshRelayArtifactManifest { + const manifest = parseSshRelayArtifactManifest(input) + const unsignedBytes = canonicalUnsignedSshRelayManifestBytes(manifest) + const keys = acceptedKeyMap(acceptedKeys) + let validAcceptedSignatures = 0 + + for (const signature of manifest.signatures) { + const publicKey = keys.get(signature.keyId) + if (!publicKey) { + throw new Error(`Unknown signing key in SSH relay manifest: ${signature.keyId}`) + } + const signatureBytes = Buffer.from(signature.signature, 'base64') + if (!nacl.sign.detached.verify(unsignedBytes, signatureBytes, publicKey)) { + throw new Error(`Invalid SSH relay manifest signature from key: ${signature.keyId}`) + } + validAcceptedSignatures += 1 + } + + if (validAcceptedSignatures === 0) { + throw new Error('SSH relay manifest requires at least one valid accepted signature') + } + return manifest +} diff --git a/src/main/ssh/ssh-relay-release-asset.test.ts b/src/main/ssh/ssh-relay-release-asset.test.ts new file mode 100644 index 00000000000..1ec020bb24d --- /dev/null +++ b/src/main/ssh/ssh-relay-release-asset.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { + parseSshRelayReleaseTag, + sshRelayRuntimeArchiveName, + sshRelayRuntimeDownloadUrl +} from './ssh-relay-release-asset' + +const contentId = `sha256:${'a'.repeat(64)}` as const + +describe('SSH relay release asset identity', () => { + it.each([ + ['v1.2.3', 'stable'], + ['v1.2.3-rc.4', 'rc'], + ['v1.2.3-rc.4.perf', 'perf'] + ] as const)('accepts exact %s tags', (tag, channel) => { + expect(parseSshRelayReleaseTag(tag).channel).toBe(channel) + }) + + it.each(['latest', 'v1.2', '1.2.3', 'v1.2.3-beta.1', 'v1.2.3-rc.1.other'])( + 'rejects mutable or unsupported tag %s', + (tag) => expect(() => parseSshRelayReleaseTag(tag)).toThrow(/release tag/i) + ) + + it('derives a content-qualified archive name and exact direct URL', () => { + const name = sshRelayRuntimeArchiveName('linux-x64-glibc', contentId) + + expect(name).toBe(`orca-ssh-relay-runtime-v1-linux-x64-glibc-${'a'.repeat(64)}.tar.xz`) + expect(sshRelayRuntimeDownloadUrl('v1.2.3-rc.4', name)).toBe( + `https://github.com/stablyai/orca/releases/download/v1.2.3-rc.4/${name}` + ) + expect(sshRelayRuntimeDownloadUrl('v1.2.3-rc.4', name)).not.toContain('/latest/') + }) +}) diff --git a/src/main/ssh/ssh-relay-release-asset.ts b/src/main/ssh/ssh-relay-release-asset.ts new file mode 100644 index 00000000000..1305507a11b --- /dev/null +++ b/src/main/ssh/ssh-relay-release-asset.ts @@ -0,0 +1,46 @@ +import type { SshRelayDigest, SshRelayRuntimeTupleId } from './ssh-relay-runtime-identity' + +const STABLE_TAG = /^v(\d+\.\d+\.\d+)$/ +const RC_TAG = /^v(\d+\.\d+\.\d+-rc\.\d+)$/ +const PERF_TAG = /^v(\d+\.\d+\.\d+-rc\.\d+\.perf)$/ +const CONTENT_ID = /^sha256:([0-9a-f]{64})$/ + +export type SshRelayReleaseIdentity = { + tag: string + version: string + channel: 'stable' | 'rc' | 'perf' +} + +export function parseSshRelayReleaseTag(tag: string): SshRelayReleaseIdentity { + for (const [pattern, channel] of [ + [STABLE_TAG, 'stable'], + [RC_TAG, 'rc'], + [PERF_TAG, 'perf'] + ] as const) { + const match = pattern.exec(tag) + if (match) { + return { tag, version: match[1], channel } + } + } + throw new Error(`Unsupported SSH relay release tag: ${tag}`) +} + +export function sshRelayRuntimeArchiveName( + tupleId: SshRelayRuntimeTupleId, + contentId: SshRelayDigest +): string { + const match = CONTENT_ID.exec(contentId) + if (!match) { + throw new Error(`Invalid SSH relay runtime content identity: ${contentId}`) + } + const extension = tupleId.startsWith('win32-') ? 'zip' : 'tar.xz' + return `orca-ssh-relay-runtime-v1-${tupleId}-${match[1]}.${extension}` +} + +export function sshRelayRuntimeDownloadUrl(tag: string, archiveName: string): string { + parseSshRelayReleaseTag(tag) + if (!/^orca-ssh-relay-runtime-v1-[A-Za-z0-9.-]+\.(?:tar\.xz|zip)$/.test(archiveName)) { + throw new Error(`Invalid SSH relay runtime archive name: ${archiveName}`) + } + return `https://github.com/stablyai/orca/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archiveName)}` +} diff --git a/src/main/ssh/ssh-relay-runtime-identity.test.ts b/src/main/ssh/ssh-relay-runtime-identity.test.ts new file mode 100644 index 00000000000..d86759021b0 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-identity.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity' + +function changedEntryContentId(role: string): string { + const tuple = structuredClone(createSshRelayArtifactTestManifest().tuples[0]) + const entry = tuple.entries.find( + (candidate) => candidate.type === 'file' && candidate.role === role + ) + if (!entry || entry.type !== 'file') { + throw new Error(`test fixture missing ${role}`) + } + entry.sha256 = `sha256:${'f'.repeat(64)}` + return computeSshRelayRuntimeContentId(tuple) +} + +describe('SSH relay runtime content identity', () => { + it('matches a fixed canonical test vector', () => { + const tuple = createSshRelayArtifactTestManifest().tuples[0] + + expect(computeSshRelayRuntimeContentId(tuple)).toBe( + 'sha256:5afe9c8094ec61a5eec6f7be6d1035faacee7362871985c74cc6ee6aceea8677' + ) + }) + + it.each(['node', 'node-pty-native', 'parcel-watcher-native', 'relay-watcher'])( + 'changes when only %s bytes change', + (role) => { + const tuple = createSshRelayArtifactTestManifest().tuples[0] + expect(changedEntryContentId(role)).not.toBe(computeSshRelayRuntimeContentId(tuple)) + } + ) + + it('changes when an executable mode changes', () => { + const tuple = structuredClone(createSshRelayArtifactTestManifest().tuples[0]) + const node = tuple.entries.find((entry) => entry.type === 'file' && entry.role === 'node') + if (!node || node.type !== 'file') { + throw new Error('test fixture missing node') + } + node.mode = 0o644 + + expect(computeSshRelayRuntimeContentId(tuple)).not.toBe( + createSshRelayArtifactTestManifest().tuples[0].contentId + ) + }) + + it('is independent of entry ordering and release metadata', () => { + const manifest = createSshRelayArtifactTestManifest() + const tuple = structuredClone(manifest.tuples[0]) + tuple.entries.reverse() + tuple.archive.name = 'ignored-by-runtime-identity.tar.xz' + tuple.archive.sha256 = `sha256:${'f'.repeat(64)}` + tuple.metadataAssets.sbom.sha256 = `sha256:${'e'.repeat(64)}` + tuple.nativeVerification.verifiedAt = '2030-01-01T00:00:00.000Z' + + expect(computeSshRelayRuntimeContentId(tuple)).toBe(manifest.tuples[0].contentId) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-identity.ts b/src/main/ssh/ssh-relay-runtime-identity.ts new file mode 100644 index 00000000000..bf88d063aa8 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-identity.ts @@ -0,0 +1,145 @@ +import { createHash } from 'node:crypto' + +export type SshRelayDigest = `sha256:${string}` +export type SshRelayRuntimeTupleId = + | 'linux-x64-glibc' + | 'linux-arm64-glibc' + | 'linux-x64-musl' + | 'linux-arm64-musl' + | 'darwin-x64' + | 'darwin-arm64' + | 'win32-x64' + | 'win32-arm64' + +export type SshRelayRuntimeFileRole = + | 'node' + | 'relay' + | 'relay-watcher' + | 'node-pty-native' + | 'parcel-watcher-native' + | 'native-runtime' + | 'runtime-javascript' + | 'license' + +export type SshRelayRuntimeEntry = + | { path: string; type: 'directory'; mode: 0o755 } + | { + path: string + type: 'file' + role: SshRelayRuntimeFileRole + size: number + mode: 0o644 | 0o755 + sha256: SshRelayDigest + } + +export type SshRelayLinuxCompatibility = { + kind: 'linux' + minimumKernelVersion: string + libc: + | { + family: 'glibc' + minimumVersion: string + minimumLibstdcxxVersion: string + minimumGlibcxxVersion: string + } + | { + family: 'musl' + minimumVersion: string + minimumLibstdcxxVersion: null + minimumGlibcxxVersion: null + } +} + +export type SshRelayRuntimeCompatibility = + | SshRelayLinuxCompatibility + | { kind: 'darwin'; minimumVersion: string } + | { + kind: 'windows' + minimumBuild: number + minimumOpenSshVersion: string + minimumPowerShellVersion: string + minimumDotNetFrameworkRelease: number + } + +export type SshRelayRuntimeIdentityInput = { + tupleId: SshRelayRuntimeTupleId + os: 'linux' | 'darwin' | 'win32' + architecture: 'x64' | 'arm64' + compatibility: SshRelayRuntimeCompatibility + nodeVersion: string + dependencies: { + nodePtyVersion: string + parcelWatcherVersion: string + } + entries: SshRelayRuntimeEntry[] +} + +function canonicalCompatibility(compatibility: SshRelayRuntimeCompatibility): object { + if (compatibility.kind === 'linux') { + return { + kind: compatibility.kind, + minimumKernelVersion: compatibility.minimumKernelVersion, + libc: { + family: compatibility.libc.family, + minimumVersion: compatibility.libc.minimumVersion, + minimumLibstdcxxVersion: compatibility.libc.minimumLibstdcxxVersion, + minimumGlibcxxVersion: compatibility.libc.minimumGlibcxxVersion + } + } + } + if (compatibility.kind === 'darwin') { + return { kind: compatibility.kind, minimumVersion: compatibility.minimumVersion } + } + return { + kind: compatibility.kind, + minimumBuild: compatibility.minimumBuild, + minimumOpenSshVersion: compatibility.minimumOpenSshVersion, + minimumPowerShellVersion: compatibility.minimumPowerShellVersion, + minimumDotNetFrameworkRelease: compatibility.minimumDotNetFrameworkRelease + } +} + +export function canonicalSshRelayRuntimeIdentityBytes( + runtime: SshRelayRuntimeIdentityInput +): Buffer { + const entries = [...runtime.entries] + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) + .map((entry) => + entry.type === 'directory' + ? { path: entry.path, type: entry.type, mode: entry.mode } + : { + path: entry.path, + type: entry.type, + role: entry.role, + size: entry.size, + mode: entry.mode, + sha256: entry.sha256 + } + ) + + // Why: runtime identity excludes archive packing, timestamps, SBOM, and signing metadata so the + // same executable tree has one stable content address across reproducible release rebuilds. + const projection = { + identitySchemaVersion: 1, + tupleId: runtime.tupleId, + os: runtime.os, + architecture: runtime.architecture, + compatibility: canonicalCompatibility(runtime.compatibility), + nodeVersion: runtime.nodeVersion, + dependencies: { + nodePtyVersion: runtime.dependencies.nodePtyVersion, + parcelWatcherVersion: runtime.dependencies.parcelWatcherVersion + }, + entries + } + return Buffer.from(JSON.stringify(projection), 'utf8') +} + +export function computeSshRelayRuntimeContentId( + runtime: SshRelayRuntimeIdentityInput +): SshRelayDigest { + const hex = createHash('sha256') + .update(canonicalSshRelayRuntimeIdentityBytes(runtime)) + .digest('hex') + return `sha256:${hex}` +}