From 6aa3f01d3a444cef3036c7dae78613ed547ced3a Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 04:39:19 -0400 Subject: [PATCH 01/12] Publish remote runtime payloads and pin their manifest into iOS The macos job copies the bundled SSH runtime payloads (after the darwin archive is signed and repacked) into the release as remote-runtime-.tar.gz with a combined remote-runtime-manifest.json. The ios job waits for macos and embeds that manifest through OXBIT_REMOTE_RUNTIME_MANIFEST; remote_runtime.rs validates it and builds the release download URL. --- .github/workflows/release.yml | 13 ++ apps/ios/src-tauri/build.rs | 22 +++ apps/ios/src-tauri/src/lib.rs | 1 + apps/ios/src-tauri/src/remote_runtime.rs | 178 +++++++++++++++++++++++ docs/INDEX.md | 1 + docs/release.md | 8 +- docs/remote-ssh.md | 13 ++ scripts/desktop/publish-release.mjs | 10 +- scripts/remote/release-manifest.mjs | 80 ++++++++++ tests/remote-release-manifest.test.ts | 76 ++++++++++ 10 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 apps/ios/src-tauri/src/remote_runtime.rs create mode 100644 scripts/remote/release-manifest.mjs create mode 100644 tests/remote-release-manifest.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c601305..13868911 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,13 +91,21 @@ jobs: tar -czf "release-files/Oxbit_${version}_web.tar.gz" -C apps/web/dist . tar -czf "release-files/Oxbit_${version}_runtime.tar.gz" -C apps/runtime/dist . tar -czf "release-files/Oxbit_${version}_extensions.tar.gz" -C examples/bundle-inspector/dist . + node scripts/remote/release-manifest.mjs release-files apps/desktop/src-tauri/resources/runtime/remote ls -l release-files - uses: actions/upload-artifact@v4 with: name: release-macos path: release-files/ if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: remote-manifest + path: release-files/remote-runtime-manifest.json + if-no-files-found: error + # The pinned manifest records the darwin payload after the macos job signs and repacks it. ios: + needs: [remote-runtime, macos] runs-on: macos-26 timeout-minutes: 90 steps: @@ -129,8 +137,13 @@ jobs: - name: Check TestFlight testing notes if: github.ref_type == 'tag' run: node scripts/ios/distribute-testflight.mjs --build '${{ steps.version.outputs.build }}' --check-notes + - uses: actions/download-artifact@v4 + with: + name: remote-manifest + path: .remote-manifest - name: Signed App Store build env: + OXBIT_REMOTE_RUNTIME_MANIFEST: ${{ github.workspace }}/.remote-manifest/remote-runtime-manifest.json IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} diff --git a/apps/ios/src-tauri/build.rs b/apps/ios/src-tauri/build.rs index 261851f6..005d4db5 100644 --- a/apps/ios/src-tauri/build.rs +++ b/apps/ios/src-tauri/build.rs @@ -1,3 +1,25 @@ +use std::{env, fs, path::PathBuf}; + +const MANIFEST_VARIABLE: &str = "OXBIT_REMOTE_RUNTIME_MANIFEST"; + fn main() { + println!("cargo:rerun-if-env-changed={MANIFEST_VARIABLE}"); + let pinned = match env::var_os(MANIFEST_VARIABLE).filter(|value| !value.is_empty()) { + Some(file) => { + let file = PathBuf::from(file); + println!("cargo:rerun-if-changed={}", file.display()); + let text = fs::read_to_string(&file).unwrap_or_else(|error| { + panic!("{MANIFEST_VARIABLE} ({}): {error}", file.display()) + }); + format!("Some({text:?})") + } + None => "None".to_owned(), + }; + let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")); + fs::write( + output.join("remote_runtime_manifest.rs"), + format!("const PINNED_MANIFEST: Option<&str> = {pinned};\n"), + ) + .expect("write the pinned remote runtime manifest"); tauri_build::build(); } diff --git a/apps/ios/src-tauri/src/lib.rs b/apps/ios/src-tauri/src/lib.rs index f278fe40..75bee0ac 100644 --- a/apps/ios/src-tauri/src/lib.rs +++ b/apps/ios/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod git_host; mod git_operations; mod git_requests; mod icon_packs; +pub mod remote_runtime; mod storage; mod watch; diff --git a/apps/ios/src-tauri/src/remote_runtime.rs b/apps/ios/src-tauri/src/remote_runtime.rs new file mode 100644 index 00000000..1cbddbcb --- /dev/null +++ b/apps/ios/src-tauri/src/remote_runtime.rs @@ -0,0 +1,178 @@ +//! The remote runtime manifest that the release workflow pins into the iOS build. + +use serde::Deserialize; +use std::collections::BTreeMap; + +include!(concat!(env!("OUT_DIR"), "/remote_runtime_manifest.rs")); + +pub const PLATFORMS: [&str; 2] = ["darwin-arm64", "linux-x64"]; +const RELEASE_DOWNLOADS: &str = "https://github.com/yannelli/oxbit/releases/download"; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub version: String, + pub platforms: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Download { + pub url: String, + pub sha256: String, + pub size: u64, +} + +/// `Ok(None)` when the build had no `OXBIT_REMOTE_RUNTIME_MANIFEST`. +pub fn pinned_manifest() -> Result, String> { + PINNED_MANIFEST + .map(|text| parse(text, env!("CARGO_PKG_VERSION"))) + .transpose() +} + +pub fn pinned_download(platform: &str) -> Result, String> { + pinned_manifest()? + .map(|manifest| manifest.download(platform)) + .transpose() +} + +pub fn parse(text: &str, app_version: &str) -> Result { + let manifest: Manifest = serde_json::from_str(text) + .map_err(|error| format!("Invalid remote runtime manifest: {error}"))?; + if manifest.version != app_version { + return Err(format!( + "Remote runtime manifest is for {}; this app is {app_version}", + manifest.version + )); + } + if manifest.platforms.len() != PLATFORMS.len() + || !PLATFORMS + .iter() + .all(|platform| manifest.platforms.contains_key(*platform)) + { + return Err(format!( + "Remote runtime manifest must list exactly {}", + PLATFORMS.join(", ") + )); + } + for (platform, payload) in &manifest.platforms { + let hex = payload + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + if payload.sha256.len() != 64 || !hex { + return Err(format!("Invalid remote runtime sha256 for {platform}")); + } + if payload.size == 0 { + return Err(format!("Empty remote runtime payload for {platform}")); + } + } + Ok(manifest) +} + +impl Manifest { + pub fn download(&self, platform: &str) -> Result { + let payload = self + .platforms + .get(platform) + .ok_or_else(|| format!("Unsupported remote runtime platform: {platform}"))?; + Ok(Download { + url: format!( + "{RELEASE_DOWNLOADS}/v{}/remote-runtime-{platform}.tar.gz", + self.version + ), + sha256: payload.sha256.clone(), + size: payload.size, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DARWIN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const LINUX: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + fn manifest(version: &str, platforms: &str) -> String { + format!(r#"{{"version":"{version}","platforms":{{{platforms}}}}}"#) + } + + fn both() -> String { + format!( + r#""darwin-arm64":{{"sha256":"{DARWIN}","size":52000000}},"linux-x64":{{"sha256":"{LINUX}","size":48000000}}"# + ) + } + + #[test] + fn parses_a_release_manifest_and_builds_download_urls() { + let parsed = parse(&manifest("0.4.0-alpha.1", &both()), "0.4.0-alpha.1").unwrap(); + assert_eq!( + parsed.download("linux-x64").unwrap(), + Download { + url: "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-linux-x64.tar.gz".into(), + sha256: LINUX.into(), + size: 48_000_000, + } + ); + assert_eq!( + parsed.download("darwin-arm64").unwrap().url, + "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-darwin-arm64.tar.gz" + ); + assert!(parsed.download("darwin-x64").is_err()); + } + + #[test] + fn rejects_a_manifest_for_another_version() { + let error = parse(&manifest("0.3.3", &both()), "0.3.4").unwrap_err(); + assert!(error.contains("0.3.3"), "{error}"); + } + + #[test] + fn rejects_missing_or_unknown_platforms() { + let darwin_only = format!(r#""darwin-arm64":{{"sha256":"{DARWIN}","size":1}}"#); + assert!(parse(&manifest("0.3.4", &darwin_only), "0.3.4").is_err()); + let with_intel = format!( + r#"{},"darwin-x64":{{"sha256":"{DARWIN}","size":1}}"#, + both() + ); + assert!(parse(&manifest("0.3.4", &with_intel), "0.3.4").is_err()); + } + + #[test] + fn rejects_malformed_digests_sizes_and_fields() { + for (digest, size) in [ + (&DARWIN.to_uppercase()[..], "1"), + (&DARWIN[..63], "1"), + (&format!("{}g", &DARWIN[..63])[..], "1"), + (DARWIN, "0"), + ] { + let platforms = format!( + r#""darwin-arm64":{{"sha256":"{digest}","size":{size}}},"linux-x64":{{"sha256":"{LINUX}","size":1}}"# + ); + assert!( + parse(&manifest("0.3.4", &platforms), "0.3.4").is_err(), + "{digest} {size}" + ); + } + let extra = format!( + r#"{{"version":"0.3.4","schema":1,"platforms":{{{}}}}}"#, + both() + ); + assert!(parse(&extra, "0.3.4").is_err()); + } + + #[test] + fn pinned_manifest_matches_the_build() { + assert_eq!( + pinned_manifest().unwrap().is_some(), + PINNED_MANIFEST.is_some() + ); + } +} diff --git a/docs/INDEX.md b/docs/INDEX.md index 106cbbfb..8674f6d3 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -6,6 +6,7 @@ Created: 2026-10-02. Last updated: 2026-10-08. | --- | --- | | [Signed releases](release.md) | Cutting a release; signing and publishing; versioned TestFlight notes, `Internal Testing` assignment, and `Public Beta` promotion; secrets and renewal; Apple TestFlight API references for distribution changes. | | [npm packages](npm.md) | Publishing `@oxbit/sdk` and `@oxbit/cli`; staging layout; install scripts; trusted publishing setup and token removal; npm trusted publishing and provenance references. | +| [Remote workspaces over SSH](remote-ssh.md) | Building SSH runtime payloads; the release's `remote-runtime-*` assets and the manifest pinned into the iOS build. | | [iOS setup](ios.md) | Building the iOS app or connecting to a computer runtime. | | [iOS release artwork](ios-release-assets.md) | Rendering app icon exports, native App Store screenshots, promo banners, and listing drafts; includes Apple design, asset specifications, and app privacy references. | | [Withdrawn v0.3.1 audit](release-audit-0.3.1.md) | Comparing alpha.5 with the withdrawn release, confirmed corrections, and unresolved CI failures. | diff --git a/docs/release.md b/docs/release.md index cb53c97b..ddc6fb39 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # Signed releases -Created: 2026-10-02. Last updated: 2026-10-07. +Created: 2026-10-02. Last updated: 2026-10-08. `.github/workflows/release.yml` builds, signs, and publishes a release when a `v` tag is pushed. The tag must match the root `package.json` version. @@ -8,10 +8,12 @@ Created: 2026-10-02. Last updated: 2026-10-07. | Job | Output | | --- | --- | | `remote-runtime` | SSH runtime payloads for `darwin-arm64` and `linux-x64` | -| `macos` | Developer ID signed, notarized, and stapled app and DMG; signed updater archive; web, runtime, and extension tarballs | -| `ios` | App Store IPA, validated and uploaded to TestFlight with tester notes and assignment to the internal `Internal Testing` group | +| `macos` | Developer ID signed, notarized, and stapled app and DMG; signed updater archive; web, runtime, and extension tarballs; the bundled SSH runtime payloads and `remote-runtime-manifest.json` | +| `ios` | App Store IPA with the pinned `remote-runtime-manifest.json`, validated and uploaded to TestFlight with tester notes and assignment to the internal `Internal Testing` group; starts after `macos` | | `publish` | GitHub release with the files above, `latest.json`, and `SHA256SUMS` | +The SSH runtime release assets and the iOS manifest pin are described in [Remote workspaces over SSH](remote-ssh.md#release-assets). + A version with a hyphen (`0.3.0-alpha.5`) publishes as a pre-release. The updater reads `releases/latest/download/latest.json`, and GitHub's latest release skips pre-releases, so stable installs are not offered an alpha. diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md index 1460b738..21bea244 100644 --- a/docs/remote-ssh.md +++ b/docs/remote-ssh.md @@ -1,5 +1,7 @@ # Remote workspaces over SSH +Created: 2026-09-08. Last updated: 2026-10-08. + Choose **Connect over SSH…** in Oxbit's project bar, File menu, or command palette. Enter an SSH config alias or `user@host`, a remote folder or file such as `~/projects/app`, and an optional port. Remote projects appear in the project switcher and recent projects with an **SSH** label and restore when the app starts. Oxbit uses the system OpenSSH client and your existing `~/.ssh/config`, identity files, SSH agent, and jump-host configuration. Connect to a new host once in a terminal to verify its host key and confirm key authentication. Password prompts and host-key acceptance are not embedded in the editor. The connector requires a known host and noninteractive key authentication; it never disables host-key checking or forwards your authentication agent. @@ -28,6 +30,17 @@ Run `bun run remote:prepare` to build and include both payloads in `apps/desktop For a native single-target artifact, set `OXBIT_REMOTE_TARGETS=darwin-arm64` or `OXBIT_REMOTE_TARGETS=linux-x64`. The desktop CI workflow builds these on their native runners, then assembles both artifacts into each desktop application using `OXBIT_REMOTE_PAYLOADS`. This avoids a Docker dependency on macOS CI runners. Missing platform payloads fail explicitly at connection time. +## Release assets + +Each tagged release attaches the payloads the macOS app bundles, copied after `scripts/desktop/sign-resources.mjs` signs and repacks the darwin archive: + +- `remote-runtime-darwin-arm64.tar.gz` and `remote-runtime-linux-x64.tar.gz` +- `remote-runtime-manifest.json`: `{"version": "", "platforms": {"": {"sha256": "", "size": }}}` + +`node scripts/remote/release-manifest.mjs ...` writes these files. Each payload directory holds `manifest.json` and `.tar.gz`, as produced by `bun run remote:prepare`. The script checks each archive against its `manifest.json` digest and requires both platforms. + +The iOS release build embeds the combined manifest. The `ios` job in `release.yml` waits for the `macos` job, downloads the `remote-manifest` artifact, and sets `OXBIT_REMOTE_RUNTIME_MANIFEST` to its path. `apps/ios/src-tauri/build.rs` turns that file into a constant; without the variable, local and simulator builds compile with no manifest. `remote_runtime::pinned_manifest()` and `remote_runtime::pinned_download(platform)` in `apps/ios/src-tauri/src/remote_runtime.rs` reject a manifest with unknown fields, a missing or unknown platform, a digest other than 64 lowercase hex characters, an empty payload, or a version other than the app's Cargo version. Download URLs follow `https://github.com/yannelli/oxbit/releases/download/v/remote-runtime-.tar.gz`. Desktop and iOS install the same digest into `~/.oxbit/remote/runtimes/` on a host, so they share one installed runtime. + - `bun run test` includes SSH target validation, shell quoting, checksum failure, and installation tests. - `bun run remote:test` builds an ephemeral Linux SSH container, creates temporary keys and a private known-hosts file, removes its outbound route, and exercises cold installation, remote file editing, conflicts, search, Git, real PTYs, TypeScript completion, token rotation, cached reconnection, shutdown, and transport loss. It cleans up the container and temporary keys. - After `bun run desktop:test:build`, `bun run remote:test:native` checks the connection dialog, keyboard dismissal, input validation, and URI submission in the native WebView. Its submission is captured by the UI test; the separate SSH smoke test covers real transport. diff --git a/scripts/desktop/publish-release.mjs b/scripts/desktop/publish-release.mjs index e8bfb3e4..c8e42ddb 100644 --- a/scripts/desktop/publish-release.mjs +++ b/scripts/desktop/publish-release.mjs @@ -24,7 +24,15 @@ const files = (await fs.readdir(directory)) .filter((name) => !generated.includes(name)) .sort() .map((name) => path.join(directory, name)); -for (const suffix of [".dmg", ".app.tar.gz", ".app.tar.gz.sig", "_ios.ipa"]) +for (const suffix of [ + ".dmg", + ".app.tar.gz", + ".app.tar.gz.sig", + "_ios.ipa", + "remote-runtime-darwin-arm64.tar.gz", + "remote-runtime-linux-x64.tar.gz", + "remote-runtime-manifest.json", +]) if (!files.some((file) => file.endsWith(suffix))) throw new Error(`Missing release file *${suffix}`); const prerelease = version.includes("-"); diff --git a/scripts/remote/release-manifest.mjs b/scripts/remote/release-manifest.mjs new file mode 100644 index 00000000..d422bc38 --- /dev/null +++ b/scripts/remote/release-manifest.mjs @@ -0,0 +1,80 @@ +/** Copy the bundled remote payloads into the release directory with one combined manifest. */ +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const remotePlatforms = ["darwin-arm64", "linux-x64"]; + +export async function buildReleaseManifest(directories, version) { + const found = {}; + for (const directory of directories) { + const source = JSON.parse( + await fs.readFile(path.join(directory, "manifest.json"), "utf8"), + ); + for (const [platform, entry] of Object.entries(source.platforms ?? {})) { + if (!remotePlatforms.includes(platform)) + throw new Error(`Unsupported remote platform: ${platform}`); + if (found[platform]) + throw new Error(`Duplicate remote platform: ${platform}`); + const archive = path.join(directory, `${platform}.tar.gz`); + const bytes = await fs.readFile(archive); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (sha256 !== entry.sha256) + throw new Error(`Remote payload checksum mismatch: ${platform}`); + found[platform] = { archive, sha256, size: bytes.length }; + } + } + const missing = remotePlatforms.filter((platform) => !found[platform]); + if (missing.length) + throw new Error(`Missing remote payloads: ${missing.join(", ")}`); + return { + archives: Object.fromEntries( + remotePlatforms.map((platform) => [platform, found[platform].archive]), + ), + manifest: { + version, + platforms: Object.fromEntries( + remotePlatforms.map((platform) => [ + platform, + { sha256: found[platform].sha256, size: found[platform].size }, + ]), + ), + }, + }; +} + +export async function writeReleaseAssets(output, directories, version) { + const { archives, manifest } = await buildReleaseManifest( + directories, + version, + ); + await fs.mkdir(output, { recursive: true }); + for (const [platform, archive] of Object.entries(archives)) + await fs.copyFile( + archive, + path.join(output, `remote-runtime-${platform}.tar.gz`), + ); + await fs.writeFile( + path.join(output, "remote-runtime-manifest.json"), + JSON.stringify(manifest, null, 2) + "\n", + ); + return manifest; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [output, ...directories] = process.argv.slice(2); + if (!output || !directories.length) + throw new Error( + "Usage: node scripts/remote/release-manifest.mjs ...", + ); + const { version } = JSON.parse( + await fs.readFile(new URL("../../package.json", import.meta.url), "utf8"), + ); + const manifest = await writeReleaseAssets( + path.resolve(output), + directories.map((directory) => path.resolve(directory)), + version, + ); + console.log(JSON.stringify(manifest, null, 2)); +} diff --git a/tests/remote-release-manifest.test.ts b/tests/remote-release-manifest.test.ts new file mode 100644 index 00000000..28f77db3 --- /dev/null +++ b/tests/remote-release-manifest.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +// @ts-expect-error plain-node release script without types +import { buildReleaseManifest, writeReleaseAssets } from "../scripts/remote/release-manifest.mjs"; + +const temps: string[] = []; +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + +function payloads(contents: Record, manifest?: Record) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "oxbit-remote-manifest-")); + temps.push(directory); + const platforms: Record = {}; + for (const [platform, text] of Object.entries(contents)) { + const bytes = Buffer.from(text); + fs.writeFileSync(path.join(directory, `${platform}.tar.gz`), bytes); + platforms[platform] = { sha256: sha256(bytes) }; + } + fs.writeFileSync(path.join(directory, "manifest.json"), JSON.stringify({ version: 1, platforms: manifest ?? platforms })); + return directory; +} + +afterEach(() => { + for (const directory of temps.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); +}); + +describe("remote runtime release manifest", () => { + it("combines per-platform artifact directories with sizes and the version", async () => { + const linux = payloads({ "linux-x64": "linux payload" }); + const darwin = payloads({ "darwin-arm64": "darwin" }); + const { manifest } = await buildReleaseManifest([linux, darwin], "0.4.0-alpha.1"); + expect(manifest).toEqual({ + version: "0.4.0-alpha.1", + platforms: { + "darwin-arm64": { sha256: sha256(Buffer.from("darwin")), size: 6 }, + "linux-x64": { sha256: sha256(Buffer.from("linux payload")), size: 13 }, + }, + }); + }); + + it("copies the archives byte for byte and writes the manifest", async () => { + const bundled = payloads({ "darwin-arm64": "signed darwin", "linux-x64": "linux" }); + const output = fs.mkdtempSync(path.join(os.tmpdir(), "oxbit-release-files-")); + temps.push(output); + const manifest = await writeReleaseAssets(output, [bundled], "0.3.4"); + expect(fs.readdirSync(output).sort()).toEqual([ + "remote-runtime-darwin-arm64.tar.gz", + "remote-runtime-linux-x64.tar.gz", + "remote-runtime-manifest.json", + ]); + for (const platform of ["darwin-arm64", "linux-x64"]) { + const published = fs.readFileSync(path.join(output, `remote-runtime-${platform}.tar.gz`)); + expect(published.equals(fs.readFileSync(path.join(bundled, `${platform}.tar.gz`)))).toBe(true); + expect(sha256(published)).toBe(manifest.platforms[platform].sha256); + } + expect(JSON.parse(fs.readFileSync(path.join(output, "remote-runtime-manifest.json"), "utf8"))).toEqual(manifest); + }); + + it("rejects an archive that does not match its manifest", async () => { + const directory = payloads({ "darwin-arm64": "a", "linux-x64": "b" }, { + "darwin-arm64": { sha256: sha256(Buffer.from("a")) }, + "linux-x64": { sha256: sha256(Buffer.from("rebuilt")) }, + }); + await expect(buildReleaseManifest([directory], "0.3.4")).rejects.toThrow("Remote payload checksum mismatch: linux-x64"); + }); + + it("rejects missing, duplicate, and unknown platforms", async () => { + const linux = payloads({ "linux-x64": "linux" }); + await expect(buildReleaseManifest([linux], "0.3.4")).rejects.toThrow("Missing remote payloads: darwin-arm64"); + await expect(buildReleaseManifest([linux, linux], "0.3.4")).rejects.toThrow("Duplicate remote platform: linux-x64"); + const intel = payloads({ "darwin-x64": "intel" }); + await expect(buildReleaseManifest([intel], "0.3.4")).rejects.toThrow("Unsupported remote platform: darwin-x64"); + }); +}); From 5ae191109e4b151fdb2c87311a46e1562bd579e7 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 04:49:12 -0400 Subject: [PATCH 02/12] Pass the iOS remote runtime manifest through the Tauri-forwarded environment tauri ios build drops OXBIT_ variables before cargo runs in the Xcode script phase, so the pin uses TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST. --- .github/workflows/release.yml | 2 +- apps/ios/src-tauri/build.rs | 4 +++- apps/ios/src-tauri/src/remote_runtime.rs | 2 +- docs/remote-ssh.md | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13868911..3644debd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,7 +143,7 @@ jobs: path: .remote-manifest - name: Signed App Store build env: - OXBIT_REMOTE_RUNTIME_MANIFEST: ${{ github.workspace }}/.remote-manifest/remote-runtime-manifest.json + TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST: ${{ github.workspace }}/.remote-manifest/remote-runtime-manifest.json IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} diff --git a/apps/ios/src-tauri/build.rs b/apps/ios/src-tauri/build.rs index 005d4db5..fbc06399 100644 --- a/apps/ios/src-tauri/build.rs +++ b/apps/ios/src-tauri/build.rs @@ -1,6 +1,8 @@ use std::{env, fs, path::PathBuf}; -const MANIFEST_VARIABLE: &str = "OXBIT_REMOTE_RUNTIME_MANIFEST"; +// `tauri ios build` runs cargo from the Xcode script phase with a filtered +// environment that keeps TAURI_ variables and drops OXBIT_ ones. +const MANIFEST_VARIABLE: &str = "TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST"; fn main() { println!("cargo:rerun-if-env-changed={MANIFEST_VARIABLE}"); diff --git a/apps/ios/src-tauri/src/remote_runtime.rs b/apps/ios/src-tauri/src/remote_runtime.rs index 1cbddbcb..7588a186 100644 --- a/apps/ios/src-tauri/src/remote_runtime.rs +++ b/apps/ios/src-tauri/src/remote_runtime.rs @@ -29,7 +29,7 @@ pub struct Download { pub size: u64, } -/// `Ok(None)` when the build had no `OXBIT_REMOTE_RUNTIME_MANIFEST`. +/// `Ok(None)` when the build had no `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST`. pub fn pinned_manifest() -> Result, String> { PINNED_MANIFEST .map(|text| parse(text, env!("CARGO_PKG_VERSION"))) diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md index 21bea244..114c9f17 100644 --- a/docs/remote-ssh.md +++ b/docs/remote-ssh.md @@ -39,7 +39,7 @@ Each tagged release attaches the payloads the macOS app bundles, copied after `s `node scripts/remote/release-manifest.mjs ...` writes these files. Each payload directory holds `manifest.json` and `.tar.gz`, as produced by `bun run remote:prepare`. The script checks each archive against its `manifest.json` digest and requires both platforms. -The iOS release build embeds the combined manifest. The `ios` job in `release.yml` waits for the `macos` job, downloads the `remote-manifest` artifact, and sets `OXBIT_REMOTE_RUNTIME_MANIFEST` to its path. `apps/ios/src-tauri/build.rs` turns that file into a constant; without the variable, local and simulator builds compile with no manifest. `remote_runtime::pinned_manifest()` and `remote_runtime::pinned_download(platform)` in `apps/ios/src-tauri/src/remote_runtime.rs` reject a manifest with unknown fields, a missing or unknown platform, a digest other than 64 lowercase hex characters, an empty payload, or a version other than the app's Cargo version. Download URLs follow `https://github.com/yannelli/oxbit/releases/download/v/remote-runtime-.tar.gz`. Desktop and iOS install the same digest into `~/.oxbit/remote/runtimes/` on a host, so they share one installed runtime. +The iOS release build embeds the combined manifest. The `ios` job in `release.yml` waits for the `macos` job, downloads the `remote-manifest` artifact, and sets `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` to its absolute path. `tauri ios build` runs cargo from the Xcode script phase and with a filtered environment that keeps `TAURI_` variables and drops `OXBIT_` ones. `apps/ios/src-tauri/build.rs` turns that file into a constant; without the variable, local and simulator builds compile with no manifest. `remote_runtime::pinned_manifest()` and `remote_runtime::pinned_download(platform)` in `apps/ios/src-tauri/src/remote_runtime.rs` reject a manifest with unknown fields, a missing or unknown platform, a digest other than 64 lowercase hex characters, an empty payload, or a version other than the app's Cargo version. Download URLs follow `https://github.com/yannelli/oxbit/releases/download/v/remote-runtime-.tar.gz`. Desktop and iOS install the same digest into `~/.oxbit/remote/runtimes/` on a host, so they share one installed runtime. - `bun run test` includes SSH target validation, shell quoting, checksum failure, and installation tests. - `bun run remote:test` builds an ephemeral Linux SSH container, creates temporary keys and a private known-hosts file, removes its outbound route, and exercises cold installation, remote file editing, conflicts, search, Git, real PTYs, TypeScript completion, token rotation, cached reconnection, shutdown, and transport loss. It cleans up the container and temporary keys. From 250f8842914b9b1840f57dfb37669b299826251c Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 05:04:00 -0400 Subject: [PATCH 03/12] Start an Oxbit runtime on an SSH host from iOS Mirror the desktop remote runtime: detect the platform, reuse the shared ~/.oxbit/remote/runtimes/ install, let the host download the pinned archive with curl or wget, or verify and stream it from the device. Launch desktop.js with the same environment scrubbing and frame protocol, tunnel a loopback port over direct-tcpip, answer taskForward, send heartbeats, and relaunch after a dropped connection within a three-per-minute budget. --- .../ios/Sources/OxbitFilesPlugin.swift | 5 + .../ios/Sources/RuntimeDownload.swift | 48 +++ apps/ios/plugins/oxbit-files/src/desktop.rs | 4 + apps/ios/plugins/oxbit-files/src/mobile.rs | 10 + apps/ios/src-tauri/src/lib.rs | 3 + apps/ios/src-tauri/src/ssh/mod.rs | 17 +- .../ios/src-tauri/src/ssh/runtime_commands.rs | 143 +++++++++ apps/ios/src-tauri/src/ssh/runtime_frames.rs | 157 +++++++++ apps/ios/src-tauri/src/ssh/runtime_install.rs | 202 ++++++++++++ apps/ios/src-tauri/src/ssh/runtime_process.rs | 162 ++++++++++ .../ios/src-tauri/src/ssh/runtime_protocol.rs | 188 +++++++++++ .../src/ssh/runtime_protocol_tests.rs | 240 ++++++++++++++ apps/ios/src-tauri/src/ssh/runtime_session.rs | 300 ++++++++++++++++++ apps/ios/src-tauri/src/ssh/runtime_tunnel.rs | 117 +++++++ 14 files changed, 1593 insertions(+), 3 deletions(-) create mode 100644 apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift create mode 100644 apps/ios/src-tauri/src/ssh/runtime_commands.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_frames.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_install.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_process.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_protocol.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_session.rs create mode 100644 apps/ios/src-tauri/src/ssh/runtime_tunnel.rs diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift index 0bd41eed..9f96c10b 100644 --- a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift +++ b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift @@ -28,6 +28,7 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate { private let sshKeys = SshKeys() private let filePicker = FilePicker() private let signing = CommitSigning() + private let runtimeDownload = RuntimeDownload() private var pending: Invoke? private var open: [String: URL] = [:] @@ -60,6 +61,10 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate { sshKeys.handle(invoke) } + @objc public func downloadRuntime(_ invoke: Invoke) { + runtimeDownload.handle(invoke) + } + @objc public func pickFiles(_ invoke: Invoke) { filePicker.pick(invoke, from: manager.viewController) } diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift new file mode 100644 index 00000000..8a4c6193 --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift @@ -0,0 +1,48 @@ +import Foundation +import Tauri + +private struct RuntimeDownloadArgs: Decodable { + let url: String + let sha256: String +} + +/// Downloads the pinned remote runtime archive to Caches; Rust checks its digest before use. +final class RuntimeDownload { + private lazy var session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 600 + return URLSession(configuration: config) + }() + + func handle(_ invoke: Invoke) { + do { + let args = try invoke.parseArgs(RuntimeDownloadArgs.self) + guard let url = URL(string: args.url), url.scheme == "https", + args.sha256.count == 64, args.sha256.allSatisfy({ "0123456789abcdef".contains($0) }) + else { return invoke.reject("Invalid remote runtime download") } + let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("remote-runtime", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent("\(args.sha256).tar.gz") + if FileManager.default.fileExists(atPath: destination.path) { + return invoke.resolve(["path": destination.path]) + } + session.downloadTask(with: url) { location, response, error in + guard error == nil, let location, (response as? HTTPURLResponse)?.statusCode == 200 else { + return invoke.reject("Could not download the remote runtime. Check the network connection and retry.") + } + do { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: location, to: destination) + invoke.resolve(["path": destination.path]) + } catch { + invoke.reject("Could not save the remote runtime download: \(error.localizedDescription)") + } + }.resume() + } catch { + invoke.reject(error.localizedDescription) + } + } +} diff --git a/apps/ios/plugins/oxbit-files/src/desktop.rs b/apps/ios/plugins/oxbit-files/src/desktop.rs index 85fb7728..9c2a5819 100644 --- a/apps/ios/plugins/oxbit-files/src/desktop.rs +++ b/apps/ios/plugins/oxbit-files/src/desktop.rs @@ -36,6 +36,10 @@ impl OxbitFiles { Err(UNSUPPORTED) } + pub fn download_runtime(&self, _url: &str, _sha256: &str) -> crate::Result { + Err(UNSUPPORTED) + } + pub fn pick_files(&self, _multiple: bool) -> crate::Result { Err(UNSUPPORTED) } diff --git a/apps/ios/plugins/oxbit-files/src/mobile.rs b/apps/ios/plugins/oxbit-files/src/mobile.rs index 4521dcdc..74384f68 100644 --- a/apps/ios/plugins/oxbit-files/src/mobile.rs +++ b/apps/ios/plugins/oxbit-files/src/mobile.rs @@ -59,6 +59,16 @@ impl OxbitFiles { .map_err(Into::into) } + /// Internal only: the pinned remote runtime archive, downloaded with URLSession. + pub fn download_runtime(&self, url: &str, sha256: &str) -> crate::Result { + self.0 + .run_mobile_plugin( + "downloadRuntime", + serde_json::json!({ "url": url, "sha256": sha256 }), + ) + .map_err(Into::into) + } + pub fn pick_files(&self, multiple: bool) -> crate::Result { self.0 .run_mobile_plugin("pickFiles", serde_json::json!({ "multiple": multiple })) diff --git a/apps/ios/src-tauri/src/lib.rs b/apps/ios/src-tauri/src/lib.rs index 26e436b3..4f2191fe 100644 --- a/apps/ios/src-tauri/src/lib.rs +++ b/apps/ios/src-tauri/src/lib.rs @@ -72,6 +72,9 @@ pub fn run() { ssh::commands::ios_ssh_trust, ssh::commands::ios_ssh_forget_host_key, ssh::commands::ios_ssh_disconnect, + ssh::runtime_commands::ios_ssh_runtime_start, + ssh::runtime_commands::ios_ssh_runtime_resume, + ssh::runtime_commands::ios_ssh_runtime_stop, ssh::git_commands::ios_ssh_git_prompt, ssh::git_commands::ios_ssh_git_trust, ssh::git_commands::ios_ssh_git_forget_host_key, diff --git a/apps/ios/src-tauri/src/ssh/mod.rs b/apps/ios/src-tauri/src/ssh/mod.rs index e52bad64..caf5f319 100644 --- a/apps/ios/src-tauri/src/ssh/mod.rs +++ b/apps/ios/src-tauri/src/ssh/mod.rs @@ -1,5 +1,5 @@ //! Built-in SSH: saved hosts, Keychain keys, trust-on-first-use host keys, pooled connections, -//! SFTP workspaces, file transfers, and Git over SSH. +//! SFTP workspaces, file transfers, Git over SSH, and remote runtimes. pub mod commands; pub mod connect; pub mod fs_commands; @@ -13,6 +13,15 @@ pub mod known_hosts; mod live_git_tests; #[cfg(test)] mod live_tests; +pub mod runtime_commands; +pub mod runtime_frames; +mod runtime_install; +mod runtime_process; +pub mod runtime_protocol; +#[cfg(test)] +mod runtime_protocol_tests; +pub mod runtime_session; +mod runtime_tunnel; pub mod session; pub mod sftp; pub mod transfer; @@ -29,7 +38,8 @@ pub struct RemoteRoot { pub struct Ssh { pub hosts: hosts::Hosts, pub known: Arc, - pub pool: session::Pool, + pub pool: Arc, + pub runtimes: Mutex>>, pub transfers: transfer::Transfers, roots: Mutex>, /// The last Git over SSH prompt per workspace root, read by the UI after a failed request. @@ -50,7 +60,8 @@ impl Ssh { )); Self { hosts: hosts::Hosts::new(directory.join("hosts.json")), - pool: session::Pool::new(known.clone()), + pool: Arc::new(session::Pool::new(known.clone())), + runtimes: Mutex::new(HashMap::new()), known, transfers: transfer::Transfers::default(), roots: Mutex::new(HashMap::new()), diff --git a/apps/ios/src-tauri/src/ssh/runtime_commands.rs b/apps/ios/src-tauri/src/ssh/runtime_commands.rs new file mode 100644 index 00000000..14c555f2 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_commands.rs @@ -0,0 +1,143 @@ +//! Tauri commands that start, resume, and stop an Oxbit runtime on a connected SSH host. The +//! WebView reaches it through a loopback tunnel with the token from the launch frame. +use super::{ + runtime_install::{self, Fetch, Source}, + runtime_process::{Event, Options, Ready}, + runtime_protocol::remote_root, + runtime_session::RemoteRuntime, +}; +use crate::{ + fs_core::{revision, Error, Result}, + AppState, +}; +use serde::Serialize; +use serde_json::Value; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use tauri::{AppHandle, Emitter, State}; +use tauri_plugin_oxbit_files::OxbitFilesExt; + +const READY_TIMEOUT: Duration = Duration::from_secs(25); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Started { + pub url: String, + pub token: String, + pub workspace_key: String, + #[serde(flatten)] + pub ready: Ready, +} + +fn started(runtime: &RemoteRuntime, ready: Ready) -> Started { + Started { + url: runtime.url(), + token: runtime.token().to_string(), + workspace_key: runtime.workspace_key().to_string(), + ready, + } +} + +fn device_fetch(app: AppHandle) -> Fetch { + Arc::new(move |download| { + let app = app.clone(); + Box::pin(async move { + let saved = tauri::async_runtime::spawn_blocking(move || { + app.oxbit_files() + .download_runtime(&download.url, &download.sha256) + }) + .await + .map_err(|_| Error::new("REMOTE_RUNTIME", "The download did not finish."))? + .map_err(|error| Error::new("REMOTE_RUNTIME", error.to_string()))?; + saved + .get("path") + .and_then(Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| Error::new("REMOTE_RUNTIME", "The download did not finish.")) + }) + }) +} + +fn runtime(state: &AppState, id: &str) -> Result> { + state + .ssh + .runtimes + .lock() + .unwrap() + .get(id) + .cloned() + .ok_or_else(|| { + Error::new( + "REMOTE_RUNTIME", + "This remote workspace is closed. Start it again.", + ) + }) +} + +/// `id` names the progress events (`ios-ssh-runtime:`) and the later resume and stop calls. +#[tauri::command] +pub async fn ios_ssh_runtime_start( + app: AppHandle, + state: State<'_, AppState>, + id: String, + host_id: String, + path: String, +) -> Result { + if id.is_empty() || id.len() > 64 || state.ssh.runtimes.lock().unwrap().contains_key(&id) { + return Err(Error::invalid("Invalid remote workspace ID")); + } + state.ssh.hosts.get(&host_id)?; + let root = remote_root(&path)?; + let workspace_key = revision(format!("ssh-runtime\0{host_id}\0{root}").as_bytes()); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let (emitter, channel) = (app.clone(), format!("ios-ssh-runtime:{id}")); + let events = Arc::new(move |event: Event| { + let _ = emitter.emit(&channel, event); + }); + let source = Source { + lookup: Arc::new(runtime_install::pinned), + fetch: device_fetch(app), + }; + let options = Options { + host_id, + root, + workspace_key, + token, + ready_timeout: READY_TIMEOUT, + }; + let runtime = RemoteRuntime::open(state.ssh.pool.clone(), source, events, options).await?; + state + .ssh + .runtimes + .lock() + .unwrap() + .insert(id.clone(), runtime.clone()); + match runtime.ensure(true).await { + Ok(ready) => Ok(started(&runtime, ready)), + Err(error) => { + state.ssh.runtimes.lock().unwrap().remove(&id); + runtime.stop().await; + Err(error) + } + } +} + +/// After the app returns to the foreground or the user asks to reconnect. +#[tauri::command] +pub async fn ios_ssh_runtime_resume(state: State<'_, AppState>, id: String) -> Result { + let runtime = runtime(&state, &id)?; + let ready = runtime.ensure(true).await?; + Ok(started(&runtime, ready)) +} + +#[tauri::command] +pub async fn ios_ssh_runtime_stop(state: State<'_, AppState>, id: String) -> Result<()> { + let removed = state.ssh.runtimes.lock().unwrap().remove(&id); + if let Some(runtime) = removed { + runtime.stop().await; + } + Ok(()) +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_frames.rs b/apps/ios/src-tauri/src/ssh/runtime_frames.rs new file mode 100644 index 00000000..44f0c019 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_frames.rs @@ -0,0 +1,157 @@ +//! The newline-delimited JSON frames between the device and `desktop.js`, version 1, capped at +//! 64 KiB per line as on desktop. +use crate::fs_core::{Error, Result}; +use serde::Deserialize; +use serde_json::json; +use std::net::IpAddr; + +pub const MAX_FRAME_BYTES: usize = 65536; + +fn invalid_response() -> Error { + Error::new("REMOTE_RUNTIME", "Invalid remote runtime response") +} + +pub fn launch_frame(root: &str, workspace_key: &str, token: &str) -> String { + let frame = json!({ + "version": 1, "type": "launch", "remoteRuntime": true, "root": root, + "workspaceKey": workspace_key, "token": token, "development": false, + }); + frame.to_string() + "\n" +} + +pub const HEARTBEAT_FRAME: &str = "{\"version\":1,\"type\":\"heartbeat\"}\n"; +pub const SHUTDOWN_FRAME: &str = "{\"version\":1,\"type\":\"shutdown\"}\n"; + +pub fn task_forwarded_frame(request: &str, port: Option) -> String { + let frame = match port { + Some(port) => { + json!({ "version": 1, "type": "taskForwarded", "request": request, "port": port }) + } + None => json!({ + "version": 1, "type": "taskForwarded", "request": request, + "error": "Could not forward the service port over SSH", + }), + }; + frame.to_string() + "\n" +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Frame { + Ready { + port: u16, + root: String, + open_file: Option, + }, + Rotated { + request: String, + }, + TaskForward { + request: String, + host: String, + port: u16, + }, + Error { + message: String, + }, + Progress { + message: String, + }, + Other, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Raw { + version: u8, + r#type: String, + port: Option, + workspace_key: Option, + root: Option, + open_file: Option, + request: Option, + host: Option, + message: Option, +} + +fn port(value: Option) -> Option { + value + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port > 0) +} + +pub fn parse_frame(line: &[u8], workspace_key: &str) -> Result { + let raw: Raw = serde_json::from_slice(line).map_err(|_| invalid_response())?; + if raw.version != 1 { + return Err(invalid_response()); + } + Ok(match raw.r#type.as_str() { + "ready" => match (port(raw.port), raw.root) { + (Some(port), Some(root)) + if raw.workspace_key.as_deref() == Some(workspace_key) && root.starts_with('/') => + { + Frame::Ready { + port, + root, + open_file: raw.open_file, + } + } + _ => { + return Err(Error::new( + "REMOTE_RUNTIME", + "Remote workspace identity did not match the connection", + )) + } + }, + "rotated" => Frame::Rotated { + request: raw.request.unwrap_or_default(), + }, + "taskForward" => match (raw.request, raw.host, port(raw.port)) { + (Some(request), Some(host), Some(port)) + if request.len() <= 128 + && (host == "localhost" || host.parse::().is_ok()) => + { + Frame::TaskForward { + request, + host, + port, + } + } + _ => return Err(invalid_response()), + }, + "error" => Frame::Error { + message: raw + .message + .unwrap_or_else(|| "Remote startup failed".into()), + }, + "progress" => Frame::Progress { + message: raw.message.unwrap_or_default(), + }, + _ => Frame::Other, + }) +} + +/// Splits stdout into frames and refuses a line longer than the desktop's 64 KiB cap. +#[derive(Default)] +pub struct FrameReader { + buffer: Vec, +} + +impl FrameReader { + pub fn push(&mut self, data: &[u8]) -> Result>> { + self.buffer.extend_from_slice(data); + let mut lines = Vec::new(); + while let Some(end) = self.buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec = self.buffer.drain(..=end).take(end).collect(); + if line.len() > MAX_FRAME_BYTES { + return Err(invalid_response()); + } + if !line.iter().all(u8::is_ascii_whitespace) { + lines.push(line); + } + } + if self.buffer.len() > MAX_FRAME_BYTES { + return Err(invalid_response()); + } + Ok(lines) + } +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_install.rs b/apps/ios/src-tauri/src/ssh/runtime_install.rs new file mode 100644 index 00000000..0bcc5e05 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_install.rs @@ -0,0 +1,202 @@ +//! Puts the runtime payload on the host: reuse the cached install, let the host download the +//! pinned archive, or stream the archive from the device over an exec channel. +use super::{ + connect::Client, + runtime_protocol::{self as protocol, Install}, + session::CONNECTION_LOST, +}; +use crate::{ + fs_core::{Error, Result}, + remote_runtime::Download, +}; +use russh::{client::Handle, ChannelMsg}; +use sha2::{Digest, Sha256}; +use std::{future::Future, path::PathBuf, pin::Pin, sync::Arc, time::Duration}; +use tokio::io::AsyncReadExt; + +pub const INSTALLING: &str = "Installing the remote runtime…"; +pub const STARTING: &str = "Starting the remote workspace…"; +const INSTALL_TIMEOUT: Duration = Duration::from_secs(180); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_OUTPUT_BYTES: usize = 8192; + +/// The payload a platform gets: the manifest pinned into this build. +pub fn pinned(platform: &str) -> Result { + crate::remote_runtime::pinned_download(platform) + .map_err(|message| Error::new("REMOTE_RUNTIME", message))? + .ok_or_else(|| { + Error::new( + "REMOTE_RUNTIME", + "This build of Oxbit has no remote runtime. Use a TestFlight or App Store build.", + ) + }) +} + +pub type Lookup = Arc Result + Send + Sync>; +/// Downloads the archive to a file on the device. +pub type Fetch = + Arc Pin> + Send>> + Send + Sync>; + +#[derive(Clone)] +pub struct Source { + pub lookup: Lookup, + pub fetch: Fetch, +} + +pub struct Output { + pub status: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +pub(super) fn lost(error: impl std::fmt::Display) -> Error { + Error::new( + CONNECTION_LOST, + format!("The SSH connection closed: {error}"), + ) +} + +/// Runs one command, writing `input` to its stdin, and collects bounded output. +pub async fn exec( + handle: &Handle, + command: &str, + input: Option, + timeout: Duration, +) -> Result { + let run = async { + let mut channel = handle.channel_open_session().await.map_err(lost)?; + channel.exec(true, command).await.map_err(lost)?; + if let Some(path) = input { + let file = tokio::fs::File::open(&path) + .await + .map_err(|error| Error::new("REMOTE_RUNTIME", error.to_string()))?; + channel.data(file).await.map_err(lost)?; + channel.eof().await.map_err(lost)?; + } + let mut output = Output { + status: None, + stdout: Vec::new(), + stderr: Vec::new(), + }; + while let Some(message) = channel.wait().await { + let (target, data) = match message { + ChannelMsg::Data { data } => (&mut output.stdout, data), + ChannelMsg::ExtendedData { data, ext: 1 } => (&mut output.stderr, data), + ChannelMsg::ExitStatus { exit_status } => { + output.status = Some(exit_status); + continue; + } + ChannelMsg::Close => break, + _ => continue, + }; + let room = MAX_OUTPUT_BYTES.saturating_sub(target.len()); + target.extend_from_slice(&data[..data.len().min(room)]); + } + Ok(output) + }; + tokio::time::timeout(timeout, run) + .await + .map_err(|_| Error::new("REMOTE_RUNTIME", "The server stopped responding."))? +} + +fn failure(output: &Output, fallback: &str) -> Error { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Error::new( + "REMOTE_RUNTIME", + if stderr.is_empty() { + fallback.to_string() + } else { + stderr + }, + ) +} + +/// The device checks the pinned digest before any byte reaches the server. +pub async fn verify(path: &PathBuf, download: &Download) -> Result<()> { + let mut file = tokio::fs::File::open(path) + .await + .map_err(|error| Error::new("REMOTE_RUNTIME", error.to_string()))?; + let (mut hasher, mut size, mut buffer) = (Sha256::new(), 0u64, vec![0u8; 64 * 1024]); + loop { + let count = file + .read(&mut buffer) + .await + .map_err(|error| Error::new("REMOTE_RUNTIME", error.to_string()))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + size += count as u64; + } + let actual: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + if actual != download.sha256 || size != download.size { + return Err(Error::new( + "REMOTE_RUNTIME", + "The downloaded remote runtime did not match this build's checksum.", + )); + } + Ok(()) +} + +pub struct Installed { + pub digest: String, + pub method: Install, +} + +pub async fn install( + handle: &Handle, + source: &Source, + progress: &(dyn Fn(&str) + Send + Sync), +) -> Result { + let probe = exec( + handle, + &protocol::sh(protocol::PROBE), + None, + COMMAND_TIMEOUT, + ) + .await?; + let (platform, downloader) = protocol::parse_probe(&String::from_utf8_lossy(&probe.stdout))?; + let download = (source.lookup)(platform)?; + let digest = download.sha256.clone(); + let cached = exec( + handle, + &protocol::sh(&protocol::cached_command(&digest)), + None, + COMMAND_TIMEOUT, + ) + .await? + .stdout + == b"yes"; + let method = protocol::choose(cached, downloader); + if method == Install::Cached { + return Ok(Installed { digest, method }); + } + progress(INSTALLING); + if let Install::HostDownload(downloader) = method { + let command = protocol::host_install_command(&digest, &download.url, downloader)?; + if exec(handle, &command, None, INSTALL_TIMEOUT).await?.status == Some(0) { + return Ok(Installed { digest, method }); + } + } + let file = (source.fetch)(download.clone()).await?; + if let Err(error) = verify(&file, &download).await { + let _ = tokio::fs::remove_file(&file).await; + return Err(error); + } + let command = protocol::stream_install_command(&digest)?; + let output = exec(handle, &command, Some(file), INSTALL_TIMEOUT).await?; + if output.status != Some(0) { + return Err(failure( + &output, + "The remote runtime could not be installed.", + )); + } + Ok(Installed { + digest, + method: Install::DeviceStream, + }) +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_process.rs b/apps/ios/src-tauri/src/ssh/runtime_process.rs new file mode 100644 index 00000000..2453a4a8 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_process.rs @@ -0,0 +1,162 @@ +//! The remote runtime's exec channel: stdout frames, the PID on stderr, heartbeats, and the +//! loopback ports for task services the runtime asks to forward. +use super::{ + runtime_frames::{self as frames, Frame, FrameReader}, + runtime_protocol::Install, + runtime_tunnel::Tunnel, + session::Pool, +}; +use crate::fs_core::{Error, Result}; +use russh::{client::Msg, ChannelMsg, ChannelReadHalf, ChannelWriteHalf}; +use serde::Serialize; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::{sync::watch, task::JoinHandle}; + +const HEARTBEAT: Duration = Duration::from_secs(10); +const MAX_TASK_FORWARDS: usize = 256; +const MAX_STDERR_BYTES: usize = 4096; +pub const STOPPED: &str = + "Remote runtime stopped. Check the folder path, available disk space, and platform compatibility."; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum Event { + Progress { message: String }, + Running, + Reconnecting { message: String }, + Failed { message: String }, +} + +pub type Events = Arc; + +pub struct Options { + pub host_id: String, + pub root: String, + pub workspace_key: String, + pub token: String, + pub ready_timeout: Duration, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Ready { + pub root: String, + pub open_file: Option, + #[serde(skip)] + pub installed: Option, +} + +pub type Writer = Arc>; + +pub struct Stdout { + pub reader: ChannelReadHalf, + pub frames: FrameReader, + /// The first stderr line is the runtime's PID. + pub stderr: Vec, +} + +impl Stdout { + pub async fn next(&mut self, workspace_key: &str) -> Result> { + loop { + match self.reader.wait().await { + Some(ChannelMsg::Data { data }) => { + return self + .frames + .push(&data)? + .iter() + .map(|line| frames::parse_frame(line, workspace_key)) + .collect(); + } + Some(ChannelMsg::ExtendedData { data, ext: 1 }) => { + let room = MAX_STDERR_BYTES.saturating_sub(self.stderr.len()); + self.stderr.extend_from_slice(&data[..data.len().min(room)]); + } + Some(ChannelMsg::Eof | ChannelMsg::Close) | None => { + return Err(Error::new("REMOTE_RUNTIME", STOPPED)) + } + Some(_) => {} + } + } + } + + pub fn pid(&self) -> Option { + let stderr = String::from_utf8_lossy(&self.stderr); + stderr + .lines() + .next() + .and_then(|line| line.trim().parse().ok()) + } +} + +pub struct Process { + pub writer: Writer, + pub alive: Arc, + pub tasks: Vec>, +} + +impl Process { + pub fn close(self) { + self.alive.store(false, Ordering::SeqCst); + for task in self.tasks { + task.abort(); + } + tokio::spawn(async move { + let _ = self.writer.close().await; + }); + } +} + +pub async fn heartbeat(writer: Writer, alive: Arc) { + while alive.load(Ordering::SeqCst) { + tokio::time::sleep(HEARTBEAT).await; + if writer + .data(frames::HEARTBEAT_FRAME.as_bytes()) + .await + .is_err() + { + return; + } + } +} + +/// One loopback port per `host:port` the runtime asks for, kept across relaunches. +#[derive(Default)] +pub struct TaskForwards(tokio::sync::Mutex>); + +impl TaskForwards { + pub async fn port( + &self, + pool: &Arc, + host_id: &str, + host: &str, + port: u16, + ) -> Result { + let mut forwards = self.0.lock().await; + let key = format!("{host}:{port}"); + if let Some(tunnel) = forwards.get(&key) { + return Ok(tunnel.port); + } + if forwards.len() >= MAX_TASK_FORWARDS { + return Err(Error::new( + "REMOTE_RUNTIME", + "Too many task port forwards; reconnect the workspace", + )); + } + let (_sender, target) = watch::channel(Some((host.to_string(), port))); + let tunnel = Tunnel::open(pool.clone(), host_id.to_string(), target).await?; + let local = tunnel.port; + forwards.insert(key, tunnel); + Ok(local) + } + + pub async fn clear(&self) { + self.0.lock().await.clear(); + } +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs new file mode 100644 index 00000000..6c548e89 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs @@ -0,0 +1,188 @@ +//! The desktop remote runtime contract from apps/runtime/src/ssh.ts and ssh-target.ts: platform +//! names and the shared install layout, install scripts, and launch command. +use crate::fs_core::{Error, Result}; + +pub fn platform(system: &str, machine: &str) -> Result<&'static str> { + match (system.trim(), machine.trim()) { + ("Linux", "x86_64") => Ok("linux-x64"), + ("Darwin", "arm64") => Ok("darwin-arm64"), + _ => Err(Error::new( + "REMOTE_UNSUPPORTED", + "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon.", + )), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Downloader { + Curl, + Wget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Install { + Cached, + HostDownload(Downloader), + DeviceStream, +} + +/// Cached first, then a download on the host, then the device streams the archive. +pub fn choose(cached: bool, downloader: Option) -> Install { + match (cached, downloader) { + (true, _) => Install::Cached, + (false, Some(downloader)) => Install::HostDownload(downloader), + (false, None) => Install::DeviceStream, + } +} + +pub const PROBE: &str = "uname -s; uname -m; \ + if command -v curl >/dev/null 2>&1; then echo curl; \ + elif command -v wget >/dev/null 2>&1; then echo wget; else echo none; fi"; + +pub fn parse_probe(output: &str) -> Result<(&'static str, Option)> { + let lines: Vec<&str> = output.lines().map(str::trim).collect(); + let [system, machine, downloader, ..] = lines[..] else { + return Err(Error::new( + "REMOTE_UNSUPPORTED", + "Could not detect the server's operating system.", + )); + }; + let downloader = match downloader { + "curl" => Some(Downloader::Curl), + "wget" => Some(Downloader::Wget), + _ => None, + }; + Ok((platform(system, machine)?, downloader)) +} + +pub fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Runs a script under `sh` whatever the login shell is. +pub fn sh(script: &str) -> String { + format!("sh -c {}", shell_quote(script)) +} + +pub fn valid_digest(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn remote(digest: &str) -> String { + format!("\"$HOME/.oxbit/remote/runtimes/{digest}\"") +} + +pub fn cached_command(digest: &str) -> String { + format!( + "if [ -f {}/.complete ]; then printf yes; fi", + remote(digest) + ) +} + +/// Byte-identical to `installScript` in apps/runtime/src/ssh-target.ts. +const INSTALL: &str = r#"set -eu +umask 077 +base="$HOME/.oxbit/remote/runtimes" +mkdir -p "$base" +stage=$(mktemp -d "$base/.install-XXXXXXXX") +lock="" +trap 'rm -rf "$stage"; [ -z "$lock" ] || rmdir "$lock" 2>/dev/null || true' EXIT +trap 'exit 1' HUP INT TERM +cat > "$stage/runtime.tar.gz" +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$stage/runtime.tar.gz") +else + actual=$(shasum -a 256 "$stage/runtime.tar.gz") +fi +[ "${actual%% *}" = '@DIGEST@' ] || { echo 'Runtime checksum mismatch' >&2; exit 1; } +mkdir "$stage/runtime" +tar -xzf "$stage/runtime.tar.gz" -C "$stage/runtime" +"$stage/runtime/bin/node" --version >/dev/null +touch "$stage/runtime/.complete" +# Serialize only the atomic publish, after the slow transfer and extraction. +attempt=0 +while ! mkdir "$base/@DIGEST@.lock" 2>/dev/null; do + if [ -f "$base/@DIGEST@/.complete" ]; then exit 0; fi + attempt=$((attempt + 1)) + [ "$attempt" -lt 30 ] || { echo 'Another runtime install is in progress; retry shortly.' >&2; exit 1; } + sleep 1 +done +lock="$base/@DIGEST@.lock" +if [ ! -f "$base/@DIGEST@/.complete" ]; then + mv "$stage/runtime" "$base/@DIGEST@" +fi +"#; + +pub fn install_script(digest: &str) -> Result { + if !valid_digest(digest) { + return Err(Error::invalid("Invalid runtime digest")); + } + Ok(INSTALL.replace("@DIGEST@", digest)) +} + +/// The archive arrives on stdin. +pub fn stream_install_command(digest: &str) -> Result { + Ok(sh(&install_script(digest)?)) +} + +/// The host fetches the archive itself; the install script checks the pinned digest. +pub fn host_install_command(digest: &str, url: &str, downloader: Downloader) -> Result { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(Error::invalid("Invalid runtime download URL")); + } + let fetch = match downloader { + Downloader::Curl => "curl -fsSL --connect-timeout 15", + Downloader::Wget => "wget -qO- -T 15", + }; + let pipeline = format!( + "{fetch} {} | sh -c {}", + shell_quote(url), + shell_quote(&install_script(digest)?) + ); + Ok(sh(&pipeline)) +} + +/// Prints the runtime's PID on stderr; stdout carries only protocol frames. +pub fn launch_command(digest: &str) -> String { + let remote = remote(digest); + let script = format!( + "echo $$ >&2; cd {remote} || exit 1; unset NODE_OPTIONS NODE_PATH OXBIT_LSP_COMMAND; \ + exec {remote}/bin/node {remote}/desktop.js" + ); + sh(&script) +} + +/// Stops an earlier runtime of ours that outlived its SSH connection, so its workspace lock frees. +pub fn stop_stale_command(pid: u32) -> String { + let script = format!( + "case \"$(ps -p {pid} -o args= 2>/dev/null)\" in *\"/.oxbit/remote/runtimes/\"*desktop.js*) \ + kill -TERM {pid}; i=0; while kill -0 {pid} 2>/dev/null && [ $i -lt 50 ]; do sleep 0.1; i=$((i + 1)); done;; esac" + ); + sh(&script) +} + +/// The dialog's folder as desktop.js expects it: absolute, or under `/~` for the home folder. +pub fn remote_root(path: &str) -> Result { + let path = path.trim(); + if path.len() > 4096 || path.chars().any(char::is_control) { + return Err(Error::invalid( + "Remote paths cannot contain control characters", + )); + } + if path.starts_with('/') { + return Ok(path.to_string()); + } + let relative = path + .strip_prefix('~') + .unwrap_or(path) + .trim_start_matches('/'); + let relative = relative.trim_end_matches('/'); + Ok(if relative.is_empty() { + "/~".into() + } else { + format!("/~/{relative}") + }) +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs new file mode 100644 index 00000000..ce95c6fe --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -0,0 +1,240 @@ +use super::{runtime_frames::*, runtime_protocol::*, runtime_tunnel::healthy}; +use serde_json::{json, Value}; + +const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn frame(value: Value) -> Vec { + value.to_string().into_bytes() +} + +#[test] +fn maps_uname_to_the_desktop_platform_names() { + assert_eq!(platform("Linux", "x86_64").unwrap(), "linux-x64"); + assert_eq!(platform("Darwin\n", "arm64\n").unwrap(), "darwin-arm64"); + for (system, machine) in [ + ("Linux", "aarch64"), + ("Darwin", "x86_64"), + ("FreeBSD", "amd64"), + ] { + let error = platform(system, machine).unwrap_err(); + assert_eq!( + error.message, + "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon." + ); + } + assert_eq!( + parse_probe("Linux\nx86_64\ncurl\n").unwrap(), + ("linux-x64", Some(Downloader::Curl)) + ); + assert_eq!( + parse_probe("Darwin\narm64\nwget\n").unwrap(), + ("darwin-arm64", Some(Downloader::Wget)) + ); + assert_eq!( + parse_probe("Linux\nx86_64\nnone\n").unwrap(), + ("linux-x64", None) + ); + assert!(parse_probe("Linux\n").is_err()); +} + +#[test] +fn selects_the_cache_then_the_host_download_then_the_device_stream() { + assert_eq!(choose(true, Some(Downloader::Curl)), Install::Cached); + assert_eq!(choose(true, None), Install::Cached); + assert_eq!( + choose(false, Some(Downloader::Wget)), + Install::HostDownload(Downloader::Wget) + ); + assert_eq!(choose(false, None), Install::DeviceStream); +} + +#[test] +fn install_script_matches_the_desktop_script() { + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../runtime/src/ssh-target.ts"), + ) + .unwrap(); + let start = source.find("return `set -eu").unwrap() + "return `".len(); + let end = start + source[start..].find("`;\n}").unwrap(); + let desktop = source[start..end] + .replace("\\${", "${") + .replace("${digest}", KEY); + assert_eq!(install_script(KEY).unwrap(), desktop); + assert!(install_script("ABC").is_err()); + assert!(install_script(&KEY.to_uppercase()).is_err()); +} + +#[test] +fn builds_the_shared_remote_commands() { + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + assert_eq!( + cached_command(KEY), + format!("if [ -f \"$HOME/.oxbit/remote/runtimes/{KEY}\"/.complete ]; then printf yes; fi") + ); + let url = "https://github.com/yannelli/oxbit/releases/download/v0.3.4/remote-runtime-linux-x64.tar.gz"; + let curl = host_install_command(KEY, url, Downloader::Curl).unwrap(); + assert!( + curl.starts_with("sh -c 'curl -fsSL --connect-timeout 15 '\\''https://github.com/"), + "{curl}" + ); + assert!(curl.contains(KEY)); + let wget = host_install_command(KEY, url, Downloader::Wget).unwrap(); + assert!(wget.starts_with("sh -c 'wget -qO- -T 15 "), "{wget}"); + assert!(host_install_command(KEY, "file:///etc/passwd", Downloader::Curl).is_err()); + let launch = launch_command(KEY); + assert!(launch.starts_with("sh -c 'echo $$ >&2; cd \"$HOME/.oxbit/remote/runtimes/")); + assert!(launch.contains("unset NODE_OPTIONS NODE_PATH OXBIT_LSP_COMMAND; exec \"$HOME")); + assert!(launch.ends_with("/desktop.js'")); + let stop = stop_stale_command(4242); + assert!( + stop.contains("ps -p 4242 -o args=") && stop.contains("kill -TERM 4242"), + "{stop}" + ); +} + +#[test] +fn maps_the_folder_field_to_a_launch_root() { + for (input, root) in [ + ("", "/~"), + ("~", "/~"), + ("~/", "/~"), + ("~/project", "/~/project"), + ("project/app/", "/~/project/app"), + ("/srv/app", "/srv/app"), + ] { + assert_eq!(remote_root(input).unwrap(), root, "{input}"); + } + assert!(remote_root("bad\npath").is_err()); +} + +#[test] +fn writes_launch_heartbeat_and_task_frames() { + let launch: Value = serde_json::from_str(&launch_frame("/~/app", KEY, "token")).unwrap(); + assert_eq!( + launch, + json!({ "version": 1, "type": "launch", "remoteRuntime": true, "root": "/~/app", + "workspaceKey": KEY, "token": "token", "development": false }) + ); + assert!(launch_frame("/", KEY, "t").ends_with("}\n")); + assert_eq!(HEARTBEAT_FRAME, "{\"version\":1,\"type\":\"heartbeat\"}\n"); + let forwarded: Value = serde_json::from_str(&task_forwarded_frame("r1", Some(4100))).unwrap(); + assert_eq!( + forwarded, + json!({ "version": 1, "type": "taskForwarded", "request": "r1", "port": 4100 }) + ); + let failed: Value = serde_json::from_str(&task_forwarded_frame("r1", None)).unwrap(); + assert_eq!( + failed["error"], + "Could not forward the service port over SSH" + ); +} + +#[test] +fn parses_and_validates_runtime_frames() { + let ready = json!({ "version": 1, "type": "ready", "port": 41234, "workspaceKey": KEY, "root": "/root/project" }); + assert_eq!( + parse_frame(&frame(ready.clone()), KEY).unwrap(), + Frame::Ready { + port: 41234, + root: "/root/project".into(), + open_file: None + } + ); + for (field, value) in [ + ("workspaceKey", json!("other")), + ("port", json!(0)), + ("port", json!(70000)), + ("root", json!("relative")), + ] { + let mut wrong = ready.clone(); + wrong[field] = value; + let error = parse_frame(&frame(wrong), KEY).unwrap_err(); + assert_eq!( + error.message, "Remote workspace identity did not match the connection", + "{field}" + ); + } + let forward = + json!({ "version": 1, "type": "taskForward", "request": "r", "host": "::1", "port": 3000 }); + assert_eq!( + parse_frame(&frame(forward.clone()), KEY).unwrap(), + Frame::TaskForward { + request: "r".into(), + host: "::1".into(), + port: 3000 + } + ); + for (field, value) in [ + ("host", json!("example.com")), + ("request", json!("x".repeat(129))), + ("port", json!(0)), + ] { + let mut wrong = forward.clone(); + wrong[field] = value; + assert_eq!( + parse_frame(&frame(wrong), KEY).unwrap_err().message, + "Invalid remote runtime response" + ); + } + assert_eq!( + parse_frame( + &frame(json!({ "version": 1, "type": "error", "message": "busy" })), + KEY + ) + .unwrap(), + Frame::Error { + message: "busy".into() + } + ); + assert_eq!( + parse_frame( + &frame(json!({ "version": 1, "type": "rotated", "request": "q" })), + KEY + ) + .unwrap(), + Frame::Rotated { + request: "q".into() + } + ); + let process = json!({ "version": 1, "type": "process", "pid": 7, "running": true }); + assert_eq!(parse_frame(&frame(process), KEY).unwrap(), Frame::Other); + assert!(parse_frame(&frame(json!({ "version": 2, "type": "ready" })), KEY).is_err()); + assert!(parse_frame(b"not json", KEY).is_err()); +} + +#[test] +fn splits_frames_across_reads_and_caps_a_line_at_64_kib() { + let mut reader = FrameReader::default(); + assert!(reader.push(b"{\"a\":1}\n{\"b\"").unwrap() == vec![b"{\"a\":1}".to_vec()]); + assert_eq!( + reader.push(b":2}\n\n").unwrap(), + vec![b"{\"b\":2}".to_vec()] + ); + let mut exact = vec![b'x'; MAX_FRAME_BYTES]; + exact.push(b'\n'); + assert_eq!( + FrameReader::default().push(&exact).unwrap()[0].len(), + MAX_FRAME_BYTES + ); + assert!(FrameReader::default() + .push(&vec![b'x'; MAX_FRAME_BYTES + 1]) + .is_err()); + let mut long = vec![b'x'; MAX_FRAME_BYTES + 1]; + long.push(b'\n'); + assert!(FrameReader::default().push(&long).is_err()); +} + +#[test] +fn accepts_only_a_protocol_1_health_response() { + let ok = + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n{\"ok\":true,\"protocol\":1}"; + assert!(healthy(ok)); + assert!(healthy("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n19\r\n{\"ok\":true,\"protocol\":1}\r\n0\r\n\r\n")); + assert!(!healthy( + "HTTP/1.1 200 OK\r\n\r\n{\"ok\":true,\"protocol\":2}" + )); + assert!(!healthy( + "HTTP/1.1 403 Forbidden\r\n\r\n{\"ok\":true,\"protocol\":1}" + )); + assert!(!healthy("garbage")); +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_session.rs b/apps/ios/src-tauri/src/ssh/runtime_session.rs new file mode 100644 index 00000000..1efb2c3b --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_session.rs @@ -0,0 +1,300 @@ +//! One remote runtime: launches `desktop.js` on the server, waits for `ready`, and relaunches it +//! after the process or the SSH connection ends, within a budget so reconnects cannot loop. +use super::{ + runtime_frames::{self as frames, Frame, FrameReader}, + runtime_install::{self, exec, lost, Installed, Source}, + runtime_process::{ + heartbeat, Event, Events, Options, Process, Ready, Stdout, TaskForwards, Writer, + }, + runtime_protocol as protocol, + runtime_tunnel::{health, Tunnel}, + session::Pool, +}; +use crate::fs_core::{Error, Result}; +use std::{ + collections::VecDeque, + future::Future, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Weak, + }, + time::{Duration, Instant}, +}; +use tokio::sync::{watch, Mutex}; + +const RELAUNCH_LIMIT: usize = 3; +const RELAUNCH_WINDOW: Duration = Duration::from_secs(60); +const GAVE_UP: &str = + "Oxbit stopped reconnecting to the server after repeated failures. Reconnect to retry."; + +#[derive(Default)] +struct Launch { + process: Option, + ready: Option, + pid: Option, + attempts: VecDeque, +} + +pub struct RemoteRuntime { + pool: Arc, + source: Source, + events: Events, + options: Options, + target: watch::Sender>, + pub tunnel: Tunnel, + forwards: TaskForwards, + launch: Mutex, + stopped: AtomicBool, + myself: Weak, +} + +impl RemoteRuntime { + pub async fn open( + pool: Arc, + source: Source, + events: Events, + options: Options, + ) -> Result> { + let (target, receiver) = watch::channel(None); + let tunnel = Tunnel::open(pool.clone(), options.host_id.clone(), receiver).await?; + Ok(Arc::new_cyclic(|myself| Self { + pool, + source, + events, + options, + target, + tunnel, + forwards: TaskForwards::default(), + launch: Mutex::default(), + stopped: AtomicBool::new(false), + myself: myself.clone(), + })) + } + + /// The running runtime, or a reconnect and relaunch. Automatic attempts share a budget of + /// three per minute; `user` attempts bypass it. + pub async fn ensure(&self, user: bool) -> Result { + let mut launch = self.launch.lock().await; + if self.stopped.load(Ordering::SeqCst) { + return Err(Error::new( + "REMOTE_RUNTIME", + "This remote workspace is closed.", + )); + } + if let (Some(process), Some(ready)) = (&launch.process, &launch.ready) { + if process.alive.load(Ordering::SeqCst) { + return Ok(ready.clone()); + } + } + let now = Instant::now(); + launch + .attempts + .retain(|at| now.duration_since(*at) < RELAUNCH_WINDOW); + if !user && launch.attempts.len() >= RELAUNCH_LIMIT { + return Err(Error::new("REMOTE_RUNTIME", GAVE_UP)); + } + launch.attempts.push_back(now); + let _ = self.target.send(None); + if let Some(process) = launch.process.take() { + process.close(); + } + let result = self.relaunch(&mut launch).await; + if let Err(error) = &result { + (self.events)(Event::Failed { + message: error.message.clone(), + }); + } + result + } + + async fn relaunch(&self, launch: &mut Launch) -> Result { + let options = &self.options; + let connection = self.pool.connection(&options.host_id).await?; + if let Some(pid) = launch.pid.take() { + let stop = protocol::stop_stale_command(pid); + let _ = exec(&connection.handle, &stop, None, Duration::from_secs(10)).await; + } + let progress = |message: &str| { + (self.events)(Event::Progress { + message: message.into(), + }) + }; + let Installed { digest, method } = + runtime_install::install(&connection.handle, &self.source, &progress).await?; + progress(runtime_install::STARTING); + let channel = connection + .handle + .channel_open_session() + .await + .map_err(lost)?; + channel + .exec(true, protocol::launch_command(&digest)) + .await + .map_err(lost)?; + let (reader, writer) = channel.split(); + let writer: Writer = Arc::new(writer); + let frame = frames::launch_frame(&options.root, &options.workspace_key, &options.token); + writer.data(frame.as_bytes()).await.map_err(lost)?; + let mut stdout = Stdout { + reader, + frames: FrameReader::default(), + stderr: Vec::new(), + }; + let waited = tokio::time::timeout(options.ready_timeout, async { + loop { + for frame in stdout.next(&options.workspace_key).await? { + match frame { + Frame::Ready { port, root, open_file } => return Ok((port, root, open_file)), + Frame::Error { message } => return Err(Error::new("REMOTE_RUNTIME", message)), + other => self.observe(other, &writer), + } + } + } + }) + .await + .unwrap_or_else(|_| Err(Error::new( + "REMOTE_RUNTIME", + "Remote runtime startup timed out. Check the remote folder and operating system compatibility.", + ))); + let (port, root, open_file) = match waited { + Ok(ready) => ready, + Err(error) => { + let _ = writer.close().await; + return Err(error); + } + }; + launch.pid = stdout.pid(); + health(&self.pool, &options.host_id, port).await?; + let alive = Arc::new(AtomicBool::new(true)); + let tasks = vec![ + tokio::spawn(heartbeat(writer.clone(), alive.clone())), + tokio::spawn(watch_process( + self.myself.clone(), + stdout, + writer.clone(), + alive.clone(), + )), + ]; + launch.process = Some(Process { + writer, + alive, + tasks, + }); + let ready = Ready { + root, + open_file, + installed: Some(method), + }; + launch.ready = Some(ready.clone()); + let _ = self.target.send(Some(("127.0.0.1".into(), port))); + (self.events)(Event::Running); + Ok(ready) + } + + fn observe(&self, frame: Frame, writer: &Writer) { + match frame { + Frame::Progress { message } => (self.events)(Event::Progress { message }), + Frame::TaskForward { + request, + host, + port, + } => { + let (runtime, writer) = (self.myself.clone(), writer.clone()); + tokio::spawn(async move { + let Some(runtime) = runtime.upgrade() else { + return; + }; + let local = runtime + .forwards + .port(&runtime.pool, &runtime.options.host_id, &host, port) + .await + .ok(); + let reply = frames::task_forwarded_frame(&request, local); + let _ = writer.data(reply.as_bytes()).await; + }); + } + _ => {} + } + } + + /// Relaunches with backoff after the process or connection ended, until the budget runs out. + /// Boxed because it re-enters `ensure`, which spawns the task that calls it. + fn revive(self: Arc) -> Pin + Send>> { + Box::pin(async move { + for delay in [1, 4, 10] { + tokio::time::sleep(Duration::from_secs(delay)).await; + if self.stopped.load(Ordering::SeqCst) { + return; + } + let message = "Reconnecting to the remote workspace…".to_string(); + (self.events)(Event::Reconnecting { message }); + match self.ensure(false).await { + Ok(_) => return, + Err(error) if error.message == GAVE_UP => return, + Err(_) => {} + } + } + }) + } + + /// Asks the runtime to shut down, then closes its channel and every loopback listener. + pub async fn stop(&self) { + self.stopped.store(true, Ordering::SeqCst); + let _ = self.target.send(None); + self.forwards.clear().await; + let process = self.launch.lock().await.process.take(); + if let Some(process) = process { + let _ = process.writer.data(frames::SHUTDOWN_FRAME.as_bytes()).await; + let deadline = Instant::now() + Duration::from_millis(4500); + while process.alive.load(Ordering::SeqCst) && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + process.close(); + } + } + + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.tunnel.port) + } + + pub fn token(&self) -> &str { + &self.options.token + } + + pub fn workspace_key(&self) -> &str { + &self.options.workspace_key + } + + #[cfg(test)] + pub async fn pid(&self) -> Option { + self.launch.lock().await.pid + } +} + +async fn watch_process( + runtime: Weak, + mut stdout: Stdout, + writer: Writer, + alive: Arc, +) { + loop { + let Some(current) = runtime.upgrade() else { + return; + }; + match stdout.next(¤t.options.workspace_key).await { + Ok(frames) => frames + .into_iter() + .for_each(|frame| current.observe(frame, &writer)), + Err(_) => break, + } + } + alive.store(false, Ordering::SeqCst); + let _ = writer.close().await; + let runtime = runtime + .upgrade() + .filter(|runtime| !runtime.stopped.load(Ordering::SeqCst)); + if let Some(runtime) = runtime { + tokio::spawn(runtime.revive()); + } +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs b/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs new file mode 100644 index 00000000..ed8180c9 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs @@ -0,0 +1,117 @@ +//! Loopback listeners for the WebView. Each accepted connection becomes a `direct-tcpip` channel +//! to a port on the server, over the host's pooled SSH connection. +use super::{runtime_install::lost, session::Pool}; +use crate::fs_core::{Error, Result}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::watch, + task::JoinHandle, +}; + +const HEALTH_TIMEOUT: Duration = Duration::from_secs(10); + +/// Where accepted connections go on the server; `None` refuses them while the runtime restarts. +pub type Target = watch::Receiver>; + +pub struct Tunnel { + pub port: u16, + task: JoinHandle<()>, +} + +impl Tunnel { + pub async fn open(pool: Arc, host_id: String, target: Target) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|error| { + Error::new("TUNNEL", format!("Could not open a local port: {error}")) + })?; + let port = listener + .local_addr() + .map_err(|error| Error::new("TUNNEL", error.to_string()))? + .port(); + let task = tokio::spawn(async move { + while let Ok((socket, peer)) = listener.accept().await { + let Some((host, remote)) = target.borrow().clone() else { + continue; + }; + let (pool, host_id) = (pool.clone(), host_id.clone()); + tokio::spawn(async move { + let _ = forward(&pool, &host_id, socket, &host, remote, peer.port()).await; + }); + } + }); + Ok(Self { port, task }) + } +} + +impl Drop for Tunnel { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn forward( + pool: &Pool, + host_id: &str, + mut socket: TcpStream, + host: &str, + port: u16, + origin: u16, +) -> Result<()> { + let connection = pool.connection(host_id).await?; + let channel = connection + .handle + .channel_open_direct_tcpip(host, port.into(), "127.0.0.1", origin.into()) + .await + .map_err(lost)?; + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; + Ok(()) +} + +/// The desktop's readiness check: `/api/health` answers protocol 1 through the server's loopback. +pub async fn health(pool: &Pool, host_id: &str, port: u16) -> Result<()> { + let check = async { + let connection = pool.connection(host_id).await?; + let channel = connection + .handle + .channel_open_direct_tcpip("127.0.0.1", port.into(), "127.0.0.1", 0) + .await + .map_err(lost)?; + let mut stream = channel.into_stream(); + stream + .write_all(b"GET /api/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .await + .map_err(lost)?; + let mut response = Vec::new(); + let _ = (&mut stream).take(4096).read_to_end(&mut response).await; + Ok::<_, Error>(String::from_utf8_lossy(&response).into_owned()) + }; + let response = tokio::time::timeout(HEALTH_TIMEOUT, check) + .await + .map_err(|_| Error::new("REMOTE_RUNTIME", "The remote runtime did not answer."))??; + if healthy(&response) { + Ok(()) + } else { + Err(Error::new( + "REMOTE_RUNTIME", + "Remote runtime health check failed", + )) + } +} + +pub fn healthy(response: &str) -> bool { + let Some((head, body)) = response.split_once("\r\n\r\n") else { + return false; + }; + let ok = head + .lines() + .next() + .is_some_and(|status| status.split(' ').nth(1) == Some("200")); + let body = body.trim(); + let body = body.find('{').map_or(body, |start| &body[start..]); + let body = body.rfind('}').map_or(body, |end| &body[..=end]); + ok && serde_json::from_str::(body).is_ok_and(|value| { + value["ok"] == serde_json::json!(true) && value["protocol"] == serde_json::json!(1) + }) +} From 89aa5020a7a866ad816b4cf89fea56348c6e3460 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 05:11:01 -0400 Subject: [PATCH 04/12] Add Start Oxbit on This Server to the iOS app A saved SSH host can start the remote runtime from the start screen or the command palette. The dialog shows the desktop's install and start progress and defaults the folder to the host's last one. The workspace connects the existing runtime client to the loopback tunnel, appears in recent workspaces, resumes when the app returns to the foreground, and stops the remote runtime when it closes. --- apps/ios/src/ios.css | 1 + apps/ios/src/main.tsx | 84 ++++++++++++++++------ apps/ios/src/ssh-connect.tsx | 26 +++++-- apps/ios/src/start-screen.tsx | 11 ++- apps/ios/src/workspaces.ts | 48 ++++++++++++- packages/host-ios/src/index.ts | 2 + packages/host-ios/src/native.ts | 20 ++++++ packages/host-ios/src/workspaces.ts | 2 +- tests/ios/ssh-bridge.ts | 30 +++++++- tests/ios/ssh-runtime.spec.ts | 104 ++++++++++++++++++++++++++++ 10 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 tests/ios/ssh-runtime.spec.ts diff --git a/apps/ios/src/ios.css b/apps/ios/src/ios.css index 6bab627f..1d0d5eaa 100644 --- a/apps/ios/src/ios.css +++ b/apps/ios/src/ios.css @@ -86,6 +86,7 @@ body { background: #0f1115; color: #e6e6e6; -webkit-text-size-adjust: 100%; } .ssh-key-facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 12px; margin: 0; } .ssh-key-facts dt { color: var(--fg-3); } .ssh-key-facts dd { margin: 0; min-width: 0; } +.ssh-connect :is(.button, select, input:not([type="checkbox"])) { min-height: 44px; } .runtime-form label.ssh-check { flex-direction: row; align-items: center; } .ssh-transfer progress { width: 100%; height: 8px; accent-color: var(--accent); } .ssh-transfer-file { margin: 0; overflow-wrap: anywhere; } diff --git a/apps/ios/src/main.tsx b/apps/ios/src/main.tsx index 3bd9dab7..f738e320 100644 --- a/apps/ios/src/main.tsx +++ b/apps/ios/src/main.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import type { Session } from "@oxbit/app-workbench"; -import { IosGitClient, native, type RecentWorkspace, type SshFileSystem } from "@oxbit/host-ios"; +import { IosGitClient, native, ssh, type RecentWorkspace, type RemoteRuntimeEvent, type SshFileSystem } from "@oxbit/host-ios"; import { configurePanelWindows, currentTheme, themeMode, themeVariables, Workbench } from "@oxbit/workbench"; import { installTextInputPolicy, setLocale } from "@oxbit/ui"; import { StartScreen } from "./start-screen.js"; @@ -39,8 +39,18 @@ function describe(error: unknown) { return error instanceof Error ? error.message : String(error); } -function sshTarget(recent?: RecentWorkspace): { preset?: SshTarget } { - return recent?.hostId ? { preset: { hostId: recent.hostId, path: recent.remotePath ?? "~" } } : {}; +type SshDialog = { preset?: SshTarget; mode?: "files" | "runtime" }; + +function sshTarget(recent?: RecentWorkspace, mode?: SshDialog["mode"]): SshDialog { + return recent?.hostId ? { preset: { hostId: recent.hostId, path: recent.remotePath ?? "~" }, mode } : { mode }; +} + +/** The most recent folder per saved host, for the remote folder field. */ +function lastFolders(recents: RecentWorkspace[]) { + const folders: Record = {}; + for (const recent of recents) + if (recent.hostId && recent.remotePath && !(recent.hostId in folders)) folders[recent.hostId] = recent.remotePath; + return folders; } /** Uploads land in the selected folder, or beside the selected file. */ @@ -59,7 +69,8 @@ function App() { const [gitSettings, setGitSettings] = useState(false); const [cloning, setCloning] = useState(false); const [sshSettings, setSshSettings] = useState(false); - const [sshConnect, setSshConnect] = useState<{ preset?: SshTarget }>(); + const [sshConnect, setSshConnect] = useState(); + const [remoteProgress, setRemoteProgress] = useState(); const [transfer, setTransfer] = useState(); const [gitPrompt, setGitPrompt] = useState<{ rootId: string; resolve: (retry: boolean) => void }>(); const [busy, setBusy] = useState(); @@ -84,8 +95,44 @@ function App() { return undefined; }), []); + const close = useCallback(async () => { + const previous = current.current; + if (!previous) return; + flushSync(() => { + setWorkspace(undefined); + setSheet(true); + }); + await closeWorkspace(previous).catch((e) => setError(describe(e))); + }, []); + + /** Progress while a remote runtime starts; afterwards, reconnects show as notifications. */ + const remoteEvent = useCallback((event: RemoteRuntimeEvent) => { + if (opening.current) { + if (event.state === "progress") setRemoteProgress(event.message); + return; + } + const workbench = current.current?.session.workbench; + if (event.state === "failed") workbench?.notify(event.message, "error"); + else if (event.state === "running") workbench?.notify("Reconnected to the remote workspace.", "info"); + else workbench?.notify(event.message, "info"); + }, []); + + const resume = useCallback(() => { + const remote = current.current?.remote; + if (remote) void ssh.runtimeResume(remote.id).catch(() => {}); + }, []); + const open = useCallback(async (request: OpenRequest) => { if (opening.current) return "A workspace is already opening."; + const active = current.current; + if (request.kind === "sshRuntime" && active?.remote && active.recent.hostId === request.hostId) { + if (active.recent.remotePath === request.path) { + setSheet(false); + return; + } + // The new runtime cannot take the folder lock while the previous one holds it. + await close(); + } if (request.kind === "documents" && current.current?.recent.kind === "documents" && request.directory === current.current.recent.directory) { setSheet(false); @@ -104,9 +151,10 @@ function App() { opening.current = true; setError(undefined); setBusy(request.kind === "pick" ? "Choose a folder…" : "Opening…"); + setRemoteProgress(undefined); try { const previous = current.current; - const next = await openWorkspace(request); + const next = await openWorkspace(request.kind === "sshRuntime" ? { ...request, onEvent: remoteEvent } : request); flushSync(() => { setWorkspace(next); setWorkspaceKey(key => key + 1); @@ -122,24 +170,14 @@ function App() { opening.current = false; setBusy(undefined); } - }, []); - - const close = useCallback(async () => { - const previous = current.current; - if (!previous) return; - flushSync(() => { - setWorkspace(undefined); - setSheet(true); - }); - await closeWorkspace(previous).catch((e) => setError(describe(e))); - }, []); + }, [remoteEvent, close]); useEffect(() => { void (async () => { try { setRecents(await loadRecents()); const last = await lastWorkspace(); - if (last?.kind === "ssh") setSshConnect(sshTarget(last)); + if (last?.kind === "ssh" || last?.kind === "sshRuntime") setSshConnect(sshTarget(last, last.kind === "sshRuntime" ? "runtime" : "files")); else if (last) await open({ kind: "recent", recent: last }); } catch (e) { setError(describe(e)); @@ -150,12 +188,14 @@ function App() { }, [open]); useEffect(() => { + // iOS suspends sockets in the background, so a remote runtime reconnects on return. const persist = () => { if (document.visibilityState === "hidden") void current.current?.session.persist(); + else resume(); }; document.addEventListener("visibilitychange", persist); return () => document.removeEventListener("visibilitychange", persist); - }, []); + }, [resume]); useEffect(() => { const session = workspace?.session; @@ -175,6 +215,8 @@ function App() { const sshCommands = [ ["ssh.hosts", "SSH Hosts and Keys", () => setSshSettings(true)], ["ssh.connect", "Connect with SSH…", () => setSshConnect({})], + ["ssh.runtime", "Start Oxbit on This Server…", () => setSshConnect({ mode: "runtime" })], + ...workspace.remote ? [["ssh.runtime.reconnect", "Reconnect to Server", resume]] as const : [], ...filesystem ? [ ["ssh.upload", "Upload Files Here…", () => setTransfer({ kind: "upload", filesystem, directory: selectedDirectory(session) })], ["ssh.download", "Download to Device…", () => @@ -190,7 +232,7 @@ function App() { return () => { for (const disposable of disposables) disposable.dispose(); }; - }, [workspace?.session, workspace?.recent.kind, open, close]); + }, [workspace?.session, workspace?.recent.kind, workspace?.remote, open, close, resume]); const start = ( setGitSettings(true)} onClone={() => setCloning(true)} onSsh={recent => setSshConnect(sshTarget(recent))} + onSshRuntime={recent => setSshConnect(sshTarget(recent, "runtime"))} onSshSettings={() => setSshSettings(true)} /> ); @@ -224,7 +267,8 @@ function App() { {gitSettings && setGitSettings(false)} />} {cloning && open({ kind: "documents", directory })} onClose={() => setCloning(false)} />} - {sshConnect && open({ kind: "ssh", ...target })} + {sshConnect && open({ kind: sshConnect.mode === "runtime" ? "sshRuntime" : "ssh", ...target })} onManage={() => { setSshConnect(undefined); setSshSettings(true); }} onClose={() => setSshConnect(undefined)} />} {sshSettings && setSshSettings(false)} />} {transfer && { if (transfer.kind === "upload") void workspace?.session.workbench.refreshFiles(); }} diff --git a/apps/ios/src/ssh-connect.tsx b/apps/ios/src/ssh-connect.tsx index 6c50f96c..47baa9ba 100644 --- a/apps/ios/src/ssh-connect.tsx +++ b/apps/ios/src/ssh-connect.tsx @@ -11,8 +11,12 @@ type Prompt = | { kind: "unknown"; hostKey: SshHostKey } | { kind: "changed"; hostKey: SshHostKey; known: KnownHostKey[] }; -export function SshConnect({ preset, onOpen, onManage, onClose }: { +/** `runtime` starts Oxbit on the server; `files` opens the folder over SFTP. */ +export function SshConnect({ preset, mode = "files", progress, lastFolders = {}, onOpen, onManage, onClose }: { preset?: SshTarget; + mode?: "files" | "runtime"; + progress?: string; + lastFolders?: Record; onOpen: (target: SshTarget & { label: string }) => Promise; onManage: () => void; onClose: () => void; @@ -20,6 +24,7 @@ export function SshConnect({ preset, onOpen, onManage, onClose }: { const [hosts, setHosts] = useState(); const [hostId, setHostId] = useState(preset?.hostId ?? ""); const [path, setPath] = useState(preset?.path ?? "~"); + const runtime = mode === "runtime"; const [password, setPassword] = useState(""); const [savePassword, setSavePassword] = useState(false); const [needsPassword, setNeedsPassword] = useState(false); @@ -57,13 +62,15 @@ export function SshConnect({ preset, onOpen, onManage, onClose }: { const initial = list.find(item => item.id === hostId) ?? list[0]; if (!initial) return; setHostId(initial.id); + if (!preset) setPath(lastFolders[initial.id] ?? "~"); if (autoConnect.current && initial.id === preset?.hostId && (initial.auth === "key" || initial.passwordSaved)) return connect(initial); }).catch(failure => { if (active) setError(describe(failure)); }).finally(() => { if (active) setBusy(false); }); return () => { active = false; }; }, []); - const title = prompt?.kind === "changed" ? "Host Key Changed" : prompt ? "Confirm Host Key" : "Connect with SSH"; + const title = prompt?.kind === "changed" ? "Host Key Changed" : prompt ? "Confirm Host Key" + : runtime ? "Start Oxbit on This Server" : "Connect with SSH"; return { if (!busy) onClose(); }} initialFocus={prompt ? undefined : "select"}> {prompt && host ?
{prompt.kind === "unknown" ? <> @@ -95,12 +102,18 @@ export function SshConnect({ preset, onOpen, onManage, onClose }: { setError("Forgot the saved host key. Connect again to review the new fingerprint."); })}>Forget saved host key}
- :
{ + : { event.preventDefault(); if (host && !busy) perform(() => connect(host)); }}> + {runtime &&

Oxbit installs its runtime on the server and runs terminals, tasks, and agents there. Linux x64 and macOS Apple Silicon servers are supported.

} {hosts && !hosts.length ?

Add a server in SSH Hosts and Keys first.

: <> - @@ -111,11 +124,14 @@ export function SshConnect({ preset, onOpen, onManage, onClose }: { } } + {busy && progress &&

{progress}

} {error &&

{error}

}
- +
}
; diff --git a/apps/ios/src/start-screen.tsx b/apps/ios/src/start-screen.tsx index 7df74400..32cf2740 100644 --- a/apps/ios/src/start-screen.tsx +++ b/apps/ios/src/start-screen.tsx @@ -16,6 +16,7 @@ export function StartScreen({ onGitSettings, onClone, onSsh, + onSshRuntime, onSshSettings, }: { recents: RecentWorkspace[]; @@ -31,11 +32,12 @@ export function StartScreen({ onGitSettings: () => void; onClone: () => void; onSsh: (recent?: RecentWorkspace) => void; + onSshRuntime: (recent?: RecentWorkspace) => void; onSshSettings: () => void; }) { const folders = recents.filter((recent) => recent.kind === "bookmark" || (recent.kind === "documents" && recent.directory)); const runtimes = recents.filter(recent => recent.kind === "runtime"); - const servers = recents.filter(recent => recent.kind === "ssh"); + const servers = recents.filter(recent => recent.kind === "ssh" || recent.kind === "sshRuntime"); return (
@@ -99,12 +101,15 @@ export function StartScreen({

On a server

{servers.map(recent =>
-
)} +
diff --git a/apps/ios/src/workspaces.ts b/apps/ios/src/workspaces.ts index db7ba57f..9f97d4e4 100644 --- a/apps/ios/src/workspaces.ts +++ b/apps/ios/src/workspaces.ts @@ -1,4 +1,4 @@ -import { createWorkbenchSession, RuntimeFileSystem, type Session } from "@oxbit/app-workbench"; +import { createWorkbenchSession, RuntimeClient, RuntimeFileSystem, type Session } from "@oxbit/app-workbench"; import { connectRuntime, runtimeScope } from "./runtime.js"; import { normalizePath } from "@oxbit/host-browser"; import { @@ -13,7 +13,9 @@ import { loadRecents, native, rememberWorkspace, + ssh, type RecentWorkspace, + type RemoteRuntimeEvent, } from "@oxbit/host-ios"; export const DOCUMENTS_ID = "documents"; @@ -23,6 +25,8 @@ export interface OpenWorkspace { recent: RecentWorkspace; session: Session; git?: IosGitClient; + /** A runtime this app started on an SSH host; closing the workspace stops it. */ + remote?: { id: string; unlisten: () => void }; } export type OpenRequest = @@ -30,11 +34,12 @@ export type OpenRequest = | { kind: "pick" } | { kind: "runtime"; url: string; code?: string } | { kind: "ssh"; hostId: string; path: string; label: string } + | { kind: "sshRuntime"; hostId: string; path: string; label: string; onEvent?: (event: RemoteRuntimeEvent) => void } | { kind: "recent"; recent: RecentWorkspace }; const iconPackStore = new IosIconPackStore(); -async function resolvePath(request: Exclude): Promise<{ path: string; recent: Omit }> { +async function resolvePath(request: Exclude): Promise<{ path: string; recent: Omit }> { if (request.kind === "documents" || (request.kind === "recent" && request.recent.kind === "documents")) { const directory = request.kind === "documents" ? request.directory : request.recent.directory; const relative = directory ? normalizePath(directory) : undefined; @@ -73,7 +78,9 @@ export async function openWorkspace(request: OpenRequest): Promise): Promise< } } +/** The host must already be connected. Progress and reconnect events go to `onEvent`. */ +async function openSshRuntime(request: Extract): Promise { + const id = crypto.randomUUID(); + const unlisten = await ssh.onRuntime(id, event => request.onEvent?.(event)); + let runtime: RuntimeClient | undefined; + let session: Session | undefined; + try { + const started = await ssh.runtimeStart(id, request.hostId, request.path); + runtime = new RuntimeClient(started.url, "default", { token: started.token, persistToken: false }); + await runtime.connect(); + const scope = "ios:" + started.workspaceKey; + session = await createWorkbenchSession({ + filesystem: new RuntimeFileSystem(runtime, scope), runtime, persistence: new IosPersistence(scope), protectUnload: false, iconPackStore, + }); + if (started.openFile) await session.workbench.openFile(started.openFile, { preview: false }); + const recent: RecentWorkspace = { + id: "sshRuntime:" + started.workspaceKey, kind: "sshRuntime", name: `${request.label}: ${started.root.split("/").at(-1) || started.root}`, + hostId: request.hostId, remotePath: started.root, lastOpened: Date.now(), + }; + await rememberWorkspace(recent); + await native.storageSet(SESSION_SCOPE, LAST_KEY, recent.id); + return { recent, session, remote: { id, unlisten } }; + } catch (error) { + if (session) await session.dispose(); + else runtime?.dispose(); + unlisten(); + await ssh.runtimeStop(id).catch(() => {}); + throw error; + } +} + export async function closeWorkspace(workspace: OpenWorkspace) { await workspace.session.persist().catch(() => {}); try { @@ -128,6 +166,10 @@ export async function closeWorkspace(workspace: OpenWorkspace) { } finally { if (workspace.git || workspace.recent.kind === "ssh") await workspace.session.filesystem.dispose?.(); if (workspace.recent.kind === "bookmark") await native.closeFolder(workspace.recent.id).catch(() => {}); + if (workspace.remote) { + workspace.remote.unlisten(); + await ssh.runtimeStop(workspace.remote.id).catch(() => {}); + } } } diff --git a/packages/host-ios/src/index.ts b/packages/host-ios/src/index.ts index 1bdf57ff..26fc323f 100644 --- a/packages/host-ios/src/index.ts +++ b/packages/host-ios/src/index.ts @@ -25,6 +25,8 @@ export { type PickedFile, type TransferProgress, type TransferSummary, + type RemoteRuntimeStarted, + type RemoteRuntimeEvent, } from "./native.js"; export { IosGitClient, SSH_PROMPT_CODES, type SshGitPromptHandler } from "./git.js"; export { IosFileSystem, SshFileSystem, revisionOf } from "./filesystem.js"; diff --git a/packages/host-ios/src/native.ts b/packages/host-ios/src/native.ts index ccad8c22..34fb275b 100644 --- a/packages/host-ios/src/native.ts +++ b/packages/host-ios/src/native.ts @@ -162,6 +162,20 @@ function rawWrite(command: string, id: string, path: string, bytes: Uint8Array, }); } +/** A runtime started on an SSH host, reached through a loopback tunnel. */ +export interface RemoteRuntimeStarted { + url: string; + token: string; + workspaceKey: string; + root: string; + openFile?: string | null; +} +export type RemoteRuntimeEvent = + | { state: "progress"; message: string } + | { state: "running" } + | { state: "reconnecting"; message: string } + | { state: "failed"; message: string }; + /** SFTP workspace roots take the same arguments as device roots. */ export const ssh = { closeRoot: (id: string) => call("ios_ssh_close_root", { id }), @@ -201,6 +215,12 @@ export const ssh = { cancelTransfer: (transferId: string) => call("ios_ssh_transfer_cancel", { transferId }), onTransfer: (transferId: string, listener: (progress: TransferProgress) => void) => listen(`ios-ssh-transfer:${transferId}`, (event) => listener(event.payload)), + runtimeStart: (id: string, hostId: string, path: string) => + call("ios_ssh_runtime_start", { id, hostId, path }), + runtimeResume: (id: string) => call("ios_ssh_runtime_resume", { id }), + runtimeStop: (id: string) => call("ios_ssh_runtime_stop", { id }), + onRuntime: (id: string, listener: (event: RemoteRuntimeEvent) => void) => + listen(`ios-ssh-runtime:${id}`, (event) => listener(event.payload)), }; export const native = { diff --git a/packages/host-ios/src/workspaces.ts b/packages/host-ios/src/workspaces.ts index 467c26d0..b5bec37f 100644 --- a/packages/host-ios/src/workspaces.ts +++ b/packages/host-ios/src/workspaces.ts @@ -3,7 +3,7 @@ import { SESSION_SCOPE } from "./persistence.js"; export interface RecentWorkspace { id: string; - kind: "documents" | "bookmark" | "runtime" | "ssh"; + kind: "documents" | "bookmark" | "runtime" | "ssh" | "sshRuntime"; name: string; url?: string; bookmark?: string; diff --git a/tests/ios/ssh-bridge.ts b/tests/ios/ssh-bridge.ts index 72bccc3f..db0ba2ad 100644 --- a/tests/ios/ssh-bridge.ts +++ b/tests/ios/ssh-bridge.ts @@ -6,14 +6,19 @@ export interface SshSeed { known?: Record; /** Errors that `git.clone` returns in order before it succeeds. */ clone?: ("SSH_KEY_REQUIRED" | "HOST_KEY_UNKNOWN" | "HOST_KEY_CHANGED")[]; + /** `ios_ssh_runtime_start` fails with this message after the install progress. */ + runtimeFailure?: string; } +/** Where the mocked remote runtime listens; specs answer its WebSocket with `page.routeWebSocket`. */ +export const RUNTIME_URL = "http://127.0.0.1:9399"; + export const PRESENTED = { algorithm: "ssh-ed25519", fingerprint: "SHA256:qP8mY2tVt3rJ0q7dGm1xC5sWn9uL4kHf6aZ2bE8cR0o" }; export const SAVED = { algorithm: "ssh-ed25519", fingerprint: "SHA256:Lk3Vx9aQe2Tz7Wn1Rb5Hc8Ym4Uf0Jd6Gs2Po9Ki3Lm7", added: 1 }; /** Serves the SSH commands from memory. Transfers hold at 40% until `__sshMock.release()` or a cancel. */ export async function installSshBridge(page: Page, seed: SshSeed = {}) { - await page.addInitScript(({ seed, presented, saved }) => { + await page.addInitScript(({ seed, presented, saved, runtimeUrl }) => { const encoder = new TextEncoder(); const hosts = seed.hosts ?? []; const keys = seed.keys ?? []; @@ -23,6 +28,7 @@ export async function installSshBridge(page: Page, seed: SshSeed = {}) { const files = new Map([["README.md", "# Remote project\n"], ["src/main.ts", "export const remote = true;\n"]]); const directories = new Set(["src"]); const pending = new Map void; cancel: () => void }>(); + const runtimes = new Set(); const calls: string[] = []; const clone = [...(seed.clone ?? [])]; const prompts = new Map(); @@ -111,6 +117,26 @@ export async function installSshBridge(page: Page, seed: SshSeed = {}) { prompts.delete(id); }, ios_ssh_git_forget_host_key: ({ id }) => { prompts.delete(id); }, + // Install progress, held until `release()`; then the start progress and the started runtime. + ios_ssh_runtime_start: ({ id, hostId, path }, emit) => { + if (!connected.has(hostId)) fail("NOT_CONNECTED", "Connect to this SSH host before opening its files."); + emit(`ios-ssh-runtime:${id}`, { state: "progress", message: "Installing the remote runtime…" }); + return new Promise((resolve, reject) => pending.set(id, { + finish: () => { + if (seed.runtimeFailure) return reject({ code: "REMOTE_UNSUPPORTED", message: seed.runtimeFailure }); + emit(`ios-ssh-runtime:${id}`, { state: "progress", message: "Starting the remote workspace…" }); + runtimes.add(id); + const root = "/home/dev/" + String(path).replace(/^~\/?/, ""); + setTimeout(() => resolve({ url: runtimeUrl, token: "t".repeat(64), workspaceKey: "a".repeat(64), root, openFile: null }), 300); + }, + cancel: () => reject({ code: "CANCELLED", message: "Cancelled" }), + })); + }, + ios_ssh_runtime_resume: ({ id }) => { + if (!runtimes.has(id)) fail("REMOTE_RUNTIME", "This remote workspace is closed. Start it again."); + return { url: runtimeUrl, token: "t".repeat(64), workspaceKey: "a".repeat(64), root: "/home/dev/project", openFile: null }; + }, + ios_ssh_runtime_stop: ({ id }) => { runtimes.delete(id); }, ios_ssh_transfer_cancel: ({ transferId }) => { pending.get(transferId)?.cancel(); pending.delete(transferId); }, }; Object.assign(window, { @@ -138,5 +164,5 @@ export async function installSshBridge(page: Page, seed: SshSeed = {}) { }, }, }); - }, { seed, presented: PRESENTED, saved: SAVED }); + }, { seed, presented: PRESENTED, saved: SAVED, runtimeUrl: RUNTIME_URL }); } diff --git a/tests/ios/ssh-runtime.spec.ts b/tests/ios/ssh-runtime.spec.ts new file mode 100644 index 00000000..4869e8c7 --- /dev/null +++ b/tests/ios/ssh-runtime.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installBridge } from "./bridge.js"; +import { installSshBridge, PRESENTED, RUNTIME_URL, type SshSeed } from "./ssh-bridge.js"; + +const OUT = process.env.OXBIT_SSH_SCREENSHOTS ?? "evidence/ios-ssh"; +const key = { id: "key-1", name: "Phone", algorithm: "ssh-ed25519", fingerprint: "SHA256:Zq0c7Qm4l7gK3qGxw0b3m8R3pYk9uTn2vL6hJd1sFfE", publicKey: "ssh-ed25519 AAAAC3Nza phone@oxbit-ios" }; +const host = { id: "host-1", label: "Build box", hostname: "build.example.test", port: 22, username: "dev", auth: "key" as const, keyId: "key-1", passwordSaved: false }; +const seed: SshSeed = { hosts: [host], keys: [key], known: { "host-1": [{ ...PRESENTED, added: 1 }] } }; + +/** Answers the runtime protocol the way a remote `desktop.js` does for an empty project. */ +async function mockRuntime(page: Page) { + const methods: string[] = []; + await page.routeWebSocket(RUNTIME_URL.replace("http", "ws") + "/ws", socket => { + socket.onMessage(message => { + const request = JSON.parse(String(message)); + methods.push(request.method); + const result = request.method === "auth.authenticate" + ? { workspaceId: "default", capabilities: ["files", "terminal", "tasks", "agents"], trusted: false, owner: true, workspaceKey: "a".repeat(64), workspaceName: "project" } + : request.method === "fs.list" ? [{ path: "hello.ts", name: "hello.ts", kind: "file" }] + : request.method.endsWith(".list") ? [] : {}; + socket.send(JSON.stringify({ v: 1, type: "response", id: request.id, result })); + }); + }); + return methods; +} + +async function start(page: Page, overrides: SshSeed = {}) { + await installBridge(page); + await installSshBridge(page, { ...seed, ...overrides }); + const methods = await mockRuntime(page); + await page.goto("/"); + return methods; +} + +async function shoot(page: Page, name: string) { + await page.screenshot({ path: `${OUT}/${name}-393x852.png`, animations: "disabled" }); + await page.setViewportSize({ width: 320, height: 700 }); + await page.screenshot({ path: `${OUT}/${name}-320x700.png`, animations: "disabled" }); + await page.setViewportSize({ width: 393, height: 852 }); +} + +async function topmost(page: Page, name: string) { + const dialog = page.getByRole("dialog", { name, exact: true }); + await expect(dialog).toBeVisible(); + return dialog.evaluate(element => { + const box = element.getBoundingClientRect(); + return element.contains(document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)); + }); +} + +const calls = (page: Page) => page.evaluate(() => (window as any).__sshMock.calls as string[]); +const release = (page: Page) => page.evaluate(() => (window as any).__sshMock.release()); + +async function openDialog(page: Page) { + await page.getByRole("button", { name: "Start Oxbit on This Server…", exact: true }).click(); + expect(await topmost(page, "Start Oxbit on This Server")).toBe(true); +} + +test("Start Oxbit on This Server shows the desktop progress, opens the workspace, and stops it on close", async ({ page }) => { + const methods = await start(page); + await openDialog(page); + await expect(page.getByRole("textbox", { name: "Remote folder" })).toHaveValue("~"); + for (const control of [page.getByRole("button", { name: "Start", exact: true }), page.getByRole("combobox", { name: "Server" })]) + expect((await control.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await shoot(page, "runtime-start"); + + await page.getByRole("textbox", { name: "Remote folder" }).fill("project"); + await page.getByRole("button", { name: "Start", exact: true }).click(); + await expect(page.getByRole("status").filter({ hasText: "Installing the remote runtime…" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Starting…", exact: true })).toBeDisabled(); + await shoot(page, "runtime-installing"); + await release(page); + + await expect(page.locator(".workspace-title")).toHaveText("Build box: project"); + expect(await calls(page)).toEqual(expect.arrayContaining(["ios_ssh_connect", "ios_ssh_runtime_start"])); + expect(methods[0]).toBe("auth.authenticate"); + expect(methods).toContain("fs.list"); + + await page.locator(".workspace-title").click(); + const entry = page.getByRole("region", { name: "On a server" }).getByRole("button", { name: /^Build box: project/ }); + await expect(entry).toContainText("Oxbit runtime · /home/dev/project"); + expect((await entry.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await shoot(page, "runtime-recent"); + + await openDialog(page); + await expect(page.getByRole("textbox", { name: "Remote folder" })).toHaveValue("/home/dev/project"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + + await page.getByRole("button", { name: /^Oxbit/ }).first().click(); + await expect(page.locator(".workspace-title")).toHaveText("Oxbit"); + await expect.poll(() => calls(page)).toContain("ios_ssh_runtime_stop"); +}); + +test("A failed start keeps the dialog open with the error and stops the runtime", async ({ page }) => { + await start(page, { runtimeFailure: "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon." }); + await openDialog(page); + await page.getByRole("button", { name: "Start", exact: true }).click(); + await expect(page.getByRole("status").filter({ hasText: "Installing the remote runtime…" })).toBeVisible(); + await release(page); + const dialog = page.getByRole("dialog", { name: "Start Oxbit on This Server", exact: true }); + await expect(dialog.getByRole("alert")).toHaveText("Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon."); + await expect.poll(() => calls(page)).toContain("ios_ssh_runtime_stop"); + await shoot(page, "runtime-error"); +}); From c0c7b21a07368056e7436b280417979465efe519 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 05:16:59 -0400 Subject: [PATCH 05/12] Test the iOS remote runtime live against the Docker sshd Covers the device-stream and curl installs, fs.read and a terminal command through the tunnel, the cached launch, relaunch after kill -9, and reconnect after docker restart. The live script serves the tarball while cargo runs. --- .../src-tauri/src/ssh/live_runtime_tests.rs | 245 ++++++++++++++++++ apps/ios/src-tauri/src/ssh/live_tests.rs | 2 +- apps/ios/src-tauri/src/ssh/mod.rs | 2 + .../ios/src-tauri/src/ssh/runtime_protocol.rs | 2 +- .../src/ssh/runtime_protocol_tests.rs | 2 +- scripts/ios/runtime-probe.mjs | 49 ++++ scripts/ios/ssh-live.mjs | 29 ++- scripts/remote/sshd.Dockerfile | 2 +- 8 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 apps/ios/src-tauri/src/ssh/live_runtime_tests.rs create mode 100644 scripts/ios/runtime-probe.mjs diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs new file mode 100644 index 00000000..5226592d --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs @@ -0,0 +1,245 @@ +//! Live remote runtime against the Docker sshd fixture: device-stream and host-download installs, +//! the cached launch, the tunnel, relaunch after a kill, and reconnect after `docker restart`. +use super::{ + connect::{Credential, Outcome, Target}, + keys, + known_hosts::KnownHosts, + live_tests::{env, exec, restart}, + runtime_install::{Source, INSTALLING, STARTING}, + runtime_process::{Event, Options}, + runtime_protocol::{Downloader, Install}, + runtime_session::RemoteRuntime, + session::Pool, +}; +use crate::{fs_core::revision, remote_runtime::Download}; +use std::{ + path::PathBuf, + process::Command, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +type Seen = Arc>>; + +fn payload() -> (PathBuf, String) { + let directory = PathBuf::from(env("OXBIT_SSH_TEST_PAYLOAD_DIR")); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(directory.join("manifest.json")).unwrap()).unwrap(); + let sha256 = manifest["platforms"]["linux-x64"]["sha256"] + .as_str() + .unwrap() + .to_string(); + (directory.join("linux-x64.tar.gz"), sha256) +} + +/// `url` is what the host downloads; the device side copies the local archive. +fn source(url: String, scratch: PathBuf) -> Source { + let (archive, sha256) = payload(); + let size = std::fs::metadata(&archive).unwrap().len(); + let download = Download { url, sha256, size }; + Source { + lookup: Arc::new(move |platform| { + assert_eq!(platform, "linux-x64"); + Ok(download.clone()) + }), + fetch: Arc::new(move |download| { + let (archive, copy) = ( + archive.clone(), + scratch.join(format!("{}.tar.gz", download.sha256)), + ); + Box::pin(async move { + std::fs::copy(&archive, ©).unwrap(); + Ok(copy) + }) + }), + } +} + +async fn start(pool: &Arc, host_id: &str, source: Source, seen: &Seen) -> Arc { + let root = "/~/project".to_string(); + let seen = seen.clone(); + let options = Options { + host_id: host_id.into(), + workspace_key: revision(format!("ssh-runtime\0{host_id}\0{root}").as_bytes()), + root, + token: format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ), + ready_timeout: Duration::from_secs(120), + }; + let events = Arc::new(move |event| seen.lock().unwrap().push(event)); + RemoteRuntime::open(pool.clone(), source, events, options) + .await + .unwrap() +} + +async fn probe(runtime: &RemoteRuntime) -> String { + let (url, token) = (runtime.url(), runtime.token().to_string()); + let script = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../scripts/ios/runtime-probe.mjs"); + let output = tokio::task::spawn_blocking(move || { + Command::new("node") + .args([ + script.to_str().unwrap(), + &url, + &token, + "hello.ts", + "echo oxbit-$((6*7))", + "oxbit-42", + ]) + .output() + .unwrap() + }) + .await + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "probe failed: {stderr}"); + String::from_utf8(output.stdout).unwrap() +} + +fn progress(seen: &Seen) -> Vec { + let events = seen.lock().unwrap(); + events + .iter() + .filter_map(|event| match event { + Event::Progress { message } => Some(message.clone()), + _ => None, + }) + .collect() +} + +async fn running_after(seen: &Seen, from: usize, limit: Duration) -> bool { + let deadline = Instant::now() + limit; + while Instant::now() < deadline { + if seen.lock().unwrap()[from..].contains(&Event::Running) { + return true; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + false +} + +fn remote_runtimes(container: &str) -> String { + exec( + container, + // The brackets keep the pattern from matching the `sh -c` running pgrep. + "pgrep -f '[.]oxbit/remote/runtimes/.*/desktop[.]js' || true", + ) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the Docker sshd fixture"] +async fn live_remote_runtime() { + let container = env("OXBIT_SSH_TEST_CONTAINER"); + let port: u16 = env("OXBIT_SSH_TEST_PORT").parse().unwrap(); + let served = env("OXBIT_SSH_TEST_PAYLOAD_URL"); + let key_text = std::fs::read_to_string(env("OXBIT_SSH_TEST_KEY")).unwrap(); + let imported = + keys::import("Live", &key_text, Some(&env("OXBIT_SSH_TEST_PASSPHRASE"))).unwrap(); + let scratch = std::env::temp_dir().join(format!("oxbit-runtime-live-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&scratch).unwrap(); + let known = Arc::new(KnownHosts::new(scratch.join("known_hosts.json"))); + let pool = Arc::new(Pool::new(known.clone())); + let target = Target { + host_id: "live".into(), + hostname: "127.0.0.1".into(), + port, + username: "root".into(), + }; + let credential = Credential::Key(Arc::new(keys::decode(&imported.private_key).unwrap())); + if let Outcome::HostUnknown { host_key } = pool + .connect(target.clone(), credential.clone()) + .await + .unwrap() + { + known + .trust( + "127.0.0.1", + port, + &host_key.algorithm, + &host_key.fingerprint, + ) + .unwrap(); + } + assert!(matches!( + pool.connect(target, credential).await.unwrap(), + Outcome::Connected { .. } + )); + exec(&container, "rm -rf /root/.oxbit"); + + let seen: Seen = Arc::default(); + let missing = source(format!("{served}/missing.tar.gz"), scratch.clone()); + let runtime = start(&pool, "live", missing, &seen).await; + let ready = runtime.ensure(true).await.unwrap(); + assert_eq!(ready.installed, Some(Install::DeviceStream)); + assert_eq!(ready.root, "/root/project"); + assert_eq!(progress(&seen), [INSTALLING, STARTING]); + let result = probe(&runtime).await; + assert!(result.contains("export const message"), "{result}"); + println!( + "PASS device-stream install after the host download failed, fs.read and terminal: {}", + result.trim() + ); + runtime.stop().await; + let deadline = Instant::now() + Duration::from_secs(10); + while !remote_runtimes(&container).is_empty() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(250)).await; + } + assert_eq!(remote_runtimes(&container), ""); + println!("PASS stop ends the remote process"); + + exec(&container, "rm -rf /root/.oxbit/remote/runtimes"); + let seen: Seen = Arc::default(); + let hosted = source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + let runtime = start(&pool, "live", hosted.clone(), &seen).await; + let ready = runtime.ensure(true).await.unwrap(); + assert_eq!( + ready.installed, + Some(Install::HostDownload(Downloader::Curl)) + ); + probe(&runtime).await; + runtime.stop().await; + println!("PASS host-download install with curl"); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted, &seen).await; + let ready = runtime.ensure(true).await.unwrap(); + assert_eq!(ready.installed, Some(Install::Cached)); + assert_eq!(progress(&seen), [STARTING]); + let url = runtime.url(); + println!("PASS cached launch skips the install"); + + let pid = runtime.pid().await.expect("the launch reports its PID"); + let mark = seen.lock().unwrap().len(); + exec(&container, &format!("kill -9 {pid}")); + assert!( + running_after(&seen, mark, Duration::from_secs(120)).await, + "no relaunch: {:?}", + seen.lock().unwrap() + ); + assert_ne!(runtime.pid().await, Some(pid)); + assert_eq!(runtime.url(), url); + probe(&runtime).await; + println!("PASS relaunch after the remote process was killed, same local URL"); + + let mark = seen.lock().unwrap().len(); + restart(&container, port).await; + exec( + &container, + "/usr/sbin/sshd -p 2222 -o PasswordAuthentication=yes -o UsePAM=no -o PermitRootLogin=yes", + ); + let automatic = running_after(&seen, mark, Duration::from_secs(60)).await; + if !automatic { + runtime.ensure(true).await.unwrap(); + } + probe(&runtime).await; + println!( + "PASS reconnect after docker restart ({})", + if automatic { "automatic" } else { "resume" } + ); + + runtime.stop().await; + std::fs::remove_dir_all(&scratch).unwrap(); +} diff --git a/apps/ios/src-tauri/src/ssh/live_tests.rs b/apps/ios/src-tauri/src/ssh/live_tests.rs index 79e368b0..bc3120c5 100644 --- a/apps/ios/src-tauri/src/ssh/live_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_tests.rs @@ -44,7 +44,7 @@ pub(super) fn host_fingerprint(container: &str) -> String { .to_string() } -async fn restart(container: &str, port: u16) { +pub(super) async fn restart(container: &str, port: u16) { docker(&["restart", container]); for _ in 0..100 { if tokio::net::TcpStream::connect(("127.0.0.1", port)) diff --git a/apps/ios/src-tauri/src/ssh/mod.rs b/apps/ios/src-tauri/src/ssh/mod.rs index caf5f319..2f3fcfc4 100644 --- a/apps/ios/src-tauri/src/ssh/mod.rs +++ b/apps/ios/src-tauri/src/ssh/mod.rs @@ -12,6 +12,8 @@ pub mod known_hosts; #[cfg(test)] mod live_git_tests; #[cfg(test)] +mod live_runtime_tests; +#[cfg(test)] mod live_tests; pub mod runtime_commands; pub mod runtime_frames; diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs index 6c548e89..2f301e75 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs @@ -134,7 +134,7 @@ pub fn host_install_command(digest: &str, url: &str, downloader: Downloader) -> return Err(Error::invalid("Invalid runtime download URL")); } let fetch = match downloader { - Downloader::Curl => "curl -fsSL --connect-timeout 15", + Downloader::Curl => "curl -fsSL --connect-timeout 15 --speed-limit 1024 --speed-time 30", Downloader::Wget => "wget -qO- -T 15", }; let pipeline = format!( diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs index ce95c6fe..36e5aa72 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -74,7 +74,7 @@ fn builds_the_shared_remote_commands() { let url = "https://github.com/yannelli/oxbit/releases/download/v0.3.4/remote-runtime-linux-x64.tar.gz"; let curl = host_install_command(KEY, url, Downloader::Curl).unwrap(); assert!( - curl.starts_with("sh -c 'curl -fsSL --connect-timeout 15 '\\''https://github.com/"), + curl.starts_with("sh -c 'curl -fsSL --connect-timeout 15 --speed-limit 1024 --speed-time 30 '\\''https://github.com/"), "{curl}" ); assert!(curl.contains(KEY)); diff --git a/scripts/ios/runtime-probe.mjs b/scripts/ios/runtime-probe.mjs new file mode 100644 index 00000000..382b56c4 --- /dev/null +++ b/scripts/ios/runtime-probe.mjs @@ -0,0 +1,49 @@ +// Drives a runtime through the iOS tunnel the way the WebView does: authenticate, read a file, +// trust the workspace, and run one terminal command. Prints one JSON line. +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +const [url, token, file, command, expected] = process.argv.slice(2); +const { WebSocket } = createRequire(new URL("../../apps/runtime/package.json", import.meta.url))("ws"); +const socket = new WebSocket(url.replace(/^http/, "ws") + "/ws", { origin: "tauri://localhost" }); +const pending = new Map(); +const output = []; +let sequence = 0; +const run = randomUUID(); +const fail = (message) => { console.error(message); process.exit(1); }; +setTimeout(() => fail("probe timed out"), 60000).unref(); +socket.on("error", (error) => fail(String(error))); +socket.on("message", (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type === "event" && message.event === "terminal.data") output.push(message.params.data); + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + if (message.error) entry.reject(new Error(`${entry.method}: ${JSON.stringify(message.error)}`)); + else entry.resolve(message.result); +}); +const request = (method, params = {}) => new Promise((resolve, reject) => { + // Unique per run: the runtime persists operation results by request id across launches. + const id = `probe-${run}-${++sequence}`; + pending.set(id, { method, resolve, reject }); + socket.send(JSON.stringify({ v: 1, type: "request", id, method, params })); +}); +await new Promise((resolve) => socket.once("open", resolve)); +try { + const session = await request("auth.authenticate", { token }); + const read = await request("fs.read", { path: file }); + await request("workspace.trust", { trusted: true }); + const terminal = await request("terminal.create", { cols: 80, rows: 24 }); + await request("terminal.input", { id: terminal.id, data: command + "\r" }); + const deadline = Date.now() + 30000; + while (!output.join("").includes(expected)) { + if (Date.now() > deadline) fail("terminal output did not arrive: " + JSON.stringify(output.join("").slice(-400))); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await request("terminal.kill", { id: terminal.id }); + console.log(JSON.stringify({ owner: session.owner, text: read.text, terminal: true })); + socket.close(); + process.exit(0); +} catch (error) { + fail(String(error)); +} diff --git a/scripts/ios/ssh-live.mjs b/scripts/ios/ssh-live.mjs index 5bf36366..4fae84de 100644 --- a/scripts/ios/ssh-live.mjs +++ b/scripts/ios/ssh-live.mjs @@ -1,6 +1,7 @@ // Runs the iOS crate's live SSH test against the Docker sshd fixture from scripts/remote. -import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import { createReadStream, mkdtempSync, rmSync, existsSync } from "node:fs"; +import { createServer as createHttpServer } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,6 +13,11 @@ const crate = join(root, "apps/ios/src-tauri"); const helpers = "/Applications/Docker.app/Contents/Resources/bin"; if (existsSync(helpers)) process.env.PATH = `${helpers}:${process.env.PATH}`; const run = (command, args, options = {}) => execFileSync(command, args, { stdio: "inherit", ...options }); +// Async so the tarball server below keeps answering while cargo runs. +const runAsync = (command, args, options) => new Promise((resolve, reject) => { + spawn(command, args, { stdio: "inherit", ...options }).on("error", reject) + .on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`))); +}); const docker = (...args) => execFileSync("docker", args, { encoding: "utf8" }).trim(); const freePort = () => new Promise((resolve, reject) => { const server = createServer().listen(0, "127.0.0.1", () => { @@ -20,6 +26,17 @@ const freePort = () => new Promise((resolve, reject) => { }).on("error", reject); }); +// The remote runtime test needs `bun run remote:prepare`; the host downloads from this server. +const payload = join(root, "apps/desktop/src-tauri/resources/runtime/remote"); +const hasPayload = existsSync(join(payload, "linux-x64.tar.gz")); +const files = createHttpServer((request, response) => { + if (request.url !== "/linux-x64.tar.gz") return response.writeHead(404).end(); + response.writeHead(200, { "content-type": "application/gzip" }); + createReadStream(join(payload, "linux-x64.tar.gz")).pipe(response); +}); +await new Promise((resolve) => files.listen(0, "127.0.0.1", resolve)); +if (!hasPayload) console.log("live_remote_runtime skipped: run `bun run remote:prepare` first"); + const temp = mkdtempSync(join(tmpdir(), "oxbit-ios-ssh-")); const name = `oxbit-ios-ssh-${randomBytes(4).toString("hex")}`; const passphrase = randomBytes(12).toString("hex"); @@ -29,12 +46,13 @@ try { run("ssh-keygen", ["-q", "-t", "ed25519", "-N", passphrase, "-C", "oxbit-live", "-f", join(temp, "identity")]); const [port, passwordPort] = [await freePort(), await freePort()]; docker("run", "-d", "--name", name, "--platform", "linux/amd64", - "-p", `127.0.0.1:${port}:22`, "-p", `127.0.0.1:${passwordPort}:2222`, + ...(process.platform === "linux" ? ["--add-host", "host.docker.internal:host-gateway"] : []), "-p", `127.0.0.1:${port}:22`, "-p", `127.0.0.1:${passwordPort}:2222`, "-v", `${join(temp, "identity.pub")}:/test-key:ro`, "oxbit-ssh-test:local"); docker("exec", name, "sh", "-c", "cp /test-key /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && " + `echo 'root:${password}' | chpasswd && /usr/sbin/sshd -p 2222 -o PasswordAuthentication=yes -o UsePAM=no -o PermitRootLogin=yes`); - run("cargo", ["test", "--locked", "--lib", "ssh::live_", "--", "--ignored", "--nocapture", "--test-threads=1"], { + await runAsync("cargo", ["test", "--locked", "--lib", process.argv[2] ?? "ssh::live_", "--", "--ignored", "--nocapture", "--test-threads=1", + ...(hasPayload ? [] : ["--skip", "live_remote_runtime"])], { cwd: crate, env: { ...process.env, @@ -44,9 +62,12 @@ try { OXBIT_SSH_TEST_KEY: join(temp, "identity"), OXBIT_SSH_TEST_PASSPHRASE: passphrase, OXBIT_SSH_TEST_PASSWORD: password, + OXBIT_SSH_TEST_PAYLOAD_DIR: payload, + OXBIT_SSH_TEST_PAYLOAD_URL: `http://host.docker.internal:${files.address().port}`, }, }); } finally { + files.close(); try { docker("rm", "-f", name); } catch { /* the container never started */ } rmSync(temp, { recursive: true, force: true }); } diff --git a/scripts/remote/sshd.Dockerfile b/scripts/remote/sshd.Dockerfile index 0ae3cedd..91fed34f 100644 --- a/scripts/remote/sshd.Dockerfile +++ b/scripts/remote/sshd.Dockerfile @@ -1,5 +1,5 @@ FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git procps iproute2 && rm -rf /var/lib/apt/lists/* \ +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git procps iproute2 curl ca-certificates && rm -rf /var/lib/apt/lists/* \ && mkdir -p /run/sshd /root/.ssh /root/project && chmod 700 /root/.ssh \ && ssh-keygen -A && git init /root/project \ && printf 'export const message = "hello";\n' > /root/project/hello.ts From 064c5c0330e4ff4a5ec590577124b8f385737ed8 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 05:17:00 -0400 Subject: [PATCH 06/12] Document the iOS remote runtime --- docs/INDEX.md | 2 +- docs/ios-ssh.md | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 3f8c0ea3..c1c99f51 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -11,6 +11,6 @@ Created: 2026-10-02. Last updated: 2026-10-08. | [iOS release artwork](ios-release-assets.md) | Rendering app icon exports, native App Store screenshots, promo banners, and listing drafts; includes Apple design, asset specifications, and app privacy references. | | [Withdrawn v0.3.1 audit](release-audit-0.3.1.md) | Comparing alpha.5 with the withdrawn release, confirmed corrections, and unresolved CI failures. | | [iOS language servers](ios-language-servers.md) | Changing device-local LSPs, JavaScriptCore, or the native filesystem bridge; includes Apple and Tauri API references. | -| [iOS SSH and SFTP workspaces](ios-ssh.md) | Changing SSH keys, saved hosts, known-hosts trust, the russh session pool, the SFTP FileSystem, transfers, or Git over SSH remotes; includes russh/aws-lc build notes and russh, Keychain, SFTP, git2 transport, and pack protocol references. | +| [iOS SSH and SFTP workspaces](ios-ssh.md) | Changing SSH keys, saved hosts, known-hosts trust, the russh session pool, the SFTP FileSystem, transfers, Git over SSH remotes, or the remote runtime started from iOS; includes russh/aws-lc build notes and russh, Keychain, SFTP, git2 transport, and pack protocol references. | | [iOS source control integration](ios-source-control.md) | On-device repositories, GitHub and Gitea credentials, OpenPGP commit signing, SSH remote key choice, native transport, and official API references | | [Source Control](source-control.md) | Git workflows in runtime workspaces | diff --git a/docs/ios-ssh.md b/docs/ios-ssh.md index 5ca9dd78..664cd623 100644 --- a/docs/ios-ssh.md +++ b/docs/ios-ssh.md @@ -2,12 +2,13 @@ Created: 2026-10-08. Last updated: 2026-10-08. -The iOS app opens folders on a server over SFTP and uses SSH remotes for Git in device folders. Keys, saved hosts, host-key trust, the connection pool, and transfers live in the iOS crate (`apps/ios/src-tauri/src/ssh/`). Private keys and saved passwords live in the Keychain through the `oxbit-files` plugin. +The iOS app opens folders on a server over SFTP, starts an Oxbit runtime on a server, and uses SSH remotes for Git in device folders. Keys, saved hosts, host-key trust, the connection pool, and transfers live in the iOS crate (`apps/ios/src-tauri/src/ssh/`). Private keys and saved passwords live in the Keychain through the `oxbit-files` plugin. ## Entry points - The start screen section "On a server" has Connect with SSH…, SSH Hosts and Keys…, and recent SSH workspaces. -- With a workspace open, the command palette has `ssh.hosts` (SSH Hosts and Keys) and `ssh.connect` (Connect with SSH…). +- The same section has Start Oxbit on This Server… and recent remote runtime workspaces. +- With a workspace open, the command palette has `ssh.hosts` (SSH Hosts and Keys), `ssh.connect` (Connect with SSH…), `ssh.runtime` (Start Oxbit on This Server…), and `ssh.runtime.reconnect`. - An SSH workspace adds `ssh.upload` (Upload Files Here…) and `ssh.download` (Download to Device…) to the palette and the explorer context menu. All four dialogs render as `.ios-shell > .modal-scrim`, above the workspace sheet. @@ -104,6 +105,18 @@ Prompts: a failed request stores its prompt in `Ssh.git_prompts` under the works Host keys share `known_hosts.json` and its refusal rules with SFTP workspaces, so trusting a server in either place covers both. Each Git network operation opens its own SSH connection and disconnects when libgit2 drops the stream. Private keys load from the Keychain only when a remote needs one and stay out of progress and error text; `safe_output` still redacts HTTPS tokens in the same output. +## Remote runtime + +Start Oxbit on This Server runs the desktop's headless runtime (`desktop.js`) on a saved host and connects the iOS runtime client to it, so terminals, tasks, and agents run on the server. The Rust side mirrors `apps/runtime/src/ssh.ts` and `ssh-target.ts` and lives in `apps/ios/src-tauri/src/ssh/runtime_*.rs`. The remote layout matches the desktop, so a host shared with the desktop reuses its install under `~/.oxbit/remote/runtimes/`. + +- Platform: `uname -s; uname -m` maps to `linux-x64` or `darwin-arm64`. Other hosts fail with the desktop's message. +- Payload: `runtime_install::pinned` is the one manifest lookup. It calls `remote_runtime::pinned_download`, which reads `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` at build time. A build without a manifest reports that it has no remote runtime. +- Install order: a `.complete` marker skips the install. Otherwise the host runs `curl -fsSL` (with a 15 s connect timeout and a 30 s stall limit) or `wget -qO-` piped into the desktop's install script, which checks the pinned SHA-256 before it writes `.complete`. If the host has neither tool or the download fails, the `oxbit-files` plugin downloads the archive with `URLSession` (`RuntimeDownload.swift`, HTTPS only), Rust checks its SHA-256 and size, and the archive streams into the same script over the exec channel. A unit test compares the script byte for byte with `ssh-target.ts`. +- Launch: `bin/node desktop.js` runs with `NODE_OPTIONS`, `NODE_PATH`, and `OXBIT_LSP_COMMAND` unset. The launch frame carries `remoteRuntime: true`, the root, the workspace key, and a random token. The runtime then accepts that token as the owner, so `/api/pair` and its rate limit are not used. Stdout frames follow version 1 with a 64 KiB line cap: `ready`, `rotated`, `taskForward`, `error`, and `progress`. A heartbeat goes out every 10 s against the runtime's 75 s lease. +- Tunnel: a listener on `127.0.0.1:0` forwards each accepted connection over a `direct-tcpip` channel to the port in the `ready` frame. `taskForward` requests get their own loopback listener per `host:port` and a `taskForwarded` reply. The WebView reaches the loopback port through `NSAllowsLocalNetworking` and the `127.0.0.0/8` exception in `apps/ios/src-tauri/Info.ios.plist`. +- Lifecycle: closing the workspace sends `shutdown` and closes the channel and listeners. When the exec channel ends, or the app returns to the foreground, `ensure` reconnects the pooled SSH connection, stops a stale runtime by PID only when its command line is an Oxbit runtime, and relaunches with the same token and local port, so the runtime client's own WebSocket reconnect picks it up. Automatic attempts stop after 3 in 60 s; Reconnect (`ssh.runtime.reconnect`) retries. +- Origins: the runtime allows `tauri://localhost`. A dev build served from `http://127.0.0.1:9281` is not an allowed origin, so remote runtimes need a bundled build. + ## Build notes - `russh = "=0.64.1"` and `ssh-key = "=0.7.0-rc.11"` are pinned together because russh requires that exact `ssh-key` release. Upgrade both in one change. @@ -117,12 +130,11 @@ Host keys share `known_hosts.json` and its refusal rules with SFTP workspaces, s - Unit tests: `cargo test --locked` in `apps/ios/src-tauri` (keys, hosts, known hosts, SSH URL parsing and command quoting in `git_core/ssh_transport_tests.rs`, key selection in `ssh/git.rs`) and `cargo test --locked -p tauri-plugin-oxbit-files` in the same folder (request validation and metadata filtering). - Keychain: `bun run ios:check` compiles `SshKeyStore.swift` with `Tests/SshKeys/main.swift` and runs save, read, list, update, delete, and password save/read/forget against the macOS login keychain, under per-run service names that it deletes afterwards. Its first Keychain call is a write; when that returns `errSecInteractionNotAllowed` (-25308), as in a `Background` launchd session with a locked login keychain (`launchctl managername`), it prints `SshKeyStore tests skipped: the login keychain is locked in this session` and exits 0. Any other failure fails the check. An unsigned binary on macOS uses the file-based keychain, which rejects `kSecReturnData` with `kSecMatchLimitAll` (`errSecParam`), so `keys()` lists accounts and reads each item. The macOS run does not exercise the data-protection keychain or `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` as iOS applies them. -- Live tests: `bun run ios:ssh-live` builds `scripts/remote/sshd.Dockerfile`, starts a container with a key port and a password port, and runs the ignored `ssh::live_` tests one at a time. `ssh::live_git_tests` runs first and covers the key prompt without a saved host, the first-use host key prompt, clone over `ssh://`, push, fetch, pull, a server error from a missing repository, and refusal when the saved host key differs. `ssh::live_tests` covers key auth with a passphrase-protected key, first-use trust, SFTP list/read/write/conflict/mkdir/rename/delete, upload and download with progress and cancel, password auth, reconnect after `docker restart`, and refusal after the container's host keys are regenerated. The script removes the container afterwards. -- UI: `bunx playwright test -c tests/ios/playwright.config.ts ssh.spec.ts` with the in-memory SSH bridge in `tests/ios/ssh-bridge.ts`. Its `clone` seed makes `git.clone` return the SSH prompt errors in order. `OXBIT_SSH_SCREENSHOTS` sets the screenshot folder (default `evidence/ios-ssh`). +- Live tests: `bun run ios:ssh-live` builds `scripts/remote/sshd.Dockerfile`, starts a container with a key port and a password port, and runs the ignored `ssh::live_` tests one at a time. `ssh::live_git_tests` runs first and covers the key prompt without a saved host, the first-use host key prompt, clone over `ssh://`, push, fetch, pull, a server error from a missing repository, and refusal when the saved host key differs. `ssh::live_runtime_tests` installs the runtime through the device stream after a failed host download and through `curl` from a local HTTP server, runs `fs.read` and a terminal command through the tunnel with `scripts/ios/runtime-probe.mjs`, reuses the cache, relaunches after `kill -9`, and reconnects after `docker restart`; it needs `bun run remote:prepare` first and is skipped without the payload. `ssh::live_tests` covers key auth with a passphrase-protected key, first-use trust, SFTP list/read/write/conflict/mkdir/rename/delete, upload and download with progress and cancel, password auth, reconnect after `docker restart`, and refusal after the container's host keys are regenerated. The script removes the container afterwards. +- UI: `bunx playwright test -c tests/ios/playwright.config.ts ssh.spec.ts` with the in-memory SSH bridge in `tests/ios/ssh-bridge.ts`. Its `clone` seed makes `git.clone` return the SSH prompt errors in order. `ssh-runtime.spec.ts` covers the start dialog, progress, the recent entry, and a failed start, with the runtime WebSocket mocked by `page.routeWebSocket`. `OXBIT_SSH_SCREENSHOTS` sets the screenshot folder (default `evidence/ios-ssh`). ## Not implemented -- A terminal and the remote runtime over SSH. - scp-style remotes on a port other than 22 (use `ssh://host:port/path`), password authentication for Git remotes, and `ssh+git://` or `git+ssh://` URLs. - Keyboard-interactive authentication, SSH agents, certificates, and jump hosts. - Port forwarding and exec channels in the UI. @@ -142,4 +154,6 @@ Host keys share `known_hosts.json` and its refusal rules with SFTP workspaces, s - git2 custom transports: https://docs.rs/git2/0.21.0/git2/transport/index.html - Git pack protocol over SSH: https://git-scm.com/docs/pack-protocol - Git URL forms: https://git-scm.com/docs/git-clone +- direct-tcpip channels: https://www.rfc-editor.org/rfc/rfc4254#section-7.2 +- NSAllowsLocalNetworking: https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsallowslocalnetworking - SFTP version 3 draft: https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02 From 1fa0b013a09f18053aa5e30d36ad2c0884d4a072 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 05:24:33 -0400 Subject: [PATCH 07/12] Add TestFlight notes for 0.4.1 --- docs/testflight/0.4.1.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/testflight/0.4.1.md diff --git a/docs/testflight/0.4.1.md b/docs/testflight/0.4.1.md new file mode 100644 index 00000000..05662124 --- /dev/null +++ b/docs/testflight/0.4.1.md @@ -0,0 +1,15 @@ +Start Oxbit on an SSH server + +1. On a saved SSH host (Linux x64 or an Apple silicon Mac), tap Start Oxbit on This Server… and choose a remote folder. Check that the progress shows "Installing the remote runtime…" on the first start and "Starting the remote workspace…", then the folder opens. Start it again and check that the install step is skipped. + +2. In the remote workspace, open a terminal and run a command. Run a task from Tasks, and if Agent ACP is turned on in Add-ons, start an agent conversation. Check that each runs on the server. + +3. Run a task that serves a web page and open its link. Check that the page loads on the device. + +4. Switch to another app for a minute, come back, and check that the workspace reconnects. Turn on Airplane Mode briefly, turn it off, and check that it reconnects or offers Reconnect. + +5. Close the workspace and check on the server that the Oxbit process stopped. + +6. If the server cannot reach github.com, check that the start still works (the device downloads the runtime and sends it over SSH). + +Report crashes, install or start failures, disconnects that do not recover, or tasks that run on the wrong machine, with the server OS, reproduction steps, device model, iOS version, and screenshots. From 5728079ef054c23072baa5d4a7593c5a0e55fbdc Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 09:03:45 -0400 Subject: [PATCH 08/12] Verify and cancel the iOS remote runtime download in Swift RuntimeDownloadStore checks size and SHA-256 before keeping an archive and cancels by runtime ID, so closing a workspace ends its download. ios:check runs a macOS harness against a fixed GitHub release asset. --- .../ios/Sources/OxbitFilesPlugin.swift | 4 + .../ios/Sources/RuntimeDownload.swift | 53 ++++---- .../ios/Sources/RuntimeDownloadStore.swift | 119 ++++++++++++++++++ .../ios/Tests/RuntimeDownload/main.swift | 83 ++++++++++++ apps/ios/plugins/oxbit-files/src/desktop.rs | 12 +- apps/ios/plugins/oxbit-files/src/mobile.rs | 19 ++- .../ios/src-tauri/src/ssh/runtime_commands.rs | 23 +++- scripts/ios/run.mjs | 7 ++ 8 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift create mode 100644 apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift index 9f96c10b..fecc8822 100644 --- a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift +++ b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift @@ -65,6 +65,10 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate { runtimeDownload.handle(invoke) } + @objc public func cancelRuntimeDownload(_ invoke: Invoke) { + runtimeDownload.cancel(invoke) + } + @objc public func pickFiles(_ invoke: Invoke) { filePicker.pick(invoke, from: manager.viewController) } diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift index 8a4c6193..50d089c4 100644 --- a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift @@ -2,45 +2,40 @@ import Foundation import Tauri private struct RuntimeDownloadArgs: Decodable { + let id: String let url: String let sha256: String + let size: Int64 } -/// Downloads the pinned remote runtime archive to Caches; Rust checks its digest before use. +private struct RuntimeDownloadCancelArgs: Decodable { + let id: String +} + +/// Plugin dispatch for `RuntimeDownloadStore`, which saves the archive under Caches. final class RuntimeDownload { - private lazy var session: URLSession = { - let config = URLSessionConfiguration.ephemeral - config.httpShouldSetCookies = false - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 600 - return URLSession(configuration: config) - }() + private let store = RuntimeDownloadStore( + directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("remote-runtime", isDirectory: true)) func handle(_ invoke: Invoke) { do { let args = try invoke.parseArgs(RuntimeDownloadArgs.self) - guard let url = URL(string: args.url), url.scheme == "https", - args.sha256.count == 64, args.sha256.allSatisfy({ "0123456789abcdef".contains($0) }) - else { return invoke.reject("Invalid remote runtime download") } - let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("remote-runtime", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let destination = directory.appendingPathComponent("\(args.sha256).tar.gz") - if FileManager.default.fileExists(atPath: destination.path) { - return invoke.resolve(["path": destination.path]) - } - session.downloadTask(with: url) { location, response, error in - guard error == nil, let location, (response as? HTTPURLResponse)?.statusCode == 200 else { - return invoke.reject("Could not download the remote runtime. Check the network connection and retry.") + store.download(id: args.id, url: args.url, sha256: args.sha256, size: args.size) { result in + switch result { + case .success(let file): invoke.resolve(["path": file.path]) + case .failure(let error): invoke.reject(error.message) } - do { - try? FileManager.default.removeItem(at: destination) - try FileManager.default.moveItem(at: location, to: destination) - invoke.resolve(["path": destination.path]) - } catch { - invoke.reject("Could not save the remote runtime download: \(error.localizedDescription)") - } - }.resume() + } + } catch { + invoke.reject(error.localizedDescription) + } + } + + func cancel(_ invoke: Invoke) { + do { + store.cancel(id: try invoke.parseArgs(RuntimeDownloadCancelArgs.self).id) + invoke.resolve() } catch { invoke.reject(error.localizedDescription) } diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift new file mode 100644 index 00000000..006c89b6 --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift @@ -0,0 +1,119 @@ +import CryptoKit +import Foundation + +enum RuntimeDownloadError: Error, Equatable { + case invalid + case network(URLError.Code) + case status(Int) + case mismatch + case cancelled + case save(String) + + var message: String { + switch self { + case .invalid: return "Invalid remote runtime download" + case .network, .status: return "Could not download the remote runtime. Check the network connection and retry." + case .mismatch: return "The remote runtime download did not match its pinned size and SHA-256." + case .cancelled: return "The remote runtime download was cancelled." + case .save(let reason): return "Could not save the remote runtime download: \(reason)" + } + } +} + +/// Downloads the pinned remote runtime archive to `/.tar.gz` and keeps it only +/// when its size and SHA-256 match. Each caller ID has at most one download, which `cancel` ends. +final class RuntimeDownloadStore { + let directory: URL + private let session: URLSession + private let lock = NSLock() + private var tasks: [String: URLSessionDownloadTask] = [:] + + init(directory: URL, session: URLSession = RuntimeDownloadStore.defaultSession()) { + self.directory = directory + self.session = session + } + + static func defaultSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 600 + return URLSession(configuration: config) + } + + static func validated(url: String, sha256: String, size: Int64) -> URL? { + guard let parsed = URL(string: url), parsed.scheme == "https", parsed.host?.isEmpty == false, + sha256.count == 64, sha256.allSatisfy({ "0123456789abcdef".contains($0) }), size > 0 + else { return nil } + return parsed + } + + static func matches(_ file: URL, sha256: String, size: Int64) -> Bool { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path), + (attributes[.size] as? NSNumber)?.int64Value == size, + let handle = try? FileHandle(forReadingFrom: file) + else { return false } + defer { try? handle.close() } + var hasher = SHA256() + while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() == sha256 + } + + func download(id: String, url: String, sha256: String, size: Int64, + completion: @escaping (Result) -> Void) { + guard let source = Self.validated(url: url, sha256: sha256, size: size) else { + return completion(.failure(.invalid)) + } + let destination = directory.appendingPathComponent("\(sha256).tar.gz") + if Self.matches(destination, sha256: sha256, size: size) { + return completion(.success(destination)) + } + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return completion(.failure(.save(error.localizedDescription))) + } + let token = UUID().uuidString + let task = session.downloadTask(with: source) { [weak self] location, response, error in + self?.forget(id, token: token) + if let error { + let code = (error as? URLError)?.code ?? .unknown + return completion(.failure(code == .cancelled ? .cancelled : .network(code))) + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let location, status == 200 else { return completion(.failure(.status(status))) } + guard Self.matches(location, sha256: sha256, size: size) else { + try? FileManager.default.removeItem(at: location) + return completion(.failure(.mismatch)) + } + do { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: location, to: destination) + completion(.success(destination)) + } catch { + completion(.failure(.save(error.localizedDescription))) + } + } + task.taskDescription = token + lock.lock() + let previous = tasks.updateValue(task, forKey: id) + lock.unlock() + previous?.cancel() + task.resume() + } + + func cancel(id: String) { + lock.lock() + let task = tasks.removeValue(forKey: id) + lock.unlock() + task?.cancel() + } + + private func forget(_ id: String, token: String) { + lock.lock() + if tasks[id]?.taskDescription == token { tasks[id] = nil } + lock.unlock() + } +} diff --git a/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift b/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift new file mode 100644 index 00000000..ec463fee --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Fixed GitHub release assets; release files do not change after publication. +let release = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz" +let small = (url: release + ".sha256", size: Int64(108), + sha256: "a8c2b06a7490f4e5ed787973f6ab48d4dbbc73e2ab4e97a4a9e6094e46caaebf") +let large = (url: release, size: Int64(2_082_672), + sha256: "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3") +let offline: Set = [.notConnectedToInternet, .cannotFindHost, .cannotConnectToHost, + .dnsLookupFailed, .timedOut, .networkConnectionLost, .dataNotAllowed, .internationalRoamingOff] + +struct Failure: Error { let message: String } + +func expect(_ value: Bool, _ message: String) throws { + guard value else { throw Failure(message: message) } +} + +func download(_ store: RuntimeDownloadStore, id: String = "test", url: String, size: Int64, + sha256: String, then: (() -> Void)? = nil) throws -> Result { + let done = DispatchSemaphore(value: 0) + let box = NSLock() + var result: Result? + store.download(id: id, url: url, sha256: sha256, size: size) { value in + box.lock(); result = value; box.unlock() + done.signal() + } + then?() + guard done.wait(timeout: .now() + 120) == .success else { throw Failure(message: "\(url) timed out") } + box.lock(); defer { box.unlock() } + return result! +} + +func runTests(_ store: RuntimeDownloadStore) throws { + let destination = store.directory.appendingPathComponent("\(small.sha256).tar.gz") + let first = try download(store, url: small.url, size: small.size, sha256: small.sha256) + if case .failure(.network(let code)) = first, offline.contains(code) { + print("RuntimeDownload tests skipped: the network is unavailable (URLError \(code.rawValue))") + exit(0) + } + try expect(first == .success(destination), "an HTTPS download with the pinned size and digest saves \(first)") + try expect(RuntimeDownloadStore.matches(destination, sha256: small.sha256, size: small.size), + "the saved file matches its digest") + + try Data("corrupt".utf8).write(to: destination) + let healed = try download(store, url: small.url, size: small.size, sha256: small.sha256) + try expect(healed == .success(destination) + && RuntimeDownloadStore.matches(destination, sha256: small.sha256, size: small.size), + "a cached file with the wrong digest downloads again") + + let plain = small.url.replacingOccurrences(of: "https://", with: "http://") + try expect(try download(store, url: plain, size: small.size, sha256: small.sha256) == .failure(.invalid), + "http:// is refused") + try expect(try download(store, url: small.url, size: small.size, sha256: "ABC") == .failure(.invalid), + "a malformed digest is refused") + + try FileManager.default.removeItem(at: destination) + try expect(try download(store, url: small.url, size: small.size - 1, sha256: small.sha256) == .failure(.mismatch), + "a size mismatch is refused") + let zeros = String(repeating: "0", count: 64) + try expect(try download(store, url: small.url, size: small.size, sha256: zeros) == .failure(.mismatch), + "a digest mismatch is refused") + try expect(try FileManager.default.contentsOfDirectory(atPath: store.directory.path).isEmpty, + "refused downloads leave nothing in the cache") + + let cancelled = try download(store, id: "cancel", url: large.url, size: large.size, sha256: large.sha256) { + store.cancel(id: "cancel") + } + try expect(cancelled == .failure(.cancelled), "cancel ends the download: \(cancelled)") + try expect(!FileManager.default.fileExists(atPath: store.directory.appendingPathComponent("\(large.sha256).tar.gz").path), + "a cancelled download leaves no archive") +} + +let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("oxbit-runtime-download-\(UUID().uuidString)", isDirectory: true) +defer { try? FileManager.default.removeItem(at: directory) } +do { + try runTests(RuntimeDownloadStore(directory: directory)) + print("RuntimeDownload tests passed") +} catch { + FileHandle.standardError.write(Data("RuntimeDownload tests failed: \((error as? Failure)?.message ?? "\(error)")\n".utf8)) + try? FileManager.default.removeItem(at: directory) + exit(1) +} diff --git a/apps/ios/plugins/oxbit-files/src/desktop.rs b/apps/ios/plugins/oxbit-files/src/desktop.rs index 9c2a5819..05ca2fd9 100644 --- a/apps/ios/plugins/oxbit-files/src/desktop.rs +++ b/apps/ios/plugins/oxbit-files/src/desktop.rs @@ -36,7 +36,17 @@ impl OxbitFiles { Err(UNSUPPORTED) } - pub fn download_runtime(&self, _url: &str, _sha256: &str) -> crate::Result { + pub fn download_runtime( + &self, + _id: &str, + _url: &str, + _sha256: &str, + _size: u64, + ) -> crate::Result { + Err(UNSUPPORTED) + } + + pub fn cancel_runtime_download(&self, _id: &str) -> crate::Result<()> { Err(UNSUPPORTED) } diff --git a/apps/ios/plugins/oxbit-files/src/mobile.rs b/apps/ios/plugins/oxbit-files/src/mobile.rs index 74384f68..2075825f 100644 --- a/apps/ios/plugins/oxbit-files/src/mobile.rs +++ b/apps/ios/plugins/oxbit-files/src/mobile.rs @@ -59,16 +59,29 @@ impl OxbitFiles { .map_err(Into::into) } - /// Internal only: the pinned remote runtime archive, downloaded with URLSession. - pub fn download_runtime(&self, url: &str, sha256: &str) -> crate::Result { + /// Internal only: the pinned remote runtime archive, downloaded with URLSession. `id` names + /// the download for `cancel_runtime_download`. + pub fn download_runtime( + &self, + id: &str, + url: &str, + sha256: &str, + size: u64, + ) -> crate::Result { self.0 .run_mobile_plugin( "downloadRuntime", - serde_json::json!({ "url": url, "sha256": sha256 }), + serde_json::json!({ "id": id, "url": url, "sha256": sha256, "size": size }), ) .map_err(Into::into) } + pub fn cancel_runtime_download(&self, id: &str) -> crate::Result<()> { + self.0 + .run_mobile_plugin("cancelRuntimeDownload", serde_json::json!({ "id": id })) + .map_err(Into::into) + } + pub fn pick_files(&self, multiple: bool) -> crate::Result { self.0 .run_mobile_plugin("pickFiles", serde_json::json!({ "multiple": multiple })) diff --git a/apps/ios/src-tauri/src/ssh/runtime_commands.rs b/apps/ios/src-tauri/src/ssh/runtime_commands.rs index 14c555f2..ee7fce7b 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_commands.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_commands.rs @@ -37,13 +37,18 @@ fn started(runtime: &RemoteRuntime, ready: Ready) -> Started { } } -fn device_fetch(app: AppHandle) -> Fetch { +/// `id` is the runtime's ID, so stopping the runtime cancels its download. +fn device_fetch(app: AppHandle, id: String) -> Fetch { Arc::new(move |download| { - let app = app.clone(); + let (app, id) = (app.clone(), id.clone()); Box::pin(async move { let saved = tauri::async_runtime::spawn_blocking(move || { - app.oxbit_files() - .download_runtime(&download.url, &download.sha256) + app.oxbit_files().download_runtime( + &id, + &download.url, + &download.sha256, + download.size, + ) }) .await .map_err(|_| Error::new("REMOTE_RUNTIME", "The download did not finish."))? @@ -99,7 +104,7 @@ pub async fn ios_ssh_runtime_start( }); let source = Source { lookup: Arc::new(runtime_install::pinned), - fetch: device_fetch(app), + fetch: device_fetch(app, id.clone()), }; let options = Options { host_id, @@ -134,9 +139,15 @@ pub async fn ios_ssh_runtime_resume(state: State<'_, AppState>, id: String) -> R } #[tauri::command] -pub async fn ios_ssh_runtime_stop(state: State<'_, AppState>, id: String) -> Result<()> { +pub async fn ios_ssh_runtime_stop( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result<()> { let removed = state.ssh.runtimes.lock().unwrap().remove(&id); if let Some(runtime) = removed { + // A running install holds the launch lock until its download ends. + let _ = app.oxbit_files().cancel_runtime_download(&id); runtime.stop().await; } Ok(()) diff --git a/scripts/ios/run.mjs b/scripts/ios/run.mjs index 7d451622..86085f66 100644 --- a/scripts/ios/run.mjs +++ b/scripts/ios/run.mjs @@ -140,6 +140,13 @@ if (mode === "check") { "-o", profileTests, ]); run(profileTests, []); + const runtimeDownload = join(nativeTests, "runtime-download"); + run("swiftc", [ + "apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift", + "apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift", + "-o", runtimeDownload, + ]); + run(runtimeDownload, []); } finally { rmSync(nativeTests, { recursive: true, force: true }); } From 0e52ee2765e1c3fc9262f2dcd2c8bec0f846cab8 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 09:03:45 -0400 Subject: [PATCH 09/12] Start a new relaunch budget on Reconnect A user's start or Reconnect no longer counts toward the three automatic relaunches per minute, and the app reports when automatic attempts stop. --- apps/ios/src-tauri/src/ssh/runtime_process.rs | 26 ++++++++++++- .../src/ssh/runtime_protocol_tests.rs | 39 ++++++++++++++++++- apps/ios/src-tauri/src/ssh/runtime_session.rs | 24 +++++------- 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/apps/ios/src-tauri/src/ssh/runtime_process.rs b/apps/ios/src-tauri/src/ssh/runtime_process.rs index 2453a4a8..57680219 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_process.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_process.rs @@ -10,16 +10,18 @@ use crate::fs_core::{Error, Result}; use russh::{client::Msg, ChannelMsg, ChannelReadHalf, ChannelWriteHalf}; use serde::Serialize; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, - time::Duration, + time::{Duration, Instant}, }; use tokio::{sync::watch, task::JoinHandle}; const HEARTBEAT: Duration = Duration::from_secs(10); +const RELAUNCH_LIMIT: usize = 3; +const RELAUNCH_WINDOW: Duration = Duration::from_secs(60); const MAX_TASK_FORWARDS: usize = 256; const MAX_STDERR_BYTES: usize = 4096; pub const STOPPED: &str = @@ -113,6 +115,26 @@ impl Process { } } +/// Automatic relaunches allowed per minute. A user's Reconnect starts a new budget. +#[derive(Default)] +pub struct Budget(VecDeque); + +impl Budget { + pub fn admit(&mut self, user: bool, now: Instant) -> bool { + if user { + self.0.clear(); + return true; + } + self.0 + .retain(|at| now.duration_since(*at) < RELAUNCH_WINDOW); + if self.0.len() >= RELAUNCH_LIMIT { + return false; + } + self.0.push_back(now); + true + } +} + pub async fn heartbeat(writer: Writer, alive: Arc) { while alive.load(Ordering::SeqCst) { tokio::time::sleep(HEARTBEAT).await; diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs index 36e5aa72..33eb9a39 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -1,5 +1,8 @@ -use super::{runtime_frames::*, runtime_protocol::*, runtime_tunnel::healthy}; +use super::{ + runtime_frames::*, runtime_process::Budget, runtime_protocol::*, runtime_tunnel::healthy, +}; use serde_json::{json, Value}; +use std::time::{Duration, Instant}; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -238,3 +241,37 @@ fn accepts_only_a_protocol_1_health_response() { )); assert!(!healthy("garbage")); } + +#[test] +fn allows_three_automatic_relaunches_a_minute_until_the_user_reconnects() { + let start = Instant::now(); + let at = |seconds| start + Duration::from_secs(seconds); + let mut budget = Budget::default(); + assert!( + budget.admit(true, start), + "the user's start does not use the budget" + ); + for second in [1, 5, 15] { + assert!( + budget.admit(false, at(second)), + "automatic attempt at {second} s" + ); + } + assert!( + !budget.admit(false, at(20)), + "the 4th automatic attempt waits" + ); + assert!(!budget.admit(false, at(60))); + assert!( + budget.admit(false, at(61)), + "attempts older than a minute expire" + ); + assert!(budget.admit(true, at(62)), "Reconnect bypasses the budget"); + for second in [63, 64, 65] { + assert!( + budget.admit(false, at(second)), + "Reconnect starts a new budget" + ); + } + assert!(!budget.admit(false, at(66))); +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_session.rs b/apps/ios/src-tauri/src/ssh/runtime_session.rs index 1efb2c3b..4df4d11e 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_session.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_session.rs @@ -4,7 +4,7 @@ use super::{ runtime_frames::{self as frames, Frame, FrameReader}, runtime_install::{self, exec, lost, Installed, Source}, runtime_process::{ - heartbeat, Event, Events, Options, Process, Ready, Stdout, TaskForwards, Writer, + heartbeat, Budget, Event, Events, Options, Process, Ready, Stdout, TaskForwards, Writer, }, runtime_protocol as protocol, runtime_tunnel::{health, Tunnel}, @@ -12,7 +12,6 @@ use super::{ }; use crate::fs_core::{Error, Result}; use std::{ - collections::VecDeque, future::Future, pin::Pin, sync::{ @@ -23,9 +22,7 @@ use std::{ }; use tokio::sync::{watch, Mutex}; -const RELAUNCH_LIMIT: usize = 3; -const RELAUNCH_WINDOW: Duration = Duration::from_secs(60); -const GAVE_UP: &str = +pub const GAVE_UP: &str = "Oxbit stopped reconnecting to the server after repeated failures. Reconnect to retry."; #[derive(Default)] @@ -33,7 +30,7 @@ struct Launch { process: Option, ready: Option, pid: Option, - attempts: VecDeque, + budget: Budget, } pub struct RemoteRuntime { @@ -73,7 +70,7 @@ impl RemoteRuntime { } /// The running runtime, or a reconnect and relaunch. Automatic attempts share a budget of - /// three per minute; `user` attempts bypass it. + /// three per minute; a `user` attempt bypasses it and starts a new one. pub async fn ensure(&self, user: bool) -> Result { let mut launch = self.launch.lock().await; if self.stopped.load(Ordering::SeqCst) { @@ -87,14 +84,9 @@ impl RemoteRuntime { return Ok(ready.clone()); } } - let now = Instant::now(); - launch - .attempts - .retain(|at| now.duration_since(*at) < RELAUNCH_WINDOW); - if !user && launch.attempts.len() >= RELAUNCH_LIMIT { + if !launch.budget.admit(user, Instant::now()) { return Err(Error::new("REMOTE_RUNTIME", GAVE_UP)); } - launch.attempts.push_back(now); let _ = self.target.send(None); if let Some(process) = launch.process.take() { process.close(); @@ -231,10 +223,14 @@ impl RemoteRuntime { (self.events)(Event::Reconnecting { message }); match self.ensure(false).await { Ok(_) => return, - Err(error) if error.message == GAVE_UP => return, + Err(error) if error.message == GAVE_UP => break, Err(_) => {} } } + if !self.stopped.load(Ordering::SeqCst) { + let message = GAVE_UP.to_string(); + (self.events)(Event::Failed { message }); + } }) } From 8119a5753c209a5c0f6e1f61cc4c8287154ae2b6 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 09:09:56 -0400 Subject: [PATCH 10/12] Test wget installs, taskForward, and the relaunch budget live The second live runtime test installs with wget on a host without curl, fetches a service task through its taskForward listener, and checks that three failed automatic relaunches wait for Reconnect. --- .../src/ssh/live_runtime_task_tests.rs | 142 ++++++++++++++++++ .../src-tauri/src/ssh/live_runtime_tests.rs | 52 +++++-- apps/ios/src-tauri/src/ssh/mod.rs | 2 + scripts/ios/runtime-probe.mjs | 36 +---- scripts/ios/runtime-socket.mjs | 42 ++++++ scripts/ios/runtime-task-probe.mjs | 30 ++++ scripts/ios/ssh-live.mjs | 2 +- scripts/remote/sshd.Dockerfile | 2 +- 8 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs create mode 100644 scripts/ios/runtime-socket.mjs create mode 100644 scripts/ios/runtime-task-probe.mjs diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs new file mode 100644 index 00000000..2ae34f6b --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs @@ -0,0 +1,142 @@ +//! Live remote runtime cases on the Docker sshd fixture: the wget install, a task service reached +//! through `taskForward`, and the relaunch budget after repeated failures. +use super::{ + live_runtime_tests::{live, payload, probe, source, start, Live, Seen}, + live_tests::{docker, exec}, + runtime_process::Event, + runtime_protocol::{Downloader, Install}, + runtime_session::{RemoteRuntime, GAVE_UP}, +}; +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::Arc, + time::{Duration, Instant}, +}; + +const TASK_PORT: u16 = 4170; +const TASK_BODY: &str = "hello from the server task"; + +/// A service task that runs the runtime's own Node, since the fixture has no other. +fn write_task(container: &str, scratch: &Path, node: &str) { + let script = format!( + "require('http').createServer((q,r)=>r.end('{TASK_BODY}')).listen(Number(process.env.OXBIT_PORT),\ + process.env.OXBIT_HOST,()=>console.log('ready http://'+process.env.OXBIT_HOST+':'+process.env.OXBIT_PORT+'/'))" + ); + let tasks = serde_json::json!({ "version": 1, "tasks": { "web": { + "type": "service", "execution": "process", "command": node, "args": ["-e", script], + "host": "127.0.0.1", "port": TASK_PORT, + } } }); + let file = scratch.join("tasks.json"); + std::fs::write(&file, tasks.to_string()).unwrap(); + exec(container, "mkdir -p /root/project/.oxbit"); + let target = format!("{container}:/root/project/.oxbit/tasks.json"); + docker(&["cp", file.to_str().unwrap(), &target]); +} + +async fn task_probe(runtime: &RemoteRuntime) -> serde_json::Value { + let (url, token) = (runtime.url(), runtime.token().to_string()); + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../scripts/ios/runtime-task-probe.mjs"); + let output = tokio::task::spawn_blocking(move || { + Command::new("node") + .args([script.to_str().unwrap(), &url, &token, "web"]) + .output() + .unwrap() + }) + .await + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "task probe failed: {stderr}"); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn port_of(link: &str) -> u16 { + let rest = link.strip_prefix("http://127.0.0.1:").unwrap(); + rest.split('/').next().unwrap().parse().unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the Docker sshd fixture"] +async fn live_runtime_wget_tasks_and_budget() { + let Live { + container, + served, + pool, + scratch, + .. + } = live().await; + let (_, sha256) = payload(); + let node = format!("/root/.oxbit/remote/runtimes/{sha256}/bin/node"); + let hosted = || source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + + exec(&container, "mv /usr/bin/curl /usr/bin/curl.off"); + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted(), &seen).await; + let ready = runtime.ensure(true).await; + exec(&container, "mv /usr/bin/curl.off /usr/bin/curl"); + assert_eq!( + ready.unwrap().installed, + Some(Install::HostDownload(Downloader::Wget)) + ); + probe(&runtime).await; + println!("PASS host-download install with wget when the host has no curl"); + + write_task(&container, &scratch, &node); + let result = task_probe(&runtime).await; + let link = result["link"].as_str().unwrap(); + let local = port_of(link); + assert_ne!(local, TASK_PORT, "the link points at the device listener"); + assert_eq!(result["body"], TASK_BODY, "{result}"); + assert_eq!(result["after"], "closed", "{result}"); + runtime.stop().await; + assert!(tokio::net::TcpStream::connect(("127.0.0.1", local)) + .await + .is_err()); + println!( + "PASS taskForward: {link} served the task, closed connections after the task stopped, and stopped listening when the workspace closed" + ); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted(), &seen).await; + runtime.ensure(true).await.unwrap(); + let pid = runtime.pid().await.unwrap(); + let mark = seen.lock().unwrap().len(); + exec( + &container, + &format!("mv {node} {node}.off && kill -9 {pid}"), + ); + let gave_up = Event::Failed { + message: GAVE_UP.into(), + }; + let deadline = Instant::now() + Duration::from_secs(60); + while !seen.lock().unwrap()[mark..].contains(&gave_up) && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(250)).await; + } + let after = seen.lock().unwrap()[mark..].to_vec(); + let reconnects = after + .iter() + .filter(|event| matches!(event, Event::Reconnecting { .. })) + .count(); + let failures = after + .iter() + .filter(|event| matches!(event, Event::Failed { message } if message != GAVE_UP)) + .count(); + assert_eq!((reconnects, failures), (3, 3), "{after:?}"); + assert_eq!(after.last(), Some(&gave_up), "{after:?}"); + let count = seen.lock().unwrap().len(); + assert_eq!(runtime.ensure(false).await.unwrap_err().message, GAVE_UP); + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + seen.lock().unwrap().len(), + count, + "no attempt after giving up" + ); + exec(&container, &format!("mv {node}.off {node}")); + runtime.ensure(true).await.unwrap(); + probe(&runtime).await; + println!("PASS 3 automatic relaunches failed, the 4th waited, and Reconnect relaunched"); + + runtime.stop().await; + std::fs::remove_dir_all(&scratch).unwrap(); +} diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs index 5226592d..83cc5821 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs @@ -19,9 +19,9 @@ use std::{ time::{Duration, Instant}, }; -type Seen = Arc>>; +pub(super) type Seen = Arc>>; -fn payload() -> (PathBuf, String) { +pub(super) fn payload() -> (PathBuf, String) { let directory = PathBuf::from(env("OXBIT_SSH_TEST_PAYLOAD_DIR")); let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(directory.join("manifest.json")).unwrap()).unwrap(); @@ -33,7 +33,7 @@ fn payload() -> (PathBuf, String) { } /// `url` is what the host downloads; the device side copies the local archive. -fn source(url: String, scratch: PathBuf) -> Source { +pub(super) fn source(url: String, scratch: PathBuf) -> Source { let (archive, sha256) = payload(); let size = std::fs::metadata(&archive).unwrap().len(); let download = Download { url, sha256, size }; @@ -55,7 +55,12 @@ fn source(url: String, scratch: PathBuf) -> Source { } } -async fn start(pool: &Arc, host_id: &str, source: Source, seen: &Seen) -> Arc { +pub(super) async fn start( + pool: &Arc, + host_id: &str, + source: Source, + seen: &Seen, +) -> Arc { let root = "/~/project".to_string(); let seen = seen.clone(); let options = Options { @@ -75,7 +80,7 @@ async fn start(pool: &Arc, host_id: &str, source: Source, seen: &Seen) -> .unwrap() } -async fn probe(runtime: &RemoteRuntime) -> String { +pub(super) async fn probe(runtime: &RemoteRuntime) -> String { let (url, token) = (runtime.url(), runtime.token().to_string()); let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../scripts/ios/runtime-probe.mjs"); @@ -99,7 +104,7 @@ async fn probe(runtime: &RemoteRuntime) -> String { String::from_utf8(output.stdout).unwrap() } -fn progress(seen: &Seen) -> Vec { +pub(super) fn progress(seen: &Seen) -> Vec { let events = seen.lock().unwrap(); events .iter() @@ -110,7 +115,7 @@ fn progress(seen: &Seen) -> Vec { .collect() } -async fn running_after(seen: &Seen, from: usize, limit: Duration) -> bool { +pub(super) async fn running_after(seen: &Seen, from: usize, limit: Duration) -> bool { let deadline = Instant::now() + limit; while Instant::now() < deadline { if seen.lock().unwrap()[from..].contains(&Event::Running) { @@ -121,7 +126,7 @@ async fn running_after(seen: &Seen, from: usize, limit: Duration) -> bool { false } -fn remote_runtimes(container: &str) -> String { +pub(super) fn remote_runtimes(container: &str) -> String { exec( container, // The brackets keep the pattern from matching the `sh -c` running pgrep. @@ -129,9 +134,16 @@ fn remote_runtimes(container: &str) -> String { ) } -#[tokio::test(flavor = "multi_thread")] -#[ignore = "needs the Docker sshd fixture"] -async fn live_remote_runtime() { +pub(super) struct Live { + pub container: String, + pub port: u16, + pub served: String, + pub pool: Arc, + pub scratch: PathBuf, +} + +/// Connects host "live" to the fixture with a trusted host key and removes `~/.oxbit`. +pub(super) async fn live() -> Live { let container = env("OXBIT_SSH_TEST_CONTAINER"); let port: u16 = env("OXBIT_SSH_TEST_PORT").parse().unwrap(); let served = env("OXBIT_SSH_TEST_PAYLOAD_URL"); @@ -168,7 +180,25 @@ async fn live_remote_runtime() { Outcome::Connected { .. } )); exec(&container, "rm -rf /root/.oxbit"); + Live { + container, + port, + served, + pool, + scratch, + } +} +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the Docker sshd fixture"] +async fn live_remote_runtime() { + let Live { + container, + port, + served, + pool, + scratch, + } = live().await; let seen: Seen = Arc::default(); let missing = source(format!("{served}/missing.tar.gz"), scratch.clone()); let runtime = start(&pool, "live", missing, &seen).await; diff --git a/apps/ios/src-tauri/src/ssh/mod.rs b/apps/ios/src-tauri/src/ssh/mod.rs index 2f3fcfc4..ef892dba 100644 --- a/apps/ios/src-tauri/src/ssh/mod.rs +++ b/apps/ios/src-tauri/src/ssh/mod.rs @@ -12,6 +12,8 @@ pub mod known_hosts; #[cfg(test)] mod live_git_tests; #[cfg(test)] +mod live_runtime_task_tests; +#[cfg(test)] mod live_runtime_tests; #[cfg(test)] mod live_tests; diff --git a/scripts/ios/runtime-probe.mjs b/scripts/ios/runtime-probe.mjs index 382b56c4..d3e8bcca 100644 --- a/scripts/ios/runtime-probe.mjs +++ b/scripts/ios/runtime-probe.mjs @@ -1,45 +1,17 @@ // Drives a runtime through the iOS tunnel the way the WebView does: authenticate, read a file, // trust the workspace, and run one terminal command. Prints one JSON line. -import { randomUUID } from "node:crypto"; -import { createRequire } from "node:module"; +import { connect, fail, until } from "./runtime-socket.mjs"; const [url, token, file, command, expected] = process.argv.slice(2); -const { WebSocket } = createRequire(new URL("../../apps/runtime/package.json", import.meta.url))("ws"); -const socket = new WebSocket(url.replace(/^http/, "ws") + "/ws", { origin: "tauri://localhost" }); -const pending = new Map(); -const output = []; -let sequence = 0; -const run = randomUUID(); -const fail = (message) => { console.error(message); process.exit(1); }; setTimeout(() => fail("probe timed out"), 60000).unref(); -socket.on("error", (error) => fail(String(error))); -socket.on("message", (raw) => { - const message = JSON.parse(raw.toString()); - if (message.type === "event" && message.event === "terminal.data") output.push(message.params.data); - const entry = pending.get(message.id); - if (!entry) return; - pending.delete(message.id); - if (message.error) entry.reject(new Error(`${entry.method}: ${JSON.stringify(message.error)}`)); - else entry.resolve(message.result); -}); -const request = (method, params = {}) => new Promise((resolve, reject) => { - // Unique per run: the runtime persists operation results by request id across launches. - const id = `probe-${run}-${++sequence}`; - pending.set(id, { method, resolve, reject }); - socket.send(JSON.stringify({ v: 1, type: "request", id, method, params })); -}); -await new Promise((resolve) => socket.once("open", resolve)); try { - const session = await request("auth.authenticate", { token }); + const { socket, request, events, session } = await connect(url, token); + const output = () => events.filter((event) => event.event === "terminal.data").map((event) => event.params.data).join(""); const read = await request("fs.read", { path: file }); await request("workspace.trust", { trusted: true }); const terminal = await request("terminal.create", { cols: 80, rows: 24 }); await request("terminal.input", { id: terminal.id, data: command + "\r" }); - const deadline = Date.now() + 30000; - while (!output.join("").includes(expected)) { - if (Date.now() > deadline) fail("terminal output did not arrive: " + JSON.stringify(output.join("").slice(-400))); - await new Promise((resolve) => setTimeout(resolve, 50)); - } + await until(() => output().includes(expected), "terminal output " + JSON.stringify(output().slice(-400))); await request("terminal.kill", { id: terminal.id }); console.log(JSON.stringify({ owner: session.owner, text: read.text, terminal: true })); socket.close(); diff --git a/scripts/ios/runtime-socket.mjs b/scripts/ios/runtime-socket.mjs new file mode 100644 index 00000000..0f4ffc9e --- /dev/null +++ b/scripts/ios/runtime-socket.mjs @@ -0,0 +1,42 @@ +// The runtime WebSocket as the iOS WebView opens it: `tauri://localhost` origin, owner token. +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +const { WebSocket } = createRequire(new URL("../../apps/runtime/package.json", import.meta.url))("ws"); + +export const fail = (message) => { console.error(message); process.exit(1); }; + +export async function until(check, what, limit = 30000) { + const deadline = Date.now() + limit; + for (;;) { + const value = await check(); + if (value) return value; + if (Date.now() > deadline) fail(`${what} did not happen in ${limit} ms`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +export async function connect(url, token) { + const socket = new WebSocket(url.replace(/^http/, "ws") + "/ws", { origin: "tauri://localhost" }); + const pending = new Map(), events = [], run = randomUUID(); + let sequence = 0; + socket.on("error", (error) => fail(String(error))); + socket.on("message", (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type === "event") events.push(message); + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + if (message.error) entry.reject(new Error(`${entry.method}: ${JSON.stringify(message.error)}`)); + else entry.resolve(message.result); + }); + const request = (method, params = {}) => new Promise((resolve, reject) => { + // Unique per run: the runtime persists operation results by request id across launches. + const id = `probe-${run}-${++sequence}`; + pending.set(id, { method, resolve, reject }); + socket.send(JSON.stringify({ v: 1, type: "request", id, method, params })); + }); + await new Promise((resolve) => socket.once("open", resolve)); + const session = await request("auth.authenticate", { token }); + return { socket, request, events, session }; +} diff --git a/scripts/ios/runtime-task-probe.mjs b/scripts/ios/runtime-task-probe.mjs new file mode 100644 index 00000000..50b9b1a4 --- /dev/null +++ b/scripts/ios/runtime-task-probe.mjs @@ -0,0 +1,30 @@ +// Starts a service task through the iOS tunnel, fetches its link, which the runtime rewrites to +// the device's taskForward listener, then stops the task and fetches again. Prints one JSON line. +import { connect, fail, until } from "./runtime-socket.mjs"; + +const [url, token, name] = process.argv.slice(2); +setTimeout(() => fail("task probe timed out"), 90000).unref(); +try { + const { socket, request } = await connect(url, token); + await request("workspace.trust", { trusted: true }); + const task = (await request("tasks.catalog")).tasks.find((entry) => entry.name === name); + if (!task) fail(`task ${name} is not in the catalog`); + const started = await request("tasks.start", { taskId: task.id }); + const current = async () => (await request("tasks.list")).find((run) => run.id === started.id); + const ready = await until(async () => { + const run = await current(); + if (run?.state === "failed") fail("task failed: " + JSON.stringify(run)); + return run?.state === "ready" && run.links.length > 0 && run; + }, "task readiness", 45000); + const link = ready.links.find((entry) => new URL(entry).hostname === "127.0.0.1"); + const body = await (await fetch(link, { signal: AbortSignal.timeout(10000) })).text(); + await request("tasks.stop", { id: started.id }); + await until(async () => ["stopped", "completed", "failed"].includes((await current())?.state), "task stop"); + const after = await fetch(link, { signal: AbortSignal.timeout(5000) }) + .then((response) => `answered ${response.status}`, () => "closed"); + console.log(JSON.stringify({ link, body, after })); + socket.close(); + process.exit(0); +} catch (error) { + fail(String(error)); +} diff --git a/scripts/ios/ssh-live.mjs b/scripts/ios/ssh-live.mjs index 4fae84de..d3e29a05 100644 --- a/scripts/ios/ssh-live.mjs +++ b/scripts/ios/ssh-live.mjs @@ -52,7 +52,7 @@ try { "cp /test-key /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && " + `echo 'root:${password}' | chpasswd && /usr/sbin/sshd -p 2222 -o PasswordAuthentication=yes -o UsePAM=no -o PermitRootLogin=yes`); await runAsync("cargo", ["test", "--locked", "--lib", process.argv[2] ?? "ssh::live_", "--", "--ignored", "--nocapture", "--test-threads=1", - ...(hasPayload ? [] : ["--skip", "live_remote_runtime"])], { + ...(hasPayload ? [] : ["--skip", "live_remote_runtime", "--skip", "live_runtime_wget_tasks_and_budget"])], { cwd: crate, env: { ...process.env, diff --git a/scripts/remote/sshd.Dockerfile b/scripts/remote/sshd.Dockerfile index 91fed34f..a510cbf0 100644 --- a/scripts/remote/sshd.Dockerfile +++ b/scripts/remote/sshd.Dockerfile @@ -1,5 +1,5 @@ FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git procps iproute2 curl ca-certificates && rm -rf /var/lib/apt/lists/* \ +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git procps iproute2 curl wget ca-certificates && rm -rf /var/lib/apt/lists/* \ && mkdir -p /run/sshd /root/.ssh /root/project && chmod 700 /root/.ssh \ && ssh-keygen -A && git init /root/project \ && printf 'export const message = "hello";\n' > /root/project/hello.ts From 8e6b0de8622a2770a92e52baed3af24b5b23eabc Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 09:09:57 -0400 Subject: [PATCH 11/12] Document the iOS runtime download checks and live coverage --- docs/ios-ssh.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ios-ssh.md b/docs/ios-ssh.md index 664cd623..b1541acb 100644 --- a/docs/ios-ssh.md +++ b/docs/ios-ssh.md @@ -111,10 +111,10 @@ Start Oxbit on This Server runs the desktop's headless runtime (`desktop.js`) on - Platform: `uname -s; uname -m` maps to `linux-x64` or `darwin-arm64`. Other hosts fail with the desktop's message. - Payload: `runtime_install::pinned` is the one manifest lookup. It calls `remote_runtime::pinned_download`, which reads `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` at build time. A build without a manifest reports that it has no remote runtime. -- Install order: a `.complete` marker skips the install. Otherwise the host runs `curl -fsSL` (with a 15 s connect timeout and a 30 s stall limit) or `wget -qO-` piped into the desktop's install script, which checks the pinned SHA-256 before it writes `.complete`. If the host has neither tool or the download fails, the `oxbit-files` plugin downloads the archive with `URLSession` (`RuntimeDownload.swift`, HTTPS only), Rust checks its SHA-256 and size, and the archive streams into the same script over the exec channel. A unit test compares the script byte for byte with `ssh-target.ts`. +- Install order: a `.complete` marker skips the install. Otherwise the host runs `curl -fsSL` (with a 15 s connect timeout and a 30 s stall limit) or `wget -qO-` piped into the desktop's install script, which checks the pinned SHA-256 before it writes `.complete`. If the host has neither tool or the download fails, the `oxbit-files` plugin downloads the archive with `URLSession` (`RuntimeDownloadStore.swift`, HTTPS only) and keeps it in Caches only when its size and SHA-256 match. Rust checks both again, and the archive streams into the same script over the exec channel. A unit test compares the script byte for byte with `ssh-target.ts`. - Launch: `bin/node desktop.js` runs with `NODE_OPTIONS`, `NODE_PATH`, and `OXBIT_LSP_COMMAND` unset. The launch frame carries `remoteRuntime: true`, the root, the workspace key, and a random token. The runtime then accepts that token as the owner, so `/api/pair` and its rate limit are not used. Stdout frames follow version 1 with a 64 KiB line cap: `ready`, `rotated`, `taskForward`, `error`, and `progress`. A heartbeat goes out every 10 s against the runtime's 75 s lease. - Tunnel: a listener on `127.0.0.1:0` forwards each accepted connection over a `direct-tcpip` channel to the port in the `ready` frame. `taskForward` requests get their own loopback listener per `host:port` and a `taskForwarded` reply. The WebView reaches the loopback port through `NSAllowsLocalNetworking` and the `127.0.0.0/8` exception in `apps/ios/src-tauri/Info.ios.plist`. -- Lifecycle: closing the workspace sends `shutdown` and closes the channel and listeners. When the exec channel ends, or the app returns to the foreground, `ensure` reconnects the pooled SSH connection, stops a stale runtime by PID only when its command line is an Oxbit runtime, and relaunches with the same token and local port, so the runtime client's own WebSocket reconnect picks it up. Automatic attempts stop after 3 in 60 s; Reconnect (`ssh.runtime.reconnect`) retries. +- Lifecycle: closing the workspace cancels a running device download, sends `shutdown`, and closes the channel and listeners. Task listeners stay open until then, as on the desktop (`apps/runtime/src/ssh.ts`); after a task stops, their connections close without data. When the exec channel ends, or the app returns to the foreground, `ensure` reconnects the pooled SSH connection, stops a stale runtime by PID only when its command line is an Oxbit runtime, and relaunches with the same token and local port, so the runtime client's own WebSocket reconnect picks it up. Automatic attempts stop after 3 in 60 s with a message to reconnect. Reconnect (`ssh.runtime.reconnect`) and the foreground return bypass that budget and start a new one. - Origins: the runtime allows `tauri://localhost`. A dev build served from `http://127.0.0.1:9281` is not an allowed origin, so remote runtimes need a bundled build. ## Build notes @@ -130,7 +130,8 @@ Start Oxbit on This Server runs the desktop's headless runtime (`desktop.js`) on - Unit tests: `cargo test --locked` in `apps/ios/src-tauri` (keys, hosts, known hosts, SSH URL parsing and command quoting in `git_core/ssh_transport_tests.rs`, key selection in `ssh/git.rs`) and `cargo test --locked -p tauri-plugin-oxbit-files` in the same folder (request validation and metadata filtering). - Keychain: `bun run ios:check` compiles `SshKeyStore.swift` with `Tests/SshKeys/main.swift` and runs save, read, list, update, delete, and password save/read/forget against the macOS login keychain, under per-run service names that it deletes afterwards. Its first Keychain call is a write; when that returns `errSecInteractionNotAllowed` (-25308), as in a `Background` launchd session with a locked login keychain (`launchctl managername`), it prints `SshKeyStore tests skipped: the login keychain is locked in this session` and exits 0. Any other failure fails the check. An unsigned binary on macOS uses the file-based keychain, which rejects `kSecReturnData` with `kSecMatchLimitAll` (`errSecParam`), so `keys()` lists accounts and reads each item. The macOS run does not exercise the data-protection keychain or `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` as iOS applies them. -- Live tests: `bun run ios:ssh-live` builds `scripts/remote/sshd.Dockerfile`, starts a container with a key port and a password port, and runs the ignored `ssh::live_` tests one at a time. `ssh::live_git_tests` runs first and covers the key prompt without a saved host, the first-use host key prompt, clone over `ssh://`, push, fetch, pull, a server error from a missing repository, and refusal when the saved host key differs. `ssh::live_runtime_tests` installs the runtime through the device stream after a failed host download and through `curl` from a local HTTP server, runs `fs.read` and a terminal command through the tunnel with `scripts/ios/runtime-probe.mjs`, reuses the cache, relaunches after `kill -9`, and reconnects after `docker restart`; it needs `bun run remote:prepare` first and is skipped without the payload. `ssh::live_tests` covers key auth with a passphrase-protected key, first-use trust, SFTP list/read/write/conflict/mkdir/rename/delete, upload and download with progress and cancel, password auth, reconnect after `docker restart`, and refusal after the container's host keys are regenerated. The script removes the container afterwards. +- Runtime download: `bun run ios:check` compiles `RuntimeDownloadStore.swift` with `Tests/RuntimeDownload/main.swift` and downloads fixed ripgrep 14.1.1 release assets from GitHub: a download with matching size and SHA-256, a corrupt cached file replaced, refusal of `http://` and a malformed digest, size and digest mismatches that leave nothing in the cache, and cancellation. Without a network it prints `RuntimeDownload tests skipped: the network is unavailable` and exits 0. +- Live tests: `bun run ios:ssh-live` builds `scripts/remote/sshd.Dockerfile`, starts a container with a key port and a password port, and runs the ignored `ssh::live_` tests one at a time. `ssh::live_git_tests` runs first and covers the key prompt without a saved host, the first-use host key prompt, clone over `ssh://`, push, fetch, pull, a server error from a missing repository, and refusal when the saved host key differs. `ssh::live_runtime_tests` installs the runtime through the device stream after a failed host download and through `curl` from a local HTTP server, runs `fs.read` and a terminal command through the tunnel with `scripts/ios/runtime-probe.mjs`, reuses the cache, relaunches after `kill -9`, and reconnects after `docker restart`. `ssh::live_runtime_task_tests` installs with `wget` on a host without `curl`, runs a service task and fetches it through the `taskForward` listener with `scripts/ios/runtime-task-probe.mjs`, and checks that 3 failed automatic relaunches stop and wait for Reconnect. Both need `bun run remote:prepare` first and are skipped without the payload. `ssh::live_tests` covers key auth with a passphrase-protected key, first-use trust, SFTP list/read/write/conflict/mkdir/rename/delete, upload and download with progress and cancel, password auth, reconnect after `docker restart`, and refusal after the container's host keys are regenerated. The script removes the container afterwards. - UI: `bunx playwright test -c tests/ios/playwright.config.ts ssh.spec.ts` with the in-memory SSH bridge in `tests/ios/ssh-bridge.ts`. Its `clone` seed makes `git.clone` return the SSH prompt errors in order. `ssh-runtime.spec.ts` covers the start dialog, progress, the recent entry, and a failed start, with the runtime WebSocket mocked by `page.routeWebSocket`. `OXBIT_SSH_SCREENSHOTS` sets the screenshot folder (default `evidence/ios-ssh`). ## Not implemented From 1b545da3cdc00495b4b7a8267d5c542506a1644b Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Thu, 8 Oct 2026 09:57:22 -0400 Subject: [PATCH 12/12] Pack instead of publish in the npm dry run when the version is already on npm npm 11 rejects publish --dry-run over an existing version, so pull requests that touch the publish inputs failed while the package versions stayed unchanged. --- docs/npm.md | 4 +++- scripts/npm/publish.mjs | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/npm.md b/docs/npm.md index 98eb101a..a030d576 100644 --- a/docs/npm.md +++ b/docs/npm.md @@ -1,6 +1,6 @@ # npm packages -Created: 2026-10-05. Last updated: 2026-10-05. +Created: 2026-10-05. Last updated: 2026-10-08. `.github/workflows/npm.yml` publishes two packages to the [`@oxbit` npm org](https://www.npmjs.com/org/oxbit). @@ -15,6 +15,8 @@ versions publish under `latest`. npm rejects a version number that was published before, including after an unpublish. Pull requests that change the publish inputs run both packages with `--dry-run`. +When npm already has the version, the dry run packs the staged package instead, +because `npm publish --dry-run` rejects an existing version. ## Staging diff --git a/scripts/npm/publish.mjs b/scripts/npm/publish.mjs index 06930b14..4f03d9ca 100644 --- a/scripts/npm/publish.mjs +++ b/scripts/npm/publish.mjs @@ -167,7 +167,8 @@ async function main() { const name = `@oxbit/${target}`; const { version } = await readJson(target === "sdk" ? "packages/sdk/package.json" : "package.json"); if (values["expect-tag"] !== undefined) assertTag(values["expect-tag"], version); - if (!values["dry-run"] && (await isPublished(name, version))) { + const published = await isPublished(name, version); + if (!values["dry-run"] && published) { console.log(`${name}@${version} is already on npm`); return; } @@ -178,8 +179,10 @@ async function main() { await fs.copyFile(path.join(root, "LICENSE"), path.join(stage, "LICENSE")); console.log(`Staged ${name}@${version} in ${stage}`); - const args = ["publish", stage, "--access", "public", "--tag", distTag(version)]; - args.push(values["dry-run"] ? "--dry-run" : "--provenance"); + // npm rejects a publish dry run over an existing version, so a published version is only packed. + const args = published + ? ["pack", stage, "--dry-run"] + : ["publish", stage, "--access", "public", "--tag", distTag(version), values["dry-run"] ? "--dry-run" : "--provenance"]; execFileSync("npm", args, { stdio: "inherit" }); }